diff --git a/.changeset/khaki-dancers-admire.md b/.changeset/khaki-dancers-admire.md new file mode 100644 index 0000000000..7c5c61aa90 --- /dev/null +++ b/.changeset/khaki-dancers-admire.md @@ -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. diff --git a/.changeset/slimy-games-brake.md b/.changeset/slimy-games-brake.md new file mode 100644 index 0000000000..a8fafb09f3 --- /dev/null +++ b/.changeset/slimy-games-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add `debug:log` action for debugging. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index d91b840292..5b8624c97f 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -262,6 +262,7 @@ tooltips touchpoints transpilation transpiled +truthy ui unmanaged unregister diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index c8449dd351..988af89e47 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -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) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index f9c11edea3..8481e64eac 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -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 diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 80da310ef1..0dab9670de 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -534,6 +534,7 @@ export interface TemplateEntityV1beta2 extends Entity { name?: string; action: string; input?: JsonObject; + if?: string | boolean; }>; output?: { [name: string]: string; diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 458bdcd0aa..b06a71c8da 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -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/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 98b953c9e0..6ee6fcb1f1 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -33,6 +33,7 @@ export interface TemplateEntityV1beta2 extends Entity { name?: string; action: string; input?: JsonObject; + if?: string | boolean; }>; output?: { [name: string]: string }; owner?: string; diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index a3d8e6ba86..b14cdb8ee0 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -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.", diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml index b0693b31aa..b3228a20d0 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml @@ -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 }}' diff --git a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml index 16e651eaf7..02da577b39 100644 --- a/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/v1beta2-demo/template/catalog-info.yaml @@ -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 %} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 0209fc50dd..b4fb37de8f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -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 }), ]; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts new file mode 100644 index 0000000000..ebdf13d11c --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts new file mode 100644 index 0000000000..720cd75300 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.test.ts @@ -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>) as jest.Mocked; + + 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!'), + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts new file mode 100644 index 0000000000..d3c5a2ea1d --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/debug/log.ts @@ -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 { + 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), []); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts index e4281c172b..6218b7f213 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/index.ts @@ -15,6 +15,7 @@ */ export * from './catalog'; +export { createBuiltinActions } from './createBuiltinActions'; +export * from './debug'; export * from './fetch'; export * from './publish'; -export { createBuiltinActions } from './createBuiltinActions'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 537a2b882d..b1b0c39bd3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index edef02064f..c6ad1a8105 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts @@ -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 { 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 }; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 1d0d5ad3a3..94eaa62c85 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -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; }, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.test.ts new file mode 100644 index 0000000000..a4e347b827 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.test.ts @@ -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); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts new file mode 100644 index 0000000000..36f91afdff --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts @@ -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; +} diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index d7d57a55ff..31c0f77463 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -51,6 +51,7 @@ export type TaskSpec = { name: string; action: string; input?: JsonObject; + if?: string | boolean; }>; output: { [name: string]: string }; }; diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index cc1149ee61..c19f9ae37f 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -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 ( onUserStepChange(step.id)}> @@ -186,7 +188,11 @@ export const TaskStatusStepper = memo( >
{step.name} - + {isSkipped ? ( + Skipped + ) : ( + + )}
diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx index cd51488242..230df903da 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.test.tsx @@ -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( , { 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(); }); }); diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx index c0b55193c2..394d755424 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx @@ -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 ( - {links.map(({ url, title, icon }, i) => ( - - ))} + {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) => ( + + ))} ); }; diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 9ba480a441..0a34450678 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -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;