Add action to fetch the DSN of a Sentry project
Signed-off-by: Brent Swisher <brent@brentswisher.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-sentry': patch
|
||||
---
|
||||
|
||||
Add sentry:fetch:dsn action to retrieve a Sentry project's DSN
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { createSentryCreateProjectAction } from './actions/createProject';
|
||||
export { createSentryFetchDSNAction } from './actions/fetchDSN';
|
||||
export { sentryModule as default } from './module';
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user