Skip to content

Commit

Permalink
adding backend plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
sammbetts committed Oct 23, 2023
1 parent d6e2f52 commit ba872a4
Show file tree
Hide file tree
Showing 15 changed files with 14,299 additions and 0 deletions.
1 change: 1 addition & 0 deletions service-health-backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.createTable('incident_record', table => {
table.comment(
'Table for tracking the service incidents we have alerted on',
);

table
.string('incident_id')
.notNullable()
.primary()
.comment('The upstream identifier of the incident');
table.boolean('sent').notNullable().comment('Have we sent the alert?');
});
};

/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('incident_record');
};
48 changes: 48 additions & 0 deletions service-health-backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"name": "@sammbetts/plugin-service-health-backend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "^0.19.5",
"@backstage/config": "^1.1.0",
"@types/express": "*",
"axios": "^1.5.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^2.5.1",
"luxon": "^3.4.3",
"node-fetch": "^2.6.7",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.22.13",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist",
"migrations/**/*.{js,d.ts}"
]
}
17 changes: 17 additions & 0 deletions service-health-backend/src/functions/dateTimeFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DateTime } from 'luxon';

export function convertToUKDateTimeFormat(isoDateString: string): string {
const dateTime = DateTime.fromISO(isoDateString, { setZone: true });

const day = dateTime.day;
const month = dateTime.month;
const year = dateTime.year;
const hours = dateTime.hour;
const minutes = dateTime.minute;

return `${day.toString().padStart(2, '0')}/${month
.toString()
.padStart(2, '0')}/${year}, ${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}`;
}
23 changes: 23 additions & 0 deletions service-health-backend/src/functions/sendSlackNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const sendSlackNotification = async (
serviceName: string,
incidentServiceName: string,
incidentTime: string,
slackWebhookUrl: string,
) => {
const payload = {
text: `:warning: *Service Health - Third party incident alert* :warning:
\nAn active incident has been reported by *${serviceName}* at *${incidentTime}* affecting *${incidentServiceName}*.
\n
\n_Powered by Backstage Service Health Plugin_`,
};

fetch(slackWebhookUrl, {
method: 'POST',
headers: {},
body: JSON.stringify(payload),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to send Slack notification: ${response.status}`);
}
});
};
2 changes: 2 additions & 0 deletions service-health-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './service/router';
export * from './service/IncidentNotifier';
17 changes: 17 additions & 0 deletions service-health-backend/src/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getRootLogger } from '@backstage/backend-common';
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 logger = getRootLogger();

startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});

process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
59 changes: 59 additions & 0 deletions service-health-backend/src/service/DatabaseLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import { Knex } from 'knex';

const migrationsDir = resolvePackagePath(
'@internal/plugin-service-health-backend',
'migrations',
);

const TABLE_NAME = 'incident_record';

export class DatabaseHandler {
static async create(
database: PluginDatabaseManager,
): Promise<DatabaseHandler> {
const client: any = await database.getClient();

if (!database.migrations?.skip) {
await client.migrate.latest({
directory: migrationsDir,
});
}

return new DatabaseHandler(client);
}

private readonly client: Knex;

private constructor(client: Knex) {
this.client = client;
}

getIncident = async (incidentId: string) => {
const [incident] = await this.client
.select()
.from(TABLE_NAME)
.where('incident_id', incidentId)
.limit(1);

return incident;
};

createIncidentRecord = async (incidentId: string) => {
await this.client
.insert({
incident_id: incidentId,
sent: false,
})
.into(TABLE_NAME);
};

updateIncidentRecord = async (id: string, sent: boolean) => {
return await this.client(TABLE_NAME).where({ incident_id: id }).update({
sent,
});
};
}
93 changes: 93 additions & 0 deletions service-health-backend/src/service/IncidentNotifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { sendSlackNotification } from '../functions/sendSlackNotification';
import { refreshAllServices } from './serviceHealthLogic';
import { DatabaseHandler } from './DatabaseLayer';
import { convertToUKDateTimeFormat } from '../functions/dateTimeFunction';

export class IncidentNotifier {
private readonly logger: Logger;
private db?: DatabaseHandler;
private slackWebhookUrl: string;

constructor(logger: Logger, config: Config) {
this.logger = logger;
this.slackWebhookUrl = config.getString(
'serviceHealth.slackWebhookUrl',
);
}

async connect(database: PluginDatabaseManager) {
this.db = await DatabaseHandler.create(database);
}
async run(): Promise<{ services: any[] }> {
if (!this.db) {
this.logger.error(
"Can't handle incidents, not connected to the database",
);
return { services: [] };
}
try {
const healthData = await refreshAllServices(this.db);
const newIncidents: any[] = [];

for (const service of healthData) {
for (const incident of service.incidents) {
if (await this.isIncidentNew(incident.id)) {
incident.serviceName = service.serviceName;
incident.componentName = service.incidentComponents;
incident.updated = convertToUKDateTimeFormat(
incident.modified || incident.updated_at || incident.date_updated,
);
newIncidents.push(incident);
}
}
}
if (newIncidents.length > 0) {
await this.logIncidents(newIncidents);
}
return {
services: newIncidents,
};
} catch (error) {
this.logger.error(`Error while fetching health data: ${(error as Error).message}`);
return { services: [] };
}
}

private async isIncidentNew(incidentId: string): Promise<boolean> {
const existingIncident = await this.db?.getIncident(incidentId);
return !existingIncident;
}

async logIncidents(incidents: any[]): Promise<void> {
for (const incident of incidents) {
try {
const incidentNameOptions = [
incident.service_name,
incident.componentName,
incident.services,
];
let incidentName = '';
for (const option of incidentNameOptions) {
if (option) {
incidentName = option;
break;
}
}
await sendSlackNotification(
incident.serviceName,
incidentName,
incident.updated,
this.slackWebhookUrl,
);
await this.db?.createIncidentRecord(incident.id);
} catch (error) {
this.logger.error(
`Failed to send Slack notification for incident ID: ${incident.id}`,
);
}
}
}
}
36 changes: 36 additions & 0 deletions service-health-backend/src/service/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import express from 'express';
import request from 'supertest';
import { getVoidLogger } from '@backstage/backend-common';

import { createRouter } from './router';

describe('createRouter', () => {
let app: express.Express;

beforeAll(async () => {
const mockDatabaseService = {
getClient: jest.fn(),
migrations: {
skip: true,
},
};
const router = await createRouter({
logger: getVoidLogger(),
database: mockDatabaseService as any,
});
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' });
});
});
});
47 changes: 47 additions & 0 deletions service-health-backend/src/service/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import express, { Request, Response } from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { PluginDatabaseManager, errorHandler } from '@backstage/backend-common';
import { DatabaseHandler } from './DatabaseLayer';
import { refreshAllServices } from './serviceHealthLogic';

export interface RouterOptions {
logger: Logger;
database: PluginDatabaseManager;
}

export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const dbHandler = await DatabaseHandler.create(options.database);
router.use(express.json());

router.get('/latest', async (_: Request, response: Response) => {
const services = await refreshAllServices(dbHandler);
response.json({ timestamp: Date.now().toString(), services });
});

router.get('/health', async (_: Request, response: Response) => {
response.json({ status: 'ok' });
});

router.post('/health', async (_: Request, response: Response) => {
response.json({ status: 'ok' });
});

router.patch('/health/:id', async (request: Request, response: Response) => {
const { id } = request.params;
const body = request.body;
const count = await dbHandler.updateIncidentRecord(id, body);

if (count) {
response.json({ status: 'ok' });
} else {
response.status(404).json({ message: 'Record not found' });
}
});

router.use(errorHandler());
return router;
}
Loading

0 comments on commit ba872a4

Please sign in to comment.