Merge pull request #31126 from backstage/blam/scaffolder-actions-registry

`scaffolder`: Allow executing `ActionsRegistry` actions from the Scaffolder
This commit is contained in:
Ben Lambert
2025-09-12 15:08:17 +02:00
committed by GitHub
6 changed files with 113 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Added support for executing actions from the `ActionsRegistry` in the `scaffolder-backend`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Updating `catalog:get-catalog-entity` action to be `readOnly` and non destructive
@@ -28,6 +28,11 @@ export const createGetCatalogEntityAction = ({
actionsRegistry.register({
name: 'get-catalog-entity',
title: 'Get Catalog Entity',
attributes: {
destructive: false,
readOnly: true,
idempotent: true,
},
description: `
This allows you to get a single entity from the software catalog.
Each entity in the software catalog has a unique name, kind, and namespace. The default namespace is "default".
@@ -56,6 +56,7 @@ import {
convertFiltersToRecord,
convertGlobalsToRecord,
} from './util/templating';
import { actionsServiceRef } from '@backstage/backend-plugin-api/alpha';
/**
* Scaffolder plugin
@@ -139,6 +140,7 @@ export const scaffolderPlugin = createBackendPlugin({
auditor: coreServices.auditor,
catalog: catalogServiceRef,
events: eventsServiceRef,
actionsRegistry: actionsServiceRef,
},
async init({
logger,
@@ -153,6 +155,7 @@ export const scaffolderPlugin = createBackendPlugin({
permissions,
events,
auditor,
actionsRegistry,
}) {
const log = loggerToWinstonLogger(logger);
const integrations = ScmIntegrations.fromConfig(config);
@@ -222,6 +225,7 @@ export const scaffolderPlugin = createBackendPlugin({
additionalWorkspaceProviders,
events,
auditor,
actionsRegistry,
});
httpRouter.use(router);
},
@@ -68,6 +68,8 @@ import {
import { createDefaultFilters } from '../lib/templating/filters/createDefaultFilters';
import { createRouter } from './router';
import { DatabaseTaskStore } from '../scaffolder/tasks/DatabaseTaskStore';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
function createDatabase(): DatabaseService {
return DatabaseManager.fromConfig(
@@ -178,6 +180,7 @@ const createTestRouter = async (
| Record<string, TemplateGlobal>
| CreatedTemplateGlobal[];
autocompleteHandlers?: Record<string, AutocompleteHandler>;
actionsRegistry?: ActionsService;
} = {},
) => {
const logger = mockServices.logger.mock({
@@ -246,6 +249,7 @@ const createTestRouter = async (
}),
createDebugLogAction(),
],
actionsRegistry: overrides.actionsRegistry ?? actionsRegistryServiceMock(),
});
router.use(mockErrorHandler());
@@ -275,6 +279,49 @@ describe('scaffolder router', () => {
expect(response.body[0].id).toBeDefined();
expect(response.body.length).toBe(2);
});
it('should include actions from the remote actions registry', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
mockActionsRegistry.register({
name: 'my-demo-action',
title: 'Test',
description: 'Test',
schema: {
input: z => z.object({ name: z.string() }),
output: z => z.object({ name: z.string() }),
},
action: async () => ({ output: { name: 'test' } }),
});
const { router } = await createTestRouter({
actionsRegistry: mockActionsRegistry,
});
const response = await request(router).get('/v2/actions').send();
expect(response.status).toEqual(200);
expect(response.body.length).toBe(3);
expect(response.body).toContainEqual({
description: 'Test',
examples: [],
id: 'test:my-demo-action',
schema: {
input: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: { name: { type: 'string' } },
required: ['name'],
type: 'object',
},
output: {
$schema: 'http://json-schema.org/draft-07/schema#',
additionalProperties: false,
properties: { name: { type: 'string' } },
required: ['name'],
type: 'object',
},
},
});
});
});
describe('GET /v2/templating-extensions', () => {
@@ -26,6 +26,7 @@ import {
resolveSafeChildPath,
SchedulerService,
} from '@backstage/backend-plugin-api';
import { Schema } from 'jsonschema';
import {
CompoundEntityRef,
Entity,
@@ -131,6 +132,8 @@ import {
} from './rules';
import { TaskFilters } from '@backstage/plugin-scaffolder-node';
import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { isPlainObject } from 'lodash';
/**
* RouterOptions
@@ -163,6 +166,7 @@ export interface RouterOptions {
events?: EventsService;
auditor?: AuditorService;
autocompleteHandlers?: Record<string, AutocompleteHandler>;
actionsRegistry: ActionsService;
}
function isSupportedTemplate(entity: TemplateEntityV1beta3) {
@@ -198,7 +202,7 @@ export async function createRouter(
config,
database,
catalog,
actions,
actions = [],
scheduler,
additionalTemplateFilters,
additionalTemplateGlobals,
@@ -210,6 +214,7 @@ export async function createRouter(
auth,
httpAuth,
auditor,
actionsRegistry,
} = options;
const concurrentTasksLimit =
@@ -301,7 +306,47 @@ export async function createRouter(
workers.push(worker);
}
actions?.forEach(action => actionRegistry.register(action));
// 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());