From 04592af95367e06933cfca0f22675a0327af7091 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 26 May 2025 18:27:24 +0200 Subject: [PATCH 01/10] feat: implementing ActionRegistry service to register distributed actions Signed-off-by: benjdlambert --- packages/backend-defaults/package.json | 7 +- .../backend-defaults/src/CreateBackend.ts | 2 + .../actionsRegistry/PluginActionsRegistry.ts | 128 ++++++ .../actionsRegistryServiceFactory.test.ts | 367 ++++++++++++++++++ .../actionsRegistryServiceFactory.ts | 57 +++ .../src/entrypoints/actionsRegistry/index.ts | 16 + packages/backend-plugin-api/package.json | 3 +- .../definitions/ActionsRegistryService.ts | 66 ++++ .../src/services/definitions/coreServices.ts | 15 + .../src/services/definitions/index.ts | 6 + yarn.lock | 2 + 11 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts create mode 100644 packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 600f68d3f4..67507dd606 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -20,6 +20,7 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", + "./actionsRegistry": "./src/entrypoints/actionsRegistry/index.ts", "./auditor": "./src/entrypoints/auditor/index.ts", "./auth": "./src/entrypoints/auth/index.ts", "./cache": "./src/entrypoints/cache/index.ts", @@ -45,6 +46,9 @@ "types": "src/index.ts", "typesVersions": { "*": { + "actionsRegistry": [ + "src/entrypoints/actionsRegistry/index.ts" + ], "auditor": [ "src/entrypoints/auditor/index.ts" ], @@ -188,7 +192,8 @@ "winston-transport": "^4.5.0", "yauzl": "^3.0.0", "yn": "^4.0.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zod-to-json-schema": "^3.20.4" }, "devDependencies": { "@aws-sdk/util-stream-node": "^3.350.0", diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index d618c94138..30a913af68 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -35,8 +35,10 @@ import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler'; import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo'; import { eventsServiceFactory } from '@backstage/plugin-events-node'; +import { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; export const defaultServiceFactories = [ + actionsRegistryServiceFactory, auditorServiceFactory, authServiceFactory, cacheServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts new file mode 100644 index 0000000000..8dedf40658 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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 { + ActionsRegistryAction, + HttpAuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import PromiseRouter from 'express-promise-router'; +import { Router, json } from 'express'; +import { z, ZodType } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { InputError, NotFoundError } from '@backstage/errors'; + +export class PluginActionsRegistry { + private constructor( + private readonly actions: Map< + string, + ActionsRegistryAction + >, + private readonly router: Router, + private readonly logger: LoggerService, + private readonly httpAuth: HttpAuthService, + ) { + this.bindRoutes(); + } + + static create({ + httpAuth, + logger, + }: { + httpAuth: HttpAuthService; + logger: LoggerService; + }): PluginActionsRegistry { + return new PluginActionsRegistry( + new Map(), + PromiseRouter(), + logger, + httpAuth, + ); + } + + getRouter(): Router { + return this.router; + } + + register( + options: ActionsRegistryAction, + ): void { + this.actions.set(options.id, options as ActionsRegistryAction); + } + + private bindRoutes() { + this.router.use(json()); + + this.router.get('/.backstage/v1/actions', (_, res) => { + return res.json({ + actions: Array.from(this.actions.entries()).map(([_id, action]) => ({ + ...action, + schema: { + input: action.schema?.input + ? zodToJsonSchema(action.schema.input) + : zodToJsonSchema(z.any()), + output: action.schema?.output + ? zodToJsonSchema(action.schema.output) + : zodToJsonSchema(z.any()), + }, + })), + }); + }); + + this.router.post( + '/.backstage/v1/actions/:actionId/invoke', + async (req, res) => { + const action = this.actions.get(req.params.actionId); + + if (!action) { + throw new NotFoundError(`Action "${req.params.actionId}" not found`); + } + + const input = action.schema?.input + ? action.schema.input.safeParse(req.body) + : ({ success: true, data: undefined } as const); + + if (!input.success) { + throw new InputError( + `Invalid input to action "${req.params.actionId}"`, + input.error, + ); + } + + const credentials = await this.httpAuth.credentials(req); + + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); + + const output = action.schema?.output + ? action.schema.output.safeParse(result) + : ({ success: true, data: result } as const); + + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, + ); + } + + return res.json({ response: output.data }); + }, + ); + } +} diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts new file mode 100644 index 0000000000..b3b518a8e9 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -0,0 +1,367 @@ +/* + * Copyright 2025 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 { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { httpRouterServiceFactory } from '../httpRouter'; +import request from 'supertest'; +import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; + +describe('actionsRegistryServiceFactory', () => { + const defaultServices = [ + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(), + }), + ]; + + describe('typescript tests', () => { + it('should properly infer the input types', () => { + createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + test: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: async ({ input: { test } }) => { + // @ts-expect-error - test is not a boolean + const _t: boolean = test; + return { ok: true }; + }, + }); + }, + }); + }, + }); + + expect(true).toBe(true); + }); + + it('should properly infer the output types', () => { + createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + test: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + // @ts-expect-error - ok is not a boolean + action: async () => { + return { ok: 'bo' }; + }, + }); + }, + }); + }, + }); + + expect(true).toBe(true); + }); + }); + + describe('/.backstage/v1/actions', () => { + it('should allow registering of actions', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: async () => ({ ok: true }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + }, + output: { + type: 'object', + properties: { + ok: { + type: 'boolean', + }, + }, + }, + }, + }, + ], + }); + }); + + it('should allow registering of actions with no schema', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + action: async () => ({ ok: true }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: {}, + output: {}, + }, + }, + ], + }); + }); + }); + + describe('/.backstage/v1/actions/:actionId/invoke', () => { + const mockAction = jest.fn(); + + beforeEach(() => { + mockAction.mockResolvedValue({ ok: true }); + }); + + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: mockAction, + }); + }, + }); + }, + }); + + it('should throw an error if the action is not found', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).post( + '/api/my-plugin/.backstage/v1/actions/test/invoke', + ); + + expect(status).toBe(404); + expect(body).toMatchObject({ + error: { + message: 'Action "test" not found', + }, + }); + }); + + it('should throw an error if the action input does not match the schema', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 123, + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: expect.stringMatching( + 'Invalid input to action "my-plugin:test"', + ), + }, + }); + }); + + it('should call the action with the input', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(mockAction).toHaveBeenCalledWith({ + input: { + name: 'test', + }, + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: { + type: 'user', + userEntityRef: 'user:default/mock', + }, + }, + logger: expect.anything(), + }); + }); + + it('should validate the output of the action if provided', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockResolvedValue({ ok: 'blob' }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: expect.stringMatching( + 'Invalid output from action "my-plugin:test"', + ), + }, + }); + }); + + it('should return the output of the action', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(status).toBe(200); + expect(body).toMatchObject({ response: { ok: true } }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts new file mode 100644 index 0000000000..424b60acc4 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2025 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 { + ActionsRegistryActionOptions, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { PluginActionsRegistry } from './PluginActionsRegistry'; +import { z, ZodType } from 'zod'; + +export const actionsRegistryServiceFactory = createServiceFactory({ + service: coreServices.actionsRegistry, + deps: { + metadata: coreServices.pluginMetadata, + httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + logger: coreServices.logger, + }, + factory: ({ metadata, httpRouter, httpAuth, logger }) => { + const pluginActionsRegistry = PluginActionsRegistry.create({ + httpAuth, + logger, + }); + + httpRouter.use(pluginActionsRegistry.getRouter()); + + return { + async register< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, + >(options: ActionsRegistryActionOptions) { + pluginActionsRegistry.register({ + ...options, + id: `${metadata.getId()}:${options.name}`, + schema: { + input: options.schema?.input?.(z), + output: options.schema?.output?.(z), + }, + }); + }, + }; + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts new file mode 100644 index 0000000000..ac65dd7a90 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 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. + */ +export { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 17ff606187..2f2c8fd552 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -63,7 +63,8 @@ "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", "knex": "^3.0.0", - "luxon": "^3.0.0" + "luxon": "^3.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts new file mode 100644 index 0000000000..895eeb9338 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2025 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 { z, ZodType } from 'zod'; +import { LoggerService } from './LoggerService'; +import { BackstageCredentials } from './AuthService'; + +export type ActionsRegistryActionContext = { + input: z.infer; + logger: LoggerService; + credentials: BackstageCredentials; +}; + +// todo: opaque type? +export type ActionsRegistryAction< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + id: string; + name: string; + title: string; + description: string; + // todo: what about additional metadata? + schema?: { + input?: TInputSchema; + output?: TOutputSchema; + }; + action: ( + context: ActionsRegistryActionContext, + ) => Promise : void>; +}; + +export type ActionsRegistryActionOptions< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + name: string; + title: string; + description: string; + // todo: what about additional metadata? + schema?: { + input?: (zod: typeof z) => TInputSchema; + output?: (zod: typeof z) => TOutputSchema; + }; + action: ( + context: ActionsRegistryActionContext, + ) => Promise : void>; +}; + +export interface ActionsRegistryService { + register( + options: ActionsRegistryActionOptions, + ): void; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 8c8c6c0f82..48ee4c084f 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -35,6 +35,21 @@ export namespace coreServices { id: 'core.auth', }); + /** + * Service for registering and managing actions. + * + * See {@link ActionsRegistryService} + * and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs} + * for more information. + * + * @public + */ + export const actionsRegistry = createServiceRef< + import('./ActionsRegistryService').ActionsRegistryService + >({ + id: 'core.actionsRegistry', + }); + /** * Authenticated user information retrieval. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index d6346cbe4e..2967c7baab 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +export type { + ActionsRegistryService, + ActionsRegistryActionOptions, + ActionsRegistryActionContext, + ActionsRegistryAction, +} from './ActionsRegistryService'; export type { AuditorService, AuditorServiceCreateEventOptions, diff --git a/yarn.lock b/yarn.lock index 5276e3d24a..26bdf48fb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3636,6 +3636,7 @@ __metadata: yauzl: "npm:^3.0.0" yn: "npm:^4.0.0" zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.20.4" peerDependencies: "@google-cloud/cloud-sql-connector": ^1.4.0 peerDependenciesMeta: @@ -3738,6 +3739,7 @@ __metadata: "@types/luxon": "npm:^3.0.0" knex: "npm:^3.0.0" luxon: "npm:^3.0.0" + zod: "npm:^3.22.4" languageName: unknown linkType: soft From 4e8f01357e71d745e0ea87fd55bf3b67eaafc088 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 11:52:39 +0200 Subject: [PATCH 02/10] feat: some tweaks to the actions registry and implementing some of the actions client Signed-off-by: benjdlambert --- app-config.yaml | 1 - packages/backend-defaults/config.d.ts | 10 ++ .../actions/DefaultActionsService.ts | 99 +++++++++++++++++++ .../actions/actionsServiceFactory.ts | 33 +++++++ .../src/entrypoints/actions/index.ts | 15 +++ ...ry.ts => DefaultActionsRegistryService.ts} | 64 ++++++++---- .../actionsRegistryServiceFactory.test.ts | 89 ++++++++++++++--- .../actionsRegistryServiceFactory.ts | 29 ++---- packages/backend-plugin-api/package.json | 2 + .../definitions/ActionsRegistryService.ts | 24 +---- .../services/definitions/ActionsService.ts | 36 +++++++ .../src/services/definitions/coreServices.ts | 17 +++- .../src/services/definitions/index.ts | 3 +- yarn.lock | 2 + 14 files changed, 348 insertions(+), 76 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts create mode 100644 packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts create mode 100644 packages/backend-defaults/src/entrypoints/actions/index.ts rename packages/backend-defaults/src/entrypoints/actionsRegistry/{PluginActionsRegistry.ts => DefaultActionsRegistryService.ts} (58%) create mode 100644 packages/backend-plugin-api/src/services/definitions/ActionsService.ts diff --git a/app-config.yaml b/app-config.yaml index 89e417fad3..5651b03f91 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,7 +35,6 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} - auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 14732bbccd..1ef054a537 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -121,6 +121,16 @@ export interface Config { }; }; + /** + * Options used by the default actions service. + */ + actions?: { + /** + * List of plugin sources to load actions from. + */ + pluginSources?: string[]; + }; + /** * Options used by the default auth, httpAuth and userInfo services. */ diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts new file mode 100644 index 0000000000..73920015a0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2025 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 { + ActionsRegistryAction, + ActionsService, + ActionsServiceAction, + DiscoveryService, + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { NotFoundError, ResponseError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; + +export class DefaultActionsService implements ActionsService { + private constructor( + private readonly discovery: DiscoveryService, + private readonly config: RootConfigService, + private readonly logger: LoggerService, + ) {} + + static create({ + discovery, + config, + logger, + }: { + discovery: DiscoveryService; + config: RootConfigService; + logger: LoggerService; + }) { + return new DefaultActionsService(discovery, config, logger); + } + + async listActions() { + const pluginSources = + this.config.getOptionalStringArray('actions.pluginSources') ?? []; + + const remoteActionsList = await Promise.all( + pluginSources.map(async source => { + const pluginBaseUrl = await this.discovery.getBaseUrl(source); + const response = await fetch(`${pluginBaseUrl}/.backstage/v1/actions`); + if (!response.ok) { + this.logger.warn(`Failed to fetch actions from ${source}`); + return []; + } + + const { actions } = (await response.json()) as { + actions: Omit, 'action'>[]; + }; + + return actions; + }), + ); + + return { actions: remoteActionsList.flat() }; + } + + async invokeAction(opts: { id: string; input?: JsonObject }) { + const pluginId = this.pluginIdFromActionId(opts.id); + + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const response = await fetch( + `${baseUrl}/.backstage/v1/actions/${opts.id}/invoke`, + { + method: 'POST', + body: JSON.stringify(opts.input), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const { output } = await response.json(); + return { output }; + } + + private pluginIdFromActionId(id: string): string { + const colonIndex = id.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid action id: ${id}`); + } + return id.substring(0, colonIndex); + } +} diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts new file mode 100644 index 0000000000..721769b847 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 { createServiceFactory } from '@backstage/backend-plugin-api'; +import { coreServices } from '@backstage/backend-plugin-api'; +import { DefaultActionsService } from './DefaultActionsService'; + +export const actionsServiceFactory = createServiceFactory({ + service: coreServices.actions, + deps: { + discovery: coreServices.discovery, + config: coreServices.rootConfig, + logger: coreServices.logger, + }, + factory: ({ discovery, config, logger }) => + DefaultActionsService.create({ + discovery, + config, + logger, + }), +}); diff --git a/packages/backend-defaults/src/entrypoints/actions/index.ts b/packages/backend-defaults/src/entrypoints/actions/index.ts new file mode 100644 index 0000000000..379ce77f52 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2025 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. + */ diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts similarity index 58% rename from packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts rename to packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 8dedf40658..fb3a5cb396 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -15,25 +15,30 @@ */ import { - ActionsRegistryAction, + ActionsRegistryActionOptions, + ActionsRegistryService, + AuthService, HttpAuthService, LoggerService, + PluginMetadataService, } from '@backstage/backend-plugin-api'; import PromiseRouter from 'express-promise-router'; import { Router, json } from 'express'; import { z, ZodType } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; -import { InputError, NotFoundError } from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; -export class PluginActionsRegistry { +export class DefaultActionsRegistryService implements ActionsRegistryService { private constructor( private readonly actions: Map< string, - ActionsRegistryAction + ActionsRegistryActionOptions >, private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, + private readonly auth: AuthService, + private readonly metadata: PluginMetadataService, ) { this.bindRoutes(); } @@ -41,15 +46,21 @@ export class PluginActionsRegistry { static create({ httpAuth, logger, + auth, + metadata, }: { httpAuth: HttpAuthService; logger: LoggerService; - }): PluginActionsRegistry { - return new PluginActionsRegistry( + auth: AuthService; + metadata: PluginMetadataService; + }): DefaultActionsRegistryService { + return new DefaultActionsRegistryService( new Map(), PromiseRouter(), logger, httpAuth, + auth, + metadata, ); } @@ -58,24 +69,31 @@ export class PluginActionsRegistry { } register( - options: ActionsRegistryAction, + options: ActionsRegistryActionOptions, ): void { - this.actions.set(options.id, options as ActionsRegistryAction); + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } private bindRoutes() { this.router.use(json()); - this.router.get('/.backstage/v1/actions', (_, res) => { + this.router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ - actions: Array.from(this.actions.entries()).map(([_id, action]) => ({ + actions: Array.from(this.actions.entries()).map(([id, action]) => ({ + id, ...action, schema: { input: action.schema?.input - ? zodToJsonSchema(action.schema.input) + ? zodToJsonSchema(action.schema.input(z)) : zodToJsonSchema(z.any()), output: action.schema?.output - ? zodToJsonSchema(action.schema.output) + ? zodToJsonSchema(action.schema.output(z)) : zodToJsonSchema(z.any()), }, })), @@ -83,7 +101,7 @@ export class PluginActionsRegistry { }); this.router.post( - '/.backstage/v1/actions/:actionId/invoke', + '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -92,7 +110,7 @@ export class PluginActionsRegistry { } const input = action.schema?.input - ? action.schema.input.safeParse(req.body) + ? action.schema.input(z).safeParse(req.body) : ({ success: true, data: undefined } as const); if (!input.success) { @@ -103,7 +121,19 @@ export class PluginActionsRegistry { } const credentials = await this.httpAuth.credentials(req); + if (this.auth.isPrincipal(credentials, 'user')) { + if (!credentials.principal.actor) { + throw new NotAllowedError( + `Actions must be invoked by a service, not a user`, + ); + } + } else if (this.auth.isPrincipal(credentials, 'none')) { + throw new NotAllowedError( + `Actions must be invoked by a service, not an anonymous request`, + ); + } + // todo: wrap up in forwardederror? const result = await action.action({ input: input.data, credentials, @@ -111,8 +141,8 @@ export class PluginActionsRegistry { }); const output = action.schema?.output - ? action.schema.output.safeParse(result) - : ({ success: true, data: result } as const); + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); if (!output.success) { throw new InputError( @@ -121,7 +151,7 @@ export class PluginActionsRegistry { ); } - return res.json({ response: output.data }); + return res.json({ output: output.data }); }, ); } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index b3b518a8e9..88f72239d0 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -25,13 +25,14 @@ import { import { httpRouterServiceFactory } from '../httpRouter'; import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; +import { InputError } from '@backstage/errors'; describe('actionsRegistryServiceFactory', () => { const defaultServices = [ actionsRegistryServiceFactory, httpRouterServiceFactory, mockServices.httpAuth.factory({ - defaultCredentials: mockCredentials.user(), + defaultCredentials: mockCredentials.service('user:default/mock'), }), ]; @@ -110,7 +111,7 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions', () => { + describe('/.backstage/actions/v1/actions', () => { it('should allow registering of actions', async () => { const pluginSubject = createBackendPlugin({ pluginId: 'my-plugin', @@ -146,7 +147,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -205,7 +206,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -226,11 +227,11 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions/:actionId/invoke', () => { + describe('/.backstage/actions/v1/actions/:actionId/invoke', () => { const mockAction = jest.fn(); beforeEach(() => { - mockAction.mockResolvedValue({ ok: true }); + mockAction.mockResolvedValue({ output: { ok: true } }); }); const pluginSubject = createBackendPlugin({ @@ -268,7 +269,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).post( - '/api/my-plugin/.backstage/v1/actions/test/invoke', + '/api/my-plugin/.backstage/actions/v1/actions/test/invoke', ); expect(status).toBe(404); @@ -285,7 +286,9 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 123, }); @@ -306,7 +309,9 @@ describe('actionsRegistryServiceFactory', () => { }); await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -318,14 +323,43 @@ describe('actionsRegistryServiceFactory', () => { credentials: { $$type: '@backstage/BackstageCredentials', principal: { - type: 'user', - userEntityRef: 'user:default/mock', + type: 'service', + subject: 'user:default/mock', }, }, logger: expect.anything(), }); }); + it('should throw an error if the action is invoked by a user', async () => { + const testServices = [ + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(), + }), + ]; + + const { server } = await startTestBackend({ + features: [pluginSubject, ...testServices], + }); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(403); + expect(body).toMatchObject({ + error: { + message: 'Actions must be invoked by a service, not a user', + }, + }); + }); + it('should validate the output of the action if provided', async () => { const { server } = await startTestBackend({ features: [pluginSubject, ...defaultServices], @@ -334,7 +368,9 @@ describe('actionsRegistryServiceFactory', () => { mockAction.mockResolvedValue({ ok: 'blob' }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -355,13 +391,38 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); expect(status).toBe(200); - expect(body).toMatchObject({ response: { ok: true } }); + expect(body).toMatchObject({ output: { ok: true } }); + }); + + it('should return the error from the action if it throws', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockRejectedValue(new InputError('test')); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: 'test', + }, + }); }); }); }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 424b60acc4..d894694091 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -15,12 +15,10 @@ */ import { - ActionsRegistryActionOptions, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { PluginActionsRegistry } from './PluginActionsRegistry'; -import { z, ZodType } from 'zod'; +import { DefaultActionsRegistryService } from './DefaultActionsRegistryService'; export const actionsRegistryServiceFactory = createServiceFactory({ service: coreServices.actionsRegistry, @@ -29,29 +27,18 @@ export const actionsRegistryServiceFactory = createServiceFactory({ httpRouter: coreServices.httpRouter, httpAuth: coreServices.httpAuth, logger: coreServices.logger, + auth: coreServices.auth, }, - factory: ({ metadata, httpRouter, httpAuth, logger }) => { - const pluginActionsRegistry = PluginActionsRegistry.create({ + factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => { + const actionsRegistryService = DefaultActionsRegistryService.create({ httpAuth, logger, + auth, + metadata, }); - httpRouter.use(pluginActionsRegistry.getRouter()); + httpRouter.use(actionsRegistryService.getRouter()); - return { - async register< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, - >(options: ActionsRegistryActionOptions) { - pluginActionsRegistry.register({ - ...options, - id: `${metadata.getId()}:${options.name}`, - schema: { - input: options.schema?.input?.(z), - output: options.schema?.output?.(z), - }, - }); - }, - }; + return actionsRegistryService; }, }); diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 2f2c8fd552..666c703ed7 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -61,7 +61,9 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", + "@types/json-schema": "^7.0.6", "@types/luxon": "^3.0.0", + "json-schema": "^0.4.0", "knex": "^3.0.0", "luxon": "^3.0.0", "zod": "^3.22.4" diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 895eeb9338..9240891227 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -23,25 +23,6 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; -// todo: opaque type? -export type ActionsRegistryAction< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, -> = { - id: string; - name: string; - title: string; - description: string; - // todo: what about additional metadata? - schema?: { - input?: TInputSchema; - output?: TOutputSchema; - }; - action: ( - context: ActionsRegistryActionContext, - ) => Promise : void>; -}; - export type ActionsRegistryActionOptions< TInputSchema extends ZodType, TOutputSchema extends ZodType, @@ -49,14 +30,15 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - // todo: what about additional metadata? schema?: { input?: (zod: typeof z) => TInputSchema; output?: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, - ) => Promise : void>; + ) => Promise< + TOutputSchema extends ZodType ? { output: z.infer } : void + >; }; export interface ActionsRegistryService { diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts new file mode 100644 index 0000000000..7887546878 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 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 { JsonObject, JsonValue } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; + +export type ActionsServiceAction = { + id: string; + name: string; + title: string; + description: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}; + +export interface ActionsService { + listActions: () => Promise<{ actions: ActionsServiceAction[] }>; + invokeAction(opts: { + id: string; + input?: JsonObject; + }): Promise<{ output: JsonValue }>; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 48ee4c084f..c5282368ac 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -36,7 +36,22 @@ export namespace coreServices { }); /** - * Service for registering and managing actions. + * Service for calling distributed actions + * + * See {@link ActionsService} + * and {@link https://backstage.io/docs/backend-system/core-services/actions | the service docs} + * for more information. + * + * @public + */ + export const actions = createServiceRef< + import('./ActionsService').ActionsService + >({ + id: 'core.actions', + }); + + /** + * Service for registering and managing distributed actions. * * See {@link ActionsRegistryService} * and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs} diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 2967c7baab..a01544b60f 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -18,8 +18,9 @@ export type { ActionsRegistryService, ActionsRegistryActionOptions, ActionsRegistryActionContext, - ActionsRegistryAction, } from './ActionsRegistryService'; + +export type { ActionsService, ActionsServiceAction } from './ActionsService'; export type { AuditorService, AuditorServiceCreateEventOptions, diff --git a/yarn.lock b/yarn.lock index 26bdf48fb7..1809a03667 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,7 +3736,9 @@ __metadata: "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "npm:^4.17.6" + "@types/json-schema": "npm:^7.0.6" "@types/luxon": "npm:^3.0.0" + json-schema: "npm:^0.4.0" knex: "npm:^3.0.0" luxon: "npm:^3.0.0" zod: "npm:^3.22.4" From 6513bba7101329e524faf8ca640f3b932b5a29c5 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:25:16 +0200 Subject: [PATCH 03/10] feat: finishing implementation of the actions client Signed-off-by: benjdlambert --- packages/backend-defaults/package.json | 4 + .../backend-defaults/src/CreateBackend.ts | 2 + .../actions/DefaultActionsService.ts | 62 +++- .../actions/actionsServiceFactory.test.ts | 330 ++++++++++++++++++ .../actions/actionsServiceFactory.ts | 4 +- .../src/entrypoints/actions/index.ts | 1 + .../actionsRegistryServiceFactory.test.ts | 8 +- 7 files changed, 394 insertions(+), 17 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 67507dd606..7c918b4dc4 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -20,6 +20,7 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", + "./actions": "./src/entrypoints/actions/index.ts", "./actionsRegistry": "./src/entrypoints/actionsRegistry/index.ts", "./auditor": "./src/entrypoints/auditor/index.ts", "./auth": "./src/entrypoints/auth/index.ts", @@ -46,6 +47,9 @@ "types": "src/index.ts", "typesVersions": { "*": { + "actions": [ + "src/entrypoints/actions/index.ts" + ], "actionsRegistry": [ "src/entrypoints/actionsRegistry/index.ts" ], diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 30a913af68..032978c806 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -36,9 +36,11 @@ import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo'; import { eventsServiceFactory } from '@backstage/plugin-events-node'; import { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; +import { actionsServiceFactory } from './entrypoints/actions'; export const defaultServiceFactories = [ actionsRegistryServiceFactory, + actionsServiceFactory, auditorServiceFactory, authServiceFactory, cacheServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index 73920015a0..de3959ec6c 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -14,14 +14,15 @@ * limitations under the License. */ import { - ActionsRegistryAction, ActionsService, ActionsServiceAction, + AuthService, + BackstageCredentials, DiscoveryService, LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; -import { NotFoundError, ResponseError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; export class DefaultActionsService implements ActionsService { @@ -29,35 +30,43 @@ export class DefaultActionsService implements ActionsService { private readonly discovery: DiscoveryService, private readonly config: RootConfigService, private readonly logger: LoggerService, + private readonly auth: AuthService, ) {} static create({ discovery, config, logger, + auth, }: { discovery: DiscoveryService; config: RootConfigService; logger: LoggerService; + auth: AuthService; }) { - return new DefaultActionsService(discovery, config, logger); + return new DefaultActionsService(discovery, config, logger, auth); } - async listActions() { + async listActions({ credentials }: { credentials: BackstageCredentials }) { const pluginSources = - this.config.getOptionalStringArray('actions.pluginSources') ?? []; + this.config.getOptionalStringArray('backend.actions.pluginSources') ?? []; const remoteActionsList = await Promise.all( pluginSources.map(async source => { const pluginBaseUrl = await this.discovery.getBaseUrl(source); - const response = await fetch(`${pluginBaseUrl}/.backstage/v1/actions`); + const response = await this.makeRequest({ + url: `${pluginBaseUrl}/.backstage/actions/v1/actions`, + pluginId: source, + credentials, + }); + if (!response.ok) { this.logger.warn(`Failed to fetch actions from ${source}`); return []; } const { actions } = (await response.json()) as { - actions: Omit, 'action'>[]; + actions: ActionsServiceAction; }; return actions; @@ -67,28 +76,57 @@ export class DefaultActionsService implements ActionsService { return { actions: remoteActionsList.flat() }; } - async invokeAction(opts: { id: string; input?: JsonObject }) { + async invokeAction(opts: { + id: string; + input?: JsonObject; + credentials: BackstageCredentials; + }) { const pluginId = this.pluginIdFromActionId(opts.id); const baseUrl = await this.discovery.getBaseUrl(pluginId); - const response = await fetch( - `${baseUrl}/.backstage/v1/actions/${opts.id}/invoke`, - { + + const response = await this.makeRequest({ + url: `${baseUrl}/.backstage/actions/v1/actions/${opts.id}/invoke`, + pluginId, + credentials: opts.credentials, + options: { method: 'POST', body: JSON.stringify(opts.input), headers: { 'Content-Type': 'application/json', }, }, - ); + }); if (!response.ok) { throw await ResponseError.fromResponse(response); } + const { output } = await response.json(); return { output }; } + private async makeRequest(opts: { + url: string; + pluginId: string; + options?: RequestInit; + credentials: BackstageCredentials; + }) { + const { url, credentials, options } = opts; + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: opts.pluginId, + }); + + return fetch(url, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + }, + }); + } + private pluginIdFromActionId(id: string): string { const colonIndex = id.indexOf(':'); if (colonIndex === -1) { diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts new file mode 100644 index 0000000000..f1bb54a184 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -0,0 +1,330 @@ +/* + * Copyright 2025 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 { + mockCredentials, + mockServices, + registerMswTestHooks, + ServiceFactoryTester, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { actionsRegistryServiceFactory } from '../actionsRegistry'; +import { httpRouterServiceFactory } from '../httpRouter'; +import { actionsServiceFactory } from './actionsServiceFactory'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { + ActionsServiceAction, + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { json } from 'express'; +import Router from 'express-promise-router'; +import request from 'supertest'; + +const server = setupServer(); + +describe('actionsServiceFactory', () => { + describe('client', () => { + registerMswTestHooks(server); + const mockActionsListEndpoint = jest.fn(); + const mockNotFoundActionsListEndpoint = jest.fn(); + + const defaultServices = [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin', 'not-found-plugin'], + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ]; + + const mockActionsDefinition: ActionsServiceAction = { + description: 'my mock description', + id: 'my-plugin:test', + name: 'testy', + title: 'Test', + }; + + beforeEach(() => { + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + mockActionsListEndpoint.mockImplementation((_req, res, ctx) => + res( + ctx.json({ + actions: [mockActionsDefinition], + }), + ), + ), + ), + rest.get( + 'http://localhost:0/api/not-found-plugin/.backstage/actions/v1/actions', + mockNotFoundActionsListEndpoint.mockImplementation((_req, res, ctx) => + res(ctx.status(404)), + ), + ), + ); + }); + + describe('listActions', () => { + it('should list all plugins in config to find actions and handle failures gracefully', async () => { + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + const { actions } = await subject.listActions({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions).toEqual([mockActionsDefinition]); + + expect(mockActionsListEndpoint).toHaveBeenCalledTimes(1); + expect(mockNotFoundActionsListEndpoint).toHaveBeenCalledTimes(1); + }); + }); + + describe('invokeAction', () => { + it('should invoke the action and return the output', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.json({ output: { ok: true } })), + ), + ); + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + const { output } = await subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(output).toEqual({ ok: true }); + }); + + it('should throw a 404 if the action does not exist', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + await expect( + subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }), + ).rejects.toThrow('404'); + }); + + it('should throw a 400 if the action returns an invalid input', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.status(400)), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + await expect( + subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }), + ).rejects.toThrow('400'); + }); + }); + + describe('integration', () => { + beforeAll(() => { + // disable the msw server for this test. + server.close(); + }); + + const features = [ + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + actionsRegistryServiceFactory, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { pluginSources: ['plugin-with-action'] }, + }, + }, + }), + + createBackendPlugin({ + pluginId: 'plugin-with-action', + register({ registerInit }) { + registerInit({ + deps: { actionsRegistry: coreServices.actionsRegistry }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'with-validation', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + string: z.string(), + }), + }, + action: async ({ input }) => { + return { + output: { + ok: true, + string: `hello ${input.name}`, + }, + }; + }, + }); + }, + }); + }, + }), + createBackendPlugin({ + pluginId: 'test-harness', + register({ registerInit }) { + registerInit({ + deps: { + actionsService: coreServices.actions, + router: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + }, + async init({ actionsService, router, httpAuth }) { + const innerRouter = Router(); + innerRouter.use(json()); + router.use(innerRouter); + + innerRouter.post('/invoke', async (req, res) => { + const { id, input } = req.body; + const response = await actionsService.invokeAction({ + id, + input, + credentials: await httpAuth.credentials(req), + }); + res.json(response); + }); + + innerRouter.get('/actions', async (req, res) => { + const response = await actionsService.listActions({ + credentials: await httpAuth.credentials(req), + }); + + res.json(response); + }); + }, + }); + }, + }), + ]; + + it('should list the actions and return the output', async () => { + const { server: testHarnessServer } = await startTestBackend({ + features, + }); + + const { body, status } = await request(testHarnessServer).get( + '/api/test-harness/actions', + ); + + expect(status).toBe(200); + expect(body).toEqual({ + actions: [ + { + description: 'Test', + id: 'plugin-with-action:with-validation', + name: 'with-validation', + 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: { + ok: { + type: 'boolean', + }, + string: { + type: 'string', + }, + }, + required: ['ok', 'string'], + type: 'object', + }, + }, + title: 'Test', + }, + ], + }); + }); + + it('should invoke the action and return the output', async () => { + const { server: testHarnessServer } = await startTestBackend({ + features, + }); + + const { body, status } = await request(testHarnessServer) + .post('/api/test-harness/invoke') + .send({ + id: 'plugin-with-action:with-validation', + input: { + name: 'world', + }, + }); + + expect(body).toEqual({ output: { ok: true, string: 'hello world' } }); + expect(status).toBe(200); + }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts index 721769b847..de1f1ef15d 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -23,11 +23,13 @@ export const actionsServiceFactory = createServiceFactory({ discovery: coreServices.discovery, config: coreServices.rootConfig, logger: coreServices.logger, + auth: coreServices.auth, }, - factory: ({ discovery, config, logger }) => + factory: ({ discovery, config, logger, auth }) => DefaultActionsService.create({ discovery, config, logger, + auth, }), }); diff --git a/packages/backend-defaults/src/entrypoints/actions/index.ts b/packages/backend-defaults/src/entrypoints/actions/index.ts index 379ce77f52..bc34150a5f 100644 --- a/packages/backend-defaults/src/entrypoints/actions/index.ts +++ b/packages/backend-defaults/src/entrypoints/actions/index.ts @@ -13,3 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export { actionsServiceFactory } from './actionsServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 88f72239d0..9efd29572c 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -63,7 +63,7 @@ describe('actionsRegistryServiceFactory', () => { action: async ({ input: { test } }) => { // @ts-expect-error - test is not a boolean const _t: boolean = test; - return { ok: true }; + return { output: { ok: true } }; }, }); }, @@ -99,7 +99,7 @@ describe('actionsRegistryServiceFactory', () => { }, // @ts-expect-error - ok is not a boolean action: async () => { - return { ok: 'bo' }; + return { output: { ok: 'bo' } }; }, }); }, @@ -135,7 +135,7 @@ describe('actionsRegistryServiceFactory', () => { ok: z.boolean(), }), }, - action: async () => ({ ok: true }), + action: async () => ({ output: { ok: true } }), }); }, }); @@ -194,7 +194,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', title: 'Test', description: 'Test', - action: async () => ({ ok: true }), + action: async () => ({ output: { ok: true } }), }); }, }); From 1db36159f59af9b1dc5e97456b0b4a8a65bb105f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:25:43 +0200 Subject: [PATCH 04/10] chore: include credentials Signed-off-by: benjdlambert --- .../src/services/definitions/ActionsService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 7887546878..06e61bddb9 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -15,6 +15,7 @@ */ import { JsonObject, JsonValue } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; +import { BackstageCredentials } from './AuthService'; export type ActionsServiceAction = { id: string; @@ -28,9 +29,12 @@ export type ActionsServiceAction = { }; export interface ActionsService { - listActions: () => Promise<{ actions: ActionsServiceAction[] }>; + listActions: (opts: { + credentials: BackstageCredentials; + }) => Promise<{ actions: ActionsServiceAction[] }>; invokeAction(opts: { id: string; input?: JsonObject; + credentials: BackstageCredentials; }): Promise<{ output: JsonValue }>; } From d8d25283f4cbeb29254f4e2c7286f82a0dec4f46 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:32:19 +0200 Subject: [PATCH 05/10] chore: added api-reports Signed-off-by: benjdlambert --- .../backend-defaults/report-actions.api.md | 17 +++++ .../report-actionsRegistry.api.md | 17 +++++ .../actions/actionsServiceFactory.ts | 3 + .../actionsRegistryServiceFactory.ts | 3 + packages/backend-plugin-api/report.api.md | 73 +++++++++++++++++++ .../definitions/ActionsRegistryService.ts | 9 +++ .../services/definitions/ActionsService.ts | 6 ++ 7 files changed, 128 insertions(+) create mode 100644 packages/backend-defaults/report-actions.api.md create mode 100644 packages/backend-defaults/report-actionsRegistry.api.md diff --git a/packages/backend-defaults/report-actions.api.md b/packages/backend-defaults/report-actions.api.md new file mode 100644 index 0000000000..4c072af02a --- /dev/null +++ b/packages/backend-defaults/report-actions.api.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ActionsService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const actionsServiceFactory: ServiceFactory< + ActionsService, + 'plugin', + 'singleton' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/report-actionsRegistry.api.md b/packages/backend-defaults/report-actionsRegistry.api.md new file mode 100644 index 0000000000..d35bf4cef4 --- /dev/null +++ b/packages/backend-defaults/report-actionsRegistry.api.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const actionsRegistryServiceFactory: ServiceFactory< + ActionsRegistryService, + 'plugin', + 'singleton' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts index de1f1ef15d..466cc02121 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -17,6 +17,9 @@ import { createServiceFactory } from '@backstage/backend-plugin-api'; import { coreServices } from '@backstage/backend-plugin-api'; import { DefaultActionsService } from './DefaultActionsService'; +/** + * @public + */ export const actionsServiceFactory = createServiceFactory({ service: coreServices.actions, deps: { diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index d894694091..70bfbf9986 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -20,6 +20,9 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultActionsRegistryService } from './DefaultActionsRegistryService'; +/** + * @public + */ export const actionsRegistryServiceFactory = createServiceFactory({ service: coreServices.actionsRegistry, deps: { diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index 52843dec96..b8c8534029 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -12,6 +12,7 @@ import type { Handler } from 'express'; import { HumanDuration } from '@backstage/types'; import { isChildPath } from '@backstage/cli-common'; import { JsonObject } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; import { Permission } from '@backstage/plugin-permission-common'; @@ -25,6 +26,72 @@ import { QueryPermissionResponse } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; import type { Request as Request_2 } from 'express'; import type { Response as Response_2 } from 'express'; +import { z } from 'zod'; +import { ZodType } from 'zod'; + +// @public (undocumented) +export type ActionsRegistryActionContext = { + input: z.infer; + logger: LoggerService; + credentials: BackstageCredentials; +}; + +// @public (undocumented) +export type ActionsRegistryActionOptions< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + name: string; + title: string; + description: string; + schema?: { + input?: (zod: typeof z) => TInputSchema; + output?: (zod: typeof z) => TOutputSchema; + }; + action: (context: ActionsRegistryActionContext) => Promise< + TOutputSchema extends ZodType + ? { + output: z.infer; + } + : void + >; +}; + +// @public (undocumented) +export interface ActionsRegistryService { + // (undocumented) + register( + options: ActionsRegistryActionOptions, + ): void; +} + +// @public (undocumented) +export interface ActionsService { + // (undocumented) + invokeAction(opts: { + id: string; + input?: JsonObject; + credentials: BackstageCredentials; + }): Promise<{ + output: JsonValue; + }>; + // (undocumented) + listActions: (opts: { credentials: BackstageCredentials }) => Promise<{ + actions: ActionsServiceAction[]; + }>; +} + +// @public (undocumented) +export type ActionsServiceAction = { + id: string; + name: string; + title: string; + description: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}; // @public export interface AuditorService { @@ -205,6 +272,12 @@ export type CacheServiceSetOptions = { // @public export namespace coreServices { const auth: ServiceRef; + const actions: ServiceRef; + const actionsRegistry: ServiceRef< + ActionsRegistryService, + 'plugin', + 'singleton' + >; const userInfo: ServiceRef; const cache: ServiceRef; const rootConfig: ServiceRef; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 9240891227..ab6539a33e 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -17,12 +17,18 @@ import { z, ZodType } from 'zod'; import { LoggerService } from './LoggerService'; import { BackstageCredentials } from './AuthService'; +/** + * @public + */ export type ActionsRegistryActionContext = { input: z.infer; logger: LoggerService; credentials: BackstageCredentials; }; +/** + * @public + */ export type ActionsRegistryActionOptions< TInputSchema extends ZodType, TOutputSchema extends ZodType, @@ -41,6 +47,9 @@ export type ActionsRegistryActionOptions< >; }; +/** + * @public + */ export interface ActionsRegistryService { register( options: ActionsRegistryActionOptions, diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 06e61bddb9..156513ebc8 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -17,6 +17,9 @@ import { JsonObject, JsonValue } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { BackstageCredentials } from './AuthService'; +/** + * @public + */ export type ActionsServiceAction = { id: string; name: string; @@ -28,6 +31,9 @@ export type ActionsServiceAction = { }; }; +/** + * @public + */ export interface ActionsService { listActions: (opts: { credentials: BackstageCredentials; From 664c07a98feb7d9879a34b680f91dab396896ac8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:33:36 +0200 Subject: [PATCH 06/10] chore: added changeset Signed-off-by: benjdlambert --- .changeset/wet-bars-report.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wet-bars-report.md diff --git a/.changeset/wet-bars-report.md b/.changeset/wet-bars-report.md new file mode 100644 index 0000000000..2890fa846d --- /dev/null +++ b/.changeset/wet-bars-report.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': minor +'@backstage/backend-defaults': patch +--- + +Added `coreServices.actionsRegistry` and `coreServices.actions` to allow registration of distributed actions from plugins, and the ability to invoke these actions From 2d2d97e12b5e1cab49025422de086705c9ad6431 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:38:45 +0200 Subject: [PATCH 07/10] chore: wrap up in forwarded error Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../DefaultActionsRegistryService.ts | 43 ++++++++++++------- .../actionsRegistryServiceFactory.test.ts | 4 +- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index fb3a5cb396..bcc5c23805 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -26,7 +26,12 @@ import PromiseRouter from 'express-promise-router'; import { Router, json } from 'express'; import { z, ZodType } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; -import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + ForwardedError, + InputError, + NotAllowedError, + NotFoundError, +} from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { private constructor( @@ -133,25 +138,31 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - // todo: wrap up in forwardederror? - const result = await action.action({ - input: input.data, - credentials, - logger: this.logger, - }); + try { + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${req.params.actionId}"`, - output.error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, + ); + } + + res.json({ output: output.data }); + } catch (error) { + throw new ForwardedError( + `Failed execution of action "${req.params.actionId}"`, + error, ); } - - return res.json({ output: output.data }); }, ); } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 9efd29572c..832155f794 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -420,7 +420,9 @@ describe('actionsRegistryServiceFactory', () => { expect(status).toBe(400); expect(body).toMatchObject({ error: { - message: 'test', + message: expect.stringContaining( + 'Failed execution of action "my-plugin:test"', + ), }, }); }); From a9219496d5c073aaa0b8caf32ece10455cf65e61 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 14:19:41 +0200 Subject: [PATCH 08/10] chore: code review comments Signed-off-by: benjdlambert --- app-config.yaml | 1 + .../actions/DefaultActionsService.ts | 22 +++---- .../actions/actionsServiceFactory.test.ts | 20 ++++--- .../DefaultActionsRegistryService.ts | 59 ++++++++----------- .../actionsRegistryServiceFactory.test.ts | 6 +- .../actionsRegistryServiceFactory.ts | 2 +- packages/backend-plugin-api/report.api.md | 22 +++---- .../definitions/ActionsRegistryService.ts | 10 ++-- .../services/definitions/ActionsService.ts | 10 ++-- packages/backend-test-utils/report.api.md | 24 ++++++++ .../src/next/services/mockServices.ts | 16 +++++ 11 files changed, 115 insertions(+), 77 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 5651b03f91..89e417fad3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,6 +35,7 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} + auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index de3959ec6c..4f11050646 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -47,15 +47,14 @@ export class DefaultActionsService implements ActionsService { return new DefaultActionsService(discovery, config, logger, auth); } - async listActions({ credentials }: { credentials: BackstageCredentials }) { + async list({ credentials }: { credentials: BackstageCredentials }) { const pluginSources = this.config.getOptionalStringArray('backend.actions.pluginSources') ?? []; const remoteActionsList = await Promise.all( pluginSources.map(async source => { - const pluginBaseUrl = await this.discovery.getBaseUrl(source); const response = await this.makeRequest({ - url: `${pluginBaseUrl}/.backstage/actions/v1/actions`, + path: `/.backstage/actions/v1/actions`, pluginId: source, credentials, }); @@ -76,17 +75,16 @@ export class DefaultActionsService implements ActionsService { return { actions: remoteActionsList.flat() }; } - async invokeAction(opts: { + async invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; }) { const pluginId = this.pluginIdFromActionId(opts.id); - - const baseUrl = await this.discovery.getBaseUrl(pluginId); - const response = await this.makeRequest({ - url: `${baseUrl}/.backstage/actions/v1/actions/${opts.id}/invoke`, + path: `/.backstage/actions/v1/actions/${encodeURIComponent( + opts.id, + )}/invoke`, pluginId, credentials: opts.credentials, options: { @@ -107,18 +105,20 @@ export class DefaultActionsService implements ActionsService { } private async makeRequest(opts: { - url: string; + path: string; pluginId: string; options?: RequestInit; credentials: BackstageCredentials; }) { - const { url, credentials, options } = opts; + const { path, pluginId, credentials, options } = opts; + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const { token } = await this.auth.getPluginRequestToken({ onBehalfOf: credentials, targetPluginId: opts.pluginId, }); - return fetch(url, { + return fetch(`${baseUrl}${path}`, { ...options, headers: { ...options?.headers, diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index f1bb54a184..7b7285c89a 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -66,6 +66,10 @@ describe('actionsServiceFactory', () => { id: 'my-plugin:test', name: 'testy', title: 'Test', + schema: { + input: {}, + output: {}, + }, }; beforeEach(() => { @@ -89,13 +93,13 @@ describe('actionsServiceFactory', () => { ); }); - describe('listActions', () => { + describe('list', () => { it('should list all plugins in config to find actions and handle failures gracefully', async () => { const subject = await ServiceFactoryTester.from(actionsServiceFactory, { dependencies: defaultServices, }).getSubject(); - const { actions } = await subject.listActions({ + const { actions } = await subject.list({ credentials: mockCredentials.service('user:default/mock'), }); @@ -106,7 +110,7 @@ describe('actionsServiceFactory', () => { }); }); - describe('invokeAction', () => { + describe('invoke', () => { it('should invoke the action and return the output', async () => { server.use( rest.post( @@ -118,7 +122,7 @@ describe('actionsServiceFactory', () => { dependencies: defaultServices, }).getSubject(); - const { output } = await subject.invokeAction({ + const { output } = await subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }); @@ -139,7 +143,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -159,7 +163,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -238,7 +242,7 @@ describe('actionsServiceFactory', () => { innerRouter.post('/invoke', async (req, res) => { const { id, input } = req.body; - const response = await actionsService.invokeAction({ + const response = await actionsService.invoke({ id, input, credentials: await httpAuth.credentials(req), @@ -247,7 +251,7 @@ describe('actionsServiceFactory', () => { }); innerRouter.get('/actions', async (req, res) => { - const response = await actionsService.listActions({ + const response = await actionsService.list({ credentials: await httpAuth.credentials(req), }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index bcc5c23805..a1777cb9b6 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -34,19 +34,15 @@ import { } from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { + private actions: Map> = + new Map(); + private constructor( - private readonly actions: Map< - string, - ActionsRegistryActionOptions - >, - private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, private readonly auth: AuthService, private readonly metadata: PluginMetadataService, - ) { - this.bindRoutes(); - } + ) {} static create({ httpAuth, @@ -59,36 +55,14 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth: AuthService; metadata: PluginMetadataService; }): DefaultActionsRegistryService { - return new DefaultActionsRegistryService( - new Map(), - PromiseRouter(), - logger, - httpAuth, - auth, - metadata, - ); + return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata); } - getRouter(): Router { - return this.router; - } + createRouter(): Router { + const router = PromiseRouter(); + router.use(json()); - register( - options: ActionsRegistryActionOptions, - ): void { - const id = `${this.metadata.getId()}:${options.name}`; - - if (this.actions.has(id)) { - throw new Error(`Action with id "${id}" is already registered`); - } - - this.actions.set(id, options); - } - - private bindRoutes() { - this.router.use(json()); - - this.router.get('/.backstage/actions/v1/actions', (_, res) => { + router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, @@ -105,7 +79,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { }); }); - this.router.post( + router.post( '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -165,5 +139,18 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { } }, ); + return router; + } + + register( + options: ActionsRegistryActionOptions, + ): void { + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 832155f794..5d47ff055d 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -194,7 +194,11 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', title: 'Test', description: 'Test', - action: async () => ({ output: { ok: true } }), + schema: { + input: z => z.undefined(), + output: z => z.string(), + }, + action: async () => ({ output: 'ok' }), }); }, }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 70bfbf9986..85515a6046 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -40,7 +40,7 @@ export const actionsRegistryServiceFactory = createServiceFactory({ metadata, }); - httpRouter.use(actionsRegistryService.getRouter()); + httpRouter.use(actionsRegistryService.createRouter()); return actionsRegistryService; }, diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index b8c8534029..68597dcbe1 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -44,16 +44,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: (context: ActionsRegistryActionContext) => Promise< - TOutputSchema extends ZodType - ? { + z.infer extends void + ? void + : { output: z.infer; } - : void >; }; @@ -68,7 +68,7 @@ export interface ActionsRegistryService { // @public (undocumented) export interface ActionsService { // (undocumented) - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; @@ -76,7 +76,7 @@ export interface ActionsService { output: JsonValue; }>; // (undocumented) - listActions: (opts: { credentials: BackstageCredentials }) => Promise<{ + list: (opts: { credentials: BackstageCredentials }) => Promise<{ actions: ActionsServiceAction[]; }>; } @@ -87,9 +87,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index ab6539a33e..3044c84ed8 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -36,14 +36,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, ) => Promise< - TOutputSchema extends ZodType ? { output: z.infer } : void + z.infer extends void + ? void + : { output: z.infer } >; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 156513ebc8..69b13afaaa 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -25,9 +25,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; @@ -35,10 +35,10 @@ export type ActionsServiceAction = { * @public */ export interface ActionsService { - listActions: (opts: { + list: (opts: { credentials: BackstageCredentials; }) => Promise<{ actions: ActionsServiceAction[] }>; - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index 7edcf6e1b7..379a2a70ac 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ActionsService } from '@backstage/backend-plugin-api'; import { AuditorService } from '@backstage/backend-plugin-api'; import { AuthService } from '@backstage/backend-plugin-api'; import { Backend } from '@backstage/backend-app-api'; @@ -161,6 +163,28 @@ export function mockErrorHandler(): ErrorRequestHandler< // @public export namespace mockServices { + // (undocumented) + export namespace actions { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) + export namespace actionsRegistry { + const // (undocumented) + factory: () => ServiceFactory< + ActionsRegistryService, + 'plugin', + 'singleton' + >; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } // (undocumented) export namespace auditor { const // (undocumented) diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index f68f1337b9..0c5b18bd1e 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -53,6 +53,8 @@ import { MockRootLoggerService } from './MockRootLoggerService'; import { MockUserInfoService } from './MockUserInfoService'; import { mockCredentials } from './mockCredentials'; import { MockEventsService } from './MockEventsService'; +import { actionsServiceFactory } from '@backstage/backend-defaults/actions'; +import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry'; /** @internal */ function createLoggerMock() { @@ -518,6 +520,20 @@ export namespace mockServices { search: jest.fn(), })); } + export namespace actions { + export const factory = () => actionsServiceFactory; + export const mock = simpleMock(coreServices.actions, () => ({ + list: jest.fn(), + invoke: jest.fn(), + })); + } + + export namespace actionsRegistry { + export const factory = () => actionsRegistryServiceFactory; + export const mock = simpleMock(coreServices.actionsRegistry, () => ({ + register: jest.fn(), + })); + } /** * Creates a functional mock implementation of the From c999c2568bfc923ea1579bf9070ac5a98ef9d476 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 14:26:27 +0200 Subject: [PATCH 09/10] chore: break apart changeset Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .changeset/sweet-ducks-wait.md | 5 ++++ .changeset/wet-bars-report.md | 1 - .changeset/wet-bars-reporting.md | 5 ++++ .../DefaultActionsRegistryService.ts | 26 +++++++++---------- 4 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 .changeset/sweet-ducks-wait.md create mode 100644 .changeset/wet-bars-reporting.md diff --git a/.changeset/sweet-ducks-wait.md b/.changeset/sweet-ducks-wait.md new file mode 100644 index 0000000000..4792fafe84 --- /dev/null +++ b/.changeset/sweet-ducks-wait.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': minor +--- + +Added mock implementations for `ActionsService` and `ActionsRegistryService` diff --git a/.changeset/wet-bars-report.md b/.changeset/wet-bars-report.md index 2890fa846d..d556f6cc61 100644 --- a/.changeset/wet-bars-report.md +++ b/.changeset/wet-bars-report.md @@ -1,6 +1,5 @@ --- '@backstage/backend-plugin-api': minor -'@backstage/backend-defaults': patch --- Added `coreServices.actionsRegistry` and `coreServices.actions` to allow registration of distributed actions from plugins, and the ability to invoke these actions diff --git a/.changeset/wet-bars-reporting.md b/.changeset/wet-bars-reporting.md new file mode 100644 index 0000000000..7dc66cb280 --- /dev/null +++ b/.changeset/wet-bars-reporting.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added some default implementations for the `ActionsService` and `ActionsRegistryService` that allow registration of actions for a particular plugin. diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index a1777cb9b6..17523a2ee5 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -82,6 +82,19 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { router.post( '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { + const credentials = await this.httpAuth.credentials(req); + if (this.auth.isPrincipal(credentials, 'user')) { + if (!credentials.principal.actor) { + throw new NotAllowedError( + `Actions must be invoked by a service, not a user`, + ); + } + } else if (this.auth.isPrincipal(credentials, 'none')) { + throw new NotAllowedError( + `Actions must be invoked by a service, not an anonymous request`, + ); + } + const action = this.actions.get(req.params.actionId); if (!action) { @@ -99,19 +112,6 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - const credentials = await this.httpAuth.credentials(req); - if (this.auth.isPrincipal(credentials, 'user')) { - if (!credentials.principal.actor) { - throw new NotAllowedError( - `Actions must be invoked by a service, not a user`, - ); - } - } else if (this.auth.isPrincipal(credentials, 'none')) { - throw new NotAllowedError( - `Actions must be invoked by a service, not an anonymous request`, - ); - } - try { const result = await action.action({ input: input.data, From 0a9deff5cb0178d60991fd139c90865b37525d2f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 15:12:16 +0200 Subject: [PATCH 10/10] chore: refactor the test Signed-off-by: benjdlambert --- .../actions/DefaultActionsService.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index 4f11050646..2421482f4c 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -53,22 +53,24 @@ export class DefaultActionsService implements ActionsService { const remoteActionsList = await Promise.all( pluginSources.map(async source => { - const response = await this.makeRequest({ - path: `/.backstage/actions/v1/actions`, - pluginId: source, - credentials, - }); + try { + const response = await this.makeRequest({ + path: `/.backstage/actions/v1/actions`, + pluginId: source, + credentials, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const { actions } = (await response.json()) as { + actions: ActionsServiceAction; + }; - if (!response.ok) { - this.logger.warn(`Failed to fetch actions from ${source}`); + return actions; + } catch (error) { + this.logger.warn(`Failed to fetch actions from ${source}`, error); return []; } - - const { actions } = (await response.json()) as { - actions: ActionsServiceAction; - }; - - return actions; }), );