From 987cd8a6f564e68ff748ca3d49cc530a44db438f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 11 Sep 2025 13:31:41 +0200 Subject: [PATCH 1/6] chore: make the catalog:get-catalog-entity action readOnly Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../src/actions/createGetCatalogEntityAction.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts index a1c7cefe78..b9f98291fd 100644 --- a/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts +++ b/plugins/catalog-backend/src/actions/createGetCatalogEntityAction.ts @@ -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". From e934a2777571f7c04f01f1c78ca3372971f8a031 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 08:58:17 +0200 Subject: [PATCH 2/6] chore: added the ability to pull actions from actions registry Signed-off-by: benjdlambert --- .changeset/dark-teams-give.md | 5 ++ .../src/ScaffolderPlugin.ts | 4 ++ .../scaffolder-backend/src/service/router.ts | 46 ++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 .changeset/dark-teams-give.md diff --git a/.changeset/dark-teams-give.md b/.changeset/dark-teams-give.md new file mode 100644 index 0000000000..692f015618 --- /dev/null +++ b/.changeset/dark-teams-give.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Updating `catalog:get-catalog-entity` action to be `readOnly` and non destructive diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 7ef3abcbe3..a00da9e524 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -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); }, diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 8296a6003f..2c9df0c8c4 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -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 { isObject } from 'lodash'; /** * RouterOptions @@ -163,6 +166,7 @@ export interface RouterOptions { events?: EventsService; auditor?: AuditorService; autocompleteHandlers?: Record; + 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,44 @@ 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. + 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 (isObject(output)) { + for (const [key, value] of Object.entries(output)) { + 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()); From a3edb2eb408793779e71e105d9d7439a0df6364f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 08:59:59 +0200 Subject: [PATCH 3/6] chore: added comment] Signed-off-by: benjdlambert --- plugins/scaffolder-backend/src/service/router.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 2c9df0c8c4..a48bcb46b0 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -309,6 +309,9 @@ export async function createRouter( // 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(), }); From 53e19ab6d84851f954e9fd4092c82900986d68c1 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 09:10:58 +0200 Subject: [PATCH 4/6] chore: added tests Signed-off-by: benjdlambert --- .../src/service/router.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4144c552fb..60fcce0739 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -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 | CreatedTemplateGlobal[]; autocompleteHandlers?: Record; + 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 actiosn 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', () => { From a57185fd484bd563f1db6887d9807222231dfa64 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 09:11:40 +0200 Subject: [PATCH 5/6] chore: changeset Signed-off-by: benjdlambert --- .changeset/bumpy-eyes-divide.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bumpy-eyes-divide.md diff --git a/.changeset/bumpy-eyes-divide.md b/.changeset/bumpy-eyes-divide.md new file mode 100644 index 0000000000..6300f7aaf6 --- /dev/null +++ b/.changeset/bumpy-eyes-divide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added support for executing actions from the `ActionsRegistry` in the `scaffolder-backend` From ca0aeea3427591f3f76161b5b2c8d52b791a5501 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 12 Sep 2025 10:23:20 +0200 Subject: [PATCH 6/6] chore: code-review comments Signed-off-by: benjdlambert --- plugins/scaffolder-backend/src/service/router.test.ts | 2 +- plugins/scaffolder-backend/src/service/router.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 60fcce0739..16a6000224 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -280,7 +280,7 @@ describe('scaffolder router', () => { expect(response.body.length).toBe(2); }); - it('should include actiosn from the remote actions registry', async () => { + it('should include actions from the remote actions registry', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); mockActionsRegistry.register({ name: 'my-demo-action', diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a48bcb46b0..2a0792346d 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -133,7 +133,7 @@ import { import { TaskFilters } from '@backstage/plugin-scaffolder-node'; import { ActionsService } from '@backstage/backend-plugin-api/alpha'; -import { isObject } from 'lodash'; +import { isPlainObject } from 'lodash'; /** * RouterOptions @@ -335,8 +335,8 @@ export async function createRouter( credentials: await ctx.getInitiatorCredentials(), }); - if (isObject(output)) { - for (const [key, value] of Object.entries(output)) { + if (isPlainObject(output)) { + for (const [key, value] of Object.entries(output as JsonObject)) { ctx.output(key as keyof typeof output, value); } }