We have a running joke at Stackery regarding our tiny little gong that's used to mark the occasion when we get a new customer.
And while I'm all about the sales team celebrating their successes (albeit with a far-too-small gong), I felt like the dev team needed its own way to commemorate major product releases and iterations.
Then I saw that Serverless Framework is doing its No Server November challenge, and I thought, what a perfect way to show off our multiple framework support while iterating on our Github Webhooks Tutorial to support Serverless Framework projects!
Stackery makes it easy to import an existing stack or create new a stack based on an existing template. And, conveniently, I had already build a GitHub webhook listener just the week before as part of the webhook tutorial. However, the rules of the competition specifically state that "to qualify, the entry must use the Serverless Framework and a serverless backend" - and I was curious to see the differences when building out my app using that framework as compared to our default (AWS SAM).
So the first thing I did was create an empty Serverless Framework template I could use to build my app on. This was quite simple - I just created a serverless.yml
file in a new directory and added the following:
service: serverless-gong
frameworkVersion: ">=1.4.0 <2.0.0"
provider:
name: aws
runtime: nodejs8.10
I initialized a new git repository, and added, committed and pushed the serverless.yml
file to it.
Now it was time to import my new Serverless Framework boilerplate into Stackery so I could start adding resources. In the Stackery App, I navigated to my Stacks
page, and clicked the Create New Stack
button in the upper right, filling it out like so:
Then, in the Stackery Dashboard, I created an API Gateway resource with a POST
route with a /webhook
path and a Function resource named handleGong
, and connected them with a wire. All of this, including saving and using environment variables for your GitHub secret, is documented in the webhook tutorial, so I won't go through it again. In the end, I had a setup very similar to that found at the end of that tutorial, with the exception of having a serverless.yml
file rather than a template.yml
for the configuration, and having everything in one directory (which was fine for a small project like this, but not ideal in the long run).
With the added resources, my serverless configuration now looked like this:
service: serverless-gong
frameworkVersion: '>=1.4.0 <2.0.0'
provider:
name: aws
runtime: nodejs8.10
functions:
handleGong:
handler: handler.gongHandler
description:
Fn::Sub:
- 'Stackery Stack #{StackeryStackTagName} Environment #{StackeryEnvironmentTagName} Function #{ResourceName}'
- ResourceName: handleGong
events:
- http:
path: /webhook
method: POST
environment:
GITHUB_WEBHOOK_SECRET:
Ref: StackeryEnvConfiggithubSecretAsString
SLACK_WEBHOOK_URL:
Ref: StackeryEnvConfigslackWebhookURLAsString
resources:
Parameters:
StackeryStackTagName:
Type: String
Description: Stack Name (injected by Stackery at deployment time)
Default: serverless-gong
StackeryEnvironmentTagName:
Type: String
Description: Environment Name (injected by Stackery at deployment time)
Default: dev
StackeryEnvConfiggithubSecretAsString:
Type: AWS::SSM::Parameter::Value<String>
Default: /Stackery/Environments/<StackeryEnvId>/Config/githubSecret
StackeryEnvConfigslackWebhookURLAsString:
Type: AWS::SSM::Parameter::Value<String>
Default: /Stackery/Environments/<StackeryEnvId>/Config/slackWebhookURL
Metadata:
StackeryEnvConfigParameters:
StackeryEnvConfiggithubSecretAsString: githubSecret
StackeryEnvConfigslackWebhookURLAsString: slackWebhookURL
plugins:
- serverless-cf-vars
And my Dashboard looked like so:
Since I had already written a webhook starter function that at the moment logged to the console, it didn't feel necessary to reinvent the wheel, so I committed in Stackery, then git pull
ed my code to see the updates, and created a handler.js
file in the same directory as the serverless.yml
. In it, I pasted the code from my previous webhook function - this was going to be my starting point:
const crypto = require('crypto');
function signRequestBody(key, body) {
return `sha1=${crypto.createHmac('sha1', key).update(body, 'utf-8').digest('hex')}`;
}
// The webhook handler function
exports.gongHandler = async event => {
// get the GitHub secret from the environment variables
const token = process.env.GITHUB_WEBHOOK_SECRET;
const calculatedSig = signRequestBody(token, event.body);
let errMsg;
// get the remaining variables from the GitHub event
const headers = event.headers;
const sig = headers['X-Hub-Signature'];
const githubEvent = headers['X-GitHub-Event'];
const body = JSON.parse(event.body);
// this determines username for a push event, but lists the repo owner for other events
const username = body.pusher ? body.pusher.name : body.repository.owner.login;
const message = body.pusher ? `${username} pushed this awesomeness/atrocity through (delete as necessary)` : `The repo owner is ${username}.`
// get repo variables
const { repository } = body;
const repo = repository.full_name;
const url = repository.url;
// check that a GitHub webhook secret variable exists, if not, return an error
if (typeof token !== 'string') {
errMsg = 'Must provide a \'GITHUB_WEBHOOK_SECRET\' env variable';
return {
statusCode: 401,
headers: { 'Content-Type': 'text/plain' },
body: errMsg,
};
}
// check validity of GitHub token
if (sig !== calculatedSig) {
errMsg = 'X-Hub-Signature incorrect. Github webhook token doesn\'t match';
return {
statusCode: 401,
headers: { 'Content-Type': 'text/plain' },
body: errMsg,
};
}
// print some messages to the CloudWatch console
console.log('---------------------------------');
console.log(`\nGithub-Event: "${githubEvent}" on this repo: "${repo}" at the url: ${url}.\n ${message}`);
console.log('Contents of event.body below:');
console.log(event.body);
console.log('---------------------------------');
// return a 200 response if the GitHub tokens match
const response = {
statusCode: 200,
body: JSON.stringify({
input: event,
}),
};
return response;
};
At this point, I prepared and did the initial deploy of my stack in order to get the Rest API endpoint for the GitHub webhook I needed to set up. Again, the webhook tutorial runs through the deployment and webhook setup process step by step, so I won't repeat it here.
Using the Rest API /webhook
url, I created a webhook in our Stackery CLI repo that was now listening for events, and I confirmed in my CloudWatch logs that it was indeed working.
The next step was to modify the function so it "gonged" our Slack channel when our Stackery CLI repo was updated with a new release. To do that, I had to create a custom Slack app for our channel and set up its incoming webhooks. Luckily, Slack makes that really easy to do, and I just followed the step-by-step instructions in Slack's webhook API guide to get going.
I set up a #gong-test
channel in our Slack for testing so as to not annoy my co-workers with incessant gonging, and copied the URL Slack provided (it should look something like https://hooks.slack.com/services/T00000000/B00000000/12345abcde
).
Before editing the Lambda function itself, I needed a way for it to reference that URL as well as my GitHub secret without hard-coding it in my function that would then be committed to my public repo (because that is a Very Bad Way to handle secrets). This is where Stackery Environments come in handy.
I saved my GitHub secret and Slack URL in my environment config like so:
Then referenced it in my function:
And will add it to my function code in the next step, using process.env.GITHUB_WEBHOOK_SECRET
and process.env.SLACK_WEBHOOK_URL
as the variables.
Since we're automating our gong, what's more appropriate than an automated gong? After a somewhat frustrating YouTube search, I found this specimen:
A auto-gong for our automated app? Perfect! Now let's use our function to send that gong to our Slack channel.
Here's the code for the final gongHandler
function in handler.js
:
const crypto = require('crypto');
const Slack = require('slack-node');
// validate your payload from GitHub
function signRequestBody(key, body) {
return `sha1=${crypto.createHmac('sha1', key).update(body, 'utf-8').digest('hex')}`;
}
// webhook handler function
exports.gongHandler = async event => {
// get the GitHub secret from the environment variables
const token = process.env.GITHUB_WEBHOOK_SECRET;
const calculatedSig = signRequestBody(token, event.body);
let errMsg;
// get the remaining variables from the GitHub event
const headers = event.headers;
const sig = headers['X-Hub-Signature'];
const githubEvent = headers['X-GitHub-Event'];
const body = JSON.parse(event.body);
// get repo variables
const { repository, release } = body;
const repo = repository.full_name;
const url = repository.url;
// set variables for a release event
let releaseVersion, releaseUrl, author = null;
if (githubEvent === 'release') {
releaseVersion = release.tag_name;
releaseUrl = release.html_url;
author = release.author.login;
}
// check that a GitHub webhook secret variable exists, if not, return an error
if (typeof token !== 'string') {
errMsg = 'Must provide a \'GITHUB_WEBHOOK_SECRET\' env variable';
return {
statusCode: 401,
headers: { 'Content-Type': 'text/plain' },
body: errMsg,
};
}
// check validity of GitHub token
if (sig !== calculatedSig) {
errMsg = 'X-Hub-Signature incorrect. Github webhook token doesn\'t match';
return {
statusCode: 401,
headers: { 'Content-Type': 'text/plain' },
body: errMsg,
};
}
// if the event is a 'release' event, gong the Slack channel!
const webhookUri = process.env.SLACK_WEBHOOK_URL;
const slack = new Slack();
slack.setWebhook(webhookUri);
// send slack message
if (githubEvent === 'release') {
slack.webhook({
channel: "#gong-test", // your desired channel here
username: "gongbot",
icon_emoji: ":gong:", // because Slack is for emojis
text: `It's time to celebrate! ${author} pushed release version ${releaseVersion}. See it here: ${releaseUrl}!\n:gong: https://youtu.be/8nBOF5sJrSE?t=11` // your message
}, function(err, response) {
console.log(response);
if (err) {
console.log('Something went wrong');
console.log(err);
}
});
}
// (optional) print some messages to the CloudWatch console (for testing)
console.log('---------------------------------');
console.log(`\nGithub-Event: "${githubEvent}" on this repo: "${repo}" at the url: ${url}.`);
console.log(event.body);
console.log('---------------------------------');
// return a 200 response if the GitHub tokens match
const response = {
statusCode: 200,
body: JSON.stringify({
input: event,
}),
};
return response;
};
Finally, I needed to add a package.json
file so that I could use dependencies. When creating a function using an AWS SAM template, Stackery would do this for your automatically, but in this case I had to create the file and add the following myself:
{
"private": true,
"dependencies": {
"aws-sdk": "~2",
"slack-node": "0.1.8"
}
}
I added, committed and pushed the new code, re-deployed my Serverless Framework stack, then added another GitHub webhook to a test repo. I created a GitHub release in my test repo, and waited in anticipation.
Milliseconds later, I hear the familiar click-click-click of Slack...
Pretty awesome, if I do say so myself. 🔔
A few notes:
releaseVersion, releaseUrl, author
.console.log()
in your serverless function, the results can be seen in the AWS CloudWatch logs. Stackery provides a convenient direct link for each function.If you'd like to make your own serverless gong, all of the configuration code is available in my Serverless Gong GitHub repository. Just create a new stack your Stackery account (you can sign up for a free trial if you don't have one yet), choose Create New Repo
as the Repo Source, and select Specify Remote Source
to paste in the link to my repo as a template.
Add your GitHub and Slack environment parameters, deploy your stack, and sit back and wait for your Slack to gong!