diff --git a/.changeset/weak-otters-love.md b/.changeset/weak-otters-love.md new file mode 100644 index 0000000000..2c273e067b --- /dev/null +++ b/.changeset/weak-otters-love.md @@ -0,0 +1,16 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/plugin-scaffolder-backend': minor +--- + +`createRouter` of scaffolder backend will now require additional option as `entityClient` which could be generated by `CatalogEntityClient` in `plugin-scaffolder-backend` package. Here is example to generate `entityClient`. + +```js +import { CatalogEntityClient } from '@backstage/plugin-scaffolder-backend'; +import { SingleHostDiscovery } from '@backstage/backend-common'; + +const discovery = SingleHostDiscovery.fromConfig(config); +const entityClient = new CatalogEntityClient({ discovery }); +``` + +- Scaffolder's API `/v1/jobs` will accept `templateName` instead of `template` Entity. diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 6eacd143c1..5d36d508a5 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -21,7 +21,9 @@ import { Publishers, CreateReactAppTemplater, Templaters, + CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; @@ -40,6 +42,9 @@ export default async function createPlugin({ const dockerClient = new Docker(); + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + return await createRouter({ preparers, templaters, @@ -47,5 +52,6 @@ export default async function createPlugin({ logger, config, dockerClient, + entityClient, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index 8de15920e9..2dc69feb45 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -11,7 +11,9 @@ import { CreateReactAppTemplater, Templaters, RepoVisibilityOptions, + CatalogEntityClient, } from '@backstage/plugin-scaffolder-backend'; +import { SingleHostDiscovery } from '@backstage/backend-common'; import { Octokit } from '@octokit/rest'; import { Gitlab } from '@gitbeaker/node'; import type { PluginEnvironment } from '../types'; @@ -97,6 +99,10 @@ export default async function createPlugin({ } const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const entityClient = new CatalogEntityClient({ discovery }); + return await createRouter({ preparers, templaters, @@ -104,5 +110,6 @@ export default async function createPlugin({ logger, config, dockerClient, + entityClient, }); } diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 09cacb28eb..a245d2ed24 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -44,7 +44,8 @@ "nodegit": "0.27.0", "uuid": "^8.2.0", "winston": "^3.2.1", - "yaml": "^1.10.0" + "yaml": "^1.10.0", + "cross-fetch": "^3.0.6" }, "devDependencies": { "@backstage/cli": "^0.2.0", diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index c461bfede6..28e6a2b24c 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -16,3 +16,4 @@ export * from './scaffolder'; export * from './service/router'; +export * from './lib/catalog'; diff --git a/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts new file mode 100644 index 0000000000..4541f03a4a --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/catalog/CatalogEntityClient.ts @@ -0,0 +1,72 @@ +/* + * 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 fetch from 'cross-fetch'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + ConflictError, + NotFoundError, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; + +/** + * A catalog client tailored for reading out entity data from the catalog. + */ +export class CatalogEntityClient { + private readonly discovery: PluginEndpointDiscovery; + + constructor(options: { discovery: PluginEndpointDiscovery }) { + this.discovery = options.discovery; + } + + /** + * Looks up a single template using a template name. + * + * Throws a NotFoundError or ConflictError if 0 or multiple templates are found. + */ + async findTemplate(templateName: string): Promise { + const conditions = [ + 'kind=template', + `metadata.name=${encodeURIComponent(templateName)}`, + ]; + + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const response = await fetch( + `${baseUrl}/entities?filter=${conditions.join(',')}`, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Request failed with ${response.status} ${response.statusText}, ${text}`, + ); + } + + const templates: TemplateEntityV1alpha1[] = await response.json(); + + if (templates.length !== 1) { + if (templates.length > 1) { + throw new ConflictError( + 'Templates lookup resulted in multiple matches', + ); + } else { + throw new NotFoundError('Template not found'); + } + } + + return templates[0]; + } +} diff --git a/plugins/scaffolder-backend/src/lib/catalog/index.ts b/plugins/scaffolder-backend/src/lib/catalog/index.ts new file mode 100644 index 0000000000..a8de546e00 --- /dev/null +++ b/plugins/scaffolder-backend/src/lib/catalog/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { CatalogEntityClient } from './CatalogEntityClient'; diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index fae7ee8beb..c2b60a2040 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -35,6 +35,10 @@ import Docker from 'dockerode'; jest.mock('dockerode'); +const generateEntityClient: any = (template: any) => ({ + findTemplate: () => Promise.resolve(template), +}); + describe('createRouter - working directory', () => { const mockPrepare = jest.fn(); const mockPreparers = new Preparers(); @@ -74,6 +78,8 @@ describe('createRouter - working directory', () => { }, }; + const mockedEntityClient = generateEntityClient(template); + it('should throw an error when working directory does not exist or is not writable', async () => { mockAccess.mockImplementation(() => { throw new Error('access error'); @@ -87,6 +93,7 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: ConfigReader.fromConfigs([workDirConfig('/path')]), dockerClient: new Docker(), + entityClient: mockedEntityClient, }), ).rejects.toThrow('access error'); }); @@ -99,11 +106,12 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: ConfigReader.fromConfigs([workDirConfig('/path')]), dockerClient: new Docker(), + entityClient: mockedEntityClient, }); const app = express().use(router); await request(app).post('/v1/jobs').send({ - template, + templateName: '', values: {}, }); @@ -121,11 +129,12 @@ describe('createRouter - working directory', () => { publishers: new Publishers(), config: ConfigReader.fromConfigs([]), dockerClient: new Docker(), + entityClient: mockedEntityClient, }); const app = express().use(router); await request(app).post('/v1/jobs').send({ - template, + templateName: '', values: {}, }); @@ -137,6 +146,43 @@ describe('createRouter - working directory', () => { describe('createRouter', () => { let app: express.Express; + const template = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + description: 'Create a new CRA website project', + name: 'create-react-app-template', + tags: ['experimental', 'react', 'cra'], + title: 'Create React App Template', + }, + spec: { + owner: 'web@example.com', + path: '.', + schema: { + properties: { + component_id: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + }, + description: { + description: 'Description of the component', + title: 'Description', + type: 'string', + }, + use_typescript: { + default: true, + description: 'Include typescript', + title: 'Use Typescript', + type: 'boolean', + }, + }, + required: ['component_id', 'use_typescript'], + }, + templater: 'cra', + type: 'website', + }, + }; beforeAll(async () => { const router = await createRouter({ @@ -146,6 +192,7 @@ describe('createRouter', () => { publishers: new Publishers(), config: ConfigReader.fromConfigs([]), dockerClient: new Docker(), + entityClient: generateEntityClient(template), }); app = express().use(router); }); @@ -155,47 +202,9 @@ describe('createRouter', () => { }); describe('POST /v1/jobs', () => { - const template = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - description: 'Create a new CRA website project', - name: 'create-react-app-template', - tags: ['experimental', 'react', 'cra'], - title: 'Create React App Template', - }, - spec: { - owner: 'web@example.com', - path: '.', - schema: { - properties: { - component_id: { - description: 'Unique name of the component', - title: 'Name', - type: 'string', - }, - description: { - description: 'Description of the component', - title: 'Description', - type: 'string', - }, - use_typescript: { - default: true, - description: 'Include typescript', - title: 'Use Typescript', - type: 'boolean', - }, - }, - required: ['component_id', 'use_typescript'], - }, - templater: 'cra', - type: 'website', - }, - }; - it('rejects template values which do not match the template schema definition', async () => { const response = await request(app).post('/v1/jobs').send({ - template, + templateName: '', values: {}, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index fb40489622..2dc6a794fb 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Config, JsonValue } from '@backstage/config'; import fs from 'fs-extra'; import Docker from 'dockerode'; @@ -29,6 +28,7 @@ import { TemplaterBuilder, PublisherBuilder, } from '../scaffolder'; +import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; export interface RouterOptions { @@ -39,6 +39,7 @@ export interface RouterOptions { logger: Logger; config: Config; dockerClient: Docker; + entityClient: CatalogEntityClient; } export async function createRouter( @@ -54,6 +55,7 @@ export async function createRouter( logger: parentLogger, config, dockerClient, + entityClient, } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); @@ -104,14 +106,17 @@ export async function createRouter( }); }) .post('/v1/jobs', async (req, res) => { - const template: TemplateEntityV1alpha1 = req.body.template; + const templateName: string = req.body.templateName; const values: RequiredTemplateValues & Record = req.body.values; + 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; diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index f58c8e68d1..ca52418e92 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -15,7 +15,6 @@ */ import { createApiRef, DiscoveryApi } from '@backstage/core'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', @@ -33,20 +32,17 @@ export class ScaffolderApi { * Executes the scaffolding of a component, given a template and its * parameter values. * - * @param template Template entity for the scaffolder to use. New project is going to be created out of this template. + * @param templateName Template name for the scaffolder to use. New project is going to be created out of this template. * @param values Parameters for the template, e.g. name, description */ - async scaffold( - template: TemplateEntityV1alpha1, - values: Record, - ) { + async scaffold(templateName: string, values: Record) { const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ template, values: { ...values } }), + body: JSON.stringify({ templateName, values: { ...values } }), }); if (response.status !== 201) { diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index f99df26264..cb6b38903e 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -96,7 +96,7 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const job = await scaffolderApi.scaffold(template!, formState); + const job = await scaffolderApi.scaffold(templateName, formState); setJobId(job); } catch (e) { errorApi.post(e);