Merge pull request #31244 from drodil/scaffolder_actions_registry2

fix: distributed actions visibility in scaffolder
This commit is contained in:
Ben Lambert
2025-10-14 11:54:59 +01:00
committed by GitHub
10 changed files with 432 additions and 82 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Fixed distributed actions not being visible in the scaffolder template actions.
Depending on the plugin startup order, some of the distributed actions were not being registered correctly,
causing them to be invisible in the scaffolder template actions list.
+3 -1
View File
@@ -65,7 +65,9 @@ backend:
- host: example.com
- host: '*.mozilla.org'
# workingDirectory: /tmp # Use this to configure a working directory for the scaffolder, defaults to the OS temp-dir
actions:
pluginSources:
- catalog
# See README.md in the proxy-backend plugin for information on the configuration format
proxy:
endpoints:
@@ -0,0 +1,268 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConflictError, NotFoundError } from '@backstage/errors';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { DefaultTemplateActionRegistry } from './TemplateActionRegistry';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
describe('DefaultTemplateActionRegistry', () => {
let registry: DefaultTemplateActionRegistry;
let mockActionsService: ReturnType<typeof actionsRegistryServiceMock>;
let mockLogger: ReturnType<typeof mockServices.logger.mock>;
beforeEach(() => {
mockActionsService = actionsRegistryServiceMock();
mockLogger = mockServices.logger.mock();
registry = new DefaultTemplateActionRegistry(
mockActionsService,
mockLogger,
);
});
describe('register', () => {
it('should register a template action successfully', () => {
const action = createTemplateAction({
id: 'test-action',
description: 'Test action',
handler: jest.fn(),
});
expect(() => registry.register(action)).not.toThrow();
});
it('should throw ConflictError when registering action with duplicate ID', () => {
const action1 = createTemplateAction({
id: 'duplicate-action',
description: 'First action',
handler: jest.fn(),
});
const action2 = createTemplateAction({
id: 'duplicate-action',
description: 'Second action',
handler: jest.fn(),
});
registry.register(action1);
expect(() => registry.register(action2)).toThrow(ConflictError);
expect(() => registry.register(action2)).toThrow(
"Template action with ID 'duplicate-action' has already been registered",
);
});
});
describe('get', () => {
it('should return a registered action', async () => {
const action = createTemplateAction({
id: 'test-action',
description: 'Test action',
handler: jest.fn(),
});
registry.register(action);
const result = await registry.get('test-action', {
credentials: mockCredentials.user(),
});
expect(result).toBe(action);
});
it('should return action from ActionsService', async () => {
mockActionsService.register({
name: 'service-action',
title: 'Service Action',
description: 'Service action',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
attributes: {
readOnly: true,
destructive: false,
},
action: async () => ({ output: {} }),
});
const result = await registry.get('test:service-action', {
credentials: mockCredentials.user(),
});
expect(result.id).toBe('test:service-action');
expect(result.description).toBe('Service action');
expect(result.supportsDryRun).toBe(true);
});
it('should throw NotFoundError when action is not found', async () => {
await expect(
registry.get('non-existent-action', {
credentials: mockCredentials.user(),
}),
).rejects.toThrow(NotFoundError);
await expect(
registry.get('non-existent-action', {
credentials: mockCredentials.user(),
}),
).rejects.toThrow(
"Template action with ID 'non-existent-action' is not registered",
);
});
});
describe('list', () => {
it('should return empty map when no actions are registered', async () => {
const result = await registry.list({
credentials: mockCredentials.user(),
});
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it('should return all registered actions', async () => {
const action1 = createTemplateAction({
id: 'action-1',
description: 'First action',
handler: jest.fn(),
});
const action2 = createTemplateAction({
id: 'action-2',
description: 'Second action',
handler: jest.fn(),
});
registry.register(action1);
registry.register(action2);
const result = await registry.list({
credentials: mockCredentials.user(),
});
expect(result.size).toBe(2);
expect(result.get('action-1')).toBe(action1);
expect(result.get('action-2')).toBe(action2);
});
it('should include actions from ActionsService', async () => {
mockActionsService.register({
name: 'service-action',
title: 'Service Action',
description: 'Service action',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
attributes: {
readOnly: true,
destructive: false,
},
action: async () => ({ output: {} }),
});
const result = await registry.list({
credentials: mockCredentials.user(),
});
expect(result.size).toBe(1);
const action = result.get('test:service-action');
expect(action).toBeDefined();
expect(action!.id).toBe('test:service-action');
expect(action!.description).toBe('Service action');
expect(action!.supportsDryRun).toBe(true);
});
it('should prioritize locally registered actions over service actions', async () => {
const localAction = createTemplateAction({
id: 'test:same-id',
description: 'Local action',
handler: jest.fn(),
});
mockActionsService.register({
name: 'same-id',
title: 'Same ID',
description: 'Service action',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
action: async () => ({ output: {} }),
});
registry.register(localAction);
const result = await registry.list({
credentials: mockCredentials.user(),
});
expect(result.get('test:same-id')).toBe(localAction);
expect(mockLogger.warn).toHaveBeenCalledWith(
"Template action with ID 'test:same-id' has already been registered, skipping action provided by actions service",
);
});
it('should set supportsDryRun to false for destructive actions', async () => {
mockActionsService.register({
name: 'destructive-action',
title: 'Destructive Action',
description: 'Destructive action',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
attributes: {
readOnly: false,
destructive: true,
},
action: async () => ({ output: {} }),
});
const result = await registry.list({
credentials: mockCredentials.user(),
});
const action = result.get('test:destructive-action');
expect(action!.supportsDryRun).toBe(false);
});
it('should set supportsDryRun to false for non-readonly actions', async () => {
mockActionsService.register({
name: 'non-readonly-action',
title: 'Non-readonly Action',
description: 'Non-readonly action',
schema: {
input: z => z.object({}),
output: z => z.object({}),
},
attributes: {
readOnly: false,
destructive: false,
},
action: async () => ({ output: {} }),
});
const result = await registry.list({
credentials: mockCredentials.user(),
});
const action = result.get('test:non-readonly-action');
expect(action!.supportsDryRun).toBe(false);
});
});
});
@@ -16,13 +16,40 @@
import { ConflictError, NotFoundError } from '@backstage/errors';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import {
BackstageCredentials,
LoggerService,
} from '@backstage/backend-plugin-api';
import { isPlainObject } from 'lodash';
import { Schema } from 'jsonschema';
import { JsonObject } from '@backstage/types';
/**
* @internal
*/
export interface TemplateActionRegistry {
register(action: TemplateAction<any, any, any>): void;
get(
actionId: string,
options: { credentials: BackstageCredentials },
): Promise<TemplateAction<any, any, any>>;
list(options: {
credentials: BackstageCredentials;
}): Promise<Map<string, TemplateAction<any, any, any>>>;
}
/**
* Registry of all registered template actions.
*/
export class TemplateActionRegistry {
export class DefaultTemplateActionRegistry implements TemplateActionRegistry {
private readonly actions = new Map<string, TemplateAction>();
constructor(
private readonly actionsRegistry: ActionsService,
private readonly logger: LoggerService,
) {}
register(action: TemplateAction<any, any, any>) {
if (this.actions.has(action.id)) {
throw new ConflictError(
@@ -33,8 +60,11 @@ export class TemplateActionRegistry {
this.actions.set(action.id, action);
}
get(actionId: string): TemplateAction<any, any, any> {
const action = this.actions.get(actionId);
async get(
actionId: string,
options: { credentials: BackstageCredentials },
): Promise<TemplateAction<any, any, any>> {
const action = (await this.list(options)).get(actionId);
if (!action) {
throw new NotFoundError(
`Template action with ID '${actionId}' is not registered. See https://backstage.io/docs/features/software-templates/builtin-actions/ on how to add a new action module.`,
@@ -43,7 +73,49 @@ export class TemplateActionRegistry {
return action;
}
list(): TemplateAction<any, any, any>[] {
return [...this.actions.values()];
async list(options: {
credentials: BackstageCredentials;
}): Promise<Map<string, TemplateAction<any, any, any>>> {
const ret = new Map(this.actions);
const { actions } = await this.actionsRegistry.list({
credentials: options.credentials,
});
for (const action of actions) {
if (ret.has(action.id)) {
this.logger.warn(
`Template action with ID '${action.id}' has already been registered, skipping action provided by actions service`,
);
continue;
}
ret.set(action.id, {
id: action.id,
description: action.description,
examples: [],
supportsDryRun:
action.attributes?.readOnly === true &&
action.attributes?.destructive === false,
handler: async ctx => {
const { output } = await this.actionsRegistry.invoke({
id: action.id,
input: ctx.input,
credentials: await ctx.getInitiatorCredentials(),
});
if (isPlainObject(output)) {
for (const [key, value] of Object.entries(output as JsonObject)) {
ctx.output(key as keyof typeof output, value);
}
}
},
schema: {
input: action.schema.input as Schema,
output: action.schema.output as Schema,
},
});
}
return ret;
}
}
@@ -16,4 +16,7 @@
export * from './builtin';
export { TemplateActionRegistry } from './TemplateActionRegistry';
export {
type TemplateActionRegistry,
DefaultTemplateActionRegistry,
} from './TemplateActionRegistry';
@@ -16,24 +16,46 @@
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { TemplateActionRegistry } from '../actions';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
/** @internal */
export class DecoratedActionsRegistry extends TemplateActionRegistry {
export class DecoratedActionsRegistry implements TemplateActionRegistry {
private readonly innerActions: Map<string, TemplateAction> = new Map();
constructor(
private readonly innerRegistry: TemplateActionRegistry,
extraActions: Array<TemplateAction>,
) {
super();
for (const action of extraActions) {
this.register(action);
this.innerActions.set(action.id, action);
}
}
get(actionId: string): TemplateAction {
async get(
actionId: string,
options: { credentials: BackstageCredentials },
): Promise<TemplateAction> {
try {
return super.get(actionId);
} catch {
return this.innerRegistry.get(actionId);
return await this.innerRegistry.get(actionId, options);
} catch (e) {
if (!this.innerActions.has(actionId)) {
throw e;
}
return this.innerActions.get(actionId)!;
}
}
async list(options: {
credentials: BackstageCredentials;
}): Promise<Map<string, TemplateAction<any, any, any>>> {
const inner = await this.innerRegistry.list(options);
return new Map<string, TemplateAction<any, any, any>>([
...inner,
...this.innerActions,
]);
}
register(action: TemplateAction<any, any, any>): void {
this.innerRegistry.register(action);
}
}
@@ -41,9 +41,9 @@ import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuid } from 'uuid';
import { TemplateActionRegistry } from '../actions';
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
import { TemplateActionRegistry } from '../actions';
interface DryRunInput {
spec: TaskSpec;
@@ -15,15 +15,18 @@
*/
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
import { TemplateActionRegistry } from '../actions';
import {
DefaultTemplateActionRegistry,
TemplateActionRegistry,
} from '../actions';
import { ScmIntegrations } from '@backstage/integration';
import { JsonObject } from '@backstage/types';
import { ConfigReader } from '@backstage/config';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import {
createTemplateAction,
TaskSecrets,
TaskContext,
TaskSecrets,
} from '@backstage/plugin-scaffolder-node';
import { UserEntity } from '@backstage/catalog-model';
import {
@@ -36,6 +39,7 @@ import {
mockCredentials,
mockServices,
} from '@backstage/backend-test-utils';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
describe('NunjucksWorkflowRunner', () => {
let actionRegistry: TemplateActionRegistry;
@@ -104,7 +108,10 @@ describe('NunjucksWorkflowRunner', () => {
// This one is ESM-only
stripAnsi = await import('strip-ansi').then(m => m.default);
actionRegistry = new TemplateActionRegistry();
actionRegistry = new DefaultTemplateActionRegistry(
actionsRegistryServiceMock(),
mockServices.logger.mock(),
);
fakeActionHandler = jest.fn();
fakeTaskLog = jest.fn();
@@ -61,8 +61,8 @@ import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
import { BackstageLoggerTransport, WinstonLogger } from './logger';
import { convertFiltersToRecord } from '../../util/templating';
import {
CheckpointState,
CheckpointContext,
CheckpointState,
} from '@backstage/plugin-scaffolder-node/alpha';
type NunjucksWorkflowRunnerOptions = {
@@ -245,7 +245,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
return;
}
const action: TemplateAction<JsonObject> =
this.options.actionRegistry.get(step.action);
await this.options.actionRegistry.get(step.action, {
credentials: await task.getInitiatorCredentials(),
});
const { taskLogger } = createStepLogger({
task,
step,
@@ -26,7 +26,7 @@ import {
resolveSafeChildPath,
SchedulerService,
} from '@backstage/backend-plugin-api';
import { Schema } from 'jsonschema';
import { validate } from 'jsonschema';
import {
CompoundEntityRef,
Entity,
@@ -41,10 +41,10 @@ import { ScmIntegrations } from '@backstage/integration';
import { EventsService } from '@backstage/plugin-events-node';
import {
createConditionAuthorizer,
createPermissionIntegrationRouter,
createConditionTransformer,
ConditionTransformer,
createConditionAuthorizer,
createConditionTransformer,
createPermissionIntegrationRouter,
} from '@backstage/plugin-permission-node';
import {
TaskSpec,
@@ -53,11 +53,11 @@ import {
} from '@backstage/plugin-scaffolder-common';
import {
RESOURCE_TYPE_SCAFFOLDER_ACTION,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
RESOURCE_TYPE_SCAFFOLDER_TASK,
RESOURCE_TYPE_SCAFFOLDER_TEMPLATE,
scaffolderActionPermissions,
scaffolderTaskPermissions,
scaffolderPermissions,
scaffolderTaskPermissions,
scaffolderTemplatePermissions,
taskCancelPermission,
taskCreatePermission,
@@ -67,6 +67,7 @@ import {
} from '@backstage/plugin-scaffolder-common/alpha';
import {
TaskBroker,
TaskFilters,
TaskStatus,
TemplateAction,
TemplateFilter,
@@ -80,15 +81,14 @@ import {
} from '@backstage/plugin-scaffolder-node/alpha';
import { HumanDuration, JsonObject } from '@backstage/types';
import express from 'express';
import { validate } from 'jsonschema';
import { Duration } from 'luxon';
import { pathToFileURL } from 'url';
import { v4 as uuid } from 'uuid';
import { z } from 'zod';
import {
DatabaseTaskStore,
DefaultTemplateActionRegistry,
TaskWorker,
TemplateActionRegistry,
} from '../scaffolder';
import { createDryRunner } from '../scaffolder/dryrun';
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
@@ -115,25 +115,22 @@ import {
} from '../util/templating';
import { createDefaultFilters } from '../lib/templating/filters/createDefaultFilters';
import {
ScaffolderPermissionRuleInput,
TaskPermissionRuleInput,
isTaskPermissionRuleInput,
ActionPermissionRuleInput,
isActionPermissionRuleInput,
isTaskPermissionRuleInput,
isTemplatePermissionRuleInput,
ScaffolderPermissionRuleInput,
TaskPermissionRuleInput,
TemplatePermissionRuleInput,
} from './permissions';
import { CatalogService } from '@backstage/plugin-catalog-node';
import {
scaffolderActionRules,
scaffolderTemplateRules,
scaffolderTaskRules,
scaffolderTemplateRules,
} from './rules';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { isPlainObject } from 'lodash';
/**
* RouterOptions
@@ -272,7 +269,10 @@ export async function createRouter(
taskBroker = options.taskBroker;
}
const actionRegistry = new TemplateActionRegistry();
const actionRegistry = new DefaultTemplateActionRegistry(
actionsRegistry,
logger,
);
const templateExtensions = {
additionalTemplateFilters: convertFiltersToRecord(
@@ -306,48 +306,10 @@ export async function createRouter(
workers.push(worker);
}
// TODO(blam): it's a little unfortunate that you have to restart the scaffolder
// backend in order to pick these up. We should really just make `ActionsRegistry.get()` async
// and then we can move this logic into the there instead.
// But we can't make those changes until next major.
// Alternatively, we could look at setting up a periodic task that refreshes the actions registry, but
// not feeling that it's worth the complexity.
const { actions: distributedActions } = await actionsRegistry.list({
credentials: await auth.getOwnServiceCredentials(),
});
for (const action of actions) {
actionRegistry.register(action);
}
for (const action of distributedActions) {
actionRegistry.register({
id: action.id,
description: action.description,
examples: [],
supportsDryRun:
action.attributes?.readOnly === true &&
action.attributes?.destructive === false,
handler: async ctx => {
const { output } = await actionsRegistry.invoke({
id: action.id,
input: ctx.input,
credentials: await ctx.getInitiatorCredentials(),
});
if (isPlainObject(output)) {
for (const [key, value] of Object.entries(output as JsonObject)) {
ctx.output(key as keyof typeof output, value);
}
}
},
schema: {
input: action.schema.input as Schema,
output: action.schema.output as Schema,
},
});
}
const launchWorkers = () => workers.forEach(worker => worker.start());
const shutdownWorkers = async () => {
@@ -479,16 +441,20 @@ export async function createRouter(
eventId: 'action-fetch',
request: req,
});
const credentials = await httpAuth.credentials(req);
try {
const actionsList = actionRegistry.list().map(action => {
return {
id: action.id,
description: action.description,
examples: action.examples,
schema: action.schema,
};
});
const list = await actionRegistry.list({ credentials });
const actionsList = Array.from(list.values())
.map(action => {
return {
id: action.id,
description: action.description,
examples: action.examples,
schema: action.schema,
};
})
.sort((a, b) => a.id.localeCompare(b.id));
await auditorEvent?.success();