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` 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/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". 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.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 4144c552fb..16a6000224 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 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', () => { diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 8296a6003f..2a0792346d 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 { isPlainObject } 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,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());