From cf792300a70a3025aa03b6eee11ae6f81410cd3d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 11:45:04 +0100 Subject: [PATCH 01/12] scaffolder-backend: add support for deep templating in task steps Co-authored-by: Johan Haals --- .../src/scaffolder/tasks/TaskWorker.ts | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index e717fcae1b..e7ad21e703 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -94,22 +94,20 @@ export class TaskWorker { throw new Error(`Action '${step.action}' does not exist`); } - const parameters: { [name: string]: JsonValue } = {}; - for (const [name, maybeTemplateStr] of Object.entries( - step.parameters ?? {}, - )) { - if (typeof maybeTemplateStr === 'string') { - const value = handlebars.compile(maybeTemplateStr, { - noEscape: true, - strict: true, - data: false, - preventIndent: true, - })(templateCtx); - parameters[name] = value; - } else { - parameters[name] = maybeTemplateStr; - } - } + const parameters = JSON.parse( + JSON.stringify(step.parameters), + (_key, value) => { + if (typeof value === 'string') { + return handlebars.compile(value, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + } + return value; + }, + ); const stepOutputs: { [name: string]: JsonValue } = {}; @@ -138,16 +136,19 @@ export class TaskWorker { } } - const output = Object.fromEntries( - Object.entries(task.spec.output).map(([name, templateStr]) => { - const value = handlebars.compile(templateStr, { - noEscape: true, - strict: true, - data: false, - preventIndent: true, - })(templateCtx); - return [name, value]; - }), + const output = JSON.parse( + JSON.stringify(task.spec.output), + (_key, value) => { + if (typeof value === 'string') { + return handlebars.compile(value, { + noEscape: true, + strict: true, + data: false, + preventIndent: true, + })(templateCtx); + } + return value; + }, ); await task.complete('completed', { output }); From 79bc9626afd280afda3e84cda99d4da70636555f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 13:07:21 +0100 Subject: [PATCH 02/12] scaffolder-backend: implemented beta1 task spec translation Co-authored-by: Johan Haals --- .../src/lib/catalog/CatalogEntityClient.ts | 6 +++--- .../src/scaffolder/tasks/TaskWorker.ts | 5 +++-- .../src/scaffolder/tasks/TemplateConverter.ts | 1 + .../src/scaffolder/tasks/types.ts | 1 + .../scaffolder-backend/src/service/router.ts | 21 ++++++++++++++++++- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index cea0e85148..57f73b1876 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntity } from '@backstage/catalog-model'; import { CatalogApi } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/backend-common'; @@ -32,7 +32,7 @@ export class CatalogEntityClient { async findTemplate( templateName: string, options?: { token?: string }, - ): Promise { + ): Promise { const { items: templates } = (await this.catalogClient.getEntities( { filter: { @@ -41,7 +41,7 @@ export class CatalogEntityClient { }, }, options, - )) as { items: TemplateEntityV1alpha1[] }; + )) as { items: TemplateEntity[] }; if (templates.length !== 1) { if (templates.length > 1) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index e7ad21e703..9d0c0863aa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -17,7 +17,7 @@ import { PassThrough } from 'stream'; import { Logger } from 'winston'; import * as winston from 'winston'; -import { JsonValue } from '@backstage/config'; +import { JsonValue, JsonObject } from '@backstage/config'; import { TaskBroker, Task } from './types'; import fs from 'fs-extra'; import path from 'path'; @@ -57,10 +57,11 @@ export class TaskWorker { ); const templateCtx: { + parameters: JsonObject; steps: { [stepName: string]: { output: { [outputName: string]: JsonValue } }; }; - } = { steps: {} }; + } = { parameters: task.spec.values, steps: {} }; for (const step of task.spec.steps) { const metadata = { stepId: step.id }; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 4139948513..8019b676b5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -86,6 +86,7 @@ export function templateEntityToSpec( }); return { + values: {}, steps, output: { remoteUrl: '{{ steps.publish.output.remoteUrl }}', diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index ae18a59e41..7fc35d9e46 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -43,6 +43,7 @@ export type DbTaskEventRow = { }; export type TaskSpec = { + values: JsonObject; steps: Array<{ id: string; name: string; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 3d10bbd101..358d571000 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -45,6 +45,7 @@ import { import { registerLegacyActions } from '../scaffolder/stages/legacy'; import { getWorkingDirectory } from './helpers'; import { + InputError, NotFoundError, PluginDatabaseManager, } from '@backstage/backend-common'; @@ -250,7 +251,25 @@ export async function createRouter( res.status(400).json({ errors: validationResult.errors }); return; } - const taskSpec = templateEntityToSpec(template, values); + + let taskSpec; + if (template.apiVersion === 'backstage.io/v1alpha1') { + taskSpec = templateEntityToSpec(template, values); + } else if (template.apiVersion === 'backstage.io/v1beta1') { + // TODO: add v1beta1 type + // const betaTemplate = template as TemplateEntityV1beta1 + const betaTemplate = template as any; + taskSpec = { + values, + steps: betaTemplate.spec.steps, + output: betaTemplate.spec.output, + }; + } else { + throw new InputError( + `Unknown apiVersion field in schema entity, ${template.apiVersion}`, + ); + } + const result = await taskBroker.dispatch(taskSpec); res.status(201).json({ id: result.taskId }); From d0ed25196c8d2bd7f32f9055d4cfbb01808eb05a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 14:51:14 +0100 Subject: [PATCH 03/12] scaffolder-backend: fix file path resolution for templates with a file location Co-authored-by: Johan Haals --- .changeset/slimy-singers-return.md | 5 +++++ .../src/scaffolder/tasks/TemplateConverter.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/slimy-singers-return.md diff --git a/.changeset/slimy-singers-return.md b/.changeset/slimy-singers-return.md new file mode 100644 index 0000000000..c5fc60aaa2 --- /dev/null +++ b/.changeset/slimy-singers-return.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fixed file path resolution for templates with a file location diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts index 8019b676b5..7f76ac33d0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TemplateConverter.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, dirname } from 'path'; import { JsonValue } from '@backstage/config'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; @@ -39,7 +39,7 @@ export function templateEntityToSpec( let url: string; if (protocol === 'file') { - const path = resolvePath(location, template.spec.path || '.'); + const path = resolvePath(dirname(location), template.spec.path || '.'); url = `file://${path}`; } else { From dffce4661147d36ac3d92797c7fde766f2d70d8a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 15:14:21 +0100 Subject: [PATCH 04/12] scaffolder: add new template parameter schema endpoint and usage in frontend Co-authored-by: Johan Haals --- .../scaffolder-backend/src/service/router.ts | 73 ++++++++- plugins/scaffolder/src/api.ts | 42 +++++ .../MultistepJsonForm/MultistepJsonForm.tsx | 58 +++---- .../components/TemplatePage/TemplatePage.tsx | 147 ++++++++---------- 4 files changed, 206 insertions(+), 114 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 358d571000..2cee30fc87 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -232,6 +232,77 @@ export async function createRouter( // NOTE: The v2 API is unstable router + .get( + '/v2/templates/:namespace/:kind/:name/parameter-schema', + async (req, res) => { + const { namespace, kind, name } = req.params; + + if (namespace !== 'default') { + throw new InputError( + `Invalid namespace, only 'default' namespace is supported`, + ); + } + if (kind.toLowerCase() !== 'template') { + throw new InputError( + `Invalid kind, only 'template' kind is supported`, + ); + } + + const template = await entityClient.findTemplate(name); + if (template.apiVersion === 'backstage.io/v1beta1') { + const betaTemplate = template as any; // TODO: proper type + const parameters = betaTemplate.spec.schema; + const steps = Array.isArray(parameters) ? parameters : [parameters]; + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: steps.map(schema => ({ + title: schema.title ?? 'Fill in template parameters', + schema, + })), + }); + } else if (template.apiVersion === 'backstage.io/v1alpha1') { + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: [ + { + title: 'Fill in template parameters', + schema: template.spec.schema, + }, + { + title: 'Choose owner and repo', + schema: { + type: 'object', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: + 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', + }, + access: { + type: 'string', + title: 'Access', + description: + 'Who should have access, in org/team or user format', + }, + }, + }, + }, + ], + }); + } else { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, + ); + } + }, + ) .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; const values: TemplaterValues = { @@ -266,7 +337,7 @@ export async function createRouter( }; } else { throw new InputError( - `Unknown apiVersion field in schema entity, ${template.apiVersion}`, + `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, ); } diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 20fca5b3f2..ac165e021a 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { EntityName } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, @@ -28,6 +30,14 @@ export const scaffolderApiRef = createApiRef({ description: 'Used to make requests towards the scaffolder backend', }); +type TemplateParameterSchema = { + title: string; + steps: Array<{ + title: string; + schema: JsonObject; + }>; +}; + export type LogEvent = { type: 'log' | 'completion'; body: { @@ -41,6 +51,10 @@ export type LogEvent = { }; export interface ScaffolderApi { + getTemplateParameterSchema( + templateName: EntityName, + ): Promise; + /** * Executes the scaffolding of a component, given a template and its * parameter values. @@ -72,6 +86,34 @@ export class ScaffolderClient implements ScaffolderApi { this.identityApi = options.identityApi; } + async getTemplateParameterSchema( + templateName: EntityName, + ): Promise { + const { namespace, kind, name } = templateName; + + const token = await this.identityApi.getIdToken(); + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const templatePath = [namespace, kind, name] + .map(s => encodeURIComponent(s)) + .join('/'); + const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`; + + const response = await fetch(url, { + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch template parameter schema, ${await response.text()}`, + ); + } + + const schema: TemplateParameterSchema = await response.json(); + return schema; + } + /** * Executes the scaffolding of a component, given a template and its * parameter values. diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 27c5b4f18a..17acc1e8dd 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -32,12 +32,12 @@ import React, { useState } from 'react'; const Form = withTheme(MuiTheme); type Step = { schema: JSONSchema; - label: string; + title: string; } & Partial, 'schema'>>; type Props = { /** - * Steps for the form, each contains label and form schema + * Steps for the form, each contains title and form schema */ steps: Step[]; formData: Record; @@ -66,31 +66,35 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map(({ label, schema, ...formProps }) => ( - - {label} - -
['schema']} - onSubmit={e => { - if (e.errors.length === 0) handleNext(); - }} - {...formProps} - > - - -
-
-
- ))} + {steps.map( + ({ title, schema: { title: _, ...schema }, ...formProps }) => ( + + + {title} + + +
['schema']} + onSubmit={e => { + if (e.errors.length === 0) handleNext(); + }} + {...formProps} + > + + +
+
+
+ ), + )}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0fc1a3cf5d..1c8a662acb 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Content, errorApiRef, @@ -24,9 +23,8 @@ import { useApi, useRouteRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; -import { IChangeEvent } from '@rjsf/core'; +import { FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; @@ -36,50 +34,59 @@ import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; -const useTemplate = ( - templateName: string, - catalogApi: typeof catalogApiRef.T, -) => { - const { value, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template', 'metadata.name': templateName }, - }); - return response.items as TemplateEntityV1alpha1[]; - }, [catalogApi, templateName]); - return { template: value?.[0], loading, error }; +const useTemplateParameterSchema = (templateName: string) => { + const scaffolderApi = useApi(scaffolderApiRef); + const { value, loading, error } = useAsync( + () => + scaffolderApi.getTemplateParameterSchema({ + name: templateName, + kind: 'template', + namespace: 'default', + }), + [scaffolderApi, templateName], + ); + return { schema: value, loading, error }; }; -const OWNER_REPO_SCHEMA = { - $schema: 'http://json-schema.org/draft-07/schema#' as const, - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string' as const, - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string' as const, - title: 'Store path', - description: - 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', - }, - access: { - type: 'string' as const, - title: 'Access', - description: 'Who should have access, in org/team or user format', - }, - }, +const storePathValidator = ( + formData: { storePath?: string }, + errors: FormValidation, +) => { + const { storePath } = formData; + if (!storePath) { + return errors; + } + + try { + const parsedUrl = parseGitUrl(storePath); + + if (!parsedUrl.resource || !parsedUrl.owner || !parsedUrl.name) { + if (parsedUrl.resource === 'dev.azure.com') { + errors.storePath.addError( + "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", + ); + } else { + errors.storePath.addError( + 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', + ); + } + } + } catch (ex) { + errors.storePath.addError( + `Failed validation of the store path with message ${ex.message}`, + ); + } + + return errors; }; export const TemplatePage = () => { const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); const navigate = useNavigate(); const rootLink = useRouteRef(rootRouteRef); - const { template, loading } = useTemplate(templateName, catalogApi); + const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); @@ -98,17 +105,12 @@ export const TemplatePage = () => { } }; - if (!loading && !template) { - errorApi.post(new Error('Template was not found.')); + if (error) { + errorApi.post(new Error(`Failed to load template, ${error}`)); return ; } - - if (template && !template?.spec?.schema) { - errorApi.post( - new Error( - 'Template schema is corrupted, please check the template.yaml file.', - ), - ); + if (!loading && !schema) { + errorApi.post(new Error('Template was not found.')); return ; } @@ -125,51 +127,24 @@ export const TemplatePage = () => { /> {loading && } - {template && ( - + {schema && ( + { - const { storePath } = formData; - try { - const parsedUrl = parseGitUrl(storePath); - - if ( - !parsedUrl.resource || - !parsedUrl.owner || - !parsedUrl.name - ) { - if (parsedUrl.resource === 'dev.azure.com') { - errors.storePath.addError( - "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", - ); - } else { - errors.storePath.addError( - 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', - ); - } - } - } catch (ex) { - errors.storePath.addError( - `Failed validation of the store pathn with message ${ex.message}`, - ); - } - - return errors; - }, - }, - ]} + steps={schema.steps.map(step => { + // TODO: Using this workaround to keep storePath validation, but we should replace + // it with a custom store path selection widget + if ((step.schema as any)?.properties?.storePath) { + return { + ...step, + validate: (a, b) => storePathValidator(a, b), + }; + } + return step; + })} /> )} From ccaa5d7d43bef0ab88ca9623b2e8decb4a216f85 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 16:03:39 +0100 Subject: [PATCH 05/12] scaffolder: implement uiSchema support in template parameter schema Co-authored-by: Johan Haals --- .../MultistepJsonForm/MultistepJsonForm.tsx | 61 ++++++++-------- .../MultistepJsonForm/schema.test.ts | 71 +++++++++++++++++++ .../components/MultistepJsonForm/schema.ts | 71 +++++++++++++++++++ 3 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts create mode 100644 plugins/scaffolder/src/components/MultistepJsonForm/schema.ts diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 17acc1e8dd..71084a1eda 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JSONSchema } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; import { Content, StructuredMetadataTable } from '@backstage/core'; import { Box, @@ -28,10 +28,11 @@ import { import { FormProps, IChangeEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useState } from 'react'; +import { transformSchemaToProps } from './schema'; const Form = withTheme(MuiTheme); type Step = { - schema: JSONSchema; + schema: JsonObject; title: string; } & Partial, 'schema'>>; @@ -66,35 +67,33 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map( - ({ title, schema: { title: _, ...schema }, ...formProps }) => ( - - - {title} - - -
['schema']} - onSubmit={e => { - if (e.errors.length === 0) handleNext(); - }} - {...formProps} - > - - -
-
-
- ), - )} + {steps.map(({ title, schema, ...formProps }) => ( + + + {title} + + +
{ + if (e.errors.length === 0) handleNext(); + }} + {...formProps} + {...transformSchemaToProps(schema)} + > + + +
+
+
+ ))}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts new file mode 100644 index 0000000000..e02e5be01c --- /dev/null +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 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 { transformSchemaToProps } from './schema'; + +describe('transformSchemaToProps', () => { + it('transforms deep schema', () => { + const inputSchema = { + type: 'object', + properties: { + field1: { + type: 'string', + 'ui:derp': 'herp', + }, + field2: { + type: 'object', + properties: { + fieldX: { + type: 'string', + 'ui:derp': 'xerp', + }, + }, + }, + }, + }; + const expectedSchema = { + type: 'object', + properties: { + field1: { + type: 'string', + }, + field2: { + type: 'object', + properties: { + fieldX: { + type: 'string', + }, + }, + }, + }, + }; + const expectedUiSchema = { + field1: { + 'ui:derp': 'herp', + }, + field2: { + fieldX: { + 'ui:derp': 'xerp', + }, + }, + }; + + expect(transformSchemaToProps(inputSchema)).toEqual({ + schema: expectedSchema, + uiSchema: expectedUiSchema, + }); + }); +}); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts b/plugins/scaffolder/src/components/MultistepJsonForm/schema.ts new file mode 100644 index 0000000000..7de3994a8e --- /dev/null +++ b/plugins/scaffolder/src/components/MultistepJsonForm/schema.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 { JsonObject } from '@backstage/config'; +import { FormProps } from '@rjsf/core'; + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) { + const { properties } = schema; + if (!isObject(properties)) { + return; + } + for (const propName in properties) { + if (!properties.hasOwnProperty(propName)) { + continue; + } + const schemaNode = properties[propName]; + if (!isObject(schemaNode)) { + continue; + } + + if (schemaNode.type === 'object') { + const innerUiSchema = {}; + uiSchema[propName] = innerUiSchema; + extractUiSchema(schemaNode, innerUiSchema); + } else { + for (const innerKey in schemaNode) { + if (!schemaNode.hasOwnProperty(innerKey)) { + continue; + } + const innerValue = schemaNode[innerKey]; + if (innerKey.startsWith('ui:')) { + const innerUiSchema = uiSchema[propName] || {}; + if (!isObject(innerUiSchema)) { + throw new TypeError('Unexpected non-object in uiSchema'); + } + uiSchema[propName] = innerUiSchema; + + innerUiSchema[innerKey] = innerValue; + delete schemaNode[innerKey]; + } + } + } + } +} + +export function transformSchemaToProps( + inputSchema: JsonObject, +): { schema: FormProps['schema']; uiSchema: FormProps['uiSchema'] } { + const schema = JSON.parse(JSON.stringify(inputSchema)); + delete schema.title; // Rendered separately + const uiSchema = {}; + extractUiSchema(schema, uiSchema); + return { schema, uiSchema }; +} From 6132a9a775d95d96d203b53e77361a2280b2e1d0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 17:33:31 +0100 Subject: [PATCH 06/12] catalog-model,catalog-backend: add template beta2 schema Co-authored-by: Johan Haals --- .../src/kinds/TemplateEntityV1beta2.ts | 49 +++++++ packages/catalog-model/src/kinds/index.ts | 2 + .../schema/kinds/Template.v1beta2.schema.json | 126 ++++++++++++++++++ .../processors/BuiltinKindsEntityProcessor.ts | 2 + 4 files changed, 179 insertions(+) create mode 100644 packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts create mode 100644 packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts new file mode 100644 index 0000000000..ce5080f25e --- /dev/null +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2020 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 type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/Template.v1beta2.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; +import { JsonObject } from '@backstage/config'; + +const API_VERSION = ['backstage.io/v1beta2'] as const; +const KIND = 'Template' as const; + +export interface TemplateEntityV1beta2 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id: string; + name: string; + action: string; + parameters?: JsonObject; + }>; + output?: { [name: string]: string }; + }; +} + +export const templateEntityV1beta2Validator = ajvCompiledJsonSchemaValidator( + KIND, + API_VERSION, + schema, + [commonSchema, entityMetaSchema, entitySchema], +); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index bc157c79df..4ae2db483a 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -57,6 +57,8 @@ export type { TemplateEntityV1alpha1 as TemplateEntity, TemplateEntityV1alpha1, } from './TemplateEntityV1alpha1'; +export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2'; +export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2'; export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json new file mode 100644 index 0000000000..f1c502ea5e --- /dev/null +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -0,0 +1,126 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "TemplateV1beta1", + "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", + "kind": "Template", + "metadata": { + "name": "react-ssr-template", + "title": "React SSR Template", + "description": "Next.js application skeleton for creating isomorphic web applications.", + "tags": ["recommended", "react"] + }, + "spec": { + "owner": "artist-relations-team", + "type": "website", + "parameters": { + "required": ["name", "description"], + "properties": { + "name": { + "title": "Name", + "type": "string", + "description": "Unique name of the component" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Description of the component" + } + } + } + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1beta2"] + }, + "kind": { + "enum": ["Template"] + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.", + "examples": ["React SSR Template"], + "minLength": 1 + } + } + }, + "spec": { + "type": "object", + "required": ["type", "steps"], + "properties": { + "type": { + "type": "string", + "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "examples": ["service", "website", "library"], + "minLength": 1 + }, + "parameters": { + "oneOf": [ + { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + }, + { + "type": "array", + "description": "A list of separate forms to collect parameters.", + "items": { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + } + } + ] + }, + "steps": { + "type": "array", + "description": "A list of steps to execute.", + "items": { + "type": "object", + "description": "The JSONSchema describing a template The ID of the step, which can be used to refer to its outputs.", + "required": ["id", "name", "action"], + "properties": { + "id": { + "type": "string", + "description": "The ID of the step, which can be used to refer to its outputs." + }, + "name": { + "type": "string", + "description": "The name of the step, which will be displayed in the UI during the scaffolding process." + }, + "action": { + "type": "string", + "description": "The name of the action to execute." + }, + "parameters": { + "type": "object", + "description": "A templated object describing the inputs to the action." + } + } + } + }, + "output": { + "type": "object", + "description": "A templated object describing the outputs of the scaffolding task.", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + ] +} diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index ef94c54010..26a8538707 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -45,6 +45,7 @@ import { SystemEntity, systemEntityV1alpha1Validator, templateEntityV1alpha1Validator, + templateEntityV1beta2Validator, UserEntity, userEntityV1alpha1Validator, } from '@backstage/catalog-model'; @@ -59,6 +60,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { groupEntityV1alpha1Validator, locationEntityV1alpha1Validator, templateEntityV1alpha1Validator, + templateEntityV1beta2Validator, userEntityV1alpha1Validator, systemEntityV1alpha1Validator, domainEntityV1alpha1Validator, From e6f2e15b561b1fe9565c8d3bbed4dcd6c9dc536e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 19 Feb 2021 17:57:41 +0100 Subject: [PATCH 07/12] scaffolder-backend: migrate to using template beta2 types --- .../src/lib/catalog/CatalogEntityClient.ts | 9 ++- .../src/scaffolder/tasks/types.ts | 2 +- .../scaffolder-backend/src/service/router.ts | 80 +++++++++++++------ 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts index 57f73b1876..46a1433be2 100644 --- a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { TemplateEntity } from '@backstage/catalog-model'; +import { + TemplateEntityV1alpha1, + TemplateEntityV1beta2, +} from '@backstage/catalog-model'; import { CatalogApi } from '@backstage/catalog-client'; import { ConflictError, NotFoundError } from '@backstage/backend-common'; @@ -32,7 +35,7 @@ export class CatalogEntityClient { async findTemplate( templateName: string, options?: { token?: string }, - ): Promise { + ): Promise { const { items: templates } = (await this.catalogClient.getEntities( { filter: { @@ -41,7 +44,7 @@ export class CatalogEntityClient { }, }, options, - )) as { items: TemplateEntity[] }; + )) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] }; if (templates.length !== 1) { if (templates.length > 1) { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 7fc35d9e46..27a1b62d04 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -48,7 +48,7 @@ export type TaskSpec = { id: string; name: string; action: string; - parameters?: { [name: string]: JsonValue }; + parameters?: JsonObject; }>; output: { [name: string]: string }; }; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2cee30fc87..21d9efc641 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -50,6 +50,11 @@ import { PluginDatabaseManager, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; +import { + TemplateEntityV1alpha1, + TemplateEntityV1beta2, + Entity, +} from '@backstage/catalog-model'; export interface RouterOptions { preparers: PreparerBuilder; @@ -63,6 +68,21 @@ export interface RouterOptions { catalogClient: CatalogApi; } +function isAlpha1Template( + entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, +): entity is TemplateEntityV1alpha1 { + return ( + entity.apiVersion === 'backstage.io/v1alpha1' || + entity.apiVersion === 'backstage.io/v1beta1' + ); +} + +function isBeta2Template( + entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2, +): entity is TemplateEntityV1beta2 { + return entity.apiVersion === 'backstage.io/v1beta2'; +} + export async function createRouter( options: RouterOptions, ): Promise { @@ -144,6 +164,11 @@ export async function createRouter( const template = await entityClient.findTemplate(templateName, { token: getBearerToken(req.headers.authorization), }); + if (!isAlpha1Template(template)) { + throw new InputError( + `This endpoint does not support templates with version ${template.apiVersion}`, + ); + } const validationResult: ValidatorResult = validate( values, @@ -249,18 +274,16 @@ export async function createRouter( } const template = await entityClient.findTemplate(name); - if (template.apiVersion === 'backstage.io/v1beta1') { - const betaTemplate = template as any; // TODO: proper type - const parameters = betaTemplate.spec.schema; - const steps = Array.isArray(parameters) ? parameters : [parameters]; + if (isBeta2Template(template)) { + const parameters = [template.spec.parameters ?? []].flat(); res.json({ title: template.metadata.title ?? template.metadata.name, - steps: steps.map(schema => ({ + steps: parameters.map(schema => ({ title: schema.title ?? 'Fill in template parameters', schema, })), }); - } else if (template.apiVersion === 'backstage.io/v1alpha1') { + } else if (isAlpha1Template(template)) { res.json({ title: template.metadata.title ?? template.metadata.name, steps: [ @@ -298,7 +321,9 @@ export async function createRouter( }); } else { throw new InputError( - `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, ); } }, @@ -313,31 +338,36 @@ export async function createRouter( }; const template = await entityClient.findTemplate(templateName); - const validationResult: ValidatorResult = validate( - values, - template.spec.schema, - ); - - if (!validationResult.valid) { - res.status(400).json({ errors: validationResult.errors }); - return; - } - let taskSpec; - if (template.apiVersion === 'backstage.io/v1alpha1') { + if (isAlpha1Template(template)) { + const result = validate(values, template.spec.schema); + + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + taskSpec = templateEntityToSpec(template, values); - } else if (template.apiVersion === 'backstage.io/v1beta1') { - // TODO: add v1beta1 type - // const betaTemplate = template as TemplateEntityV1beta1 - const betaTemplate = template as any; + } else if (isBeta2Template(template)) { + for (const parameters of [template.spec.parameters ?? []].flat()) { + const result = validate(values, parameters); + + if (!result.valid) { + res.status(400).json({ errors: result.errors }); + return; + } + } + taskSpec = { values, - steps: betaTemplate.spec.steps, - output: betaTemplate.spec.output, + steps: template.spec.steps, + output: template.spec.output ?? {}, }; } else { throw new InputError( - `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, + `Unsupported apiVersion field in schema entity, ${ + (template as Entity).apiVersion + }`, ); } From 12d8f27a6cf41cd6da0e041f9cc7899813e0586c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 23 Feb 2021 15:15:48 +0100 Subject: [PATCH 08/12] added changesets --- .changeset/brown-hotels-study.md | 6 ++++++ .changeset/fluffy-elephants-suffer.md | 7 +++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/brown-hotels-study.md create mode 100644 .changeset/fluffy-elephants-suffer.md diff --git a/.changeset/brown-hotels-study.md b/.changeset/brown-hotels-study.md new file mode 100644 index 0000000000..2b9e6a86b8 --- /dev/null +++ b/.changeset/brown-hotels-study.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Move logic for constructing the template form to the backend, using a new `./parameter-schema` endpoint that returns the form schema for a given template. diff --git a/.changeset/fluffy-elephants-suffer.md b/.changeset/fluffy-elephants-suffer.md new file mode 100644 index 0000000000..cd118829ed --- /dev/null +++ b/.changeset/fluffy-elephants-suffer.md @@ -0,0 +1,7 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Add version `backstage.io/v1beta2` schema for Template entities. From 6ff4403801453bd7384c67b23cbb111994fc5215 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 23 Feb 2021 23:40:29 +0100 Subject: [PATCH 09/12] catalog-model: tweak Template beta2 schema + add tests --- .../src/kinds/TemplateEntityV1beta2.test.ts | 109 ++++++++++++++++++ .../src/kinds/TemplateEntityV1beta2.ts | 5 +- .../schema/kinds/Template.v1beta2.schema.json | 25 +++- 3 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts new file mode 100644 index 0000000000..ee064360c4 --- /dev/null +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 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 { + TemplateEntityV1beta2, + templateEntityV1beta2Validator as validator, +} from './TemplateEntityV1beta2'; + +describe('templateEntityV1beta2Validator', () => { + let entity: TemplateEntityV1beta2; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1beta2', + kind: 'Template', + metadata: { + name: 'test', + }, + spec: { + type: 'website', + parameters: { + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: 'GitHub store path in org/repo format', + }, + }, + }, + steps: [ + { + id: 'fetch', + name: 'Fetch', + action: 'fetch:plan', + parameters: { + url: './template', + }, + }, + ], + output: { + fetchUrl: '{{ steps.fetch.output.targetUrl }}', + }, + }, + }; + }); + + it('happy path: accepts valid data', async () => { + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(validator.check(entity)).resolves.toBe(false); + }); + + it('rejects missing type', async () => { + delete (entity as any).spec.type; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('accepts any other type', async () => { + (entity as any).spec.type = 'hallo'; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts missing parameters', async () => { + delete (entity as any).spec.parameters; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts missing outputs', async () => { + delete (entity as any).spec.outputs; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + await expect(validator.check(entity)).rejects.toThrow(/type/); + }); + + it('rejects missing steps', async () => { + delete (entity as any).spec.steps; + await expect(validator.check(entity)).rejects.toThrow(/steps/); + }); +}); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index ce5080f25e..3de280b632 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Entity } from '../entity/Entity'; +import type { Entity, EntityMeta } from '../entity/Entity'; import schema from '../schema/kinds/Template.v1beta2.schema.json'; import entitySchema from '../schema/Entity.schema.json'; import entityMetaSchema from '../schema/EntityMeta.schema.json'; @@ -28,6 +28,9 @@ const KIND = 'Template' as const; export interface TemplateEntityV1beta2 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; + metadata: EntityMeta & { + title?: string; + }; spec: { type: string; parameters?: JsonObject | JsonObject[]; 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 f1c502ea5e..1209136338 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -29,6 +29,27 @@ "description": "Description of the component" } } + }, + "steps": [ + { + "id": "fetch", + "name": "Fetch", + "action": "fetch:plain", + "parameters": { + "url": "./template" + } + }, + { + "id": "publish", + "name": "Publish to GitHub", + "action": "publish:github", + "parameters": { + "repoUrl": "{{ parameters.repoUrl }}" + } + } + ], + "output": { + "catalogInfoUrl": "{{ steps.publish.output.catalogInfoUrl }}" } } } @@ -52,7 +73,7 @@ "properties": { "title": { "type": "string", - "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.", + "description": "The nice display name for the template.", "examples": ["React SSR Template"], "minLength": 1 } @@ -89,7 +110,7 @@ "description": "A list of steps to execute.", "items": { "type": "object", - "description": "The JSONSchema describing a template The ID of the step, which can be used to refer to its outputs.", + "description": "A description of the step to execute.", "required": ["id", "name", "action"], "properties": { "id": { From 1719d05cbc5a62383728cf75d4e34aecfbcdefa7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 23 Feb 2021 23:40:43 +0100 Subject: [PATCH 10/12] scaffolder: review tweaks --- plugins/scaffolder-backend/src/service/router.ts | 2 +- .../src/components/MultistepJsonForm/MultistepJsonForm.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 21d9efc641..9d6f40bef0 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -269,7 +269,7 @@ export async function createRouter( } if (kind.toLowerCase() !== 'template') { throw new InputError( - `Invalid kind, only 'template' kind is supported`, + `Invalid kind, only 'Template' kind is supported`, ); } diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 71084a1eda..eb51e86d59 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -74,7 +74,6 @@ export const MultistepJsonForm = ({
Date: Tue, 23 Feb 2021 23:53:23 +0100 Subject: [PATCH 11/12] catalog-model: make Template beta2 step id and name optional --- .../src/kinds/TemplateEntityV1beta2.test.ts | 15 +++++++++++++++ .../src/kinds/TemplateEntityV1beta2.ts | 4 ++-- .../src/schema/kinds/Template.v1beta2.schema.json | 2 +- plugins/scaffolder-backend/src/service/router.ts | 6 +++++- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index ee064360c4..60f3de9e59 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -106,4 +106,19 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps; await expect(validator.check(entity)).rejects.toThrow(/steps/); }); + + it('accepts step with missing id', async () => { + delete (entity as any).spec.steps[0].id; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('accepts step with missing name', async () => { + delete (entity as any).spec.steps[0].name; + await expect(validator.check(entity)).resolves.toBe(true); + }); + + it('rejects step with missing action', async () => { + delete (entity as any).spec.steps[0].action; + await expect(validator.check(entity)).rejects.toThrow(/action/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 3de280b632..c78e7f85d5 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -35,8 +35,8 @@ export interface TemplateEntityV1beta2 extends Entity { type: string; parameters?: JsonObject | JsonObject[]; steps: Array<{ - id: string; - name: string; + id?: string; + name?: string; action: string; parameters?: JsonObject; }>; 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 1209136338..bd9999f8bd 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -111,7 +111,7 @@ "items": { "type": "object", "description": "A description of the step to execute.", - "required": ["id", "name", "action"], + "required": ["action"], "properties": { "id": { "type": "string", diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 9d6f40bef0..65b9039e27 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -360,7 +360,11 @@ export async function createRouter( taskSpec = { values, - steps: template.spec.steps, + steps: template.spec.steps.map((step, index) => ({ + ...step, + id: step.id ?? `step-${index + 1}`, + name: step.name ?? step.action, + })), output: template.spec.output ?? {}, }; } else { From b38be1083c7f5faab00f36d6287a466e14f91ebb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 24 Feb 2021 10:11:40 +0100 Subject: [PATCH 12/12] scaffolder: fix TemplatePage tests --- .../TemplatePage/TemplatePage.test.tsx | 65 +++++-------------- 1 file changed, 16 insertions(+), 49 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index f0c77f2f03..1d66e07c6a 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; @@ -25,45 +24,6 @@ import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; -const templateMock = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/something/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - tags: ['Recommended', 'React'], - uid: '55efc748-4a2b-460f-9e47-3f4fd23b46f7', - etag: 'MTM3YThjY2QtYTc1MS00MTFkLTk3YTAtNzgyMDg3MDVmZTVm', - generation: 1, - }, - spec: { - processor: 'cookiecutter', - type: 'website', - path: '.', - schema: { - required: ['component_id', 'description'], - properties: { - component_id: { - title: 'Name', - type: 'string', - description: 'Unique name of the component', - }, - description: { - title: 'Description', - type: 'string', - description: 'Description of the component', - }, - }, - }, - }, -}; - jest.mock('react-router-dom', () => { return { ...(jest.requireActual('react-router-dom') as any), @@ -73,26 +33,28 @@ jest.mock('react-router-dom', () => { }; }); -const scaffolderApiMock: Partial = { +const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), + getTemplateParameterSchema: jest.fn(), + getTask: jest.fn(), + streamLogs: jest.fn(), }; -const catalogApiMock = { - getEntities: jest.fn() as jest.MockedFunction, -}; const errorApiMock = { post: jest.fn(), error$: jest.fn() }; const apis = ApiRegistry.from([ [scaffolderApiRef, scaffolderApiMock], [errorApiRef, errorApiMock], - [catalogApiRef, catalogApiMock], ]); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); it('renders correctly', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] }); + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({ + title: 'React SSR Template', + steps: [], + }); const rendered = await renderInTestApp( @@ -113,7 +75,7 @@ describe('TemplatePage', () => { const promise = new Promise(res => { resolve = res; }); - catalogApiMock.getEntities.mockReturnValueOnce(promise); + scaffolderApiMock.getTemplateParameterSchema.mockReturnValueOnce(promise); const rendered = await renderInTestApp( @@ -129,12 +91,17 @@ describe('TemplatePage', () => { expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument(); await act(async () => { - resolve!({ items: [templateMock] }); + resolve!({ + title: 'React SSR Template', + steps: [], + }); }); }); it('navigates away if no template was loaded', async () => { - catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] }); + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue( + undefined as any, + ); const rendered = await renderWithEffects(