diff --git a/workspaces/azure-devops/.changeset/shy-points-heal.md b/workspaces/azure-devops/.changeset/shy-points-heal.md new file mode 100644 index 000000000..710b7b8b8 --- /dev/null +++ b/workspaces/azure-devops/.changeset/shy-points-heal.md @@ -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 diff --git a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/README.md b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/README.md index 80851a132..f1bcfd667 100644 --- a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/README.md +++ b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/README.md @@ -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 }}` }} +``` diff --git a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.examples.ts b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.examples.ts index 1f532bbbb..68c3764cd 100644 --- a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.examples.ts +++ b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.examples.ts @@ -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, + }, + }, + ], + }), + }, ]; diff --git a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.test.ts b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.test.ts index d95da1855..1623be594 100644 --- a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.test.ts +++ b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.test.ts @@ -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' } }), + ); + }); }); diff --git a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.ts b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.ts index 1caa11d78..fd62f14ec 100644 --- a/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.ts +++ b/workspaces/azure-devops/plugins/scaffolder-backend-module-azure-devops/src/actions/devopsRunPipeline.ts @@ -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); }, }); }