diff --git a/.changeset/petite-bats-obey.md b/.changeset/petite-bats-obey.md new file mode 100644 index 0000000000..308640a3f3 --- /dev/null +++ b/.changeset/petite-bats-obey.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-sentry': minor +--- + +Add ability to configure the API Base URL diff --git a/plugins/scaffolder-backend-module-sentry/README.md b/plugins/scaffolder-backend-module-sentry/README.md index ad5ee96d54..6fcc598880 100644 --- a/plugins/scaffolder-backend-module-sentry/README.md +++ b/plugins/scaffolder-backend-module-sentry/README.md @@ -36,6 +36,14 @@ scaffolder: token: ${SENTRY_TOKEN} ``` +You can optional override the default Sentry API Base URL (https://sentry.io/api/0) in your `app-config.yaml`: + +```yaml +scaffolder: + sentry: + apiBaseUrl: API-BASE-URL +``` + After that you can use the action in your template: ```yaml diff --git a/plugins/scaffolder-backend-module-sentry/report.api.md b/plugins/scaffolder-backend-module-sentry/report.api.md index 6fbece93c8..d9b38822a1 100644 --- a/plugins/scaffolder-backend-module-sentry/report.api.md +++ b/plugins/scaffolder-backend-module-sentry/report.api.md @@ -18,6 +18,7 @@ export function createSentryCreateProjectAction(options: { slug?: string | undefined; platform?: string | undefined; authToken?: string | undefined; + apiBaseUrl?: string | undefined; }, { [x: string]: any; @@ -33,6 +34,7 @@ export function createSentryFetchDSNAction(options: { organizationSlug: string; projectSlug: string; authToken?: string | undefined; + apiBaseUrl?: string | undefined; }, { dsn?: string | undefined; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.test.ts index 6fab15f59f..625acc29fe 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.test.ts @@ -45,6 +45,7 @@ describe('sentry:project:create action', () => { slug?: string; platform?: string; authToken?: string; + apiBaseUrl?: string; }> => createMockActionContext({ workspacePath: './dev/proj', @@ -279,4 +280,47 @@ describe('sentry:project:create action', () => { }, }); }); + + it(`should ${examples[3].description}`, async () => { + expect.assertions(3); + + let input; + try { + input = yaml.parse(examples[3].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + http.post( + `${input.apiBaseUrl || 'https://sentry.io/api/0'}/teams/${ + input.organizationSlug + }/${input.teamSlug}/projects/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer d16711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d6gf95`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ + name: 'Scaffolded project A', + }); + return new HttpResponse(JSON.stringify({ id: 'mock-id' }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }, + ), + ); + + await action.handler({ + ...actionContext, + input: { + ...actionContext.input, + ...input, + }, + }); + }); }); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts index 1744a995a9..3d4d2af2d1 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts @@ -100,4 +100,24 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'Creates a Sentry project with a custom API base URL.', + example: yaml.stringify({ + steps: [ + { + id: 'create-sentry-project', + action: 'sentry:project:create', + name: 'Create a Sentry project with custom API base URL.', + input: { + organizationSlug: 'my-org', + teamSlug: 'team-a', + name: 'Scaffolded project A', + apiBaseUrl: 'https://custom.sentry.io/api/0', + authToken: + 'd16711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d6gf95', + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index 0279c198a9..aa1a546d80 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -44,6 +44,7 @@ describe('sentry:project:create action', () => { slug?: string; platform?: string; authToken?: string; + apiBaseUrl?: string; }> => createMockActionContext({ workspacePath: './dev/proj', @@ -245,4 +246,64 @@ describe('sentry:project:create action', () => { new InputError(`Sentry Response was: OUCH`), ); }); + + it('should create a Sentry project with custom apiBaseUrl.', async () => { + expect.assertions(3); + + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + actionContext.input = { + ...actionContext.input, + apiBaseUrl: 'https://custom.sentry.io/api/0', + }; + + worker.use( + http.post( + `https://custom.sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ + name: actionContext.input.name, + }); + return HttpResponse.json({ id: 'mock-id' }, { status: 201 }); + }, + ), + ); + + await action.handler(actionContext); + }); + + it('should create a Sentry project with apiBaseUrl from config.', async () => { + expect.assertions(3); + + const action = createSentryCreateProjectAction( + createScaffolderConfig({ + sentry: { + apiBaseUrl: 'https://config.sentry.io/api/0', + }, + }), + ); + const actionContext = getActionContext(); + + worker.use( + http.post( + `https://config.sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ + name: actionContext.input.name, + }); + return HttpResponse.json({ id: 'mock-id' }, { status: 201 }); + }, + ), + ); + + await action.handler(actionContext); + }); }); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index 57f8b349e0..421f0e005e 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -67,11 +67,25 @@ export function createSentryCreateProjectAction(options: { config: Config }) { 'authenticate via bearer auth token. Requires scope: project:write', }) .optional(), + apiBaseUrl: z => + z + .string({ + description: + 'Optional base URL for the Sentry API. e.g. https://sentry.io/api/0', + }) + .optional(), }, }, async handler(ctx) { - const { organizationSlug, teamSlug, name, slug, platform, authToken } = - ctx.input; + const { + organizationSlug, + teamSlug, + name, + slug, + platform, + authToken, + apiBaseUrl, + } = ctx.input; const body: any = { name: name, @@ -93,11 +107,16 @@ export function createSentryCreateProjectAction(options: { config: Config }) { throw new InputError(`No valid sentry token given`); } + const baseUrl = + apiBaseUrl || + config.getOptionalString('scaffolder.sentry.apiBaseUrl') || + 'https://sentry.io/api/0'; + const { result } = await ctx.checkpoint({ key: `create.project.${organizationSlug}.${teamSlug}`, fn: async () => { const response = await fetch( - `https://sentry.io/api/0/teams/${organizationSlug}/${teamSlug}/projects/`, + `${baseUrl}/teams/${organizationSlug}/${teamSlug}/projects/`, { method: 'POST', headers: { 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 index 0eed483cbe..556149bd1f 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.test.ts @@ -42,6 +42,7 @@ describe('sentry:fetch:dsn action', () => { organizationSlug: string; projectSlug: string; authToken?: string; + apiBaseUrl?: string; }> => createMockActionContext({ workspacePath: './dev/proj', @@ -147,4 +148,50 @@ describe('sentry:fetch:dsn action', () => { ); }, ); + + it(`should ${examples[2].description}`, async () => { + expect.assertions(3); + + let input; + try { + input = yaml.parse(examples[2].example).steps[0].input; + } catch (error) { + console.error('Failed to parse YAML:', error); + } + + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(null); + + worker.use( + http.get( + `${input.apiBaseUrl || 'https://sentry.io/api/0'}/projects/${ + input.organizationSlug + }/${input.projectSlug}/keys/`, + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( + `Bearer b14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df97`, + ); + expect(request.headers.get('Content-Type')).toBe(`application/json`); + return HttpResponse.json( + [{ dsn: { public: 'https://abcdef1234567890@sentry.io/67890' } }], + { + 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 index 0b1454ebac..6043cf3de7 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.examples.ts @@ -54,4 +54,23 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: 'Fetch the DSN for a Sentry project with custom API base URL.', + example: yaml.stringify({ + steps: [ + { + id: 'fetch-sentry-dsn', + action: 'sentry:fetch:dsn', + name: 'Fetch DSN with custom API base URL', + input: { + organizationSlug: 'my-org', + projectSlug: 'my-project', + apiBaseUrl: 'https://sentry.io/api/0/custom', + authToken: + 'b14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df97', + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts index 4140bf893b..463da50466 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.test.ts @@ -41,6 +41,7 @@ describe('sentry:fetch:dsn action', () => { organizationSlug: string; projectSlug: string; authToken?: string; + apiBaseUrl?: string; }> => createMockActionContext({ workspacePath: './dev/proj', @@ -196,4 +197,66 @@ describe('sentry:fetch:dsn action', () => { new InputError('No public DSN found in project keys'), ); }); + + it('should fetch DSN with custom apiBaseUrl.', async () => { + expect.assertions(3); + + const action = createSentryFetchDSNAction(createScaffolderConfig()); + const actionContext = getActionContext(); + actionContext.input = { + ...actionContext.input, + apiBaseUrl: 'https://custom.sentry.io/api/0', + }; + const mockDSN = 'https://test@sentry.io/123'; + + worker.use( + http.get( + `https://custom.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 fetch DSN with apiBaseUrl from config.', async () => { + expect.assertions(3); + + const action = createSentryFetchDSNAction( + createScaffolderConfig({ + sentry: { + apiBaseUrl: 'https://config.sentry.io/api/0', + }, + }), + ); + const actionContext = getActionContext(); + const mockDSN = 'https://test@sentry.io/123'; + + worker.use( + http.get( + `https://config.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); + }); }); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts index 47fd4be39f..f77332b6f7 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/fetchDSN.ts @@ -51,6 +51,13 @@ export function createSentryFetchDSNAction(options: { config: Config }) { 'authenticate via bearer auth token. Requires one of the following scopes: project:admin, project:read, project:write', }) .optional(), + apiBaseUrl: z => + z + .string({ + description: + 'Optional base URL for the Sentry API. e.g. https://sentry.io/api/0', + }) + .optional(), }, output: { dsn: z => @@ -62,7 +69,8 @@ export function createSentryFetchDSNAction(options: { config: Config }) { }, }, async handler(ctx) { - const { organizationSlug, projectSlug, authToken } = ctx.input; + const { organizationSlug, projectSlug, authToken, apiBaseUrl } = + ctx.input; const token = authToken ? authToken @@ -72,8 +80,13 @@ export function createSentryFetchDSNAction(options: { config: Config }) { throw new InputError(`No valid sentry token given`); } + const baseUrl = + apiBaseUrl || + config.getOptionalString('scaffolder.sentry.apiBaseUrl') || + 'https://sentry.io/api/0'; + const response = await fetch( - `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/keys/`, + `${baseUrl}/projects/${organizationSlug}/${projectSlug}/keys/`, { method: 'GET', headers: {