├── .gitignore ├── .eslintrc ├── package.json ├── app.template.yaml ├── LICENSE.txt ├── README.md └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | app.yaml 2 | npm-debug.log 3 | node_modules 4 | .jenkins_api_token 5 | .slack_webhook_url 6 | .slack_bot_token 7 | .slack_verification_token 8 | email_role_checker_secret.json 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [2, 4], 4 | "quotes": [2, "double"], 5 | "linebreak-style": [2, "unix"], 6 | "semi": [2, "always"], 7 | "no-console": 0, 8 | "comma-dangle": 0, 9 | "no-unused-vars": [2, { 10 | "args": "after-used", 11 | "argsIgnorePattern": "^_" 12 | }] 13 | }, 14 | "env": { 15 | "es6": true, 16 | "node": true 17 | }, 18 | "ecmaFeatures": { 19 | "modules": true, 20 | }, 21 | "parserOptions": { 22 | "sourceType": "module" 23 | }, 24 | "extends": "eslint:recommended" 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "khan-sun", 3 | "version": "0.1.0", 4 | "description": "Khan's outgoing webhooks for deployment", 5 | "main": "server.js", 6 | "repository": "https://github.com/Khan/slack-deploy-hooks", 7 | "scripts": { 8 | "start": "node server.js", 9 | "monitor": "nodemon --exec npm run start -- server.js", 10 | "predeploy": "build-scripts/predeploy", 11 | "lint": "eslint --fix *.js", 12 | "deploy": "npm run predeploy && gcloud app deploy app.yaml --project khan-sun --promote --stop-previous-version" 13 | }, 14 | "dependencies": { 15 | "body-parser": "^1.13", 16 | "express": "^4.13", 17 | "googleapis": "^18.0.0", 18 | "q": "^1.4", 19 | "request": "^2.64.0" 20 | }, 21 | "devDependencies": { 22 | "eslint": "^1.4", 23 | "nodemon": "^1.11.0" 24 | }, 25 | "author": "Benjamin Pollack ", 26 | "license": "MIT" 27 | } 28 | -------------------------------------------------------------------------------- /app.template.yaml: -------------------------------------------------------------------------------- 1 | # Template file, actual app.yaml is built from this via build-scripts/predeploy 2 | runtime: nodejs8 3 | threadsafe: true # send more than one concurrent request at a time 4 | 5 | handlers: 6 | - url: /.* 7 | script: server.js 8 | 9 | env_variables: 10 | SLACK_BOT_TOKEN: XXX-REPLACE-WITH-SLACK-BOT-TOKEN-XXX 11 | SLACK_VERIFICATION_TOKEN: XXX-REPLACE-WITH-SLACK-VERIFICATION-TOKEN-XXX 12 | JENKINS_API_TOKEN: XXX-REPLACE-WITH-JENKINS-API-TOKEN-XXX 13 | DEPLOY_ROOM_ID: "C096UP7D0" 14 | 15 | # Ensures any .log files as well as build scripts and the app.yaml template are 16 | # not uploaded (the other entries are the default values for skip_files). 17 | # NOTE(benkraft): We *do* upload node_modules! App Engine Standard requires it. 18 | skip_files: 19 | - ^(.*/)?#.*#$ 20 | - ^(.*/)?.*~$ 21 | - ^(.*/)?.*\.py[co]$ 22 | - ^(.*/)?.*/RCS/.*$ 23 | - ^(.*/)?\..*$ 24 | - ^(.*/)?.*\.log$ 25 | - ^app.template.yaml$ 26 | - ^build-scripts$ 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Khan Academy. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slack Deploy Hooks 2 | > The Khan deployment service for Slack 3 | 4 | Slack Deploy Hooks replace the old Sun Wukong Hubot plugin and adds a 5 | lot of functionality. 6 | 7 | **This repository is no longer used, and retained only for posterity. See 8 | Khan/buildmaster for the new version.** 9 | 10 | ## Settings 11 | 12 | The environment variable `DEPLOY_ROOM_ID` sets the room in which the hooks will 13 | listen. It should be a Slack channel ID, which you can get from the [Slack API 14 | test client][api-test]. 15 | 16 | [api-test]: https://api.slack.com/methods/channels.list/test 17 | 18 | For a deploy, you will also need several secrets. The deploy script will give 19 | instructions. 20 | 21 | ## Making a deploy 22 | 23 | ### Prerequisites 24 | You will need to install Node and npm through the usual channels (0.12.7 25 | or higher, please), and will also need a working `gcloud` tool for deploying. 26 | 27 | Node can be install via the usual `brew install node`. 28 | 29 | Set up the gcloud tool as [per instructions][gcloud-install]. (Note 30 | that on Mac you can use `brew cask install google-cloud-sdk` instead of their 31 | installer, if you prefer.) 32 | 33 | [gcloud-install]: https://cloud.google.com/container-engine/docs/before-you-begin#install_the_gcloud_command_line_interface 34 | 35 | ### Development 36 | 37 | First, you can optionally provision secrets locally. Instructions will be 38 | given on your first deploy. 39 | 40 | The Slack deploy hooks are a vanilla Node application, so a simple 41 | 42 | $ npm install 43 | $ env SUN_DEBUG=1 SLACK_BOT_TOKEN=`cat .slack_bot_token` npm run monitor 44 | 45 | Will give you a fully set-up local copy of khan-sun. You can then easily test 46 | it simply by using a tool like httpie and submitting Slack outgoing webhooks 47 | and observing the result. For example, to test the sun: ping command: 48 | 49 | curl -d 'team_id=T0001&team_domain=example&channel_id=C090KRE5P&channel_name=bot-testing&user_id=U09M5G8G6&user_name=csilvers&text=sun: ping&trigger_word=sun:' localhost:8080 50 | 51 | Should give you the response like `{}`, indicating success, from the HTTP 52 | command, and print out the response text in the terminal running Sun. (If you 53 | do not see any output, you probably forgot to set the `SUN_DEBUG` flag specified 54 | above.) 55 | 56 | ### Build and Deploy 57 | 58 | All you have to do is run `npm run deploy` and follow directions. 59 | 60 | You will need some secrets installed. The deploy script will complain if it 61 | can't find them, letting you know the details of which secrets are expected in 62 | which files. 63 | 64 | You also need to have loggged in with `gcloud` and must have permissions in the 65 | `khan-sun` project. 66 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Description: 3 | * Send commands to jenkins. 4 | * 5 | * Dependencies: 6 | * None 7 | * 8 | * Configuration: 9 | * The JENKINS_API_TOKEN environment variables must be set. 10 | * The SUN_DEBUG flag can be set if you want to debug this 11 | * module without risking talking to Jenkins. 12 | * The SLACK_BOT_TOKEN variable should be set to the Slack bot user's token, 13 | * which will be used to send messages and make API calls. 14 | * The SLACK_VERIFICATION_TOKEN variable should be set to the verification 15 | * token Slack uses for this web service. (For testing, as long you keep 16 | * it blank, it's fine.) 17 | * 18 | * Author: 19 | * bmp and csilvers 20 | * 21 | * NOTE(benkraft): App Engine Standard requires that this file (or more 22 | * generally the app entrypoint) be server.js, for now. 23 | * TODO(csilvers): split this up into several files. 24 | */ 25 | 26 | const Q = require("q"); // TODO(benkraft): replace with native promises 27 | const bodyParser = require("body-parser"); 28 | const express = require("express"); 29 | const request = require("request"); 30 | const googleapis = require("googleapis"); 31 | 32 | const googleKey = require("./email_role_checker_secret.json"); 33 | 34 | const help_text = `*Commands* 35 | - \`sun: help\` - show the help text 36 | - \`sun: queue [username]\` - add someone to the deploy queue (user is "me" or of the form \`user1 + user2 (optional note)\`) 37 | - \`sun: up next\` - move the person at the front of the queue to deploying, and ping them 38 | - \`sun: remove [username]\` - remove someone from the deploy queue (user is "me" or a username) 39 | - \`sun: test [+ ...]\` - run tests on a particular branch, independent of a deploy 40 | - \`sun: delete znd \` - ask Jenkins to delete the given znd 41 | - \`sun: prompt znd cleanup\` - check in with znd owners about cleaning up their znd 42 | - \`sun: history\` - print the changelogs for the 5 most recent successful deploys 43 | - \`sun: deploy [+ ...] [, cc @ [+ @ ...]]\` - deploy a particular branch (or merge multiple branches) to production, optionally CC another user(s) 44 | - \`sun: set default\` - after a deploy succeeds, sets the deploy as default 45 | - \`sun: abort\` - abort a deploy (at any point during the process) 46 | - \`sun: finish\` - do the last step in deploy, to merge with master and let the next person deploy 47 | - \`sun: emergency rollback\` - roll back the production site outside of the deploy process 48 | - \`sun: cc @ [+ ...]\` - CC user(s) so they get notified during the current deploy 49 | `; 50 | 51 | const emoji_help_text = `:speech_balloon: 52 | :sun::question: help 53 | :sun::heavy_plus_sign: queue 54 | :sun::fast_forward: next 55 | :sun::x: remove 56 | :sun::test-tube: test 57 | :sun::amphora: history 58 | :sun::treeeee: deploy 59 | :sun::rocket: set default 60 | :sun::skull: abort 61 | :sun::party_dino: finish 62 | :sun::scream: emergency rollback 63 | `; 64 | 65 | // The room to listen to deployment commands in. For safety reasons, sun 66 | // will only listen in this room by default. This should be a slack 67 | // channel ID, because the API we're using to send doesn't support linking to a 68 | // room by name. You can get the list of our channels, including their IDs, at 69 | // https://api.slack.com/methods/channels.list/test; we could probably call 70 | // this dynamically, but for now, hard-coding it is easier. The default is 71 | // #bot-testing. 72 | // TODO(benkraft): if Slack ever fixes this issue, switch to using the room 73 | // name for readability. 74 | const DEPLOYMENT_ROOM_ID = process.env.DEPLOY_ROOM_ID || "C090KRE5P"; 75 | 76 | // Whether to run in DEBUG mode. In DEBUG mode, sun will not 77 | // actually post commands to Jenkins, nor will it only honor Jenkins 78 | // state commands that come from the actual Jenkins, allowing for 79 | // easier debugging 80 | const DEBUG = !!process.env.SUN_DEBUG; 81 | 82 | // deployer | [deployer1, deployer2] | optional extra message 83 | const TOPIC_REGEX = /^([^|]*) ?\| ?\[([^\]]*)\](.*)$/; 84 | // person1 + person2 + person3 (optional notes on deploy) 85 | const DEPLOYER_REGEX = /^([^(]*)(?:\((.*)\))?$/; 86 | // Any number of hyphens, en dashes, and em dashes. 87 | const NO_DEPLOYER_REGEX = /^[-–—]*$/; 88 | 89 | // Internal field separator 90 | // TODO(drosile): Use the field separator to split out commands and arguments 91 | // so we can have simpler, more legible regular expressions. Alternatively, 92 | // we could look into using an option-parsing library 93 | const FIELD_SEP = ','; 94 | 95 | // CSRF token used to submit Jenkins POST requests - in Jenkins-land, this is 96 | // referred to as a "crumb". This value is retrieved when the first Jenkins 97 | // POST is made, and stored throughout the lifetime of this instance since 98 | // Jenkins CSRF tokens apparently do not expire. (But if we get a CSRF error, 99 | // we try refetching it, just in case; they may expire e.g. when Jenkins gets 100 | // restarted.) 101 | let JENKINS_CSRF_TOKEN = null; 102 | 103 | // User list for the bot to CC on deploy messages. This gets reset by 104 | // handleSafeDeploy, and can be added to as part of initiating the deploy 105 | // or with the `cc` command 106 | // TODO(drosile): Figure out a way to avoid the statefulness implied by this, 107 | // or at least to minimize its effects. 108 | // TODO(drosile): Apply the CC list only to specific actions. Currently it will 109 | // CC the list for any action, even if it is unrelated to the current deploy. 110 | let CC_USERS = []; 111 | 112 | // Email addresses of users authorized to deploy. Set by parseIAMPolicy(). 113 | let VALID_DEPLOYER_EMAILS = new Set(); 114 | // Maps slack user ids to email addresses. 115 | const USER_EMAILS = new Map(); 116 | 117 | //-------------------------------------------------------------------------- 118 | // Utility code 119 | //-------------------------------------------------------------------------- 120 | 121 | /** 122 | * An error that will be reported back to the user. 123 | * 124 | * We note if it's a CSRF error, since callers may wish to handle that rather 125 | * than raising. 126 | */ 127 | class SunError { 128 | constructor(msg, isCSRFError) { 129 | this.message = msg; 130 | this.isCSRFError = isCSRFError; 131 | this.name = "SunError"; 132 | } 133 | } 134 | 135 | 136 | /** 137 | * Generates a Slack message blob, without sending it, with everything 138 | * prepopulated. Useful for when you need to return a message to slack through 139 | * some mechanism other than replyAsSun (e.g., as a response to an outgoing 140 | * webhook). 141 | * 142 | * @param msg the *incoming* hook data from Slack, as handled in the main 143 | * route method 144 | * @param reply The text you want embedded in the message 145 | * @param omitCurrentUser If set do not ping the current user (and cc's) 146 | * as part ofaa the message 147 | */ 148 | function sunMessage(msg, reply, omitCurrentUser) { 149 | let message_text = ''; 150 | if (!omitCurrentUser) { 151 | message_text += `@${msg.user} `; 152 | if (CC_USERS.length != 0) { 153 | message_text += `(cc ${CC_USERS.join(', ')}) `; 154 | } 155 | } 156 | message_text += reply; 157 | return { 158 | username: "Sun Wukong", 159 | icon_emoji: ":monkey_face:", 160 | channel: msg.channel, 161 | text: message_text, 162 | link_names: true 163 | }; 164 | } 165 | 166 | 167 | /** 168 | * Reply as sun, immediately sending a response 169 | * @param msg the *incoming* message hook data from Slack 170 | * @param reply the text you want to send as the reply 171 | */ 172 | function replyAsSun(msg, reply, omitCurrentUser) { 173 | if (DEBUG) { 174 | console.log(`Sending: "${reply}"`); 175 | } 176 | // TODO(benkraft): more usefully handle any errors we get from this. 177 | // Except it's not clear what to do if posting to slack fails. 178 | slackAPI("chat.postMessage", sunMessage(msg, reply, omitCurrentUser)) 179 | .catch(console.error); 180 | } 181 | 182 | 183 | /** 184 | * Try to write a useful log for various types of HTTP errors. 185 | * 186 | * err should be the exception or an HTTP response. body should be the decoded 187 | * response body, if there is one. 188 | * 189 | * This only logs the error; after calling it you should throw a SunError with 190 | * a user-friendly message. 191 | */ 192 | function logHttpError(err, body) { 193 | console.error("HTTP Error"); 194 | console.error(` Error: ${JSON.stringify(err)}`); 195 | if (err.stack) { 196 | console.error(` Stack: ${err.stack}`); 197 | } 198 | if (err.statusCode) { 199 | console.error(` Status: ${err.statusCode}`); 200 | } 201 | if (err.headers) { 202 | const headers = JSON.stringify(err.headers); 203 | console.error(` Headers: ${headers}`); 204 | } 205 | if (body) { 206 | console.error(` Body: ${body}`); 207 | } 208 | } 209 | 210 | 211 | /** 212 | * Report an error back to the user. 213 | * 214 | * If it's a SunError, or otherwise has a `message` attribute, just use that; 215 | * otherwise send a general failure message. If the thrower can log anything 216 | * about the error, they should, since they have more information about what's 217 | * going on and why. Either way, we'll try our best to log it too. 218 | */ 219 | function onError(msg, err) { 220 | console.error("ERROR"); 221 | console.error(` Error: ${JSON.stringify(err)}`); 222 | if (err.stack) { 223 | console.error(` Stack: ${err.stack}`); 224 | } 225 | let errorMessage; 226 | if (err.message) { 227 | errorMessage = err.message; 228 | } else { 229 | errorMessage = ("Something went wrong and I won't know what to " + 230 | "do! Try doing things yourself, or check my " + 231 | "logs."); 232 | } 233 | // The error message may come after another message. Wait a second to 234 | // encourage slack to put the messages in the right order. 235 | setTimeout(() => replyAsSun(msg, errorMessage), 1000); 236 | } 237 | 238 | /** 239 | * Get a promise for the [response, body] of a request-style options object 240 | * 241 | * A URL may be passed as the options object in the case of a GET request. 242 | * On error, rejects with the error, and logs it, but does not report back to 243 | * the user. 244 | */ 245 | function requestQ(options) { 246 | const deferred = Q.defer(); 247 | request(options, (err, resp, body) => { 248 | if (err) { 249 | logHttpError(err, body); 250 | deferred.reject(err); 251 | } else { 252 | deferred.resolve([resp, body]); 253 | } 254 | }); 255 | return deferred.promise; 256 | } 257 | 258 | /** 259 | * Get a promise for the body of a 200 response 260 | * 261 | * On a non-200 response, rejects with the response and logs the error. 262 | * Otherwise like requestQ. 263 | */ 264 | function request200(options) { 265 | return requestQ(options).spread((resp, body) => { 266 | if (resp.statusCode > 299) { 267 | logHttpError(resp, body); 268 | // Q will catch this and reject the promise 269 | throw resp; 270 | } else { 271 | return body; 272 | } 273 | }); 274 | } 275 | 276 | /** 277 | * Make a request to the given Slack API call with the given params object 278 | * 279 | * Returns a promise for an object (decoded from the response JSON). 280 | */ 281 | function slackAPI(call, params) { 282 | params.token = process.env.SLACK_BOT_TOKEN; 283 | const options = { 284 | url: `https://slack.com/api/${call}`, 285 | // Slack always accepts both GET and POST, but using GET for things 286 | // that modify data is questionable. 287 | method: "POST", 288 | form: params, 289 | }; 290 | return request200(options).then(JSON.parse).then(data => { 291 | if (!data.ok) { 292 | throw new SunError("Slack won't listen to me! " + 293 | `It said \`${data.error}\` when I tried to ` + 294 | `\`${call}\`.`); 295 | } 296 | return data; 297 | }); 298 | } 299 | 300 | /** 301 | * Parse a list of users and add them to the CC_USERS variable for notification 302 | * of deploys. 303 | * TODO(drosile): uniq and/or validate CC_USERS as they are added 304 | */ 305 | function addUsersToCCList(users_str) { 306 | users_str.split(/\s*\+\s*/) 307 | .map(username => username.startsWith('@') ? username : `@${username}`) 308 | .map(username => CC_USERS.push(username)); 309 | } 310 | 311 | //-------------------------------------------------------------------------- 312 | // Developer email validation 313 | //-------------------------------------------------------------------------- 314 | 315 | function getUserEmails() { 316 | return slackAPI("users.list", {}).then(data => { 317 | for (let i in data.members) { 318 | const member = data.members[i]; 319 | USER_EMAILS.set(member.id, member.profile.email); 320 | } 321 | return "ok"; 322 | }); 323 | } 324 | 325 | /** 326 | * Returns a promise that resolves to a boolean. Check if an email is authorized 327 | * to deploy. 328 | */ 329 | function isFulltimeDev(userId) { 330 | let email = USER_EMAILS.get(userId); 331 | const promises = []; 332 | if (email === undefined) { 333 | // Maybe a new user, reload slack emails. 334 | promises.push(getUserEmails()); 335 | console.error("Refetching emails, missing " + userId); 336 | } 337 | if (VALID_DEPLOYER_EMAILS.size === 0 || !VALID_DEPLOYER_EMAILS.has(email)) { 338 | // Try to load authorized emails. 339 | promises.push(getIAMPolicy()); 340 | console.error("Refetching authorized emails, missing " + email); 341 | } 342 | return Q.all(promises).then(() => { 343 | email = USER_EMAILS.get(userId); 344 | return VALID_DEPLOYER_EMAILS.has(email); 345 | }); 346 | } 347 | 348 | //-------------------------------------------------------------------------- 349 | // Talking to GCP 350 | //-------------------------------------------------------------------------- 351 | 352 | /** 353 | * Authorize with GCP using JWT secret and fetch the khan-academy IAM policy. 354 | * Returns a promise. 355 | */ 356 | function getIAMPolicy() { 357 | const deferred = Q.defer(); 358 | let cloudresourcemanager = googleapis.cloudresourcemanager("v1"); 359 | 360 | let jwtClient = new googleapis.auth.JWT( 361 | googleKey.client_email, 362 | null, 363 | googleKey.private_key, 364 | ["https://www.googleapis.com/auth/cloudplatformprojects.readonly"], 365 | null 366 | ); 367 | 368 | jwtClient.authorize((err, tokens) => { 369 | if (err) { 370 | deferred.reject(err); 371 | return; 372 | } 373 | 374 | // https://cloud.google.com/resource-manager/reference/rest/v1/projects/getIamPolicy 375 | const request = { 376 | resource_: "khan-academy", 377 | resource: {}, 378 | auth: jwtClient 379 | }; 380 | cloudresourcemanager.projects.getIamPolicy(request, (err, result) => { 381 | if (err) { 382 | deferred.reject(err); 383 | return; 384 | } 385 | parseIAMPolicy(result); 386 | deferred.resolve(); 387 | }); 388 | }); 389 | return deferred.promise; 390 | } 391 | 392 | /** 393 | * Parse the IAM policy and set VALID_DEPLOYER_EMAILS based on all the users who 394 | * have either the Editor or the Owner role in the project. 395 | */ 396 | function parseIAMPolicy(result) { 397 | const emails = new Set(); 398 | const devRoles = ["roles/editor", "roles/owner"]; 399 | for (let i in result.bindings) { 400 | const users = result.bindings[i]; 401 | if (devRoles.indexOf(users.role) !== -1) { 402 | for (let j in users.members) { 403 | const member = users.members[j]; 404 | if (member.indexOf(":") == -1) { 405 | // Ignore members who aren't of the format type:id. 406 | continue; 407 | } 408 | const parts = member.split(":", 2); 409 | const memberType = parts[0]; 410 | if (memberType !== "user") { 411 | continue; 412 | } 413 | emails.add(parts[1]); 414 | } 415 | } 416 | } 417 | VALID_DEPLOYER_EMAILS = emails; 418 | } 419 | 420 | 421 | //-------------------------------------------------------------------------- 422 | // Talking to Jenkins 423 | //-------------------------------------------------------------------------- 424 | 425 | function getJenkinsCSRFToken() { 426 | return requestQ({ 427 | url: ("https://jenkins.khanacademy.org/crumbIssuer/api/json"), 428 | auth: { 429 | username: "jenkins@khanacademy.org", 430 | password: process.env.JENKINS_API_TOKEN, 431 | }, 432 | }).spread((res, body) => { 433 | const data = JSON.parse(body); 434 | if (data.crumb === undefined) { 435 | console.error("Operation aborted. Found no crumb data at " + 436 | "/crumbIssuer/api/json. Maybe Jenkins CSRF protection has " + 437 | "been turned off?"); 438 | throw new SunError( 439 | "Operation aborted due to problems acquiring a CSRF token"); 440 | } else { 441 | JENKINS_CSRF_TOKEN = data.crumb; 442 | } 443 | }).catch(err => { 444 | console.error("Operation aborted. Encountered the following error " + 445 | "when trying to reach /crumbIssuer/api/json: " + err); 446 | throw new SunError( 447 | "Operation aborted due to problems acquiring a CSRF token"); 448 | }); 449 | } 450 | 451 | /** 452 | * Low-level method to send a request to Jenkins. 453 | * 454 | * path should be the URL path; postData should be an object which we will 455 | * encode. If allowRedirect is falsy, we will consider a 3xx response an 456 | * error. If allowRedirect is truthy, we will consider a 3xx response a 457 | * success. (This is because a 302, for instance, might mean that we need to 458 | * follow a redirect to do the thing we want, or it might mean that we were 459 | * successful and are now getting redirected to a new page.) 460 | * 461 | * Caller should already have set up JENKINS_CSRF_TOKEN, if necessary. 462 | */ 463 | function getOrPostToJenkins(path, postData, allowRedirect) { 464 | const options = { 465 | url: "https://jenkins.khanacademy.org" + path, 466 | method: "GET", 467 | auth: { 468 | username: "jenkins@khanacademy.org", 469 | password: process.env.JENKINS_API_TOKEN, 470 | }, 471 | }; 472 | if (postData !== null) { 473 | options.method = "POST"; 474 | postData['Jenkins-Crumb'] = JENKINS_CSRF_TOKEN; 475 | options.form = postData; 476 | } 477 | 478 | if (DEBUG) { 479 | console.log(options); 480 | return Q(''); // a promise resolving to the empty string 481 | } 482 | 483 | return requestQ(options).catch(_err => { 484 | // Replace the error (which has already been logged) with a more 485 | // user-friendly one. We put the catch() first because the spread() 486 | // throws its own readable errors, and we don't want to mess with that. 487 | throw new SunError("Jenkins won't pick up the phone! You'll have " + 488 | "to talk to it yourself."); 489 | }).spread((res, body) => { 490 | if (res.statusCode === 403 && body.indexOf("No valid crumb") !== -1) { 491 | // Most callers should catch this, but we include an error message 492 | // just in case. 493 | throw new SunError( 494 | "Jenkins didn't like my crumb. (That's the way the cookie " + 495 | "crumbles.) You'll have to talk to it yourself.", true); 496 | } else if ((!allowRedirect && res.statusCode > 299) 497 | || res.statusCode > 399) { 498 | logHttpError(res, body); 499 | throw new SunError( 500 | "Jenkins didn't like what I said! You'll have " + 501 | "to talk to it yourself."); 502 | } 503 | return body; 504 | }); 505 | } 506 | 507 | /** 508 | * Make a get/post to jenkins. 509 | * 510 | * We tell readers what we're doing, and fetch a CSRF token if necessary. 511 | */ 512 | function runOnJenkins(msg, path, postData, message, allowRedirect) { 513 | // Tell readers what we're doing. 514 | if (message) { 515 | replyAsSun(msg, (DEBUG ? "DEBUG :: " : "") + message); 516 | } 517 | 518 | // If we haven't yet grabbed a CSRF token (needed for POST requests), then 519 | // grab one before making our request. 520 | if (!JENKINS_CSRF_TOKEN) { 521 | return getJenkinsCSRFToken().then(body => { 522 | return getOrPostToJenkins(path, postData, allowRedirect); 523 | }); 524 | } else { 525 | return getOrPostToJenkins(path, postData, allowRedirect).catch(err => { 526 | if (err.isCSRFError) { 527 | // For some reason our CSRF token was no longer valid. Fetch a 528 | // new one and try again. 529 | // TODO(benkraft): Avoid getting to this point by figuring out 530 | // when we need to fetch a new crumb. 531 | return getJenkinsCSRFToken().then(body => { 532 | return getOrPostToJenkins(path, postData, allowRedirect); 533 | }); 534 | } else { 535 | // Any other error, we don't know what to do so we rethrow. 536 | throw err; 537 | } 538 | }); 539 | } 540 | } 541 | 542 | /** 543 | * Return the path of a url that points to the given job. 544 | * 545 | * jobName can be like "deploy/test" if the job is in a folder. 546 | */ 547 | function jobPath(jobName) { 548 | // So deploy/test expands to deploy/job/test 549 | let jobUrlPart = jobName.split('/').join('/job/'); 550 | return "/job/" + jobUrlPart; 551 | } 552 | 553 | 554 | /** 555 | * Returns a promise for the current job ID if one is running, or false if not. 556 | * 557 | * Note that this isn't very useful for jobs that can run concurrently! Use it 558 | * for deploy-webapp, instead. 559 | */ 560 | function jenkinsJobStatus(jobName) { 561 | return request200({ 562 | url: ("https://jenkins.khanacademy.org" + 563 | `${jobPath(jobName)}/lastBuild/api/json`), 564 | auth: { 565 | username: "jenkins@khanacademy.org", 566 | password: process.env.JENKINS_API_TOKEN, 567 | }, 568 | }).then(body => { 569 | const data = JSON.parse(body); 570 | if (data.building === undefined || 571 | (data.building && !data.number)) { 572 | console.error("No build status found!"); 573 | console.error(` API response: ${body}`); 574 | throw body; 575 | } else if (data.building) { 576 | return data.number; 577 | } else { 578 | return null; 579 | } 580 | }).catch(_err => { 581 | // Replace the error (which has already been logged) with a more 582 | // user-friendly one. 583 | throw new SunError("Jenkins won't tell me what's running! " + 584 | "You'll have to talk to it yourself."); 585 | }); 586 | } 587 | 588 | /** 589 | * Returns a promise for the deploy-webapp-core job spawned by deploy-webapp. 590 | * 591 | * See jenkins-jobs:jobs/deploy-webapp for how we got in this mess. Most 592 | * times, we can ignore deploy-webapp-core, and just start/abort deploy-webapp. 593 | * But for clicking the "proceed" button, we need to look at the actual 594 | * underlying deploy-webapp-core job. This is unfortunately not made easy by 595 | * the Jenkins API: since there could be multiple deploy-webapp-core jobs 596 | * running, we need to look for the one spawned by deploy-webapp, which we do 597 | * by checking its console log for when it mentions the spawned build number. 598 | */ 599 | function deployWebappCoreStatus(deployWebappId) { 600 | // Pass through any nulls (but in a promise for consistency) 601 | return deployWebappId === null ? Q(null) : request200({ 602 | url: ("https://jenkins.khanacademy.org" + 603 | `${jobPath('deploy/deploy-webapp')}` + 604 | `/${deployWebappId}/logText/progressiveText`), 605 | auth: { 606 | username: "jenkins@khanacademy.org", 607 | password: process.env.JENKINS_API_TOKEN, 608 | }, 609 | }).then(body => { 610 | // Returns [match, groups...], we want group 1 (as a number) or null 611 | const buildId = body.match(/deploy-webapp-core #(\d*)/); 612 | return buildId && +buildId[1]; 613 | }).catch(_err => { 614 | // Replace the error (which has already been logged) with a more 615 | // user-friendly one. 616 | throw new SunError("Jenkins won't tell me what's running! " + 617 | "You'll have to talk to it yourself."); 618 | }); 619 | } 620 | 621 | // postData is an object, which we will encode and post to either 622 | // /job//build or /job//buildWithParameters, as appropriate. 623 | function runJobOnJenkins(msg, jobName, postData, message) { 624 | let path; 625 | if (Object.keys(postData).length === 0) { // no parameters 626 | path = `${jobPath(jobName)}/build`; 627 | } else { 628 | path = `${jobPath(jobName)}/buildWithParameters`; 629 | } 630 | 631 | return runOnJenkins(msg, path, postData, message); 632 | } 633 | 634 | 635 | function cancelJobOnJenkins(msg, jobName, jobId, message) { 636 | const path = `${jobPath(jobName)}/${jobId}/stop`; 637 | return runOnJenkins(msg, path, {}, message, true); 638 | } 639 | 640 | 641 | //-------------------------------------------------------------------------- 642 | // Slack: deploy queue 643 | //-------------------------------------------------------------------------- 644 | 645 | /** 646 | * Obfuscate a username so it won't generate an at-mention 647 | * 648 | * Every time a room topic gets updated, everyone listed in the topic gets an 649 | * at-mention. That's kind of annoying, so we stick zero-width spaces in 650 | * the usernames to avoid it. 651 | * 652 | * @param string username The username 653 | * 654 | * @return string The username, but with less at-mention. 655 | */ 656 | function obfuscateUsername(username) { 657 | // We use U+200C ZERO-WIDTH NON-JOINER because it doesn't count as a 658 | // word-break, making it easier to still highlight and delete your whole 659 | // name. 660 | return `${username[0]}\u200c${username.slice(1)}`; 661 | } 662 | 663 | /** 664 | * Unobfuscate a username 665 | * 666 | * Undo the transformation done by obfuscateUsername. 667 | * 668 | * @param string username The username 669 | * 670 | * @return string The username, but with more at-mention. 671 | */ 672 | function unobfuscateUsername(username) { 673 | return username.replace("\u200c", ""); 674 | } 675 | 676 | /** 677 | * Parse a deployer from the topic into an object. 678 | * 679 | * @param string deployerString The deployer string to be parsed. 680 | * 681 | * @return {?string|{usernames: Array., note: (undefined|string)}} The 682 | * parsed deployer. null if there is no deployer (i.e. an empty string or 683 | * series of dashes. A string if we couldn't parse the deployer. An 684 | * object if we could. 685 | */ 686 | function parseDeployer(deployerString) { 687 | const trimmed = deployerString.trim(); 688 | if (!trimmed || trimmed.match(NO_DEPLOYER_REGEX)) { 689 | return null; 690 | } 691 | const matches = trimmed.match(DEPLOYER_REGEX); 692 | if (!matches) { 693 | return trimmed; 694 | } 695 | return { 696 | usernames: matches[1].split("+") 697 | .map(username => unobfuscateUsername(username.trim())), 698 | note: matches[2], 699 | }; 700 | } 701 | 702 | /** 703 | * Turn a deployer object back into a string. 704 | * 705 | * @param {?string|{usernames: Array., note: (undefined|string)}} 706 | * deployer A deployer such as that returned by parseDeployer. 707 | * 708 | * @return string 709 | */ 710 | function stringifyDeployer(deployer) { 711 | if (!deployer) { 712 | // an em dash 713 | return "—"; 714 | } else if (!deployer.usernames) { 715 | return deployer; 716 | } else { 717 | let suffix = ""; 718 | if (deployer.note) { 719 | suffix = ` (${deployer.note})`; 720 | } 721 | const listOfUsernames = deployer.usernames 722 | .map(obfuscateUsername) 723 | .join(" + "); 724 | return listOfUsernames + suffix; 725 | } 726 | } 727 | 728 | /** 729 | * Turn a deploy object into a string useful for notifying all deployers. 730 | * For instance, a return value might be "@csilves @amy". 731 | * 732 | * @param {?string|{usernames: Array., note: (undefined|string)}} 733 | * deployer A deployer such as that returned by parseDeployer. 734 | * 735 | * @return string 736 | */ 737 | function stringifyDeployerUsernames(deployer) { 738 | // deployer will be either an object with a username key, or a 739 | // string which we couldn't parse. 740 | if (deployer.usernames) { 741 | return deployer.usernames.map(username => `@${username}`).join(" "); 742 | } else { 743 | return deployer; 744 | } 745 | } 746 | 747 | /** 748 | * Get a promise for the parsed topic of the deployment room. 749 | * 750 | * The promise will resolve to an object with keys "deployer" (a deployer (as 751 | * output by parseDeployer), or null if no one is deploying), "queue" (an array 752 | * of deployers), and "suffix" (a string to be appended to the end of the 753 | * queue, such as an extra message), if Sun can parse the topic. 754 | * 755 | * Rejects the promise if the API request fails or if it can't understand the 756 | * topic, and replies with a message to say so. 757 | */ 758 | function getTopic(_msg) { 759 | const params = {channel: DEPLOYMENT_ROOM_ID}; 760 | return slackAPI("channels.info", params).then(info => { 761 | let topic = info.channel.topic.value; 762 | // Slack for some reason escapes &, <, and > using HTML escapes, which 763 | // is just plain incorrect, but we'll handle it. Since these seem to 764 | // be the only escapes it uses, we won't bother to do something 765 | // fancier. 766 | topic = topic.replace("<", "<") 767 | .replace(">", ">") 768 | .replace("&", "&"); 769 | const matches = topic.match(TOPIC_REGEX); 770 | if (!matches) { 771 | console.error(`Error parsing topic: ${topic}`); 772 | throw new SunError(":confounded: I can't understand the topic. " + 773 | "You'll have to do it yourself."); 774 | } else { 775 | const people = matches[2] 776 | .split(",") 777 | .map(parseDeployer) 778 | .filter(person => person); 779 | const topicObj = { 780 | deployer: parseDeployer(matches[1]), 781 | queue: people, 782 | suffix: matches[3], 783 | }; 784 | return topicObj; 785 | } 786 | }); 787 | } 788 | 789 | /** 790 | * Set the topic of the deployment room. 791 | * 792 | * Accepts a topic of the form returned by getTopic(). If setting the topic 793 | * fails, replies to Slack to say so. 794 | * 795 | * Returns a promise for being done. 796 | */ 797 | function setTopic(msg, topic) { 798 | const listOfPeople = topic.queue.map(stringifyDeployer).join(", "); 799 | const deployer = stringifyDeployer(topic.deployer); 800 | const newTopic = `${deployer} | [${listOfPeople}]${topic.suffix}`; 801 | return slackAPI("channels.setTopic", { 802 | channel: DEPLOYMENT_ROOM_ID, 803 | topic: newTopic, 804 | }); 805 | } 806 | 807 | 808 | //-------------------------------------------------------------------------- 809 | // Slack: sun wukong the monkey king 810 | //-------------------------------------------------------------------------- 811 | 812 | /** 813 | * Return a promise that resolves to whether the pipeline step is valid. 814 | */ 815 | function validatePipelineStep(step, deployWebappCoreId) { 816 | let expectedName = null; 817 | if (step === "set-default-start") { 818 | // The "/input" form is where you click to proceed or abort. 819 | // The 'proceed' button has the following name on it. 820 | expectedName = "SetDefault"; 821 | } else if (step == "finish-with-success") { 822 | expectedName = "Finish"; 823 | } 824 | 825 | if (!expectedName) { 826 | return Q(false); // not a step we were expecting to see next! 827 | } 828 | if (!deployWebappCoreId) { 829 | return Q(false); // a deploy isn't even running now! 830 | } 831 | 832 | const path = (`${jobPath("deploy/deploy-webapp-core")}/` + 833 | `${deployWebappCoreId}/input/`); 834 | return getOrPostToJenkins(path, null, false).then(body => { 835 | return body.indexOf(`name="${expectedName}"`) !== -1; 836 | }).catch(_err => { // 404: no inputs expected right now at all 837 | return false; 838 | }); 839 | } 840 | 841 | function wrongPipelineStep(msg, badStep) { 842 | replyAsSun(msg, `:hal9000: I'm sorry, @${msg.user}. I'm ` + 843 | "afraid I can't let you do that. (It's not time to " + 844 | `${badStep}. If you disagree, bring it up with Jenkins.)`); 845 | } 846 | 847 | function validateUserAuth(msg) { 848 | return isFulltimeDev(msg.user_id).then(result => { 849 | if (result) { 850 | return "ok"; 851 | } else { 852 | throw new SunError( 853 | ":hal9000: You must be a fulltime developer to " + 854 | "do that. Ask <#C0BBDFJ7M|it> to make " + 855 | "sure you have the correct GCP roles. " + 856 | "It's always possible to use Jenkins to deploy if you " + 857 | "are getting this message in error."); 858 | } 859 | }); 860 | } 861 | 862 | function handleHelp(msg) { 863 | return replyAsSun(msg, help_text); 864 | } 865 | 866 | function handleEmojiHelp(msg) { 867 | return replyAsSun(msg, emoji_help_text); 868 | } 869 | 870 | function handlePing(msg) { 871 | return replyAsSun(msg, "I AM THE MONKEY KING!"); 872 | } 873 | 874 | function handleFingersCrossed(msg) { 875 | return replyAsSun(msg, "Okay, I've crossed my fingers. :fingerscrossed:"); 876 | } 877 | 878 | function handleState(msg) { 879 | return jenkinsJobStatus("deploy/deploy-webapp").then( 880 | deployWebappId => deployWebappCoreStatus( 881 | deployWebappId).then(deployWebappCoreId => { 882 | let text; 883 | if (deployWebappId) { 884 | text = (`deploy/deploy-webapp #${deployWebappId} ` + 885 | `(deploy/deploy-webapp-core ` + 886 | `#${deployWebappCoreId}) is currently running.`); 887 | } else { 888 | text = "No deploy is currently running."; 889 | } 890 | replyAsSun(msg, text); 891 | })); 892 | } 893 | 894 | function handlePodBayDoors(msg) { 895 | return wrongPipelineStep(msg, "open the pod bay doors"); 896 | } 897 | 898 | function handleQueueMe(msg) { 899 | return validateUserAuth(msg).then(() => { 900 | let user = msg.user; 901 | const arg = msg.match[1].trim(); 902 | if (arg && arg !== "me") { 903 | user = arg; 904 | } 905 | return getTopic(msg).then(topic => { 906 | if (topic.queue.length === 0 && topic.deployer === null) { 907 | topic.deployer = user; 908 | return setTopic(msg, topic); 909 | } else { 910 | topic.queue.push(obfuscateUsername(user)); 911 | return setTopic(msg, topic); 912 | } 913 | }); 914 | }); 915 | } 916 | 917 | function doQueueNext(msg) { 918 | return getTopic(msg).then(topic => { 919 | const newDeployer = topic.queue[0]; 920 | const newTopic = { 921 | deployer: newDeployer, 922 | queue: topic.queue.slice(1), 923 | suffix: topic.suffix 924 | }; 925 | // Wait for the topic change to complete, then pass down the new 926 | // deployer. 927 | return setTopic(msg, newTopic).then(_ => newDeployer); 928 | }).then(newDeployer => { 929 | if (!newDeployer) { 930 | return replyAsSun(msg, "Okay. Anybody else want to deploy?", 931 | true); 932 | } else { 933 | const mentions = stringifyDeployerUsernames(newDeployer); 934 | return replyAsSun(msg, `Okay, ${mentions} it is your turn!`, true); 935 | } 936 | }); 937 | } 938 | 939 | function handleQueueNext(msg) { 940 | // TODO(csilvers): complain if they do 'next' after the happy dance, 941 | // since we do that automatically now. 942 | return doQueueNext(msg); 943 | } 944 | 945 | 946 | function handleRemoveMe(msg) { 947 | let user = msg.user; 948 | const arg = msg.match[1].trim(); 949 | if (arg && arg !== "me") { 950 | user = arg; 951 | } 952 | return getTopic(msg).then(topic => { 953 | topic.queue = topic.queue.filter( 954 | deploy => !deploy.usernames.includes(user)); 955 | // TODO(benkraft): if the removed user is deploying, do an `up next` as 956 | // well. 957 | return setTopic(msg, topic); 958 | }); 959 | } 960 | 961 | function handleCCUsers(msg) { 962 | return jenkinsJobStatus("deploy/deploy-webapp").then(deployWebappId => { 963 | if (deployWebappId) { 964 | let ccUsers = msg.match[1]; 965 | addUsersToCCList(ccUsers); 966 | replyAsSun(msg, "Okay, I've added them to the notification list for this deploy."); 967 | } else { 968 | replyAsSun(msg, "It doesn't look like there is any deploy going on."); 969 | } 970 | return; 971 | }); 972 | } 973 | 974 | 975 | function handleDeleteZnd(msg) { 976 | const znd_name = msg.match[1].trim(); 977 | const responseText = "Okay, I'll ask Jenkins to delete that ZND!"; 978 | const postData = { 979 | "ZND_NAME": znd_name, 980 | }; 981 | return runJobOnJenkins(msg, "deploy/delete-znd", postData, responseText); 982 | } 983 | 984 | 985 | function handleNotifyZndOwners(msg) { 986 | const responseText = ("Okay, I'll check in with ZND owners about " + 987 | "cleaning up their ZNDs"); 988 | return runJobOnJenkins(msg, "deploy/notify-znd-owners", {}, responseText); 989 | } 990 | 991 | 992 | function handleHistory(msg) { 993 | const responseText = ( 994 | "Asking jenkins to print the changelog for the last 5 deploys. " + 995 | "(This may take a minute.)"); 996 | return runJobOnJenkins(msg, "deploy/deploy-history", 997 | {"SLACK_CHANNEL": msg.channel}, responseText); } 998 | 999 | 1000 | function handleMakeCheck(msg) { 1001 | const deployBranch = msg.match[1]; 1002 | const caller = msg.user; 1003 | const postData = { 1004 | "GIT_REVISION": deployBranch, 1005 | "SLACK_CHANNEL": msg.channel, 1006 | "REPORT_RESULT": true, 1007 | "DEPLOYER_USERNAME": "@" + caller, 1008 | }; 1009 | let responseText = ("Telling Jenkins to run tests on branch `" + 1010 | deployBranch + "`."); 1011 | return runJobOnJenkins(msg, "deploy/webapp-test", postData, responseText); 1012 | } 1013 | 1014 | function handleDeploy(msg) { 1015 | // Check that it's not Friday 1016 | const d = new Date(); 1017 | // Adjust for time zone (UTC) 1018 | // If (d.getHours() - 7) is negative, d moves back a day 1019 | d.setHours(d.getHours() - 7); 1020 | if (d.getDay() === 5) { 1021 | return replyAsSun(msg, 1022 | ":frog: It's Friday! Please don't make changes that potentially " + 1023 | "affect many parts of the site. If your change affects only " + 1024 | "a small surface area that you can verify manually, go " + 1025 | "forth and deploy with `sun: deploy-not-risky [branch-name]`"); 1026 | } else { 1027 | return handleSafeDeploy(msg); 1028 | } 1029 | } 1030 | 1031 | function handleSafeDeploy(msg) { 1032 | return validateUserAuth(msg).then(() => { 1033 | const deployBranch = msg.match[1]; 1034 | const ccUsers = msg.match[2]; 1035 | 1036 | jenkinsJobStatus("deploy/deploy-webapp").then(deployWebappId => { 1037 | if (deployWebappId) { 1038 | replyAsSun(msg, "I think there's a deploy already going on. " + 1039 | "If that's not the case, take it up with Jenkins."); 1040 | return false; 1041 | } 1042 | 1043 | CC_USERS = []; 1044 | if (!!ccUsers) { 1045 | addUsersToCCList(ccUsers); 1046 | } 1047 | const caller = msg.user; 1048 | const postData = { 1049 | "GIT_REVISION": deployBranch, 1050 | "DEPLOYER_USERNAME": "@" + caller, 1051 | }; 1052 | 1053 | runJobOnJenkins(msg, "deploy/deploy-webapp", postData, 1054 | "Telling Jenkins to deploy branch `" + deployBranch + "`."); 1055 | return true; 1056 | }).then(startedDeploy => { 1057 | if (startedDeploy) { 1058 | return getTopic(msg).then(topic => { 1059 | if (topic.queue.length > 0) { 1060 | const mentions = stringifyDeployerUsernames(topic.queue[0]); 1061 | return replyAsSun(msg, `${mentions}, now would be ` + 1062 | "a good time to run `sun: test master + " + 1063 | deployBranch + " + `", 1064 | true); 1065 | } 1066 | }); 1067 | } 1068 | }); 1069 | }); 1070 | } 1071 | 1072 | function handleSetDefault(msg) { 1073 | return validateUserAuth(msg).then(() => { 1074 | return jenkinsJobStatus("deploy/deploy-webapp") 1075 | .then(deployWebappId => deployWebappCoreStatus(deployWebappId)) 1076 | .then(deployWebappCoreId => { 1077 | return validatePipelineStep("set-default-start", deployWebappCoreId).then(isValid => { 1078 | if (!isValid) { 1079 | return wrongPipelineStep(msg, "set-default"); 1080 | } 1081 | // Hit the "continue" button. 1082 | replyAsSun(msg, (DEBUG ? "DEBUG :: " : "") + 1083 | "Telling Jenkins to set default"); 1084 | const path = `${jobPath("deploy/deploy-webapp-core")}/${deployWebappCoreId}/input/SetDefault/proceedEmpty`; 1085 | return runOnJenkins(null, path, {}, null, false); 1086 | }); 1087 | }); 1088 | }); 1089 | } 1090 | 1091 | function handleAbort(msg) { 1092 | jenkinsJobStatus("deploy/deploy-webapp").then(deployWebappId => { 1093 | if (deployWebappId) { 1094 | CC_USERS = []; 1095 | // There's a job running, so we should probably cancel it. 1096 | return cancelJobOnJenkins(msg, "deploy/deploy-webapp", 1097 | deployWebappId, 1098 | "Telling Jenkins to cancel deploy/deploy-webapp " + 1099 | "#" + deployWebappId + "."); 1100 | } else { 1101 | // If no deploy is in progress, we had better not abort. 1102 | return replyAsSun(msg, 1103 | "I don't think there's a deploy going. If you need " + 1104 | "to roll back the production servers because you noticed " + 1105 | "some problems after a deploy finished, :speech_balloon: " + 1106 | "_“sun: emergency rollback”_. If you think there's a " + 1107 | "deploy going, then I'm confused and you'll have to talk " + 1108 | "to Jenkins yourself."); 1109 | } 1110 | }); 1111 | } 1112 | 1113 | function handleFinish(msg) { 1114 | return validateUserAuth(msg).then(() => { 1115 | return jenkinsJobStatus("deploy/deploy-webapp") 1116 | .then(deployWebappId => deployWebappCoreStatus(deployWebappId)) 1117 | .then(deployWebappCoreId => { 1118 | return validatePipelineStep("finish-with-success", deployWebappCoreId).then(isValid => { 1119 | if (!isValid) { 1120 | return wrongPipelineStep(msg, "finish-with-success"); 1121 | } 1122 | // Hit the "continue" button. 1123 | replyAsSun(msg, (DEBUG ? "DEBUG :: " : "") + 1124 | "Telling Jenkins to finish this deploy!"); 1125 | CC_USERS = []; 1126 | const path = `${jobPath("deploy/deploy-webapp-core")}/${deployWebappCoreId}/input/Finish/proceedEmpty`; 1127 | // wait a little while before notifying the next person. 1128 | // hopefully the happy dance has appeared by then, if not 1129 | // humans will have to figure it out themselves. 1130 | setTimeout(() => doQueueNext(msg), 5000); 1131 | return runOnJenkins(null, path, {}, null, false); 1132 | }); 1133 | }); 1134 | }); 1135 | } 1136 | 1137 | function handleEmergencyRollback(msg) { 1138 | const jobname = "deploy/---EMERGENCY-ROLLBACK---"; 1139 | return runJobOnJenkins(msg, jobname, {}, 1140 | "Telling Jenkins to roll back the live site to a safe " + 1141 | "version"); 1142 | } 1143 | 1144 | const textHandlerMap = new Map([ 1145 | // Get help 1146 | [/^help$/i, handleHelp], 1147 | // Return ping and verify you're in the right room 1148 | [/^ping$/i, handlePing], 1149 | // Return the dump of the JSON state 1150 | [/^(cross (your )?fingers|fingers crossed).*$/i, handleFingersCrossed], 1151 | // Return the dump of the JSON state 1152 | [/^state$/i, handleState], 1153 | // Attempt to open the pod bay doors 1154 | [/^open the pod bay doors/i, handlePodBayDoors], 1155 | // Add the sender to the deploy queue 1156 | [/^(?:en)?queue\s*(.*)$/i, handleQueueMe], 1157 | // Move on to the next deployer 1158 | [/^(?:up )?next/i, handleQueueNext], 1159 | // Remove the sender from the deploy queue 1160 | [/^(?:remove|dequeue)\s*(.*)$/i, handleRemoveMe], 1161 | // Run tests on a branch outside the deploy process 1162 | [new RegExp("^test\\s+(?:branch\\s+)?([^" + FIELD_SEP + "]*)$", 'i'), handleMakeCheck], 1163 | // Delete a given znd 1164 | [new RegExp("^delete(?: znd)?\\s+(?:znd\\s+)?([^" + FIELD_SEP + "]*)$", 'i'), handleDeleteZnd], 1165 | // Begin the deployment process for the specified branch 1166 | [/^prompt znd cleanup$/i, handleNotifyZndOwners], 1167 | // Print recent deploy history 1168 | [/^history$/i, handleHistory], 1169 | // Begin the deployment process for the specified branch (if not Friday) 1170 | [new RegExp("^deploy\\s+(?:branch\\s+)?([^" + FIELD_SEP + "]*)(?:,(?:\\s+)?cc(?:[\\s:]+)([^" + FIELD_SEP + "]+))?$", 'i'), 1171 | handleDeploy], 1172 | // Begin the deployment process for the (non-risky) branch 1173 | [new RegExp("^deploy-not-risky\\s+(?:branch\\s+)?([^" + FIELD_SEP + "]*)(?:,(?:\\s+)?cc(?:[\\s:]+)([^" + FIELD_SEP + "]+))?$", 'i'), 1174 | handleSafeDeploy], 1175 | // Set the branch in testing to the default branch 1176 | [/^set.default$/i, handleSetDefault], 1177 | // Abort the current deployment step 1178 | [/^abort.*$/i, handleAbort], 1179 | // Mark the version currently testing as default as good and mark the 1180 | // deploy as done 1181 | [/^finish.*$/i, handleFinish], 1182 | [/^yolo.*$/i, handleFinish], 1183 | // Roll back production to the previous version after set default 1184 | [/^emergency rollback.*$/i, handleEmergencyRollback], 1185 | // CC users on the current deploy (they will be at-mentioned in further messages) 1186 | [new RegExp("^cc\\s+([^" + FIELD_SEP + "]*)$", 'i'), handleCCUsers], 1187 | // Catch-all: if we didn't get a valid command, send the help message. 1188 | [/^.*$/i, handleHelp], 1189 | ]); 1190 | 1191 | const emojiHandlerMap = new Map([ 1192 | [/^:(?:question|grey_question):/i, handleEmojiHelp], 1193 | [/^:point_left:/i, handlePing], 1194 | [/^:fingerscrossed:/i, handleFingersCrossed], 1195 | [/^:shrug:/i, handleState], 1196 | [/^:hal9000:/i, handlePodBayDoors], 1197 | [/^:(?:plus1|heavy_plus_sign):\s*(.*)$/i, handleQueueMe], 1198 | [/^:(?:arrow_right|arrow_forward|fast_forward):/i, handleQueueNext], 1199 | [/^:(?:x|negative_squared_cross_mark|heavy_multiplication_x):\s*(.*)$/i, 1200 | handleRemoveMe], 1201 | [new RegExp("^:(?:test-tube|100):\\s*([^" + FIELD_SEP + "]*)", 'i'), handleMakeCheck], 1202 | [/^:amphora:/i, handleHistory], 1203 | [new RegExp("^:(?:ship|shipit|passenger_ship|pirate_ship|treeeee):\\s*([^" + FIELD_SEP + "]*)", 'i'), 1204 | handleDeploy], 1205 | [new RegExp("^:confidence-high:\\s*([^" + FIELD_SEP + "]*)", 'i'), 1206 | handleSafeDeploy], 1207 | [/^:rocket:/i, handleSetDefault], 1208 | [/^:(?:skull|skull_and_crossbones|sad_mac|sadpanda|party_parrot_sad):/i, 1209 | handleAbort], 1210 | [/^:(?:cry|disappointed):/i, handleAbort], 1211 | [/^:(?:yolo|party_dino|ballot_box_with_check|checkered_flag):/i, 1212 | handleFinish], 1213 | [/^:(?:heavy_check_mark|white_check_mark):/i, handleFinish], 1214 | [/^:scream:/i, handleEmergencyRollback], 1215 | [new RegExp("^:phone:\\s+([^" + FIELD_SEP + "]*)$", 'i'), handleCCUsers], 1216 | [/^.*$/i, handleEmojiHelp], 1217 | ]); 1218 | 1219 | const app = express(); 1220 | app.use(bodyParser.urlencoded({extended: true})); 1221 | app.post("/", (req, res) => { 1222 | if (req.body.token !== process.env.SLACK_VERIFICATION_TOKEN) { 1223 | res.status(401).send("You appear to be unauthorized."); 1224 | return; 1225 | } 1226 | const message = { 1227 | channel: "#" + req.body.channel_name, 1228 | channel_id: req.body.channel_id, 1229 | user: req.body.user_name, 1230 | user_id: req.body.user_id, 1231 | text: req.body.text.substring(req.body.trigger_word.length).trimLeft() 1232 | }; 1233 | if (message.channel_id !== DEPLOYMENT_ROOM_ID) { 1234 | res.json(sunMessage( 1235 | message, 1236 | `Sorry, I only respond to messages in <#${DEPLOYMENT_ROOM_ID}>!`)); 1237 | return; 1238 | } 1239 | const handlerMap = (req.body.trigger_word[0] === ':' ? 1240 | emojiHandlerMap : textHandlerMap); 1241 | for (let [rx, fn] of handlerMap) { 1242 | const match = rx.exec(message.text); 1243 | if (match !== null) { 1244 | message.match = match; 1245 | Q(fn(message)) 1246 | .catch(err => onError(message, err)) 1247 | .then(() => res.send({})); 1248 | break; 1249 | } 1250 | } 1251 | }); 1252 | 1253 | 1254 | const server = app.listen(process.env.PORT || "8080", "0.0.0.0", () => { 1255 | console.log("App listening at https://%s:%s", 1256 | server.address().address, server.address().port); 1257 | console.log("Press Ctrl-C to quit."); 1258 | }); 1259 | --------------------------------------------------------------------------------