Skip to content

Commit

Permalink
Template approval commands
Browse files Browse the repository at this point in the history
the command is used as an approval process for the cfn-template.yaml.
When a template is uploaded to an s3 bucket through the cmd, it appends
the suffix `.pending` to the filename. This then enables the
maintainers/admins to review the changes in the cfn-template.yaml before
using it for the stack.

When a template has already been approved (the filename is not appended
with `.pending`) it results to a noop.

When a template is changed, the filename is changed because of the md5
hashing.
  • Loading branch information
pauloancheta authored and jpb committed Jan 17, 2018
1 parent 7651f0e commit 16bf034
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 4 deletions.
13 changes: 11 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
"npm": ">=4.0"
},
"dependencies": {
"@types/diff": "^3.2.2",
"aws-sdk": "^2.167.0",
"bluebird": "^3.5.1",
"cli-color": "^1.2.0",
"dateformat": "^2.0.0",
"diff": "^3.4.0",
"handlebars": "^4.0.10",
"inquirer": "^3.3.0",
"jmespath": "0.15.0",
Expand All @@ -36,6 +38,7 @@
"request": "^2.83.0",
"request-promise-native": "^1.0.5",
"tmp": "0.0.31",
"ts-md5": "^1.2.2",
"tv4": "^1.3.0",
"winston": "^2.4.0",
"wrap-ansi": "^3.0.1",
Expand Down
173 changes: 173 additions & 0 deletions src/approval/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { S3 } from 'aws-sdk';
import { Md5 } from 'ts-md5/dist/md5';
import * as fs from 'fs';
import * as cli from 'cli-color';
import * as path from 'path';
import * as url from 'url';
import * as jsdiff from 'diff';
import * as inquirer from 'inquirer';

import { Arguments } from 'yargs';
import { loadStackArgs } from '../cfn/index';
import configureAWS from '../configureAWS';
import { logger } from '../logger';

export async function requestApproveTemplate(argv: Arguments): Promise<number> {
const stackArgs = await loadStackArgs(argv);

if (typeof stackArgs.ApprovedTemplateLocation === 'string' && stackArgs.ApprovedTemplateLocation.length > 0) {
const s3 = new S3();

await configureAWS(stackArgs.Profile, stackArgs.Region);

const templatePath = path.resolve(path.dirname(argv.argsfile), stackArgs.Template);
const cfnTemplate = await fs.readFileSync(templatePath);

const s3Url = url.parse(stackArgs.ApprovedTemplateLocation);
const s3Path = s3Url.path ? s3Url.path : '';
const s3Bucket = s3Url.hostname ? s3Url.hostname : '';

const fileName = new Md5().appendStr(cfnTemplate.toString()).end().toString();
const fullFileName = `${fileName}${path.extname(stackArgs.Template)}.pending`;

const hashedKey = path.join(s3Path.substring(1), fullFileName);

try {
await s3.headObject({
Bucket: s3Bucket,
Key: hashedKey
}).promise();

logSuccess(`👍 Your template has already been approved`);
} catch (e) {
if (e.code === 'NotFound') {
await s3.putObject({
Body: cfnTemplate,
Bucket: s3Bucket,
Key: hashedKey
}).promise();

logSuccess(`Successfully uploaded the cloudformation template to ${stackArgs.ApprovedTemplateLocation}`);
logSuccess(`Approve template with:\n iidy approve-template s3://${s3Bucket}/${hashedKey}`);
} else {
throw new Error(e);
}
}

return 0;
} else {
logError(`\`ApprovedTemplateLocation\` must be provided in ${argv.argsfile}`);
return 1;
}

}

export async function approveTemplate(argv: Arguments): Promise<number> {
await configureAWS(argv.profile, 'us-east-1');
const s3 = new S3();

const s3Url = url.parse(argv.filename);
const s3Path = s3Url.path ? s3Url.path.replace(/^\//, '') : '';
const s3Bucket = s3Url.hostname ? s3Url.hostname : '';

const bucketDir = path.parse(s3Path).dir;

try {
await s3.headObject({
Bucket: s3Bucket,
Key: s3Path.replace(/\.pending$/, '')
}).promise();

logSuccess(`👍 The template has already been approved`);
} catch (e) {
if (e.code === 'NotFound') {

const pendingTemplate = await s3.getObject({
Bucket: s3Bucket,
Key: s3Path
}).promise().then((template) => template.Body);

const previouslyApprovedTemplate = await s3.getObject({
Bucket: s3Bucket,
Key: `${bucketDir}/latest`
}).promise()
.then((template) => template.Body)
.catch((e) => {
if (e.code !== 'NoSuchKey') {
return Promise.reject(e);
}
return Buffer.from('');
});

const diff = jsdiff.diffLines(
previouslyApprovedTemplate!.toString(),
pendingTemplate!.toString(),
);
let colorizedString = '';

diff.forEach(function(part) {
if (part.added) {
colorizedString = colorizedString + cli.green(part.value);
} else if (part.removed) {
colorizedString = colorizedString + cli.red(part.value);
}
});
console.log(colorizedString);

const resp = await inquirer.prompt(
{
name: 'confirmed',
type: 'confirm', default: false,
message: `Do these changes look good for you?`
});

if (resp.confirmed) {
// create a new pending file
await s3.putObject({
Body: pendingTemplate,
Bucket: s3Bucket,
Key: s3Path.replace(/\.pending$/, '')
}).promise();
logDebug('Created a new cfn-template');

// copy to latest
await s3.putObject({
Body: pendingTemplate,
Bucket: s3Bucket,
Key: `${bucketDir}/latest`
}).promise();
logDebug('Updated latest');

// delete the old pending file
await s3.deleteObject({
Bucket: s3Bucket,
Key: s3Path
}).promise();
logDebug('Deleted pending file.');

console.log();
logSuccess(`Template has been successfully approved!`);
return 0;
} else {
return 0;
}

} else {
throw new Error(e);
}
}

return 0;
}

function logSuccess(text: string) {
logger.info(cli.green(text));
}

function logDebug(text: string) {
logger.debug(text);
}

function logError(text: string) {
logger.error(cli.red(text));
}
2 changes: 2 additions & 0 deletions src/cfn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type CfnOperation = 'CREATE_STACK' | 'UPDATE_STACK' | 'CREATE_CHANGESET'
export type StackArgs = {
StackName: string
Template: string
ApprovedTemplateLocation?: string
Region?: AWSRegion
Profile?: string
Capabilities?: aws.CloudFormation.Capabilities
Expand Down Expand Up @@ -1515,6 +1516,7 @@ export async function convertStackToIIDY(argv: Arguments): Promise<number> {
const stackArgs: StackArgs = {
Template: './cfn-template.yaml',
StackName: StackNameArg,
ApprovedTemplateLocation: undefined,
Parameters,
Tags,
StackPolicy: './stack-policy.json',
Expand Down
26 changes: 25 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface CfnStackCommands {

export interface MiscCommands {
initStackArgs: Handler;
requestApproveTemplate: Handler;
approveTemplate: Handler;
renderMain: Handler;
demoMain: Handler;
convertStackToIIDY: Handler;
Expand All @@ -82,7 +84,7 @@ export interface Commands extends CfnStackCommands, MiscCommands {};
// faster. See the git history of this file to see the non-lazy form.
// Investigate this again if we can use babel/webpack to shrinkwrap

type LazyLoadModules = './cfn' | './preprocess' | './demo' | './render' | './initStackArgs';
type LazyLoadModules = './cfn' | './preprocess' | './demo' | './render' | './initStackArgs' | './approval';
const lazyLoad = (fnname: keyof Commands, modName: LazyLoadModules = './cfn'): Handler =>
(args) => {
// note, the requires must be literal for `pkg` to find the modules to include
Expand All @@ -96,6 +98,8 @@ const lazyLoad = (fnname: keyof Commands, modName: LazyLoadModules = './cfn'): H
return require('./render')[fnname](args);
} else if (modName === './initStackArgs') {
return require('./initStackArgs')[fnname](args);
} else if (modName === './approval') {
return require('./approval')[fnname](args);
}
}

Expand All @@ -119,6 +123,8 @@ const lazy: Commands = {
demoMain: lazyLoad('demoMain', './demo'),
convertStackToIIDY: lazyLoad('convertStackToIIDY'),
initStackArgs: lazyLoad('initStackArgs', './initStackArgs'),
requestApproveTemplate: lazyLoad('requestApproveTemplate', './approval'),
approveTemplate: lazyLoad('approveTemplate', './approval'),
// TODO example command pull down an examples dir

};
Expand Down Expand Up @@ -359,6 +365,24 @@ export function buildArgs(commands = lazy, wrapMainHandler = wrapCommandHandler)
}),
wrapMainHandler(commands.convertStackToIIDY))

.command(
'request-approve-template <argsfile>',
description('request approval of cfn-template'),
(args) => args
.demandCommand(0,0),
wrapMainHandler(commands.requestApproveTemplate))

.command(
'approve-template <filename>',
description('approve pending cfn-template'),
(args) => args
.demandCommand(0,0)
.option('profile', {
type: 'string', default: null,
description: description('aws profile')
}),
wrapMainHandler(commands.approveTemplate))

.command(
'init-stack-args',
description('initialize stack-args.yaml and cfn-template.yaml'),
Expand Down
7 changes: 6 additions & 1 deletion src/initStackArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ export async function initStackArgs(argv: Arguments): Promise<number> {
Type: "AWS::CloudFormation::WaitConditionHandle"
Properties: {}`;

const stackArgs = `# REQUIRED SETTINGS:
const stackArgs = `# INITIALIZED STACK ARGS
# $imports:
# environment: env:ENVIRONMENT
# REQUIRED SETTINGS:
StackName: <string>
Template: ./cfn-template.yaml
# optionally you can use the yaml pre-processor by prepending 'render:' to the filename
# Template: render:<local file path or s3 path>
# ApprovedTemplateLocation: s3://your-bucket/
# OPTIONAL SETTINGS:
# Region: <aws region name>
Expand Down

0 comments on commit 16bf034

Please sign in to comment.