Added ability to wait for pipeline to complete and to get output vars (#3651)
* Added ability to wait for pipeline to complete and to get output variables Signed-off-by: Casper Jensen <cajen@pandora.net> * Update workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.ts Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: CasperJ <2158013+CasperJ@users.noreply.github.com> * reverted auth refactoring Signed-off-by: Casper Jensen <cajen@pandora.net> * added zod schema to package.json Signed-off-by: Casper Jensen <cajen@pandora.net> * fix zod mismatch Signed-off-by: Casper Jensen <cajen@pandora.net> * Added example Signed-off-by: Casper Jensen <cajen@pandora.net> * removed zod as denpendency as it's not needed as a direct denpendency Signed-off-by: Casper Jensen <cajen@pandora.net> --------- Signed-off-by: Casper Jensen <cajen@pandora.net> Signed-off-by: CasperJ <2158013+CasperJ@users.noreply.github.com> Co-authored-by: Casper Jensen <cajen@pandora.net> Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com>
This commit is contained in:
parent
2445bb3e7f
commit
6f19535725
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@backstage-community/plugin-scaffolder-backend-module-azure-devops': minor
|
||||
---
|
||||
|
||||
Added ability to wait for pipeline to complete and to get output variables
|
||||
|
|
@ -172,3 +172,24 @@ spec:
|
|||
projectRepo: ${{ (parameters.repoUrl | parseRepoUrl)['repo'] }}
|
||||
sampleTemplateParameterKey: sampleTemplateParameterValue
|
||||
```
|
||||
|
||||
### Example running a pipeline, waiting for it to complete, and printing the output using the `azure:pipeline:run` action
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
steps:
|
||||
- id: runAzurePipeline
|
||||
name: Run Pipeline
|
||||
action: azure:pipeline:run
|
||||
input:
|
||||
#[...]
|
||||
pollingInterval: 10 # Poll for pipeline run status every 10 second
|
||||
pipelineTimeout: 300 # Timeout after 5 minutes
|
||||
output:
|
||||
text:
|
||||
- title: Pipeline run info
|
||||
content: |
|
||||
**pipelineRunStatus:** `${{ steps['runAzurePipeline'].output.pipelineRunStatus }}` }}
|
||||
**pipelineRunId:** `${{ steps['runAzurePipeline'].output.pipelineRunId }}` }}
|
||||
**pipeline output:** `${{ steps['runAzurePipeline'].output.pipelineOutput['myOutputVar'].value }}` }}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -110,4 +110,23 @@ export const examples: TemplateExample[] = [
|
|||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Run Azure Pipeline and wait for completion',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
id: 'runAzurePipeline',
|
||||
action: 'azure:pipeline:run',
|
||||
name: 'Run Azure Devops Pipeline and wait for completion',
|
||||
input: {
|
||||
organization: 'organization',
|
||||
pipelineId: 'pipelineId',
|
||||
project: 'project',
|
||||
pollingInterval: 10,
|
||||
pipelineTimeout: 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ import { ScmIntegrations } from '@backstage/integration';
|
|||
import { ConfigReader } from '@backstage/config';
|
||||
import { WebApi } from 'azure-devops-node-api';
|
||||
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
|
||||
import {
|
||||
Run,
|
||||
RunResult,
|
||||
RunState,
|
||||
} from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
|
||||
|
||||
describe('publish:azure', () => {
|
||||
const config = new ConfigReader({
|
||||
|
|
@ -65,6 +70,7 @@ describe('publish:azure', () => {
|
|||
|
||||
const mockPipelineClient = {
|
||||
runPipeline: jest.fn(),
|
||||
getRun: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGitApi = {
|
||||
|
|
@ -96,6 +102,9 @@ describe('publish:azure', () => {
|
|||
mockPipelineClient.runPipeline.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
mockPipelineClient.getRun.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
|
|
@ -123,6 +132,9 @@ describe('publish:azure', () => {
|
|||
mockPipelineClient.runPipeline.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
mockPipelineClient.getRun.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
|
||||
const templateParameters = {
|
||||
templateParameterKey: 'templateParameterValue',
|
||||
|
|
@ -231,6 +243,10 @@ describe('publish:azure', () => {
|
|||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
result: 'InProgress',
|
||||
}));
|
||||
mockPipelineClient.getRun.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
result: 'InProgress',
|
||||
}));
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
|
|
@ -251,4 +267,114 @@ describe('publish:azure', () => {
|
|||
'InProgress',
|
||||
);
|
||||
});
|
||||
|
||||
it('should wait when polling is specified', async () => {
|
||||
mockPipelineClient.runPipeline.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
|
||||
let runCount = 0;
|
||||
mockPipelineClient.getRun.mockImplementation(() => {
|
||||
runCount++;
|
||||
if (runCount === 3)
|
||||
return {
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
result: RunResult.Succeeded,
|
||||
state: RunState.Completed,
|
||||
} as Run;
|
||||
|
||||
return {
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
state: RunState.InProgress,
|
||||
} as Run;
|
||||
});
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
host: 'dev.azure.com',
|
||||
organization: 'org',
|
||||
pipelineId: '1',
|
||||
project: 'project',
|
||||
token: 'input-token',
|
||||
branch: 'master',
|
||||
pollingInterval: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockPipelineClient.runPipeline).toHaveBeenCalledTimes(1);
|
||||
expect(mockPipelineClient.getRun).toHaveBeenCalledTimes(4);
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'pipelineTimeoutExceeded',
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should timeout when when pipeline timout is reached', async () => {
|
||||
mockPipelineClient.runPipeline.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
}));
|
||||
|
||||
mockPipelineClient.getRun.mockImplementation(() => {
|
||||
return {
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
state: RunState.InProgress,
|
||||
} as Run;
|
||||
});
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
host: 'dev.azure.com',
|
||||
organization: 'org',
|
||||
pipelineId: '1',
|
||||
project: 'project',
|
||||
token: 'input-token',
|
||||
branch: 'master',
|
||||
pollingInterval: 1,
|
||||
pipelineTimeout: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'pipelineTimeoutExceeded',
|
||||
true,
|
||||
);
|
||||
expect(mockPipelineClient.runPipeline).toHaveBeenCalledTimes(1);
|
||||
expect(mockPipelineClient.getRun).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should output variables from pipeline if available', async () => {
|
||||
mockPipelineClient.runPipeline.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
result: 'InProgress',
|
||||
}));
|
||||
mockPipelineClient.getRun.mockImplementation(() => ({
|
||||
_links: { web: { href: 'http://pipeline-run-url.com' } },
|
||||
result: 'InProgress',
|
||||
variables: {
|
||||
var1: { isSecret: false, value: 'foo' },
|
||||
var2: { isSecret: true, value: 'bar' },
|
||||
},
|
||||
}));
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
host: 'dev.azure.com',
|
||||
organization: 'org',
|
||||
pipelineId: '1',
|
||||
project: 'project',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'pipelineOutput',
|
||||
expect.objectContaining({ var1: { isSecret: false, value: 'foo' } }),
|
||||
);
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'pipelineOutput',
|
||||
expect.objectContaining({ var2: { isSecret: true, value: 'bar' } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,68 +44,75 @@ export function createAzureDevopsRunPipelineAction(options: {
|
|||
}) {
|
||||
const { integrations } = options;
|
||||
|
||||
return createTemplateAction<{
|
||||
host?: string;
|
||||
organization: string;
|
||||
pipelineId: string;
|
||||
project: string;
|
||||
branch?: string;
|
||||
token?: string;
|
||||
templateParameters?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}>({
|
||||
return createTemplateAction({
|
||||
id: 'azure:pipeline:run',
|
||||
description: '',
|
||||
examples,
|
||||
schema: {
|
||||
input: {
|
||||
required: ['organization', 'pipelineId', 'project'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
host: {
|
||||
type: 'string',
|
||||
title: 'Host',
|
||||
description: 'The host of Azure DevOps. Defaults to dev.azure.com',
|
||||
},
|
||||
organization: {
|
||||
type: 'string',
|
||||
title: 'Organization',
|
||||
description: 'The name of the Azure DevOps organization.',
|
||||
},
|
||||
pipelineId: {
|
||||
type: 'string',
|
||||
title: 'Pipeline ID',
|
||||
description: 'The pipeline ID.',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
title: 'Project',
|
||||
description: 'The name of the Azure project.',
|
||||
},
|
||||
branch: {
|
||||
title: 'Repository Branch',
|
||||
type: 'string',
|
||||
description: "The branch of the pipeline's repository.",
|
||||
},
|
||||
templateParameters: {
|
||||
type: 'object',
|
||||
title: 'Template Parameters',
|
||||
description:
|
||||
host: d =>
|
||||
d
|
||||
.string()
|
||||
.describe('The host of Azure DevOps. Defaults to dev.azure.com')
|
||||
.optional(),
|
||||
organization: d =>
|
||||
d.string().describe('The name of the Azure DevOps organization.'),
|
||||
pipelineId: d => d.string().describe('The pipeline ID.'),
|
||||
project: d => d.string().describe('The name of the Azure project.'),
|
||||
branch: d =>
|
||||
d
|
||||
.string()
|
||||
.describe("The branch of the pipeline's repository.")
|
||||
.optional(),
|
||||
token: d =>
|
||||
d.string().describe('Token to use for Ado REST API.').optional(),
|
||||
pollingInterval: d =>
|
||||
d
|
||||
.number()
|
||||
.describe(
|
||||
'Seconds between each poll for pipeline update. 0 = no polling.',
|
||||
)
|
||||
.optional(),
|
||||
pipelineTimeout: d =>
|
||||
d
|
||||
.number()
|
||||
.describe(
|
||||
'Max. seconds to wait for pipeline completion. Only effective if `poolingInterval` is greater than zero.',
|
||||
)
|
||||
.optional(),
|
||||
templateParameters: d =>
|
||||
d
|
||||
.record(d.string(), d.string())
|
||||
.describe(
|
||||
'Azure DevOps pipeline template parameters in key-value pairs.',
|
||||
},
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
required: ['pipelineRunUrl'],
|
||||
properties: {
|
||||
pipelineRunUrl: {
|
||||
type: 'string',
|
||||
},
|
||||
pipelineRunStatus: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
pipelineRunUrl: d => d.string().describe('Url of the pipeline'),
|
||||
pipelineRunStatus: d =>
|
||||
d
|
||||
.enum(['canceled', 'failed', 'succeeded', 'unknown'])
|
||||
.describe('Pipeline Run status'),
|
||||
pipelineRunId: d =>
|
||||
d.number().describe('The pipeline Run ID.').optional(),
|
||||
pipelineTimeoutExceeded: d =>
|
||||
d
|
||||
.boolean()
|
||||
.describe(
|
||||
'True if the pipeline did not complete within the defined timespan.',
|
||||
),
|
||||
pipelineOutput: d =>
|
||||
d
|
||||
.record(
|
||||
d.string(),
|
||||
d.object({
|
||||
isSecret: d.boolean().optional(),
|
||||
value: d.string().optional(),
|
||||
}),
|
||||
)
|
||||
.describe('Object containing output variables')
|
||||
.optional(),
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
|
|
@ -116,6 +123,8 @@ export function createAzureDevopsRunPipelineAction(options: {
|
|||
project,
|
||||
branch,
|
||||
templateParameters,
|
||||
pollingInterval,
|
||||
pipelineTimeout,
|
||||
} = ctx.input;
|
||||
|
||||
const url = `https://${host}/${organization}`;
|
||||
|
|
@ -156,15 +165,16 @@ export function createAzureDevopsRunPipelineAction(options: {
|
|||
}
|
||||
|
||||
// Log the createOptions object in a readable format
|
||||
ctx.logger.debug(
|
||||
'Create options for running the pipeline:',
|
||||
JSON.stringify(createOptions, null, 2),
|
||||
);
|
||||
ctx.logger.debug('Create options for running the pipeline:', {
|
||||
RunPipelineParameters: JSON.stringify(createOptions, null, 2),
|
||||
});
|
||||
|
||||
const pipelineRun = await client.runPipeline(
|
||||
const pipelineIdAsInt = parseInt(pipelineId, 10);
|
||||
|
||||
let pipelineRun = await client.runPipeline(
|
||||
createOptions,
|
||||
project,
|
||||
parseInt(pipelineId, 10),
|
||||
pipelineIdAsInt,
|
||||
);
|
||||
|
||||
// Log the pipeline run URL
|
||||
|
|
@ -175,6 +185,35 @@ export function createAzureDevopsRunPipelineAction(options: {
|
|||
ctx.logger.info(`Pipeline run state: ${RunState[pipelineRun.state]}`);
|
||||
}
|
||||
|
||||
let timeoutExceeded = false;
|
||||
if ((pollingInterval || 0) > 0) {
|
||||
let totalRunningTime = 0;
|
||||
const delayInSec = pollingInterval!;
|
||||
do {
|
||||
await new Promise(f => setTimeout(f, delayInSec * 1000));
|
||||
|
||||
pipelineRun = await client.getRun(
|
||||
project,
|
||||
pipelineIdAsInt,
|
||||
pipelineRun.id!,
|
||||
);
|
||||
ctx.logger.info(
|
||||
`Pipeline run state: ${
|
||||
pipelineRun.state ? RunState[pipelineRun.state] : RunState.Unknown
|
||||
}`,
|
||||
);
|
||||
|
||||
totalRunningTime += delayInSec;
|
||||
timeoutExceeded =
|
||||
pipelineTimeout !== undefined && totalRunningTime > pipelineTimeout;
|
||||
} while (pipelineRun.state === RunState.InProgress && !timeoutExceeded);
|
||||
}
|
||||
|
||||
pipelineRun = await client.getRun(
|
||||
project,
|
||||
pipelineIdAsInt,
|
||||
pipelineRun.id!,
|
||||
);
|
||||
// Log the pipeline run result if available
|
||||
if (pipelineRun.result) {
|
||||
ctx.logger.info(
|
||||
|
|
@ -188,7 +227,10 @@ export function createAzureDevopsRunPipelineAction(options: {
|
|||
);
|
||||
|
||||
ctx.output('pipelineRunUrl', pipelineRun._links.web.href);
|
||||
ctx.output('pipelineRunId', pipelineRun.id!);
|
||||
ctx.output('pipelineRunStatus', pipelineRun.result?.toString());
|
||||
ctx.output('pipelineTimeoutExceeded', timeoutExceeded);
|
||||
ctx.output('pipelineOutput', pipelineRun.variables);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue