From 0958182a8467708f7a42e99cdf75f6b34d0d3f6b Mon Sep 17 00:00:00 2001 From: Brian Lou Date: Thu, 17 Oct 2024 15:28:26 -0700 Subject: [PATCH] Restructure jobs router + Add heartbeat --- src/config/index.ts | 6 +++ src/jobs/README.md | 50 ++++++++++++------------ src/jobs/heartbeat.test.ts | 24 ++++++++++++ src/jobs/heartbeat.ts | 15 ++++++++ src/jobs/index.test.ts | 27 +++++++++++-- src/jobs/index.ts | 79 +++++++++++++++++++++++--------------- 6 files changed, 140 insertions(+), 61 deletions(-) create mode 100644 src/jobs/heartbeat.test.ts create mode 100644 src/jobs/heartbeat.ts diff --git a/src/config/index.ts b/src/config/index.ts index 97a81b51..f95ff257 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -237,3 +237,9 @@ export const KAFKA_CONTROL_PLANE_WEBHOOK_SECRET = * Regex */ export const SHA256_REGEX = /^[A-Fa-f0-9]{64}$/; + +/** + * Cron Job consts + */ +export const INFRA_HUB_HEARTBEAT_CHANNEL = + process.env.INFRA_HUB_HEARTBEAT_CHANNEL || 'C07T1QYJ672'; diff --git a/src/jobs/README.md b/src/jobs/README.md index d33db607..599a8fac 100644 --- a/src/jobs/README.md +++ b/src/jobs/README.md @@ -2,36 +2,21 @@ Files in this subdirectory contain code for webhooks which trigger cron jobs. They are ran by Cloud Scheduler. -One of the available cron jobs is `stale-triage-notifier` (defined in `slackNotificaitons.ts`) with a payload in the following shape: - -```ts -type PubSubPayload = { - name: string; - slo?: number; - repos?: string[]; -}; -``` - -This payload will be sent regularly using the [Cloud Scheduler][cloud_scheduler] -to notify product owners about their issues pending triage over [our SLO][process_doc]. - -[cloud_scheduler]: https://cloud.google.com/scheduler/docs/tut-pub-sub#create_a_job -[process_doc]: https://www.notion.so/sentry/Engaging-Customers-177c77ac473e41eabe9ca7b4bf537537#9d7b15dec9c345618b9195fb5c785e53 - ## List of all Cron Jobs -| Job Name | Route | Files | -| -------------------------------- | -------------------------------- | --------------------------------- | -| `stale-triage-notifier` | `/jobs/stale-triage-notifier` | `slackNotifications.ts` | -| `stale-bot` | `/jobs/stale-bot` | `stalebot.ts` | -| `slack-scores` | `/jobs/slack-scores` | `slackScores.ts` | -| `gocd-paused-pipeline-bot` | `/jobs/gocd-paused-pipeline-bot` | `gocdPausedPipelineBot.ts` | +| Job Name | Route | Folders | +| -------------------------------- | -------------------------------- | ------------------------------- | +| `stale-triage-notifier` | `/jobs/stale-triage-notifier` | `/staleTriageNotifier` | +| `stale-bot` | `/jobs/stale-bot` | `/staleBot` | +| `slack-scores` | `/jobs/slack-scores` | `/slackScores` | +| `gocd-paused-pipeline-bot` | `/jobs/gocd-paused-pipeline-bot` | `/gocdPausedPipeline` | +| `heartbeat` | `/jobs/heartbeat` | `/heartbeat` | ## Development To add a new cron job: -* Create a unique file in this subdirectory +* Create a new file and corresponding test file in this subdirectory * In this file, export a function: ```ts @@ -41,14 +26,27 @@ export async function cronJobName( ){} ``` -(`org` and `now` are are used for some reason by all of the other cron jobs, but you can just rename them to `_org` and `_now`) -This function should run logic a single time when it is called. +or + +```ts +export async function cronJobName(){} +``` + +Depending on if your job is a Github-related job, or a generic cron job. This function should run logic a single time when it is called. * In `index.ts`, import the function and add the following code to the `routeJobs` function: ```ts server.post('/cron-job-path', (request, reply) => - handleJobRoute(cronJobName, request, reply) + handleGithubJobs(cronJobName, request, reply) +); +``` + +or + +```ts +server.post('/cron-job-path', (request, reply) => + handleCronJobs(cronJobName, request, reply) ); ``` diff --git a/src/jobs/heartbeat.test.ts b/src/jobs/heartbeat.test.ts new file mode 100644 index 00000000..1d4687e2 --- /dev/null +++ b/src/jobs/heartbeat.test.ts @@ -0,0 +1,24 @@ +import { bolt } from '@/api/slack'; + +import { heartbeat } from './heartbeat'; + +describe('test uptime heartbeat', function () { + let postMessageSpy; + beforeEach(() => { + postMessageSpy = jest + .spyOn(bolt.client.chat, 'postMessage') + .mockImplementation(jest.fn()); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should send a message to slack', async () => { + await heartbeat(); + expect(postMessageSpy).toHaveBeenCalledTimes(1); + expect(postMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Infra Hub is up', + }) + ); + }); +}); diff --git a/src/jobs/heartbeat.ts b/src/jobs/heartbeat.ts new file mode 100644 index 00000000..51fe241c --- /dev/null +++ b/src/jobs/heartbeat.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; + +import { bolt } from '@/api/slack'; +import { INFRA_HUB_HEARTBEAT_CHANNEL } from '@/config'; + +export async function heartbeat() { + try { + await bolt.client.chat.postMessage({ + channel: INFRA_HUB_HEARTBEAT_CHANNEL, + text: 'Infra Hub is up', + }); + } catch (err) { + Sentry.captureException(err); + } +} diff --git a/src/jobs/index.test.ts b/src/jobs/index.test.ts index ee154077..1d453068 100644 --- a/src/jobs/index.test.ts +++ b/src/jobs/index.test.ts @@ -7,18 +7,20 @@ import { OAuth2Client } from 'google-auth-library'; import * as gocdPausedPipelineBot from './gocdPausedPipelineBot'; import { triggerPausedPipelineBot } from './gocdPausedPipelineBot'; +import * as heartbeat from './heartbeat'; import * as slackNotifications from './slackNotifications'; import { notifyProductOwnersForUntriagedIssues } from './slackNotifications'; import * as slackScores from './slackScores'; import { triggerSlackScores } from './slackScores'; import * as staleBot from './stalebot'; import { triggerStaleBot } from './stalebot'; -import { handleJobRoute, routeJobs } from '.'; +import { handleGithubJobs, routeJobs } from '.'; const mockGocdPausedPipelineBot = jest.fn(); const mockNotifier = jest.fn(); const mockSlackScores = jest.fn(); const mockStaleBot = jest.fn(); +const mockHeartbeat = jest.fn(); jest .spyOn(gocdPausedPipelineBot, 'triggerPausedPipelineBot') @@ -30,6 +32,7 @@ jest .spyOn(slackScores, 'triggerSlackScores') .mockImplementation(mockSlackScores); jest.spyOn(staleBot, 'triggerStaleBot').mockImplementation(mockStaleBot); +jest.spyOn(heartbeat, 'heartbeat').mockImplementation(mockHeartbeat); class MockReply { statusCode: number = 0; @@ -62,7 +65,7 @@ describe('cron jobs testing', function () { }, } as FastifyRequest<{ Body: { message: { data: string } } }>; const reply = new MockReply() as FastifyReply; - await handleJobRoute(mapOperation(operationSlug), request, reply); + await handleGithubJobs(mapOperation(operationSlug), request, reply); return reply; } let server: FastifyInstance; @@ -135,7 +138,7 @@ describe('cron jobs testing', function () { headers: {}, } as FastifyRequest<{ Body: { message: { data: string } } }>; const reply = new MockReply() as FastifyReply; - await handleJobRoute(mapOperation('stale-bot'), request, reply); + await handleGithubJobs(mapOperation('stale-bot'), request, reply); expect(reply.statusCode).toBe(400); }); @@ -153,7 +156,7 @@ describe('cron jobs testing', function () { }, } as FastifyRequest<{ Body: { message: { data: string } } }>; const reply = new MockReply() as FastifyReply; - await handleJobRoute(mapOperation('stale-bot'), request, reply); + await handleGithubJobs(mapOperation('stale-bot'), request, reply); expect(reply.statusCode).toBe(400); }); @@ -227,4 +230,20 @@ describe('cron jobs testing', function () { expect(mockSlackScores).not.toHaveBeenCalled(); expect(mockStaleBot).not.toHaveBeenCalled(); }); + + it('POST /heartbeat should call uptime heartbeat', async () => { + const reply = await server.inject({ + method: 'POST', + url: '/jobs/heartbeat', + headers: { + authorization: 'Bearer 1234abcd', + }, + }); + expect(reply.statusCode).toBe(204); + expect(mockGocdPausedPipelineBot).not.toHaveBeenCalled(); + expect(mockNotifier).not.toHaveBeenCalled(); + expect(mockSlackScores).not.toHaveBeenCalled(); + expect(mockStaleBot).not.toHaveBeenCalled(); + expect(mockHeartbeat).toHaveBeenCalled(); + }); }); diff --git a/src/jobs/index.ts b/src/jobs/index.ts index 80ddc026..06ed03e9 100644 --- a/src/jobs/index.ts +++ b/src/jobs/index.ts @@ -9,17 +9,15 @@ import { GH_ORGS } from '@/config'; import { Fastify } from '@/types'; import { triggerPausedPipelineBot } from './gocdPausedPipelineBot'; +import { heartbeat } from './heartbeat'; import { notifyProductOwnersForUntriagedIssues } from './slackNotifications'; import { triggerSlackScores } from './slackScores'; import { triggerStaleBot } from './stalebot'; -// Error handling wrapper function -// Additionally handles Auth from Cloud Scheduler -export async function handleJobRoute( - handler, +async function handleCronAuth( request: FastifyRequest, reply: FastifyReply -) { +): Promise { try { const client = new OAuth2Client(); // Get the Cloud Scheduler JWT in the "Authorization" header. @@ -28,7 +26,7 @@ export async function handleJobRoute( if (!bearer) { reply.code(400); reply.send(); - return; + return false; } const match = bearer.match(/Bearer (.*)/); @@ -36,7 +34,7 @@ export async function handleJobRoute( if (!match) { reply.code(400); reply.send(); - return; + return false; } const token = match[1]; @@ -54,7 +52,21 @@ export async function handleJobRoute( } catch (e) { reply.code(401); reply.send(); - return; + return false; + } + return true; +} + +// Error handling wrapper function for Cron Jobs involving Github +// Additionally handles Auth from Cloud Scheduler +export async function handleGithubJobs( + handler, + request: FastifyRequest, + reply: FastifyReply +) { + const verified = await handleCronAuth(request, reply); + if (!verified) { + return; // Reply is already sent, so no need to re-send } const tx = Sentry.startTransaction({ op: 'webhooks', @@ -74,39 +86,44 @@ export async function handleJobRoute( tx.finish(); } -export const opts = { - schema: { - body: { - type: 'object', - required: ['message'], - properties: { - message: { - type: 'object', - required: ['data'], - properties: { - data: { - type: 'string', - }, - }, - }, - }, - }, - }, -}; +// Error handling wrapper function for Cron Jobs +// Additionally handles Auth from Cloud Scheduler +export async function handleCronJobs( + handler, + request: FastifyRequest, + reply: FastifyReply +) { + const verified = await handleCronAuth(request, reply); + if (!verified) { + return; // Reply is already sent, so no need to re-send + } + const tx = Sentry.startTransaction({ + op: 'webhooks', + name: 'jobs.jobsHandler', + }); + + reply.code(204); + reply.send(); // Respond early to not block the webhook sender + await handler(); + tx.finish(); +} // Function that creates a sub fastify server for job webhooks export async function routeJobs(server: Fastify, _options): Promise { server.post('/stale-triage-notifier', (request, reply) => - handleJobRoute(notifyProductOwnersForUntriagedIssues, request, reply) + handleGithubJobs(notifyProductOwnersForUntriagedIssues, request, reply) ); server.post('/stale-bot', (request, reply) => - handleJobRoute(triggerStaleBot, request, reply) + handleGithubJobs(triggerStaleBot, request, reply) ); server.post('/slack-scores', (request, reply) => - handleJobRoute(triggerSlackScores, request, reply) + handleGithubJobs(triggerSlackScores, request, reply) ); server.post('/gocd-paused-pipeline-bot', (request, reply) => - handleJobRoute(triggerPausedPipelineBot, request, reply) + handleGithubJobs(triggerPausedPipelineBot, request, reply) + ); + server.post('/heartbeat', (request, reply) => + handleCronJobs(heartbeat, request, reply) ); // Default handler for invalid routes