UNPKG

12.8 kBJavaScriptView Raw
1'use strict';
2
3const get = require('lodash.get');
4const moment = require('moment');
5const Handlebars = require('handlebars');
6const forge = require('node-forge');
7const AWS = require('aws-sdk');
8const path = require('path');
9const fs = require('fs-extra');
10const inquirer = require('inquirer');
11const Lambda = require('./lambda');
12const utils = require('./utils');
13
14/**
15 * The main Kes class. This class is used in the command module to create
16 * the CLI interface for kes. This class can be extended in order to override
17 * and modify the behavior of kes cli.
18 *
19 * @example
20 * const { Kes, Config } = require('kes');
21 *
22 * const options = { stack: 'myStack' };
23 * const config = new Config(options);
24 * const kes = new Kes(config);
25 *
26 * // create a new stack
27 * kes.deployStack()
28 * .then(() => describeCF())
29 * .then(() => updateSingleLambda('myLambda'))
30 * .catch(e => console.log(e));
31 *
32 * @param {Object} config an instance of the Config class (config.js)
33 */
34class Kes {
35 constructor(config) {
36 this.config = config;
37
38 this.stack = this.config.stack;
39 this.bucket = get(config, 'bucket');
40
41 // template name
42 if (config.parent) {
43 this.cf_template_name = `${config.nested_cf_name}.yml`;
44 }
45 else {
46 this.cf_template_name = `${path.basename(config.cfFile, '.template.yml')}.yml`;
47 }
48
49 this.templateUrl = `https://s3.amazonaws.com/${this.bucket}/${this.stack}/${this.cf_template_name}`;
50
51 utils.configureAws(this.config.region, this.config.profile, this.config.role);
52 this.s3 = new AWS.S3();
53 this.cf = new AWS.CloudFormation();
54 this.AWS = AWS;
55 this.Lambda = Lambda;
56 this.startTime = moment();
57 }
58
59 /**
60 * Updates code of a deployed lambda function
61 *
62 * @param {String} name the name of the lambda function defined in config.yml
63 * @return {Promise} returns the promise of an AWS response object
64 */
65 updateSingleLambda(name) {
66 const lambda = new this.Lambda(this.config);
67 return lambda.updateSingleLambda(name);
68 }
69
70 parseCF(cfFile) {
71 const t = fs.readFileSync(cfFile, 'utf8');
72
73 Handlebars.registerHelper('ToMd5', function(value) {
74 if (value) {
75 const md = forge.md.md5.create();
76 md.update(value);
77 return md.digest().toHex();
78 }
79 return value;
80 });
81
82 Handlebars.registerHelper('ToJson', function(value) {
83 return JSON.stringify(value);
84 });
85
86 const template = Handlebars.compile(t);
87 return template(this.config);
88 }
89
90 /**
91 * Compiles a CloudFormation template in Yaml format.
92 *
93 * Reads the configuration yaml from `.kes/config.yml`.
94 *
95 * Writes the template to `.kes/cloudformation.yml`.
96 *
97 * Uses `.kes/cloudformation.template.yml` as the base template
98 * for generating the final CF template.
99 *
100 * @return {Promise} returns the promise of an AWS response object
101 */
102 compileCF() {
103 const lambda = new this.Lambda(this.config);
104
105 return lambda.process().then((config) => {
106 this.config = config;
107 let cf;
108
109 // if there is a template parse CF there first
110 if (this.config.template) {
111 let mainCF = this.parseCF(this.config.template.cfFile);
112
113 // check if there is a CF over
114 try {
115 fs.lstatSync(this.config.cfFile);
116 let overrideCF = this.parseCF(this.config.cfFile);
117
118 // merge the the two
119 cf = utils.mergeYamls(mainCF, overrideCF);
120 }
121 catch (e) {
122 if (!e.message.includes('ENOENT')) {
123 console.log(`compiling the override template at ${this.config.cfFile} failed:`);
124 throw e;
125 }
126 cf = mainCF;
127 }
128 }
129 else {
130 cf = this.parseCF(this.config.cfFile);
131 }
132
133 const destPath = path.join(this.config.kesFolder, this.cf_template_name);
134 console.log(`Template saved to ${destPath}`);
135 return fs.writeFileSync(destPath, cf);
136 });
137 }
138
139 /**
140 * This is just a wrapper around AWS s3.upload method.
141 * It uploads a given string to a S3 object.
142 *
143 * @param {String} bucket the s3 bucket name
144 * @param {String} key the path and name of the object
145 * @param {String} body the content of the object
146 * @returns {Promise} returns the promise of an AWS response object
147 */
148 uploadToS3(bucket, key, body) {
149 return this.s3.upload({ Bucket: bucket, Key: key, Body: body })
150 .promise()
151 .then(() => {
152 const httpUrl = `http://${bucket}.s3.amazonaws.com/${key}`;
153 console.log(`Uploaded: s3://${bucket}/${key}`);
154 return httpUrl;
155 });
156 }
157
158 /**
159 * Uploads the Cloud Formation template to a given S3 location
160 *
161 * @returns {Promise} returns the promise of an AWS response object
162 */
163 uploadCF() {
164 // build the template first
165 return this.compileCF().then(() => {
166 // make sure cloudformation template exists
167 try {
168 fs.accessSync(path.join(this.config.kesFolder, this.cf_template_name));
169 }
170 catch (e) {
171 throw new Error(`${this.cf_template_name} is missing.`);
172 }
173
174 // upload CF template to S3
175 if (this.bucket) {
176 return this.uploadToS3(
177 this.bucket,
178 `${this.stack}/${this.cf_template_name}`,
179 fs.readFileSync(path.join(this.config.kesFolder, this.cf_template_name))
180 );
181 }
182 else {
183 console.log('Skipping CF template upload because internal bucket value is not provided.');
184 return true;
185 }
186 });
187 }
188
189 /**
190 * Wait for the current stack and log the current outcome
191 *
192 * @returns {Promise} undefined
193 */
194 waitFor(wait) {
195 console.log('Waiting for the CF operation to complete');
196 return this.cf.waitFor(wait, { StackName: this.stack }).promise()
197 .then(r => {
198 if (r && r.Stacks && r.Stacks[0] && r.Stacks[0].StackStatus) {
199 console.log(`CF operation is in state of ${r.Stacks[0].StackStatus}`);
200 }
201 else {
202 console.log(`CF operation is completed`);
203 }
204 });
205 }
206
207 /**
208 * Calls CloudFormation's update-stack or create-stack methods
209 *
210 * @returns {Promise} returns the promise of an AWS response object
211 */
212 cloudFormation() {
213 const cfParams = [];
214 // add custom params from the config file if any
215 if (this.config.params) {
216 this.config.params.forEach((p) => {
217 cfParams.push({
218 ParameterKey: p.name,
219 ParameterValue: p.value,
220 UsePreviousValue: p.usePrevious || false
221 //NoEcho: p.noEcho || true
222 });
223 });
224 }
225
226 const capabilities = get(this.config, 'capabilities', []);
227
228 const params = {
229 StackName: this.stack,
230 Parameters: cfParams,
231 Capabilities: capabilities
232 };
233
234 if (this.config.tags) {
235 params.Tags = Object.keys(this.config.tags).map((key) => ({
236 Key: key,
237 Value: this.config.tags[key]
238 }));
239 }
240 else {
241 params.Tags = [];
242 }
243
244 if (this.bucket) {
245 params.TemplateURL = this.templateUrl;
246 }
247 else {
248 params.TemplateBody = fs.readFileSync(path.join(this.config.kesFolder, this.cf_template_name)).toString();
249 }
250
251 let wait = 'stackUpdateComplete';
252
253 // check if the stack exists
254 return this.cf.describeStacks({ StackName: this.stack }).promise()
255 .then(r => this.cf.updateStack(params).promise())
256 .catch(e => {
257 if (e.message.includes('does not exist')) {
258 wait = 'stackCreateComplete';
259 return this.cf.createStack(params).promise();
260 }
261 throw e;
262 })
263 .then(() => this.waitFor(wait))
264 .catch((e) => {
265 const errorsWithDetail = [
266 'CREATE_FAILED',
267 'Resource is not in the state stackUpdateComplete',
268 'UPDATE_ROLLBACK_COMPLETE',
269 'ROLLBACK_COMPLETE',
270 'UPDATE_ROLLBACK_FAILED'
271 ];
272 const errorRequiresDetail = errorsWithDetail.filter(i => e.message.includes(i));
273
274 if (e.message === 'No updates are to be performed.') {
275 console.log(e.message);
276 return e.message;
277 }
278 else if (errorRequiresDetail.length > 0) {
279 console.log('There was an error deploying the CF stack');
280 console.log(e.message);
281
282 // get the error info here
283 return this.cf.describeStackEvents({ StackName: this.stack }).promise();
284 }
285 else {
286 console.log('There was an error deploying the CF stack');
287 throw e;
288 }
289 })
290 .then((r) => {
291 if (r && r.StackEvents) {
292 console.log('Here is the list of failures in chronological order:');
293 r.StackEvents.forEach((s) => {
294 if (s.ResourceStatus &&
295 s.ResourceStatus.includes('FAILED') &&
296 moment(s.Timestamp) > this.startTime) {
297 console.log(`${s.Timestamp} | ` +
298 `${s.ResourceStatus} | ` +
299 `${s.ResourceType} | ` +
300 `${s.LogicalResourceId} | ` +
301 `${s.ResourceStatusReason}`);
302 }
303 });
304 throw new Error('CloudFormation Deployment failed');
305 }
306 });
307 }
308
309 /**
310 * Validates the CF template
311 *
312 * @returns {Promise} returns the promise of an AWS response object
313 */
314 validateTemplate() {
315 console.log('Validating the template');
316 const url = this.templateUrl;
317
318 const params = {};
319
320 if (this.bucket) {
321 // upload the template to the bucket first
322 params.TemplateURL = url;
323
324 return this.uploadCF()
325 .then(() => this.cf.validateTemplate(params).promise())
326 .then(() => console.log('Template is valid'));
327 }
328 else {
329 params.TemplateBody = fs.readFileSync(path.join(this.config.kesFolder, this.cf_template_name)).toString();
330 }
331
332 // Build and upload the CF template
333 return this.cf.validateTemplate(params)
334 .promise().then(() => console.log('Template is valid'));
335 }
336
337 /**
338 * Describes the cloudformation stack deployed
339 *
340 * @returns {Promise} returns the promise of an AWS response object
341 */
342 describeCF() {
343 const cf = new AWS.CloudFormation();
344
345 return cf.describeStacks({
346 StackName: this.stack
347 }).promise();
348 }
349
350 /**
351 * Deletes the current stack
352 *
353 * @returns {Promise} undefined
354 */
355 deleteCF() {
356 return this.cf.deleteStack({
357 StackName: this.stack
358 }).promise()
359 .then(() => this.waitFor('stackDeleteComplete'))
360 .then(() => console.log(`${this.stack} is successfully deleted`));
361 }
362
363 /**
364 * Generic create/update method for CloudFormation
365 *
366 * @returns {Promise} returns the promise of an AWS response object
367 */
368 opsStack() {
369 return this.uploadCF()
370 .then(() => this.cloudFormation())
371 .then(() => {
372 if (this.config.showOutputs) {
373 return this.describeCF();
374 }
375 return Promise.resolve();
376 })
377 .then((r) => {
378 if (r && r.Stacks[0] && r.Stacks[0].Outputs) {
379 console.log('\nList of the CloudFormation outputs:\n');
380 r.Stacks[0].Outputs.map((o) => console.log(`${o.OutputKey}: ${o.OutputValue}`));
381 }
382 });
383 }
384
385 /**
386 * [Deprecated] Creates a CloudFormation stack for the class instance
387 * If exists, will update the existing one
388 *
389 * @returns {Promise} returns the promise of an AWS response object
390 */
391 upsertStack() {
392 return this.opsStack();
393 }
394
395 /**
396 * Creates a CloudFormation stack for the class instance
397 * If exists, will update the existing one
398 *
399 * @returns {Promise} returns the promise of an AWS response object
400 */
401 deployStack() {
402 return this.opsStack();
403 }
404 /**
405 * [Deprecated] Creates a CloudFormation stack for the class instance
406 *
407 * @returns {Promise} returns the promise of an AWS response object
408 */
409 createStack() {
410 return this.opsStack();
411 }
412
413 /**
414 * [Deprecated] Updates an existing CloudFormation stack for the class instance
415 *
416 * @returns {Promise} returns the promise of an AWS response object
417 */
418 updateStack() {
419 return this.opsStack();
420 }
421
422 /**
423 * Deletes the main stack
424 *
425 * @returns {Promise} returns the promise of an AWS response object
426 */
427 deleteStack() {
428 if (this.config.yes) {
429 return this.deleteCF();
430 }
431 return inquirer.prompt(
432 [{
433 type: 'confirm',
434 name: 'delete',
435 message: `Are you sure you want to delete ${this.stack}? This operation is not reversible`
436 }]
437 ).then(answers => {
438 if (answers.delete) {
439 return this.deleteCF();
440 }
441 console.log('Operation canceled');
442 return Promise.resolve();
443 });
444 }
445}
446
447module.exports = Kes;