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:
CasperJ 2025-05-23 20:49:55 +02:00 committed by GitHub
parent 2445bb3e7f
commit 6f19535725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 274 additions and 61 deletions

View File

@ -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

View File

@ -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 }}` }}
```

View File

@ -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,
},
},
],
}),
},
];

View File

@ -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' } }),
);
});
});

View File

@ -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);
},
});
}