-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: Add a generic webhook for sending event notifications #879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 12 commits
b15d743
20b59c3
575eb6f
e38691d
3627224
53802ac
6f14385
2d50f63
67ff8c2
780a143
2628a1a
ad0745b
c61f87c
3915a01
30cb490
7a308c7
85ac0f9
7ff4b83
bc4ed32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /* | ||
|
|
||
| This file contains secrets used for verifying incoming events from different HTTP sources. | ||
|
|
||
| */ | ||
|
|
||
| export const EVENT_NOTIFIER_SECRETS = { | ||
| // Follow the pattern below to add a new secret | ||
| // 'example-service': process.env.EXAMPLE_SERVICE_SECRET, | ||
| }; | ||
| if (process.env.ENV !== 'production') | ||
| EVENT_NOTIFIER_SECRETS['example-service'] = | ||
| process.env.EXAMPLE_SERVICE_SECRET; |
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { IncomingMessage, Server, ServerResponse } from 'http'; | ||
|
|
||
| import { EventAlertType } from '@datadog/datadog-api-client/dist/packages/datadog-api-client-v1'; | ||
| import { Block, KnownBlock } from '@slack/types'; | ||
| import { FastifyInstance } from 'fastify'; | ||
|
|
||
| // e.g. the return type of `buildServer` | ||
|
|
@@ -26,3 +28,24 @@ export interface KafkaControlPlaneResponse { | |
| title: string; | ||
| body: string; | ||
| } | ||
|
|
||
| export type GenericEvent = { | ||
| source: string; | ||
| timestamp: number; | ||
| service_name?: string; // Official service registry name if applicable | ||
|
||
| data: { | ||
| title: string; | ||
| message: string; | ||
| channels: { | ||
| slack?: string[]; // list of Slack Channels | ||
| datadog?: string[]; // list of DD Monitors | ||
| jira?: string[]; // list of Jira Projects | ||
| bigquery?: string; | ||
| }; | ||
| tags?: string[]; // Not used for Slack | ||
| misc: { | ||
| alertType?: EventAlertType; // Datadog alert type | ||
| blocks?: (KnownBlock | Block)[]; // Optional Slack blocks | ||
|
||
| }; | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import testInvalidPayload from '@test/payloads/generic-notifier/testInvalidPayload.json'; | ||
| import testPayload from '@test/payloads/generic-notifier/testPayload.json'; | ||
| import { createNotifierRequest } from '@test/utils/createGenericMessageRequest'; | ||
|
|
||
| import { buildServer } from '@/buildServer'; | ||
| import { DATADOG_API_INSTANCE } from '@/config'; | ||
| import { bolt } from '@api/slack'; | ||
|
|
||
| import { messageSlack } from './generic-notifier'; | ||
|
|
||
| describe('generic messages webhook', function () { | ||
| let fastify; | ||
| beforeEach(async function () { | ||
| fastify = await buildServer(false); | ||
| }); | ||
|
|
||
| afterEach(function () { | ||
| fastify.close(); | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('correctly inserts generic notifier when stage starts', async function () { | ||
| jest.spyOn(bolt.client.chat, 'postMessage').mockImplementation(jest.fn()); | ||
| jest | ||
| .spyOn(DATADOG_API_INSTANCE, 'createEvent') | ||
| .mockImplementation(jest.fn()); | ||
| const response = await createNotifierRequest(fastify, testPayload); | ||
|
|
||
| expect(response.statusCode).toBe(200); | ||
| }); | ||
|
|
||
| it('returns 400 for an invalid source', async function () { | ||
| const response = await fastify.inject({ | ||
| method: 'POST', | ||
| url: '/event-notifier/v1', | ||
| payload: testInvalidPayload, | ||
| }); | ||
| expect(response.statusCode).toBe(400); | ||
| }); | ||
| it('returns 400 for invalid signature', async function () { | ||
| const response = await fastify.inject({ | ||
| method: 'POST', | ||
| url: '/event-notifier/v1', | ||
| headers: { | ||
| 'x-infra-hub-signature': 'invalid', | ||
| }, | ||
| payload: testPayload, | ||
| }); | ||
| expect(response.statusCode).toBe(400); | ||
| }); | ||
|
|
||
| it('returns 400 for no signature', async function () { | ||
| const response = await fastify.inject({ | ||
| method: 'POST', | ||
| url: '/event-notifier/v1', | ||
| payload: testPayload, | ||
| }); | ||
| expect(response.statusCode).toBe(400); | ||
| }); | ||
|
|
||
| describe('messageSlack tests', function () { | ||
| afterEach(function () { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('writes to slack', async function () { | ||
| const postMessageSpy = jest.spyOn(bolt.client.chat, 'postMessage'); | ||
| await messageSlack(testPayload); | ||
| expect(postMessageSpy).toHaveBeenCalledTimes(1); | ||
| const message = postMessageSpy.mock.calls[0][0]; | ||
| expect(message).toEqual({ | ||
| channel: '#aaaaaa', | ||
| text: 'Random text here', | ||
| unfurl_links: false, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it('checks that slack msg is sent', async function () { | ||
| const postMessageSpy = jest.spyOn(bolt.client.chat, 'postMessage'); | ||
| const response = await createNotifierRequest(fastify, testPayload); | ||
|
|
||
| expect(postMessageSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| expect(response.statusCode).toBe(200); | ||
| }); | ||
| it('checks that dd msg is sent', async function () { | ||
| const ddMessageSpy = jest.spyOn(DATADOG_API_INSTANCE, 'createEvent'); | ||
| const response = await createNotifierRequest(fastify, testPayload); | ||
|
|
||
| expect(ddMessageSpy).toHaveBeenCalledTimes(1); | ||
|
|
||
| expect(response.statusCode).toBe(200); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { v1 } from '@datadog/datadog-api-client'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import { FastifyReply, FastifyRequest } from 'fastify'; | ||
| import moment from 'moment-timezone'; | ||
|
|
||
| import { GenericEvent } from '@types'; | ||
|
|
||
| import { bolt } from '@/api/slack'; | ||
| import { DATADOG_API_INSTANCE } from '@/config'; | ||
| import { EVENT_NOTIFIER_SECRETS } from '@/config/secrets'; | ||
| import { extractAndVerifySignature } from '@/utils/auth/extractAndVerifySignature'; | ||
|
|
||
| export async function genericEventNotifier( | ||
| request: FastifyRequest<{ Body: GenericEvent }>, | ||
| reply: FastifyReply | ||
| ): Promise<void> { | ||
| try { | ||
| // If the webhook secret is not defined, throw an error | ||
| const { body }: { body: GenericEvent } = request; | ||
| if ( | ||
| body.source === undefined || | ||
| EVENT_NOTIFIER_SECRETS[body.source] === undefined | ||
| ) { | ||
| reply.code(400).send('Invalid source or missing secret'); | ||
| throw new Error('Invalid source or missing secret'); | ||
| } | ||
|
|
||
| const isVerified = await extractAndVerifySignature( | ||
| request, | ||
| reply, | ||
| 'x-infra-hub-signature', | ||
| EVENT_NOTIFIER_SECRETS[body.source] | ||
| ); | ||
| if (!isVerified) { | ||
| // If the signature is not verified, return (since extractAndVerifySignature sends the response) | ||
| return; | ||
| } | ||
|
|
||
| await messageSlack(body); | ||
| await sendEventToDatadog(body, moment().unix()); | ||
|
||
| reply.code(200).send('OK'); | ||
| return; | ||
| } catch (err) { | ||
| console.error(err); | ||
| Sentry.captureException(err); | ||
| reply.code(500).send(); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| export async function sendEventToDatadog( | ||
| message: GenericEvent, | ||
| timestamp: number | ||
| ) { | ||
| if (message.data.channels.datadog) { | ||
|
||
| const params: v1.EventCreateRequest = { | ||
| title: message.data.title, | ||
| text: message.data.message, | ||
| alertType: message.data.misc.alertType, | ||
| dateHappened: timestamp, | ||
| tags: message.data.tags, | ||
| }; | ||
| await DATADOG_API_INSTANCE.createEvent({ body: params }); | ||
| } | ||
| } | ||
|
|
||
| export async function messageSlack(message: GenericEvent) { | ||
| if (message.data.channels.slack) { | ||
| for (const channel of message.data.channels.slack) { | ||
| const text = message.data.message; | ||
| try { | ||
| await bolt.client.chat.postMessage({ | ||
| channel: channel, | ||
| blocks: message.data.misc.blocks, | ||
| text: text, | ||
| unfurl_links: false, | ||
| }); | ||
| } catch (err) { | ||
| Sentry.setContext('msg:', { text }); | ||
| Sentry.captureException(err); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| { | ||
| "source": "admin", | ||
| "title": "this is a title", | ||
| "body": "this is a text body" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "service_name": "official_service_name", | ||
| "data": { | ||
| "message": "Random text here", | ||
| "tags": [ | ||
| "source:example-service", "sentry-region:all", "sentry-user:bob" | ||
| ], | ||
| "misc": {}, | ||
| "channels": { | ||
| "slack": ["#C07GZR8LA82"], | ||
| "datadog": ["example-proj-id"], | ||
| "jira": ["INC"] | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.