diff --git a/.changeset/afraid-trees-stare.md b/.changeset/afraid-trees-stare.md new file mode 100644 index 0000000000..748bbb3cfe --- /dev/null +++ b/.changeset/afraid-trees-stare.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added the ability to be able to define an actions `input` and `output` schema using `zod` instead of hand writing types and `jsonschema` diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index ffe3312501..3e89e89582 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -24,8 +24,53 @@ passed as `input` to the function. In `packages/backend/src/plugins/scaffolder/actions/custom.ts` we can create a new action. -```ts -import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; +```ts title="With Zod" +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import fs from 'fs-extra'; +import { z } from 'zod'; + +export const createNewFileAction = () => { + return createTemplateAction({ + id: 'mycompany:create-file', + schema: { + input: z.object({ + contents: z.string().describe('The contents of the file'), + filename: z + .string() + .describe('The filename of the file that will be created'), + }), + }, + + async handler(ctx) { + await fs.outputFile( + `${ctx.workspacePath}/${ctx.input.filename}`, + ctx.input.contents, + ); + }, + }); +}; +``` + +So let's break this down. The `createNewFileAction` is a function that returns a +`createTemplateAction`, and it's a good place to pass in dependencies which +close over the `TemplateAction`. Take a look at our +[built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) +for reference. + +The `createTemplateAction` takes an object which specifies the following: + +- `id` - a unique ID for your custom action. We encourage you to namespace these + in some way so that they won't collide with future built-in actions that we + may ship with the `scaffolder-backend` plugin. +- `schema.input` - A `zod` or JSON schema object for input values to your function +- `schema.output` - A `zod` or JSON schema object for values which are output from the + function using `ctx.output` +- `handler` - the actual code which is run part of the action, with a context + +You can also choose to define your custom action using JSON schema instead of `zod`: + +```ts title="With JSON Schema" +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; export const createNewFileAction = () => { @@ -59,27 +104,6 @@ export const createNewFileAction = () => { }; ``` -So let's break this down. The `createNewFileAction` is a function that returns a -`createTemplateAction`, and it's a good place to pass in dependencies which -close over the `TemplateAction`. Take a look at our -[built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) -for reference. - -We set the type generic to `{ contents: string, filename: string }` which is -there to set the type on the handler `ctx` `inputs` property so we get good type -checking. This could be generated from the next part of this guide, the `input` -schema, but it's not supported right now. Feel free to contribute 🚀 👍. - -The `createTemplateAction` takes an object which specifies the following: - -- `id` - a unique ID for your custom action. We encourage you to namespace these - in some way so that they won't collide with future built-in actions that we - may ship with the `scaffolder-backend` plugin. -- `schema.input` - A JSON schema for input values to your function -- `schema.output` - A JSON schema for values which are outputted from the - function using `ctx.output` -- `handler` - the actual code which is run part of the action, with a context - ### The context object When the action `handler` is called, we provide you a `context` as the only @@ -89,10 +113,10 @@ argument. It looks like the following: - `ctx.logger` - a Winston logger for additional logging inside your action - `ctx.logStream` - a stream version of the logger if needed - `ctx.workspacePath` - a string of the working directory of the template run -- `ctx.input` - an object which should match the JSON schema provided in the +- `ctx.input` - an object which should match the `zod` or JSON schema provided in the `schema.input` part of the action definition - `ctx.output` - a function which you can call to set outputs that match the - JSON schema in `schema.output` for ex. `ctx.output('downloadUrl', something)` + JSON schema or `zod` in `schema.output` for ex. `ctx.output('downloadUrl', myDownloadUrl)` - `createTemporaryDirectory` a function to call to give you a temporary directory somewhere on the runner so you can store some files there rather than polluting the `workspacePath` diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 293ff7ed29..7b4ac83d58 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -24,6 +24,7 @@ import { Observable } from '@backstage/types'; import { Octokit } from 'octokit'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { ScmIntegrations } from '@backstage/integration'; import { SpawnOptionsWithoutStdio } from 'child_process'; @@ -31,8 +32,11 @@ import { TaskSecrets as TaskSecrets_2 } from '@backstage/plugin-scaffolder-node' import { TaskSpec } from '@backstage/plugin-scaffolder-common'; import { TaskSpecV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateAction as TemplateAction_2 } from '@backstage/plugin-scaffolder-node'; +import { TemplateActionOptions } from '@backstage/plugin-scaffolder-node'; import { UrlReader } from '@backstage/backend-common'; import { Writable } from 'stream'; +import { ZodType } from 'zod'; +import { ZodTypeDef } from 'zod'; // @public @deprecated (undocumented) export type ActionContext = ActionContext_2; @@ -40,7 +44,7 @@ export type ActionContext = ActionContext_2; // @public export const createBuiltinActions: ( options: CreateBuiltInActionsOptions, -) => TemplateAction_2[]; +) => TemplateAction_2[]; // @public export interface CreateBuiltInActionsOptions { @@ -72,7 +76,7 @@ export function createCatalogRegisterAction(options: { // @public export function createCatalogWriteAction(): TemplateAction_2<{ filePath?: string | undefined; - entity: Entity; + entity: {}; }>; // @public @@ -514,9 +518,16 @@ export const createPublishGitlabMergeRequestAction: (options: { export function createRouter(options: RouterOptions): Promise; // @public @deprecated (undocumented) -export const createTemplateAction: ( - templateAction: TemplateAction_2, -) => TemplateAction_2; +export const createTemplateAction: < + TParams, + TInputSchema extends ZodType | Schema = {}, + TOutputSchema extends ZodType | Schema = {}, + TActionInput = TInputSchema extends ZodType + ? IReturn + : TParams, +>( + action: TemplateActionOptions, +) => TemplateAction_2; // @public export type CreateWorkerOptions = { @@ -863,11 +874,11 @@ export type TemplateAction = // @public export class TemplateActionRegistry { // (undocumented) - get(actionId: string): TemplateAction_2; + get(actionId: string): TemplateAction_2; // (undocumented) - list(): TemplateAction_2[]; + list(): TemplateAction_2[]; // (undocumented) - register(action: TemplateAction_2): void; + register(action: TemplateAction_2): void; } // @public (undocumented) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts index d044d095cd..3868380919 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/TemplateActionRegistry.ts @@ -14,27 +14,26 @@ * limitations under the License. */ -import { JsonObject } from '@backstage/types'; import { ConflictError, NotFoundError } from '@backstage/errors'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; - /** * Registry of all registered template actions. * @public */ export class TemplateActionRegistry { - private readonly actions = new Map>(); + private readonly actions = new Map(); - register(action: TemplateAction) { + register(action: TemplateAction) { if (this.actions.has(action.id)) { throw new ConflictError( `Template action with ID '${action.id}' has already been registered`, ); } + this.actions.set(action.id, action); } - get(actionId: string): TemplateAction { + get(actionId: string): TemplateAction { const action = this.actions.get(actionId); if (!action) { throw new NotFoundError( @@ -44,7 +43,7 @@ export class TemplateActionRegistry { return action; } - list(): TemplateAction[] { + list(): TemplateAction[] { return [...this.actions.values()]; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts index 5c5a62956f..aaaf504833 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/write.ts @@ -17,8 +17,8 @@ import fs from 'fs-extra'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import * as yaml from 'yaml'; -import { Entity } from '@backstage/catalog-model'; import { resolveSafeChildPath } from '@backstage/backend-common'; +import { z } from 'zod'; const id = 'catalog:write'; @@ -56,29 +56,26 @@ const examples = [ * Writes a catalog descriptor file containing the provided entity to a path in the workspace. * @public */ + export function createCatalogWriteAction() { - return createTemplateAction<{ filePath?: string; entity: Entity }>({ + return createTemplateAction({ id, description: 'Writes the catalog-info.yaml for your template', - examples, schema: { - input: { - type: 'object', - properties: { - filePath: { - title: 'Catalog file path', - description: 'Defaults to catalog-info.yaml', - type: 'string', - }, - entity: { - title: 'Entity info to write catalog-info.yaml', - description: - 'You can provide the same values used in the Entity schema.', - type: 'object', - }, - }, - }, + input: z.object({ + filePath: z + .string() + .optional() + .describe('Defaults to catalog-info.yaml'), + // TODO: this should reference an zod entity validator if it existed. + entity: z + .object({}) + .describe( + 'You can provide the same values used in the Entity schema.', + ), + }), }, + examples, supportsDryRun: true, async handler(ctx) { ctx.logStream.write(`Writing catalog-info.yaml`); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index d83d9c5b07..9ba4a27b1a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -22,7 +22,6 @@ import { GithubCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; -import { JsonObject } from '@backstage/types'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { createCatalogRegisterAction, @@ -95,7 +94,7 @@ export interface CreateBuiltInActionsOptions { */ export const createBuiltinActions = ( options: CreateBuiltInActionsOptions, -): TemplateAction[] => { +): TemplateAction[] => { const { reader, integrations, @@ -188,5 +187,5 @@ export const createBuiltinActions = ( }), ]; - return actions as TemplateAction[]; + return actions as TemplateAction[]; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts index c02375554c..7109a6621e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts +++ b/plugins/scaffolder-backend/src/scaffolder/dryrun/DecoratedActionsRegistry.ts @@ -15,14 +15,13 @@ */ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { JsonObject } from '@backstage/types'; import { TemplateActionRegistry } from '../actions'; /** @internal */ export class DecoratedActionsRegistry extends TemplateActionRegistry { constructor( private readonly innerRegistry: TemplateActionRegistry, - extraActions: Array>, + extraActions: Array, ) { super(); for (const action of extraActions) { @@ -30,7 +29,7 @@ export class DecoratedActionsRegistry extends TemplateActionRegistry { } } - get(actionId: string): TemplateAction { + get(actionId: string): TemplateAction { try { return super.get(actionId); } catch { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 453cfae83b..0d2246ed36 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -24,8 +24,13 @@ import { ScmIntegrations } from '@backstage/integration'; import { ConfigReader } from '@backstage/config'; import { TaskContext } from './types'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; -import { TaskSecrets } from '@backstage/plugin-scaffolder-node'; +import { + createTemplateAction, + TaskSecrets, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; import { UserEntity } from '@backstage/catalog-model'; +import { z } from 'zod'; // The Stream module is lazy loaded, so make sure it's in the module cache before mocking fs void winston.transports.Stream; @@ -103,6 +108,20 @@ describe('DefaultWorkflowRunner', () => { }, }); + actionRegistry.register( + createTemplateAction({ + id: 'jest-zod-validated-action', + description: 'Mock action for testing', + handler: fakeActionHandler, + supportsDryRun: true, + schema: { + input: z.object({ + foo: z.number(), + }), + }, + }) as TemplateAction, + ); + actionRegistry.register({ id: 'output-action', description: 'Mock action for testing', @@ -151,6 +170,41 @@ describe('DefaultWorkflowRunner', () => { ); }); + it('should throw an error if the action has a zod schema and the input does not match', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { id: 'test', name: 'name', action: 'jest-zod-validated-action' }, + ], + }); + + await expect(runner.execute(task)).rejects.toThrow( + /Invalid input passed to action jest-zod-validated-action, instance requires property \"foo\"/, + ); + }); + + it('should run the action when the zod validation passes', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-zod-validated-action', + input: { foo: 1 }, + }, + ], + }); + + await runner.execute(task); + + expect(fakeActionHandler).toHaveBeenCalledTimes(1); + }); + it('should run the action when the validation passes', async () => { const task = createMockTaskWithSpec({ apiVersion: 'scaffolder.backstage.io/v1beta3', diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 516abe79e4..88abe05cad 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -380,10 +380,7 @@ function scaffoldingTracker() { template, }); - async function skipDryRun( - step: TaskStep, - action: TemplateAction, - ) { + async function skipDryRun(step: TaskStep, action: TemplateAction) { task.emitLog(`Skipping because ${action.id} does not support dry-run`, { stepId: step.id, status: 'skipped', diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c339de8d10..e40958c778 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -66,7 +66,6 @@ export interface RouterOptions { database: PluginDatabaseManager; catalogClient: CatalogApi; scheduler?: PluginTaskScheduler; - actions?: TemplateAction[]; /** * @deprecated taskWorkers is deprecated in favor of concurrentTasksLimit option with a single TaskWorker diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 506ea0517d..89b7171ef9 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -13,14 +13,15 @@ import { Schema } from 'jsonschema'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UserEntity } from '@backstage/catalog-model'; import { Writable } from 'stream'; +import { z } from 'zod'; // @public -export type ActionContext = { +export type ActionContext = { logger: Logger; logStream: Writable; secrets?: TaskSecrets; workspacePath: string; - input: TInput; + input: TActionInput; output(name: string, value: JsonValue): void; createTemporaryDirectory(): Promise; templateInfo?: TemplateInfo; @@ -32,9 +33,16 @@ export type ActionContext = { }; // @public -export const createTemplateAction: ( - templateAction: TemplateAction, -) => TemplateAction; +export const createTemplateAction: < + TParams, + TInputSchema extends z.ZodType | Schema = {}, + TOutputSchema extends z.ZodType | Schema = {}, + TActionInput = TInputSchema extends z.ZodType + ? IReturn + : TParams, +>( + action: TemplateActionOptions, +) => TemplateAction; // @alpha export interface ScaffolderActionsExtensionPoint { @@ -51,7 +59,7 @@ export type TaskSecrets = Record & { }; // @public (undocumented) -export type TemplateAction = { +export type TemplateAction = { id: string; description?: string; examples?: { @@ -63,6 +71,26 @@ export type TemplateAction = { input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: (ctx: ActionContext) => Promise; +}; + +// @public (undocumented) +export type TemplateActionOptions< + TActionInput = {}, + TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, +> = { + id: string; + description?: string; + examples?: { + description: string; + example: string; + }[]; + supportsDryRun?: boolean; + schema?: { + input?: TInputSchema; + output?: TOutputSchema; + }; + handler: (ctx: ActionContext) => Promise; }; ``` diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json index 642a59b801..b7fd22a2bd 100644 --- a/plugins/scaffolder-node/package.json +++ b/plugins/scaffolder-node/package.json @@ -29,7 +29,9 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/types": "workspace:^", "jsonschema": "^1.2.6", - "winston": "^3.2.1" + "winston": "^3.2.1", + "zod": "~3.18.0", + "zod-to-json-schema": "~3.18.0" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/plugins/scaffolder-node/src/actions/createTemplateAction.ts b/plugins/scaffolder-node/src/actions/createTemplateAction.ts index 24d88a1681..8c98042d8c 100644 --- a/plugins/scaffolder-node/src/actions/createTemplateAction.ts +++ b/plugins/scaffolder-node/src/actions/createTemplateAction.ts @@ -14,17 +14,61 @@ * limitations under the License. */ -import { JsonObject } from '@backstage/types'; -import { TemplateAction } from './types'; +import { ActionContext, TemplateAction } from './types'; +import { z } from 'zod'; +import { Schema } from 'jsonschema'; +import zodToJsonSchema from 'zod-to-json-schema'; + +/** @public */ +export type TemplateActionOptions< + TActionInput = {}, + TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, +> = { + id: string; + description?: string; + examples?: { description: string; example: string }[]; + supportsDryRun?: boolean; + schema?: { + input?: TInputSchema; + output?: TOutputSchema; + }; + handler: (ctx: ActionContext) => Promise; +}; /** * This function is used to create new template actions to get type safety. - * + * Will convert zod schemas to json schemas for use throughout the system. * @public */ -export const createTemplateAction = ( - templateAction: TemplateAction, -): TemplateAction => { - // TODO(blam): Can add some more validation here to validate the action later on +export const createTemplateAction = < + TParams, + TInputSchema extends Schema | z.ZodType = {}, + TOutputSchema extends Schema | z.ZodType = {}, + TActionInput = TInputSchema extends z.ZodType + ? IReturn + : TParams, +>( + action: TemplateActionOptions, +): TemplateAction => { + const inputSchema = + action.schema?.input && 'safeParseAsync' in action.schema.input + ? zodToJsonSchema(action.schema.input) + : action.schema?.input; + + const outputSchema = + action.schema?.output && 'safeParseAsync' in action.schema.output + ? zodToJsonSchema(action.schema.output) + : action.schema?.output; + + const templateAction = { + ...action, + schema: { + ...action.schema, + input: inputSchema, + output: outputSchema, + }, + }; + return templateAction; }; diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 7fdee6d692..e4af098356 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -export { createTemplateAction } from './createTemplateAction'; +export { + createTemplateAction, + type TemplateActionOptions, +} from './createTemplateAction'; export { type ActionContext, type TemplateAction } from './types'; diff --git a/plugins/scaffolder-node/src/actions/types.ts b/plugins/scaffolder-node/src/actions/types.ts index 5a4f5796a1..cce149adbe 100644 --- a/plugins/scaffolder-node/src/actions/types.ts +++ b/plugins/scaffolder-node/src/actions/types.ts @@ -16,22 +16,22 @@ import { Logger } from 'winston'; import { Writable } from 'stream'; -import { JsonValue, JsonObject } from '@backstage/types'; -import { Schema } from 'jsonschema'; +import { JsonObject, JsonValue } from '@backstage/types'; import { TaskSecrets } from '../tasks/types'; import { TemplateInfo } from '@backstage/plugin-scaffolder-common'; import { UserEntity } from '@backstage/catalog-model'; +import { Schema } from 'jsonschema'; /** * ActionContext is passed into scaffolder actions. * @public */ -export type ActionContext = { +export type ActionContext = { logger: Logger; logStream: Writable; secrets?: TaskSecrets; workspacePath: string; - input: TInput; + input: TActionInput; output(name: string, value: JsonValue): void; /** @@ -63,7 +63,7 @@ export type ActionContext = { }; /** @public */ -export type TemplateAction = { +export type TemplateAction = { id: string; description?: string; examples?: { description: string; example: string }[]; @@ -72,5 +72,5 @@ export type TemplateAction = { input?: Schema; output?: Schema; }; - handler: (ctx: ActionContext) => Promise; + handler: (ctx: ActionContext) => Promise; }; diff --git a/yarn.lock b/yarn.lock index d365859add..a89f3c14fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7740,6 +7740,8 @@ __metadata: "@backstage/types": "workspace:^" jsonschema: ^1.2.6 winston: ^3.2.1 + zod: ~3.18.0 + zod-to-json-schema: ~3.18.0 languageName: unknown linkType: soft