Sending DB migration Alerts To Slack Using AWS Serverless stack and AWS CDK

#cdk  

#serverless  

#aws  

#lambda  

#api gateway  

Sun Oct 03 2021

As we were developing more features and scaling the product, our data guy was more confused with the changes on DB and wanted to catch up with the migrations that we were doing.

So I thought why not just let him know in Slack if there's a new DB migration every time that we release to production?

It should be easy, right? We're using Prisma as our DB driver so any changes on the DB are tracked with schema.prisma file and we just deploy to production when we merge a PR to master branch on GitHub. So I just need to know when a PR gets merged if there's a change in the schema.prisma file, if so, then I can call Slack API and notify our data guy that we're having a new migration.

Let's do this!

I'm a Serverless and AWS-CDK fan and one of the main reasons is doing something like this, takes only a couple of hours!

So I need to:

  • Set up a Slack App to provide me a webhook URL to send messages to a specific channel
  • Build an endpoint that accepts Post and set it on webhook settings in the Github repo.
  • Write a Lambda function, which checks for DB migration change and calls the Slack API

digram

Infrastructure as Code!

First thing first, let's start a CDK project:

mkdir pr-alert
cd pr-alert
cdk init app --language typescript

Ok, great! now I have everything ready to build a simple POST endpoint to use as a GitHub Webhook. I'll set the Webhook setting to Post to my endpoint every time we push. ("Just the push event.") The reason is, in push events, there's a list of files that has been changed and then there's a property that indicated if the push is PR merged and there's a branch field too, so I can check if that's master or not. (more info on the hook)

Checking pr-alert.ts file in my bin folder, CDK initiate a new stack.

const app = new cdk.App();
new PrAlertStack(app, 'PrAlertStack', {});

and I see my stack in lib/pr-alert-stack.ts file where I should code my infrastructure.

Cool, cool! but before that, let me write my function which would have my whole logic to receive a webhook payload, finding if PR has been merged to master and then send a Slack message. Let's create a file alert.js in a a new folder calling resources.

const main = async function (event, context) {
   console.log(JSON.stringify(event, null, 2));
}
module.exports = { main };

Awesome! so right now, every time I call this function, it should just print out the event in Cloudwatch for me. Later I'll write what I need...

Now, let's go back to my stack file and code my API endpoint, but before that, I need to install CDK packages for Lambda and API Gateway:

yarn add @aws-cdk/aws-apigateway @aws-cdk/aws-lambda

then let's jump into the stack

mport * as cdk from '@aws-cdk/core';

import * as lambda from '@aws-cdk/aws-lambda';
import * as apigateway from "@aws-cdk/aws-apigateway";


export class PRAlertStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // our function
    const handler = new lambda.Function(this, "alertHandler", {
      runtime: lambda.Runtime.NODEJS_12_X, 
      code: lambda.Code.fromAsset("resources"),
      handler: "alert.main",
      environment: {
        SLACK_CHANNEL_URL: "{GET_THIS_FROM_YOUR_SLACK_APP}",
        WEBHOOK_SECRET:"{GET_THIS_FROM_YOUR_GITHUB_REPO_WEBHOOKS_SETTING}"
      }
    });

    // our API
    const api = new apigateway.RestApi(this, "pr-alert-api", {
      restApiName: "PR Alert Service",
    });
    const postPRAlert = new apigateway.LambdaIntegration(handler, {
      requestTemplates: { "application/json": '{ "statusCode": "200" }' }
    });

    api.root.addMethod("POST", postPRAlert); 
  }
}

This is AWESOME! now we have our endpoint which is Post / in API Gateway, hooked with our lambda function, so every time we call this endpoint, we'll run the lambda function. As soon as I deploy this, it will spit out the endpoint URL in my console.

cdk build
cdk deploy

note: for making cdk deploy work, you need to set up your AWS credential

Setting up a Github Webhook

Having the endpoint, I browse over to Github and make a new webhook: Repository Setting > webhooks > add webhook. Paste the endpoint URL and choose a secret.

Send the alert to Slack from a Lambda function!

Let's go back to our function and write the logic. I break down the work into functions as I want to test them later and in general, I like it in this way:

  • Github's sending a signed secret over the call that needs to be validated to make sure it is coming from the right source.
const verifyGitHubSignature = (req = {}, secret = "") => {
  const sig = req.headers["X-Hub-Signature"];
  const hmac = crypto.createHmac("sha1", secret);
  const digest = Buffer.from(
    "sha1=" + hmac.update(JSON.stringify(req.body)).digest("hex"),
    "utf8"
  );
  const checksum = Buffer.from(sig, "utf8");
  console.log({ checksum, digest });
  console.log("timing", crypto.timingSafeEqual(digest, checksum));
  if (
    checksum.length !== digest.length
    // || !crypto.timingSafeEqual(digest, checksum)
  ) {
    return false;
  } else {
    return true;
  }
};
  • As part of the payload, there's commit prop which is an array of commits that a "push event" contains and inside each commit there's a list of files that have been changed in that commit.
const migrationCommit = (commits) => {
  const allModifiedFiles = commits.map((c) => c.modified);
  console.log({ allModifiedFiles: [].concat.apply([], allModifiedFiles) });
  if ([].concat.apply([], allModifiedFiles).includes("prisma/schema.prisma")) {
    return true;
  }
  return false;
};
  • Great! now I can just write the main body and use my functions:
const main = async function (event, context) {
  console.log(JSON.stringify(event, null, 2));
  const secret = event.headers["X-Hub-Signature"];
  if (!verifyGitHubSignature(event, githubSecret)) {
    return {
      statusCode: 403,
    };
  }
  try {
    var method = event.httpMethod;

    if (method === "POST") {
      if (event.path === "/") {
        const body = JSON.parse(event.body);
        const { ref, commits } = body;
        console.log({ ref, commits });
        if (ref.includes("master") && commits.length !== 0) {
          console.log("Pushed to Master");
          console.log("migrated?", migrationCommit(commits));
          if (migrationCommit(commits)) {
            // send message to the Slack
            await fetch(slackChannel, {
              method: "post",
              body: JSON.stringify({
                text: "<!here> DB Migration Alert: the commit that has been pushed to the master branch includes DB migration",
              }),
              headers: { "Content-Type": "application/json" },
            });
          }
        }
        return {
          statusCode: 200,
          headers: {},
          body: JSON.stringify("success"),
        };
      }
    }

    return {
      statusCode: 400,
    };
  } catch (error) {
    var body = error.stack || JSON.stringify(error, null, 2);
    return {
      statusCode: 400,
      headers: {},
      body: JSON.stringify({ error: body }),
    };
  }
};

By the way, I'm using 2 libraries that should be installed and packaged when I deploy to AWS. let's install them inside the resources package.

cd resources
npm init
yarn add crypto node-fetch

crypto helps me in decoding the Github signature and node-fetch enables me to call Slack API.

An exciting moment, let's deploy again:

yarn cdk deploy

Well, that's it, now every time we'd have a PR including DB migration, as soon as we merge it, we'll receive a message in Slack!

Find the whole project here.

© Copyright 2022 Farmin Farzin