chore: fix off the other removals

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2022-03-03 17:45:50 +01:00
parent 800a2b6e80
commit 23c735d94b
17 changed files with 65 additions and 969 deletions
@@ -23,7 +23,6 @@ import {
SystemEntity,
UserEntity,
} from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
describe('BuiltinKindsEntityProcessor', () => {
@@ -539,38 +538,5 @@ describe('BuiltinKindsEntityProcessor', () => {
},
});
});
it('generates relations for template entities', async () => {
const entity: TemplateEntityV1beta2 = {
apiVersion: 'backstage.io/v1beta2',
kind: 'Template',
metadata: { name: 'n' },
spec: {
parameters: {},
steps: [],
type: 'service',
owner: 'o',
},
};
await processor.postProcessEntity(entity, location, emit);
expect(emit).toBeCalledTimes(2);
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Group', namespace: 'default', name: 'o' },
type: 'ownerOf',
target: { kind: 'Template', namespace: 'default', name: 'n' },
},
});
expect(emit).toBeCalledWith({
type: 'relation',
relation: {
source: { kind: 'Template', namespace: 'default', name: 'n' },
type: 'ownedBy',
target: { kind: 'Group', namespace: 'default', name: 'o' },
},
});
});
});
});
@@ -48,10 +48,6 @@ import {
UserEntity,
userEntityV1alpha1Validator,
} from '@backstage/catalog-model';
import {
TemplateEntityV1beta2,
templateEntityV1beta2Validator,
} from '@backstage/plugin-scaffolder-common';
import {
CatalogProcessor,
CatalogProcessorEmit,
@@ -67,7 +63,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
resourceEntityV1alpha1Validator,
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
templateEntityV1beta2Validator,
userEntityV1alpha1Validator,
systemEntityV1alpha1Validator,
domainEntityV1alpha1Validator,
@@ -135,19 +130,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
}
}
/*
* Emit relations for the Template kind
*/
if (entity.kind === 'Template') {
const template = entity as TemplateEntityV1beta2;
doEmit(
template.spec.owner,
{ defaultKind: 'Group', defaultNamespace: selfRef.namespace },
RELATION_OWNED_BY,
RELATION_OWNER_OF,
);
}
/*
* Emit relations for the Component kind
*/
+4 -4
View File
@@ -15,7 +15,7 @@
*/
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { EntityTextFilter } from './filters';
const entities: Entity[] = [
@@ -37,9 +37,9 @@ const entities: Entity[] = [
},
];
const templates: TemplateEntityV1beta2[] = [
const templates: TemplateEntityV1beta3[] = [
{
apiVersion: 'backstage.io/v1beta2',
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 'react-app',
@@ -52,7 +52,7 @@ const templates: TemplateEntityV1beta2[] = [
},
},
{
apiVersion: 'backstage.io/v1beta2',
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
name: 'gRPC service',
@@ -19,10 +19,7 @@ import { Writable } from 'stream';
import { JsonValue, JsonObject } from '@backstage/types';
import { Schema } from 'jsonschema';
import { TaskSecrets } from '../tasks/types';
import {
TemplateInfo,
TemplateMetadata,
} from '@backstage/plugin-scaffolder-common';
import { TemplateInfo } from '@backstage/plugin-scaffolder-common';
/**
* ActionContext is passed into scaffolder actions.
@@ -47,10 +44,6 @@ export type ActionContext<Input extends JsonObject> = {
*/
createTemporaryDirectory(): Promise<string>;
/**
* @deprecated please use templateInfo instead
*/
metadata?: TemplateMetadata;
templateInfo?: TemplateInfo;
};
@@ -1,441 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 mockFs from 'mock-fs';
import * as winston from 'winston';
import { createTemplateAction, TemplateActionRegistry } from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { HandlebarsWorkflowRunner } from './HandlebarsWorkflowRunner';
import { TaskContext } from './types';
import { RepoSpec } from '../actions/builtin/publish/util';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
describe('LegacyWorkflowRunner', () => {
let runner: HandlebarsWorkflowRunner;
const logger = getVoidLogger();
let actionRegistry = new TemplateActionRegistry();
let fakeActionHandler: jest.Mock;
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'token' }],
},
}),
);
const createMockTaskWithSpec = (spec: TaskSpec): TaskContext => ({
spec,
complete: async () => {},
done: false,
emitLog: async () => {},
getWorkspaceName: () => Promise.resolve('test-workspace'),
});
beforeEach(() => {
winston.format.simple(); // put logform the require cache before mocking fs
mockFs({
'/tmp': mockFs.directory(),
});
actionRegistry = new TemplateActionRegistry();
actionRegistry.register({
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'mockOutputData');
ctx.output('badOutput', false);
},
});
fakeActionHandler = jest.fn();
actionRegistry.register({
id: 'test-metadata-action',
handler: fakeActionHandler,
});
runner = new HandlebarsWorkflowRunner({
actionRegistry,
integrations,
workingDirectory: '/tmp',
logger,
});
});
afterEach(() => {
mockFs.restore();
});
it('should fail when the action does not exist', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
await expect(() => runner.execute(task)).rejects.toThrow(
/Template action with ID 'not-found-action' is not registered/,
);
});
it('should pass metadata through', async () => {
const entityRef = `template:default/templateName`;
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{
id: 'test',
name: 'name',
action: 'test-metadata-action',
input: { foo: 1 },
},
],
output: {},
values: {},
templateInfo: { entityRef },
});
await runner.execute(task);
expect(fakeActionHandler.mock.calls[0][0].templateInfo).toEqual({
entityRef,
});
});
describe('templating', () => {
it('should template the output', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'test-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should template the input', async () => {
const inputAction = createTemplateAction<{
name: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['name'],
properties: {
name: {
title: 'name',
description: 'Enter name',
type: 'string',
},
},
},
},
async handler(ctx) {
if (ctx.input.name !== 'mockOutputData') {
throw new Error(
`expected name to be "mockOutputData" got ${ctx.input.name}`,
);
}
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
name: '{{ steps.test.output.testOutput }}',
},
},
],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
});
describe('conditionals', () => {
it('should execute steps conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
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 { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should execute steps conditionally with eq helper', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{ id: 'test', name: 'test', action: 'test-action' },
{
id: 'conditional',
name: 'conditional',
action: 'test-action',
if: '{{ eq steps.test.output.testOutput "mockOutputData" }}',
},
],
output: {
result: '{{ steps.conditional.output.testOutput }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('mockOutputData');
});
it('should skip test conditionally', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
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 { output } = await runner.execute(task);
expect(output.result).toBeUndefined();
});
});
describe('parsing', () => {
it('should parse strings as objects if possible', async () => {
const inputAction = createTemplateAction<{
address: { line1: string };
list: string[];
address2: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['address'],
properties: {
address: {
title: 'address',
description: 'Enter name',
type: 'object',
properties: {
line1: {
type: 'string',
},
},
},
address2: {
type: 'string',
},
list: {
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (ctx.input.list.length !== 1) {
throw new Error(
`expected list to have length "1" got ${ctx.input.list.length}`,
);
}
if (ctx.input.address.line1 !== 'line 1') {
throw new Error(
`expected address.line1 to be "line 1" got ${ctx.input.address.line1}`,
);
}
if (ctx.input.address2 !== '{"not valid"}') {
throw new Error(
`expected address2 to be "{"not valid"}" got ${ctx.input.address2}`,
);
}
ctx.output('address', ctx.input.address.line1);
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
address: JSON.stringify({ line1: 'line 1' }),
list: JSON.stringify(['hey!']),
address2: '{"not valid"}',
},
},
],
output: {
result: '{{ steps.test-input.output.address }}',
},
values: {},
});
const { output } = await runner.execute(task);
expect(output.result).toBe('line 1');
});
it('should provide a parseRepoUrl helper', async () => {
const inputAction = createTemplateAction<{
destination: RepoSpec;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['destination'],
properties: {
destination: {
title: 'destination',
type: 'object',
properties: {
repo: {
type: 'string',
},
host: {
type: 'string',
},
owner: {
type: 'string',
},
organization: {
type: 'string',
},
workspace: {
type: 'string',
},
project: {
type: 'string',
},
},
},
},
},
},
async handler(ctx) {
ctx.output('host', ctx.input.destination.host);
ctx.output('repo', ctx.input.destination.repo);
if (ctx.input.destination.owner) {
ctx.output('owner', ctx.input.destination.owner);
}
if (ctx.input.destination.host !== 'github.com') {
throw new Error(
`expected host to be "github.com" got ${ctx.input.destination.host}`,
);
}
if (ctx.input.destination.repo !== 'repo') {
throw new Error(
`expected repo to be "repo" got ${ctx.input.destination.repo}`,
);
}
if (
ctx.input.destination.owner &&
ctx.input.destination.owner !== 'owner'
) {
throw new Error(
`expected repo to be "owner" got ${ctx.input.destination.owner}`,
);
}
},
});
actionRegistry.register(inputAction);
const task = createMockTaskWithSpec({
apiVersion: 'backstage.io/v1beta2',
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
destination: '{{ parseRepoUrl parameters.repoUrl }}',
},
},
],
output: {
host: '{{ steps.test-input.output.host }}',
repo: '{{ steps.test-input.output.repo }}',
owner: '{{ steps.test-input.output.owner }}',
},
values: {
repoUrl: 'github.com?repo=repo&owner=owner',
},
});
const { output } = await runner.execute(task);
expect(output.host).toBe('github.com');
expect(output.repo).toBe('repo');
expect(output.owner).toBe('owner');
});
});
});
@@ -1,308 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { TaskContext, WorkflowRunner, WorkflowResponse } from './types';
import * as Handlebars from 'handlebars';
import { TemplateActionRegistry } from '..';
import { ScmIntegrations } from '@backstage/integration';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { isTruthy } from './helper';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { Logger } from 'winston';
import path from 'path';
import fs from 'fs-extra';
import { validate as validateJsonSchema } from 'jsonschema';
import { JsonObject, JsonValue } from '@backstage/types';
import { InputError } from '@backstage/errors';
import { TaskSpec, TaskSpecV1beta2 } from '@backstage/plugin-scaffolder-common';
type Options = {
workingDirectory: string;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
logger: Logger;
};
const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta2 =>
taskSpec.apiVersion === 'backstage.io/v1beta2';
/**
* This is the legacy workflow runner, which supports handlebars. This entire implementation will be replaced
* with the default workflow runner interface in the future so this entire thing can go bye bye.
*/
export class HandlebarsWorkflowRunner implements WorkflowRunner {
private readonly handlebars: typeof Handlebars;
constructor(private readonly options: Options) {
this.handlebars = Handlebars.create();
// TODO(blam): this should be a public facing API but it's a little
// scary right now, so we're going to lock it off like the component API is
// in the frontend until we can work out a nice way to do it.
this.handlebars.registerHelper('parseRepoUrl', repoUrl => {
return JSON.stringify(parseRepoUrl(repoUrl, this.options.integrations));
});
this.handlebars.registerHelper('projectSlug', repoUrl => {
const { owner, repo } = parseRepoUrl(repoUrl, this.options.integrations);
return `${owner}/${repo}`;
});
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
this.handlebars.registerHelper('not', value => !isTruthy(value));
this.handlebars.registerHelper('eq', (a, b) => a === b);
}
async execute(task: TaskContext): Promise<WorkflowResponse> {
if (!isValidTaskSpec(task.spec)) {
throw new InputError(`Task spec is not a valid v1beta2 task spec`);
}
const { actionRegistry } = this.options;
const workspacePath = path.join(
this.options.workingDirectory,
await task.getWorkspaceName(),
);
try {
await fs.ensureDir(workspacePath);
await task.emitLog(
`Starting up task with ${task.spec.steps.length} steps`,
);
const templateCtx: {
parameters: JsonObject;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
} = { parameters: task.spec.values, steps: {} };
for (const step of task.spec.steps) {
const metadata = { stepId: step.id };
try {
const taskLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: {},
});
const stream = new PassThrough();
stream.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, metadata);
}
});
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',
});
const action = actionRegistry.get(step.action);
if (!action) {
throw new Error(`Action '${step.action}' does not exist`);
}
const input =
step.input &&
JSON.parse(JSON.stringify(step.input), (_key, value) => {
if (typeof value === 'string') {
const templated = this.handlebars.compile(value, {
noEscape: true,
data: false,
preventIndent: true,
})(templateCtx);
// 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;
});
if (action.schema?.input) {
const validateResult = validateJsonSchema(
input,
action.schema.input,
);
if (!validateResult.valid) {
const errors = validateResult.errors.join(', ');
throw new InputError(
`Invalid input passed to action ${action.id}, ${errors}`,
);
}
}
const stepOutputs: { [name: string]: JsonValue } = {};
// Keep track of all tmp dirs that are created by the action so we can remove them after
const tmpDirs = new Array<string>();
this.options.logger.debug(`Running ${action.id} with input`, {
input: JSON.stringify(input, null, 2),
});
await action.handler({
// deprecated in favor of templateInfo.baseUrl
baseUrl: task.spec.baseUrl,
logger: taskLogger,
logStream: stream,
input,
secrets: task.secrets ?? {},
workspacePath,
async createTemporaryDirectory() {
const tmpDir = await fs.mkdtemp(
`${workspacePath}_step-${step.id}-`,
);
tmpDirs.push(tmpDir);
return tmpDir;
},
output(name: string, value: JsonValue) {
stepOutputs[name] = value;
},
// deprecated in favor of templateInfo
metadata: task.spec.metadata,
templateInfo: task.spec.templateInfo,
});
// Remove all temporary directories that were created when executing the action
for (const tmpDir of tmpDirs) {
await fs.remove(tmpDir);
}
templateCtx.steps[step.id] = { output: stepOutputs };
await task.emitLog(`Finished step ${step.name}`, {
...metadata,
status: 'completed',
});
} catch (error) {
await task.emitLog(String(error.stack), {
...metadata,
status: 'failed',
});
throw error;
}
}
const output = JSON.parse(
JSON.stringify(task.spec.output),
(_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;
}
// 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;
},
);
return { output };
} finally {
if (workspacePath) {
await fs.remove(workspacePath);
}
}
}
}
@@ -257,8 +257,6 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
const stepOutput: { [outputName: string]: JsonValue } = {};
await action.handler({
// deprecated in favourof templateInfo.baseUrl
baseUrl: task.spec.baseUrl,
input,
secrets: task.secrets ?? {},
logger: taskLogger,
@@ -274,8 +272,6 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
output(name: string, value: JsonValue) {
stepOutput[name] = value;
},
// deprecated in favour of templateInfo
metadata: task.spec.metadata,
templateInfo: task.spec.templateInfo,
});
@@ -19,16 +19,10 @@ import { ConfigReader } from '@backstage/config';
import { DatabaseTaskStore } from './DatabaseTaskStore';
import { StorageTaskBroker } from './StorageTaskBroker';
import { TaskWorker } from './TaskWorker';
import { HandlebarsWorkflowRunner } from './HandlebarsWorkflowRunner';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateActionRegistry } from '../actions';
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
jest.mock('./HandlebarsWorkflowRunner');
const MockedHandlebarsWorkflowRunner =
HandlebarsWorkflowRunner as jest.Mock<HandlebarsWorkflowRunner>;
MockedHandlebarsWorkflowRunner.mockImplementation();
jest.mock('./NunjucksWorkflowRunner');
const MockedNunjucksWorkflowRunner =
NunjucksWorkflowRunner as jest.Mock<NunjucksWorkflowRunner>;
@@ -58,10 +52,6 @@ describe('TaskWorker', () => {
const actionRegistry: TemplateActionRegistry = {} as TemplateActionRegistry;
const workingDirectory = '/tmp/scaffolder';
const handlebarsWorkflowRunner: HandlebarsWorkflowRunner = {
execute: jest.fn(),
} as unknown as HandlebarsWorkflowRunner;
const workflowRunner: NunjucksWorkflowRunner = {
execute: jest.fn(),
} as unknown as NunjucksWorkflowRunner;
@@ -72,47 +62,11 @@ describe('TaskWorker', () => {
beforeEach(() => {
jest.resetAllMocks();
MockedHandlebarsWorkflowRunner.mockImplementation(
() => handlebarsWorkflowRunner,
);
MockedNunjucksWorkflowRunner.mockImplementation(() => workflowRunner);
});
const logger = getVoidLogger();
it('should call the legacy workflow runner when the apiVersion is not beta3', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = await TaskWorker.create({
logger,
workingDirectory,
integrations,
taskBroker: broker,
actionRegistry,
});
await broker.dispatch({
spec: {
apiVersion: 'backstage.io/v1beta2',
steps: [{ id: 'test', name: 'test', action: 'not-found-action' }],
output: {
result: '{{ steps.test.output.testOutput }}',
},
values: {},
},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
expect(MockedHandlebarsWorkflowRunner).toBeCalledWith({
actionRegistry,
integrations,
logger,
workingDirectory,
});
expect(handlebarsWorkflowRunner.execute).toHaveBeenCalled();
});
it('should call the default workflow runner when the apiVersion is beta3', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = await TaskWorker.create({
@@ -15,7 +15,6 @@
*/
import { TaskContext, TaskBroker, WorkflowRunner } from './types';
import { HandlebarsWorkflowRunner } from './HandlebarsWorkflowRunner';
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
import { Logger } from 'winston';
import { TemplateActionRegistry } from '../actions';
@@ -31,7 +30,6 @@ import { TemplateFilter } from '../../lib/templating/SecureTemplater';
export type TaskWorkerOptions = {
taskBroker: TaskBroker;
runners: {
legacyWorkflowRunner: HandlebarsWorkflowRunner;
workflowRunner: WorkflowRunner;
};
};
@@ -68,13 +66,6 @@ export class TaskWorker {
additionalTemplateFilters,
} = options;
const legacyWorkflowRunner = new HandlebarsWorkflowRunner({
logger,
actionRegistry,
integrations,
workingDirectory,
});
const workflowRunner = new NunjucksWorkflowRunner({
actionRegistry,
integrations,
@@ -85,7 +76,7 @@ export class TaskWorker {
return new TaskWorker({
taskBroker: taskBroker,
runners: { legacyWorkflowRunner, workflowRunner },
runners: { workflowRunner },
});
}
@@ -100,10 +91,15 @@ export class TaskWorker {
async runOneTask(task: TaskContext) {
try {
const { output } =
task.spec.apiVersion === 'scaffolder.backstage.io/v1beta3'
? await this.options.runners.workflowRunner.execute(task)
: await this.options.runners.legacyWorkflowRunner.execute(task);
if (task.spec.apiVersion !== 'scaffolder.backstage.io/v1beta3') {
throw new Error(
`Unsupported Template apiVersion ${task.spec.apiVersion}`,
);
}
const { output } = await this.options.runners.workflowRunner.execute(
task,
);
await task.complete('completed', { output });
} catch (error) {
@@ -26,10 +26,7 @@ import {
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, InputError, NotFoundError } from '@backstage/errors';
import {
TemplateEntityV1beta2,
TemplateEntityV1beta3,
} from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import fs from 'fs-extra';
import os from 'os';
import { Logger } from 'winston';
@@ -95,7 +92,7 @@ export async function findTemplate(options: {
entityRef: CompoundEntityRef;
token?: string;
catalogApi: CatalogApi;
}): Promise<TemplateEntityV1beta3 | TemplateEntityV1beta2> {
}): Promise<TemplateEntityV1beta3> {
const { entityRef, token, catalogApi } = options;
if (entityRef.namespace.toLocaleLowerCase('en-US') !== DEFAULT_NAMESPACE) {
@@ -114,5 +111,5 @@ export async function findTemplate(options: {
);
}
return template as TemplateEntityV1beta3 | TemplateEntityV1beta2;
return template as TemplateEntityV1beta3;
}
@@ -35,7 +35,7 @@ import {
UrlReaders,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ConfigReader } from '@backstage/config';
import ObservableImpl from 'zen-observable';
import express from 'express';
@@ -76,8 +76,8 @@ const mockUrlReader = UrlReaders.default({
describe('createRouter', () => {
let app: express.Express;
let taskBroker: TaskBroker;
const template: TemplateEntityV1beta2 = {
apiVersion: 'backstage.io/v1beta2',
const template: TemplateEntityV1beta3 = {
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: {
description: 'Create a new CRA website project',
@@ -22,11 +22,8 @@ import { Config } from '@backstage/config';
import { InputError, NotFoundError } from '@backstage/errors';
import { ScmIntegrations } from '@backstage/integration';
import {
TemplateEntityV1beta2,
TemplateEntityV1beta3,
TaskSpecV1beta3,
TaskSpec,
TaskSpecV1beta2,
} from '@backstage/plugin-scaffolder-common';
import express from 'express';
import Router from 'express-promise-router';
@@ -61,13 +58,8 @@ export interface RouterOptions {
additionalTemplateFilters?: Record<string, TemplateFilter>;
}
function isSupportedTemplate(
entity: TemplateEntityV1beta2 | TemplateEntityV1beta3,
) {
return (
entity.apiVersion === 'backstage.io/v1beta2' ||
entity.apiVersion === 'scaffolder.backstage.io/v1beta3'
);
function isSupportedTemplate(entity: TemplateEntityV1beta3) {
return entity.apiVersion === 'scaffolder.backstage.io/v1beta3';
}
/** @public */
@@ -181,64 +173,7 @@ export async function createRouter(
token: getBearerToken(req.headers.authorization),
});
let taskSpec: TaskSpec;
if (isSupportedTemplate(template)) {
if (template.apiVersion === 'backstage.io/v1beta2') {
logger.warn(
`Scaffolding ${stringifyEntityRef(
template,
)} with deprecated apiVersion ${
template.apiVersion
}. Please migrate the template to backstage.io/v1beta3. https://backstage.io/docs/features/software-templates/migrating-from-v1beta2-to-v1beta3`,
);
}
for (const parameters of [template.spec.parameters ?? []].flat()) {
const result = validate(values, parameters);
if (!result.valid) {
res.status(400).json({ errors: result.errors });
return;
}
}
const baseUrl = getEntityBaseUrl(template);
const baseTaskSpec = {
baseUrl,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
// deprecated in favour of templateInfo
metadata: { name: template.metadata?.name },
templateInfo: {
entityRef: stringifyEntityRef({
kind,
namespace,
name: template.metadata?.name,
}),
baseUrl,
},
};
taskSpec =
template.apiVersion === 'backstage.io/v1beta2'
? ({
...baseTaskSpec,
apiVersion: template.apiVersion,
values,
} as TaskSpecV1beta2)
: ({
...baseTaskSpec,
apiVersion: template.apiVersion,
parameters: values,
} as TaskSpecV1beta3);
} else {
if (!isSupportedTemplate(template)) {
throw new InputError(
`Unsupported apiVersion field in schema entity, ${
(template as Entity).apiVersion
@@ -246,6 +181,35 @@ export async function createRouter(
);
}
for (const parameters of [template.spec.parameters ?? []].flat()) {
const result = validate(values, parameters);
if (!result.valid) {
res.status(400).json({ errors: result.errors });
return;
}
}
const baseUrl = getEntityBaseUrl(template);
const taskSpec: TaskSpec = {
apiVersion: template.apiVersion,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
parameters: values,
templateInfo: {
entityRef: stringifyEntityRef({
kind,
namespace,
name: template.metadata?.name,
}),
baseUrl,
},
};
const result = await taskBroker.dispatch({
spec: taskSpec,
secrets: {
+3 -3
View File
@@ -17,7 +17,7 @@
import React, { ComponentType } from 'react';
import { Routes, Route, useOutlet } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
@@ -35,14 +35,14 @@ import { useElementFilter } from '@backstage/core-plugin-api';
export type RouterProps = {
/** @deprecated use components.TemplateCardComponent instead */
TemplateCardComponent?:
| ComponentType<{ template: TemplateEntityV1beta2 }>
| ComponentType<{ template: TemplateEntityV1beta3 }>
| undefined;
/** @deprecated use component.TaskPageComponent instead */
TaskPageComponent?: ComponentType<{}>;
components?: {
TemplateCardComponent?:
| ComponentType<{ template: TemplateEntityV1beta2 }>
| ComponentType<{ template: TemplateEntityV1beta3 }>
| undefined;
TaskPageComponent?: ComponentType<{}>;
};
@@ -24,7 +24,7 @@ import {
SupportButton,
} from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { useRouteRef } from '@backstage/core-plugin-api';
import {
EntityKindPicker,
@@ -52,7 +52,7 @@ const useStyles = makeStyles(theme => ({
export type ScaffolderPageProps = {
TemplateCardComponent?:
| ComponentType<{ template: TemplateEntityV1beta2 }>
| ComponentType<{ template: TemplateEntityV1beta3 }>
| undefined;
groups?: Array<{
title?: React.ReactNode;
@@ -298,10 +298,7 @@ export const TaskPage = ({ loadingText }: TaskPageProps) => {
return;
}
const formData =
taskStream.task!.spec.apiVersion === 'backstage.io/v1beta2'
? taskStream.task!.spec.values
: taskStream.task!.spec.parameters;
const formData = taskStream.task!.spec.parameters;
const { name } = parseEntityRef(
taskStream.task!.spec.templateInfo?.entityRef,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
ScmIntegrationIcon,
scmIntegrationsApiRef,
@@ -99,7 +99,7 @@ const useDeprecationStyles = makeStyles(theme => ({
}));
export type TemplateCardProps = {
template: TemplateEntityV1beta2;
template: TemplateEntityV1beta3;
deprecated?: boolean;
};
@@ -112,7 +112,7 @@ type TemplateProps = {
};
const getTemplateCardProps = (
template: TemplateEntityV1beta2,
template: TemplateEntityV1beta3,
): TemplateProps & { key: string } => {
return {
key: template.metadata.uid!,
@@ -16,7 +16,7 @@
import React, { ComponentType } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateEntityV1beta2 } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
Content,
ContentHeader,
@@ -34,7 +34,7 @@ import { TemplateCard } from '../TemplateCard';
*/
export type TemplateListProps = {
TemplateCardComponent?:
| ComponentType<{ template: TemplateEntityV1beta2 }>
| ComponentType<{ template: TemplateEntityV1beta3 }>
| undefined;
group?: {
title?: React.ReactNode;
@@ -106,7 +106,7 @@ export const TemplateList = ({
maybeFilteredEntities.map((template: Entity) => (
<Card
key={stringifyEntityRef(template)}
template={template as TemplateEntityV1beta2}
template={template as TemplateEntityV1beta3}
deprecated={template.apiVersion === 'backstage.io/v1beta2'}
/>
))}