Merge pull request #30142 from backstage/blam/actions-cleanup/14

`scaffolder-backend`: last of the actions migrated, and removal of the old `TemplateAction` types
This commit is contained in:
Ben Lambert
2025-06-05 14:29:13 +02:00
committed by GitHub
31 changed files with 841 additions and 1045 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-scaffolder-node-test-utils': minor
---
**BREAKING CHANGES**
Because of the removal of the `logStream` property to the `ActionsContext` this has been removed from the `createMockActionContext` method.
You can remove this as it's no longer supported in the scaffolder actions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Migrating to latest action format
+60
View File
@@ -0,0 +1,60 @@
---
'@backstage/plugin-scaffolder-node': minor
---
**BREAKING CHANGES**
The legacy methods to define `createTemplateActions` have been replaced with the new native `zod` approaches for defining input and output schemas.
You can migrate actions that look like the following with the below examples:
```ts
// really old legacy json schema
createTemplateAction<{ repoUrl: string }, { repoOutput: string }>({
id: 'test',
schema: {
input: {
type: 'object'
required: ['repoUrl']
properties: {
repoUrl: {
type: 'string',
description: 'repository url description'
}
}
}
}
});
// old zod method
createTemplateAction({
id: 'test'
schema: {
input: {
repoUrl: z.string({ description: 'repository url description' })
}
}
})
// new method:
createTemplateAction({
id: 'test',
schema: {
input: {
repoUrl: z => z.string({ description: 'repository url description' })
}
}
})
// or for more complex zod types like unions
createTemplateAction({
id: 'test',
schema: {
input: z => z.object({
repoUrl: z.string({ description: 'repository url description' })
})
}
})
```
This breaking change also means that `logStream` has been removed entirely from `ActionsContext`, and that the `logger` is now just a `LoggerService` implementation instead. There is no replacement for the `logStream`, if you wish to still keep using a `logStream` we recommend that you create your own stream that writes to `ctx.logger` instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updating the scaffolder action boilerplate to use new `zod` schema
@@ -12,22 +12,16 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
export function createExampleAction() {
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
myParameter: string;
}>({
return createTemplateAction({
id: 'acme:example',
description: 'Runs an example action',
schema: {
input: {
type: 'object',
required: ['myParameter'],
properties: {
myParameter: {
title: 'An example parameter',
description: "This is an example parameter, don't set it to foo",
type: 'string',
},
},
myParameter: z =>
z.string({
description:
"This is an example parameter, don't set it to foo",
}),
},
},
async handler(ctx) {
@@ -5,7 +5,6 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
@@ -103,14 +102,16 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
{
repoUrl: string;
title: string;
description?: string;
targetBranch?: string;
sourceBranch: string;
token?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
description?: string | undefined;
targetBranch?: string | undefined;
token?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
},
JsonObject,
'v1'
{
pullRequestUrl: string;
},
'v2'
>;
```
@@ -139,6 +139,7 @@ const findBranches = async (opts: {
return r.values[0];
};
const createBranch = async (opts: {
workspace: string;
repo: string;
@@ -190,6 +191,7 @@ const createBranch = async (opts: {
return await response.json();
};
const getDefaultBranch = async (opts: {
workspace: string;
repo: string;
@@ -233,73 +235,60 @@ export function createPublishBitbucketCloudPullRequestAction(options: {
}) {
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
title: string;
description?: string;
targetBranch?: string;
sourceBranch: string;
token?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
}>({
return createTemplateAction({
id: 'publish:bitbucketCloud:pull-request',
examples,
schema: {
input: {
type: 'object',
required: ['repoUrl', 'title', 'sourceBranch'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
title: {
title: 'Pull Request title',
type: 'string',
repoUrl: z =>
z.string({
description: 'Repository Location',
}),
title: z =>
z.string({
description: 'The title for the pull request',
},
description: {
title: 'Pull Request Description',
type: 'string',
description: 'The description of the pull request',
},
targetBranch: {
title: 'Target Branch',
type: 'string',
description: `Branch of repository to apply changes to. The default value is 'master'`,
},
sourceBranch: {
title: 'Source Branch',
type: 'string',
}),
description: z =>
z
.string({
description: 'The description of the pull request',
})
.optional(),
targetBranch: z =>
z
.string({
description: `Branch of repository to apply changes to. The default value is 'master'`,
})
.optional(),
sourceBranch: z =>
z.string({
description: 'Branch of repository to copy changes from',
},
token: {
title: 'Authorization Token',
type: 'string',
description:
'The token to use for authorization to BitBucket Cloud',
},
gitAuthorName: {
title: 'Author Name',
type: 'string',
description: `Sets the author name for the commit. The default value is 'Scaffolder'`,
},
gitAuthorEmail: {
title: 'Author Email',
type: 'string',
description: `Sets the author email for the commit.`,
},
},
}),
token: z =>
z
.string({
description:
'The token to use for authorization to BitBucket Cloud',
})
.optional(),
gitAuthorName: z =>
z
.string({
description: `Sets the author name for the commit. The default value is 'Scaffolder'`,
})
.optional(),
gitAuthorEmail: z =>
z
.string({
description: `Sets the author email for the commit.`,
})
.optional(),
},
output: {
type: 'object',
properties: {
pullRequestUrl: {
title: 'A URL to the pull request with the provider',
type: 'string',
},
},
pullRequestUrl: z =>
z.string({
description: 'A URL to the pull request with the provider',
}),
},
},
async handler(ctx) {
@@ -97,7 +97,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with targetBranchName', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
fakeClient = {
@@ -181,7 +181,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with no sourcePath', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -250,7 +250,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with sourcePath', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -306,7 +306,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with repoUrl', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -359,7 +359,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with reviewers and teamReviewers', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -406,7 +406,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with no reviewers and teamReviewers', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -431,7 +431,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with assignees', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -475,7 +475,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with broken symlink', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -520,7 +520,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with executable file mode 755', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -579,7 +579,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with executable file mode 775', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -638,7 +638,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with commit message', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -683,7 +683,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with force fork', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -729,7 +729,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author name and email', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -779,7 +779,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author name', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -828,7 +828,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author email', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -877,7 +877,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author from config file', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -946,7 +946,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author attributes and config file', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -1017,7 +1017,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with author fallback and no config', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -1112,7 +1112,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with createWhenEmpty equals true', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
@@ -1175,7 +1175,7 @@ describe('createPublishGithubPullRequestAction', () => {
describe('with createWhenEmpty equals false', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
fakeClient.createPullRequest.mockResolvedValueOnce(null);
+89 -44
View File
@@ -76,15 +76,26 @@ export function createCatalogRegisterAction(options: {
}): TemplateAction<
| {
catalogInfoUrl: string;
optional?: boolean;
optional?: boolean | undefined;
}
| {
catalogInfoUrl: string;
optional?: boolean | undefined;
catalogInfoPath?: string | undefined;
}
| {
repoContentsUrl: string;
catalogInfoPath?: string;
optional?: boolean;
optional?: boolean | undefined;
}
| {
repoContentsUrl: string;
optional?: boolean | undefined;
catalogInfoPath?: string | undefined;
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
@@ -93,12 +104,23 @@ export function createCatalogWriteAction(): TemplateAction<
entity: Record<string, any>;
filePath?: string | undefined;
},
any,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
export function createDebugLogAction(): TemplateAction<any, any, 'v1'>;
export function createDebugLogAction(): TemplateAction<
{
message?: string | undefined;
listWorkspace?: boolean | 'with-contents' | 'with-filenames' | undefined;
},
{
[x: string]: any;
},
'v2'
>;
// @public
export function createFetchCatalogEntityAction(options: {
@@ -126,11 +148,13 @@ export function createFetchPlainAction(options: {
}): TemplateAction<
{
url: string;
targetPath?: string;
token?: string;
targetPath?: string | undefined;
token?: string | undefined;
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
@@ -141,10 +165,12 @@ export function createFetchPlainFileAction(options: {
{
url: string;
targetPath: string;
token?: string;
token?: string | undefined;
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
@@ -156,18 +182,21 @@ export function createFetchTemplateAction(options: {
}): TemplateAction<
{
url: string;
targetPath?: string;
values: any;
templateFileExtension?: string | boolean;
copyWithoutTemplating?: string[];
cookiecutterCompat?: boolean;
replace?: boolean;
trimBlocks?: boolean;
lstripBlocks?: boolean;
token?: string;
targetPath?: string | undefined;
values?: Record<string, any> | undefined;
copyWithoutRender?: string[] | undefined;
copyWithoutTemplating?: string[] | undefined;
cookiecutterCompat?: boolean | undefined;
templateFileExtension?: string | boolean | undefined;
replace?: boolean | undefined;
trimBlocks?: boolean | undefined;
lstripBlocks?: boolean | undefined;
token?: string | undefined;
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
@@ -180,15 +209,17 @@ export function createFetchTemplateFileAction(options: {
{
url: string;
targetPath: string;
values: any;
cookiecutterCompat?: boolean;
replace?: boolean;
trimBlocks?: boolean;
lstripBlocks?: boolean;
token?: string;
values?: Record<string, any> | undefined;
cookiecutterCompat?: boolean | undefined;
replace?: boolean | undefined;
trimBlocks?: boolean | undefined;
lstripBlocks?: boolean | undefined;
token?: string | undefined;
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
@@ -196,15 +227,17 @@ export const createFilesystemDeleteAction: () => TemplateAction<
{
files: string[];
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
export const createFilesystemReadDirAction: () => TemplateAction<
{
recursive: boolean;
paths: string[];
recursive: boolean;
},
{
files: {
@@ -218,26 +251,38 @@ export const createFilesystemReadDirAction: () => TemplateAction<
fullPath: string;
}[];
},
'v1'
'v2'
>;
// @public
export const createFilesystemRenameAction: () => TemplateAction<
{
files: Array<{
files: {
from: string;
to: string;
overwrite?: boolean;
}>;
overwrite?: boolean | undefined;
}[];
},
JsonObject,
'v1'
{
[x: string]: any;
},
'v2'
>;
// @public
export function createWaitAction(options?: {
maxWaitTime?: Duration | HumanDuration;
}): TemplateAction<HumanDuration, JsonObject, 'v1'>;
}): TemplateAction<
{
minutes?: number | undefined;
seconds?: number | undefined;
milliseconds?: number | undefined;
},
{
[x: string]: any;
},
'v2'
>;
// @public @deprecated
export type CreateWorkerOptions = {
@@ -35,73 +35,45 @@ export function createCatalogRegisterAction(options: {
}) {
const { catalogClient, integrations, auth } = options;
return createTemplateAction<
| { catalogInfoUrl: string; optional?: boolean }
| { repoContentsUrl: string; catalogInfoPath?: string; optional?: boolean }
>({
return createTemplateAction({
id,
description:
'Registers entities from a catalog descriptor file in the workspace into the software catalog.',
examples,
schema: {
input: {
oneOf: [
{
type: 'object',
required: ['catalogInfoUrl'],
properties: {
catalogInfoUrl: {
title: 'Catalog Info URL',
description:
'An absolute URL pointing to the catalog info file location',
type: 'string',
},
optional: {
title: 'Optional',
input: z =>
z.union([
z.object({
catalogInfoUrl: z.string({
description:
'An absolute URL pointing to the catalog info file location',
}),
optional: z
.boolean({
description:
'Permit the registered location to optionally exist. Default: false',
type: 'boolean',
},
},
},
{
type: 'object',
required: ['repoContentsUrl'],
properties: {
repoContentsUrl: {
title: 'Repository Contents URL',
description:
'An absolute URL pointing to the root of a repository directory tree',
type: 'string',
},
catalogInfoPath: {
title: 'Fetch URL',
})
.optional(),
}),
z.object({
repoContentsUrl: z.string({
description:
'An absolute URL pointing to the root of a repository directory tree',
}),
catalogInfoPath: z
.string({
description:
'A relative path from the repo root pointing to the catalog info file, defaults to /catalog-info.yaml',
type: 'string',
},
optional: {
title: 'Optional',
})
.optional(),
optional: z
.boolean({
description:
'Permit the registered location to optionally exist. Default: false',
type: 'boolean',
},
},
},
],
},
output: {
type: 'object',
required: ['catalogInfoUrl'],
properties: {
entityRef: {
type: 'string',
},
catalogInfoUrl: {
type: 'string',
},
},
},
})
.optional(),
}),
]),
},
async handler(ctx) {
const { input } = ctx;
@@ -18,7 +18,6 @@ import fs from 'fs-extra';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import * as yaml from 'yaml';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import { z } from 'zod';
import { examples } from './write.examples';
const id = 'catalog:write';
@@ -33,18 +32,17 @@ export function createCatalogWriteAction() {
id,
description: 'Writes the catalog-info.yaml for your template',
schema: {
input: z.object({
filePath: z
.string()
.optional()
.describe('Defaults to catalog-info.yaml'),
input: {
filePath: z =>
z.string().optional().describe('Defaults to catalog-info.yaml'),
// TODO: this should reference an zod entity validator if it existed.
entity: z
.record(z.any())
.describe(
'You can provide the same values used in the Entity schema.',
),
}),
entity: z =>
z
.record(z.any())
.describe(
'You can provide the same values used in the Entity schema.',
),
},
},
examples,
supportsDryRun: true,
@@ -19,7 +19,6 @@ import { join, relative } from 'path';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { examples } from './log.examples';
import fs from 'fs';
import { z } from 'zod';
const id = 'debug:log';
@@ -34,24 +33,23 @@ const id = 'debug:log';
* @public
*/
export function createDebugLogAction() {
return createTemplateAction<{
message?: string;
listWorkspace?: boolean | 'with-filenames' | 'with-contents';
}>({
return createTemplateAction({
id,
description:
'Writes a message into the log and/or lists all files in the workspace.',
examples,
schema: {
input: z.object({
message: z.string({ description: 'Message to output.' }).optional(),
listWorkspace: z
.union([z.boolean(), z.enum(['with-filenames', 'with-contents'])], {
description:
'List all files in the workspace. If used with "with-contents", also the file contents are listed.',
})
.optional(),
}),
input: {
message: z =>
z.string({ description: 'Message to output.' }).optional(),
listWorkspace: z =>
z
.union([z.boolean(), z.enum(['with-filenames', 'with-contents'])], {
description:
'List all files in the workspace. If used with "with-contents", also the file contents are listed.',
})
.optional(),
},
},
supportsDryRun: true,
async handler(ctx) {
@@ -15,7 +15,6 @@
*/
import { createWaitAction } from './wait';
import { Writable } from 'stream';
import { examples } from './wait.examples';
import yaml from 'yaml';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
@@ -23,13 +22,7 @@ import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-
describe('debug:wait examples', () => {
const action = createWaitAction();
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const mockContext = createMockActionContext({
logStream,
});
const mockContext = createMockActionContext();
beforeEach(() => {
jest.resetAllMocks();
@@ -15,19 +15,12 @@
*/
import { createWaitAction } from './wait';
import { Writable } from 'stream';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
describe('debug:wait', () => {
const action = createWaitAction();
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const mockContext = createMockActionContext({
logStream,
});
const mockContext = createMockActionContext();
beforeEach(() => {
jest.resetAllMocks();
@@ -48,27 +48,30 @@ export function createWaitAction(options?: {
return Duration.fromISOTime(MAX_WAIT_TIME_IN_ISO);
};
return createTemplateAction<HumanDuration>({
return createTemplateAction({
id,
description: 'Waits for a certain period of time.',
examples,
schema: {
input: {
type: 'object',
properties: {
minutes: {
title: 'Waiting period in minutes.',
type: 'number',
},
seconds: {
title: 'Waiting period in seconds.',
type: 'number',
},
milliseconds: {
title: 'Waiting period in milliseconds.',
type: 'number',
},
},
minutes: z =>
z
.number({
description: 'Waiting period in minutes.',
})
.optional(),
seconds: z =>
z
.number({
description: 'Waiting period in seconds.',
})
.optional(),
milliseconds: z =>
z
.number({
description: 'Waiting period in milliseconds.',
})
.optional(),
},
},
async handler(ctx) {
@@ -39,39 +39,32 @@ export function createFetchPlainAction(options: {
}) {
const { reader, integrations } = options;
return createTemplateAction<{
url: string;
targetPath?: string;
token?: string;
}>({
return createTemplateAction({
id: ACTION_ID,
examples,
description:
'Downloads content and places it in the workspace, or optionally in a subdirectory specified by the `targetPath` input option.',
schema: {
input: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Fetch URL',
url: z =>
z.string({
description:
'Relative path or absolute URL pointing to the directory tree to fetch',
type: 'string',
},
targetPath: {
title: 'Target Path',
description:
'Target path within the working directory to download the contents to.',
type: 'string',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
}),
targetPath: z =>
z
.string({
description:
'Target path within the working directory to download the contents to.',
})
.optional(),
token: z =>
z
.string({
description:
'An optional token to use for authentication when reading the resources.',
})
.optional(),
},
},
supportsDryRun: true,
@@ -14,18 +14,20 @@
* limitations under the License.
*/
import { UrlReaderService } from '@backstage/backend-plugin-api';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import {
resolveSafeChildPath,
UrlReaderService,
} from '@backstage/backend-plugin-api';
import { ScmIntegrations } from '@backstage/integration';
import { examples } from './plainFile.examples';
import {
createTemplateAction,
fetchFile,
} from '@backstage/plugin-scaffolder-node';
/**
* Downloads content and places it in the workspace, or optionally
* in a subdirectory specified by the 'targetPath' input option.
* Downloads a single file and places it in the workspace.
* @public
*/
export function createFetchPlainFileAction(options: {
@@ -34,38 +36,29 @@ export function createFetchPlainFileAction(options: {
}) {
const { reader, integrations } = options;
return createTemplateAction<{
url: string;
targetPath: string;
token?: string;
}>({
return createTemplateAction({
id: 'fetch:plain:file',
description: 'Downloads single file and places it in the workspace.',
examples,
schema: {
input: {
type: 'object',
required: ['url', 'targetPath'],
properties: {
url: {
title: 'Fetch URL',
url: z =>
z.string({
description:
'Relative path or absolute URL pointing to the single file to fetch.',
type: 'string',
},
targetPath: {
title: 'Target Path',
}),
targetPath: z =>
z.string({
description:
'Target path within the working directory to download the file as.',
type: 'string',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
}),
token: z =>
z
.string({
description:
'An optional token to use for authentication when reading the resources.',
})
.optional(),
},
},
supportsDryRun: true,
@@ -41,89 +41,87 @@ export function createFetchTemplateAction(options: {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
return createTemplateAction<{
url: string;
targetPath?: string;
values: any;
templateFileExtension?: string | boolean;
// Cookiecutter compat options
copyWithoutTemplating?: string[];
cookiecutterCompat?: boolean;
replace?: boolean;
trimBlocks?: boolean;
lstripBlocks?: boolean;
token?: string;
}>({
return createTemplateAction({
id: 'fetch:template',
description:
'Downloads a skeleton, templates variables into file and directory names and content, and places the result in the workspace, or optionally in a subdirectory specified by the `targetPath` input option.',
examples,
schema: {
input: {
type: 'object',
required: ['url'],
properties: {
url: {
title: 'Fetch URL',
url: z =>
z.string({
description:
'Relative path or absolute URL pointing to the directory tree to fetch',
type: 'string',
},
targetPath: {
title: 'Target Path',
description:
'Target path within the working directory to download the contents to. Defaults to the working directory root.',
type: 'string',
},
values: {
title: 'Template Values',
description: 'Values to pass on to the templating engine',
type: 'object',
},
copyWithoutRender: {
title: '[Deprecated] Copy Without Render',
description:
'An array of glob patterns. Any files or directories which match are copied without being processed as templates.',
type: 'array',
items: {
type: 'string',
},
},
copyWithoutTemplating: {
title: 'Copy Without Templating',
description:
'An array of glob patterns. Contents of matched files or directories are copied without being processed, but paths are subject to rendering.',
type: 'array',
items: {
type: 'string',
},
},
cookiecutterCompat: {
title: 'Cookiecutter compatibility mode',
description:
'Enable features to maximise compatibility with templates built for fetch:cookiecutter',
type: 'boolean',
},
templateFileExtension: {
title: 'Template File Extension',
description:
'If set, only files with the given extension will be templated. If set to `true`, the default extension `.njk` is used.',
type: ['string', 'boolean'],
},
replace: {
title: 'Replace files',
description:
'If set, replace files in targetPath instead of skipping existing ones.',
type: 'boolean',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
}),
targetPath: z =>
z
.string({
description:
'Target path within the working directory to download the contents to. Defaults to the working directory root.',
})
.optional(),
values: z =>
z
.record(z.any(), {
description: 'Values to pass on to the templating engine',
})
.optional(),
copyWithoutRender: z =>
z
.array(z.string(), {
description:
'An array of glob patterns. Any files or directories which match are copied without being processed as templates.',
})
.optional(),
copyWithoutTemplating: z =>
z
.array(z.string(), {
description:
'An array of glob patterns. Contents of matched files or directories are copied without being processed, but paths are subject to rendering.',
})
.optional(),
cookiecutterCompat: z =>
z
.boolean({
description:
'Enable features to maximise compatibility with templates built for fetch:cookiecutter',
})
.optional(),
templateFileExtension: z =>
z
.union([z.string(), z.boolean()], {
description:
'If set, only files with the given extension will be templated. If set to `true`, the default extension `.njk` is used.',
})
.optional(),
replace: z =>
z
.boolean({
description:
'If set, replace files in targetPath instead of skipping existing ones.',
})
.optional(),
trimBlocks: z =>
z
.boolean({
description:
'If set, the first newline after a block is removed (block, not variable tag).',
})
.optional(),
lstripBlocks: z =>
z
.boolean({
description:
'If set, leading spaces and tabs are stripped from the start of a line to a block.',
})
.optional(),
token: z =>
z
.string({
description:
'An optional token to use for authentication when reading the resources.',
})
.optional(),
},
},
supportsDryRun: true,
@@ -38,60 +38,63 @@ export function createFetchTemplateFileAction(options: {
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
return createTemplateAction<{
url: string;
targetPath: string;
values: any;
cookiecutterCompat?: boolean;
replace?: boolean;
trimBlocks?: boolean;
lstripBlocks?: boolean;
token?: string;
}>({
return createTemplateAction({
id: 'fetch:template:file',
description: 'Downloads single file and places it in the workspace.',
examples,
schema: {
input: {
type: 'object',
required: ['url', 'targetPath'],
properties: {
url: {
title: 'Fetch URL',
url: z =>
z.string({
description:
'Relative path or absolute URL pointing to the single file to fetch.',
type: 'string',
},
targetPath: {
title: 'Target Path',
}),
targetPath: z =>
z.string({
description:
'Target path within the working directory to download the file as.',
type: 'string',
},
values: {
title: 'Template Values',
description: 'Values to pass on to the templating engine',
type: 'object',
},
cookiecutterCompat: {
title: 'Cookiecutter compatibility mode',
description:
'Enable features to maximise compatibility with templates built for fetch:cookiecutter',
type: 'boolean',
},
replace: {
title: 'Replace file',
description:
'If set, replace file in targetPath instead of overwriting existing one.',
type: 'boolean',
},
token: {
title: 'Token',
description:
'An optional token to use for authentication when reading the resources.',
type: 'string',
},
},
}),
values: z =>
z
.record(z.any(), {
description: 'Values to pass on to the templating engine',
})
.optional(),
cookiecutterCompat: z =>
z
.boolean({
description:
'Enable features to maximise compatibility with templates built for fetch:cookiecutter',
})
.optional(),
replace: z =>
z
.boolean({
description:
'If set, replace file in targetPath instead of overwriting existing one.',
})
.optional(),
trimBlocks: z =>
z
.boolean({
description:
'If set, the first newline after a block is removed (block, not variable tag).',
})
.optional(),
lstripBlocks: z =>
z
.boolean({
description:
'If set, leading spaces and tabs are stripped from the start of a line to a block.',
})
.optional(),
token: z =>
z
.string({
description:
'An optional token to use for authentication when reading the resources.',
})
.optional(),
},
},
supportsDryRun: true,
@@ -26,24 +26,16 @@ import { examples } from './delete.examples';
* @public
*/
export const createFilesystemDeleteAction = () => {
return createTemplateAction<{ files: string[] }>({
return createTemplateAction({
id: 'fs:delete',
description: 'Deletes files and directories from the workspace',
examples,
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
files: z =>
z.array(z.string(), {
description: 'A list of files and directories that will be deleted',
type: 'array',
items: {
type: 'string',
},
},
},
}),
},
},
supportsDryRun: true,
@@ -14,19 +14,21 @@
* limitations under the License.
*/
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import z from 'zod';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import fs from 'fs/promises';
import path from 'path';
import { z as zod } from 'zod';
const contentSchema = z.object({
name: z.string().describe('Name of the file or directory'),
path: z
.string()
.describe('path to the file or directory relative to the workspace'),
fullPath: z.string().describe('full path to the file or directory'),
});
type Content = z.infer<typeof contentSchema>;
const contentSchema = (z: typeof zod) =>
z.object({
name: z.string().describe('Name of the file or directory'),
path: z
.string()
.describe('path to the file or directory relative to the workspace'),
fullPath: z.string().describe('full path to the file or directory'),
});
type Content = zod.infer<ReturnType<typeof contentSchema>>;
/**
* Creates new action that enables reading directories in the workspace.
@@ -38,14 +40,14 @@ export const createFilesystemReadDirAction = () => {
description: 'Reads files and directories from the workspace',
supportsDryRun: true,
schema: {
input: z.object({
paths: z.array(z.string().min(1)),
recursive: z.boolean().default(false),
}),
output: z.object({
files: z.array(contentSchema),
folders: z.array(contentSchema),
}),
input: {
paths: z => z.array(z.string().min(1)),
recursive: z => z.boolean().default(false),
},
output: {
files: z => z.array(contentSchema(z)),
folders: z => z.array(contentSchema(z)),
},
},
async handler(ctx) {
const files: Content[] = [];
@@ -16,56 +16,42 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import { InputError } from '@backstage/errors';
import fs from 'fs-extra';
import { examples } from './rename.examples';
import { InputError } from '@backstage/errors';
/**
* Creates a new action that allows renames of files and directories in the workspace.
* @public
*/
export const createFilesystemRenameAction = () => {
return createTemplateAction<{
files: Array<{
from: string;
to: string;
overwrite?: boolean;
}>;
}>({
return createTemplateAction({
id: 'fs:rename',
description: 'Renames files and directories within the workspace',
examples,
schema: {
input: {
required: ['files'],
type: 'object',
properties: {
files: {
title: 'Files',
description:
'A list of file and directory names that will be renamed',
type: 'array',
items: {
type: 'object',
required: ['from', 'to'],
properties: {
from: {
type: 'string',
title: 'The source location of the file to be renamed',
},
to: {
type: 'string',
title: 'The destination of the new file',
},
overwrite: {
type: 'boolean',
title:
files: z =>
z.array(
z.object({
from: z.string({
description: 'The source location of the file to be renamed',
}),
to: z.string({
description: 'The destination of the new file',
}),
overwrite: z
.boolean({
description:
'Overwrite existing file or directory, default is false',
},
},
})
.optional(),
}),
{
description:
'A list of file and directory names that will be renamed',
},
},
},
),
},
},
supportsDryRun: true,
@@ -78,7 +64,6 @@ export const createFilesystemRenameAction = () => {
if (!file.from || !file.to) {
throw new InputError('each file must have a from and to property');
}
const sourceFilepath = resolveSafeChildPath(
ctx.workspacePath,
file.from,
@@ -23,11 +23,9 @@ import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import {
createTemplateAction,
TaskSecrets,
TemplateAction,
TaskContext,
} from '@backstage/plugin-scaffolder-node';
import { UserEntity } from '@backstage/catalog-model';
import { z } from 'zod';
import {
AuthorizeResult,
PermissionEvaluator,
@@ -38,7 +36,6 @@ import {
mockCredentials,
mockServices,
} from '@backstage/backend-test-utils';
import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger';
describe('NunjucksWorkflowRunner', () => {
let actionRegistry: TemplateActionRegistry;
@@ -127,32 +124,12 @@ describe('NunjucksWorkflowRunner', () => {
handler: fakeActionHandler,
schema: {
input: {
type: 'object',
required: ['foo'],
properties: {
foo: {
type: 'number',
},
},
foo: z => z.number(),
},
},
}),
);
actionRegistry.register(
createTemplateAction({
id: 'jest-legacy-zod-validated-action',
description: 'Mock action for testing',
handler: fakeActionHandler,
supportsDryRun: true,
schema: {
input: z.object({
foo: z.number(),
}),
},
}) as TemplateAction,
);
actionRegistry.register(
createTemplateAction({
id: 'jest-zod-validated-action',
@@ -186,13 +163,14 @@ describe('NunjucksWorkflowRunner', () => {
id: 'checkpoints-action',
description: 'Mock action with checkpoints',
schema: {
output: z.object({
key1: z.string(),
key2: z.string(),
key3: z.string(),
key4: z.string(),
key5: z.string(),
}),
output: z =>
z.object({
key1: z.string(),
key2: z.string(),
key3: z.string(),
key4: z.string(),
key5: z.string(),
}),
},
handler: async ctx => {
const key1 = await ctx.checkpoint({
@@ -222,7 +200,9 @@ describe('NunjucksWorkflowRunner', () => {
ctx.output('key2', key2);
ctx.output('key3', key3);
// @ts-expect-error - not valid output type
ctx.output('key4', key4);
// @ts-expect-error - not valid output type
ctx.output('key5', key5);
},
}),
@@ -236,7 +216,7 @@ describe('NunjucksWorkflowRunner', () => {
actionRegistry,
integrations,
workingDirectory: mockDir.path,
logger: loggerToWinstonLogger(logger),
logger,
permissions: mockedPermissionApi,
});
});
@@ -280,22 +260,6 @@ describe('NunjucksWorkflowRunner', () => {
);
});
it('should throw an error if the action has legacy zod schema and the input does not match', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'test',
name: 'name',
action: 'jest-legacy-zod-validated-action',
},
],
});
await expect(runner.execute(task)).rejects.toThrow(
/Invalid input passed to action jest-legacy-zod-validated-action, instance requires property \"foo\"/,
);
});
it('should run the action when the zod validation passes', async () => {
const task = createMockTaskWithSpec({
steps: [
@@ -313,23 +277,6 @@ describe('NunjucksWorkflowRunner', () => {
expect(fakeActionHandler).toHaveBeenCalledTimes(1);
});
it('should run the action when the zod validation passes with legacy zod', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'test',
name: 'name',
action: 'jest-legacy-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({
steps: [
@@ -28,7 +28,6 @@ import fs from 'fs-extra';
import { validate as validateJsonSchema } from 'jsonschema';
import nunjucks from 'nunjucks';
import path from 'path';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import {
SecureTemplater,
@@ -40,6 +39,7 @@ import { TaskTrackType, WorkflowResponse, WorkflowRunner } from './types';
import type {
AuditorService,
LoggerService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { UserEntity } from '@backstage/catalog-model';
@@ -59,14 +59,13 @@ import { createDefaultFilters } from '../../lib/templating/filters/createDefault
import { scaffolderActionRules } from '../../service/rules';
import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
import { BackstageLoggerTransport, WinstonLogger } from './logger';
import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger';
import { convertFiltersToRecord } from '../../util/templating';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
actionRegistry: TemplateActionRegistry;
integrations: ScmIntegrations;
logger: winston.Logger;
logger: LoggerService;
auditor?: AuditorService;
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
@@ -113,7 +112,7 @@ const createStepLogger = ({
}: {
task: TaskContext;
step: TaskStep;
rootLogger: winston.Logger;
rootLogger: LoggerService;
}) => {
const taskLogger = WinstonLogger.create({
level: process.env.LOG_LEVEL || 'info',
@@ -126,22 +125,7 @@ const createStepLogger = ({
taskLogger.addRedactions(Object.values(task.secrets ?? {}));
// This stream logger should be deprecated. We're going to replace it with
// just using the logger directly, as all those logs get written to step logs
// using the stepLogStream above.
// Initially this stream used to be the only way to write to the client logs, but that
// has changed over time, there's not really a need for this anymore.
// You can just create a simple wrapper like the below in your action to write to the main logger.
// This way we also get redactions for free.
const streamLogger = new PassThrough();
streamLogger.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
taskLogger.info(message);
}
});
return { taskLogger, streamLogger };
return { taskLogger };
};
const isActionAuthorized = createConditionAuthorizer(
@@ -268,7 +252,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
}
const action: TemplateAction<JsonObject> =
this.options.actionRegistry.get(step.action);
const { taskLogger, streamLogger } = createStepLogger({
const { taskLogger } = createStepLogger({
task,
step,
rootLogger: this.options.logger,
@@ -393,9 +377,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
id: await task.getWorkspaceName(),
},
secrets: task.secrets ?? {},
// TODO(blam): move to LoggerService and away from Winston
logger: loggerToWinstonLogger(taskLogger),
logStream: streamLogger,
logger: taskLogger,
workspacePath,
async checkpoint<T extends JsonValue | void>(opts: {
key?: string;
@@ -7,12 +7,12 @@ import { ActionContext } from '@backstage/plugin-scaffolder-node';
import { JsonObject } from '@backstage/types';
// @public
export const createMockActionContext: <
export function createMockActionContext<
TActionInput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = any,
>(
options?: Partial<ActionContext<TActionInput, TActionOutput>>,
) => ActionContext<TActionInput, TActionOutput>;
): ActionContext<TActionInput, TActionOutput>;
// (No @packageDocumentation comment for this package)
```
@@ -30,13 +30,14 @@ import { loggerToWinstonLogger } from './loggerToWinstonLogger';
* @public
* @param options - optional parameters to override default mock context
*/
export const createMockActionContext = <
export function createMockActionContext<
TActionInput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = any,
>(
options?: Partial<ActionContext<TActionInput, TActionOutput>>,
): ActionContext<TActionInput, TActionOutput> => {
): ActionContext<TActionInput, TActionOutput> {
const credentials = mockCredentials.user();
const defaultContext = {
logger: loggerToWinstonLogger(mockServices.logger.mock()),
logStream: new PassThrough(),
@@ -66,16 +67,8 @@ export const createMockActionContext = <
};
}
const {
input,
logger,
logStream,
secrets,
templateInfo,
workspacePath,
task,
user,
} = options;
const { input, logger, secrets, templateInfo, workspacePath, task, user } =
options;
return {
...defaultContext,
@@ -84,11 +77,10 @@ export const createMockActionContext = <
createTemporaryDirectory: jest.fn().mockResolvedValue(workspacePath),
}),
...(logger && { logger }),
...(logStream && { logStream }),
...(input && { input }),
...(secrets && { secrets }),
...(task && { task }),
...(user && { user }),
templateInfo,
};
};
}
+90 -120
View File
@@ -7,7 +7,6 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { Expand } from '@backstage/types';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { Logger } from 'winston';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Observable } from '@backstage/types';
import { Schema } from 'jsonschema';
@@ -25,63 +24,34 @@ import { z } from 'zod';
export type ActionContext<
TActionInput extends JsonObject,
TActionOutput extends JsonObject = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1',
> = TSchemaType extends 'v2'
? {
logger: LoggerService;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
createTemporaryDirectory(): Promise<string>;
getInitiatorCredentials(): Promise<BackstageCredentials>;
task: {
id: string;
};
templateInfo?: TemplateInfo;
isDryRun?: boolean;
user?: {
entity?: UserEntity;
ref?: string;
};
signal?: AbortSignal;
each?: JsonObject;
}
: {
logger: Logger;
logStream: Writable;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
createTemporaryDirectory(): Promise<string>;
getInitiatorCredentials(): Promise<BackstageCredentials>;
task: {
id: string;
};
templateInfo?: TemplateInfo;
isDryRun?: boolean;
user?: {
entity?: UserEntity;
ref?: string;
};
signal?: AbortSignal;
each?: JsonObject;
};
_TSchemaType extends 'v2' = 'v2',
> = {
logger: LoggerService;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
createTemporaryDirectory(): Promise<string>;
getInitiatorCredentials(): Promise<BackstageCredentials>;
task: {
id: string;
};
templateInfo?: TemplateInfo;
isDryRun?: boolean;
user?: {
entity?: UserEntity;
ref?: string;
};
signal?: AbortSignal;
each?: JsonObject;
};
// @public (undocumented)
export function addFiles(options: {
@@ -180,69 +150,67 @@ export function createBranch(options: {
logger?: LoggerService | undefined;
}): Promise<void>;
// @public @deprecated (undocumented)
export function createTemplateAction<
TInputParams extends JsonObject = JsonObject,
TOutputParams extends JsonObject = JsonObject,
TInputSchema extends JsonObject = JsonObject,
TOutputSchema extends JsonObject = JsonObject,
TActionInput extends JsonObject = TInputParams,
TActionOutput extends JsonObject = TOutputParams,
>(
action: TemplateActionOptions<
TActionInput,
TActionOutput,
TInputSchema,
TOutputSchema,
'v1'
>,
): TemplateAction<TActionInput, TActionOutput, 'v1'>;
// @public @deprecated (undocumented)
export function createTemplateAction<
TInputParams extends JsonObject = JsonObject,
TOutputParams extends JsonObject = JsonObject,
TInputSchema extends z.ZodType = z.ZodType,
TOutputSchema extends z.ZodType = z.ZodType,
TActionInput extends JsonObject = z.infer<TInputSchema>,
TActionOutput extends JsonObject = z.infer<TOutputSchema>,
>(
action: TemplateActionOptions<
TActionInput,
TActionOutput,
TInputSchema,
TOutputSchema,
'v1'
>,
): TemplateAction<TActionInput, TActionOutput, 'v1'>;
// @public
export function createTemplateAction<
TInputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TOutputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TInputSchema extends
| {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
| ((zImpl: typeof z) => z.ZodType),
TOutputSchema extends
| {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
| ((zImpl: typeof z) => z.ZodType),
>(
action: TemplateActionOptions<
{
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
},
{
[key in keyof TOutputSchema]: z.infer<ReturnType<TOutputSchema[key]>>;
},
TInputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? {
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
}
: TInputSchema extends (zImpl: typeof z) => z.ZodType
? z.infer<ReturnType<TInputSchema>>
: never,
TOutputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? {
[key in keyof TOutputSchema]: z.infer<ReturnType<TOutputSchema[key]>>;
}
: TOutputSchema extends (zImpl: typeof z) => z.ZodType
? z.infer<ReturnType<TOutputSchema>>
: never,
TInputSchema,
TOutputSchema,
'v2'
>,
): TemplateAction<
FlattenOptionalProperties<{
[key in keyof TInputSchema]: z.output<ReturnType<TInputSchema[key]>>;
}>,
FlattenOptionalProperties<{
[key in keyof TOutputSchema]: z.output<ReturnType<TOutputSchema[key]>>;
}>,
FlattenOptionalProperties<
TInputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? {
[key in keyof TInputSchema]: z.output<ReturnType<TInputSchema[key]>>;
}
: TInputSchema extends (zImpl: typeof z) => z.ZodType
? z.output<ReturnType<TInputSchema>>
: never
>,
FlattenOptionalProperties<
TOutputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? {
[key in keyof TOutputSchema]: z.output<
ReturnType<TOutputSchema[key]>
>;
}
: TOutputSchema extends (zImpl: typeof z) => z.ZodType
? z.output<ReturnType<TOutputSchema>>
: never
>,
'v2'
>;
@@ -513,7 +481,7 @@ export type TaskStatus =
export type TemplateAction<
TActionInput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1',
TSchemaType extends 'v2' = 'v2',
> = {
id: string;
description?: string;
@@ -536,18 +504,20 @@ export type TemplateActionOptions<
TActionInput extends JsonObject = {},
TActionOutput extends JsonObject = {},
TInputSchema extends
| JsonObject
| z.ZodType
| {
[key in string]: (zImpl: typeof z) => z.ZodType;
} = JsonObject,
}
| ((zImpl: typeof z) => z.ZodType) = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TOutputSchema extends
| JsonObject
| z.ZodType
| {
[key in string]: (zImpl: typeof z) => z.ZodType;
} = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1' | 'v2',
}
| ((zImpl: typeof z) => z.ZodType) = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TSchemaType extends 'v2' = 'v2',
> = {
id: string;
description?: string;
@@ -14,85 +14,8 @@
* limitations under the License.
*/
import { createTemplateAction } from './createTemplateAction';
import { z } from 'zod';
describe('createTemplateAction', () => {
it('should allow creating with jsonschema and use the old deprecated types', () => {
const action = createTemplateAction<{ repoUrl: string }, { test: string }>({
id: 'test',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: { type: 'string' },
},
},
output: {
type: 'object',
required: ['test'],
properties: {
test: { type: 'string' },
},
},
},
handler: async ctx => {
// @ts-expect-error - repoUrl is string
const a: number = ctx.input.repoUrl;
const b: string = ctx.input.repoUrl;
expect(b).toBeDefined();
const stream = ctx.logStream;
expect(stream).toBeDefined();
ctx.output('test', 'value');
// @ts-expect-error - not valid output type
ctx.output('test', 4);
// @ts-expect-error - not valid output name
ctx.output('test2', 'value');
},
});
expect(action).toBeDefined();
});
it('should allow creating with zod and use the old deprecated types', () => {
const action = createTemplateAction({
id: 'test',
schema: {
input: z.object({
repoUrl: z.string(),
}),
output: z.object({
test: z.string(),
}),
},
handler: async ctx => {
// @ts-expect-error - repoUrl is string
const a: number = ctx.input.repoUrl;
const b: string = ctx.input.repoUrl;
expect(b).toBeDefined();
const stream = ctx.logStream;
expect(stream).toBeDefined();
ctx.output('test', 'value');
// @ts-expect-error - not valid output type
ctx.output('test', 4);
// @ts-expect-error - not valid output name
ctx.output('test2', 'value');
},
});
expect(action).toBeDefined();
});
it('should allow creating with new first class zod support', () => {
const action = createTemplateAction({
id: 'test',
@@ -107,14 +30,10 @@ describe('createTemplateAction', () => {
handler: async ctx => {
// @ts-expect-error - repoUrl is string
const a: number = ctx.input.repoUrl;
const b: string = ctx.input.repoUrl;
expect(b).toBeDefined();
// @ts-expect-error - logStream is not available
const stream = ctx.logStream;
expect(stream).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
[a, b];
ctx.output('test', 'value');
@@ -128,4 +47,56 @@ describe('createTemplateAction', () => {
expect(action).toBeDefined();
});
it('should allow creating with a function for input and output schema for more complex types', () => {
const action = createTemplateAction({
id: 'test',
schema: {
input: z =>
z.union([
z.object({
repoUrl: z.string(),
}),
z.object({
numberThing: z.number(),
}),
]),
output: z =>
z.object({
test: z.string(),
}),
},
handler: async ctx => {
ctx.output('test', 'value');
// @ts-expect-error - not valid output type
ctx.output('test', 4);
// @ts-expect-error - not valid output name
ctx.output('test2', 'value');
if ('repoUrl' in ctx.input) {
// @ts-expect-error - not valid input type
const a: number = ctx.input.repoUrl;
const b: string = ctx.input.repoUrl;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
[a, b];
}
if ('numberThing' in ctx.input) {
const a: number = ctx.input.numberThing;
// @ts-expect-error - not valid input type
const b: string = ctx.input.numberThing;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
[a, b];
}
},
});
expect(action).toBeDefined();
});
});
@@ -30,14 +30,18 @@ export type TemplateActionOptions<
TActionInput extends JsonObject = {},
TActionOutput extends JsonObject = {},
TInputSchema extends
| JsonObject
| z.ZodType
| { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject,
| { [key in string]: (zImpl: typeof z) => z.ZodType }
| ((zImpl: typeof z) => z.ZodType) = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TOutputSchema extends
| JsonObject
| z.ZodType
| { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1' | 'v2',
| {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
| ((zImpl: typeof z) => z.ZodType) = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TSchemaType extends 'v2' = 'v2',
> = {
id: string;
description?: string;
@@ -62,109 +66,82 @@ type FlattenOptionalProperties<T extends { [key in string]: unknown }> = Expand<
[K in keyof T as undefined extends T[K] ? K : never]?: T[K];
}
>;
/**
* @public
* @deprecated migrate to using the new built in zod schema definitions for schemas
*/
export function createTemplateAction<
TInputParams extends JsonObject = JsonObject,
TOutputParams extends JsonObject = JsonObject,
TInputSchema extends JsonObject = JsonObject,
TOutputSchema extends JsonObject = JsonObject,
TActionInput extends JsonObject = TInputParams,
TActionOutput extends JsonObject = TOutputParams,
>(
action: TemplateActionOptions<
TActionInput,
TActionOutput,
TInputSchema,
TOutputSchema,
'v1'
>,
): TemplateAction<TActionInput, TActionOutput, 'v1'>;
/**
* @public
* @deprecated migrate to using the new built in zod schema definitions for schemas
*/
export function createTemplateAction<
TInputParams extends JsonObject = JsonObject,
TOutputParams extends JsonObject = JsonObject,
TInputSchema extends z.ZodType = z.ZodType,
TOutputSchema extends z.ZodType = z.ZodType,
TActionInput extends JsonObject = z.infer<TInputSchema>,
TActionOutput extends JsonObject = z.infer<TOutputSchema>,
>(
action: TemplateActionOptions<
TActionInput,
TActionOutput,
TInputSchema,
TOutputSchema,
'v1'
>,
): TemplateAction<TActionInput, TActionOutput, 'v1'>;
/**
* 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 function createTemplateAction<
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
TInputSchema extends
| { [key in string]: (zImpl: typeof z) => z.ZodType }
| ((zImpl: typeof z) => z.ZodType),
TOutputSchema extends
| { [key in string]: (zImpl: typeof z) => z.ZodType }
| ((zImpl: typeof z) => z.ZodType),
>(
action: TemplateActionOptions<
{
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
},
{
[key in keyof TOutputSchema]: z.infer<ReturnType<TOutputSchema[key]>>;
},
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
? {
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
}
: TInputSchema extends (zImpl: typeof z) => z.ZodType
? z.infer<ReturnType<TInputSchema>>
: never,
TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
? {
[key in keyof TOutputSchema]: z.infer<ReturnType<TOutputSchema[key]>>;
}
: TOutputSchema extends (zImpl: typeof z) => z.ZodType
? z.infer<ReturnType<TOutputSchema>>
: never,
TInputSchema,
TOutputSchema,
'v2'
>,
): TemplateAction<
FlattenOptionalProperties<{
[key in keyof TInputSchema]: z.output<ReturnType<TInputSchema[key]>>;
}>,
FlattenOptionalProperties<{
[key in keyof TOutputSchema]: z.output<ReturnType<TOutputSchema[key]>>;
}>,
FlattenOptionalProperties<
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
? {
[key in keyof TInputSchema]: z.output<ReturnType<TInputSchema[key]>>;
}
: TInputSchema extends (zImpl: typeof z) => z.ZodType
? z.output<ReturnType<TInputSchema>>
: never
>,
FlattenOptionalProperties<
TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
? {
[key in keyof TOutputSchema]: z.output<
ReturnType<TOutputSchema[key]>
>;
}
: TOutputSchema extends (zImpl: typeof z) => z.ZodType
? z.output<ReturnType<TOutputSchema>>
: never
>,
'v2'
>;
export function createTemplateAction<
TInputParams extends JsonObject = JsonObject,
TOutputParams extends JsonObject = JsonObject,
TInputSchema extends
| JsonObject
| z.ZodType
| { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject,
TOutputSchema extends
| JsonObject
| z.ZodType
| { [key in string]: (zImpl: typeof z) => z.ZodType } = JsonObject,
TActionInput extends JsonObject = TInputSchema extends z.ZodType<
any,
any,
infer IReturn
>
? IReturn
: TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType } = {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TActionInput extends JsonObject = TInputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? Expand<{
[key in keyof TInputSchema]: z.infer<ReturnType<TInputSchema[key]>>;
}>
: TInputParams,
TActionOutput extends JsonObject = TOutputSchema extends z.ZodType<
any,
any,
infer IReturn
>
? IReturn
: TOutputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
: never,
TActionOutput extends JsonObject = TOutputSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
}
? Expand<{
[key in keyof TOutputSchema]: z.infer<ReturnType<TOutputSchema[key]>>;
}>
: TOutputParams,
: never,
>(
action: TemplateActionOptions<
TActionInput,
@@ -172,13 +149,7 @@ export function createTemplateAction<
TInputSchema,
TOutputSchema
>,
): TemplateAction<
TActionInput,
TActionOutput,
TInputSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }
? 'v2'
: 'v1'
> {
): TemplateAction<TActionInput, TActionOutput, 'v2'> {
const { inputSchema, outputSchema } = parseSchemas(
action as TemplateActionOptions<any, any, any>,
);
+57 -127
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { Writable } from 'stream';
import { JsonObject, JsonValue } from '@backstage/types';
import { TaskSecrets } from '../tasks';
import { TemplateInfo } from '@backstage/plugin-scaffolder-common';
@@ -32,143 +30,75 @@ import {
export type ActionContext<
TActionInput extends JsonObject,
TActionOutput extends JsonObject = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1',
> = TSchemaType extends 'v2'
? {
logger: LoggerService;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
/**
* Creates a temporary directory for use by the action, which is then cleaned up automatically.
*/
createTemporaryDirectory(): Promise<string>;
_TSchemaType extends 'v2' = 'v2',
> = {
logger: LoggerService;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
/**
* Creates a temporary directory for use by the action, which is then cleaned up automatically.
*/
createTemporaryDirectory(): Promise<string>;
/**
* Get the credentials for the current request
*/
getInitiatorCredentials(): Promise<BackstageCredentials>;
/**
* Get the credentials for the current request
*/
getInitiatorCredentials(): Promise<BackstageCredentials>;
/**
* Task information
*/
task: {
id: string;
};
/**
* Task information
*/
task: {
id: string;
};
templateInfo?: TemplateInfo;
templateInfo?: TemplateInfo;
/**
* Whether this action invocation is a dry-run or not.
* This will only ever be true if the actions as marked as supporting dry-runs.
*/
isDryRun?: boolean;
/**
* Whether this action invocation is a dry-run or not.
* This will only ever be true if the actions as marked as supporting dry-runs.
*/
isDryRun?: boolean;
/**
* The user which triggered the action.
*/
user?: {
/**
* The decorated entity from the Catalog
*/
entity?: UserEntity;
/**
* An entity ref for the author of the task
*/
ref?: string;
};
/**
* The user which triggered the action.
*/
user?: {
/**
* The decorated entity from the Catalog
*/
entity?: UserEntity;
/**
* An entity ref for the author of the task
*/
ref?: string;
};
/**
* Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
*/
signal?: AbortSignal;
/**
* Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
*/
signal?: AbortSignal;
/**
* Optional value of each invocation
*/
each?: JsonObject;
}
: /** @deprecated **/
{
// TODO(blam): move this to LoggerService
logger: Logger;
/** @deprecated - use `ctx.logger` instead */
logStream: Writable;
secrets?: TaskSecrets;
workspacePath: string;
input: TActionInput;
checkpoint<T extends JsonValue | void>(opts: {
key: string;
fn: () => Promise<T> | T;
}): Promise<T>;
output(
name: keyof TActionOutput,
value: TActionOutput[keyof TActionOutput],
): void;
/**
* Creates a temporary directory for use by the action, which is then cleaned up automatically.
*/
createTemporaryDirectory(): Promise<string>;
/**
* Get the credentials for the current request
*/
getInitiatorCredentials(): Promise<BackstageCredentials>;
/**
* Task information
*/
task: {
id: string;
};
templateInfo?: TemplateInfo;
/**
* Whether this action invocation is a dry-run or not.
* This will only ever be true if the actions as marked as supporting dry-runs.
*/
isDryRun?: boolean;
/**
* The user which triggered the action.
*/
user?: {
/**
* The decorated entity from the Catalog
*/
entity?: UserEntity;
/**
* An entity ref for the author of the task
*/
ref?: string;
};
/**
* Implement the signal to make your custom step abortable https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal
*/
signal?: AbortSignal;
/**
* Optional value of each invocation
*/
each?: JsonObject;
};
/**
* Optional value of each invocation
*/
each?: JsonObject;
};
/** @public */
export type TemplateAction<
TActionInput extends JsonObject = JsonObject,
TActionOutput extends JsonObject = JsonObject,
TSchemaType extends 'v1' | 'v2' = 'v1',
TSchemaType extends 'v2' = 'v2',
> = {
id: string;
description?: string;
+21 -19
View File
@@ -130,11 +130,7 @@ function checkRequiredParams(repoUrl: URL, ...params: string[]) {
}
}
const isZodSchema = (schema: unknown): schema is z.ZodType => {
return typeof schema === 'object' && !!schema && 'safeParseAsync' in schema;
};
const isNativeZodSchema = (
const isKeyValueZodCallback = (
schema: unknown,
): schema is { [key in string]: (zImpl: typeof z) => z.ZodType } => {
return (
@@ -144,23 +140,20 @@ const isNativeZodSchema = (
);
};
const isZodFunctionDefinition = (
schema: unknown,
): schema is (zImpl: typeof z) => z.ZodType => {
return typeof schema === 'function';
};
export const parseSchemas = (
action: TemplateActionOptions,
action: TemplateActionOptions<any, any, any>,
): { inputSchema?: Schema; outputSchema?: Schema } => {
if (!action.schema) {
return { inputSchema: undefined, outputSchema: undefined };
}
if (isZodSchema(action.schema.input)) {
return {
inputSchema: zodToJsonSchema(action.schema.input) as Schema,
outputSchema: isZodSchema(action.schema.output)
? (zodToJsonSchema(action.schema.output) as Schema)
: undefined,
};
}
if (isNativeZodSchema(action.schema.input)) {
if (isKeyValueZodCallback(action.schema.input)) {
const input = z.object(
Object.fromEntries(
Object.entries(action.schema.input).map(([k, v]) => [k, v(z)]),
@@ -169,7 +162,7 @@ export const parseSchemas = (
return {
inputSchema: zodToJsonSchema(input) as Schema,
outputSchema: isNativeZodSchema(action.schema.output)
outputSchema: isKeyValueZodCallback(action.schema.output)
? (zodToJsonSchema(
z.object(
Object.fromEntries(
@@ -181,9 +174,18 @@ export const parseSchemas = (
};
}
if (isZodFunctionDefinition(action.schema.input)) {
return {
inputSchema: zodToJsonSchema(action.schema.input(z)) as Schema,
outputSchema: isZodFunctionDefinition(action.schema.output)
? (zodToJsonSchema(action.schema.output(z)) as Schema)
: undefined,
};
}
return {
inputSchema: action.schema.input,
outputSchema: action.schema.output,
inputSchema: undefined,
outputSchema: undefined,
};
};