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 } }), }); }, });