Slack + Codedeploy = ❤️
Slack + Codedeploy = ❤️
We all know Slack, right? Have you ever thought about using it to deploy your code by just typing /deploy
? I did and this is what I designed and developed for a team that was sick and tired of dealing with the poorly designed AWS Codedeploy UI.
Context
Our stack lived on AWS and we had an Account per Environment, that means that Staging
and Production
lived on two completely separated and isolated accounts; every user had to login to what we called the Main
account and then assume the role of the environment they wanted to interact with. To have a better idea of what I'm talking about, here's what the process looked like from git push
to the actual deployment:
- A developer pushes the code to git
- CircleCI builds the code, packages everything in a
tar.gz
file and pushes it to an S3 bucket (on theMain
account) using this folder structure:prefix/productname/branchname/commitid.tar.gz
- The developer goes to Github and copies the Commit ID
- logins to the
Main
AWS account - Assumes the
Staging
role - Opens the Codedeploy console and selects the App name
- Enters the S3 bucket URL manually, pastes the Commit ID and adds the
.tar.gz
at the end of the string - Clicks the deploy button hoping he/she didn't commit any mistake composing the S3 bucket URL (he/she most likely did!)
My idea was to replace anything after step #2 with a simple Slack's Slash Command.
Slash command
When a slash command is invoked, Slack sends an HTTP POST to a Request URL you specify. This request contains a data payload with a Content-type
header set as application/x-www-form-urlencoded
. The body of the request looks like this:
token=gIkuvaNzQIHg97ATvDxqgjtO
team_id=T0001
team_domain=example
enterprise_id=E0001
enterprise_name=Globular%20Construct%20Inc
channel_id=C2147483705
channel_name=test
user_id=U2147483697
user_name=Steve
command=/weather
text=94070
response_url=https://hooks.slack.com/commands/1234/5678
trigger_id=13345224609.738474920.8088930838d88f008e0
Source: Slack Documentation
Before starting coding, I created the following slash commands through the Slack dashboard and saved their Tokens in a safe place:
/artifacts
/deploy
The App
Now that I have the slash commands ready, I need some endpoints to hit and here's where the fun part begins.
I developed an Express NodeJS app called node-slack-deployer (formerly called codedeploy-helper
) that does the job. The server comes with two endpoints for those slash commands (/slack/get-deployments
and /slack/deploy
) and one used for health checks (/
, which returns an OK
message).
But how do we know what we need to look for or deploy based on the body of the POST
? It's pretty simple: anything added after the /command
itself is passed through as text
of the body in a string
format, we just have to split it out.
Arguments for deployment command:
if (req.body.text.split(' ').length === 2) {
// Store product name and branch in product object
res.locals.product = {
service: req.body.text.split(' ')[0].toLowerCase(),
env: req.body.text.split(' ')[1].toLowerCase(),
description: 'Deploying via Slack'
};
} else {
// Store body information in product object
res.locals.product = {
service: req.body.text.split(' ')[0].toLowerCase(),
env: req.body.text.split(' ')[1].toLowerCase(),
branch: req.body.text.split(' ')[2],
hash: req.body.text.split(' ')[3],
description: 'Deploying via Slack'
};
}
Arguments for artifacts command:
//Params length must have at least 2 elements
if (params.length < 2) {
res.json(
slackHandler.payloadToSlack(
'failure',
'Code Artifacts',
400,
'Invalid number of arguments\nUsage: `/artifact [service-name] [branch]`'
)
);
return;
}
res.locals.bucketPath = awsHandler.generateBucketPath({
service: params[0],
branch: params[1],
hash: params[2]
});
And, for security reasons, we also check if the Token sent by Slack matches the one we pass to the application using environment variables:
//Check if the deployment token passed matches the env vars one
exports.checkDeploymentToken = token => {
return token === process.env.SLACK_DEPLOYMENT_TOKEN;
};
That looks good, now what? Oh yeah, let's see what the single endpoints can do for me!
Artifacts command
The /artifacts
command returns to the user a list of the last 5 built artifacts of a given product and its branch:
/artifacts api master
Since we said that the S3 bucket lives on the Main
account, we don't need any STS
magic here, only query the S3 bucket!
exports.getDeployments = async (req, res) => {
const data = await awsHandler.s3GetObjects(res.locals.bucketPath);
let max = 5;
let arr = [];
// Check if data is empty
if (data.length < 1) {
res.json(
slackHandler.payloadToSlack(
'warning',
'Code Artifacts',
404,
'Elements not found'
)
);
return;
}
// Check length of the object and returns max 5 items
if (data.length < 5) {
max = data.length;
}
for (let i = 0; i < max; i++) {
arr.push(utilsHandler.getHash(data[i].Key));
}
res.json(
slackHandler.payloadToSlack(
'success',
'Code Artifacts',
'Artifacts List:',
slackHandler.generateHashesText(arr, res.locals.params[0])
)
);
};
You can notice that data
is getting the output of the awsHandler.s3GetObjects
method, which is nothing but this AWS API call:
exports.s3GetObjects = async bucketPath => {
const listObjects = promisify(S3.listObjects, S3);
const data = await listObjects({
Bucket: this.bucketParams.Bucket,
Prefix: bucketPath
});
//Order data from newest to oldest
if (data.Contents.length > 1) {
data.Contents.sort((a, b) => {
return new Date(b.LastModified) - new Date(a.LastModified);
});
}
return data.Contents;
};
And, one last thing, slackHandler.generateHashesText
looks like this:
//Generate a formatted string of hashes with ordered list
exports.generateHashesText = (data, service) => {
let string = '';
let max = 5;
if (data.length < 5) {
max = data.length;
}
for (let i = 0; i < max; i++) {
string += `${i + 1}. <https://github.com/${
process.env.GITHUB_USER
}/${service}/commit/${data[i]}|${data[i]}>`;
if (i < max - 1) {
string += '\n';
}
}
return string;
};
Sorted: our app is returning to slack a payload including, in its body, a list of the last 5 artifacts which link the user to their Github Commit URL, in case you want to make sure that you're looking at the right commit!
Deploy Command
The /deploy
command triggers a Codedeploy deployment and it accepts 4 arguments:
- product name
- environment
- branch
- commit ID
/deploy api production master 5d0e80e4152eef31f64c7b8026277894a541f166
Here things are a bit trickier as we need to:
- Check if the command was invoked from the right slack channel;
- Check if the artifact exists;
- Assume an AWS role; and
- Finally deploy the code.
Let's start with point #1 and #2 then:
// ...
if (!this.isDeploymentChannel(req.body.channel_id)) {
console.log('Invalid Slack channel');
res.json(
slackHandler.payloadToSlack(
'failure',
'Slack Deploy',
401,
"Oooops! It looks like you can't run this command from this channel!"
)
);
return;
}
// ...
const isS3Element = await awsHandler.s3GetSingleObject(bucketPath);
if (!isS3Element) {
res.json(
slackHandler.payloadToSlack(
'warning',
'Slack Deploy',
404,
'Element not found'
)
);
return;
}
// ...
Where isDeploymentChannel
simply is:
//Check Slack Channel ID
exports.isDeploymentChannel = channelId => {
return channelId === process.env.SLACK_DEPLOYMENTS_CHANNEL_ID;
};
Then we're ready to assume the account role:
exports.assumeRole = async (req, res, next) => {
params.RoleSessionName = res.locals.product.env;
params.RoleArn = awsHandler.awsRoles[res.locals.product.env];
// If the environment received doesn't exist, throw an error
if (!params.RoleArn) {
res.json(
slackHandler.payloadToSlack(
'failure',
'Slack Deploy',
400,
`${params.RoleSessionName} is not a valid environment`
)
);
return;
}
res.locals.awsRole = await awsHandler.stsAssumeRole(params);
next();
};
Where stsAssumeRole
is:
exports.stsAssumeRole = async params => {
const assumeRole = promisify(STS.assumeRole, STS);
return await assumeRole(params);
};
Last but not least, let's deploy the code - these are the methods called by the deployment controller:
// Initialise codedeploy object
exports.codedeployInit = params => {
return new AWS.CodeDeploy({
accessKeyId: params.Credentials.AccessKeyId,
secretAccessKey: params.Credentials.SecretAccessKey,
sessionToken: params.Credentials.SessionToken
});
};
// Deploy using codedeploy
exports.codedeployDeploy = async (awsRole, product, bucketPath) => {
const CodeDeploy = this.codedeployInit(awsRole);
const params = {
applicationName: product.service,
deploymentGroupName: product.service,
description: product.description,
revision: {
revisionType: 'S3',
s3Location: {
bucket: this.bucketParams.Bucket,
bundleType: process.env.AWS_CODEDEPLOY_BUNDLE_TYPE,
key: bucketPath
}
}
};
const codedeploy = promisify(CodeDeploy.createDeployment, CodeDeploy);
return await codedeploy(params);
};
Done: the user on slack will receive a nice message saying that Codedeploy started the deployment and providing the deployment ID.
Observations
This was meant to be a quick run-through of a simple Express application pasting random snippets here and there, just to give more context. It was designed and written according to our team's needs so I appreciate it might be hard to apply it to any team/company that deploys its code using Codedeploy. However, bear in mind that its development it's not over. I recently open sourced it and I am going to spend some time improving it and adding/removing some features that can or can not be relevant to any other use case.
Contributions
Any contribution is greatly appreciated and I'd be more than happy to review your PR's or discuss improvements in the Github Issues section. The project, once again, can be found here.