diff --git a/README.md b/README.md index cc77809..61ff3d8 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,7 @@ yarn add --cwd packages/backend @pagerduty/backstage-plugin-backend ### Configuration -To use the custom actions as part of your custom project templates follow the instructions on the `Create PagerDuty service with Software Templates` section of the project's documentation [here](https://pagerduty.github.io/backstage-plugin-docs/). - +To use the custom actions as part of your custom project templates follow the instructions on the `Create PagerDuty service with Software Templates` section of the project's documentation [here](https://pagerduty.github.io/backstage-plugin-docs/advanced/create-service-software-template/). ## Support diff --git a/config.d.ts b/config.d.ts new file mode 100644 index 0000000..13e8d16 --- /dev/null +++ b/config.d.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 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 interface Config { + /** + * Configuration for the PagerDuty plugin + * @visibility frontend + */ + pagerDuty?: { + /** + * Optional Events Base URL to override the default. + * @visibility frontend + */ + eventsBaseUrl?: string; + /** + * Optional PagerDuty API Token used in API calls from the backend component. + * @visibility frontend + */ + apiToken?: string; + }; +} diff --git a/package.json b/package.json index 5a38e37..7ef021c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,9 @@ "typescript": "^4.8.4" }, "files": [ - "dist" + "dist", + "config.d.ts" ], + "configSchema": "config.d.ts", "packageManager": "yarn@3.6.3" } diff --git a/src/apis/pagerduty.test.ts b/src/apis/pagerduty.test.ts index cd86375..cbe1f99 100644 --- a/src/apis/pagerduty.test.ts +++ b/src/apis/pagerduty.test.ts @@ -1,5 +1,6 @@ /* eslint-disable jest/no-conditional-expect */ -import { createService, createServiceIntegration } from "./pagerduty"; +import { HttpError } from "../types"; +import { createService, createServiceIntegration, getAllEscalationPolicies } from "./pagerduty"; describe("PagerDuty API", () => { afterEach(() => { @@ -141,14 +142,15 @@ describe("PagerDuty API", () => { global.fetch = jest.fn(() => Promise.resolve({ status: 400, - json: () => Promise.resolve({}) }) ) as jest.Mock; + const expectedErrorMessage = "Failed to create service integration. Caller provided invalid arguments."; + try { await createServiceIntegration(serviceId, vendorId); } catch (error) { - expect(((error as Error).message)).toEqual("Failed to create service integration. Caller provided invalid arguments."); + expect(((error as Error).message)).toEqual(expectedErrorMessage); } }); @@ -158,15 +160,16 @@ describe("PagerDuty API", () => { global.fetch = jest.fn(() => Promise.resolve({ - status: 401, - json: () => Promise.resolve({}) + status: 401 }) ) as jest.Mock; + const expectedErrorMessage = "Failed to create service integration. Caller did not supply credentials or did not provide the correct credentials."; + try { await createServiceIntegration(serviceId, vendorId); } catch (error) { - expect(((error as Error).message)).toEqual("Failed to create service integration. Caller did not supply credentials or did not provide the correct credentials."); + expect(((error as Error).message)).toEqual(expectedErrorMessage); } }); @@ -176,15 +179,16 @@ describe("PagerDuty API", () => { global.fetch = jest.fn(() => Promise.resolve({ - status: 403, - json: () => Promise.resolve({}) + status: 403 }) ) as jest.Mock; + const expectedErrorMessage = "Failed to create service integration. Caller is not authorized to view the requested resource."; + try { await createServiceIntegration(serviceId, vendorId); } catch (error) { - expect(((error as Error).message)).toEqual("Failed to create service integration. Caller is not authorized to view the requested resource."); + expect(((error as Error).message)).toEqual(expectedErrorMessage); } }); @@ -194,17 +198,266 @@ describe("PagerDuty API", () => { global.fetch = jest.fn(() => Promise.resolve({ - status: 429, - json: () => Promise.resolve({}) + status: 429 }) ) as jest.Mock; + const expectedErrorMessage = "Failed to create service integration. Rate limit exceeded."; + try { await createServiceIntegration(serviceId, vendorId); } catch (error) { - expect(((error as Error).message)).toEqual("Failed to create service integration. Rate limit exceeded."); + expect(((error as Error).message)).toEqual(expectedErrorMessage); + } + }); + }); + + describe("getAllEscalationPolicies", () => { + it("should return ok", async () => { + const expectedId = "P0L1CY1D"; + const expectedName = "Test Escalation Policy"; + + const expectedResponse = [ + { + id: expectedId, + name: expectedName + } + ]; + + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId, + name: expectedName, + } + ] + }) + }) + ) as jest.Mock; + + const result = await getAllEscalationPolicies(); + + expect(result).toEqual(expectedResponse); + expect(result.length).toEqual(1); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("should NOT list escalation policies when caller provides invalid arguments", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 400, + json: () => Promise.resolve({}) + }) + ) as jest.Mock; + + const expectedStatusCode = 400; + const expectedErrorMessage = "Failed to list escalation policies. Caller provided invalid arguments."; + + try { + await getAllEscalationPolicies(); + } catch (error) { + expect(((error as HttpError).status)).toEqual(expectedStatusCode); + expect(((error as HttpError).message)).toEqual(expectedErrorMessage); + } + }); + + it("should NOT list escalation policies when correct credentials are not provided", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 401 + }) + ) as jest.Mock; + + const expectedStatusCode = 401; + const expectedErrorMessage = "Failed to list escalation policies. Caller did not supply credentials or did not provide the correct credentials."; + + try { + await getAllEscalationPolicies(); + } catch (error) { + expect(((error as HttpError).status)).toEqual(expectedStatusCode); + expect(((error as HttpError).message)).toEqual(expectedErrorMessage); } }); + + it("should NOT list escalation policies when account does not have abilities to perform the action", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 403 + }) + ) as jest.Mock; + + const expectedStatusCode = 403; + const expectedErrorMessage = "Failed to list escalation policies. Caller is not authorized to view the requested resource."; + + try { + await getAllEscalationPolicies(); + } catch (error) { + expect(((error as HttpError).status)).toEqual(expectedStatusCode); + expect(((error as HttpError).message)).toEqual(expectedErrorMessage); + } + }); + + it("should NOT list escalation policies when user is not allowed to view the requested resource", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 429 + }) + ) as jest.Mock; + + const expectedStatusCode = 429; + const expectedErrorMessage = "Failed to list escalation policies. Rate limit exceeded."; + + try { + await getAllEscalationPolicies(); + } catch (error) { + expect(((error as HttpError).status)).toEqual(expectedStatusCode); + expect(((error as HttpError).message)).toEqual(expectedErrorMessage); + } + }); + + it("should work with pagination", async () => { + const expectedId = ["P0L1CY1D1", "P0L1CY1D2", "P0L1CY1D3", "P0L1CY1D4", "P0L1CY1D5", "P0L1CY1D6", "P0L1CY1D7", "P0L1CY1D8", "P0L1CY1D9", "P0L1CY1D10"]; + const expectedName = ["Test Escalation Policy 1", "Test Escalation Policy 2", "Test Escalation Policy 3", "Test Escalation Policy 4", "Test Escalation Policy 5", "Test Escalation Policy 6", "Test Escalation Policy 7", "Test Escalation Policy 8", "Test Escalation Policy 9", "Test Escalation Policy 10"]; + + const expectedResponse = [ + { + id: expectedId[0], + name: expectedName[0] + }, + { + id: expectedId[1], + name: expectedName[1] + }, + { + id: expectedId[2], + name: expectedName[2] + }, + { + id: expectedId[3], + name: expectedName[3] + }, + { + id: expectedId[4], + name: expectedName[4] + }, + { + id: expectedId[5], + name: expectedName[5] + }, + { + id: expectedId[6], + name: expectedName[6] + }, + { + id: expectedId[7], + name: expectedName[7] + }, + { + id: expectedId[8], + name: expectedName[8] + }, + { + id: expectedId[9], + name: expectedName[9] + } + ]; + + global.fetch = jest.fn().mockReturnValueOnce( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId[0], + name: expectedName[0], + }, + { + id: expectedId[1], + name: expectedName[1], + } + ], + more: true + }) + }) + ).mockReturnValueOnce( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId[2], + name: expectedName[2], + }, + { + id: expectedId[3], + name: expectedName[3], + } + ], + more: true + }) + }) + ).mockReturnValueOnce( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId[4], + name: expectedName[4], + }, + { + id: expectedId[5], + name: expectedName[5], + } + ], + more: true + }) + }) + ).mockReturnValueOnce( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId[6], + name: expectedName[6], + }, + { + id: expectedId[7], + name: expectedName[7], + } + ], + more: true + }) + }) + ).mockReturnValueOnce( + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: expectedId[8], + name: expectedName[8], + }, + { + id: expectedId[9], + name: expectedName[9], + } + ], + more: false + }) + }) + ) as jest.Mock; + + const result = await getAllEscalationPolicies(); + + expect(result).toEqual(expectedResponse); + expect(result.length).toEqual(10); + expect(fetch).toHaveBeenCalledTimes(5); + }); }); }); diff --git a/src/apis/pagerduty.ts b/src/apis/pagerduty.ts index 62dc41d..4ec4ab4 100644 --- a/src/apis/pagerduty.ts +++ b/src/apis/pagerduty.ts @@ -1,6 +1,8 @@ -import { PagerDutyCreateIntegrationResponse, PagerDutyCreateServiceResponse } from "../types"; +import { PagerDutyCreateIntegrationResponse, PagerDutyCreateServiceResponse, PagerDutyEscalationPolicyListResponse, PagerDutyEscalationPolicy, HttpError } from "../types"; -export async function createService(name: string, description: string, escalationPolicyId: string): Promise<[string, string]> { +// Supporting custom actions + +export async function createService(name: string, description: string, escalationPolicyId: string): Promise<[string, string]> { let response: Response; const baseUrl = 'https://api.pagerduty.com/services'; const options: RequestInit = { @@ -14,7 +16,7 @@ export async function createService(name: string, description: string, escalatio }, }), headers: { - Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', }, @@ -103,3 +105,66 @@ export async function createServiceIntegration(serviceId: string, vendorId: stri throw new Error(`Failed to parse service information: ${error}`); } } + +// Supporting router + +async function getEscalationPolicies(offset: number, limit: number): Promise<[Boolean, PagerDutyEscalationPolicy[]]> { + let response: Response; + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: `Token token=${process.env.PAGERDUTY_TOKEN}`, + 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + const baseUrl = 'https://api.pagerduty.com/escalation_policies'; + + try { + response = await fetch(`${baseUrl}?total=true&sort_by=name&offset=${offset}&limit=${limit}`, options); + } catch (error) { + throw new Error(`Failed to retrieve escalation policies: ${error}`); + } + + switch (response.status) { + case 400: + throw new HttpError("Failed to list escalation policies. Caller provided invalid arguments.", 400); + case 401: + throw new HttpError("Failed to list escalation policies. Caller did not supply credentials or did not provide the correct credentials.", 401); + case 403: + throw new HttpError("Failed to list escalation policies. Caller is not authorized to view the requested resource.", 403); + case 429: + throw new HttpError("Failed to list escalation policies. Rate limit exceeded.", 429); + default: // 200 + break; + } + + let result: PagerDutyEscalationPolicyListResponse; + try { + result = await response.json(); + + return [result.more, result.escalation_policies]; + + } catch (error) { + throw new HttpError(`Failed to parse escalation policy information: ${error}`, 500); + } +} + +export async function getAllEscalationPolicies(offset: number = 0): Promise { + const limit = 50; + + try { + const res = await getEscalationPolicies(offset, limit); + const results = res[1]; + + // if more results exist + if (res[0]) { + return results.concat((await getAllEscalationPolicies(offset + limit))); + } + + return results; + } catch (error) { + + throw new HttpError(`${((error as HttpError).message) }`, ((error as HttpError).status)); + } +} diff --git a/src/index.ts b/src/index.ts index 91aeb8c..e1f65ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ -// export * from './service/router'; +export * from './service/router'; export * from './actions/custom'; diff --git a/src/run.ts b/src/run.ts index 8e2658a..79c6bd6 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,15 +1,15 @@ import { getRootLogger } from '@backstage/backend-common'; -// import yn from 'yn'; -// import { startStandaloneServer } from './service/standaloneServer'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; -// const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; -// const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); -// startStandaloneServer({ port, enableCors, logger }).catch(err => { -// logger.error(err); -// process.exit(1); -// }); +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); process.on('SIGINT', () => { logger.info('CTRL+C pressed; exiting.'); diff --git a/src/service/router.test.ts b/src/service/router.test.ts new file mode 100644 index 0000000..bd05b33 --- /dev/null +++ b/src/service/router.test.ts @@ -0,0 +1,117 @@ +import { getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; +import { PagerDutyEscalationPolicy } from '../types'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter( + { + logger: getVoidLogger(), + config: new ConfigReader({ + app: { + baseUrl: 'https://example.com/extra-path', + }, + pagerDuty: { + apiToken: `${process.env.PAGERDUTY_TOKEN}`, + }, + }), + } + ); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); + + describe('GET /escalation_policies', () => { + it('returns ok', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [ + { + id: "12345", + name: "Test Escalation Policy", + type: "escalation_policy", + summary: "Test Escalation Policy", + self: "https://api.pagerduty.com/escalation_policies/12345", + html_url: "https://example.pagerduty.com/escalation_policies/12345", + } + ] + }) + }) + ) as jest.Mock; + + const expectedStatusCode = 200; + const expectedResponse = [ + { + label: "Test Escalation Policy", + value: "12345", + } + ]; + + const response = await request(app).get('/escalation_policies'); + + const policies: PagerDutyEscalationPolicy[] = JSON.parse(response.text); + + expect(response.status).toEqual(expectedStatusCode); + expect(response.body).toEqual(expectedResponse); + expect(policies.length).toEqual(1); + }); + + it('returns unauthorized', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 401 + }) + ) as jest.Mock; + + const expectedStatusCode = 401; + const expectedErrorMessage = "Failed to list escalation policies. Caller did not supply credentials or did not provide the correct credentials."; + + const response = await request(app).get('/escalation_policies'); + + expect(response.status).toEqual(expectedStatusCode); + expect(response.text).toMatch(expectedErrorMessage); + }); + + it('returns empty list when no escalation policies exist', async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => Promise.resolve({ + escalation_policies: [] + }) + }) + ) as jest.Mock; + + const expectedStatusCode = 200; + const expectedResponse: PagerDutyEscalationPolicy[] = []; + + const response = await request(app).get('/escalation_policies'); + + const policies: PagerDutyEscalationPolicy[] = JSON.parse(response.text); + + expect(response.status).toEqual(expectedStatusCode); + expect(response.body).toEqual(expectedResponse); + expect(policies.length).toEqual(0); + }); + }); +}); \ No newline at end of file diff --git a/src/service/router.ts b/src/service/router.ts new file mode 100644 index 0000000..088c381 --- /dev/null +++ b/src/service/router.ts @@ -0,0 +1,62 @@ +import { errorHandler } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { getAllEscalationPolicies } from '../apis/pagerduty'; +import { HttpError } from '../types'; + +export interface RouterOptions { + logger: Logger; + config: Config; +} + +export async function createRouter( + options: RouterOptions +): Promise { + const { logger, config } = options; + + // Set the PagerDuty API token as an environment variable if it exists in the config file + try { + process.env.PAGERDUTY_TOKEN = config.getString('pagerDuty.apiToken'); + } + catch (error) { + logger.error(`Failed to retrieve PagerDuty API token from config file: ${error}`); + throw error; + } + + // Create the router + const router = Router(); + router.use(express.json()); + + // Add routes + // GET /escalation_policies + router.get('/escalation_policies', async (_, response) => { + try { + const escalationPolicyList = await getAllEscalationPolicies(); + const escalationPolicyDropDownOptions = escalationPolicyList.map((policy) => { + return { + label: policy.name, + value: policy.id, + }; + }); + + response.json(escalationPolicyDropDownOptions); + } catch (error) { + if (error instanceof HttpError) { + response.status(error.status).json(`${error.message}`); + } + } + }); + + // GET /health + router.get('/health', async (_, response) => { + response.status(200).json({ status: 'ok' }); + }); + + // Add error handler + router.use(errorHandler()); + + // Return the router + return router; +} \ No newline at end of file diff --git a/src/service/standaloneServer.ts b/src/service/standaloneServer.ts new file mode 100644 index 0000000..f60e2a0 --- /dev/null +++ b/src/service/standaloneServer.ts @@ -0,0 +1,38 @@ +import { createServiceBuilder, loadBackendConfig } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: '@pagerduty/backstage-plugin-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + logger.debug('Starting application server...'); + const router = await createRouter( + { + logger, + config, + } + ); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/pagerduty', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b3f7c44..62e9126 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export type PagerDutyTeam = { export type PagerDutyEscalationPolicy = { id: string; + name: string; type: string; summary: string; self: string; @@ -70,4 +71,28 @@ export type PagerDutyVendor = { summary: string; self: string; htmlUrl: string; -}; \ No newline at end of file +}; + +export type PagerDutyEscalationPolicyListResponse = { + escalation_policies: PagerDutyEscalationPolicy[]; + limit: number; + offset: number; + more: boolean; + total: number; +}; + +export type PagerDutyEscalationPolicyDropDownOption = { + label: string; + value: string; +}; + +export class HttpError extends Error { + constructor(message: string, status: number) { + super(message); + this.status = status; + } + + status: number; +} + +