Implement conditional steps in backend

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-05-28 12:38:06 +02:00
parent f26e6008f7
commit 498d6488bb
5 changed files with 190 additions and 14 deletions
@@ -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,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 };
@@ -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',
@@ -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;
}>;
output: { [name: string]: string };
};