From 498d6488bbb1cfebd143227377ab531cadd75a7c Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 28 May 2021 12:38:06 +0200 Subject: [PATCH] Implement conditional steps in backend Signed-off-by: Oliver Sand --- .../src/scaffolder/tasks/TaskWorker.test.ts | 77 ++++++++++++++++++- .../src/scaffolder/tasks/TaskWorker.ts | 64 ++++++++++++--- .../src/scaffolder/tasks/helper.test.ts | 36 +++++++++ .../src/scaffolder/tasks/helper.ts | 26 +++++++ .../src/scaffolder/tasks/types.ts | 1 + 5 files changed, 190 insertions(+), 14 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/tasks/helper.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/tasks/helper.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.test.ts index edef02064f..64a5ff260f 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,74 @@ 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'); + console.warn(events); + 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: + '{{#if steps.conditional}}{{ steps.conditional.output.testOutput }}{{/if}}', + }, + 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).toEqual(''); + }); + 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..49e55881b9 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,47 @@ 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, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + + 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', 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..d4008a64c2 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; }>; output: { [name: string]: string }; };