Skip to content

Commit

Permalink
DOP-3769: Create webhook to handle post-build operations from Gatsby …
Browse files Browse the repository at this point in the history
…Cloud builds (#852)
  • Loading branch information
rayangler authored Jul 14, 2023
1 parent 8822d58 commit 5d395cf
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 33 deletions.
1 change: 0 additions & 1 deletion .github/workflows/deploy-stg-ecs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ on:
branches:
- "master"
- "integration"
- "close-lambda-mdb-clients"
concurrency:
group: environment-stg-${{ github.ref }}
cancel-in-progress: true
Expand Down
1 change: 1 addition & 0 deletions api/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"slackSecret": "SLACK_SECRET",
"slackAuthToken": "SLACK_TOKEN",
"slackViewOpenUrl": "https://slack.com/api/views.open",
"snootySecret": "SNOOTY_SECRET",
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
"dashboardUrl": "DASHBOARD_URL",
Expand Down
1 change: 1 addition & 0 deletions api/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"slackSecret": "SLACK_SECRET",
"slackAuthToken": "SLACK_TOKEN",
"slackViewOpenUrl": "https://slack.com/api/views.open",
"snootySecret": "SNOOTY_SECRET",
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
"repoBranchesCollection": "REPO_BRANCHES_COL_NAME",
Expand Down
149 changes: 142 additions & 7 deletions api/controllers/v1/jobs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as c from 'config';
import crypto from 'crypto';
import * as mongodb from 'mongodb';
import { IConfig } from 'config';
import { APIGatewayEvent, APIGatewayProxyResult } from 'aws-lambda';
import { RepoEntitlementsRepository } from '../../../src/repositories/repoEntitlementsRepository';
import { BranchRepository } from '../../../src/repositories/branchRepository';
import { ConsoleLogger } from '../../../src/services/logger';
Expand All @@ -13,6 +15,19 @@ import { ECSContainer } from '../../../src/services/containerServices';
import { SQSConnector } from '../../../src/services/queue';
import { Batch } from '../../../src/services/batch';

// Although data in payload should always be present, it's not guaranteed from
// external callers
interface SnootyPayload {
jobId?: string;
}

// These options should only be defined if the build summary is being called after
// a Gatsby Cloud job
interface BuildSummaryOptions {
mongoClient?: mongodb.MongoClient;
previewUrl?: string;
}

export const TriggerLocalBuild = async (event: any = {}, context: any = {}): Promise<any> => {

Check warning on line 31 in api/controllers/v1/jobs.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type

Check warning on line 31 in api/controllers/v1/jobs.ts

View workflow job for this annotation

GitHub Actions / test

'context' is assigned a value but never used
const client = new mongodb.MongoClient(c.get('dbUrl'));
await client.connect();
Expand Down Expand Up @@ -160,9 +175,11 @@ async function retry(message: JobQueueMessage, consoleLogger: ConsoleLogger, url
consoleLogger.error(message['jobId'], err);
}
}
async function NotifyBuildSummary(jobId: string): Promise<any> {

async function NotifyBuildSummary(jobId: string, options: BuildSummaryOptions = {}): Promise<any> {
const { mongoClient, previewUrl } = options;
const consoleLogger = new ConsoleLogger();
const client = new mongodb.MongoClient(c.get('dbUrl'));
const client: mongodb.MongoClient = mongoClient ?? new mongodb.MongoClient(c.get('dbUrl'));
await client.connect();
const db = client.db(c.get('dbName'));
const env = c.get<string>('env');
Expand All @@ -187,6 +204,11 @@ async function NotifyBuildSummary(jobId: string): Promise<any> {
const prCommentId = await githubCommenter.getPullRequestCommentId(fullDocument.payload, pr);
const fullJobDashboardUrl = c.get<string>('dashboardUrl') + jobId;

// We currently avoid posting the Gatsby Cloud preview url on GitHub to avoid
// potentially conflicting behavior with the S3 staging link with parallel
// frontend builds. This is in case the GC build finishing first causes the
// initial comment to be made with a nullish S3 url, while subsequent comment
// updates only append the list of build logs.
if (prCommentId !== undefined) {
const ghMessage = prepGithubComment(fullDocument, fullJobDashboardUrl, true);
await githubCommenter.updateComment(fullDocument.payload, prCommentId, ghMessage);
Expand All @@ -213,7 +235,8 @@ async function NotifyBuildSummary(jobId: string): Promise<any> {
repoName,
c.get<string>('dashboardUrl'),
jobId,
fullDocument.status == 'failed'
fullDocument.status == 'failed',
previewUrl
),
entitlement['slack_user_id']
);
Expand Down Expand Up @@ -247,22 +270,26 @@ async function prepSummaryMessage(
repoName: string,
jobUrl: string,
jobId: string,
failed = false
failed = false,
previewUrl?: string
): Promise<string> {
const urls = extractUrlFromMessage(fullDocument);
let mms_urls = [null, null];
let mms_urls: Array<string | null> = [null, null];
// mms-docs needs special handling as it builds two sites (cloudmanager & ops manager)
// so we need to extract both URLs
if (repoName === 'mms-docs') {
if (urls.length >= 2) {
// TODO: Type 'string[]' is not assignable to type 'null[]'.
mms_urls = urls.slice(-2);
}
}

let url = '';
if (urls.length > 0) {
if (previewUrl) {
url = previewUrl;
} else if (urls.length > 0) {
url = urls[urls.length - 1];
}

let msg = '';
if (failed) {
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:! `;
Expand Down Expand Up @@ -385,3 +412,111 @@ async function SubmitArchiveJob(jobId: string) {
consoleLogger.info('submit archive job', JSON.stringify({ jobId: jobId, batchJobId: response.jobId }));
await client.close();
}

/**
* Checks the signature payload as a rough validation that the request was made by
* the Snooty frontend.
* @param payload - stringified JSON payload
* @param signature - the Snooty signature included in the header
*/
function validateSnootyPayload(payload: string, signature: string) {
const secret = c.get<string>('snootySecret');
const expectedSignature = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return signature === expectedSignature;
}

/**
* Performs post-build operations such as notifications and db updates for job ID
* provided in its payload. This is typically expected to only be called by
* Snooty's Gatsby Cloud source plugin.
* @param event
* @returns
*/
export async function SnootyBuildComplete(event: APIGatewayEvent): Promise<APIGatewayProxyResult> {
const consoleLogger = new ConsoleLogger();
const defaultHeaders = { 'Content-Type': 'text/plain' };

if (!event.body) {
const err = 'SnootyBuildComplete does not have a body in event payload';
consoleLogger.error('SnootyBuildCompleteError', err);
return {
statusCode: 400,
headers: defaultHeaders,
body: err,
};
}

// Keep lowercase in case header is automatically converted to lowercase
// The Snooty frontend should be mindful of using a lowercase header
const snootySignature = event.headers['x-snooty-signature'];
if (!snootySignature) {
const err = 'SnootyBuildComplete does not have a signature in event payload';
consoleLogger.error('SnootyBuildCompleteError', err);
return {
statusCode: 400,
headers: defaultHeaders,
body: err,
};
}

if (!validateSnootyPayload(event.body, snootySignature)) {
const errMsg = 'Payload signature is incorrect';
consoleLogger.error('SnootyBuildCompleteError', errMsg);
return {
statusCode: 401,
headers: defaultHeaders,
body: errMsg,
};
}

let payload: SnootyPayload | undefined;
try {
payload = JSON.parse(event.body) as SnootyPayload;
} catch (e) {
const errMsg = 'Payload is not valid JSON';
return {
statusCode: 400,
headers: defaultHeaders,
body: errMsg,
};
}

const { jobId } = payload;
if (!jobId) {
const errMsg = 'Payload missing job ID';
consoleLogger.error('SnootyBuildCompleteError', errMsg);
return {
statusCode: 400,
headers: defaultHeaders,
body: errMsg,
};
}

const client = new mongodb.MongoClient(c.get('dbUrl'));

try {
await client.connect();
const db = client.db(c.get<string>('dbName'));
const jobRepository = new JobRepository(db, c, consoleLogger);
await jobRepository.updateWithCompletionStatus(jobId, null, false);
// Placeholder preview URL until we iron out the Gatsby Cloud site URLs.
// This would probably involve fetching the URLs in the db on a per project basis
const previewUrl = 'https://www.mongodb.com/docs/';
await NotifyBuildSummary(jobId, { mongoClient: client, previewUrl });
} catch (e) {
consoleLogger.error('SnootyBuildCompleteError', e);
return {
statusCode: 500,
headers: defaultHeaders,
body: e,
};
} finally {
await client.close();
}

return {
statusCode: 200,
headers: defaultHeaders,
body: `Snooty build ${jobId} completed`,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"slackSecret": "SLACK_SECRET",
"slackAuthToken": "SLACK_TOKEN",
"slackViewOpenUrl": "https://slack.com/api/views.open",
"snootySecret": "SNOOTY_SECRET",
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
"dashboardUrl": "DASHBOARD_URL",
Expand Down
1 change: 1 addition & 0 deletions cdk-infra/static/api/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"slackSecret": "SLACK_SECRET",
"slackAuthToken": "SLACK_TOKEN",
"slackViewOpenUrl": "https://slack.com/api/views.open",
"snootySecret": "SNOOTY_SECRET",
"jobQueueCollection": "JOB_QUEUE_COL_NAME",
"entitlementCollection": "USER_ENTITLEMENT_COL_NAME",
"repoBranchesCollection": "REPO_BRANCHES_COL_NAME",
Expand Down
2 changes: 2 additions & 0 deletions cdk-infra/utils/ssm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const webhookSecureStrings = [
'/cdn/client/secret',
'/slack/webhook/secret',
'/slack/auth/token',
'/snooty/webhook/secret',
] as const;

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

export async function getWebhookSecureStrings(ssmPrefix: string): Promise<Record<string, string>> {
return getSecureStrings(ssmPrefix, webhookSecureStrings, webhookParamPathToEnvName, 'webhookParamPathToEnvName');
Expand Down
12 changes: 12 additions & 0 deletions serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ custom:
githubBotPW: ${ssm:/env/${self:provider.stage}/docs/worker_pool/github/bot/password}
slackSecret: ${ssm:/env/${self:provider.stage}/docs/worker_pool/slack/webhook/secret}
slackAuthToken: ${ssm:/env/${self:provider.stage}/docs/worker_pool/slack/auth/token}
snootySecret: ${ssm:/env/${self:provider.stage}/docs/worker_pool/snooty/webhook/secret}
JobsQueueName: autobuilder-jobs-queue-${self:provider.stage}
JobsDLQueueName: autobuilder-jobs-dlqueue-${self:provider.stage}
JobUpdatesQueueName: autobuilder-job-updates-queue-${self:provider.stage}
Expand Down Expand Up @@ -112,6 +113,7 @@ webhook-env-core: &webhook-env-core
REPO_BRANCHES_COL_NAME: ${self:custom.repoBranchesCollection}
SLACK_SECRET: ${self:custom.slackSecret}
SLACK_TOKEN: ${self:custom.slackAuthToken}
SNOOTY_SECRET: ${self:custom.snootySecret}
DASHBOARD_URL: ${self:custom.dashboardUrl.${self:provider.stage}}
NODE_CONFIG_DIR: './api/config'
TASK_DEFINITION_FAMILY: docs-worker-pool-${self:provider.stage}
Expand Down Expand Up @@ -261,6 +263,16 @@ functions:
environment:
<<: *webhook-env-core

v1SnootyBuildComplete:
handler: api/controllers/v1/jobs.SnootyBuildComplete
events:
- http:
path: /webhook/snooty/trigger/complete
method: POST
cors: true
environment:
<<: *webhook-env-core

Outputs:
JobsQueueURL:
Description: Jobs Queue Url
Expand Down
14 changes: 10 additions & 4 deletions src/repositories/jobRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ export class JobRepository extends BaseRepository {
this._queueConnector = new SQSConnector(logger, config);
}

async updateWithCompletionStatus(id: string, result: any): Promise<boolean> {
const query = { _id: id };
async updateWithCompletionStatus(
id: string | mongodb.ObjectId,
result: any,
shouldNotifySqs = true
): Promise<boolean> {
// Safely convert to object ID
const objectId = new mongodb.ObjectId(id);
const query = { _id: objectId };
const update = {
$set: {
status: 'completed',
Expand All @@ -35,8 +41,8 @@ export class JobRepository extends BaseRepository {
update,
`Mongo Timeout Error: Timed out while updating success status for jobId: ${id}`
);
if (bRet) {
await this.notify(id, c.get('jobUpdatesQueueUrl'), JobStatus.completed, 0);
if (bRet && shouldNotifySqs) {
await this.notify(objectId.toString(), c.get('jobUpdatesQueueUrl'), JobStatus.completed, 0);
}
return bRet;
}
Expand Down
Loading

0 comments on commit 5d395cf

Please sign in to comment.