├── .nvmrc ├── index.js ├── .gitignore ├── __tests__ ├── templates │ └── functions │ │ ├── transform.yml │ │ ├── import-value.yml │ │ ├── base64.yml │ │ ├── equals.yml │ │ ├── not.yml │ │ ├── ref.yml │ │ ├── or.yml │ │ ├── and.yml │ │ ├── join.yml │ │ ├── split.yml │ │ ├── get-azs.yml │ │ ├── select.yml │ │ ├── cidr.yml │ │ ├── get-att.yml │ │ ├── find-in-map.yml │ │ ├── sub.yml │ │ └── if.yml └── tasks │ ├── Build.test.js │ └── __snapshots__ │ └── Build.test.js.snap ├── jest.config.js ├── commands ├── init.js ├── build.js ├── delete.js ├── artifacts.js └── deploy.js ├── docker └── Dockerfile ├── src ├── Middleware.js ├── Task.js ├── Logger.js ├── utils │ └── intrinsic-functions-schema.js ├── Runner.js ├── tasks │ ├── Delete.js │ ├── Deploy.js │ ├── Init.js │ ├── Build.js │ └── Artifacts.js └── ApiTask.js ├── .eslintrc.js ├── .github └── workflows │ ├── nodejs.yaml │ └── docker-hub.yml ├── LICENSE ├── package.json ├── bin └── cfpack.js ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /example/appsync-with-lambdas/node_modules/ 3 | /example/appsync-with-lambdas/cloudformation.json 4 | -------------------------------------------------------------------------------- /__tests__/templates/functions/transform.yml: -------------------------------------------------------------------------------- 1 | TransformTest: !Transform { Name: "AWS::Include", Parameters: { Location: "us-east-1" } } 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | exports.testEnvironment = 'node'; 2 | 3 | exports.testMatch = [ 4 | '/__tests__/**/[^_]*.js', 5 | ]; 6 | 7 | exports.verbose = true; 8 | -------------------------------------------------------------------------------- /__tests__/templates/functions/import-value.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | Service: 3 | Type: AWS::ECS::Service 4 | Properties: 5 | Cluster: !ImportValue ClusterName 6 | -------------------------------------------------------------------------------- /__tests__/templates/functions/base64.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-base64.html#w2ab1c21c24c12c11 2 | Fn::Base64: AWS CloudFormation 3 | -------------------------------------------------------------------------------- /__tests__/templates/functions/equals.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c35b8 2 | UseProdCondition: 3 | !Equals [!Ref EnvironmentType, prod] -------------------------------------------------------------------------------- /__tests__/templates/functions/not.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c41b8 2 | MyNotCondition: 3 | !Not [!Equals [!Ref EnvironmentType, prod]] 4 | -------------------------------------------------------------------------------- /__tests__/templates/functions/ref.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html#ref-example 2 | MyEIP: 3 | Type: "AWS::EC2::EIP" 4 | Properties: 5 | InstanceId: !Ref MyEC2Instance 6 | -------------------------------------------------------------------------------- /commands/init.js: -------------------------------------------------------------------------------- 1 | const Runner = require('../src/Runner'); 2 | const InitTask = require('../src/tasks/Init'); 3 | 4 | module.exports = (args) => { 5 | const runner = new Runner(args); 6 | 7 | runner.use(new InitTask()); 8 | runner.execute(); 9 | }; 10 | -------------------------------------------------------------------------------- /__tests__/templates/functions/or.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c43b8 2 | MyOrCondition: 3 | !Or [!Equals [sg-mysggroup, !Ref ASecurityGroup], Condition: SomeOtherCondition] 4 | -------------------------------------------------------------------------------- /__tests__/templates/functions/and.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c33b8 2 | MyAndCondition: !And 3 | - !Equals ["sg-mysggroup", !Ref ASecurityGroup] 4 | - !Condition SomeOtherCondition 5 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | ARG CFPACK_VERSION 4 | 5 | ENV CFPACK_VERSION $CFPACK_VERSION 6 | ENV NODE_ENV production 7 | 8 | RUN npm i -g "cfpack.js@${CFPACK_VERSION}" && \ 9 | npm cache clean --force 10 | 11 | WORKDIR /var/cfpack 12 | 13 | ENTRYPOINT [ "cfpack" ] 14 | -------------------------------------------------------------------------------- /commands/build.js: -------------------------------------------------------------------------------- 1 | const Runner = require('../src/Runner'); 2 | const BuildTask = require('../src/tasks/Build'); 3 | 4 | module.exports = (args) => { 5 | const runner = new Runner(args); 6 | 7 | runner.loadConfig(); 8 | runner.setupLogs(); 9 | runner.use(new BuildTask()); 10 | runner.execute(); 11 | }; 12 | -------------------------------------------------------------------------------- /commands/delete.js: -------------------------------------------------------------------------------- 1 | const Runner = require('../src/Runner'); 2 | const DeleteTask = require('../src/tasks/Delete'); 3 | 4 | module.exports = (args) => { 5 | const runner = new Runner(args); 6 | 7 | runner.loadConfig(); 8 | runner.setupLogs(); 9 | runner.use(new DeleteTask()); 10 | runner.execute(); 11 | }; 12 | -------------------------------------------------------------------------------- /__tests__/templates/functions/join.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-join.html#intrinsic-function-reference-join-example2 2 | 3 | JoinWithParams: !Join 4 | - '' 5 | - - 'arn:' 6 | - !Ref Partition 7 | - ':s3:::elasticbeanstalk-*-' 8 | - !Ref 'AWS::AccountId' 9 | -------------------------------------------------------------------------------- /commands/artifacts.js: -------------------------------------------------------------------------------- 1 | const Runner = require('../src/Runner'); 2 | const ArtifactsTask = require('../src/tasks/Artifacts'); 3 | 4 | module.exports = (args) => { 5 | const runner = new Runner(args); 6 | 7 | runner.loadConfig(); 8 | runner.setupLogs(); 9 | runner.use(new ArtifactsTask()); 10 | runner.execute(); 11 | }; 12 | -------------------------------------------------------------------------------- /src/Middleware.js: -------------------------------------------------------------------------------- 1 | class Middleware { 2 | 3 | use(fn) { 4 | const self = this; 5 | self.go = ((stack) => (input, next) => { 6 | stack.call(self, input, (output) => { 7 | fn.call(self, output, next.bind(self)); 8 | }); 9 | })(self.go); 10 | } 11 | 12 | /* eslint-disable class-methods-use-this */ 13 | go(input, next) { 14 | next(input); 15 | } 16 | /* eslint-enable */ 17 | 18 | } 19 | 20 | module.exports = Middleware; 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | exports.env = { 2 | es2020: true, 3 | node: true, 4 | jest: true, 5 | }; 6 | 7 | exports.extends = [ 8 | 'airbnb-base', 9 | 'plugin:import/errors', 10 | 'plugin:import/warnings', 11 | ]; 12 | 13 | exports.plugins = [ 14 | 'import', 15 | ]; 16 | 17 | exports.rules = { 18 | 'no-plusplus': 0, 19 | 'no-tabs': 0, 20 | indent: [2, 'tab', { SwitchCase: 1 }], 21 | 'padded-blocks': [2, { classes: 'always' }], 22 | 'global-require': 0, 23 | 'import/no-dynamic-require': 0, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yaml: -------------------------------------------------------------------------------- 1 | name: Node.js 2 | on: 3 | - push 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-node@v1 10 | with: 11 | node-version: 14.x 12 | - name: Install dependencies 13 | run: npm ci --ignore-scripts 14 | env: 15 | CI: true 16 | - name: Lint codebase 17 | run: npm run lint 18 | - name: Run tests 19 | run: npm test 20 | -------------------------------------------------------------------------------- /commands/deploy.js: -------------------------------------------------------------------------------- 1 | const Runner = require('../src/Runner'); 2 | const BuildTask = require('../src/tasks/Build'); 3 | const ArtifactsTask = require('../src/tasks/Artifacts'); 4 | const DeployTask = require('../src/tasks/Deploy'); 5 | 6 | module.exports = (args) => { 7 | const runner = new Runner(args); 8 | 9 | runner.loadConfig(); 10 | runner.setupLogs(); 11 | 12 | runner.use(new BuildTask()); 13 | runner.use(new ArtifactsTask()); 14 | runner.use(new DeployTask()); 15 | 16 | runner.execute(); 17 | }; 18 | -------------------------------------------------------------------------------- /__tests__/templates/functions/split.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-split.html#w2ab1c21c24c55c13b4 2 | SimpleList: !Split [ "|" , "a|b|c" ] 3 | 4 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-split.html#w2ab1c21c24c55c13b6 5 | WithEmptyValues: !Split [ "|" , "a||c|" ] 6 | 7 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-split.html#w2ab1c21c24c55c13b8 8 | ImportedOutputValue: !Select [2, !Split [",", !ImportValue AccountSubnetIDs]] 9 | -------------------------------------------------------------------------------- /__tests__/templates/functions/get-azs.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getavailabilityzones.html#w2ab1c21c24c36c17b2 2 | evaluateRegions: 3 | - Fn::GetAZs: "" 4 | - Fn::GetAZs: 5 | Ref: "AWS::Region" 6 | - Fn::GetAZs: us-east-1 7 | 8 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getavailabilityzones.html#w2ab1c21c24c36c17b4 9 | mySubnet: 10 | Type: "AWS::EC2::Subnet" 11 | Properties: 12 | VpcId: 13 | !Ref VPC 14 | CidrBlock: 10.0.0.0/24 15 | AvailabilityZone: 16 | Fn::Select: 17 | - 0 18 | - Fn::GetAZs: "" 19 | -------------------------------------------------------------------------------- /__tests__/templates/functions/select.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-select.html#w2ab1c21c24c51c13b2 2 | Basic: !Select [ "1", [ "apples", "grapes", "oranges", "mangoes" ] ] 3 | 4 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-select.html#w2ab1c21c24c51c13b4 5 | Subnet0: 6 | Type: "AWS::EC2::Subnet" 7 | Properties: 8 | VpcId: !Ref VPC 9 | CidrBlock: !Select [ 0, !Ref DbSubnetIpBlocks ] 10 | 11 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-select.html#w2ab1c21c24c51c13b6 12 | AvailabilityZone: !Select 13 | - 0 14 | - Fn::GetAZs: !Ref 'AWS::Region' 15 | -------------------------------------------------------------------------------- /src/Task.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | class Task { 4 | 5 | constructor() { 6 | const token = []; 7 | for (let i = 0; i < 4; i++) { 8 | token.push(crypto.randomBytes(4).toString('hex')); 9 | } 10 | 11 | this.taskUUID = token.join('-'); 12 | this.input = {}; 13 | this.output = {}; 14 | this.options = {}; 15 | } 16 | 17 | setOptions(options) { 18 | this.options = options; 19 | } 20 | 21 | setLogger(log) { 22 | this.log = log; 23 | } 24 | 25 | setData(input) { 26 | this.input = input; 27 | } 28 | 29 | /* eslint-disable class-methods-use-this */ 30 | run() { 31 | throw new Error('The run method is not implemented.'); 32 | } 33 | /* eslint-enable */ 34 | 35 | } 36 | 37 | module.exports = Task; 38 | -------------------------------------------------------------------------------- /__tests__/templates/functions/cidr.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-cidr.html#intrinsic-function-reference-cidr-example2 2 | Resources: 3 | ExampleVpc: 4 | Type: AWS::EC2::VPC 5 | Properties: 6 | CidrBlock: "10.0.0.0/16" 7 | IPv6CidrBlock: 8 | Type: AWS::EC2::VPCCidrBlock 9 | Properties: 10 | AmazonProvidedIpv6CidrBlock: true 11 | VpcId: !Ref ExampleVpc 12 | ExampleSubnet: 13 | Type: AWS::EC2::Subnet 14 | DependsOn: IPv6CidrBlock 15 | Properties: 16 | AssignIpv6AddressOnCreation: true 17 | CidrBlock: !Select [ 0, !Cidr [ !GetAtt ExampleVpc.CidrBlock, 1, 8 ]] 18 | Ipv6CidrBlock: !Select [ 0, !Cidr [ !Select [ 0, !GetAtt ExampleVpc.Ipv6CidrBlocks], 1, 64 ]] 19 | VpcId: !Ref ExampleVpc 20 | -------------------------------------------------------------------------------- /__tests__/templates/functions/get-att.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html#intrinsic-function-reference-getatt-example2 2 | AWSTemplateFormatVersion: 2010-09-09 3 | Resources: 4 | myELB: 5 | Type: AWS::ElasticLoadBalancing::LoadBalancer 6 | Properties: 7 | AvailabilityZones: 8 | - eu-west-1a 9 | Listeners: 10 | - LoadBalancerPort: '80' 11 | InstancePort: '80' 12 | Protocol: HTTP 13 | myELBIngressGroup: 14 | Type: AWS::EC2::SecurityGroup 15 | Properties: 16 | GroupDescription: ELB ingress group 17 | SecurityGroupIngress: 18 | - IpProtocol: tcp 19 | FromPort: '80' 20 | ToPort: '80' 21 | SourceSecurityGroupOwnerId: !GetAtt myELB.SourceSecurityGroup.OwnerAlias 22 | SourceSecurityGroupName: !GetAtt myELB.SourceSecurityGroup.GroupName 23 | -------------------------------------------------------------------------------- /__tests__/tasks/Build.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const BuildTask = require('../../src/tasks/Build'); 3 | 4 | describe('BuildTask', () => { 5 | const templates = [ 6 | 'and.yml', 7 | 'equals.yml', 8 | 'if.yml', 9 | 'not.yml', 10 | 'or.yml', 11 | 'base64.yml', 12 | 'cidr.yml', 13 | 'find-in-map.yml', 14 | 'get-att.yml', 15 | 'get-azs.yml', 16 | 'import-value.yml', 17 | 'join.yml', 18 | 'select.yml', 19 | 'split.yml', 20 | 'sub.yml', 21 | 'transform.yml', 22 | 'ref.yml', 23 | // @todo: add template to test !Sub function 24 | ]; 25 | 26 | test.each(templates)('::processTemplate --> {%s}', (template) => { 27 | const task = new BuildTask(); 28 | const filename = path.resolve(__dirname, '../templates/functions/', template); 29 | const result = task.processTemplate(filename); 30 | expect(task.lastError).toBeFalsy(); 31 | expect(JSON.stringify(result, '', 2)).toMatchSnapshot(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /__tests__/templates/functions/find-in-map.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-findinmap.html#w2ab1c21c24c26c11 2 | Mappings: 3 | RegionMap: 4 | us-east-1: 5 | HVM64: "ami-0ff8a91507f77f867" 6 | HVMG2: "ami-0a584ac55a7631c0c" 7 | us-west-1: 8 | HVM64: "ami-0bdb828fd58c52235" 9 | HVMG2: "ami-066ee5fd4a9ef77f1" 10 | eu-west-1: 11 | HVM64: "ami-047bb4163c506cd98" 12 | HVMG2: "ami-31c2f645" 13 | ap-southeast-1: 14 | HVM64: "ami-08569b978cc4dfa10" 15 | HVMG2: "ami-0be9df32ae9f92309" 16 | ap-northeast-1: 17 | HVM64: "ami-06cd52961ce9f0d85" 18 | HVMG2: "ami-053cdd503598e4a9d" 19 | Resources: 20 | myEC2Instance: 21 | Type: "AWS::EC2::Instance" 22 | Properties: 23 | ImageId: !FindInMap 24 | - RegionMap 25 | - !Ref 'AWS::Region' 26 | - HVM64 27 | InstanceType: m1.small 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Eugene Manuilov 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const ora = require('ora'); 3 | 4 | class Logger { 5 | 6 | constructor(silent, verbose) { 7 | this.silent = silent; 8 | this.verbose = verbose; 9 | 10 | this.ora = ora({ 11 | spinner: 'dots', 12 | color: 'white', 13 | hideCursor: true, 14 | }); 15 | } 16 | 17 | start() { 18 | if (!this.silent) { 19 | this.ora.start(); 20 | } 21 | } 22 | 23 | stop() { 24 | if (this.ora.isSpinning) { 25 | this.ora.stop(); 26 | } 27 | } 28 | 29 | sayIf(message, condition) { 30 | if (condition) { 31 | this.stop(); 32 | process.stdout.write(`${message}\n`); 33 | this.start(); 34 | } 35 | } 36 | 37 | message(message) { 38 | this.sayIf(chalk.green(message), !this.silent); 39 | } 40 | 41 | info(message) { 42 | this.sayIf(chalk.white(message), !this.silent && this.verbose); 43 | } 44 | 45 | warning(message) { 46 | this.sayIf(chalk.yellow(message), !this.silent && this.verbose); 47 | } 48 | 49 | error(message, exit = true) { 50 | this.stop(); 51 | process.stderr.write(chalk.red(`${message}\n`)); 52 | if (exit) { 53 | process.exit(1); 54 | } 55 | } 56 | 57 | } 58 | 59 | module.exports = Logger; 60 | -------------------------------------------------------------------------------- /__tests__/templates/functions/sub.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html#w2ab1c25c28c59c11b4 2 | Name: !Sub 3 | - www.${Domain} 4 | - { Domain: !Ref RootDomainName } 5 | 6 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html#w2ab1c25c28c59c11b6 7 | Arn: !Sub 'arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:vpc/${vpc}' 8 | 9 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html#w2ab1c25c28c59c11b8 10 | UserData: 11 | Fn::Base64: 12 | !Sub | 13 | #!/bin/bash -xe 14 | yum update -y aws-cfn-bootstrap 15 | /opt/aws/bin/cfn-init -v --stack ${AWS::StackName} --resource LaunchConfig --configsets wordpress_install --region ${AWS::Region} 16 | /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource WebServerGroup --region ${AWS::Region} 17 | 18 | # https://github.com/eugene-manuilov/cfpack/issues/12 19 | StrapiCMSAssetBucket: 20 | Type: "AWS::S3::Bucket" 21 | DeletionPolicy: Retain 22 | Properties: 23 | BucketName: !Sub 24 | - "my-test-strapi-assets-${EnvType}" 25 | - EnvType: !Ref EnvType 26 | -------------------------------------------------------------------------------- /src/utils/intrinsic-functions-schema.js: -------------------------------------------------------------------------------- 1 | const yaml = require('js-yaml'); 2 | 3 | const types = []; 4 | 5 | const specialTypes = { 6 | Ref: 'Ref', 7 | Condition: 'Condition', 8 | }; 9 | 10 | const schema = { 11 | scalar: [ 12 | 'Base64', 13 | 'GetAtt', 14 | 'GetAZs', 15 | 'ImportValue', 16 | 'Sub', 17 | 'Ref', 18 | 'Condition', 19 | ], 20 | sequence: [ 21 | 'Cidr', 22 | 'FindInMap', 23 | 'Join', 24 | 'Select', 25 | 'And', 26 | 'Equals', 27 | 'If', 28 | 'Not', 29 | 'Or', 30 | 'Split', 31 | 'Sub', 32 | ], 33 | mapping: [ 34 | 'Transform', 35 | ], 36 | }; 37 | 38 | const constructs = { 39 | GetAtt(data) { 40 | const parts = data.split('.'); 41 | return { 42 | 'Fn::GetAtt': [ 43 | parts[0], 44 | parts.slice(1).join('.'), 45 | ], 46 | }; 47 | }, 48 | }; 49 | 50 | Object.keys(schema).forEach((kind) => { 51 | schema[kind].forEach((name) => { 52 | const fn = specialTypes[name] || `Fn::${name}`; 53 | const params = { 54 | kind, 55 | construct: constructs[name] || ((data) => ({ [fn]: data })), 56 | }; 57 | 58 | const type = new yaml.Type(`!${name}`, params); 59 | types.push(type); 60 | }); 61 | }); 62 | 63 | module.exports = yaml.Schema.create(types); 64 | -------------------------------------------------------------------------------- /src/Runner.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const chalk = require('chalk'); 3 | 4 | const Logger = require('./Logger'); 5 | const Middleware = require('./Middleware'); 6 | 7 | class Runner { 8 | 9 | constructor(args) { 10 | this.args = args; 11 | this.middleware = new Middleware(); 12 | } 13 | 14 | loadConfig() { 15 | const { args } = this; 16 | const configPath = path.isAbsolute(args.config) 17 | ? args.config 18 | : path.resolve(process.cwd(), args.config); 19 | 20 | try { 21 | const config = require(configPath); 22 | this.args = { ...config, ...args }; 23 | } catch (e) { 24 | process.stderr.write(chalk.red('Config file hasn\'t been found.\n')); 25 | process.exit(1); 26 | } 27 | } 28 | 29 | setupLogs(start = true) { 30 | this.log = new Logger(this.args.silent, this.args.verbose); 31 | if (start) { 32 | this.log.start(); 33 | } 34 | } 35 | 36 | use(task) { 37 | this.middleware.use((data, next) => { 38 | task.setOptions(this.args); 39 | task.setLogger(this.log); 40 | task.setData(data); 41 | 42 | task.run(next); 43 | }); 44 | 45 | return this; 46 | } 47 | 48 | execute() { 49 | this.middleware.go({}, () => { 50 | if (this.log) { 51 | this.log.stop(); 52 | } 53 | }); 54 | } 55 | 56 | } 57 | 58 | module.exports = Runner; 59 | -------------------------------------------------------------------------------- /__tests__/templates/functions/if.yml: -------------------------------------------------------------------------------- 1 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c39b8b4 2 | SecurityGroups: 3 | - !If [CreateNewSecurityGroup, !Ref NewSecurityGroup, !Ref ExistingSecurityGroup] 4 | 5 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c39b8b6 6 | Outputs: 7 | SecurityGroupId: 8 | Description: Group ID of the security group used. 9 | Value: !If [CreateNewSecurityGroup, !Ref NewSecurityGroup, !Ref ExistingSecurityGroup] 10 | 11 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c39b8b8 12 | MyDB: 13 | Type: "AWS::RDS::DBInstance" 14 | Properties: 15 | AllocatedStorage: 5 16 | DBInstanceClass: db.m1.small 17 | Engine: MySQL 18 | EngineVersion: 5.5 19 | MasterUsername: !Ref DBUser 20 | MasterUserPassword: !Ref DBPassword 21 | DBParameterGroupName: !Ref MyRDSParamGroup 22 | DBSnapshotIdentifier: 23 | !If [UseDBSnapshot, !Ref DBSnapshotName, !Ref "AWS::NoValue"] 24 | 25 | # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#w2ab1c21c24c21c39b8c10 26 | UpdatePolicy: 27 | AutoScalingRollingUpdate: 28 | !If 29 | - RollingUpdates 30 | - 31 | MaxBatchSize: 2 32 | MinInstancesInService: 2 33 | PauseTime: PT0M30S 34 | - !Ref "AWS::NoValue" 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfpack.js", 3 | "version": "1.5.2", 4 | "description": "A CLI tool that helps to build CloudFormation template using multiple smaller templates.", 5 | "author": "Eugene Manuilov ", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "bin": { 9 | "cfpack": "./bin/cfpack.js" 10 | }, 11 | "homepage": "https://github.com/eugene-manuilov/cfpack#readme", 12 | "bugs": { 13 | "url": "https://github.com/eugene-manuilov/cfpack/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/eugene-manuilov/cfpack.git" 18 | }, 19 | "engines": { 20 | "node": ">=8.6.0" 21 | }, 22 | "keywords": [ 23 | "aws", 24 | "cloudformation", 25 | "cf" 26 | ], 27 | "files": [ 28 | "*.md", 29 | "LICENSE", 30 | "bin/", 31 | "commands/", 32 | "src/" 33 | ], 34 | "scripts": { 35 | "cfpack": "./bin/cfpack.js", 36 | "test": "jest", 37 | "lint": "eslint src commands bin", 38 | "format": "npm run lint --silent -- --fix" 39 | }, 40 | "dependencies": { 41 | "aws-sdk": "^2.1014.0", 42 | "chalk": "^3.0.0", 43 | "glob": "^7.2.0", 44 | "js-yaml": "^3.14.1", 45 | "node-native-zip": "^1.1.0", 46 | "ora": "^4.1.1", 47 | "update-check": "^1.5.4", 48 | "yargs": "^15.4.1", 49 | "yargs-interactive": "^3.0.0" 50 | }, 51 | "devDependencies": { 52 | "eslint": "^8.1.0", 53 | "eslint-config-airbnb-base": "^14.2.1", 54 | "eslint-plugin-import": "^2.25.2", 55 | "jest": "^27.3.1", 56 | "lodash": "^4.17.21" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/tasks/Delete.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const chalk = require('chalk'); 3 | 4 | const ApiTask = require('../ApiTask'); 5 | 6 | class DeleteTask extends ApiTask { 7 | 8 | run(next) { 9 | this.log.message('Deleting stack...'); 10 | 11 | const { stack } = this.options; 12 | const params = { StackName: stack.name }; 13 | 14 | this.cloudformation = new AWS.CloudFormation({ 15 | apiVersion: '2010-05-15', 16 | region: stack.region, 17 | }); 18 | 19 | this.log.info(`├─ Checking whether ${stack.name} stack exists...`); 20 | this.cloudformation.describeStacks(params, (err, data) => { 21 | if (err) { 22 | this.log.error(`└─ ${err.code}: ${err.message}`); 23 | next(); 24 | } else { 25 | this.stackId = data.Stacks[0].StackId; 26 | this.deleteStack(next); 27 | } 28 | }); 29 | } 30 | 31 | deleteStack(next) { 32 | const { stack } = this.options; 33 | const params = { 34 | StackName: stack.name, 35 | ClientRequestToken: this.taskUUID, 36 | }; 37 | 38 | this.cloudformation.deleteStack(params, (err, data) => { 39 | if (err) { 40 | this.log.error(`${err.code}: ${err.message}`); 41 | } else { 42 | this.log.info('├─ Stack is deleting...'); 43 | this.log.info(`└─ RequestId: ${chalk.magenta(data.ResponseMetadata.RequestId)}\n`); 44 | 45 | this.startPollingEvents(this.stackId); 46 | this.cloudformation.waitFor('stackDeleteComplete', { StackName: this.stackId }, () => { 47 | this.stopPollingEvents(); 48 | this.log.message('Stack has been deleted.'); 49 | next(); 50 | }); 51 | } 52 | }); 53 | } 54 | 55 | } 56 | 57 | module.exports = DeleteTask; 58 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub.yml: -------------------------------------------------------------------------------- 1 | name: Docker Hub 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | publish: 10 | name: Publish Image 11 | runs-on: ubuntu-latest 12 | if: github.event.action == 'published' 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: docker/setup-qemu-action@v1 16 | - uses: docker/setup-buildx-action@v1 17 | - uses: actions/cache@v2 18 | with: 19 | path: /tmp/.buildx-cache 20 | key: ${{ runner.os }}-buildx-${{ github.sha }} 21 | restore-keys: | 22 | ${{ runner.os }}-buildx- 23 | - uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 27 | - name: prepare 28 | id: prep 29 | env: 30 | CFPACK_VERSION: ${{ github.event.release.name }} 31 | run: | 32 | DOCKER_IMAGE=eugenemanuilov/cfpack 33 | VERSION=${CFPACK_VERSION} 34 | 35 | TAGS="${DOCKER_IMAGE}:latest" 36 | TAGS="$TAGS,${DOCKER_IMAGE}:${CFPACK_VERSION}" 37 | TAGS="$TAGS,${DOCKER_IMAGE}:${CFPACK_VERSION%%.*}" 38 | 39 | echo "::set-output name=version::${VERSION}" 40 | echo "::set-output name=tags::${TAGS}" 41 | echo "::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ')" 42 | - uses: docker/build-push-action@v2 43 | with: 44 | cache-from: type=local,src=/tmp/.buildx-cache 45 | cache-to: type=local,dest=/tmp/.buildx-cache 46 | push: true 47 | context: ./docker 48 | file: ./docker/Dockerfile 49 | tags: ${{ steps.prep.outputs.tags }} 50 | labels: | 51 | org.opencontainers.image.title=${{ github.event.repository.name }} 52 | org.opencontainers.image.description=${{ github.event.repository.description }} 53 | org.opencontainers.image.url=${{ github.event.repository.html_url }} 54 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 55 | org.opencontainers.image.version=${{ steps.prep.outputs.version }} 56 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 57 | org.opencontainers.image.revision=${{ github.sha }} 58 | org.opencontainers.image.licenses=${{ github.event.repository.license.spdx_id }} 59 | -------------------------------------------------------------------------------- /src/tasks/Deploy.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | const chalk = require('chalk'); 3 | 4 | const ApiTask = require('../ApiTask'); 5 | 6 | class DeployTask extends ApiTask { 7 | 8 | run(next) { 9 | this.log.message('Deploying template file...'); 10 | 11 | const { stack } = this.options; 12 | this.cloudformation = new AWS.CloudFormation({ 13 | apiVersion: '2010-05-15', 14 | region: stack.region, 15 | }); 16 | 17 | this.log.info(`├─ Checking whether ${stack.name} stack exists...`); 18 | this.cloudformation.describeStacks({ StackName: stack.name }, (err) => { 19 | if (err) { 20 | this.createStack(next); 21 | } else { 22 | this.updateStack(next); 23 | } 24 | }); 25 | } 26 | 27 | getStackParams() { 28 | const { stack } = this.options; 29 | return { 30 | ...stack.params, 31 | StackName: stack.name, 32 | TemplateBody: JSON.stringify(this.input.template), 33 | ClientRequestToken: this.taskUUID, 34 | }; 35 | } 36 | 37 | createStack(next) { 38 | this.log.info('└─ Stack doesn\'t exist. Creating a new one...\n'); 39 | 40 | const params = this.getStackParams(); 41 | const callback = this.getStackRequestCallback('Stack is creating...', () => { 42 | this.cloudformation.waitFor('stackCreateComplete', { StackName: params.StackName }, () => { 43 | this.stopPollingEvents(); 44 | this.log.message('Stack has been created.'); 45 | next(); 46 | }); 47 | }); 48 | 49 | this.cloudformation.createStack(params, callback); 50 | } 51 | 52 | updateStack(next) { 53 | this.log.info('└─ Stack exists, updating...\n'); 54 | 55 | const params = this.getStackParams(); 56 | const callback = this.getStackRequestCallback('Stack is updating...', () => { 57 | this.cloudformation.waitFor('stackUpdateComplete', { StackName: params.StackName }, () => { 58 | this.stopPollingEvents(); 59 | this.log.message('Stack has been updated.'); 60 | next(); 61 | }); 62 | }); 63 | 64 | this.cloudformation.updateStack(params, callback); 65 | } 66 | 67 | getStackRequestCallback(message, callback) { 68 | return (err, data) => { 69 | if (err) { 70 | this.log.error(`${err.code}: ${err.message}`, false); 71 | this.log.info(`└─ RequestId: ${chalk.magenta(err.requestId)}`); 72 | this.log.stop(); 73 | process.exit(err.code === 'ValidationError' ? 0 : 1); 74 | } else { 75 | this.log.message(message); 76 | this.log.info(`├─ RequestId: ${chalk.magenta(data.ResponseMetadata.RequestId)}`); 77 | this.log.info(`└─ StackId: ${chalk.magenta(data.StackId)}\n`); 78 | 79 | this.startPollingEvents(); 80 | callback(); 81 | } 82 | }; 83 | } 84 | 85 | } 86 | 87 | module.exports = DeployTask; 88 | -------------------------------------------------------------------------------- /bin/cfpack.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { EOL } = require('os'); 4 | const path = require('path'); 5 | 6 | const yargs = require('yargs'); 7 | const chalk = require('chalk'); 8 | const checkForUpdate = require('update-check'); 9 | 10 | const pkg = require('../package.json'); 11 | 12 | function dispatch(args) { 13 | const commandPath = path.resolve(__dirname, '../commands/', args._[0]); 14 | const command = require(commandPath); 15 | 16 | command(args); 17 | } 18 | 19 | async function checkUpdates() { 20 | try { 21 | const update = await checkForUpdate(pkg); 22 | if (update) { 23 | process.stderr.write(chalk.yellow(`cfpack.js version ${update.latest} is now available. Please run ${chalk.bold('"npm i -g cfpack.js"')} to update!`) + EOL); 24 | } 25 | } catch (err) { 26 | process.stderr.write(chalk.yellow('Failed to automatically check for updates. Please ensure cfpack.js is up to date.') + EOL); 27 | } 28 | } 29 | 30 | async function bootstrap() { 31 | await checkUpdates(); 32 | 33 | yargs.completion('completion', (current, argv) => { 34 | const commands = ['init', 'build', 'deploy', 'artifacts', 'delete', 'completion']; 35 | const filter = (command) => command.substring(0, current.length) === current; 36 | 37 | return argv._.length <= 2 ? commands.filter(filter) : []; 38 | }); 39 | 40 | yargs.options({ 41 | config: { 42 | type: 'string', 43 | describe: 'Path to the config file.', 44 | default: 'cfpack.config.js', 45 | }, 46 | verbose: { 47 | type: 'boolean', 48 | describe: 'Show more details', 49 | }, 50 | silent: { 51 | type: 'boolean', 52 | describe: 'Prevent output from being displayed in stdout', 53 | }, 54 | }); 55 | 56 | yargs.scriptName('cfpack'); 57 | yargs.usage('Usage: cfpack '); 58 | 59 | yargs.command( 60 | 'init', 61 | 'Initializes cfpack config in the current directory.', 62 | {}, 63 | dispatch, 64 | ); 65 | 66 | yargs.command( 67 | 'build', 68 | 'Assembles templates into one CloudFormation template.', 69 | (innerYargs) => { 70 | innerYargs.option('no-validate', { 71 | describe: 'Skip template validation process', 72 | type: 'boolean', 73 | }); 74 | }, 75 | dispatch, 76 | ); 77 | 78 | yargs.command( 79 | 'deploy', 80 | 'Assembles and deploys CloudFormation template.', 81 | (innerYargs) => { 82 | innerYargs.option('no-validate', { 83 | describe: 'Skip template validation process', 84 | type: 'boolean', 85 | }); 86 | }, 87 | dispatch, 88 | ); 89 | 90 | yargs.command( 91 | 'artifacts', 92 | 'Uploads artifacts to s3 buckets.', 93 | {}, 94 | dispatch, 95 | ); 96 | 97 | yargs.command( 98 | 'delete', 99 | 'Deletes CloudFormation stack.', 100 | {}, 101 | dispatch, 102 | ); 103 | 104 | yargs.demandCommand(); 105 | yargs.parse(); 106 | } 107 | 108 | bootstrap(); 109 | -------------------------------------------------------------------------------- /src/ApiTask.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | const Task = require('./Task'); 4 | 5 | class ApiTask extends Task { 6 | 7 | static getDateTime(timestamp) { 8 | const date = new Date(timestamp); 9 | 10 | const hour = date.getHours().toString().padStart(2, '0'); 11 | const min = date.getMinutes().toString().padStart(2, '0'); 12 | const sec = date.getSeconds().toString().padStart(2, '0'); 13 | 14 | return `${hour}:${min}:${sec}`; 15 | } 16 | 17 | startPollingEvents(stackId = null) { 18 | const { stack } = this.options; 19 | 20 | this.events = {}; 21 | 22 | this.pollParams = { StackName: stackId || stack.name }; 23 | this.pollInterval = setInterval(this.pollStackEvents.bind(this), 2500); 24 | 25 | this.log.info(chalk.white.bold('Event Logs:')); 26 | } 27 | 28 | stopPollingEvents() { 29 | clearInterval(this.pollInterval); 30 | this.log.info(''); 31 | } 32 | 33 | pollStackEvents() { 34 | this.cloudformation.describeStackEvents(this.pollParams, (err, data) => { 35 | if (!err) { 36 | data.StackEvents.reverse().forEach(this.displayEvent.bind(this)); 37 | } 38 | }); 39 | } 40 | 41 | getResourceMaxLength() { 42 | if (!this.resourceMaxLength) { 43 | const { template } = this.input || {}; 44 | const { Resources } = template || {}; 45 | this.resourceMaxLength = Math.max( 46 | 35, // not less than 35 characters 47 | this.options.stack.name.length, 48 | ...Object.keys(Resources || []).map((item) => item.length), 49 | ); 50 | } 51 | 52 | return this.resourceMaxLength; 53 | } 54 | 55 | displayEvent(event) { 56 | const { 57 | EventId, 58 | ClientRequestToken, 59 | Timestamp, 60 | LogicalResourceId, 61 | ResourceStatus, 62 | ResourceStatusReason, 63 | } = event; 64 | 65 | if (ClientRequestToken === this.taskUUID && !this.events[EventId]) { 66 | this.events[EventId] = true; 67 | 68 | let resource = (LogicalResourceId || '').padEnd(this.getResourceMaxLength(), ' '); 69 | let status = (ResourceStatus || '').padEnd(45, ' '); 70 | 71 | switch (ResourceStatus) { 72 | case 'CREATE_COMPLETE': 73 | case 'UPDATE_COMPLETE': 74 | case 'DELETE_COMPLETE': 75 | case 'UPDATE_ROLLBACK_COMPLETE': 76 | status = chalk.green.bold(status); 77 | resource = chalk.green.bold(resource); 78 | break; 79 | case 'CREATE_FAILED': 80 | case 'UPDATE_FAILED': 81 | case 'DELETE_FAILED': 82 | case 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS': 83 | case 'UPDATE_ROLLBACK_IN_PROGRESS': 84 | case 'ROLLBACK_FAILED': 85 | status = chalk.red.bold(status); 86 | resource = chalk.red.bold(resource); 87 | break; 88 | default: 89 | status = chalk.gray.bold(status); 90 | break; 91 | } 92 | 93 | const message = [ 94 | `[${ApiTask.getDateTime(Timestamp)}]`, 95 | resource, 96 | status, 97 | ResourceStatusReason || chalk.gray('—'), 98 | ]; 99 | 100 | this.log.info(message.join(' ')); 101 | } 102 | } 103 | 104 | } 105 | 106 | module.exports = ApiTask; 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] - TBD 4 | 5 | - 6 | 7 | ## [v1.5.2] - 2021-10-26 8 | 9 | - Updated dependencies to the latest versions. 10 | - Added the ability to skip template validation. 11 | 12 | ## [v1.5.1] - 2020-11-20 13 | 14 | - Updated aws-sdk dependency to the latest version. 15 | - Added a workaround for big templates validation which tries not to use JSON formatting when template takes more than 51200 bytes. 16 | 17 | ## [v1.5.0] - 2020-11-14 18 | 19 | - Updated builder to sort templates before merging it. 20 | - Updated dependencies to the latest versions. 21 | - Added version update check. 22 | 23 | ## [v1.4.2] - 2020-09-17 24 | 25 | - Updated dependencies to the latest versions. 26 | - Fixed issues with !Sub function. 27 | 28 | ## [v1.4.1] - 2020-09-05 29 | 30 | - Updated dependencies to the latest versions. 31 | - Updated package.json to keep eslint and jest configs in separate files. 32 | - Fixed issues that happened when commands were run outside of a folder with cfpack.config.js file. 33 | 34 | ## [v1.4.0] - 2020-06-19 35 | 36 | - Updated dependencies to the latest versions. 37 | - Updated deploy task to exit with 0 code if there are no updates to perform. 38 | 39 | ## [v1.3.0] - 2019-07-19 40 | 41 | - Updated dependencies to fix vulnerability issues found in dependant packages. 42 | - Updated build command to validate the final template. 43 | - Added condition functions to the yaml parser schema. 44 | - Added bash/zsh-completion shortcuts for commands. 45 | - Added unit tests to check that build task properly parses yml files. 46 | - Fixed bug that didn't allow to keep templates in multiple folders. 47 | 48 | ## [v1.2.1] - 2019-06-14 49 | 50 | - Updated dependencies to fix vulnerability issues found in dependant packages. 51 | 52 | ## [v1.2.0] - 2019-01-26 53 | 54 | - Added `artifacts` command to upload files to a s3 bucket. 55 | - Added spinner to the terminal output to indicate process. 56 | - Added eslint config to standardise code base. 57 | - Reworked tasks runner to use middlewares instead of array of tasks. 58 | - Updated init command to use existing values if config file is already created. 59 | 60 | ## [v1.1.0] - 2019-01-13 61 | 62 | - Updated **deploy** command to display stack events and wait till the update process ends. 63 | - Updated **delete** command to display stack events and wait till the delete process ends. 64 | 65 | ## [v1.0.0] - 2019-01-12 66 | 67 | The initial release that contains four commands to create init file, build templates, deploy templates and delete current stack. 68 | 69 | [Unreleased]: https://github.com/eugene-manuilov/cfpack/compare/v1.5.2...master 70 | [v1.5.2]: https://github.com/eugene-manuilov/cfpack/compare/v1.5.1...v1.5.2 71 | [v1.5.1]: https://github.com/eugene-manuilov/cfpack/compare/v1.5.0...v1.5.1 72 | [v1.5.0]: https://github.com/eugene-manuilov/cfpack/compare/v1.4.2...v1.5.0 73 | [v1.4.2]: https://github.com/eugene-manuilov/cfpack/compare/v1.4.1...v1.4.2 74 | [v1.4.1]: https://github.com/eugene-manuilov/cfpack/compare/v1.4.0...v1.4.1 75 | [v1.4.0]: https://github.com/eugene-manuilov/cfpack/compare/v1.3.0...v1.4.0 76 | [v1.3.0]: https://github.com/eugene-manuilov/cfpack/compare/v1.2.1...v1.3.0 77 | [v1.2.1]: https://github.com/eugene-manuilov/cfpack/compare/v1.2.0...v1.2.1 78 | [v1.2.0]: https://github.com/eugene-manuilov/cfpack/compare/v1.1.0...v1.2.0 79 | [v1.1.0]: https://github.com/eugene-manuilov/cfpack/compare/v1.0.0...v1.1.0 80 | [v1.0.0]: https://github.com/eugene-manuilov/cfpack/releases/tag/v1.0.0 81 | -------------------------------------------------------------------------------- /src/tasks/Init.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const yargsInteractive = require('yargs-interactive'); 4 | 5 | const Task = require('../Task'); 6 | 7 | class Init extends Task { 8 | 9 | /* eslint-disable class-methods-use-this */ 10 | run(next) { 11 | const defaults = { 12 | stackname: 'my-stack', 13 | stackRegion: 'us-east-1', 14 | entryFolder: 'cloudformation', 15 | outputFile: 'cloudformation.json', 16 | }; 17 | 18 | const filename = Init.getConfigFilename(); 19 | if (fs.existsSync(filename)) { 20 | const module = require(filename); 21 | defaults.stackname = module.stack.name; 22 | defaults.stackRegion = module.stack.region; 23 | defaults.entryFolder = module.entry; 24 | defaults.outputFile = module.output; 25 | } 26 | 27 | const options = { 28 | interactive: { default: true }, 29 | stackName: { 30 | type: 'input', 31 | default: defaults.stackname, 32 | describe: 'Enter stack name', 33 | }, 34 | stackRegion: { 35 | type: 'input', 36 | default: defaults.stackRegion, 37 | describe: 'Enter region', 38 | }, 39 | entryFolder: { 40 | type: 'input', 41 | default: defaults.entryFolder, 42 | describe: 'Templates folder name', 43 | }, 44 | outputFile: { 45 | type: 'input', 46 | default: defaults.outputFile, 47 | describe: 'File name of combined template', 48 | }, 49 | }; 50 | 51 | yargsInteractive() 52 | .usage('$0 [args]') 53 | .interactive(options) 54 | .then((results) => { 55 | Init.saveConfig(results); 56 | next(results); 57 | }); 58 | } 59 | /* eslint-enable */ 60 | 61 | static getConfigFilename() { 62 | return path.resolve(process.cwd(), 'cfpack.config.js'); 63 | } 64 | 65 | static saveConfig(results) { 66 | const filename = Init.getConfigFilename(); 67 | const stream = fs.createWriteStream(filename, { encoding: 'utf8' }); 68 | 69 | stream.write(`module.exports = { 70 | entry: ${JSON.stringify(results.entryFolder)}, // folder with templates 71 | output: ${JSON.stringify(results.outputFile)}, // resulting template file 72 | verbose: true, // whether or not to display additional details 73 | silent: false, // whether or not to prevent output from being displayed in stdout 74 | stack: { 75 | name: ${JSON.stringify(results.stackName)}, // stack name 76 | region: ${JSON.stringify(results.stackRegion)}, // stack region 77 | params: { 78 | /** 79 | * Extra parameters that can be used by API 80 | * @see: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFormation.html#createStack-property 81 | */ 82 | 83 | /* uncomment if your CloudFormation template creates IAM roles */ 84 | // Capabilities: ['CAPABILITY_IAM'], 85 | 86 | /* uncomment if your CloudFormation require parameters */ 87 | // Parameters: [ 88 | // { 89 | // ParameterKey: 'my-parameter', 90 | // ParameterValue: 'my-value', 91 | // }, 92 | // ], 93 | }, 94 | /* uncomment if you need to upload artifacts before creating/updating your stack */ 95 | // artifacts: [ 96 | // { 97 | // bucket: 's3-bucket-name', 98 | // files: { 99 | // 'location/one/': 'local/files/**/*', 100 | // 'location/two.zip': { 101 | // baseDir: 'local/files/', 102 | // path: '**/*', 103 | // compression: 'zip', // zip | none 104 | // }, 105 | // }, 106 | // }, 107 | // ], 108 | }, 109 | }; 110 | `); 111 | stream.close(); 112 | } 113 | 114 | } 115 | 116 | module.exports = Init; 117 | -------------------------------------------------------------------------------- /src/tasks/Build.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const AWS = require('aws-sdk'); 6 | const yaml = require('js-yaml'); 7 | const chalk = require('chalk'); 8 | 9 | const Task = require('../Task'); 10 | const intrinsicFunctions = require('../utils/intrinsic-functions-schema'); 11 | 12 | class BuildTask extends Task { 13 | 14 | constructor() { 15 | super(); 16 | this.lastError = false; 17 | } 18 | 19 | run(next) { 20 | this.log.message('Build template file...'); 21 | 22 | this.log.info(`├─ Looking for templates in the ${this.options.entry} folder...`); 23 | this.findTemplates(); 24 | if (this.output.files.length === 0) { 25 | return; 26 | } 27 | 28 | this.log.info('├─ Processing found templates...'); 29 | this.processTemplates(); 30 | 31 | const saveTemplate = () => { 32 | this.saveFinalTemplate(); 33 | this.log.info(`└─ Final template: ${chalk.magenta(this.output.templateFile)}\n`); 34 | next(this.output); 35 | }; 36 | 37 | if (this.options.validate === false) { 38 | this.log.info('├─ Skipping validation process...'); 39 | saveTemplate(); 40 | } else { 41 | this.log.info('├─ Validating final template...'); 42 | this.validateFinalTemplate(saveTemplate); 43 | } 44 | } 45 | 46 | findTemplates() { 47 | const { entry, config } = this.options; 48 | const entryPath = path.isAbsolute(entry) 49 | ? entry 50 | : path.resolve(path.dirname(config), entry); 51 | 52 | if (!fs.existsSync(entryPath)) { 53 | this.log.error('└─ The entry folder is not found.'); 54 | } 55 | 56 | const files = this.walkTemplates(entryPath, []); 57 | if (files.length > 0) { 58 | this.log.info(`├─ Found ${files.length} template(s)...`); 59 | } else { 60 | this.log.info('└─ Found no templates in the folder...'); 61 | } 62 | 63 | this.output.files = files; 64 | } 65 | 66 | walkTemplates(dir, list) { 67 | let newlist = [...list]; 68 | 69 | fs.readdirSync(dir).sort().forEach((file) => { 70 | const filename = path.join(dir, file); 71 | if (fs.statSync(filename).isDirectory()) { 72 | newlist = [...newlist, ...this.walkTemplates(filename, list)]; 73 | } else { 74 | newlist.push(filename); 75 | } 76 | }); 77 | 78 | return newlist; 79 | } 80 | 81 | processTemplates() { 82 | const template = {}; 83 | 84 | this.output.files.forEach((file) => { 85 | this.lastError = false; 86 | 87 | const doc = this.processTemplate(file); 88 | if (!doc) { 89 | const error = this.lastError.toString().split('\n').join('\n│ '); 90 | this.log.info(`├─ Error processing ${file} template: ${error}`); 91 | return; 92 | } 93 | 94 | this.log.info(`├─ Processed ${file} template...`); 95 | 96 | Object.keys(doc).forEach((group) => { 97 | if (typeof doc[group] === 'object') { 98 | if (doc[group].constructor.name === 'Date') { 99 | const [dateString] = doc[group].toISOString().split('T'); 100 | template[group] = dateString; 101 | } else { 102 | if (!template[group]) { 103 | template[group] = {}; 104 | } 105 | 106 | Object.keys(doc[group]).forEach((key) => { 107 | template[group][key] = doc[group][key]; 108 | }); 109 | } 110 | } else { 111 | template[group] = doc[group]; 112 | } 113 | }); 114 | }); 115 | 116 | this.output.template = template; 117 | } 118 | 119 | processTemplate(file) { 120 | const content = fs.readFileSync(file, 'utf8'); 121 | 122 | try { 123 | return content.trim(0).charAt(0) === '{' 124 | ? JSON.parse(content) 125 | : yaml.safeLoad(content, { schema: intrinsicFunctions }); 126 | } catch (e) { 127 | this.lastError = e; 128 | } 129 | 130 | return false; 131 | } 132 | 133 | validateFinalTemplate(callback) { 134 | const { stack } = this.options; 135 | const cloudformation = new AWS.CloudFormation({ 136 | apiVersion: '2010-05-15', 137 | region: stack.region, 138 | }); 139 | 140 | let TemplateBody = JSON.stringify(this.output.template, '', 4); 141 | 142 | // @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/CloudFormation.html#validateTemplate-property 143 | if (TemplateBody.length > 51200) { 144 | TemplateBody = JSON.stringify(this.output.template); 145 | } 146 | 147 | cloudformation.validateTemplate({ TemplateBody }, (err) => { 148 | if (err) { 149 | this.log.error(`├─ ${err.message}`, false); 150 | this.log.error(`└─ RequestId: ${chalk.magenta(err.requestId)}`, false); 151 | this.log.stop(); 152 | process.exit(1); 153 | } else { 154 | callback(); 155 | } 156 | }); 157 | } 158 | 159 | saveFinalTemplate() { 160 | const { output, config } = this.options; 161 | 162 | let filename = output; 163 | if (!filename) { 164 | const prefix = path.join(os.tmpdir(), 'cfpack-'); 165 | const folder = fs.mkdtempSync(prefix); 166 | filename = path.join(folder, 'template.json'); 167 | } 168 | 169 | filename = path.isAbsolute(filename) 170 | ? filename 171 | : path.resolve(path.dirname(config), filename); 172 | 173 | const data = JSON.stringify(this.output.template, '', 4); 174 | fs.writeFileSync(filename, data, { encoding: 'utf8' }); 175 | this.output.templateFile = filename; 176 | } 177 | 178 | } 179 | 180 | module.exports = BuildTask; 181 | -------------------------------------------------------------------------------- /src/tasks/Artifacts.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const AWS = require('aws-sdk'); 5 | const chalk = require('chalk'); 6 | const glob = require('glob'); 7 | const Zip = require('node-native-zip'); 8 | 9 | const Task = require('../Task'); 10 | 11 | class Artifacts extends Task { 12 | 13 | constructor() { 14 | super(); 15 | this.runNextArtifacts = this.runNextArtifacts.bind(this); 16 | } 17 | 18 | run(next) { 19 | const { stack } = this.options; 20 | const { artifacts } = stack || {}; 21 | if (!artifacts) { 22 | next(this.input); 23 | return; 24 | } 25 | 26 | this.s3 = new AWS.S3({ 27 | apiVersion: '2006-03-01', 28 | region: stack.region, 29 | }); 30 | 31 | this.list = (Array.isArray(artifacts) ? artifacts : [artifacts]).reverse(); 32 | this.processArtifacts(this.list.pop()) 33 | .then(this.runNextArtifacts) 34 | .then(() => { 35 | this.log.info(''); 36 | next(this.input); 37 | }) 38 | .catch((err) => { 39 | this.log.error(err); 40 | }); 41 | } 42 | 43 | runNextArtifacts() { 44 | const item = this.list.pop(); 45 | return item 46 | ? this.processArtifacts(item).then(this.runNextArtifacts) 47 | : null; 48 | } 49 | 50 | processArtifacts(artifact) { 51 | if (!artifact) { 52 | return Promise.resolve(); 53 | } 54 | 55 | const { bucket, files } = artifact; 56 | if (!bucket) { 57 | this.log.warning('S3 bucket is not defined, skipping artifacts uploading...'); 58 | return Promise.resolve(); 59 | } 60 | 61 | const keys = Object.keys(files); 62 | if (!keys.length) { 63 | this.log.warning(`No files are defined for ${chalk.bold(bucket)} bucket, skipping...`); 64 | return Promise.resolve(); 65 | } 66 | 67 | this.log.message(`Uploading artifacts to ${chalk.bold(bucket)} bucket...`); 68 | 69 | return new Promise((resolve, reject) => { 70 | const promises = []; 71 | for (let i = 0, len = keys.length; i < len; i++) { 72 | const promise = this.processArtifact(bucket, keys[i], files[keys[i]]); 73 | if (promise) { 74 | promises.push(promise); 75 | } 76 | } 77 | 78 | Promise.all(promises) 79 | .then(() => { 80 | this.log.info('└─ Artifacts are uploaded...'); 81 | resolve(); 82 | }) 83 | .catch(() => { 84 | reject(); 85 | }); 86 | }); 87 | } 88 | 89 | processArtifact(bucket, location, options) { 90 | const args = typeof options === 'string' 91 | ? { path: options } 92 | : { ...options }; 93 | 94 | let baseDir = args.baseDir || '.'; 95 | if (!path.isAbsolute(baseDir)) { 96 | baseDir = path.join(process.cwd(), baseDir); 97 | } 98 | 99 | let filepath = args.path || ''; 100 | if (!filepath) { 101 | return false; 102 | } 103 | 104 | if (args.baseDir) { 105 | filepath = path.join(args.baseDir, filepath); 106 | } 107 | 108 | return new Promise((resolve) => { 109 | glob(filepath, { absolute: true, stat: true }, (err, files) => { 110 | if (err) { 111 | this.log.error(err); 112 | } else if (args.compression === 'zip') { 113 | this.compressAndUploadFiles(bucket, location, baseDir, files) 114 | .then(resolve) 115 | .catch(resolve); 116 | } else { 117 | this.uploadFiles(bucket, location, baseDir, files) 118 | .then(resolve) 119 | .catch(resolve); 120 | } 121 | }); 122 | }); 123 | } 124 | 125 | compressAndUploadFiles(bucket, location, baseDir, files) { 126 | const filesMap = []; 127 | const archive = new Zip(); 128 | 129 | files.forEach((file) => { 130 | if (!fs.statSync(file).isDirectory()) { 131 | filesMap.push({ 132 | name: file.substring(baseDir.length), 133 | path: file, 134 | }); 135 | } 136 | }); 137 | 138 | if (!filesMap.length) { 139 | return Promise.resolve(); 140 | } 141 | 142 | return new Promise((resolve) => { 143 | archive.addFiles(filesMap, (archiveError) => { 144 | if (archiveError) { 145 | this.log.warning(archiveError); 146 | } else { 147 | const params = { 148 | Bucket: bucket, 149 | Key: location, 150 | Body: archive.toBuffer(), 151 | }; 152 | 153 | this.s3.putObject(params, (putError) => { 154 | if (putError) { 155 | this.log.warning(`├─ ${putError}`); 156 | } else { 157 | const uri = chalk.bold(`s3://${bucket}/${location}`); 158 | this.log.info(`├─ Uploaded files to ${uri}`); 159 | } 160 | 161 | resolve(); 162 | }); 163 | } 164 | }); 165 | }); 166 | } 167 | 168 | uploadFiles(bucket, location, baseDir, files) { 169 | const filesMap = []; 170 | 171 | files.forEach((file) => { 172 | if (!fs.statSync(file).isDirectory()) { 173 | filesMap.push({ 174 | name: path.join(location, file.substring(baseDir.length)), 175 | path: file, 176 | }); 177 | } 178 | }); 179 | 180 | if (!filesMap.length) { 181 | return Promise.resolve(); 182 | } 183 | 184 | const promises = []; 185 | for (let i = 0, len = filesMap.length; i < len; i++) { 186 | const params = { 187 | Bucket: bucket, 188 | Key: filesMap[i].name, 189 | Body: fs.createReadStream(filesMap[i].path), 190 | }; 191 | 192 | promises.push(this.s3.putObject(params).promise()); 193 | } 194 | 195 | return Promise.all(promises) 196 | .then(() => { 197 | const uri = chalk.bold(`s3://${bucket}/${location}`); 198 | this.log.info(`├─ Uploaded files to ${uri}`); 199 | }) 200 | .catch((err) => { 201 | this.log.error(err); 202 | }); 203 | } 204 | 205 | } 206 | 207 | module.exports = Artifacts; 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cfpack 2 | 3 | [![Version](https://img.shields.io/npm/v/cfpack.js.svg)](https://www.npmjs.com/package/cfpack.js) 4 | [![Downloads/week](https://img.shields.io/npm/dw/cfpack.js.svg)](https://www.npmjs.com/package/cfpack.js) 5 | [![License](https://img.shields.io/npm/l/cfpack.js.svg)](https://github.com/eugene-manuilov/cfpack/blob/master/package.json) 6 | 7 | A small CLI tool that can help you to deal with huge CloudFormation templates by splitting it into multiple smaller templates. Using this tool you can also build sharable drop-in templates that you can share across your projects. 8 | 9 | [![cfpack](https://asciinema.org/a/TXlSiEeZvDNBUl2lOahyHmith.svg)](https://asciinema.org/a/TXlSiEeZvDNBUl2lOahyHmith) 10 | 11 | ## Table of Contents 12 | 13 | - [Installation](#installation) 14 | - [Enable bash/zsh-completion shortcuts](#enable-bashzsh-completion-shortcuts) 15 | - [Getting Started](#getting-started) 16 | - [Build Templates](#build-templates) 17 | - [Commands](#commands) 18 | - [Init](#init) 19 | - [Build](#build) 20 | - [Deploy](#deploy) 21 | - [Artifacts](#artifacts) 22 | - [Delete](#delete) 23 | - [Config file](#config-file) 24 | - [Parameters](#parameters) 25 | - [Artifacts](#artifacts-1) 26 | - [IAM roles](#iam-roles) 27 | - [Contribute](#contribute) 28 | - [LICENSE](#license) 29 | 30 | ## Installation 31 | 32 | Install the package as global dependency to be able to use with different projects: 33 | 34 | ``` 35 | npm i -g cfpack.js 36 | ``` 37 | 38 | You can also install it as a project dependency and add NPM scripts if you need it for a specific project or you want to run it during CI/CD process. Just run the following command: 39 | 40 | ``` 41 | npm i cfpack.js --save-dev 42 | ``` 43 | 44 | Then you can create shortcuts in your `package.json` file: 45 | 46 | ``` 47 | { 48 | "name": "my-project", 49 | ... 50 | "scripts": { 51 | "stack:build": "cfpack build", 52 | "stack:deploy": "cfpack deploy", 53 | "stack:delete": "cfpack delete" 54 | }, 55 | ... 56 | } 57 | ``` 58 | 59 | ### Enable bash/zsh-completion shortcuts 60 | 61 | If you want to enable bash/zsh-completion shortcuts in your terminal, then you need to run `cfpack completion` command and add generated script to your `.bashrc` or `.bash_profile` (or `.zshrc` for zsh). You can do it by running the following command: 62 | 63 | ``` 64 | cfpack completion >> ~/.bashrc 65 | ``` 66 | 67 | Once completion script is added, you need to logout and then login back to make sure that terminal reads the script and start using completion for cfpack commands. 68 | 69 | ## Getting Started 70 | 71 | Before you start using this tool, you need to create a configuration file in the root of your project. Just run `cfpack init` command and it will create `cfpack.config.js` file with default settings. The file is pretty obvisous and specifies a folder with template files, a path to resulting template, CloudFormation stack information and the like. Just amend values if you need to change something and it will use what you want. 72 | 73 | You may also need to make sure that you have AWS credentials on your machine. The easiest way to do it is to install AWS CLI tool on your machine and run `aws configure` command to enter access key id and secret access key. It will be used by AWS Node.js SDK to work with CloudFormation stacks when you deploy it or want to delete it. 74 | 75 | ## Build Templates 76 | 77 | Once everything is ready, you can split your original CloudFormation template into multiple smaller templates and put it into the entry folder. For example, if you have a template that declares CodeBuild, CodePipeline, S3 Bucket, AWS Lambda, DynamoDb tables and appropriate IAM resources, then you can create the following templates in the entry folder and split resoruces between them: 78 | 79 | - `build.yaml` - will contain CodeBuild and CodePipeline resources 80 | - `compute.yaml` - will contain AWS Lambda resources 81 | - `database.yaml` - will contain DynamoDb tables 82 | - `storage.yaml` - will contain S3 buckets 83 | - `roles.yaml` - will contain IAM roles and policies 84 | 85 | If you have parameters, outputs, metadata, mappings and/or conditions in your original template, then it also can be split between different templates. Just use your judment to deside what should be where. 86 | 87 | Just keep in mind that whenever you create a "sub-template", it has to adhere the standard formating and be valid from CloudFormation point of view. 88 | 89 | ## Commands 90 | 91 | The package provides four commands: `init`, `build`, `deploy` and `delete`. These commands are pretty much self explanatory, but let's take a look at each of them. 92 | 93 | #### Init 94 | 95 | The `init` command is intended to initialize configuration file in the current working directory. Just run `cfpack init` and a new `cfpack.config.js` file will be create in the folder. Please, pay attention that it will override existing one if you have already created it. 96 | 97 | #### Build 98 | 99 | The `build` command will loop through the entry folder, find all files in it, read temlates and compose the final template which will be saved at a location specified in the config file. The command understands both json and yaml templates, and uses JSON format for the final template. 100 | 101 | #### Deploy 102 | 103 | The `deploy` command executes build task first to create resulting template and then use it to create or update CloudFormation stack using AWS Node.js SDK. The command checks whether or not a stack exists to determine which action is required to create or update the stack. This command will also upload artifacts if you define it in the `cfpack.config.js` file to make sure that CloudFormation stack can properly provision resoureces like lambda functions or appsync graphql API or resolvers. 104 | 105 | #### Artifacts 106 | 107 | The `artifacts` command allows you to upload artifacts that are defined in the config file. You can define as many artifacts as you need. 108 | 109 | #### Delete 110 | 111 | Finally, the `delete` command just checks if the stack exists and then calls API to delete it. 112 | 113 | ## Config file 114 | 115 | ### Parameters 116 | 117 | As it has been said above, the config file is pretty obvious and self explanatory. It allows you to define your stack information and provide additional details like parameters or capabilities. Thus if your template uses input parameters, you can define them in the config file in the `stack` > `params` section as shown below: 118 | 119 | ``` 120 | module.exports = { 121 | ... 122 | stack: { 123 | name: "my-stack", 124 | region: "us-east-1", 125 | params: { 126 | ... 127 | Parameters: [ 128 | { 129 | ParameterKey: 'key1', 130 | ParameterValue: 'valueA' 131 | }, 132 | { 133 | ParameterKey: 'key2', 134 | ParameterValue: 'valueB' 135 | } 136 | ] 137 | } 138 | } 139 | }; 140 | ``` 141 | 142 | If your parameters contain sensetive data and you can't commit it into your repository, then you can consider using environment vairables and [dotenv](https://www.npmjs.com/package/dotenv) package to load it. Install it, create `.env` file and define values that you want to use. Then update your `cfpack.config.js` file. 143 | 144 | ``` 145 | # .env file 146 | KEY1_VALUE=valueA 147 | KEY2_VALUE=valueB 148 | ``` 149 | 150 | ``` 151 | // cfpack.conifg.js 152 | 153 | require('dotenv').config(); 154 | 155 | module.exports = { 156 | ... 157 | stack: { 158 | name: "my-stack", 159 | region: "us-east-1", 160 | params: { 161 | ... 162 | Parameters: [ 163 | { 164 | ParameterKey: 'key1', 165 | ParameterValue: process.env.KEY1_VALUE 166 | }, 167 | { 168 | ParameterKey: 'key2', 169 | ParameterValue: process.env.KEY2_VALUE 170 | } 171 | ] 172 | } 173 | } 174 | }; 175 | ``` 176 | 177 | ### Artifacts 178 | 179 | If your templates have resources (like lambda functions, appsync graphql schema or resolvers, etc) that rely on artifacts located in a s3 bucket, then you can define which files need to be uploaded during deployment process. 180 | 181 | Let's consider that you have a template like this: 182 | 183 | ``` 184 | Resources: 185 | Schema: 186 | Type: AWS::AppSync::GraphQLSchema 187 | Properties: 188 | ApiId: ... 189 | DefinitionS3Location: s3://my-bucket/graphql/schema.graphql 190 | ResolverA: 191 | Type: AWS::AppSync::Resolver 192 | Properties: 193 | ApiId: ... 194 | DataSourceName: ... 195 | TypeName: typeA 196 | FieldName: field1 197 | RequestMappingTemplateS3Location: s3://my-bucket/graphql/resolvers/typeA/field1/request.txt 198 | ResponseMappingTemplateS3Location: s3://my-bucket/graphql/resolvers/typeA/field1/response.txt 199 | ResolverB: 200 | Type: AWS::AppSync::Resolver 201 | Properties: 202 | ApiId: ... 203 | DataSourceName: ... 204 | TypeName: typeB 205 | FieldName: field2 206 | RequestMappingTemplateS3Location: s3://my-bucket/graphql/resolvers/typeB/field2/request.txt 207 | ResponseMappingTemplateS3Location: s3://my-bucket/graphql/resolvers/typeB/field2/response.txt 208 | LambdaFunctionA: 209 | Type: AWS::Lambda::Function 210 | Properties: 211 | Handler: index.handler 212 | Role: ... 213 | Code: 214 | S3Bucket: my-bucket 215 | S3Key: lambdas/function-a.zip 216 | Runtime: nodejs8.10 217 | ... 218 | LambdaFunctionB: 219 | Type: AWS::Lambda::Function 220 | Properties: 221 | Handler: index.handler 222 | Role: ... 223 | Code: 224 | S3Bucket: my-bucket 225 | S3Key: lambdas/function-b.zip 226 | Runtime: nodejs8.10 227 | ... 228 | ``` 229 | 230 | And the structure of your project looks like this: 231 | 232 | ``` 233 | /path/to/your/project 234 | ├─ package.json 235 | ├─ package-lock.json 236 | ├─ cfpack.config.json 237 | ├─ graphql 238 | │ ├─ schema.graphql 239 | │ └─ resolvers 240 | │ ├─ typeA 241 | │ │ ├─ field1 242 | │ │ │ ├─ request.txt 243 | │ │ │ └─ response.txt 244 | │ │ └─ ... 245 | │ ├─ typeB 246 | │ │ ├─ field2 247 | │ │ │ ├─ request.txt 248 | │ │ │ └─ response.txt 249 | │ │ └─ ... 250 | │ └─ ... 251 | ├─ lambdas 252 | │ ├─ functionA 253 | │ │ ├─ src 254 | │ │ ├─ node_modules 255 | │ │ ├─ package.json 256 | │ │ └─ package-lock.json 257 | │ └─ functionB 258 | │ ├─ src 259 | │ ├─ node_modules 260 | │ ├─ package.json 261 | │ └─ package-lock.json 262 | └─ ... 263 | ``` 264 | 265 | Then you can update the configuration file to upoad all artifacts like this: 266 | 267 | ``` 268 | module.exports = { 269 | ... 270 | stack: { 271 | name: "my-stack", 272 | region: "us-east-1", 273 | params: { 274 | ... 275 | }, 276 | artifacts: [ 277 | { 278 | bucket: "my-bucket", 279 | files: { 280 | "graphql/": { 281 | baseDir: "graphql", 282 | path: "**/*" 283 | }, 284 | "lambdas/function-a.zip": { 285 | baseDir: "lambdas/functionA", 286 | path: "**/*", 287 | compression: "zip" 288 | }, 289 | "lambdas/function-b.zip": { 290 | baseDir: "lambdas/functionB", 291 | path: "**/*", 292 | compression: "zip" 293 | } 294 | } 295 | } 296 | ] 297 | } 298 | }; 299 | ``` 300 | 301 | Please, pay attention that the bucket must already exist to successfully upload artifacts. It means that you can't define a bucket that you are going to use to store artifacts in your CloudFormation template because artifacts need to be uploaded before your stack is created. 302 | 303 | ### IAM roles 304 | 305 | Please, pay attention that if your CloudFormation template contains IAM roles or policies you must explicity acknowledge that it contains certain capabilities in order for AWS CloudFormation to create or update the stack. To do it, just add `Capabilities` to your config file as shown below: 306 | 307 | ``` 308 | module.exports = { 309 | ... 310 | stack: { 311 | name: "my-stack", 312 | region: "us-east-1", 313 | params: { 314 | ... 315 | Capabilities: ['CAPABILITY_IAM'], 316 | ... 317 | } 318 | } 319 | }; 320 | ``` 321 | 322 | ## Contribute 323 | 324 | Want to help or have a suggestion? Open a [new ticket](https://github.com/eugene-manuilov/cfpack/issues/new) and we can discuss it or submit a pull request. 325 | 326 | ## LICENSE 327 | 328 | The MIT License (MIT) 329 | -------------------------------------------------------------------------------- /__tests__/tasks/__snapshots__/Build.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`BuildTask ::processTemplate --> {and.yml} 1`] = ` 4 | "{ 5 | \\"MyAndCondition\\": { 6 | \\"Fn::And\\": [ 7 | { 8 | \\"Fn::Equals\\": [ 9 | \\"sg-mysggroup\\", 10 | { 11 | \\"Ref\\": \\"ASecurityGroup\\" 12 | } 13 | ] 14 | }, 15 | { 16 | \\"Condition\\": \\"SomeOtherCondition\\" 17 | } 18 | ] 19 | } 20 | }" 21 | `; 22 | 23 | exports[`BuildTask ::processTemplate --> {base64.yml} 1`] = ` 24 | "{ 25 | \\"Fn::Base64\\": \\"AWS CloudFormation\\" 26 | }" 27 | `; 28 | 29 | exports[`BuildTask ::processTemplate --> {cidr.yml} 1`] = ` 30 | "{ 31 | \\"Resources\\": { 32 | \\"ExampleVpc\\": { 33 | \\"Type\\": \\"AWS::EC2::VPC\\", 34 | \\"Properties\\": { 35 | \\"CidrBlock\\": \\"10.0.0.0/16\\" 36 | } 37 | }, 38 | \\"IPv6CidrBlock\\": { 39 | \\"Type\\": \\"AWS::EC2::VPCCidrBlock\\", 40 | \\"Properties\\": { 41 | \\"AmazonProvidedIpv6CidrBlock\\": true, 42 | \\"VpcId\\": { 43 | \\"Ref\\": \\"ExampleVpc\\" 44 | } 45 | } 46 | }, 47 | \\"ExampleSubnet\\": { 48 | \\"Type\\": \\"AWS::EC2::Subnet\\", 49 | \\"DependsOn\\": \\"IPv6CidrBlock\\", 50 | \\"Properties\\": { 51 | \\"AssignIpv6AddressOnCreation\\": true, 52 | \\"CidrBlock\\": { 53 | \\"Fn::Select\\": [ 54 | 0, 55 | { 56 | \\"Fn::Cidr\\": [ 57 | { 58 | \\"Fn::GetAtt\\": [ 59 | \\"ExampleVpc\\", 60 | \\"CidrBlock\\" 61 | ] 62 | }, 63 | 1, 64 | 8 65 | ] 66 | } 67 | ] 68 | }, 69 | \\"Ipv6CidrBlock\\": { 70 | \\"Fn::Select\\": [ 71 | 0, 72 | { 73 | \\"Fn::Cidr\\": [ 74 | { 75 | \\"Fn::Select\\": [ 76 | 0, 77 | { 78 | \\"Fn::GetAtt\\": [ 79 | \\"ExampleVpc\\", 80 | \\"Ipv6CidrBlocks\\" 81 | ] 82 | } 83 | ] 84 | }, 85 | 1, 86 | 64 87 | ] 88 | } 89 | ] 90 | }, 91 | \\"VpcId\\": { 92 | \\"Ref\\": \\"ExampleVpc\\" 93 | } 94 | } 95 | } 96 | } 97 | }" 98 | `; 99 | 100 | exports[`BuildTask ::processTemplate --> {equals.yml} 1`] = ` 101 | "{ 102 | \\"UseProdCondition\\": { 103 | \\"Fn::Equals\\": [ 104 | { 105 | \\"Ref\\": \\"EnvironmentType\\" 106 | }, 107 | \\"prod\\" 108 | ] 109 | } 110 | }" 111 | `; 112 | 113 | exports[`BuildTask ::processTemplate --> {find-in-map.yml} 1`] = ` 114 | "{ 115 | \\"Mappings\\": { 116 | \\"RegionMap\\": { 117 | \\"us-east-1\\": { 118 | \\"HVM64\\": \\"ami-0ff8a91507f77f867\\", 119 | \\"HVMG2\\": \\"ami-0a584ac55a7631c0c\\" 120 | }, 121 | \\"us-west-1\\": { 122 | \\"HVM64\\": \\"ami-0bdb828fd58c52235\\", 123 | \\"HVMG2\\": \\"ami-066ee5fd4a9ef77f1\\" 124 | }, 125 | \\"eu-west-1\\": { 126 | \\"HVM64\\": \\"ami-047bb4163c506cd98\\", 127 | \\"HVMG2\\": \\"ami-31c2f645\\" 128 | }, 129 | \\"ap-southeast-1\\": { 130 | \\"HVM64\\": \\"ami-08569b978cc4dfa10\\", 131 | \\"HVMG2\\": \\"ami-0be9df32ae9f92309\\" 132 | }, 133 | \\"ap-northeast-1\\": { 134 | \\"HVM64\\": \\"ami-06cd52961ce9f0d85\\", 135 | \\"HVMG2\\": \\"ami-053cdd503598e4a9d\\" 136 | } 137 | } 138 | }, 139 | \\"Resources\\": { 140 | \\"myEC2Instance\\": { 141 | \\"Type\\": \\"AWS::EC2::Instance\\", 142 | \\"Properties\\": { 143 | \\"ImageId\\": { 144 | \\"Fn::FindInMap\\": [ 145 | \\"RegionMap\\", 146 | { 147 | \\"Ref\\": \\"AWS::Region\\" 148 | }, 149 | \\"HVM64\\" 150 | ] 151 | }, 152 | \\"InstanceType\\": \\"m1.small\\" 153 | } 154 | } 155 | } 156 | }" 157 | `; 158 | 159 | exports[`BuildTask ::processTemplate --> {get-att.yml} 1`] = ` 160 | "{ 161 | \\"AWSTemplateFormatVersion\\": \\"2010-09-09T00:00:00.000Z\\", 162 | \\"Resources\\": { 163 | \\"myELB\\": { 164 | \\"Type\\": \\"AWS::ElasticLoadBalancing::LoadBalancer\\", 165 | \\"Properties\\": { 166 | \\"AvailabilityZones\\": [ 167 | \\"eu-west-1a\\" 168 | ], 169 | \\"Listeners\\": [ 170 | { 171 | \\"LoadBalancerPort\\": \\"80\\", 172 | \\"InstancePort\\": \\"80\\", 173 | \\"Protocol\\": \\"HTTP\\" 174 | } 175 | ] 176 | } 177 | }, 178 | \\"myELBIngressGroup\\": { 179 | \\"Type\\": \\"AWS::EC2::SecurityGroup\\", 180 | \\"Properties\\": { 181 | \\"GroupDescription\\": \\"ELB ingress group\\", 182 | \\"SecurityGroupIngress\\": [ 183 | { 184 | \\"IpProtocol\\": \\"tcp\\", 185 | \\"FromPort\\": \\"80\\", 186 | \\"ToPort\\": \\"80\\", 187 | \\"SourceSecurityGroupOwnerId\\": { 188 | \\"Fn::GetAtt\\": [ 189 | \\"myELB\\", 190 | \\"SourceSecurityGroup.OwnerAlias\\" 191 | ] 192 | }, 193 | \\"SourceSecurityGroupName\\": { 194 | \\"Fn::GetAtt\\": [ 195 | \\"myELB\\", 196 | \\"SourceSecurityGroup.GroupName\\" 197 | ] 198 | } 199 | } 200 | ] 201 | } 202 | } 203 | } 204 | }" 205 | `; 206 | 207 | exports[`BuildTask ::processTemplate --> {get-azs.yml} 1`] = ` 208 | "{ 209 | \\"evaluateRegions\\": [ 210 | { 211 | \\"Fn::GetAZs\\": \\"\\" 212 | }, 213 | { 214 | \\"Fn::GetAZs\\": { 215 | \\"Ref\\": \\"AWS::Region\\" 216 | } 217 | }, 218 | { 219 | \\"Fn::GetAZs\\": \\"us-east-1\\" 220 | } 221 | ], 222 | \\"mySubnet\\": { 223 | \\"Type\\": \\"AWS::EC2::Subnet\\", 224 | \\"Properties\\": { 225 | \\"VpcId\\": { 226 | \\"Ref\\": \\"VPC\\" 227 | }, 228 | \\"CidrBlock\\": \\"10.0.0.0/24\\", 229 | \\"AvailabilityZone\\": { 230 | \\"Fn::Select\\": [ 231 | 0, 232 | { 233 | \\"Fn::GetAZs\\": \\"\\" 234 | } 235 | ] 236 | } 237 | } 238 | } 239 | }" 240 | `; 241 | 242 | exports[`BuildTask ::processTemplate --> {if.yml} 1`] = ` 243 | "{ 244 | \\"SecurityGroups\\": [ 245 | { 246 | \\"Fn::If\\": [ 247 | \\"CreateNewSecurityGroup\\", 248 | { 249 | \\"Ref\\": \\"NewSecurityGroup\\" 250 | }, 251 | { 252 | \\"Ref\\": \\"ExistingSecurityGroup\\" 253 | } 254 | ] 255 | } 256 | ], 257 | \\"Outputs\\": { 258 | \\"SecurityGroupId\\": { 259 | \\"Description\\": \\"Group ID of the security group used.\\", 260 | \\"Value\\": { 261 | \\"Fn::If\\": [ 262 | \\"CreateNewSecurityGroup\\", 263 | { 264 | \\"Ref\\": \\"NewSecurityGroup\\" 265 | }, 266 | { 267 | \\"Ref\\": \\"ExistingSecurityGroup\\" 268 | } 269 | ] 270 | } 271 | } 272 | }, 273 | \\"MyDB\\": { 274 | \\"Type\\": \\"AWS::RDS::DBInstance\\", 275 | \\"Properties\\": { 276 | \\"AllocatedStorage\\": 5, 277 | \\"DBInstanceClass\\": \\"db.m1.small\\", 278 | \\"Engine\\": \\"MySQL\\", 279 | \\"EngineVersion\\": 5.5, 280 | \\"MasterUsername\\": { 281 | \\"Ref\\": \\"DBUser\\" 282 | }, 283 | \\"MasterUserPassword\\": { 284 | \\"Ref\\": \\"DBPassword\\" 285 | }, 286 | \\"DBParameterGroupName\\": { 287 | \\"Ref\\": \\"MyRDSParamGroup\\" 288 | }, 289 | \\"DBSnapshotIdentifier\\": { 290 | \\"Fn::If\\": [ 291 | \\"UseDBSnapshot\\", 292 | { 293 | \\"Ref\\": \\"DBSnapshotName\\" 294 | }, 295 | { 296 | \\"Ref\\": \\"AWS::NoValue\\" 297 | } 298 | ] 299 | } 300 | } 301 | }, 302 | \\"UpdatePolicy\\": { 303 | \\"AutoScalingRollingUpdate\\": { 304 | \\"Fn::If\\": [ 305 | \\"RollingUpdates\\", 306 | { 307 | \\"MaxBatchSize\\": 2, 308 | \\"MinInstancesInService\\": 2, 309 | \\"PauseTime\\": \\"PT0M30S\\" 310 | }, 311 | { 312 | \\"Ref\\": \\"AWS::NoValue\\" 313 | } 314 | ] 315 | } 316 | } 317 | }" 318 | `; 319 | 320 | exports[`BuildTask ::processTemplate --> {import-value.yml} 1`] = ` 321 | "{ 322 | \\"Resources\\": { 323 | \\"Service\\": { 324 | \\"Type\\": \\"AWS::ECS::Service\\", 325 | \\"Properties\\": { 326 | \\"Cluster\\": { 327 | \\"Fn::ImportValue\\": \\"ClusterName\\" 328 | } 329 | } 330 | } 331 | } 332 | }" 333 | `; 334 | 335 | exports[`BuildTask ::processTemplate --> {join.yml} 1`] = ` 336 | "{ 337 | \\"JoinWithParams\\": { 338 | \\"Fn::Join\\": [ 339 | \\"\\", 340 | [ 341 | \\"arn:\\", 342 | { 343 | \\"Ref\\": \\"Partition\\" 344 | }, 345 | \\":s3:::elasticbeanstalk-*-\\", 346 | { 347 | \\"Ref\\": \\"AWS::AccountId\\" 348 | } 349 | ] 350 | ] 351 | } 352 | }" 353 | `; 354 | 355 | exports[`BuildTask ::processTemplate --> {not.yml} 1`] = ` 356 | "{ 357 | \\"MyNotCondition\\": { 358 | \\"Fn::Not\\": [ 359 | { 360 | \\"Fn::Equals\\": [ 361 | { 362 | \\"Ref\\": \\"EnvironmentType\\" 363 | }, 364 | \\"prod\\" 365 | ] 366 | } 367 | ] 368 | } 369 | }" 370 | `; 371 | 372 | exports[`BuildTask ::processTemplate --> {or.yml} 1`] = ` 373 | "{ 374 | \\"MyOrCondition\\": { 375 | \\"Fn::Or\\": [ 376 | { 377 | \\"Fn::Equals\\": [ 378 | \\"sg-mysggroup\\", 379 | { 380 | \\"Ref\\": \\"ASecurityGroup\\" 381 | } 382 | ] 383 | }, 384 | { 385 | \\"Condition\\": \\"SomeOtherCondition\\" 386 | } 387 | ] 388 | } 389 | }" 390 | `; 391 | 392 | exports[`BuildTask ::processTemplate --> {ref.yml} 1`] = ` 393 | "{ 394 | \\"MyEIP\\": { 395 | \\"Type\\": \\"AWS::EC2::EIP\\", 396 | \\"Properties\\": { 397 | \\"InstanceId\\": { 398 | \\"Ref\\": \\"MyEC2Instance\\" 399 | } 400 | } 401 | } 402 | }" 403 | `; 404 | 405 | exports[`BuildTask ::processTemplate --> {select.yml} 1`] = ` 406 | "{ 407 | \\"Basic\\": { 408 | \\"Fn::Select\\": [ 409 | \\"1\\", 410 | [ 411 | \\"apples\\", 412 | \\"grapes\\", 413 | \\"oranges\\", 414 | \\"mangoes\\" 415 | ] 416 | ] 417 | }, 418 | \\"Subnet0\\": { 419 | \\"Type\\": \\"AWS::EC2::Subnet\\", 420 | \\"Properties\\": { 421 | \\"VpcId\\": { 422 | \\"Ref\\": \\"VPC\\" 423 | }, 424 | \\"CidrBlock\\": { 425 | \\"Fn::Select\\": [ 426 | 0, 427 | { 428 | \\"Ref\\": \\"DbSubnetIpBlocks\\" 429 | } 430 | ] 431 | } 432 | } 433 | }, 434 | \\"AvailabilityZone\\": { 435 | \\"Fn::Select\\": [ 436 | 0, 437 | { 438 | \\"Fn::GetAZs\\": { 439 | \\"Ref\\": \\"AWS::Region\\" 440 | } 441 | } 442 | ] 443 | } 444 | }" 445 | `; 446 | 447 | exports[`BuildTask ::processTemplate --> {split.yml} 1`] = ` 448 | "{ 449 | \\"SimpleList\\": { 450 | \\"Fn::Split\\": [ 451 | \\"|\\", 452 | \\"a|b|c\\" 453 | ] 454 | }, 455 | \\"WithEmptyValues\\": { 456 | \\"Fn::Split\\": [ 457 | \\"|\\", 458 | \\"a||c|\\" 459 | ] 460 | }, 461 | \\"ImportedOutputValue\\": { 462 | \\"Fn::Select\\": [ 463 | 2, 464 | { 465 | \\"Fn::Split\\": [ 466 | \\",\\", 467 | { 468 | \\"Fn::ImportValue\\": \\"AccountSubnetIDs\\" 469 | } 470 | ] 471 | } 472 | ] 473 | } 474 | }" 475 | `; 476 | 477 | exports[`BuildTask ::processTemplate --> {sub.yml} 1`] = ` 478 | "{ 479 | \\"Name\\": { 480 | \\"Fn::Sub\\": [ 481 | \\"www.\${Domain}\\", 482 | { 483 | \\"Domain\\": { 484 | \\"Ref\\": \\"RootDomainName\\" 485 | } 486 | } 487 | ] 488 | }, 489 | \\"Arn\\": { 490 | \\"Fn::Sub\\": \\"arn:aws:ec2:\${AWS::Region}:\${AWS::AccountId}:vpc/\${vpc}\\" 491 | }, 492 | \\"UserData\\": { 493 | \\"Fn::Base64\\": { 494 | \\"Fn::Sub\\": \\"#!/bin/bash -xe\\\\nyum update -y aws-cfn-bootstrap\\\\n/opt/aws/bin/cfn-init -v --stack \${AWS::StackName} --resource LaunchConfig --configsets wordpress_install --region \${AWS::Region}\\\\n/opt/aws/bin/cfn-signal -e $? --stack \${AWS::StackName} --resource WebServerGroup --region \${AWS::Region}\\\\n\\" 495 | } 496 | }, 497 | \\"StrapiCMSAssetBucket\\": { 498 | \\"Type\\": \\"AWS::S3::Bucket\\", 499 | \\"DeletionPolicy\\": \\"Retain\\", 500 | \\"Properties\\": { 501 | \\"BucketName\\": { 502 | \\"Fn::Sub\\": [ 503 | \\"my-test-strapi-assets-\${EnvType}\\", 504 | { 505 | \\"EnvType\\": { 506 | \\"Ref\\": \\"EnvType\\" 507 | } 508 | } 509 | ] 510 | } 511 | } 512 | } 513 | }" 514 | `; 515 | 516 | exports[`BuildTask ::processTemplate --> {transform.yml} 1`] = ` 517 | "{ 518 | \\"TransformTest\\": { 519 | \\"Fn::Transform\\": { 520 | \\"Name\\": \\"AWS::Include\\", 521 | \\"Parameters\\": { 522 | \\"Location\\": \\"us-east-1\\" 523 | } 524 | } 525 | } 526 | }" 527 | `; 528 | --------------------------------------------------------------------------------