Skip to content
This repository was archived by the owner on Aug 6, 2025. It is now read-only.

Commit 5d395cf

Browse files
authored
DOP-3769: Create webhook to handle post-build operations from Gatsby Cloud builds (#852)
1 parent 8822d58 commit 5d395cf

File tree

11 files changed

+313
-33
lines changed

11 files changed

+313
-33
lines changed

.github/workflows/deploy-stg-ecs.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ on:
33
branches:
44
- "master"
55
- "integration"
6-
- "close-lambda-mdb-clients"
76
concurrency:
87
group: environment-stg-${{ github.ref }}
98
cancel-in-progress: true

api/config/custom-environment-variables.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"slackSecret": "SLACK_SECRET",
1414
"slackAuthToken": "SLACK_TOKEN",
1515
"slackViewOpenUrl": "https://slack.com/api/views.open",
16+
"snootySecret": "SNOOTY_SECRET",
1617
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
1718
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
1819
"dashboardUrl": "DASHBOARD_URL",

api/config/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"slackSecret": "SLACK_SECRET",
1414
"slackAuthToken": "SLACK_TOKEN",
1515
"slackViewOpenUrl": "https://slack.com/api/views.open",
16+
"snootySecret": "SNOOTY_SECRET",
1617
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
1718
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
1819
"repoBranchesCollection": "REPO_BRANCHES_COL_NAME",

api/controllers/v1/jobs.ts

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as c from 'config';
2+
import crypto from 'crypto';
23
import * as mongodb from 'mongodb';
34
import { IConfig } from 'config';
5+
import { APIGatewayEvent, APIGatewayProxyResult } from 'aws-lambda';
46
import { RepoEntitlementsRepository } from '../../../src/repositories/repoEntitlementsRepository';
57
import { BranchRepository } from '../../../src/repositories/branchRepository';
68
import { ConsoleLogger } from '../../../src/services/logger';
@@ -13,6 +15,19 @@ import { ECSContainer } from '../../../src/services/containerServices';
1315
import { SQSConnector } from '../../../src/services/queue';
1416
import { Batch } from '../../../src/services/batch';
1517

18+
// Although data in payload should always be present, it's not guaranteed from
19+
// external callers
20+
interface SnootyPayload {
21+
jobId?: string;
22+
}
23+
24+
// These options should only be defined if the build summary is being called after
25+
// a Gatsby Cloud job
26+
interface BuildSummaryOptions {
27+
mongoClient?: mongodb.MongoClient;
28+
previewUrl?: string;
29+
}
30+
1631
export const TriggerLocalBuild = async (event: any = {}, context: any = {}): Promise<any> => {
1732
const client = new mongodb.MongoClient(c.get('dbUrl'));
1833
await client.connect();
@@ -160,9 +175,11 @@ async function retry(message: JobQueueMessage, consoleLogger: ConsoleLogger, url
160175
consoleLogger.error(message['jobId'], err);
161176
}
162177
}
163-
async function NotifyBuildSummary(jobId: string): Promise<any> {
178+
179+
async function NotifyBuildSummary(jobId: string, options: BuildSummaryOptions = {}): Promise<any> {
180+
const { mongoClient, previewUrl } = options;
164181
const consoleLogger = new ConsoleLogger();
165-
const client = new mongodb.MongoClient(c.get('dbUrl'));
182+
const client: mongodb.MongoClient = mongoClient ?? new mongodb.MongoClient(c.get('dbUrl'));
166183
await client.connect();
167184
const db = client.db(c.get('dbName'));
168185
const env = c.get<string>('env');
@@ -187,6 +204,11 @@ async function NotifyBuildSummary(jobId: string): Promise<any> {
187204
const prCommentId = await githubCommenter.getPullRequestCommentId(fullDocument.payload, pr);
188205
const fullJobDashboardUrl = c.get<string>('dashboardUrl') + jobId;
189206

207+
// We currently avoid posting the Gatsby Cloud preview url on GitHub to avoid
208+
// potentially conflicting behavior with the S3 staging link with parallel
209+
// frontend builds. This is in case the GC build finishing first causes the
210+
// initial comment to be made with a nullish S3 url, while subsequent comment
211+
// updates only append the list of build logs.
190212
if (prCommentId !== undefined) {
191213
const ghMessage = prepGithubComment(fullDocument, fullJobDashboardUrl, true);
192214
await githubCommenter.updateComment(fullDocument.payload, prCommentId, ghMessage);
@@ -213,7 +235,8 @@ async function NotifyBuildSummary(jobId: string): Promise<any> {
213235
repoName,
214236
c.get<string>('dashboardUrl'),
215237
jobId,
216-
fullDocument.status == 'failed'
238+
fullDocument.status == 'failed',
239+
previewUrl
217240
),
218241
entitlement['slack_user_id']
219242
);
@@ -247,22 +270,26 @@ async function prepSummaryMessage(
247270
repoName: string,
248271
jobUrl: string,
249272
jobId: string,
250-
failed = false
273+
failed = false,
274+
previewUrl?: string
251275
): Promise<string> {
252276
const urls = extractUrlFromMessage(fullDocument);
253-
let mms_urls = [null, null];
277+
let mms_urls: Array<string | null> = [null, null];
254278
// mms-docs needs special handling as it builds two sites (cloudmanager & ops manager)
255279
// so we need to extract both URLs
256280
if (repoName === 'mms-docs') {
257281
if (urls.length >= 2) {
258-
// TODO: Type 'string[]' is not assignable to type 'null[]'.
259282
mms_urls = urls.slice(-2);
260283
}
261284
}
285+
262286
let url = '';
263-
if (urls.length > 0) {
287+
if (previewUrl) {
288+
url = previewUrl;
289+
} else if (urls.length > 0) {
264290
url = urls[urls.length - 1];
265291
}
292+
266293
let msg = '';
267294
if (failed) {
268295
msg = `Your Job <${jobUrl}${jobId}|Failed>! Please check the build log for any errors.\n- Repo: *${repoName}*\n- Branch: *${fullDocument.payload.branchName}*\n- urlSlug: *${fullDocument.payload.urlSlug}*\n- Env: *${env}*\n Check logs for more errors!!\nSorry :disappointed:! `;
@@ -385,3 +412,111 @@ async function SubmitArchiveJob(jobId: string) {
385412
consoleLogger.info('submit archive job', JSON.stringify({ jobId: jobId, batchJobId: response.jobId }));
386413
await client.close();
387414
}
415+
416+
/**
417+
* Checks the signature payload as a rough validation that the request was made by
418+
* the Snooty frontend.
419+
* @param payload - stringified JSON payload
420+
* @param signature - the Snooty signature included in the header
421+
*/
422+
function validateSnootyPayload(payload: string, signature: string) {
423+
const secret = c.get<string>('snootySecret');
424+
const expectedSignature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
425+
return signature === expectedSignature;
426+
}
427+
428+
/**
429+
* Performs post-build operations such as notifications and db updates for job ID
430+
* provided in its payload. This is typically expected to only be called by
431+
* Snooty's Gatsby Cloud source plugin.
432+
* @param event
433+
* @returns
434+
*/
435+
export async function SnootyBuildComplete(event: APIGatewayEvent): Promise<APIGatewayProxyResult> {
436+
const consoleLogger = new ConsoleLogger();
437+
const defaultHeaders = { 'Content-Type': 'text/plain' };
438+
439+
if (!event.body) {
440+
const err = 'SnootyBuildComplete does not have a body in event payload';
441+
consoleLogger.error('SnootyBuildCompleteError', err);
442+
return {
443+
statusCode: 400,
444+
headers: defaultHeaders,
445+
body: err,
446+
};
447+
}
448+
449+
// Keep lowercase in case header is automatically converted to lowercase
450+
// The Snooty frontend should be mindful of using a lowercase header
451+
const snootySignature = event.headers['x-snooty-signature'];
452+
if (!snootySignature) {
453+
const err = 'SnootyBuildComplete does not have a signature in event payload';
454+
consoleLogger.error('SnootyBuildCompleteError', err);
455+
return {
456+
statusCode: 400,
457+
headers: defaultHeaders,
458+
body: err,
459+
};
460+
}
461+
462+
if (!validateSnootyPayload(event.body, snootySignature)) {
463+
const errMsg = 'Payload signature is incorrect';
464+
consoleLogger.error('SnootyBuildCompleteError', errMsg);
465+
return {
466+
statusCode: 401,
467+
headers: defaultHeaders,
468+
body: errMsg,
469+
};
470+
}
471+
472+
let payload: SnootyPayload | undefined;
473+
try {
474+
payload = JSON.parse(event.body) as SnootyPayload;
475+
} catch (e) {
476+
const errMsg = 'Payload is not valid JSON';
477+
return {
478+
statusCode: 400,
479+
headers: defaultHeaders,
480+
body: errMsg,
481+
};
482+
}
483+
484+
const { jobId } = payload;
485+
if (!jobId) {
486+
const errMsg = 'Payload missing job ID';
487+
consoleLogger.error('SnootyBuildCompleteError', errMsg);
488+
return {
489+
statusCode: 400,
490+
headers: defaultHeaders,
491+
body: errMsg,
492+
};
493+
}
494+
495+
const client = new mongodb.MongoClient(c.get('dbUrl'));
496+
497+
try {
498+
await client.connect();
499+
const db = client.db(c.get<string>('dbName'));
500+
const jobRepository = new JobRepository(db, c, consoleLogger);
501+
await jobRepository.updateWithCompletionStatus(jobId, null, false);
502+
// Placeholder preview URL until we iron out the Gatsby Cloud site URLs.
503+
// This would probably involve fetching the URLs in the db on a per project basis
504+
const previewUrl = 'https://www.mongodb.com/docs/';
505+
await NotifyBuildSummary(jobId, { mongoClient: client, previewUrl });
506+
} catch (e) {
507+
consoleLogger.error('SnootyBuildCompleteError', e);
508+
return {
509+
statusCode: 500,
510+
headers: defaultHeaders,
511+
body: e,
512+
};
513+
} finally {
514+
await client.close();
515+
}
516+
517+
return {
518+
statusCode: 200,
519+
headers: defaultHeaders,
520+
body: `Snooty build ${jobId} completed`,
521+
};
522+
}

cdk-infra/static/api/config/custom-environment-variables.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"slackSecret": "SLACK_SECRET",
1313
"slackAuthToken": "SLACK_TOKEN",
1414
"slackViewOpenUrl": "https://slack.com/api/views.open",
15+
"snootySecret": "SNOOTY_SECRET",
1516
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
1617
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
1718
"dashboardUrl": "DASHBOARD_URL",

cdk-infra/static/api/config/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"slackSecret": "SLACK_SECRET",
1313
"slackAuthToken": "SLACK_TOKEN",
1414
"slackViewOpenUrl": "https://slack.com/api/views.open",
15+
"snootySecret": "SNOOTY_SECRET",
1516
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
1617
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
1718
"repoBranchesCollection": "REPO_BRANCHES_COL_NAME",

cdk-infra/utils/ssm.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const webhookSecureStrings = [
110110
'/cdn/client/secret',
111111
'/slack/webhook/secret',
112112
'/slack/auth/token',
113+
'/snooty/webhook/secret',
113114
] as const;
114115

115116
type WebhookSecureString = typeof webhookSecureStrings[number];
@@ -125,6 +126,7 @@ webhookParamPathToEnvName.set('/cdn/client/id', 'CDN_CLIENT_ID');
125126
webhookParamPathToEnvName.set('/cdn/client/secret', 'CDN_CLIENT_SECRET');
126127
webhookParamPathToEnvName.set('/slack/auth/token', 'SLACK_TOKEN');
127128
webhookParamPathToEnvName.set('/slack/webhook/secret', 'SLACK_SECRET');
129+
webhookParamPathToEnvName.set('/snooty/webhook/secret', 'SNOOTY_SECRET');
128130

129131
export async function getWebhookSecureStrings(ssmPrefix: string): Promise<Record<string, string>> {
130132
return getSecureStrings(ssmPrefix, webhookSecureStrings, webhookParamPathToEnvName, 'webhookParamPathToEnvName');

serverless.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ custom:
6565
githubBotPW: ${ssm:/env/${self:provider.stage}/docs/worker_pool/github/bot/password}
6666
slackSecret: ${ssm:/env/${self:provider.stage}/docs/worker_pool/slack/webhook/secret}
6767
slackAuthToken: ${ssm:/env/${self:provider.stage}/docs/worker_pool/slack/auth/token}
68+
snootySecret: ${ssm:/env/${self:provider.stage}/docs/worker_pool/snooty/webhook/secret}
6869
JobsQueueName: autobuilder-jobs-queue-${self:provider.stage}
6970
JobsDLQueueName: autobuilder-jobs-dlqueue-${self:provider.stage}
7071
JobUpdatesQueueName: autobuilder-job-updates-queue-${self:provider.stage}
@@ -112,6 +113,7 @@ webhook-env-core: &webhook-env-core
112113
REPO_BRANCHES_COL_NAME: ${self:custom.repoBranchesCollection}
113114
SLACK_SECRET: ${self:custom.slackSecret}
114115
SLACK_TOKEN: ${self:custom.slackAuthToken}
116+
SNOOTY_SECRET: ${self:custom.snootySecret}
115117
DASHBOARD_URL: ${self:custom.dashboardUrl.${self:provider.stage}}
116118
NODE_CONFIG_DIR: './api/config'
117119
TASK_DEFINITION_FAMILY: docs-worker-pool-${self:provider.stage}
@@ -261,6 +263,16 @@ functions:
261263
environment:
262264
<<: *webhook-env-core
263265

266+
v1SnootyBuildComplete:
267+
handler: api/controllers/v1/jobs.SnootyBuildComplete
268+
events:
269+
- http:
270+
path: /webhook/snooty/trigger/complete
271+
method: POST
272+
cors: true
273+
environment:
274+
<<: *webhook-env-core
275+
264276
Outputs:
265277
JobsQueueURL:
266278
Description: Jobs Queue Url

src/repositories/jobRepository.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,14 @@ export class JobRepository extends BaseRepository {
2121
this._queueConnector = new SQSConnector(logger, config);
2222
}
2323

24-
async updateWithCompletionStatus(id: string, result: any): Promise<boolean> {
25-
const query = { _id: id };
24+
async updateWithCompletionStatus(
25+
id: string | mongodb.ObjectId,
26+
result: any,
27+
shouldNotifySqs = true
28+
): Promise<boolean> {
29+
// Safely convert to object ID
30+
const objectId = new mongodb.ObjectId(id);
31+
const query = { _id: objectId };
2632
const update = {
2733
$set: {
2834
status: 'completed',
@@ -35,8 +41,8 @@ export class JobRepository extends BaseRepository {
3541
update,
3642
`Mongo Timeout Error: Timed out while updating success status for jobId: ${id}`
3743
);
38-
if (bRet) {
39-
await this.notify(id, c.get('jobUpdatesQueueUrl'), JobStatus.completed, 0);
44+
if (bRet && shouldNotifySqs) {
45+
await this.notify(objectId.toString(), c.get('jobUpdatesQueueUrl'), JobStatus.completed, 0);
4046
}
4147
return bRet;
4248
}

0 commit comments

Comments
 (0)