From eea536048833d941f4d2d8d492dcbbf00d4edfc4 Mon Sep 17 00:00:00 2001 From: Brent Swisher Date: Tue, 2 Sep 2025 16:11:06 -0400 Subject: [PATCH] Add action to fetch the DSN of a Sentry project Signed-off-by: Brent Swisher --- .changeset/long-lizards-notice.md | 5 + .../report.api.md | 15 ++ .../src/actions/fetchDSN.examples.test.ts | 174 +++++++++++++++ .../src/actions/fetchDSN.examples.ts | 57 +++++ .../src/actions/fetchDSN.test.ts | 199 ++++++++++++++++++ .../src/actions/fetchDSN.ts | 104 +++++++++ .../src/index.ts | 1 + .../src/module.ts | 6 +- 8 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 .changeset/long-lizards-notice.md create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts diff --git a/.changeset/long-lizards-notice.md b/.changeset/long-lizards-notice.md new file mode 100644 index 0000000000..47ac81732f --- /dev/null +++ b/.changeset/long-lizards-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-sentry': patch +--- + +Add sentry:fetch:dsn action to retrieve a Sentry project's DSN diff --git a/plugins/scaffolder-backend-module-sentry/report.api.md b/plugins/scaffolder-backend-module-sentry/report.api.md index 98b3c8b599..7e4701e609 100644 --- a/plugins/scaffolder-backend-module-sentry/report.api.md +++ b/plugins/scaffolder-backend-module-sentry/report.api.md @@ -25,6 +25,21 @@ export function createSentryCreateProjectAction(options: { 'v2' >; +// @public +export function createSentryFetchDSNAction(options: { + config: Config; +}): TemplateAction< + { + organizationSlug: string; + projectSlug: string; + authToken?: string | undefined; + }, + { + [x: string]: any; + }, + 'v2' +>; + // @public const sentryModule: BackendFeature; export default sentryModule; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts new file mode 100644 index 0000000000..609a366408 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2021 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 { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { JsonObject } from '@backstage/types'; +import { randomBytes } from 'crypto'; +import { setupServer } from 'msw/node'; +import { HttpResponse, http } from 'msw'; +import { createSentryFetchDSNAction } from './fetchDSN'; +import yaml from 'yaml'; +import { examples } from './fetchDSN.examples'; + +describe('sentry:fetch:dsn action', () => { + const worker = setupServer(); + registerMswTestHooks(worker); + + const createScaffolderConfig = (configData: JsonObject = {}) => ({ + config: new ConfigReader({ + scaffolder: { + ...configData, + }, + }), + }); + + const getActionContext = (): ActionContext<{ + organizationSlug: string; + projectSlug: string; + authToken?: string; + }> => + createMockActionContext({ + workspacePath: './dev/proj', + logger: jest.createMockFromModule('winston'), + input: { + organizationSlug: 'org', + projectSlug: 'project', + authToken: randomBytes(5).toString('hex'), + }, + }); + + const mockSentryKeysResponse = [ + { + id: '12345', + name: 'Default', + public: 'abcdef1234567890', + secret: 'secret1234567890', + projectId: 67890, + isActive: true, + dateCreated: '2021-01-01T00:00:00.000Z', + dsn: { + secret: 'https://abcdef1234567890:secret1234567890@sentry.io/67890', + public: 'https://abcdef1234567890@sentry.io/67890', + csp: 'https://sentry.io/api/67890/csp-report/?sentry_key=abcdef1234567890', + security: + 'https://sentry.io/api/67890/security-report/?sentry_key=abcdef1234567890', + minidump: + 'https://sentry.io/api/67890/minidump/?sentry_key=abcdef1234567890', + nel: 'https://sentry.io/api/67890/nel-report/?sentry_key=abcdef1234567890', + unreal: 'https://sentry.io/api/67890/unreal/abcdef1234567890/', + cdn: 'https://sentry.io/js-sdk-loader/abcdef1234567890.min.js', + }, + browserSdkVersion: '6.x', + browserSdk: { + choices: [ + ['latest', 'latest'], + ['6.x', '6.x'], + ['5.x', '5.x'], + ['4.x', '4.x'], + ], + }, + }, + ]; + + it(`should ${examples[0].description}`, async () => { + expect.assertions(3); + + let input; + try { + input = yaml.parse(examples[0].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${input.organizationSlug}/${input.projectSlug}/keys/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer a14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df96`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + return HttpResponse.json(mockSentryKeysResponse, { status: 200 }); + }, + ), + ); + + await action.handler({ + ...actionContext, + input: { + ...actionContext.input, + ...input, + }, + }); + expect(actionContext.output).toHaveBeenCalledWith( + 'dsn', + 'https://abcdef1234567890@sentry.io/67890', + ); + }); + + it(`should ${examples[1].description}`, async () => { + expect.assertions(3); + + let input; + try { + input = yaml.parse(examples[1].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + const sentryScaffolderConfigToken = randomBytes(5).toString('hex'); + const action = createSentryFetchDSNAction( + createScaffolderConfig({ + sentry: { + token: sentryScaffolderConfigToken, + }, + }), + ); + const actionContext = getActionContext(); + actionContext.input.authToken = undefined; + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${input.organizationSlug}/${input.projectSlug}/keys/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer ${sentryScaffolderConfigToken}`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + return HttpResponse.json(mockSentryKeysResponse, { status: 200 }); + }, + ), + ); + + await action.handler({ + ...actionContext, + input: { + ...actionContext.input, + ...input, + }, + }); + expect(actionContext.output).toHaveBeenCalledWith( + 'dsn', + 'https://abcdef1234567890@sentry.io/67890', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts new file mode 100644 index 0000000000..0b1454ebac --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: + 'Fetch the DSN for a Sentry project with authentication token.', + example: yaml.stringify({ + steps: [ + { + id: 'fetch-sentry-dsn', + action: 'sentry:fetch:dsn', + name: 'Fetch DSN using auth token', + input: { + organizationSlug: 'my-org', + projectSlug: 'my-project', + authToken: + 'a14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df96', + }, + }, + ], + }), + }, + { + description: + 'Fetch the DSN for a Sentry project using the token from the configuration.', + example: yaml.stringify({ + steps: [ + { + id: 'fetch-sentry-dsn', + action: 'sentry:fetch:dsn', + name: 'Fetch DSN using config token', + input: { + organizationSlug: 'my-org', + projectSlug: 'my-project', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts new file mode 100644 index 0000000000..6396381435 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2021 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 { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { InputError } from '@backstage/errors'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { JsonObject } from '@backstage/types'; +import { randomBytes } from 'crypto'; +import { setupServer } from 'msw/node'; +import { HttpResponse, http } from 'msw'; +import { createSentryFetchDSNAction } from './fetchDSN'; + +describe('sentry:fetch:dsn action', () => { + const worker = setupServer(); + registerMswTestHooks(worker); + + const createScaffolderConfig = (configData: JsonObject = {}) => ({ + config: new ConfigReader({ + scaffolder: { + ...configData, + }, + }), + }); + + const getActionContext = (): ActionContext<{ + organizationSlug: string; + projectSlug: string; + authToken?: string; + }> => + createMockActionContext({ + workspacePath: './dev/proj', + logger: jest.createMockFromModule('winston'), + input: { + organizationSlug: 'org', + projectSlug: 'project', + authToken: randomBytes(5).toString('hex'), + }, + }); + + it('should fetch DSN from sentry project with specified parameters.', async () => { + expect.assertions(3); + + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + const mockDSN = 'https://test@sentry.io/123'; + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + return HttpResponse.json([{ dsn: { public: mockDSN } }], { + status: 200, + }); + }, + ), + ); + + await action.handler(actionContext); + expect(actionContext.output).toHaveBeenCalledWith('dsn', mockDSN); + }); + + it('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { + expect.assertions(3); + + const sentryScaffolderConfigToken = randomBytes(5).toString('hex'); + const action = createSentryFetchDSNAction( + createScaffolderConfig({ + sentry: { + token: sentryScaffolderConfigToken, + }, + }), + ); + const actionContext = getActionContext(); + actionContext.input.authToken = undefined; + const mockDSN = 'https://test@sentry.io/123'; + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer ${sentryScaffolderConfigToken}`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + return HttpResponse.json([{ dsn: { public: mockDSN } }], { + status: 200, + }); + }, + ), + ); + + await action.handler(actionContext); + expect(actionContext.output).toHaveBeenCalledWith('dsn', mockDSN); + }); + + it('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + actionContext.input.authToken = undefined; + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError('No valid sentry token given'), + ); + }); + + it('should throw InputError when sentry API returns unexpected content-type.', async () => { + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async () => { + return HttpResponse.text('Bad response', { status: 200 }); + }, + ), + ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError(`Unexpected Sentry Response Type: Bad response`), + ); + }); + + it('should throw InputError when sentry API returns error status code.', async () => { + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async () => { + return HttpResponse.json( + { detail: 'Project not found' }, + { status: 404 }, + ); + }, + ), + ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError(`Sentry Response was: Project not found`), + ); + }); + + it('should throw InputError when no keys are returned.', async () => { + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async () => { + return HttpResponse.json([], { status: 200 }); + }, + ), + ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError('No keys found for the specified project'), + ); + }); + + it('should throw InputError when no public DSN is found in keys.', async () => { + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.get( + `https://sentry.io/api/0/projects/${actionContext.input.organizationSlug}/${actionContext.input.projectSlug}/keys/`, + async () => { + return HttpResponse.json([{ dsn: {} }], { status: 200 }); + }, + ), + ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError('No public DSN found in project keys'), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts new file mode 100644 index 0000000000..d5078ce300 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2021 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 { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; +import { Config } from '@backstage/config'; + +/** + * Creates the `sentry:fetch:dsn` Scaffolder action. + * + * @remarks + * + * See {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. + * + * @param options - Configuration of the Sentry API. + * @public + */ +export function createSentryFetchDSNAction(options: { config: Config }) { + const { config } = options; + + return createTemplateAction({ + id: 'sentry:fetch:dsn', + supportsDryRun: true, + schema: { + input: { + organizationSlug: z => + z.string({ + description: 'The slug of the organization the project belongs to', + }), + projectSlug: z => + z.string({ + description: 'The slug of the project to fetch the DSN for', + }), + authToken: z => + z + .string({ + description: + 'authenticate via bearer auth token. Requires one of the following scopes: project:admin, project:read, project:write', + }) + .optional(), + }, + }, + async handler(ctx) { + const { organizationSlug, projectSlug, authToken } = ctx.input; + + const token = authToken + ? authToken + : config.getOptionalString('scaffolder.sentry.token'); + + if (!token) { + throw new InputError(`No valid sentry token given`); + } + + const response = await fetch( + `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/keys/`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.headers.get('content-type')?.includes('application/json')) { + throw new InputError( + `Unexpected Sentry Response Type: ${await response.text()}`, + ); + } + + const keys = await response.json(); + + if (response.status !== 200) { + throw new InputError( + `Sentry Response was: ${keys.detail || 'Unknown error'}`, + ); + } + + if (!Array.isArray(keys) || keys.length === 0) { + throw new InputError('No keys found for the specified project'); + } + + const publicDsn = keys[0]?.dsn?.public; + if (!publicDsn) { + throw new InputError('No public DSN found in project keys'); + } + + ctx.output('dsn', publicDsn); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-sentry/src/index.ts b/plugins/scaffolder-backend-module-sentry/src/index.ts index dc1a68675b..de818d9735 100644 --- a/plugins/scaffolder-backend-module-sentry/src/index.ts +++ b/plugins/scaffolder-backend-module-sentry/src/index.ts @@ -15,4 +15,5 @@ */ export { createSentryCreateProjectAction } from './actions/createProject'; +export { createSentryFetchDSNAction } from './actions/fetchDSN'; export { sentryModule as default } from './module'; diff --git a/plugins/scaffolder-backend-module-sentry/src/module.ts b/plugins/scaffolder-backend-module-sentry/src/module.ts index dcde344262..847a361697 100644 --- a/plugins/scaffolder-backend-module-sentry/src/module.ts +++ b/plugins/scaffolder-backend-module-sentry/src/module.ts @@ -19,6 +19,7 @@ import { } from '@backstage/backend-plugin-api'; import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; import { createSentryCreateProjectAction } from './actions/createProject'; +import { createSentryFetchDSNAction } from './actions/fetchDSN'; /** * @public @@ -35,7 +36,10 @@ export const sentryModule = createBackendModule({ reade: coreServices.urlReader, }, async init({ scaffolder, config }) { - scaffolder.addActions(createSentryCreateProjectAction({ config })); + scaffolder.addActions( + createSentryCreateProjectAction({ config }), + createSentryFetchDSNAction({ config }), + ); }, }); },