Merge pull request #5849 from SDA-SE/feat/scaffolder-conditional
Conditional scaffolder steps
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Introduce conditional steps in scaffolder templates.
|
||||
|
||||
A step can now include an `if` property that only executes a step if the
|
||||
condition is truthy. The condition can include handlebar templates.
|
||||
|
||||
```yaml
|
||||
- id: register
|
||||
if: '{{ not parameters.dryRun }}'
|
||||
name: Register
|
||||
action: catalog:register
|
||||
input:
|
||||
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
|
||||
catalogInfoPath: '/catalog-info.yaml'
|
||||
```
|
||||
|
||||
Also introduces a `not` helper in handlebar templates that allows to negate
|
||||
boolean expressions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add `debug:log` action for debugging.
|
||||
@@ -262,6 +262,7 @@ tooltips
|
||||
touchpoints
|
||||
transpilation
|
||||
transpiled
|
||||
truthy
|
||||
ui
|
||||
unmanaged
|
||||
unregister
|
||||
|
||||
@@ -722,7 +722,7 @@ You can find out more about the `steps` key
|
||||
|
||||
### `spec.owner` [optional]
|
||||
|
||||
An [entity reference](#string-references) to the owner of the component, e.g.
|
||||
An [entity reference](#string-references) to the owner of the template, e.g.
|
||||
`artist-relations-team`. This field is required.
|
||||
|
||||
In Backstage, the owner of a Template is the singular entity (commonly a team)
|
||||
|
||||
@@ -288,6 +288,7 @@ template. These follow the same standard format:
|
||||
```yaml
|
||||
- id: fetch-base # A unique id for the step
|
||||
name: Fetch Base # A title displayed in the frontend
|
||||
if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy
|
||||
action: fetch:cookiecutter # an action to call
|
||||
input: # input that is passed as arguments to the action handler
|
||||
url: ./template
|
||||
|
||||
@@ -534,6 +534,7 @@ export interface TemplateEntityV1beta2 extends Entity {
|
||||
name?: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
}>;
|
||||
output?: {
|
||||
[name: string]: string;
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('templateEntityV1beta2Validator', () => {
|
||||
input: {
|
||||
url: './template',
|
||||
},
|
||||
if: '{{ parameters.owner }}',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
@@ -122,16 +123,39 @@ describe('templateEntityV1beta2Validator', () => {
|
||||
delete (entity as any).spec.steps[0].action;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/action/);
|
||||
});
|
||||
|
||||
it('accepts missing owner', async () => {
|
||||
delete (entity as any).spec.owner;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty owner', async () => {
|
||||
(entity as any).spec.owner = '';
|
||||
await expect(validator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('rejects wrong type owner', async () => {
|
||||
(entity as any).spec.owner = 5;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/owner/);
|
||||
});
|
||||
|
||||
it('accepts missing if', async () => {
|
||||
delete (entity as any).spec.steps[0].if;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts boolean in if', async () => {
|
||||
(entity as any).spec.steps[0].if = true;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty if', async () => {
|
||||
(entity as any).spec.steps[0].if = '';
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects wrong type if', async () => {
|
||||
(entity as any).spec.steps[0].if = 5;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/if/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface TemplateEntityV1beta2 extends Entity {
|
||||
name?: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
}>;
|
||||
output?: { [name: string]: string };
|
||||
owner?: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "TemplateV1beta1",
|
||||
"$id": "TemplateV1beta2",
|
||||
"description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.",
|
||||
"examples": [
|
||||
{
|
||||
"apiVersion": "backstage.io/v1beta1",
|
||||
"apiVersion": "backstage.io/v1beta2",
|
||||
"kind": "Template",
|
||||
"metadata": {
|
||||
"name": "react-ssr-template",
|
||||
@@ -45,7 +45,8 @@
|
||||
"action": "publish:github",
|
||||
"parameters": {
|
||||
"repoUrl": "{{ parameters.repoUrl }}"
|
||||
}
|
||||
},
|
||||
"if": "{{ parameters.repoUrl }}"
|
||||
}
|
||||
],
|
||||
"output": {
|
||||
@@ -128,6 +129,10 @@
|
||||
"input": {
|
||||
"type": "object",
|
||||
"description": "A templated object describing the inputs to the action."
|
||||
},
|
||||
"if": {
|
||||
"type": ["string", "boolean"],
|
||||
"description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +146,7 @@
|
||||
"description": "A list of external hyperlinks, typically pointing to resources created or updated by the template",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"required": [],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
@@ -149,6 +154,12 @@
|
||||
"examples": ["https://github.com/my-org/my-new-repo"],
|
||||
"minLength": 1
|
||||
},
|
||||
"entityRef": {
|
||||
"type": "string",
|
||||
"description": "An entity reference to an entity in the catalog.",
|
||||
"examples": ["Component:default/my-app"],
|
||||
"minLength": 1
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "A user friendly display name for the link.",
|
||||
|
||||
@@ -35,13 +35,14 @@ spec:
|
||||
description: System of the component
|
||||
ui:field: EntityPicker
|
||||
ui:options:
|
||||
allowedKinds:
|
||||
allowedKinds:
|
||||
- System
|
||||
defaultKind: System
|
||||
|
||||
- title: Choose a location
|
||||
required:
|
||||
- repoUrl
|
||||
- dryRun
|
||||
properties:
|
||||
repoUrl:
|
||||
title: Repository Location
|
||||
@@ -50,6 +51,10 @@ spec:
|
||||
ui:options:
|
||||
allowedHosts:
|
||||
- github.com
|
||||
dryRun:
|
||||
title: Only perform a dry run, don't publish anything
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
steps:
|
||||
- id: fetch-base
|
||||
@@ -59,7 +64,8 @@ spec:
|
||||
url: ./template
|
||||
values:
|
||||
name: '{{ parameters.name }}'
|
||||
owner: '{{ parameters.owner }}'
|
||||
owner: '{{ parameters.owner }}'
|
||||
system: '{{ parameters.system }}'
|
||||
destination: '{{ parseRepoUrl parameters.repoUrl }}'
|
||||
|
||||
- id: fetch-docs
|
||||
@@ -70,6 +76,7 @@ spec:
|
||||
url: https://github.com/backstage/community/tree/main/backstage-community-sessions
|
||||
|
||||
- id: publish
|
||||
if: '{{ not parameters.dryRun }}'
|
||||
name: Publish
|
||||
action: publish:github
|
||||
input:
|
||||
@@ -78,12 +85,23 @@ spec:
|
||||
repoUrl: '{{ parameters.repoUrl }}'
|
||||
|
||||
- id: register
|
||||
if: '{{ not parameters.dryRun }}'
|
||||
name: Register
|
||||
action: catalog:register
|
||||
input:
|
||||
repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}'
|
||||
catalogInfoPath: '/catalog-info.yaml'
|
||||
|
||||
- name: Results
|
||||
if: '{{ parameters.dryRun }}'
|
||||
action: debug:log
|
||||
input:
|
||||
listWorkspace: true
|
||||
|
||||
output:
|
||||
remoteUrl: '{{ steps.publish.output.remoteUrl }}'
|
||||
entityRef: '{{ steps.register.output.entityRef }}'
|
||||
links:
|
||||
- title: Repository
|
||||
url: '{{ steps.publish.output.remoteUrl }}'
|
||||
- title: Open in catalog
|
||||
icon: 'catalog'
|
||||
entityRef: '{{ steps.register.output.entityRef }}'
|
||||
|
||||
@@ -7,6 +7,6 @@ spec:
|
||||
type: website
|
||||
lifecycle: experimental
|
||||
owner: {{cookiecutter.owner | jsonify}}
|
||||
{%- if cookiecutter.backstage_system != "" %}
|
||||
{%- if 'system' in cookiecutter %}
|
||||
system: {{ cookiecutter.system | jsonify }}
|
||||
{%- endif %}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { TemplaterBuilder } from '../../stages';
|
||||
import { createCatalogRegisterAction } from './catalog';
|
||||
import { createDebugLogAction } from './debug';
|
||||
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
|
||||
import {
|
||||
createPublishAzureAction,
|
||||
@@ -61,6 +62,7 @@ export const createBuiltinActions = (options: {
|
||||
createPublishAzureAction({
|
||||
integrations,
|
||||
}),
|
||||
createDebugLogAction(),
|
||||
createCatalogRegisterAction({ catalogClient, integrations }),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createDebugLogAction } from './log';
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import mock from 'mock-fs';
|
||||
import os from 'os';
|
||||
import { Writable } from 'stream';
|
||||
import { createDebugLogAction } from './log';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('debug:log', () => {
|
||||
const logStream = ({
|
||||
write: jest.fn(),
|
||||
} as jest.Mocked<Partial<Writable>>) as jest.Mocked<Writable>;
|
||||
|
||||
const mockTmpDir = os.tmpdir();
|
||||
const mockContext = {
|
||||
input: {},
|
||||
baseUrl: 'somebase',
|
||||
workspacePath: mockTmpDir,
|
||||
logger: getVoidLogger(),
|
||||
logStream,
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn().mockResolvedValue(mockTmpDir),
|
||||
};
|
||||
|
||||
const action = createDebugLogAction();
|
||||
|
||||
beforeEach(() => {
|
||||
mock({
|
||||
[`${mockContext.workspacePath}/README.md`]: '',
|
||||
[`${mockContext.workspacePath}/a-directory/index.md`]: '',
|
||||
});
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('should do nothing', async () => {
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(logStream.write).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should log the workspace content, if active', async () => {
|
||||
const context = {
|
||||
...mockContext,
|
||||
input: {
|
||||
listWorkspace: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
await action.handler(context);
|
||||
|
||||
expect(logStream.write).toBeCalledTimes(1);
|
||||
expect(logStream.write).toBeCalledWith(
|
||||
expect.stringContaining('README.md'),
|
||||
);
|
||||
expect(logStream.write).toBeCalledWith(
|
||||
expect.stringContaining(join('a-directory', 'index.md')),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log message', async () => {
|
||||
const context = {
|
||||
...mockContext,
|
||||
input: {
|
||||
message: 'Hello Backstage!',
|
||||
},
|
||||
};
|
||||
|
||||
await action.handler(context);
|
||||
|
||||
expect(logStream.write).toBeCalledTimes(1);
|
||||
expect(logStream.write).toBeCalledWith(
|
||||
expect.stringContaining('Hello Backstage!'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'fs-extra';
|
||||
import { relative, resolve } from 'path';
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
|
||||
/**
|
||||
* This task is useful for local development and testing of both the scaffolder
|
||||
* and scaffolder templates.
|
||||
*/
|
||||
export function createDebugLogAction() {
|
||||
return createTemplateAction<{ message?: string; listWorkspace?: boolean }>({
|
||||
id: 'debug:log',
|
||||
description:
|
||||
'Writes a message into the log or list all files in the workspace.',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
title: 'Message to output.',
|
||||
type: 'string',
|
||||
},
|
||||
listWorkspace: {
|
||||
title: 'List all files in the workspace, if true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
if (ctx.input?.message) {
|
||||
ctx.logStream.write(ctx.input.message);
|
||||
}
|
||||
|
||||
if (ctx.input?.listWorkspace) {
|
||||
const files = await recursiveReadDir(ctx.workspacePath);
|
||||
ctx.logStream.write(
|
||||
`Workspace:\n${files
|
||||
.map(f => ` - ${relative(ctx.workspacePath, f)}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function recursiveReadDir(dir: string): Promise<string[]> {
|
||||
const subdirs = await readdir(dir);
|
||||
const files = await Promise.all(
|
||||
subdirs.map(async subdir => {
|
||||
const res = resolve(dir, subdir);
|
||||
return (await stat(res)).isDirectory() ? recursiveReadDir(res) : [res];
|
||||
}),
|
||||
);
|
||||
return files.reduce((a, f) => a.concat(f), []);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
export * from './catalog';
|
||||
export { createBuiltinActions } from './createBuiltinActions';
|
||||
export * from './debug';
|
||||
export * from './fetch';
|
||||
export * from './publish';
|
||||
export { createBuiltinActions } from './createBuiltinActions';
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { createPublishGithubAction } from './github';
|
||||
export { createPublishGithubPullRequestAction } from './githubPullRequest';
|
||||
export { createPublishAzureAction } from './azure';
|
||||
export { createPublishGitlabAction } from './gitlab';
|
||||
export { createPublishBitbucketAction } from './bitbucket';
|
||||
export { createPublishFileAction } from './file';
|
||||
export { createPublishGithubAction } from './github';
|
||||
export { createPublishGithubPullRequestAction } from './githubPullRequest';
|
||||
export { createPublishGitlabAction } from './gitlab';
|
||||
|
||||
@@ -18,13 +18,13 @@ import {
|
||||
getVoidLogger,
|
||||
SingleConnectionDatabaseManager,
|
||||
} from '@backstage/backend-common';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
import os from 'os';
|
||||
import { ConfigReader, JsonObject } from '@backstage/config';
|
||||
import { StorageTaskBroker } from './StorageTaskBroker';
|
||||
import { DatabaseTaskStore } from './DatabaseTaskStore';
|
||||
import os from 'os';
|
||||
import { createTemplateAction, TemplateActionRegistry } from '../actions';
|
||||
import { RepoSpec } from '../actions/builtin/publish/util';
|
||||
import { DatabaseTaskStore } from './DatabaseTaskStore';
|
||||
import { StorageTaskBroker } from './StorageTaskBroker';
|
||||
import { TaskWorker } from './TaskWorker';
|
||||
|
||||
async function createStore(): Promise<DatabaseTaskStore> {
|
||||
const manager = SingleConnectionDatabaseManager.fromConfig(
|
||||
@@ -54,6 +54,7 @@ describe('TaskWorker', () => {
|
||||
id: 'test-action',
|
||||
handler: async ctx => {
|
||||
ctx.output('testOutput', 'winning');
|
||||
ctx.output('badOutput', false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -172,6 +173,72 @@ describe('TaskWorker', () => {
|
||||
expect((event?.body?.output as JsonObject).result).toBe('winning');
|
||||
});
|
||||
|
||||
it('should execute steps conditionally', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const taskWorker = new TaskWorker({
|
||||
logger,
|
||||
workingDirectory: os.tmpdir(),
|
||||
actionRegistry,
|
||||
taskBroker: broker,
|
||||
});
|
||||
|
||||
const { taskId } = await broker.dispatch({
|
||||
steps: [
|
||||
{ id: 'test', name: 'test', action: 'test-action' },
|
||||
{
|
||||
id: 'conditional',
|
||||
name: 'conditional',
|
||||
action: 'test-action',
|
||||
if: '{{ steps.test.output.testOutput }}',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
result: '{{ steps.conditional.output.testOutput }}',
|
||||
},
|
||||
values: {},
|
||||
});
|
||||
|
||||
const task = await broker.claim();
|
||||
await taskWorker.runOneTask(task);
|
||||
|
||||
const { events } = await storage.listEvents({ taskId });
|
||||
const event = events.find(e => e.type === 'completion');
|
||||
expect((event?.body?.output as JsonObject).result).toBe('winning');
|
||||
});
|
||||
|
||||
it('should skip steps conditionally', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const taskWorker = new TaskWorker({
|
||||
logger,
|
||||
workingDirectory: os.tmpdir(),
|
||||
actionRegistry,
|
||||
taskBroker: broker,
|
||||
});
|
||||
|
||||
const { taskId } = await broker.dispatch({
|
||||
steps: [
|
||||
{ id: 'test', name: 'test', action: 'test-action' },
|
||||
{
|
||||
id: 'conditional',
|
||||
name: 'conditional',
|
||||
action: 'test-action',
|
||||
if: '{{ steps.test.output.badOutput }}',
|
||||
},
|
||||
],
|
||||
output: {
|
||||
result: '{{ steps.conditional.output.testOutput }}',
|
||||
},
|
||||
values: {},
|
||||
});
|
||||
|
||||
const task = await broker.claim();
|
||||
await taskWorker.runOneTask(task);
|
||||
|
||||
const { events } = await storage.listEvents({ taskId });
|
||||
const event = events.find(e => e.type === 'completion');
|
||||
expect((event?.body?.output as JsonObject).result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse strings as objects if possible', async () => {
|
||||
const inputAction = createTemplateAction<{
|
||||
address: { line1: string };
|
||||
|
||||
@@ -14,18 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'stream';
|
||||
import { Logger } from 'winston';
|
||||
import * as winston from 'winston';
|
||||
import { JsonValue, JsonObject } from '@backstage/config';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import { TaskBroker, Task } from './types';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import fs from 'fs-extra';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import path from 'path';
|
||||
import { PassThrough } from 'stream';
|
||||
import * as winston from 'winston';
|
||||
import { Logger } from 'winston';
|
||||
import { parseRepoUrl } from '../actions/builtin/publish/util';
|
||||
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
|
||||
import { isTruthy } from './helper';
|
||||
import { Task, TaskBroker } from './types';
|
||||
|
||||
type Options = {
|
||||
logger: Logger;
|
||||
@@ -48,6 +49,8 @@ export class TaskWorker {
|
||||
});
|
||||
|
||||
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
|
||||
|
||||
this.handlebars.registerHelper('not', value => !isTruthy(value));
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -102,6 +105,51 @@ export class TaskWorker {
|
||||
});
|
||||
|
||||
taskLogger.add(new winston.transports.Stream({ stream }));
|
||||
|
||||
if (step.if !== undefined) {
|
||||
// Support passing values like false to disable steps
|
||||
let skip = !step.if;
|
||||
|
||||
// Evaluate strings as handlebar templates
|
||||
if (typeof step.if === 'string') {
|
||||
const condition = JSON.parse(
|
||||
JSON.stringify(step.if),
|
||||
(_key, value) => {
|
||||
if (typeof value === 'string') {
|
||||
const templated = this.handlebars.compile(value, {
|
||||
noEscape: true,
|
||||
data: false,
|
||||
preventIndent: true,
|
||||
})(templateCtx);
|
||||
|
||||
// If it's just an empty string, treat it as undefined
|
||||
if (templated === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(templated);
|
||||
} catch {
|
||||
return templated;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
);
|
||||
|
||||
skip = !isTruthy(condition);
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
await task.emitLog(`Skipped step ${step.name}`, {
|
||||
...metadata,
|
||||
status: 'skipped',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await task.emitLog(`Beginning step ${step.name}`, {
|
||||
...metadata,
|
||||
status: 'processing',
|
||||
@@ -118,13 +166,18 @@ export class TaskWorker {
|
||||
if (typeof value === 'string') {
|
||||
const templated = this.handlebars.compile(value, {
|
||||
noEscape: true,
|
||||
strict: true,
|
||||
data: false,
|
||||
preventIndent: true,
|
||||
})(templateCtx);
|
||||
|
||||
// If it's just an empty string, treat it as undefined
|
||||
if (templated === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
|
||||
if (
|
||||
(templated.startsWith('"') && templated.endsWith('"')) ||
|
||||
(templated.startsWith('{') && templated.endsWith('}')) ||
|
||||
(templated.startsWith('[') && templated.endsWith(']'))
|
||||
) {
|
||||
@@ -203,12 +256,32 @@ export class TaskWorker {
|
||||
JSON.stringify(task.spec.output),
|
||||
(_key, value) => {
|
||||
if (typeof value === 'string') {
|
||||
return this.handlebars.compile(value, {
|
||||
const templated = this.handlebars.compile(value, {
|
||||
noEscape: true,
|
||||
strict: true,
|
||||
data: false,
|
||||
preventIndent: true,
|
||||
})(templateCtx);
|
||||
|
||||
// If it's just an empty string, treat it as undefined
|
||||
if (templated === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
|
||||
if (
|
||||
(templated.startsWith('"') && templated.endsWith('"')) ||
|
||||
(templated.startsWith('{') && templated.endsWith('}')) ||
|
||||
(templated.startsWith('[') && templated.endsWith(']'))
|
||||
) {
|
||||
try {
|
||||
// Don't recursively JSON parse the values of this string.
|
||||
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
|
||||
return JSON.parse(templated);
|
||||
} catch {
|
||||
return templated;
|
||||
}
|
||||
}
|
||||
return templated;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isTruthy } from './helper';
|
||||
|
||||
describe('isTruthy', () => {
|
||||
it.each`
|
||||
value | result
|
||||
${'string'} | ${true}
|
||||
${true} | ${true}
|
||||
${1} | ${true}
|
||||
${['1']} | ${true}
|
||||
${{}} | ${true}
|
||||
${false} | ${false}
|
||||
${''} | ${false}
|
||||
${undefined} | ${false}
|
||||
${null} | ${false}
|
||||
${0} | ${false}
|
||||
${[]} | ${false}
|
||||
`('should be $result for $value', async ({ value, result }) => {
|
||||
expect(isTruthy(value)).toEqual(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
/**
|
||||
* Returns true if the input is not `false`, `undefined`, `null`, `""`, `0`, or
|
||||
* `[]`. This behavior is based on the behavior of handlebars, see
|
||||
* https://handlebarsjs.com/guide/builtin-helpers.html#if
|
||||
*/
|
||||
export function isTruthy(value: any): boolean {
|
||||
return isArray(value) ? value.length > 0 : !!value;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export type TaskSpec = {
|
||||
name: string;
|
||||
action: string;
|
||||
input?: JsonObject;
|
||||
if?: string | boolean;
|
||||
}>;
|
||||
output: { [name: string]: string };
|
||||
};
|
||||
|
||||
@@ -172,6 +172,8 @@ export const TaskStatusStepper = memo(
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isFailed = step.status === 'failed';
|
||||
const isActive = step.status === 'processing';
|
||||
const isSkipped = step.status === 'skipped';
|
||||
|
||||
return (
|
||||
<Step key={String(index)} expanded>
|
||||
<StepButton onClick={() => onUserStepChange(step.id)}>
|
||||
@@ -186,7 +188,11 @@ export const TaskStatusStepper = memo(
|
||||
>
|
||||
<div className={classes.labelWrapper}>
|
||||
<Typography variant="subtitle2">{step.name}</Typography>
|
||||
<StepTimeTicker step={step} />
|
||||
{isSkipped ? (
|
||||
<Typography variant="caption">Skipped</Typography>
|
||||
) : (
|
||||
<StepTimeTicker step={step} />
|
||||
)}
|
||||
</div>
|
||||
</StepLabel>
|
||||
</StepButton>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { TaskPageLinks } from './TaskPageLinks';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
describe('TaskPageLinks', () => {
|
||||
beforeEach(() => {});
|
||||
@@ -68,9 +68,11 @@ describe('TaskPageLinks', () => {
|
||||
links: [
|
||||
{ url: 'https://first.url', title: 'Cool link 1' },
|
||||
{ url: 'https://second.url', title: 'Cool link 2' },
|
||||
{ entityRef: 'Component:default/my-app', title: 'Open in catalog' },
|
||||
{ title: 'Skipped' },
|
||||
],
|
||||
};
|
||||
const { findByText } = await renderInTestApp(
|
||||
const { findByText, queryByText } = await renderInTestApp(
|
||||
<TaskPageLinks output={output} />,
|
||||
{
|
||||
mountedRoutes: {
|
||||
@@ -88,5 +90,15 @@ describe('TaskPageLinks', () => {
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveAttribute('href', 'https://second.url');
|
||||
|
||||
element = await findByText('Open in catalog');
|
||||
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element).toHaveAttribute(
|
||||
'href',
|
||||
'/catalog/default/Component/my-app',
|
||||
);
|
||||
|
||||
expect(queryByText('Skipped')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,21 +14,21 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { parseEntityName } from '@backstage/catalog-model';
|
||||
import { IconComponent, IconKey, useApp, useRouteRef } from '@backstage/core';
|
||||
import { IconLink } from './IconLink';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { Box } from '@material-ui/core';
|
||||
import LanguageIcon from '@material-ui/icons/Language';
|
||||
import React from 'react';
|
||||
import { TaskOutput } from '../../types';
|
||||
import { IconLink } from './IconLink';
|
||||
|
||||
type TaskPageLinksProps = {
|
||||
output: TaskOutput;
|
||||
};
|
||||
|
||||
export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
|
||||
const { entityRef, remoteUrl } = output;
|
||||
const { entityRef: entityRefOutput, remoteUrl } = output;
|
||||
let { links = [] } = output;
|
||||
const app = useApp();
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
@@ -40,12 +40,10 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
|
||||
links = [{ url: remoteUrl, title: 'Repo' }, ...links];
|
||||
}
|
||||
|
||||
if (entityRef) {
|
||||
const entityName = parseEntityName(entityRef);
|
||||
const target = entityRoute(entityName);
|
||||
if (entityRefOutput) {
|
||||
links = [
|
||||
{
|
||||
url: target,
|
||||
entityRef: entityRefOutput,
|
||||
title: 'Open in catalog',
|
||||
icon: 'catalog',
|
||||
},
|
||||
@@ -55,15 +53,25 @@ export const TaskPageLinks = ({ output }: TaskPageLinksProps) => {
|
||||
|
||||
return (
|
||||
<Box px={3} pb={3}>
|
||||
{links.map(({ url, title, icon }, i) => (
|
||||
<IconLink
|
||||
key={`output-link-${i}`}
|
||||
href={url}
|
||||
text={title ?? url}
|
||||
Icon={iconResolver(icon)}
|
||||
target="_blank"
|
||||
/>
|
||||
))}
|
||||
{links
|
||||
.filter(({ url, entityRef }) => url || entityRef)
|
||||
.map(({ url, entityRef, title, icon }) => {
|
||||
if (entityRef) {
|
||||
const entityName = parseEntityName(entityRef);
|
||||
const target = entityRoute(entityName);
|
||||
return { title, icon, url: target };
|
||||
}
|
||||
return { title, icon, url: url! };
|
||||
})
|
||||
.map(({ url, title, icon }, i) => (
|
||||
<IconLink
|
||||
key={`output-link-${i}`}
|
||||
href={url}
|
||||
text={title ?? url}
|
||||
Icon={iconResolver(icon)}
|
||||
target="_blank"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { JSONSchema } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export type Status = 'open' | 'processing' | 'failed' | 'completed';
|
||||
export type Status = 'open' | 'processing' | 'failed' | 'completed' | 'skipped';
|
||||
export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
export type Job = {
|
||||
id: string;
|
||||
@@ -66,12 +66,14 @@ export type ListActionsResponse = Array<{
|
||||
}>;
|
||||
|
||||
type OutputLink = {
|
||||
url: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
url?: string;
|
||||
entityRef?: string;
|
||||
};
|
||||
|
||||
export type TaskOutput = {
|
||||
/** @deprecated use the `links` property to link out to relevant resources */
|
||||
entityRef?: string;
|
||||
/** @deprecated use the `links` property to link out to relevant resources */
|
||||
remoteUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user