1 | 'use strict';
|
2 |
|
3 | const get = require('lodash.get');
|
4 | const moment = require('moment');
|
5 | const Handlebars = require('handlebars');
|
6 | const forge = require('node-forge');
|
7 | const AWS = require('aws-sdk');
|
8 | const path = require('path');
|
9 | const fs = require('fs-extra');
|
10 | const inquirer = require('inquirer');
|
11 | const Lambda = require('./lambda');
|
12 | const 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 | *
|
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 | */
|
34 | class 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 |
|
61 |
|
62 |
|
63 |
|
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 |
|
92 |
|
93 |
|
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
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 |
|
110 | if (this.config.template) {
|
111 | let mainCF = this.parseCF(this.config.template.cfFile);
|
112 |
|
113 |
|
114 | try {
|
115 | fs.lstatSync(this.config.cfFile);
|
116 | let overrideCF = this.parseCF(this.config.cfFile);
|
117 |
|
118 |
|
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 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
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 |
|
160 |
|
161 |
|
162 |
|
163 | uploadCF() {
|
164 |
|
165 | return this.compileCF().then(() => {
|
166 |
|
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 |
|
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 |
|
191 |
|
192 |
|
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 |
|
209 |
|
210 |
|
211 |
|
212 | cloudFormation() {
|
213 | const cfParams = [];
|
214 |
|
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 |
|
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 |
|
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 |
|
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 |
|
311 |
|
312 |
|
313 |
|
314 | validateTemplate() {
|
315 | console.log('Validating the template');
|
316 | const url = this.templateUrl;
|
317 |
|
318 | const params = {};
|
319 |
|
320 | if (this.bucket) {
|
321 |
|
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 |
|
333 | return this.cf.validateTemplate(params)
|
334 | .promise().then(() => console.log('Template is valid'));
|
335 | }
|
336 |
|
337 | |
338 |
|
339 |
|
340 |
|
341 |
|
342 | describeCF() {
|
343 | const cf = new AWS.CloudFormation();
|
344 |
|
345 | return cf.describeStacks({
|
346 | StackName: this.stack
|
347 | }).promise();
|
348 | }
|
349 |
|
350 | |
351 |
|
352 |
|
353 |
|
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 |
|
365 |
|
366 |
|
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 |
|
387 |
|
388 |
|
389 |
|
390 |
|
391 | upsertStack() {
|
392 | return this.opsStack();
|
393 | }
|
394 |
|
395 | |
396 |
|
397 |
|
398 |
|
399 |
|
400 |
|
401 | deployStack() {
|
402 | return this.opsStack();
|
403 | }
|
404 | |
405 |
|
406 |
|
407 |
|
408 |
|
409 | createStack() {
|
410 | return this.opsStack();
|
411 | }
|
412 |
|
413 | |
414 |
|
415 |
|
416 |
|
417 |
|
418 | updateStack() {
|
419 | return this.opsStack();
|
420 | }
|
421 |
|
422 | |
423 |
|
424 |
|
425 |
|
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 |
|
447 | module.exports = Kes;
|