diff --git a/.changeset/funny-dolls-draw.md b/.changeset/funny-dolls-draw.md new file mode 100644 index 0000000000..f194b30750 --- /dev/null +++ b/.changeset/funny-dolls-draw.md @@ -0,0 +1,19 @@ +--- +'@backstage/create-app': patch +--- + +The scaffolder plugin has just released the beta 3 version of software templates, which replaces the handlebars templating syntax. As part of this change, the template entity schema is no longer included in the core catalog-model as with previous versions. The decoupling of the template entities version will allow us to more easily make updates in the future. + +In order to use the new beta 3 templates, the following changes are **required** for any existing installation, inside `packages/backend/src/plugins/catalog.ts`: + +```diff ++import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend'; + +... + + const builder = await CatalogBuilder.create(env); ++ builder.addProcessor(new ScaffolderEntitiesProcessor()); + const { processingEngine, router } = await builder.build(); +``` + +If you're interested in learning more about creating custom kinds, please check out the [extending the model](https://backstage.io/docs/features/software-catalog/extending-the-model) documentation. diff --git a/docs/features/software-catalog/extending-the-model.md b/docs/features/software-catalog/extending-the-model.md index ed106bb5dd..340daae7d3 100644 --- a/docs/features/software-catalog/extending-the-model.md +++ b/docs/features/software-catalog/extending-the-model.md @@ -429,3 +429,101 @@ from one environment to the other, do rollbacks, see their relative performance metrics, and similar. This coherency and collection of tooling in one place is where something like Backstage can offer the most value and effectiveness of use. Splitting your entities apart into small islands makes this harder. + +## Implementing custom model extensions + +This section walks you through the steps involved extending the catalog model +with a new Entity type. + +### Creating a custom entity definition + +The first step of introducing a custom entity is to define what shape and schema +it has. We do this both using a TypeScript type, along with a JSONSchema schema. + +Most of the time you will want to have at least the TypeScript type of your +extension available in both frontend and backend code, which means you likely +want to have an isomorphic package that houses these types. Within the Backstage +main repo the package naming pattern of `-common` is used for isomorphic +packages, and you may choose to adopt this pattern as well. + +There's at this point no existing templates for generating isomorphic plugins +using the `@backstage/cli`. Perhaps the simplest wat to get started right now is +to copy the contents of one of the existing packages in the main repository, +such as `plugins/scaffolder-common`, and rename the folder and file contents to +the desired name. This example uses _foobar_ as the plugin name so the plugin +will be named _foobar-common_. + +Once you have a common package in place you can start adding your own entity +definitions. For the exact details on how to do that we defer to getting +inspired by the existing +[scaffolder-common](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-common/src/index.ts) +package. But in short you will need to declare a TypeScript type and a +JSONSchema for the new entity kind. + +### Building a custom processor for the entity + +The next step is to create a custom processor for your new entity kind. This +will be used within the catalog to make sure that it's able to ingest and +validate entities of our new kind. Just like with the definition package, you +can find inspiration in for example the existing +[ScaffolderEntitiesProcessor](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.ts). +We also provide a high-level example of what a catalog process for a custom +entity might look like: + +```ts +import { entityKindSchemaValidator } from '@backstage/catalog-model'; + +export class FoobarEntitiesProcessor implements CatalogProcessor { + // You often end up wanting to support multiple versions of your kind as you + // iterate on the definition, so we keep each version inside this array. + private readonly validators = [ + // This is where we use the JSONSchema that we export from our isomorphic package + entityKindSchemaValidator(foobarEntityV1alpha1Schema), + ]; + + // validateEntityKind is responsible for signaling to the catalog processing engine + // that this entity is valid and should therefore be submitted for further processing. + async validateEntityKind(entity: Entity): Promise { + for (const validator of this.validators) { + if (validator(entity)) { + return true; + } + } + + return false; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + if ( + entity.apiVersion === 'example.com/v1alpha1' && + entity.kind === 'Foobar' + ) { + const foobarEntity = entity as FoobarEntityV1alpha1; + + // Here we can modify the entity or emit results related to the entity + // Typically you will want to emit any relations associated with the entity here + emit(results.relation({ ... })) + } + + return entity; + } +} +``` + +Once the processor is created it can be wired up to the catalog via the +`CatalogBuilder` in `packages/backend/src/plugins/catalog.ts`: + +```diff ++ import { FoobarEntitiesProcessor implements CatalogProcessor { + } from '@internal/plugin-foobar-backend'; + + // ... + + const builder = await CatalogBuilder.create(env); ++ builder.addProcessor(new FoobarEntitiesProcessor()); + const { processingEngine, router } = await builder.build(); +``` diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 6a903d0df8..7beeb4f35e 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -15,6 +15,7 @@ */ import { CatalogBuilder } from '@backstage/plugin-catalog-backend'; +import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -22,6 +23,7 @@ export default async function createPlugin( env: PluginEnvironment, ): Promise { const builder = await CatalogBuilder.create(env); + builder.addProcessor(new ScaffolderEntitiesProcessor()); const { processingEngine, router } = await builder.build(); await processingEngine.start(); return router; diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index d1ded511da..876cb6bccc 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -1,4 +1,5 @@ import { CatalogBuilder } from '@backstage/plugin-catalog-backend'; +import { ScaffolderEntitiesProcessor } from '@backstage/plugin-scaffolder-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -6,6 +7,7 @@ export default async function createPlugin( env: PluginEnvironment, ): Promise { const builder = await CatalogBuilder.create(env); + builder.addProcessor(new ScaffolderEntitiesProcessor()); const { processingEngine, router } = await builder.build(); await processingEngine.start(); return router; diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 4a8c103906..ef4defcc50 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -6,13 +6,17 @@ /// import { CatalogApi } from '@backstage/catalog-client'; +import { CatalogProcessor } from '@backstage/plugin-catalog-backend'; +import { CatalogProcessorEmit } from '@backstage/plugin-catalog-backend'; import { Config } from '@backstage/config'; import { ContainerRunner } from '@backstage/backend-common'; import { createFetchCookiecutterAction } from '@backstage/plugin-scaffolder-backend-module-cookiecutter'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import { Entity } from '@backstage/catalog-model'; import express from 'express'; import { JsonObject } from '@backstage/config'; import { JsonValue } from '@backstage/config'; +import { LocationSpec } from '@backstage/catalog-model'; import { Logger as Logger_2 } from 'winston'; import { Octokit } from '@octokit/rest'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -244,6 +248,18 @@ export const runCommand: ({ logStream, }: RunCommandOptions) => Promise; +// @public (undocumented) +export class ScaffolderEntitiesProcessor implements CatalogProcessor { + // (undocumented) + postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise; + // (undocumented) + validateEntityKind(entity: Entity): Promise; +} + // Warning: (ae-missing-release-tag) "TemplateAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ffe8a1e1b8..98fd053771 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -36,6 +36,8 @@ "@backstage/config": "^0.1.10", "@backstage/errors": "^0.1.2", "@backstage/integration": "^0.6.7", + "@backstage/plugin-catalog-backend": "^0.16.0", + "@backstage/plugin-scaffolder-common": "^0.1.0", "@backstage/plugin-scaffolder-backend-module-cookiecutter": "^0.1.2", "@gitbeaker/core": "^30.2.0", "@gitbeaker/node": "^30.2.0", diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts index a7ae821463..76cda63eef 100644 --- a/plugins/scaffolder-backend/src/index.ts +++ b/plugins/scaffolder-backend/src/index.ts @@ -23,3 +23,4 @@ export * from './scaffolder'; export * from './service/router'; export * from './lib/catalog'; +export * from './processor'; diff --git a/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.test.ts b/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.test.ts new file mode 100644 index 0000000000..c5278edce3 --- /dev/null +++ b/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; +import { ScaffolderEntitiesProcessor } from './ScaffolderEntitiesProcessor'; + +const mockLocation = { type: 'a', target: 'b' }; +const mockEntity: TemplateEntityV1beta3 = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { name: 'n' }, + spec: { + parameters: {}, + steps: [], + type: 'service', + owner: 'o', + }, +}; + +describe('ScaffolderEntitiesProcessor', () => { + describe('validateEntityKind', () => { + it('validates the entity kind', async () => { + const processor = new ScaffolderEntitiesProcessor(); + + await expect(processor.validateEntityKind(mockEntity)).resolves.toBe( + true, + ); + await expect( + processor.validateEntityKind({ + ...mockEntity, + apiVersion: 'backstage.io/v1beta3', + }), + ).resolves.toBe(false); + await expect( + processor.validateEntityKind({ ...mockEntity, kind: 'Component' }), + ).resolves.toBe(false); + }); + }); + + describe('postProcessEntity', () => { + it('generates relations for component entities', async () => { + const processor = new ScaffolderEntitiesProcessor(); + + const emit = jest.fn(); + + await processor.postProcessEntity(mockEntity, mockLocation, 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' }, + }, + }); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.ts b/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.ts new file mode 100644 index 0000000000..4f3c13f342 --- /dev/null +++ b/plugins/scaffolder-backend/src/processor/ScaffolderEntitiesProcessor.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 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 { + Entity, + getEntityName, + LocationSpec, + parseEntityRef, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + entityKindSchemaValidator, +} from '@backstage/catalog-model'; +import { + CatalogProcessor, + CatalogProcessorEmit, + results, +} from '@backstage/plugin-catalog-backend'; +import { + TemplateEntityV1beta3, + templateEntityV1beta3Schema, +} from '@backstage/plugin-scaffolder-common'; + +/** @public */ +export class ScaffolderEntitiesProcessor implements CatalogProcessor { + private readonly validators = [ + entityKindSchemaValidator(templateEntityV1beta3Schema), + ]; + + async validateEntityKind(entity: Entity): Promise { + for (const validator of this.validators) { + if (validator(entity)) { + return true; + } + } + + return false; + } + + async postProcessEntity( + entity: Entity, + _location: LocationSpec, + emit: CatalogProcessorEmit, + ): Promise { + const selfRef = getEntityName(entity); + + if ( + entity.apiVersion === 'scaffolder.backstage.io/v1beta3' && + entity.kind === 'Template' + ) { + const template = entity as TemplateEntityV1beta3; + + const target = template.spec.owner; + if (target) { + const targetRef = parseEntityRef(target, { + defaultKind: 'Group', + defaultNamespace: selfRef.namespace, + }); + emit( + results.relation({ + source: selfRef, + type: RELATION_OWNED_BY, + target: { + kind: targetRef.kind, + namespace: targetRef.namespace, + name: targetRef.name, + }, + }), + ); + emit( + results.relation({ + source: { + kind: targetRef.kind, + namespace: targetRef.namespace, + name: targetRef.name, + }, + type: RELATION_OWNER_OF, + target: selfRef, + }), + ); + } + } + + return entity; + } +} diff --git a/plugins/scaffolder-backend/src/processor/index.ts b/plugins/scaffolder-backend/src/processor/index.ts new file mode 100644 index 0000000000..518827668d --- /dev/null +++ b/plugins/scaffolder-backend/src/processor/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { ScaffolderEntitiesProcessor } from './ScaffolderEntitiesProcessor'; diff --git a/plugins/scaffolder-common/.eslintrc.js b/plugins/scaffolder-common/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/scaffolder-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/scaffolder-common/README.md b/plugins/scaffolder-common/README.md new file mode 100644 index 0000000000..9217ceab86 --- /dev/null +++ b/plugins/scaffolder-common/README.md @@ -0,0 +1,3 @@ +# @backstage/plugin-scaffolder-common + +Common types and functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend. diff --git a/plugins/scaffolder-common/api-report.md b/plugins/scaffolder-common/api-report.md new file mode 100644 index 0000000000..722e746967 --- /dev/null +++ b/plugins/scaffolder-common/api-report.md @@ -0,0 +1,36 @@ +## API Report File for "@backstage/plugin-scaffolder-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Entity } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema } from '@backstage/catalog-model'; + +// @public (undocumented) +export interface TemplateEntityV1beta3 extends Entity { + // (undocumented) + apiVersion: 'scaffolder.backstage.io/v1beta3'; + // (undocumented) + kind: 'Template'; + // (undocumented) + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + input?: JsonObject; + if?: string | boolean; + }>; + output?: { + [name: string]: string; + }; + owner?: string; + }; +} + +// @public (undocumented) +export const templateEntityV1beta3Schema: JSONSchema; +``` diff --git a/plugins/scaffolder-common/package.json b/plugins/scaffolder-common/package.json new file mode 100644 index 0000000000..7f79dab541 --- /dev/null +++ b/plugins/scaffolder-common/package.json @@ -0,0 +1,45 @@ +{ + "name": "@backstage/plugin-scaffolder-common", + "description": "Common functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend plugin", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/scaffolder-common" + }, + "keywords": [ + "scaffolder" + ], + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@backstage/catalog-model": "^0.9.4", + "@backstage/config": "^0.1.10" + }, + "devDependencies": { + "@backstage/cli": "^0.7.15" + } +} diff --git a/plugins/scaffolder-common/src/Template.v1beta3.schema.json b/plugins/scaffolder-common/src/Template.v1beta3.schema.json new file mode 100644 index 0000000000..e45bfb788d --- /dev/null +++ b/plugins/scaffolder-common/src/Template.v1beta3.schema.json @@ -0,0 +1,186 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "TemplateV1beta3", + "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": "scaffolder.backstage.io/v1beta3", + "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", + "parameters": { + "required": ["name", "description", "repoUrl"], + "properties": { + "name": { + "title": "Name", + "type": "string", + "description": "Unique name of the component" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Description of the component" + }, + "repoUrl": { + "title": "Pick a repository", + "type": "string", + "ui:field": "RepoUrlPicker" + } + } + }, + "steps": [ + { + "id": "fetch", + "name": "Fetch", + "action": "fetch:plain", + "parameters": { + "url": "./template" + } + }, + { + "id": "publish", + "name": "Publish to GitHub", + "action": "publish:github", + "parameters": { + "repoUrl": "${{ parameters.repoUrl }}" + }, + "if": "${{ parameters.repoUrl }}" + } + ], + "output": { + "catalogInfoUrl": "${{ steps.publish.output.catalogInfoUrl }}" + } + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["scaffolder.backstage.io/v1beta3"] + }, + "kind": { + "enum": ["Template"] + }, + "spec": { + "type": "object", + "required": ["type", "steps"], + "properties": { + "type": { + "type": "string", + "description": "The type of component created by the template. 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 + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "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": "A description of the step to execute.", + "required": ["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." + }, + "input": { + "type": "object", + "description": "A templated object describing the inputs to the action." + }, + "if": { + "type": ["string", "boolean"], + "description": "A templated condition that skips the step when evaluated to false. If the condition is true or not defined, the step is executed. The condition is true, if the input is not `false`, `undefined`, `null`, `\"\"`, `0`, or `[]`." + } + } + } + }, + "output": { + "type": "object", + "description": "A templated object describing the outputs of the scaffolding task.", + "properties": { + "links": { + "type": "array", + "description": "A list of external hyperlinks, typically pointing to resources created or updated by the template", + "items": { + "type": "object", + "required": [], + "properties": { + "url": { + "type": "string", + "description": "A url in a standard uri format.", + "examples": ["https://github.com/my-org/my-new-repo"], + "minLength": 1 + }, + "entityRef": { + "type": "string", + "description": "An entity reference to an entity in the catalog.", + "examples": ["Component:default/my-app"], + "minLength": 1 + }, + "title": { + "type": "string", + "description": "A user friendly display name for the link.", + "examples": ["View new repo"], + "minLength": 1 + }, + "icon": { + "type": "string", + "description": "A key representing a visual icon to be displayed in the UI.", + "examples": ["dashboard"], + "minLength": 1 + } + } + } + } + }, + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + ] +} diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts new file mode 100644 index 0000000000..ab5900ab32 --- /dev/null +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2020 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 { entityKindSchemaValidator } from '@backstage/catalog-model'; +import type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3'; +import schema from './Template.v1beta3.schema.json'; + +const validator = entityKindSchemaValidator(schema); + +describe('templateEntityV1beta3Validator', () => { + let entity: TemplateEntityV1beta3; + + beforeEach(() => { + entity = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'test', + }, + spec: { + type: 'website', + owner: 'team-b', + parameters: { + required: ['owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + }, + }, + steps: [ + { + id: 'fetch', + name: 'Fetch', + action: 'fetch:plan', + input: { + url: './template', + }, + if: '${{ parameters.owner }}', + }, + ], + output: { + fetchUrl: '${{ steps.fetch.output.targetUrl }}', + }, + }, + }; + }); + + it('happy path: accepts valid data', async () => { + expect(validator(entity)).toBe(entity); + }); + + it('ignores unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + expect(validator(entity)).toBe(false); + }); + + it('ignores unknown kind', async () => { + (entity as any).kind = 'Wizard'; + expect(validator(entity)).toBe(false); + }); + + it('rejects missing type', async () => { + delete (entity as any).spec.type; + expect(() => validator(entity)).toThrow(/type/); + }); + + it('accepts any other type', async () => { + (entity as any).spec.type = 'hallo'; + expect(validator(entity)).toBe(entity); + }); + + it('accepts missing parameters', async () => { + delete (entity as any).spec.parameters; + expect(validator(entity)).toBe(entity); + }); + + it('accepts missing outputs', async () => { + delete (entity as any).spec.outputs; + expect(validator(entity)).toBe(entity); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + expect(() => validator(entity)).toThrow(/type/); + }); + + it('rejects missing steps', async () => { + delete (entity as any).spec.steps; + expect(() => validator(entity)).toThrow(/steps/); + }); + + it('accepts step with missing id', async () => { + delete (entity as any).spec.steps[0].id; + expect(validator(entity)).toBe(entity); + }); + + it('accepts step with missing name', async () => { + delete (entity as any).spec.steps[0].name; + expect(validator(entity)).toBe(entity); + }); + + it('rejects step with missing action', async () => { + delete (entity as any).spec.steps[0].action; + expect(() => validator(entity)).toThrow(/action/); + }); + + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + expect(validator(entity)).toBe(entity); + }); + + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + expect(() => validator(entity)).toThrow(/owner/); + }); + + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + expect(() => validator(entity)).toThrow(/owner/); + }); + + it('accepts missing if', async () => { + delete (entity as any).spec.steps[0].if; + expect(validator(entity)).toBe(entity); + }); + + it('accepts boolean in if', async () => { + (entity as any).spec.steps[0].if = true; + expect(validator(entity)).toBe(entity); + }); + + it('accepts empty if', async () => { + (entity as any).spec.steps[0].if = ''; + expect(validator(entity)).toBe(entity); + }); + + it('rejects wrong type if', async () => { + (entity as any).spec.steps[0].if = 5; + expect(() => validator(entity)).toThrow(/if/); + }); +}); diff --git a/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts new file mode 100644 index 0000000000..6471fe0ca6 --- /dev/null +++ b/plugins/scaffolder-common/src/TemplateEntityV1beta3.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 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 { JsonObject } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; + +/** @public */ +export interface TemplateEntityV1beta3 extends Entity { + apiVersion: 'scaffolder.backstage.io/v1beta3'; + kind: 'Template'; + spec: { + type: string; + parameters?: JsonObject | JsonObject[]; + steps: Array<{ + id?: string; + name?: string; + action: string; + input?: JsonObject; + if?: string | boolean; + }>; + output?: { [name: string]: string }; + owner?: string; + }; +} diff --git a/plugins/scaffolder-common/src/index.ts b/plugins/scaffolder-common/src/index.ts new file mode 100644 index 0000000000..3b6fa71d7a --- /dev/null +++ b/plugins/scaffolder-common/src/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 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. + */ + +/** + * Common functionalities for the scaffolder, to be shared between scaffolder and scaffolder-backend plugin + * + * @packageDocumentation + */ + +import { JSONSchema } from '@backstage/catalog-model'; +import v1beta3Schema from './Template.v1beta3.schema.json'; + +export type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3'; + +/** @public */ +export const templateEntityV1beta3Schema: JSONSchema = v1beta3Schema as Omit< + JSONSchema, + 'examples' +>;