From 4ed77889c70d641137ac019c46d3c6290b5d5247 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Fri, 19 Jul 2024 15:40:54 +0100 Subject: [PATCH 1/6] feat: function to search store by service id Signed-off-by: Tiago Barbosa --- src/db/PagerDutyBackendDatabase.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/db/PagerDutyBackendDatabase.ts b/src/db/PagerDutyBackendDatabase.ts index 26469ce..2db9d3d 100644 --- a/src/db/PagerDutyBackendDatabase.ts +++ b/src/db/PagerDutyBackendDatabase.ts @@ -17,6 +17,7 @@ export interface PagerDutyBackendStore { insertEntityMapping(entity: PagerDutyEntityMapping): Promise getAllEntityMappings(): Promise findEntityMappingByEntityRef(entityRef: string): Promise + findEntityMappingByServiceId(serviceId: string): Promise } type Options = { @@ -52,7 +53,7 @@ export class PagerDutyBackendDatabase implements PagerDutyBackendStore { processedDate: new Date(), }) .onConflict(['serviceId']) - .merge(['entityRef', 'integrationKey', 'account', 'processedDate']) + .merge(['entityRef', 'integrationKey', 'account', 'processedDate']) .returning('id'); return result.id; @@ -75,4 +76,12 @@ export class PagerDutyBackendDatabase implements PagerDutyBackendStore { return rawEntity; } + + async findEntityMappingByServiceId(serviceId: string): Promise { + const rawEntity = await this.db('pagerduty_entity_mapping') + .where('serviceId', serviceId) + .first(); + + return rawEntity; + } } \ No newline at end of file From e04d3f5d2d1e051a30e866056db51d753bbdabe6 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Fri, 19 Jul 2024 15:55:22 +0100 Subject: [PATCH 2/6] feat: improving PD integration automation Signed-off-by: Tiago Barbosa --- src/apis/pagerduty.ts | 68 ++++++++++++++++ src/service/router.ts | 182 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 237 insertions(+), 13 deletions(-) diff --git a/src/apis/pagerduty.ts b/src/apis/pagerduty.ts index 8f5f032..8077b35 100644 --- a/src/apis/pagerduty.ts +++ b/src/apis/pagerduty.ts @@ -20,6 +20,8 @@ import { PagerDutyServiceMetrics, HttpError, PagerDutyServicesAPIResponse, + PagerDutyAccountConfig, + PagerDutyIntegrationResponse, PagerDutyAccountConfig } from '@pagerduty/backstage-plugin-common'; @@ -654,3 +656,69 @@ export async function getServiceMetrics(serviceId: string, account?: string): Pr } } +export type CreateServiceIntegrationProps = { + serviceId: string; + vendorId: string; + account?: string; +} + +export async function createServiceIntegration({ serviceId, vendorId, account }: CreateServiceIntegrationProps): Promise { + let response: Response; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/services`; + const token = await getAuthToken(account); + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + integration: { + name: 'Backstage', + service: { + id: serviceId, + type: 'service_reference', + }, + vendor: { + id: vendorId, + type: 'vendor_reference', + } + } + }), + headers: { + Authorization: token, + 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch(`${baseUrl}/${serviceId}/integrations`, options); + } catch (error) { + throw new Error(`Failed to create service integration: ${error}`); + } + + switch (response.status) { + case 400: + throw new Error(`Failed to create service integration. Caller provided invalid arguments.`); + case 401: + throw new Error(`Failed to create service integration. Caller did not supply credentials or did not provide the correct credentials.`); + case 403: + throw new Error(`Failed to create service integration. Caller is not authorized to view the requested resource.`); + case 429: + throw new Error(`Failed to create service integration. Rate limit exceeded.`); + default: // 201 + break; + } + + let result: PagerDutyIntegrationResponse; + try { + result = await response.json() as PagerDutyIntegrationResponse; + + return result.integration.integration_key ?? ''; + + } catch (error) { + throw new Error(`Failed to parse service information: ${error}`); + } +} + + diff --git a/src/service/router.ts b/src/service/router.ts index f1fe3fd..bd5bc53 100644 --- a/src/service/router.ts +++ b/src/service/router.ts @@ -1,12 +1,54 @@ +import { + AuthService, + DiscoveryService, + LoggerService, + RootConfigService +} from '@backstage/backend-plugin-api'; +import { + createLegacyAuthAdapters, + errorHandler +} from '@backstage/backend-common'; +import { + getAllEscalationPolicies, + getChangeEvents, + getIncidents, + getOncallUsers, + getServiceById, + getServiceByIntegrationKey, + getServiceStandards, + getServiceMetrics, + getAllServices, + loadPagerDutyEndpointsFromConfig, + createServiceIntegration, import { AuthService, DiscoveryService, LoggerService, RootConfigService } from '@backstage/backend-plugin-api'; import { createLegacyAuthAdapters, errorHandler } from '@backstage/backend-common'; import { getAllEscalationPolicies, getChangeEvents, getIncidents, getOncallUsers, getServiceById, getServiceByIntegrationKey, getServiceStandards, getServiceMetrics, getAllServices, loadPagerDutyEndpointsFromConfig } from '../apis/pagerduty'; import { HttpError, PagerDutyChangeEventsResponse, PagerDutyIncidentsResponse, PagerDutyOnCallUsersResponse, PagerDutyServiceResponse, PagerDutyServiceStandardsResponse, PagerDutyServiceMetricsResponse, PagerDutyServicesResponse, PagerDutyEntityMapping, PagerDutyEntityMappingsResponse, PagerDutyService } from '@pagerduty/backstage-plugin-common'; +} from '../apis/pagerduty'; +import { + HttpError, + PagerDutyChangeEventsResponse, + PagerDutyIncidentsResponse, + PagerDutyOnCallUsersResponse, + PagerDutyServiceResponse, + PagerDutyServiceStandardsResponse, + PagerDutyServiceMetricsResponse, + PagerDutyServicesResponse, + PagerDutyEntityMapping, + PagerDutyEntityMappingsResponse, + PagerDutyService, +} from '@pagerduty/backstage-plugin-common'; import { loadAuthConfig } from '../auth/auth'; -import { PagerDutyBackendStore, RawDbEntityResultRow } from '../db/PagerDutyBackendDatabase'; +import { + PagerDutyBackendStore, + RawDbEntityResultRow +} from '../db/PagerDutyBackendDatabase'; import * as express from 'express'; import Router from 'express-promise-router'; -import type { CatalogApi, GetEntitiesResponse } from '@backstage/catalog-client'; +import type { + CatalogApi, + GetEntitiesResponse +} from '@backstage/catalog-client'; export interface RouterOptions { @@ -64,7 +106,7 @@ export async function buildEntityMappingsResponse( }>, componentEntities: GetEntitiesResponse, pagerDutyServices: PagerDutyService[] - ) : Promise { +): Promise { const result: PagerDutyEntityMappingsResponse = { mappings: [] @@ -203,6 +245,39 @@ export async function createRouter( // Create the router const router = Router(); router.use(express.json()); + // GET /catalog/entity/:entityRef + router.get('/catalog/entity/:type/:namespace/:name', async (request, response) => { + const type = request.params.type; + const namespace = request.params.namespace; + const name = request.params.name; + + try { + if (type && namespace && name) { + const entityRef = `${type}:${namespace}/${name}`.toLowerCase(); + const foundEntity = await catalogApi?.getEntityByRef(entityRef); + + if (foundEntity) { + response.json(foundEntity.metadata.annotations?.["pagerduty.com/service-id"]); + + } + else { + response.status(404); + } + } + else { + response.status(400).json("Bad Request: ':entityRef' must be provided as part of the path"); + } + } + catch (error) { + if (error instanceof HttpError) { + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); // POST /mapping/entity router.post('/mapping/entity', async (request, response) => { @@ -218,6 +293,31 @@ export async function createRouter( const entityMappings = await store.getAllEntityMappings(); const oldMapping = entityMappings.find((mapping) => mapping.serviceId === entity.serviceId); + // in case a mapping is defined and no integration exists, + // we need to create one + if (entity.entityRef !== "" && + (entity.integrationKey === "" || entity.integrationKey === undefined)) { + + const backstageVendorId = 'PRO19CT'; + // check for existing integration key on service + const service = await getServiceById(entity.serviceId, entity.account); + const backstageIntegration = service.integrations?.find((integration) => integration.vendor?.id === backstageVendorId); + + if (!backstageIntegration) { + // If an integration does not exist for service, + // create it in PagerDuty + const integrationKey = await createServiceIntegration({ + serviceId: entity.serviceId, + vendorId: backstageVendorId, + account: entity.account + }); + + entity.integrationKey = integrationKey; + } else { + entity.integrationKey = backstageIntegration.integration_key; + } + } + const entityMappingId = await store.insertEntityMapping(entity); // Refresh new and old entity unless they are empty strings @@ -257,8 +357,6 @@ export async function createRouter( // Get all the entity mappings from the database const entityMappings = await store.getAllEntityMappings(); - logger.info(`Retrieved ${entityMappings.length} entity mappings from the database.`); - // Get all the entities from the catalog const componentEntities = await catalogApi!.getEntities({ filter: { @@ -266,16 +364,12 @@ export async function createRouter( } }); - logger.info(`Retrieved ${componentEntities.items.length} entities from the catalog.`); - // Build reference dictionary of componentEntities with serviceId as the key and entity reference and name pair as the value const componentEntitiesDict: Record = await createComponentEntitiesReferenceDict(componentEntities); // Get all services from PagerDuty const pagerDutyServices = await getAllServices(); - logger.info(`Retrieved ${pagerDutyServices.length} services from PagerDuty.`); - // Build the response object const result: PagerDutyEntityMappingsResponse = await buildEntityMappingsResponse(entityMappings, componentEntitiesDict, componentEntities, pagerDutyServices); @@ -299,7 +393,6 @@ export async function createRouter( const entityNamespace: string = request.params.namespace || ''; const entityName: string = request.params.name || ''; - if (entityType === '' || entityNamespace === '' || entityName === '') { @@ -332,10 +425,43 @@ export async function createRouter( } }); - // Add routes + // GET /mapping/entity/service/:serviceId + router.get('/mapping/entity/service/:serviceId', async (request, response) => { + try { + // Get the type, namespace and entity name from the request parameters + const serviceId: string = request.params.serviceId ?? ''; + + if (serviceId === '') { + response.status(400).json("Required params not specified."); + return; + } + + // Get all the entity mappings from the database + const entityMapping = await store.findEntityMappingByServiceId(serviceId); + + if (!entityMapping) { + response.status(404).json(`Mapping for serviceId ${serviceId} not found.`); + return; + } + + response.json({ + mapping: entityMapping + }); + + } catch (error) { + if (error instanceof HttpError) { + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + // GET /escalation_policies router.get('/escalation_policies', async (_, response) => { - + try { let escalationPolicyList = await getAllEscalationPolicies(); @@ -349,7 +475,7 @@ export async function createRouter( const escalationPolicyDropDownOptions = escalationPolicyList.map((policy) => { let policyLabel = policy.name; - if(policy.account && policy.account !== 'default'){ + if (policy.account && policy.account !== 'default') { policyLabel = `(${policy.account}) ${policy.name}`; } @@ -460,6 +586,36 @@ export async function createRouter( } }); + // POST /services/:serviceId/integration/:vendorId + router.post('/services/:serviceId/integration/:vendorId', async (request, response) => { + try { + const serviceId: string = request.params.serviceId || ''; + const vendorId: string = request.params.vendorId || ''; + const account = request.query.account as string || ''; + + if (serviceId === '' || vendorId === '') { + response.status(400).json("Bad Request: ':serviceId' and ':vendorId' must be provided as part of the path"); + } + + const integrationKey = await createServiceIntegration({ + serviceId, + vendorId, + account + }); + + response.json(integrationKey); + } catch (error) { + if (error instanceof HttpError) { + logger.error(`Error occurred while processing request: ${error.message}`); + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + // GET /services/:serviceId/change-events router.get('/services/:serviceId/change-events', async (request, response) => { try { From cad531e64bddedf8336e9c97a52b0c3838787708 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Thu, 25 Jul 2024 09:12:28 +0100 Subject: [PATCH 3/6] deps: updating to latest pagerduty/backstage-plugin-common version Signed-off-by: Tiago Barbosa --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fba85a1..5203674 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@backstage/plugin-catalog-node": "^1.12.0", "@backstage/plugin-scaffolder-node": "^0.4.4", "@material-ui/core": "^4.12.4", - "@pagerduty/backstage-plugin-common": "0.2.0", + "@pagerduty/backstage-plugin-common": "0.2.1", "@rjsf/core": "^5.14.3", "@types/express": "^4.17.6", "express": "^4.19.2", diff --git a/yarn.lock b/yarn.lock index 6bbbbf8..ef73a9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4907,7 +4907,7 @@ __metadata: "@backstage/plugin-catalog-node": ^1.12.0 "@backstage/plugin-scaffolder-node": ^0.4.4 "@material-ui/core": ^4.12.4 - "@pagerduty/backstage-plugin-common": 0.2.0 + "@pagerduty/backstage-plugin-common": 0.2.1 "@rjsf/core": ^5.14.3 "@types/express": ^4.17.6 "@types/node": ^20.9.2 @@ -4931,10 +4931,10 @@ __metadata: languageName: unknown linkType: soft -"@pagerduty/backstage-plugin-common@npm:0.2.0": - version: 0.2.0 - resolution: "@pagerduty/backstage-plugin-common@npm:0.2.0" - checksum: d7243ef9c11408eee046be351346455316dcd8984c33a61d773fec292843d3df9eb9906dfc5ce0ed4fc0c17a60b9b3ebbfe1a8f4a25b8ecc003ffa8d4304db77 +"@pagerduty/backstage-plugin-common@npm:0.2.1": + version: 0.2.1 + resolution: "@pagerduty/backstage-plugin-common@npm:0.2.1" + checksum: 76233c2162d8e7bd3479e13652042cf949911be065f0bf92c5823cbc03c122b8ff49938ca36ab8449da800de7dd9c85724f70e3ff5323e77ef879478394115c9 languageName: node linkType: hard From 00065d4409e54551f32b9f3336024f5f3b1e1d00 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Thu, 25 Jul 2024 09:13:36 +0100 Subject: [PATCH 4/6] feat: :card_file_box: add new table to store settings and its operations Signed-off-by: Tiago Barbosa --- migrations/20240722_add_settings_table.js | 27 +++++++++++++++++ src/db/PagerDutyBackendDatabase.ts | 36 ++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 migrations/20240722_add_settings_table.js diff --git a/migrations/20240722_add_settings_table.js b/migrations/20240722_add_settings_table.js new file mode 100644 index 0000000..f612422 --- /dev/null +++ b/migrations/20240722_add_settings_table.js @@ -0,0 +1,27 @@ +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('pagerduty_settings', table => { + table + .string('id') + .unique() + .notNullable(); + table + .string('value'); + table + .dateTime('updatedAt') + .defaultTo(knex.fn.now()); + table.index(['id'], 'settings_id_idx'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('pagerduty_settings', table => { + table.dropIndex([], 'settings_id_idx'); + }); + await knex.schema.dropTable('pagerduty_settings'); +}; \ No newline at end of file diff --git a/src/db/PagerDutyBackendDatabase.ts b/src/db/PagerDutyBackendDatabase.ts index 2db9d3d..85a5ae5 100644 --- a/src/db/PagerDutyBackendDatabase.ts +++ b/src/db/PagerDutyBackendDatabase.ts @@ -1,4 +1,4 @@ -import { PagerDutyEntityMapping } from "@pagerduty/backstage-plugin-common"; +import { PagerDutyEntityMapping, PagerDutySetting } from "@pagerduty/backstage-plugin-common"; import { resolvePackagePath } from "@backstage/backend-plugin-api"; import { Knex } from 'knex'; import { v4 as uuid } from 'uuid'; @@ -18,6 +18,9 @@ export interface PagerDutyBackendStore { getAllEntityMappings(): Promise findEntityMappingByEntityRef(entityRef: string): Promise findEntityMappingByServiceId(serviceId: string): Promise + updateSetting(setting: PagerDutySetting): Promise + findSetting(settingId: string): Promise + getAllSettings(): Promise } type Options = { @@ -84,4 +87,35 @@ export class PagerDutyBackendDatabase implements PagerDutyBackendStore { return rawEntity; } + + async updateSetting(setting: PagerDutySetting): Promise { + const [result] = await this.db('pagerduty_settings') + .insert({ + id: setting.id, + value: setting.value + }) + .onConflict(['id']) + .merge(['value']) + .returning('id'); + + return result.id; + } + + async findSetting(settingId: string): Promise { + const rawEntity = await this.db('pagerduty_settings') + .where('id', settingId) + .first(); + + return rawEntity; + } + + async getAllSettings(): Promise { + const rawEntities = await this.db('pagerduty_settings'); + + if (!rawEntities) { + return []; + } + + return rawEntities; + } } \ No newline at end of file From 329e056ca243cef21d4ad64842dc8948d3438997 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Thu, 25 Jul 2024 10:10:03 +0100 Subject: [PATCH 5/6] feat: adding apis to interact with settings persistence Signed-off-by: Tiago Barbosa --- src/service/router.ts | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/service/router.ts b/src/service/router.ts index bd5bc53..d2f7484 100644 --- a/src/service/router.ts +++ b/src/service/router.ts @@ -37,6 +37,7 @@ import { PagerDutyEntityMapping, PagerDutyEntityMappingsResponse, PagerDutyService, + PagerDutySetting } from '@pagerduty/backstage-plugin-common'; import { loadAuthConfig } from '../auth/auth'; import { @@ -279,6 +280,73 @@ export async function createRouter( } }); + // POST /settings + router.post('/settings', async (request, response) => { + try { + // Get the serviceId from the request parameters + const settings : PagerDutySetting[] = request.body; + + // For each setting, update or insert the value in the database + await Promise.all(settings.map(async (setting) => { + if(setting.id === undefined || setting.value === undefined) { + response.status(400).json("Bad Request: 'id' and 'value' are required"); + } + + if(!isValidSetting(setting.value)) { + response.status(400).json("Bad Request: 'value' is invalid. Valid options are 'backstage', 'pagerduty', 'both' or 'disabled'"); + } + + await store.updateSetting(setting); + })); + + response.sendStatus(200); + + } catch (error) { + if (error instanceof HttpError) { + logger.error(`Error occurred while processing request: ${error.message}`); + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + + // GET /settings/:settingId + router.get('/settings/:settingId', async (request, response) => { + try { + // Get param from the request + const settingId = request.params.settingId; + + // Find setting by id + const setting = await store.findSetting(settingId); + + if (!setting) { + response.status(404).json({}); + return; + } + + response.json(setting); + } catch (error) { + if (error instanceof HttpError) { + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + + function isValidSetting(value: string): boolean { + if(value === "backstage" || value === "pagerduty" || value === "both" || value === "disabled") { + return true; + } + + return false; + } + // POST /mapping/entity router.post('/mapping/entity', async (request, response) => { try { From 887ba4686d1bb3bfbeb4633858929a82845c39ba Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Thu, 25 Jul 2024 10:10:18 +0100 Subject: [PATCH 6/6] feat: adding support for service dependencies Signed-off-by: Tiago Barbosa --- src/apis/pagerduty.ts | 241 +++++++++++++++++++++++++++++++++++-- src/service/router.test.ts | 88 +++++++++++++- src/service/router.ts | 133 +++++++++++++++++++- 3 files changed, 445 insertions(+), 17 deletions(-) diff --git a/src/apis/pagerduty.ts b/src/apis/pagerduty.ts index 8077b35..5903e98 100644 --- a/src/apis/pagerduty.ts +++ b/src/apis/pagerduty.ts @@ -22,7 +22,8 @@ import { PagerDutyServicesAPIResponse, PagerDutyAccountConfig, PagerDutyIntegrationResponse, - PagerDutyAccountConfig + PagerDutyServiceDependency, + PagerDutyServiceDependencyResponse, } from '@pagerduty/backstage-plugin-common'; import { DateTime } from 'luxon'; @@ -102,6 +103,155 @@ function getApiBaseUrl(account?: string): string { } // Supporting router +export async function addServiceRelationsToService(serviceRelations: PagerDutyServiceDependency[], account?: string): Promise { + let response: Response; + const options: RequestInit = { + method: 'POST', + headers: { + Authorization: await getAuthToken(account), + 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + relationships: serviceRelations + }) + }; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/service_dependencies/associate`; + + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to retrieve service dependencies: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError(`Failed to add service dependencies. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + + switch (response.status) { + case 400: + throw new HttpError("Failed to add service dependencies. Caller provided invalid arguments. Please review the response for error details. Retrying with the same arguments will not work.", 400); + case 401: + throw new HttpError("Failed to add service dependencies. Caller did not supply credentials or did not provide the correct credentials. If you are using an API key, it may be invalid or your Authorization header may be malformed.", 401); + case 403: + throw new HttpError("Failed to add service dependencies. Caller is not authorized to view the requested resource. While your authentication is valid, the authenticated user or token does not have permission to perform this action.", 403); + case 404: + throw new HttpError("Failed to add service dependencies. The requested resource was not found.", 404); + default: // 200 + break; + } + + let result: PagerDutyServiceDependencyResponse; + try { + result = await response.json(); + + return result.relationships; + + } catch (error) { + throw new HttpError(`Failed to parse service dependency information: ${error}`, 500); + } +} + +export async function removeServiceRelationsFromService(serviceRelations: PagerDutyServiceDependency[], account?: string): Promise { + let response: Response; + const options: RequestInit = { + method: 'POST', + headers: { + Authorization: await getAuthToken(account), + 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + relationships: serviceRelations + }) + }; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/service_dependencies/disassociate`; + + try { + response = await fetchWithRetries(`${baseUrl}`, options); + } catch (error) { + throw new Error(`Failed to retrieve service dependencies: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError(`Failed to remove service dependencies. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + + switch (response.status) { + case 400: + throw new HttpError("Failed to remove service dependencies. Caller provided invalid arguments. Please review the response for error details. Retrying with the same arguments will not work.", 400); + case 401: + throw new HttpError("Failed to remove service dependencies. Caller did not supply credentials or did not provide the correct credentials. If you are using an API key, it may be invalid or your Authorization header may be malformed.", 401); + case 403: + throw new HttpError("Failed to remove service dependencies. Caller is not authorized to view the requested resource. While your authentication is valid, the authenticated user or token does not have permission to perform this action.", 403); + case 404: + throw new HttpError("Failed to remove service dependencies. The requested resource was not found.", 404); + default: // 200 + break; + } + + let result: PagerDutyServiceDependencyResponse; + try { + result = await response.json(); + + return result.relationships; + + } catch (error) { + throw new HttpError(`Failed to parse service dependency information: ${error}`, 500); + } +} + +export async function getServiceRelationshipsById(serviceId: string, account?: string): Promise { + let response: Response; + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: await getAuthToken(account), + 'Accept': 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }, + }; + + const apiBaseUrl = getApiBaseUrl(account); + const baseUrl = `${apiBaseUrl}/service_dependencies/technical_services/${serviceId}`; + + try { + response = await fetchWithRetries(baseUrl, options); + } catch (error) { + throw new Error(`Failed to retrieve service dependencies: ${error}`); + } + + if (response.status >= 500) { + throw new HttpError(`Failed to list service dependencies. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + + switch (response.status) { + case 400: + throw new HttpError("Failed to list service dependencies. Caller provided invalid arguments. Please review the response for error details. Retrying with the same arguments will not work.", 400); + case 401: + throw new HttpError("Failed to list service dependencies. Caller did not supply credentials or did not provide the correct credentials. If you are using an API key, it may be invalid or your Authorization header may be malformed.", 401); + case 403: + throw new HttpError("Failed to list service dependencies. Caller is not authorized to view the requested resource. While your authentication is valid, the authenticated user or token does not have permission to perform this action.", 403); + case 404: + throw new HttpError("Failed to list service dependencies. The requested resource was not found.", 404); + default: // 200 + break; + } + + let result: PagerDutyServiceDependencyResponse; + try { + result = await response.json(); + + return result.relationships; + } catch (error) { + throw new HttpError(`Failed to parse service dependency information: ${error}`, 500); + } +} + async function getEscalationPolicies(offset: number, limit: number, account?: string): Promise<[Boolean, PagerDutyEscalationPolicy[]]> { let response: Response; @@ -119,11 +269,15 @@ async function getEscalationPolicies(offset: number, limit: number, account?: st const baseUrl = `${apiBaseUrl}/escalation_policies`; try { - response = await fetch(`${baseUrl}?${params}`, options); + response = await fetchWithRetries(`${baseUrl}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve escalation policies: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to list escalation policies. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to list escalation policies. Caller provided invalid arguments.", 400); @@ -208,11 +362,15 @@ export async function isEventNoiseReductionEnabled(account?: string): Promise= 500) { + throw new HttpError(`Failed to read abilities. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 401: throw new Error(`Failed to read abilities. Caller did not supply credentials or did not provide the correct credentials.`); @@ -256,11 +414,15 @@ export async function getOncallUsers(escalationPolicy: string, account?: string) const baseUrl = `${apiBaseUrl}/oncalls`; try { - response = await fetch(`${baseUrl}?${params}`, options); + response = await fetchWithRetries(`${baseUrl}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve oncalls: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to list oncalls. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to list oncalls. Caller provided invalid arguments.", 400); @@ -332,11 +494,15 @@ export async function getServiceById(serviceId: string, account?: string): Promi const baseUrl = `${apiBaseUrl}/services`; try { - response = await fetch(`${baseUrl}/${serviceId}?${params}`, options); + response = await fetchWithRetries(`${baseUrl}/${serviceId}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to get service. Caller provided invalid arguments.", 400); @@ -378,11 +544,15 @@ export async function getServiceByIntegrationKey(integrationKey: string, account const baseUrl = `${apiBaseUrl}/services`; try { - response = await fetch(`${baseUrl}?${params}`, options); + response = await fetchWithRetries(`${baseUrl}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to get service. Caller provided invalid arguments.", 400); @@ -440,7 +610,11 @@ export async function getAllServices(): Promise { do { const paginatedUrl = `${baseUrl}?${params}&offset=${offset}&limit=${limit}`; - response = await fetch(paginatedUrl, options); + response = await fetchWithRetries(paginatedUrl, options); + + if (response.status >= 500) { + throw new HttpError(`Failed to get services. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } switch (response.status) { case 400: @@ -489,11 +663,15 @@ export async function getChangeEvents(serviceId: string, account?: string): Prom const baseUrl = `${apiBaseUrl}/services`; try { - response = await fetch(`${baseUrl}/${serviceId}/change_events?${params}`, options); + response = await fetchWithRetries(`${baseUrl}/${serviceId}/change_events?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve change events for service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get change events for service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to get change events for service. Caller provided invalid arguments.", 400); @@ -534,11 +712,15 @@ export async function getIncidents(serviceId: string, account?: string): Promise const baseUrl = `${apiBaseUrl}/incidents`; try { - response = await fetch(`${baseUrl}?${params}`, options); + response = await fetchWithRetries(`${baseUrl}?${params}`, options); } catch (error) { throw new Error(`Failed to retrieve incidents for service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get incidents for service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to get incidents for service. Caller provided invalid arguments.", 400); @@ -580,11 +762,15 @@ export async function getServiceStandards(serviceId: string, account?: string): const baseUrl = `${apiBaseUrl}/standards/scores/technical_services/${serviceId}`; try { - response = await fetch(baseUrl, options); + response = await fetchWithRetries(baseUrl, options); } catch (error) { throw new Error(`Failed to retrieve service standards for service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get service standards for service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 401: throw new HttpError("Failed to get service standards for service. Caller did not supply credentials or did not provide the correct credentials.", 401); @@ -633,11 +819,15 @@ export async function getServiceMetrics(serviceId: string, account?: string): Pr const baseUrl = `${apiBaseUrl}/analytics/metrics/incidents/services`; try { - response = await fetch(baseUrl, options); + response = await fetchWithRetries(baseUrl, options); } catch (error) { throw new Error(`Failed to retrieve service metrics for service: ${error}`); } + if (response.status >= 500) { + throw new HttpError(`Failed to get service metrics for service. PagerDuty API returned a server error. Retrying with the same arguments will not work.`, response.status); + } + switch (response.status) { case 400: throw new HttpError("Failed to get service metrics for service. Caller provided invalid arguments. Please review the response for error details. Retrying with the same arguments will not work.", 400); @@ -692,11 +882,15 @@ export async function createServiceIntegration({ serviceId, vendorId, account }: }; try { - response = await fetch(`${baseUrl}/${serviceId}/integrations`, options); + response = await fetchWithRetries(`${baseUrl}/${serviceId}/integrations`, options); } catch (error) { throw new Error(`Failed to create service integration: ${error}`); } + if (response.status >= 500) { + throw new Error(`Failed to create service integration. PagerDuty API returned a server error. Retrying with the same arguments will not work.`); + } + switch (response.status) { case 400: throw new Error(`Failed to create service integration. Caller provided invalid arguments.`); @@ -721,4 +915,27 @@ export async function createServiceIntegration({ serviceId, vendorId, account }: } } +export async function fetchWithRetries(url: string, options: RequestInit): Promise { + let response: Response; + let error: Error = new Error(); + + // set retry parameters + const maxRetries = 5; + const delay = 1000; + let factor = 2; + + for (let i = 0; i < maxRetries; i++) { + try { + response = await fetch(url, options); + return response; + } catch (e) { + error = e; + } + + const timeout = delay * factor; + await new Promise(resolve => setTimeout(resolve, timeout)); + factor *= 2; + } + throw new Error(`Failed to fetch data after ${maxRetries} retries. Last error: ${error}`); +} diff --git a/src/service/router.test.ts b/src/service/router.test.ts index 58fc5fa..261b436 100644 --- a/src/service/router.test.ts +++ b/src/service/router.test.ts @@ -4,7 +4,7 @@ import express from 'express'; import request from 'supertest'; import { createRouter, createComponentEntitiesReferenceDict, buildEntityMappingsResponse } from './router'; -import { PagerDutyEscalationPolicy, PagerDutyService, PagerDutyServiceResponse, PagerDutyOnCallUsersResponse, PagerDutyChangeEventsResponse, PagerDutyChangeEvent, PagerDutyIncidentsResponse, PagerDutyIncident, PagerDutyServiceStandardsResponse, PagerDutyServiceMetricsResponse, PagerDutyEntityMappingsResponse } from '@pagerduty/backstage-plugin-common'; +import { PagerDutyEscalationPolicy, PagerDutyService, PagerDutyServiceResponse, PagerDutyOnCallUsersResponse, PagerDutyChangeEventsResponse, PagerDutyChangeEvent, PagerDutyIncidentsResponse, PagerDutyIncident, PagerDutyServiceStandardsResponse, PagerDutyServiceMetricsResponse, PagerDutyEntityMappingsResponse, PagerDutyServiceDependencyResponse } from '@pagerduty/backstage-plugin-common'; import { mocked } from "jest-mock"; import fetch, { Response } from "node-fetch"; @@ -85,6 +85,92 @@ describe('createRouter', () => { }); }); + describe('DELETE /dependencies/service/:serviceId', () => { + it.each(testInputs)('returns 400 if dependencies are not provided', async () => { + const response = await request(app).delete('/dependencies/service/12345'); + + expect(response.status).toEqual(400); + expect(response.body).toEqual("Bad Request: 'dependencies' must be provided as part of the request body"); + }); + + it.each(testInputs)('returns 200 if service relations are removed successfully', async () => { + mocked(fetch).mockReturnValue(mockedResponse(200, {})); + + const response = await request(app) + .delete('/dependencies/service/12345') + .send(['dependency1', 'dependency2']); + + expect(response.status).toEqual(200); + }); + }); + + describe('POST /dependencies/service/:serviceId', () => { + it.each(testInputs)('returns 400 if dependencies are not provided', async () => { + const response = await request(app).post('/dependencies/service/12345'); + + expect(response.status).toEqual(400); + expect(response.body).toEqual("Bad Request: 'dependencies' must be provided as part of the request body"); + }); + + it.each(testInputs)('returns 200 if service relations are added successfully', async () => { + mocked(fetch).mockReturnValue(mockedResponse(200, {})); + + const response = await request(app) + .post('/dependencies/service/12345') + .send(['dependency1', 'dependency2']); + + expect(response.status).toEqual(200); + }); + }); + + describe('GET /dependencies/service/:serviceId', () => { + it.each(testInputs)('returns 200 with service relationships if serviceId is valid', async () => { + const mockedResult : PagerDutyServiceDependencyResponse = { + relationships: [ + { + id: "12345", + type: "service_dependency", + dependent_service: { + id: "54321", + type: "technical_service_reference" + }, + supporting_service: { + id: "12345", + type: "technical_service_reference" + } + }, + { + id: "871278", + type: "service_dependency", + dependent_service: { + id: "91292", + type: "technical_service_reference" + }, + supporting_service: { + id: "12345", + type: "technical_service_reference" + } + } + ] + } + + mocked(fetch).mockReturnValue(mockedResponse(200, mockedResult)); + + const response = await request(app).get('/dependencies/service/12345'); + + expect(response.status).toEqual(200); + expect(response.body).toHaveProperty('relationships'); + }); + + it.each(testInputs)('returns 404 if serviceId is not found', async () => { + mocked(fetch).mockReturnValue(mockedResponse(404, {})); + + const response = await request(app).get('/dependencies/service/S3RVICE1D'); + + expect(response.status).toEqual(404); + }); + }); + describe('GET /escalation_policies', () => { it.each(testInputs)('returns ok', async () => { mocked(fetch).mockReturnValue(mockedResponse(200, { diff --git a/src/service/router.ts b/src/service/router.ts index d2f7484..2b2fa09 100644 --- a/src/service/router.ts +++ b/src/service/router.ts @@ -20,10 +20,9 @@ import { getAllServices, loadPagerDutyEndpointsFromConfig, createServiceIntegration, -import { AuthService, DiscoveryService, LoggerService, RootConfigService } from '@backstage/backend-plugin-api'; -import { createLegacyAuthAdapters, errorHandler } from '@backstage/backend-common'; -import { getAllEscalationPolicies, getChangeEvents, getIncidents, getOncallUsers, getServiceById, getServiceByIntegrationKey, getServiceStandards, getServiceMetrics, getAllServices, loadPagerDutyEndpointsFromConfig } from '../apis/pagerduty'; -import { HttpError, PagerDutyChangeEventsResponse, PagerDutyIncidentsResponse, PagerDutyOnCallUsersResponse, PagerDutyServiceResponse, PagerDutyServiceStandardsResponse, PagerDutyServiceMetricsResponse, PagerDutyServicesResponse, PagerDutyEntityMapping, PagerDutyEntityMappingsResponse, PagerDutyService } from '@pagerduty/backstage-plugin-common'; + getServiceRelationshipsById, + addServiceRelationsToService, + removeServiceRelationsFromService } from '../apis/pagerduty'; import { HttpError, @@ -37,6 +36,7 @@ import { PagerDutyEntityMapping, PagerDutyEntityMappingsResponse, PagerDutyService, + PagerDutyServiceDependency, PagerDutySetting } from '@pagerduty/backstage-plugin-common'; import { loadAuthConfig } from '../auth/auth'; @@ -246,6 +246,131 @@ export async function createRouter( // Create the router const router = Router(); router.use(express.json()); + + // DELETE /dependencies/service/:serviceId + router.delete('/dependencies/service/:serviceId', async (request, response) => { + try { + // Get the serviceId from the request parameters + const serviceId = request.params.serviceId || ''; + const account = request.query.account as string || ''; + + if(serviceId === '') { + response.status(400).json("Bad Request: ':serviceId' must be provided as part of the path"); + } + + const dependencies: string[] = Object.keys(request.body).length === 0 ? [] : request.body; + if(!dependencies || dependencies.length === 0) { + response.status(400).json("Bad Request: 'dependencies' must be provided as part of the request body"); + } + + const serviceRelations : PagerDutyServiceDependency[] = []; + + dependencies.forEach(async (dependency) => { + serviceRelations.push({ + supporting_service: { + id: dependency, + type: "service" + }, + dependent_service: { + id: serviceId, + type: "service" + } + }); + }); + + await removeServiceRelationsFromService(serviceRelations, account); + + response.sendStatus(200); + + } catch (error) { + if (error instanceof HttpError) { + logger.error(`Error occurred while processing request: ${error.message}`); + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + + // POST /dependencies/service/:serviceId + router.post('/dependencies/service/:serviceId', async (request, response) => { + try { + // Get the serviceId from the request parameters + const serviceId = request.params.serviceId || ''; + const account = request.query.account as string || ''; + + if(serviceId === '') { + response.status(400).json("Bad Request: ':serviceId' must be provided as part of the path"); + } + + const dependencies: string[] = Object.keys(request.body).length === 0 ? [] : request.body; + if(!dependencies || dependencies.length === 0) { + response.status(400).json("Bad Request: 'dependencies' must be provided as part of the request body"); + } + + const serviceRelations : PagerDutyServiceDependency[] = []; + + dependencies.forEach(async (dependency) => { + serviceRelations.push({ + supporting_service: { + id: dependency, + type: "service" + }, + dependent_service: { + id: serviceId, + type: "service" + } + }); + }); + + await addServiceRelationsToService(serviceRelations, account); + + response.sendStatus(200); + + } catch (error) { + if (error instanceof HttpError) { + logger.error(`Error occurred while processing request: ${error.message}`); + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + + + router.get('/dependencies/service/:serviceId', async (request, response) => { + try { + const serviceId = request.params.serviceId; + const account = request.query.account as string || ''; + + if (serviceId) { + const serviceRelationships: PagerDutyServiceDependency[] = await getServiceRelationshipsById(serviceId, account); + + if (serviceRelationships) { + response.json({ + relationships: serviceRelationships + }); + } + } + else { + response.status(400).json("Bad Request: ':serviceId' must be provided as part of the path"); + } + } + catch (error) { + if (error instanceof HttpError) { + response.status(error.status).json({ + errors: [ + `${error.message}` + ] + }); + } + } + }); + // GET /catalog/entity/:entityRef router.get('/catalog/entity/:type/:namespace/:name', async (request, response) => { const type = request.params.type;