Skip to content

Commit

Permalink
Restructure jobs router + Add heartbeat
Browse files Browse the repository at this point in the history
  • Loading branch information
brian-lou committed Oct 17, 2024
1 parent 5fadb11 commit 0958182
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 61 deletions.
6 changes: 6 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
50 changes: 24 additions & 26 deletions src/jobs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
);
```

Expand Down
24 changes: 24 additions & 0 deletions src/jobs/heartbeat.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
);
});
});
15 changes: 15 additions & 0 deletions src/jobs/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
27 changes: 23 additions & 4 deletions src/jobs/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});

Expand Down Expand Up @@ -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();
});
});
79 changes: 48 additions & 31 deletions src/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
try {
const client = new OAuth2Client();
// Get the Cloud Scheduler JWT in the "Authorization" header.
Expand All @@ -28,15 +26,15 @@ export async function handleJobRoute(
if (!bearer) {
reply.code(400);
reply.send();
return;
return false;
}

const match = bearer.match(/Bearer (.*)/);

if (!match) {
reply.code(400);
reply.send();
return;
return false;
}

const token = match[1];
Expand All @@ -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',
Expand All @@ -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<void> {
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
Expand Down

0 comments on commit 0958182

Please sign in to comment.