chore: converting the rest of the actions

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-06-04 11:10:07 +02:00
parent 1b5e061d0d
commit 89a941dad8
17 changed files with 400 additions and 505 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Migrating to latest action format
+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,45 +36,35 @@ 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,
async handler(ctx) {
ctx.logger.info('Fetching plain content from remote URL');
ctx.logger.info('Fetching plain file content from remote URL');
// Finally move the template result into the task workspace
const outputPath = resolveSafeChildPath(
ctx.workspacePath,
ctx.input.targetPath,
@@ -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,7 +16,6 @@
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';
@@ -25,80 +24,60 @@ import { examples } from './rename.examples';
* @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,
async handler(ctx) {
if (!Array.isArray(ctx.input?.files)) {
throw new InputError('files must be an Array');
}
for (const file of ctx.input.files) {
if (!file.from || !file.to) {
throw new InputError('each file must have a from and to property');
}
const sourceFilepath = resolveSafeChildPath(
ctx.workspacePath,
file.from,
);
const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to);
try {
await fs.move(sourceFilepath, destFilepath, {
overwrite: file.overwrite ?? false,
});
ctx.logger.info(
`File ${sourceFilepath} renamed to ${destFilepath} successfully`,
if (!fs.existsSync(sourceFilepath)) {
throw new Error(
`File ${sourceFilepath} does not exist, so rename cannot succeed.`,
);
} catch (err) {
ctx.logger.error(
`Failed to rename file ${sourceFilepath} to ${destFilepath}:`,
err,
);
throw err;
}
const destExists = fs.existsSync(destFilepath);
if (destExists && !file.overwrite) {
throw new Error(
`File ${destFilepath} already exists, refusing to overwrite.`,
);
}
await fs.move(sourceFilepath, destFilepath, { overwrite: true });
ctx.logger.info(`Moved file from ${sourceFilepath} to ${destFilepath}`);
}
},
});
@@ -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;