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 Lambda = require('./lambda');
|
11 | const utils = require('./utils');
|
12 |
|
13 | /**
|
14 | * The main Kes class. This class is used in the command module to create
|
15 | * the CLI interface for kes. This class can be extended in order to override
|
16 | * and modify the behaviour of kes cli.
|
17 | *
|
18 | * @example
|
19 | * const { Kes, Config } = require('kes');
|
20 | *
|
21 | * const options = { stack: 'myStack' };
|
22 | * const config = new Config(options);
|
23 | * const kes = new Kes(config);
|
24 | *
|
25 | *
|
26 | * kes.deployStack()
|
27 | * .then(() => describeCF())
|
28 | * .then(() => updateSingleLambda('myLambda'))
|
29 | * .catch(e => console.log(e));
|
30 | *
|
31 | * @param {Object} config an instance of the Config class (config.js)
|
32 | */
|
33 | class Kes {
|
34 | constructor(config) {
|
35 | this.config = config;
|
36 |
|
37 | this.stack = this.config.stack;
|
38 | this.bucket = get(config, 'bucket');
|
39 | this.templateUrl = `https://s3.amazonaws.com/${this.bucket}/${this.stack}/cloudformation.yml`;
|
40 |
|
41 | utils.configureAws(this.config.region, this.config.profile, this.config.role);
|
42 | this.s3 = new AWS.S3();
|
43 | this.cf = new AWS.CloudFormation();
|
44 | this.AWS = AWS;
|
45 | this.Lambda = Lambda;
|
46 | this.startTime = moment();
|
47 | }
|
48 |
|
49 | /**
|
50 | * Updates code of a deployed lambda function
|
51 | *
|
52 | * @param {String} name the name of the lambda function defined in config.yml
|
53 | * @return {Promise} returns the promise of an AWS response object
|
54 | */
|
55 | updateSingleLambda(name) {
|
56 | const lambda = new this.Lambda(this.config);
|
57 | return lambda.updateSingleLambda(name);
|
58 | }
|
59 |
|
60 | parseCF(cfFile) {
|
61 | const t = fs.readFileSync(cfFile, 'utf8');
|
62 |
|
63 | Handlebars.registerHelper('ToMd5', function(value) {
|
64 | if (value) {
|
65 | const md = forge.md.md5.create();
|
66 | md.update(value);
|
67 | return md.digest().toHex();
|
68 | }
|
69 | return value;
|
70 | });
|
71 |
|
72 | Handlebars.registerHelper('ToJson', function(value) {
|
73 | return JSON.stringify(value);
|
74 | });
|
75 |
|
76 | const template = Handlebars.compile(t);
|
77 | return template(this.config);
|
78 | }
|
79 |
|
80 | /**
|
81 | * Compiles a CloudFormation template in Yaml format.
|
82 | *
|
83 | * Reads the configuration yaml from `.kes/config.yml`.
|
84 | *
|
85 | * Writes the template to `.kes/cloudformation.yml`.
|
86 | *
|
87 | * Uses `.kes/cloudformation.template.yml` as the base template
|
88 | * for generating the final CF template.
|
89 | *
|
90 | * @return {Promise} returns the promise of an AWS response object
|
91 | */
|
92 | compileCF() {
|
93 | const lambda = new this.Lambda(this.config);
|
94 |
|
95 | return lambda.process().then((config) => {
|
96 | this.config = config;
|
97 | let cf;
|
98 |
|
99 | // if there is a template parse CF there first
|
100 | if (this.config.template) {
|
101 | let mainCF = this.parseCF(this.config.template.cfFile);
|
102 |
|
103 | // check if there is a CF over
|
104 | try {
|
105 | fs.lstatSync(this.config.cfFile);
|
106 | let overrideCF = this.parseCF(this.config.cfFile);
|
107 |
|
108 | // merge the the two
|
109 | cf = utils.mergeYamls(mainCF, overrideCF);
|
110 | }
|
111 | catch (e) {
|
112 | cf = mainCF;
|
113 | }
|
114 | }
|
115 | else {
|
116 | cf = this.parseCF(this.config.cfFile);
|
117 | }
|
118 |
|
119 | const destPath = path.join(this.config.kesFolder, 'cloudformation.yml');
|
120 | console.log(`Template saved to ${destPath}`);
|
121 | return fs.writeFileSync(destPath, cf);
|
122 | });
|
123 | }
|
124 |
|
125 | /**
|
126 | * This is just a wrapper around AWS s3.upload method.
|
127 | * It uploads a given string to a S3 object.
|
128 | *
|
129 | * @param {String} bucket the s3 bucket name
|
130 | * @param {String} key the path and name of the object
|
131 | * @param {String} body the content of the object
|
132 | * @returns {Promise} returns the promise of an AWS response object
|
133 | */
|
134 | uploadToS3(bucket, key, body) {
|
135 | return this.s3.upload({ Bucket: bucket, Key: key, Body: body })
|
136 | .promise()
|
137 | .then(() => console.log(`Uploaded: s3:
|
138 | }
|
139 |
|
140 | |
141 |
|
142 |
|
143 |
|
144 |
|
145 | uploadCF() {
|
146 |
|
147 | return this.compileCF().then(() => {
|
148 |
|
149 | try {
|
150 | fs.accessSync(path.join(this.config.kesFolder, 'cloudformation.yml'));
|
151 | }
|
152 | catch (e) {
|
153 | throw new Error('cloudformation.yml is missing.');
|
154 | }
|
155 |
|
156 |
|
157 | if (this.bucket) {
|
158 | return this.uploadToS3(
|
159 | this.bucket,
|
160 | `${this.stack}/cloudformation.yml`,
|
161 | fs.readFileSync(path.join(this.config.kesFolder, 'cloudformation.yml'))
|
162 | );
|
163 | }
|
164 | else {
|
165 | console.log('Skipping CF template upload because internal bucket value is not provided.');
|
166 | return true;
|
167 | }
|
168 | });
|
169 | }
|
170 |
|
171 | |
172 |
|
173 |
|
174 |
|
175 |
|
176 | cloudFormation() {
|
177 | const cfParams = [];
|
178 |
|
179 | if (this.config.params) {
|
180 | this.config.params.forEach((p) => {
|
181 | cfParams.push({
|
182 | ParameterKey: p.name,
|
183 | ParameterValue: p.value,
|
184 | UsePreviousValue: p.usePrevious || false
|
185 |
|
186 | });
|
187 | });
|
188 | }
|
189 |
|
190 | const capabilities = get(this.config, 'capabilities', []);
|
191 |
|
192 | const params = {
|
193 | StackName: this.stack,
|
194 | Parameters: cfParams,
|
195 | Capabilities: capabilities
|
196 | };
|
197 |
|
198 | if (this.config.tags) {
|
199 | params.Tags = Object.keys(this.config.tags).map((key) => ({
|
200 | Key: key,
|
201 | Value: this.config.tags[key]
|
202 | }));
|
203 | }
|
204 | else {
|
205 | params.Tags = [];
|
206 | }
|
207 |
|
208 | if (this.bucket) {
|
209 | params.TemplateURL = this.templateUrl;
|
210 | }
|
211 | else {
|
212 | params.TemplateBody = fs.readFileSync(path.join(this.config.kesFolder, 'cloudformation.yml')).toString();
|
213 | }
|
214 |
|
215 | let wait = 'stackUpdateComplete';
|
216 |
|
217 |
|
218 | return this.cf.describeStacks({ StackName: this.stack }).promise()
|
219 | .then(r => this.cf.updateStack(params).promise())
|
220 | .catch(e => {
|
221 | if (e.message.includes('does not exist')) {
|
222 | wait = 'stackCreateComplete';
|
223 | return this.cf.createStack(params).promise();
|
224 | }
|
225 | throw e;
|
226 | })
|
227 | .then(() => {
|
228 | console.log('Waiting for the CF operation to complete');
|
229 | return this.cf.waitFor(wait, { StackName: this.stack }).promise();
|
230 | })
|
231 | .then(r => console.log(`CF operation is in state of ${r.Stacks[0].StackStatus}`))
|
232 | .catch((e) => {
|
233 | const errorsWithDetail = [
|
234 | 'CREATE_FAILED',
|
235 | 'Resource is not in the state stackUpdateComplete',
|
236 | 'UPDATE_ROLLBACK_COMPLETE',
|
237 | 'ROLLBACK_COMPLETE',
|
238 | 'UPDATE_ROLLBACK_FAILED'
|
239 | ];
|
240 | const errorRequiresDetail = errorsWithDetail.filter(i => e.message.includes(i));
|
241 |
|
242 | if (e.message === 'No updates are to be performed.') {
|
243 | console.log(e.message);
|
244 | return e.message;
|
245 | }
|
246 | else if (errorRequiresDetail.length > 0) {
|
247 | console.log('There was an error deploying the CF stack');
|
248 | console.log(e.message);
|
249 |
|
250 |
|
251 | return this.cf.describeStackEvents({ StackName: this.stack }).promise();
|
252 | }
|
253 | else {
|
254 | console.log('There was an error deploying the CF stack');
|
255 | throw e;
|
256 | }
|
257 | })
|
258 | .then((r) => {
|
259 | if (r && r.StackEvents) {
|
260 | console.log('Here is the list of failures in chronological order:');
|
261 | r.StackEvents.forEach((s) => {
|
262 | if (s.ResourceStatus &&
|
263 | s.ResourceStatus.includes('FAILED') &&
|
264 | moment(s.Timestamp) > this.startTime) {
|
265 | console.log(`${s.Timestamp} | ` +
|
266 | `${s.ResourceStatus} | ` +
|
267 | `${s.ResourceType} | ` +
|
268 | `${s.LogicalResourceId} | ` +
|
269 | `${s.ResourceStatusReason}`);
|
270 | }
|
271 | });
|
272 | throw new Error('CloudFormation Deployment failed');
|
273 | }
|
274 | });
|
275 | }
|
276 |
|
277 | |
278 |
|
279 |
|
280 |
|
281 |
|
282 | validateTemplate() {
|
283 | console.log('Validating the template');
|
284 | const url = `https://s3.amazonaws.com/${this.bucket}/${this.stack}/cloudformation.yml`;
|
285 |
|
286 | const params = {};
|
287 |
|
288 | if (this.bucket) {
|
289 |
|
290 | params.TemplateURL = url;
|
291 |
|
292 | return this.uploadCF()
|
293 | .then(() => this.cf.validateTemplate(params).promise())
|
294 | .then(() => console.log('Template is valid'));
|
295 | }
|
296 | else {
|
297 | params.TemplateBody = fs.readFileSync(path.join(this.config.kesFolder, 'cloudformation.yml')).toString();
|
298 | }
|
299 |
|
300 |
|
301 | return this.cf.validateTemplate(params)
|
302 | .promise().then(() => console.log('Template is valid'));
|
303 | }
|
304 |
|
305 | |
306 |
|
307 |
|
308 |
|
309 |
|
310 | describeCF() {
|
311 | const cf = new AWS.CloudFormation();
|
312 |
|
313 | return cf.describeStacks({
|
314 | StackName: this.stack
|
315 | }).promise();
|
316 | }
|
317 |
|
318 | |
319 |
|
320 |
|
321 |
|
322 |
|
323 | opsStack() {
|
324 | return this.uploadCF().then(() => this.cloudFormation());
|
325 | }
|
326 |
|
327 | |
328 |
|
329 |
|
330 |
|
331 |
|
332 |
|
333 | upsertStack() {
|
334 | return this.opsStack();
|
335 | }
|
336 |
|
337 | |
338 |
|
339 |
|
340 |
|
341 |
|
342 |
|
343 | deployStack() {
|
344 | return this.opsStack();
|
345 | }
|
346 | |
347 |
|
348 |
|
349 |
|
350 |
|
351 | createStack() {
|
352 | return this.opsStack();
|
353 | }
|
354 |
|
355 | |
356 |
|
357 |
|
358 |
|
359 |
|
360 | updateStack() {
|
361 | return this.opsStack();
|
362 | }
|
363 | }
|
364 |
|
365 | module.exports = Kes;
|