Merge pull request #15561 from zcason/update-scaffolder-action-to-zod

Update scaffolder action to zod
This commit is contained in:
Ben Lambert
2023-02-28 11:27:02 +01:00
committed by GitHub
16 changed files with 255 additions and 92 deletions
+19 -8
View File
@@ -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<TInput extends JsonObject> = ActionContext_2<TInput>;
@@ -40,7 +44,7 @@ export type ActionContext<TInput extends JsonObject> = ActionContext_2<TInput>;
// @public
export const createBuiltinActions: (
options: CreateBuiltInActionsOptions,
) => TemplateAction_2<JsonObject>[];
) => 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<express.Router>;
// @public @deprecated (undocumented)
export const createTemplateAction: <TInput extends JsonObject>(
templateAction: TemplateAction_2<TInput>,
) => TemplateAction_2<TInput>;
export const createTemplateAction: <
TParams,
TInputSchema extends ZodType<any, ZodTypeDef, any> | Schema = {},
TOutputSchema extends ZodType<any, ZodTypeDef, any> | Schema = {},
TActionInput = TInputSchema extends ZodType<any, any, infer IReturn>
? IReturn
: TParams,
>(
action: TemplateActionOptions<TActionInput, TInputSchema, TOutputSchema>,
) => TemplateAction_2<TActionInput>;
// @public
export type CreateWorkerOptions = {
@@ -863,11 +874,11 @@ export type TemplateAction<TInput extends JsonObject> =
// @public
export class TemplateActionRegistry {
// (undocumented)
get(actionId: string): TemplateAction_2<JsonObject>;
get(actionId: string): TemplateAction_2;
// (undocumented)
list(): TemplateAction_2<JsonObject>[];
list(): TemplateAction_2[];
// (undocumented)
register<TInput extends JsonObject>(action: TemplateAction_2<TInput>): void;
register(action: TemplateAction_2): void;
}
// @public (undocumented)
@@ -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<string, TemplateAction<any>>();
private readonly actions = new Map<string, TemplateAction>();
register<TInput extends JsonObject>(action: TemplateAction<TInput>) {
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<JsonObject> {
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<JsonObject>[] {
list(): TemplateAction[] {
return [...this.actions.values()];
}
}
@@ -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`);
@@ -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<JsonObject>[] => {
): TemplateAction[] => {
const {
reader,
integrations,
@@ -188,5 +187,5 @@ export const createBuiltinActions = (
}),
];
return actions as TemplateAction<JsonObject>[];
return actions as TemplateAction[];
};
@@ -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<TemplateAction<JsonObject>>,
extraActions: Array<TemplateAction>,
) {
super();
for (const action of extraActions) {
@@ -30,7 +29,7 @@ export class DecoratedActionsRegistry extends TemplateActionRegistry {
}
}
get(actionId: string): TemplateAction<JsonObject> {
get(actionId: string): TemplateAction {
try {
return super.get(actionId);
} catch {
@@ -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<unknown>,
);
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',
@@ -380,10 +380,7 @@ function scaffoldingTracker() {
template,
});
async function skipDryRun(
step: TaskStep,
action: TemplateAction<JsonObject>,
) {
async function skipDryRun(step: TaskStep, action: TemplateAction) {
task.emitLog(`Skipping because ${action.id} does not support dry-run`, {
stepId: step.id,
status: 'skipped',
@@ -66,7 +66,6 @@ export interface RouterOptions {
database: PluginDatabaseManager;
catalogClient: CatalogApi;
scheduler?: PluginTaskScheduler;
actions?: TemplateAction<any>[];
/**
* @deprecated taskWorkers is deprecated in favor of concurrentTasksLimit option with a single TaskWorker
+35 -7
View File
@@ -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<TInput extends JsonObject> = {
export type ActionContext<TActionInput extends JsonObject> = {
logger: Logger;
logStream: Writable;
secrets?: TaskSecrets;
workspacePath: string;
input: TInput;
input: TActionInput;
output(name: string, value: JsonValue): void;
createTemporaryDirectory(): Promise<string>;
templateInfo?: TemplateInfo;
@@ -32,9 +33,16 @@ export type ActionContext<TInput extends JsonObject> = {
};
// @public
export const createTemplateAction: <TInput extends JsonObject>(
templateAction: TemplateAction<TInput>,
) => TemplateAction<TInput>;
export const createTemplateAction: <
TParams,
TInputSchema extends z.ZodType<any, z.ZodTypeDef, any> | Schema = {},
TOutputSchema extends z.ZodType<any, z.ZodTypeDef, any> | Schema = {},
TActionInput = TInputSchema extends z.ZodType<any, any, infer IReturn>
? IReturn
: TParams,
>(
action: TemplateActionOptions<TActionInput, TInputSchema, TOutputSchema>,
) => TemplateAction<TActionInput>;
// @alpha
export interface ScaffolderActionsExtensionPoint {
@@ -51,7 +59,7 @@ export type TaskSecrets = Record<string, string> & {
};
// @public (undocumented)
export type TemplateAction<TInput extends JsonObject> = {
export type TemplateAction<TActionInput = unknown> = {
id: string;
description?: string;
examples?: {
@@ -63,6 +71,26 @@ export type TemplateAction<TInput extends JsonObject> = {
input?: Schema;
output?: Schema;
};
handler: (ctx: ActionContext<TInput>) => Promise<void>;
handler: (ctx: ActionContext<TActionInput>) => Promise<void>;
};
// @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<TActionInput>) => Promise<void>;
};
```
+3 -1
View File
@@ -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:^"
@@ -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<TActionInput>) => Promise<void>;
};
/**
* 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 = <TInput extends JsonObject>(
templateAction: TemplateAction<TInput>,
): TemplateAction<TInput> => {
// 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<any, any, infer IReturn>
? IReturn
: TParams,
>(
action: TemplateActionOptions<TActionInput, TInputSchema, TOutputSchema>,
): TemplateAction<TActionInput> => {
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;
};
+4 -1
View File
@@ -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';
+6 -6
View File
@@ -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<TInput extends JsonObject> = {
export type ActionContext<TActionInput extends JsonObject> = {
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<TInput extends JsonObject> = {
};
/** @public */
export type TemplateAction<TInput extends JsonObject> = {
export type TemplateAction<TActionInput = unknown> = {
id: string;
description?: string;
examples?: { description: string; example: string }[];
@@ -72,5 +72,5 @@ export type TemplateAction<TInput extends JsonObject> = {
input?: Schema;
output?: Schema;
};
handler: (ctx: ActionContext<TInput>) => Promise<void>;
handler: (ctx: ActionContext<TActionInput>) => Promise<void>;
};