├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .rspec ├── .tool-versions ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── cfer ├── cfer-dbg ├── console ├── json-to-cfer └── setup ├── cfer.gemspec ├── doc └── cfer-demo.gif ├── examples ├── common │ └── instance_deps.rb ├── instance.rb ├── vpc.md └── vpc.rb ├── lib ├── cfer.rb ├── cfer │ ├── block.rb │ ├── cfn │ │ ├── cfer_credentials_provider.rb │ │ └── client.rb │ ├── cli.rb │ ├── config.rb │ ├── console.rb │ ├── core │ │ ├── client.rb │ │ ├── functions.rb │ │ ├── hooks.rb │ │ ├── resource.rb │ │ └── stack.rb │ ├── util │ │ ├── error.rb │ │ └── json.rb │ └── version.rb └── cferext │ ├── aws │ ├── auto_scaling │ │ └── auto_scaling_group.rb │ ├── cloud_formation │ │ └── wait_condition.rb │ ├── iam │ │ ├── policy.rb │ │ └── policy_generator.rb │ ├── kms │ │ └── key.rb │ ├── rds │ │ └── db_instance.rb │ └── route53 │ │ └── record_dsl.rb │ └── cfer │ └── stack_validation.rb └── spec ├── cfer_spec.rb ├── cfn_spec.rb ├── client_spec.rb ├── extensions_spec.rb ├── fn_spec.rb ├── spec_helper.rb └── support ├── included_json_stack.json ├── included_stack.rb ├── includes_json_stack.rb ├── includes_stack.rb ├── parameters_stack.rb ├── parameters_stack_params.rb ├── simple_stack.json ├── simple_stack.rb ├── stack_policy.json └── stack_policy_during_update.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Cfer 2 | on: [push] 3 | jobs: 4 | Build: 5 | strategy: 6 | matrix: 7 | os: [ubuntu, macos] 8 | ruby: ["2.7", "3.0", "3.1"] 9 | runs-on: ${{matrix.os}}-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: ${{matrix.ruby}} 15 | bundler-cache: true 16 | - run: bundle install --without debug --jobs=3 --retry=3 17 | - run: bundle exec rspec 18 | - run: bundle exec yard 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Cfer 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_type: 6 | type: choice 7 | description: Type of release to run 8 | required: true 9 | options: 10 | - major 11 | - minor 12 | - patch 13 | jobs: 14 | Publish: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: 3.1 23 | bundler-cache: true 24 | - run: bundle install --without debug --without test --jobs=3 --retry=3 25 | - name: Bump version 26 | run: | 27 | git config --global user.name "CI" 28 | git config --global user.email "seanedwards@users.noreply.github.com" 29 | bundle exec bump ${{inputs.release_type}} --tag --tag-prefix v --changelog 30 | git merge v$(bundle exec bump current) 31 | git push --follow-tags 32 | - name: Publish to RubyGems 33 | run: | 34 | mkdir -p $HOME/.gem 35 | touch $HOME/.gem/credentials 36 | chmod 0600 $HOME/.gem/credentials 37 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 38 | gem build *.gemspec 39 | gem push *.gem 40 | env: 41 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_API_KEY}}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.swp 11 | **/.tags 12 | 13 | !/doc/cfer-demo.gif 14 | 15 | vendor 16 | 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.1.2 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Cfer Change Log 2 | 3 | ## Next 4 | ## 1.0.0 5 | 6 | * Support for Ruby 3 7 | * Deps updates 8 | * Syntactic sugar for exporting values from a Cfn stack 9 | 10 | ## 0.8.0 11 | Upgrade to AWS SDK v3 12 | 13 | ## 0.7.0 14 | 15 | ### Enhancements 16 | * Adds `Fn::split()` and `Fn::cidr()` from the CloudFormation spec. 17 | * Changes the return type of the `resource` function to a `Handle` object, which eases certain uses of references and attributes. 18 | 19 | ### Bugfixes 20 | * Fixes an issue with canceling stack updates when specifying a CloudFormation Role ARN. 21 | * Fixes the git integration that records the current git hash into the stack metadata. 22 | 23 | ## 0.6.2 24 | ### Bugfixes 25 | * Fixes a Cri compatibility issue, which should have gone out in 0.6.1 26 | 27 | ## 0.6.1 28 | ### Bugfixes 29 | * Fixes an issue with version pinning of Docile. Docile 1.3 makes breaking changes, so Cfer now pins Docile 1.1.* 30 | * Removes Yard version specification. There's no particular need to pin yard to a version, and Github reported security problems with the old version. 31 | 32 | ## 0.6.0 33 | 34 | ### Enhancements 35 | * Colorized JSON in `generate` for more readable output. #41 36 | * Adds `--notification-arns` CLI option. #43 37 | * Adds `--role-arn` CLI option. #46 38 | 39 | ### Bugfixes 40 | * Don't dump backtrace when trying to delete a nonexistent stack. #42 41 | 42 | ## 0.5.0 43 | 44 | ### **BREAKING CHANGES** 45 | * `--parameters :` option is removed from CLI. Use `name=value` instead. 46 | For example: `cfer generate stack.rb parameter_name=parameter_value` 47 | 48 | ### Enhancements 49 | * Adds support for Pre-build and Post-build hooks for resources and stacks. 50 | * Adds `json-to-cfer` script to automatically convert json templates to Cfer DSL code. 51 | * Adds support for directly converging JSON files. 52 | * Replaces [Thor](https://github.com/erikhuda/thor) with [Cri](https://github.com/ddfreyne/cri) as a CLI option parser. 53 | * Relaxes some version constraints to make it easier to integrate with older Rails projects. 54 | * Pulled stack validation out into an extension using post-build hooks. 55 | * Adds some extension methods to improve usability of certain resources. 56 | * Namespace cleanup. 57 | * Supports reading ruby template from stdin by specifying the filename `-` 58 | * Adds exponential backoff to `tail` command. 59 | * `--on-failure` flag is now case insensitive. 60 | * Removes `--pretty-print` as a global option and adds `--minified` to the `generate` command. 61 | * Various test improvements. 62 | 63 | ### Bugfixes 64 | * Fixes "Stack does not exist" error being reported when stack creation fails and `--on-failure=DELETE` is specified. 65 | 66 | ## 0.4.2 67 | 68 | ### Bugfixes 69 | * Templates now uploaded to S3 in all cases where they should be. 70 | * Fixes extensions (should be `class_eval`, not `instance_eval`) 71 | 72 | ## 0.4.0 73 | 74 | ### **BREAKING CHANGES** 75 | * Provisioning is removed from Cfer core and moved to [cfer-provisioning](https://github.com/seanedwards/cfer-provisioning) 76 | 77 | ### Enhancements 78 | * Adds support for assume-role authentication with MFA (see: https://docs.aws.amazon.com/cli/latest/userguide/cli-roles.html) 79 | * Adds support for yml-format parameter files with environment-specific sections. 80 | * Adds a DSL for IAM policies. 81 | * Adds `cfer estimate` command to estimate the cost of a template using the AWS CloudFormation cost estimation API. 82 | * Enhancements to chef provisioner to allow for references in chef attributes. (Thanks to @eropple) 83 | * Adds continue/rollback/quit selection when `^C` is caught during a converge. 84 | * Stores Cfer version and Git repo information in the Repo metadata. 85 | * Added support for uploading templates to S3 with the `--s3-path` and `--force-s3` options. 86 | * Added new way of extending resources, making plugins easier. 87 | * Added support for [CloudFormation Change Sets](https://aws.amazon.com/blogs/aws/new-change-sets-for-aws-cloudformation/) via the `--change` option. 88 | 89 | ### Bugfixes 90 | 91 | ## 0.3.0 92 | 93 | ### Enhancements: 94 | * `parameters` hash now includes parameters that are set on the existing stack, but not passed in via CLI during a stack update. 95 | * `parameters` hash now includes defaults for parameters that were not passed on the CLI during a stack creation. 96 | * Adds a `lookup_output` function, for looking up outputs of stacks in the same account+region. (See #8) 97 | * Adds provisioning for cfn-init and chef-solo, including resource signaling. 98 | * Adds support for stack policies. 99 | * Cfer no longer validates parameters itself. CloudFormation will throw an error if something is wrong. 100 | * Adds release notes to the README. 101 | 102 | ### Bugfixes: 103 | * Removes automatic parameter mapping in favor of an explicit function available to resources. (Fixes Issue #8) 104 | * No more double-printing the stack summary when converging a stack with tailing enabled. 105 | * Update demo to only use 2 AZs, since us-west-1 only has two. 106 | * `AllowedValues` attribute on parameters is now an array, not a CSV string. (Thanks to @rlister) 107 | 108 | ## 0.2.0 109 | 110 | ### Enhancements: 111 | * Adds support for including other files via `include_template` function. 112 | * Adds basic Dockerfile 113 | 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.3 2 | COPY . /usr/src/app 3 | WORKDIR /usr/src/app 4 | RUN bundle install 5 | RUN rake install 6 | RUN cfer version 7 | 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in cfer.gemspec 4 | gemspec 5 | 6 | group :test do 7 | gem 'rspec' 8 | gem 'rspec-mocks' 9 | gem 'guard-rspec' 10 | end 11 | 12 | group :debug do 13 | gem 'pry' 14 | gem 'pry-byebug' 15 | gem 'pry-rescue' 16 | gem 'pry-stack_explorer' 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Sean Edwards 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cfer 2 | 3 | ![Build Status](https://github.com/seanedwards/cfer/actions/workflows/build.yml/badge.svg?branch=master) 4 | [![Gem Version](https://badge.fury.io/rb/cfer.svg)](http://badge.fury.io/rb/cfer) 5 | 6 | 7 | Cfer is a lightweight toolkit for managing CloudFormation templates. 8 | 9 | Read about Cfer [here](https://github.com/seanedwards/cfer/blob/master/examples/vpc.md). 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'cfer' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install cfer 26 | 27 | ## Usage 28 | 29 | To quickly see Cfer in action, try converging the example stacks: 30 | 31 | ```bash 32 | cfer converge vpc -t examples/vpc.rb --profile [YOUR-PROFILE] --region [YOUR-REGION] 33 | cfer converge instance -t examples/instance.rb --profile [YOUR-PROFILE] --region [YOUR-REGION] KeyName=[YOUR-EC2-SSH-KEY] 34 | ``` 35 | 36 | You should see something like this: 37 | 38 | ![Demo](https://raw.githubusercontent.com/seanedwards/cfer/master/doc/cfer-demo.gif) 39 | 40 | ### Command line 41 | 42 | COMMANDS 43 | converge Create or update a cloudformation stack according to the template 44 | delete Deletes a CloudFormation stack 45 | describe Fetches and prints information about a CloudFormation 46 | estimate Prints a link to the Amazon cost caculator estimating the cost of the resulting CloudFormation stack 47 | generate Generates a CloudFormation template by evaluating a Cfer template 48 | help show help 49 | tail Follows stack events on standard output as they occur 50 | 51 | ### Template Anatomy 52 | 53 | See the `examples` directory for some examples of complete templates. 54 | 55 | #### Parameters 56 | 57 | Parameters may be defined using the `parameter` function: 58 | 59 | ```ruby 60 | parameter :ParameterName, 61 | type: 'String', 62 | default: 'ParameterValue' 63 | ``` 64 | 65 | Any parameter can be referenced either in Ruby by using the `parameters` hash: 66 | 67 | ```ruby 68 | parameters[:ParameterName] 69 | ``` 70 | 71 | Parameters can also be used in a CloudFormation reference by using the `Fn::ref` function: 72 | 73 | ```ruby 74 | Fn::ref(:ParameterName) 75 | ``` 76 | 77 | #### Resources 78 | 79 | Resources may be defined using the `resource` function: 80 | 81 | ```ruby 82 | resource :ResourceName, 'AWS::CloudFormation::CustomResource', AttributeName: {:attribute_key => 'attribute_value'} do 83 | property_name 'property_value' 84 | end 85 | ``` 86 | 87 | Gets transformed into the corresponding CloudFormation block: 88 | 89 | ```json 90 | "ResourceName": { 91 | "Type": "AWS::CloudFormation::CustomResource", 92 | "AttributeName": { 93 | "attribute_key": "attribute_value" 94 | }, 95 | "Properties": { 96 | "PropertyName": "property_value" 97 | } 98 | } 99 | ``` 100 | 101 | #### Outputs 102 | 103 | Outputs may be defined using the `output` function: 104 | 105 | ```ruby 106 | output :OutputName, Fn::ref(:ResourceName) 107 | ``` 108 | 109 | Outputs may have an optional description: 110 | 111 | ```ruby 112 | output :OutputName, Fn::ref(:ResourceName), description: 'The resource that does stuff' 113 | ``` 114 | 115 | Outputs may be retireved from other stacks anywhere in a template by using the `lookup_output` function. 116 | 117 | ```ruby 118 | lookup_output('stack_name', 'output_name') 119 | ``` 120 | 121 | Outputs may also be exported for use by `Fn::ImportValue` in other cloudformation stacks: 122 | 123 | ```ruby 124 | output :OutputName, Fn::ref(:ResourceName), export: Fn::sub('${AWS::StackName}-OutputName') 125 | ``` 126 | 127 | #### Including code from multiple files 128 | 129 | Templates can get pretty large, and splitting template code into multiple 130 | files can help keep things more manageable. The `include_template` 131 | function works in a similar way to ruby's `require_relative`, but 132 | within the context of the CloudFormation stack: 133 | 134 | ```ruby 135 | include_template 'ec2.rb' 136 | ``` 137 | 138 | You can also include multiple files in a single call: 139 | 140 | ```ruby 141 | include_template( 142 | 'stack/ec2.rb', 143 | 'stack/elb.rb' 144 | ) 145 | ``` 146 | 147 | The path to included files is relative to the base template file 148 | (e.g. the `converge` command `-t` option). 149 | 150 | ## SDK 151 | 152 | Embedding the Cfer SDK involves interacting with two components: The `Client` and the `Stack`. 153 | The Cfer `Client` is the interface with the Cloud provider. 154 | 155 | ### Basic API 156 | 157 | The simplest way to use Cfer from Ruby looks similar to the CLI: 158 | 159 | ```ruby 160 | Cfer.converge! '', template: '' 161 | ``` 162 | 163 | This is identical to running `cfer converge --template `, but is better suited to embedding in Rakefiles, chef recipes, or your own Ruby scripts. 164 | See the Rakefile in this repository for how this might look. 165 | 166 | ### Cfn Client 167 | 168 | The Client is a wrapper around Amazon's CloudFormation client from the AWS Ruby SDK. 169 | Its purpose is to interact with the CloudFormation API. 170 | 171 | Create a new client: 172 | 173 | ```ruby 174 | Cfer::Cfn::Client.new(stack_name: ) 175 | ``` 176 | 177 | `Cfer::Cfn::Client` also accepts options to be passed into the internal `Aws::CloudFormation::Client` constructor. 178 | 179 | #### `converge(stack)` 180 | 181 | Creates or updates the CloudFormation stack to match the input `stack` object. See below for how to create Cfer stack objects. 182 | 183 | ```ruby 184 | client.converge() 185 | ``` 186 | 187 | #### `tail(options = {})` 188 | 189 | Yields to the specified block for each CloudFormation event that qualifies given the specified options. 190 | 191 | ```ruby 192 | client.tail number: 1, follow: true do |event| 193 | # Called for each CloudFormation event, as they occur, until the stack enters a COMPLETE or FAILED state. 194 | end 195 | ``` 196 | 197 | ### Cfer Stacks 198 | 199 | A Cfer stack represents a baked CloudFormation template, which is ready to be converted to JSON. 200 | 201 | Create a new stack: 202 | 203 | #### `stack_from_file` 204 | 205 | ```ruby 206 | stack = Cfer::stack_from_file(, client: ) 207 | ``` 208 | 209 | #### `stack_from_block` 210 | 211 | ```ruby 212 | stack = Cfer::stack_from_block(client: ) do 213 | # Stack definition goes here 214 | end 215 | ``` 216 | 217 | ## Contributing 218 | 219 | This project uses [git-flow](http://nvie.com/posts/a-successful-git-branching-model/). Please name branches and pull requests according to that convention. 220 | 221 | Always use `--no-ff` when merging into `develop` or `master`. 222 | 223 | This project also contains a [Code of Conduct](https://github.com/seanedwards/cfer/blob/master/CODE_OF_CONDUCT.md), which should be followed when submitting feedback or contributions to this project. 224 | 225 | ### New features 226 | 227 | * Branch from `develop` 228 | * Merge into `develop` 229 | * Name branch `feature/` 230 | 231 | ### Unreleased bugs 232 | 233 | * Branch from `develop` 234 | * Merge into `develop` 235 | * Name branch `bugfix/` 236 | 237 | ### Bugfixes against releases 238 | 239 | * Branch from `master` 240 | * Merge into `develop` and `master` 241 | * Name branch `hotfix/` 242 | 243 | ### Releases 244 | 245 | * Branch from `develop` 246 | * Merge into `develop` and `master` 247 | * Name branch `release/` 248 | 249 | # Release Notes 250 | 251 | [Change Log](https://github.com/seanedwards/cfer/blob/master/CHANGELOG.md) 252 | 253 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/cfer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | lib = File.expand_path(File.dirname(__FILE__) + '/../lib') 3 | $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib) 4 | 5 | module Cfer 6 | DEBUG = false 7 | end 8 | 9 | require 'cfer/cli' 10 | 11 | 12 | Cfer::Cli::main(ARGV) 13 | 14 | -------------------------------------------------------------------------------- /bin/cfer-dbg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | lib = File.expand_path(File.dirname(__FILE__) + '/../lib') 3 | $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib) 4 | 5 | require 'cfer/console' 6 | 7 | Pry::rescue { 8 | Cfer::Cli::main(ARGV) 9 | } 10 | 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | lib = File.expand_path(File.dirname(__FILE__) + '/../lib') 3 | $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib) 4 | 5 | require "bundler/setup" 6 | require 'cfer/console' 7 | 8 | Pry.start 9 | 10 | -------------------------------------------------------------------------------- /bin/json-to-cfer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'json' 3 | require 'pp' 4 | require 'active_support/inflector' 5 | 6 | template = JSON.parse(STDIN.read) 7 | 8 | def indent(str, prefix = ' ') 9 | prefix + str.gsub("\n", "\n#{prefix}") 10 | end 11 | 12 | def serialize_arg(arg, indent=0) 13 | ret = "" 14 | PP.pp(arg, ret) 15 | ret.strip 16 | end 17 | 18 | def serialize_args(*args) 19 | options = args.last.is_a?(Hash) ? args.pop : {} 20 | args_str = [] 21 | 22 | args.each do |arg| 23 | args_str << serialize_arg(arg) 24 | end 25 | 26 | opts_str = [] 27 | options.each do |k, v| 28 | opts_str << "#{serialize_arg(k)}: #{serialize_arg(v)}" 29 | end 30 | args_str << indent(opts_str.join(",\n"), ' ') unless opts_str.empty? 31 | 32 | args_str.join(', ').strip 33 | end 34 | 35 | description = template.delete('Description') 36 | parameters = template.delete('Parameters') || {} 37 | resources = template.delete('Resources') || {} 38 | outputs = template.delete('Outputs') || {} 39 | version = template.delete('AWSTemplateFormatVersion') 40 | 41 | if description 42 | puts "description #{serialize_arg(description)}" 43 | puts "" 44 | end 45 | 46 | puts "##############" 47 | puts "# Parameters #" 48 | puts "##############" 49 | 50 | parameters.each do |k, param| 51 | puts "parameter #{serialize_args(k, param)}" 52 | puts "" 53 | end 54 | 55 | puts "#############" 56 | puts "# Resources #" 57 | puts "#############" 58 | 59 | resources.each do |k, attrs| 60 | properties = attrs.delete('Properties') || {} 61 | type = attrs.delete('Type') 62 | 63 | puts "resource #{serialize_args(k, type, attrs)} do" 64 | properties.each do |k, v| 65 | puts indent("#{k.underscore} #{serialize_args(v)}", ' ') 66 | end 67 | puts "end" 68 | puts "" 69 | end 70 | 71 | outputs.each do |k, out| 72 | value = out.delete('Value') 73 | 74 | puts "output #{serialize_args(k, value, out)}" 75 | end 76 | 77 | puts "" 78 | 79 | template.each do |k, v| 80 | puts "self[#{serialize_arg(k)}] = #{serialize_arg(v)}" 81 | end 82 | 83 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /cfer.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'cfer/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'cfer' 8 | spec.version = Cfer::VERSION 9 | spec.authors = ['Sean Edwards'] 10 | spec.email = ['stedwards87+cfer@gmail.com'] 11 | 12 | spec.summary = %q{Toolkit for automating infrastructure using AWS CloudFormation} 13 | spec.description = spec.summary 14 | spec.homepage = 'https://github.com/seanedwards/cfer' 15 | spec.license = 'MIT' 16 | 17 | spec.required_ruby_version = ['>= 2.2.5'] 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = 'bin' 21 | spec.executables = ['cfer', 'json-to-cfer'] 22 | spec.require_paths = ['lib'] 23 | 24 | spec.add_runtime_dependency 'docile', '~> 1.4' 25 | spec.add_runtime_dependency 'cri', '~> 2.7' 26 | spec.add_runtime_dependency 'activesupport', '>= 3' 27 | spec.add_runtime_dependency 'aws-sdk-cloudformation', '~> 1' 28 | spec.add_runtime_dependency 'aws-sdk-s3', '~> 1' 29 | spec.add_runtime_dependency 'preconditions', '~> 0.3.0' 30 | spec.add_runtime_dependency 'semantic', '~> 1.4' 31 | spec.add_runtime_dependency 'rainbow', '~> 3' 32 | spec.add_runtime_dependency 'highline', '~> 2.1' 33 | spec.add_runtime_dependency 'table_print', '~> 1.5' 34 | spec.add_runtime_dependency 'git', '~> 1.3' 35 | spec.add_runtime_dependency 'rexml', '~> 3' 36 | 37 | spec.add_development_dependency 'yard' 38 | spec.add_development_dependency 'rake' 39 | spec.add_development_dependency 'bump' 40 | end 41 | -------------------------------------------------------------------------------- /doc/cfer-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seanedwards/cfer/28361f4ebc6df24b08cb7a8d36c93bfe938125df/doc/cfer-demo.gif -------------------------------------------------------------------------------- /examples/common/instance_deps.rb: -------------------------------------------------------------------------------- 1 | # By not specifying a default value, a parameter becomes required. 2 | # Specify this parameter by adding `--parameters KeyName:` to your CLI options. 3 | parameter :KeyName 4 | 5 | # We define some more parameters the same way we did in the VPC template. 6 | # Cfer will fetch the output values from the `vpc` stack we created earlier. 7 | # 8 | # If you created the VPC stack with a different name, you can overwrite these default values 9 | # by adding `Vpc: to your `--parameters` option 10 | parameter :Vpc, default: 'vpc' 11 | parameter :VpcId, default: (lookup_output(parameters[:Vpc], 'vpcid') rescue nil) 12 | parameter :SubnetId, default: (lookup_output(parameters[:Vpc], 'subnetid1') rescue nil) 13 | 14 | # This is the Ubuntu 14.04 LTS HVM AMI provided by Amazon. 15 | parameter :ImageId, default: 'ami-fce3c696' 16 | parameter :InstanceType, default: 't2.micro' 17 | 18 | # Define a security group to be applied to an instance. 19 | # This one will allow SSH access from anywhere, and no other inbound traffic. 20 | resource :instancesg, "AWS::EC2::SecurityGroup" do 21 | group_description 'Wide-open SSH' 22 | vpc_id Fn::ref(:VpcId) 23 | 24 | # Parameter values can be Ruby arrays and hashes. These will be transformed to JSON. 25 | # You could write your own functions to make stuff like this easier, too. 26 | security_group_ingress [ 27 | { 28 | CidrIp: '0.0.0.0/0', 29 | IpProtocol: 'tcp', 30 | FromPort: 22, 31 | ToPort: 22 32 | } 33 | ] 34 | end 35 | -------------------------------------------------------------------------------- /examples/instance.rb: -------------------------------------------------------------------------------- 1 | description 'Example stack template for a small EC2 instance' 2 | 3 | # NOTE: This template depends on vpc.rb 4 | 5 | # You can use the `include_template` function to include other ruby files into this Cloudformation template. 6 | include_template 'common/instance_deps.rb' 7 | 8 | # We can define extensions to resources, which extend the basic JSON-building 9 | # functionality of Cfer. Cfer provides a few of these, but you're free 10 | # to define your own by using `Cfer::Core::Resource.extend_resource` and specifying a 11 | # CloudFormation resource type. Inside the block, define any methods you'd like to use: 12 | Cfer::Core::Resource.extend_resource "AWS::EC2::Instance" do 13 | def boot_script(data) 14 | # This function simply wraps a bash script in the little bit of extra 15 | # sugar (hashbang + base64 encoding) that EC2 requires for userdata boot scripts. 16 | # See the AWS docs here: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html 17 | script = <<-EOS.strip_heredoc 18 | #!/bin/bash 19 | #{data} 20 | EOS 21 | 22 | user_data Base64.encode64(script) 23 | end 24 | end 25 | 26 | resource :instance, "AWS::EC2::Instance" do 27 | # Using the extension defined above, we can have the instance write a simple 28 | # file to show that it's working. When you converge this template, there 29 | # should be a `welcome.txt` file sitting in the `ubuntu` user's home directory. 30 | boot_script "echo 'Welcome to Cfer!' > /home/ubuntu/welcome.txt" 31 | 32 | image_id Fn::ref(:ImageId) 33 | instance_type Fn::ref(:InstanceType) 34 | key_name Fn::ref(:KeyName) 35 | 36 | network_interfaces [ { 37 | AssociatePublicIpAddress: "true", 38 | DeviceIndex: "0", 39 | GroupSet: [ Fn::ref(:instancesg) ], 40 | SubnetId: Fn::ref(:SubnetId) 41 | } ] 42 | end 43 | 44 | output :instance, Fn::ref(:instance) 45 | output :instanceip, Fn::get_att(:instance, :PublicIp) 46 | -------------------------------------------------------------------------------- /examples/vpc.md: -------------------------------------------------------------------------------- 1 | When I first encountered AWS CloudFormation, I was appalled by the format for about ten minutes. This is the criticism of it that I've heard most often: the unwieldy syntax makes it unapproachable. Writing it by hand is worse than lunch meetings. I don't disagree. 2 | 3 | However, I think CloudFormation makes a pretty decent intermediate language. Users of Elasic Beanstalk might notice a bunch of CloudFormation stacks in their account, all containing some kind of Amazon-produced EB magic. This is where CloudFormation excels: Machine-generated infrastructure. 4 | 5 | To that end, I took a couple weekends and wrote [Cfer](https://github.com/seanedwards/cfer), a DSL for generating CloudFormation templates in Ruby. [I'm](http://chrisfjones.github.io/coffin/) [not](https://github.com/bazaarvoice/cloudformation-ruby-dsl) [the](https://github.com/stevenjack/cfndsl) [only](https://github.com/Optaros/cloud_builder) [one](https://github.com/rapid7/convection) [doing](https://github.com/cloudtools/troposphere) [this](https://cfn-pyplates.readthedocs.org/en/latest/). But I'll run through an example template, which will help you build a basic VPC in AWS, and at the same time, address some of the features of Cfer that might make CloudFormation a little more appealing to you. 6 | 7 | If you find the format of this post difficult to follow, you can see this same example as a fully functional template in [examples/vpc.rb](https://github.com/seanedwards/cfer/blob/develop/examples/vpc.rb) 8 | 9 | 10 | This template creates the following resources for a basic beginning AWS VPC setup: 11 | 12 | 1. A VPC 13 | 2. A route table to control network routing 14 | 3. An Internet gateway to route traffic to the public internet 15 | 4. 3 subnets, one in each of the account's first 3 availability zones 16 | 5. A default network route to the IGW 17 | 6. Associated plumbing resources to link it all together 18 | 19 | ## Template Parameters 20 | 21 | Template parameters allow you to use the same template to build multiple similar instances of parts of your infrastructure. Parameters may be defined using the `parameter` function: 22 | 23 | ```ruby 24 | parameter :VpcName, default: 'Example VPC' 25 | ``` 26 | 27 | Resources are created using the `resource` function, accepting the following arguments: 28 | 29 | 1. The resource name (string or symbol) 30 | 2. The resource type. See the AWS CloudFormation docs for the [available resource types](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html). 31 | 32 | 33 | ## The VPC Resource 34 | 35 | The VPC is the foundation of your private network in AWS. 36 | 37 | ```ruby 38 | resource :vpc, 'AWS::EC2::VPC' do 39 | ``` 40 | 41 | Each line within the resource block sets a single property. These properties are simply camelized using the ActiveSupport gem's `camelize` function. This means that the `cidr_block` function will set the `CidrBlock` property. 42 | 43 | ```ruby 44 | cidr_block '172.42.0.0/16' 45 | ``` 46 | 47 | Following this pattern, `enable_dns_support` sets the `EnableDnsSupport` property. 48 | 49 | ```ruby 50 | enable_dns_support true 51 | enable_dns_hostnames true 52 | instance_tenancy 'default' 53 | ``` 54 | 55 | The `tag` function is available on all resources, and adds keys to the resource's `Tags` property. It accepts the following arguments: 56 | 57 | 1. Tag name (symbol or string) 58 | 2. Tag value 59 | 60 | ```ruby 61 | tag :DefaultVpc, true 62 | ``` 63 | 64 | Parameters are required at template generation time, and therefore may be referenced using the `parameters` hash anywhere in a template. This will render the parameter value as a string constant in the CloudFormation JSON output. 65 | 66 | ```ruby 67 | tag :Name, parameters[:VpcName] 68 | ``` 69 | 70 | Finally, we can finish this resource by closing the block that we started when we called the `resource` function. 71 | 72 | ```ruby 73 | end 74 | ``` 75 | 76 | ## The Internet Gateway 77 | 78 | Instances in your VPC will need to be able to access the internet somehow, and an internet gateway is the mechanism for making this happen. Let's create one. 79 | 80 | If there are no properties to set on a resource, the `do..end` block may be omitted entirely 81 | 82 | ```ruby 83 | resource :defaultigw, 'AWS::EC2::InternetGateway' 84 | ``` 85 | 86 | ## Attaching the Gateway 87 | 88 | For a gateway to be routable, it needs to be attached to a specific VPC using a "VPC Gateway Attachment" resource. 89 | 90 | `Fn::ref` serves the same purpose as CloudFormation's `{"Ref": ""}` intrinsic function. 91 | 92 | ```ruby 93 | resource :vpcigw, 'AWS::EC2::VPCGatewayAttachment' do 94 | vpc_id Fn::ref(:vpc) 95 | internet_gateway_id Fn::ref(:defaultigw) 96 | end 97 | ``` 98 | 99 | ## The Route Table 100 | 101 | A VPC also needs a route table. Every VPC comes with a default route table, but I like to create my own resources so that they're all expressed in the template. 102 | 103 | ```ruby 104 | resource :routetable, 'AWS::EC2::RouteTable' do 105 | vpc_id Fn::ref(:vpc) 106 | end 107 | ``` 108 | 109 | ## The Default Route 110 | 111 | We also have to set up a default route, so that any traffic that the VPC doesn't recognize gets routed off to the internet gateway. 112 | 113 | The `resource` function accepts one additional parameter that was not addressed above: the options hash. Additional options passed here will be placed inside the resource, but outside the `Properties` block. In this case, we've specified that the default route explicitly depends on the VPC Internet Gateway. 114 | 115 | As of this writing, this is actually a required workaround for this template. The gateway must be attached to the VPC before a route can be created to it, but since the gateway attachment isn't actually referenced anywhere in this resource, we need to explicitly declare that dependency. 116 | 117 | ```ruby 118 | resource :defaultroute, 'AWS::EC2::Route', DependsOn: [:vpcigw] do 119 | route_table_id Fn::ref(:routetable) 120 | gateway_id Fn::ref(:defaultigw) 121 | destination_cidr_block '0.0.0.0/0' 122 | end 123 | ``` 124 | 125 | ## The Subnets 126 | 127 | Naturally, you'll also need networks. Like the route table, I like to create my own so that I have control of their configuration inside the template. 128 | 129 | Notice `Fn::select`, `Fn::get_azs` and `AWS::region` in this snippet. These all map to the CloudFormation functions and variables of the same name. 130 | 131 | We'll use Ruby to create three identical subnets: 132 | 133 | ```ruby 134 | (1..3).each do |i| 135 | ``` 136 | 137 | The subnets themselves will be in the first three availability zones of the account. A more sophisticated template might want to handle this differently. 138 | 139 | ```ruby 140 | resource "subnet#{i}", 'AWS::EC2::Subnet' do 141 | availability_zone Fn::select(i, Fn::get_azs(AWS::region)) 142 | cidr_block "172.42.#{i}.0/24" 143 | vpc_id Fn::ref(:vpc) 144 | end 145 | ``` 146 | 147 | Now the subnet needs to be associated with the route table, so that hosts in the subnet are able to access the rest of the network and the internet. 148 | 149 | ```ruby 150 | resource "srta#{i}".to_sym, 'AWS::EC2::SubnetRouteTableAssociation' do 151 | subnet_id Fn::ref("subnet#{i}") 152 | route_table_id Fn::ref(:routetable) 153 | end 154 | ``` 155 | 156 | We can use the `output` function to output the subnet IDs we've just created. We'll go over how Cfer makes this useful in part 2 of this post. 157 | 158 | ```ruby 159 | output "subnetid#{i}", Fn::ref("subnet#{i}") 160 | ``` 161 | 162 | And of course, end the iteration block. 163 | 164 | ```ruby 165 | end 166 | ``` 167 | 168 | 169 | Finally, let's output the VPC ID too, since we'll probably need that in other templates. 170 | 171 | ```ruby 172 | output :vpcid, Fn::ref(:vpc) 173 | ``` 174 | 175 | ## Converging the Stack 176 | 177 | Now that you have your template ready, you'll be able to use Cfer to create or update a CloudFormation stack: 178 | 179 | ```bash 180 | cfer converge vpc -t examples/vpc.rb --profile ${AWS_PROFILE} --region ${AWS_REGION} 181 | ``` 182 | 183 | Which should produce something like this. 184 | 185 | ![Cfer Demo](https://raw.githubusercontent.com/seanedwards/cfer/master/doc/cfer-demo.gif) 186 | 187 | Use `cfer help` to get more usage information, or check `README.md` and `Rakefile` in the source repository to see how to embed Cfer into your own projects. 188 | 189 | ## In Part 2... 190 | 191 | In part 2, we'll go over some additional features of Cfer. If you want a preview, you can check out the [instance.rb](https://github.com/seanedwards/cfer/blob/develop/examples/instance.rb) example, which covers how you can use Cfer to create security groups, instances with automated provisioning, and how to automatically look up outputs from other stacks. 192 | 193 | Cfer can be found on [GitHub](https://github.com/seanedwards/cfer) and [RubyGems](https://rubygems.org/gems/cfer) and is MIT licensed. Pull requests are welcome. 194 | 195 | -------------------------------------------------------------------------------- /examples/vpc.rb: -------------------------------------------------------------------------------- 1 | description 'Stack template for a simple example VPC' 2 | 3 | # This template creates the following resources for a basic beginning AWS VPC setup: 4 | # 5 | # 1) A VPC 6 | # 2) A route table to control network routing 7 | # 3) An Internet gateway to route traffic to the public internet 8 | # 4) 3 subnets, one in each of the account's first 3 availability zones 9 | # 5) A default network route to the IGW 10 | # 6) Associated plumbing resources to link it all together 11 | 12 | # Parameters may be defined using the `parameter` function 13 | parameter :VpcName, default: 'Example VPC' 14 | 15 | # Resources are created using the `resource` function, accepting the following arguments: 16 | # 1) The resource name (string or symbol) 17 | # 2) The resource type. See the AWS CloudFormation docs for the available resource types: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html 18 | resource :vpc, 'AWS::EC2::VPC' do 19 | # Each line within the resource block sets a single property. 20 | # These properties are simply camelized using the ActiveSupport gem's `camelize` function. 21 | # This means that the `cidr_block` function will set the `CidrBlock` property. 22 | cidr_block '172.42.0.0/16' 23 | 24 | # Following this pattern, `enable_dns_support` sets the `EnableDnsSupport` property. 25 | enable_dns_support true 26 | enable_dns_hostnames true 27 | instance_tenancy 'default' 28 | 29 | # The `tag` function is available on all resources, and adds keys to the resource's `Tags` property. It accepts the following arguments: 30 | # 1) Tag name (symbol or string) 31 | # 2) Tag value 32 | tag :DefaultVpc, true 33 | 34 | # Parameters are required at template generation time, and therefore may be referenced using the `parameters` hash anywhere in a template. 35 | # This will render the parameter value as a string constant in the CloudFormation JSON output 36 | tag :Name, parameters[:VpcName] 37 | end 38 | 39 | # If there are no properties to set on a resource, the block may be omitted entirely 40 | resource :defaultigw, 'AWS::EC2::InternetGateway' 41 | 42 | resource :vpcigw, 'AWS::EC2::VPCGatewayAttachment' do 43 | # Fn::ref serves the same purpose as CloudFormation's {"Ref": ""} intrinsic function. 44 | vpc_id Fn::ref(:vpc) 45 | internet_gateway_id Fn::ref(:defaultigw) 46 | end 47 | 48 | resource :routetable, 'AWS::EC2::RouteTable' do 49 | vpc_id Fn::ref(:vpc) 50 | end 51 | 52 | (1..2).each do |i| 53 | resource "subnet#{i}", 'AWS::EC2::Subnet' do 54 | # Other CloudFormation intrinsics, such as `Fn::Select` and `AWS::Region` are available as Ruby objects 55 | # Inspecting these functions will reveal that they simply return a Ruby hash representing the same CloudFormation structures 56 | availability_zone Fn::select(i, Fn::get_azs(AWS::region)) 57 | # this calculates "172.42.#{i}.0/24" 58 | cidr_block Fn::select(i, Fn::cidr(Fn::get_att(:vpc, :CidrBlock), 256, 8)) 59 | vpc_id Fn::ref(:vpc) 60 | end 61 | 62 | resource "srta#{i}".to_sym, 'AWS::EC2::SubnetRouteTableAssociation' do 63 | subnet_id Fn::ref("subnet#{i}") 64 | route_table_id Fn::ref(:routetable) 65 | end 66 | 67 | # Functions do not need to be called in any particular order. 68 | # The `output` function defines a stack output, which may be referenced from another stack using the `@stack_name.output_name` format 69 | output "subnetid#{i}", Fn::ref("subnet#{i}") 70 | end 71 | 72 | # The `resource` function accepts one additional parameter that was not addressed above: the options hash 73 | # Additional options passed here will be placed inside the resource, but outside the `Properties` block. 74 | # In this case, we've specified that the default route explicitly depends on the VPC Internet Gateway. 75 | # (As of this writing, this is actually a required workaround for this template, 76 | # because the gateway must be attached to the VPC before a route can be created to it.) 77 | resource :defaultroute, 'AWS::EC2::Route', DependsOn: [:vpcigw] do 78 | route_table_id Fn::ref(:routetable) 79 | gateway_id Fn::ref(:defaultigw) 80 | destination_cidr_block '0.0.0.0/0' 81 | end 82 | 83 | output :vpcid, Fn::ref(:vpc) 84 | 85 | 86 | -------------------------------------------------------------------------------- /lib/cfer.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | require 'aws-sdk-cloudformation' 3 | require 'aws-sdk-s3' 4 | require 'logger' 5 | require 'json' 6 | require 'preconditions' 7 | require 'rainbow' 8 | 9 | 10 | # Contains extensions that Cfer will dynamically use 11 | module CferExt 12 | module AWS 13 | end 14 | end 15 | 16 | # Contains the core Cfer logic 17 | module Cfer 18 | DEBUG = false unless defined? DEBUG 19 | 20 | # Code relating to working with Amazon CloudFormation 21 | module Cfn 22 | end 23 | 24 | # Code relating to building the CloudFormation document out of the Ruby DSL 25 | module Core 26 | end 27 | 28 | %w{ 29 | DB 30 | ASG 31 | }.each do |acronym| 32 | ActiveSupport::Inflector.inflections.acronym acronym 33 | end 34 | 35 | # The Cfer logger 36 | LOGGER = Logger.new(STDERR) 37 | LOGGER.level = Logger::INFO 38 | LOGGER.formatter = proc { |severity, _datetime, _progname, msg| 39 | msg = 40 | case severity 41 | when 'FATAL' 42 | Rainbow(msg).red.bright 43 | when 'ERROR' 44 | Rainbow(msg).red 45 | when 'WARN' 46 | Rainbow(msg).yellow 47 | when 'DEBUG' 48 | Rainbow(msg).black.bright 49 | else 50 | msg 51 | end 52 | 53 | "#{msg}\n" 54 | } 55 | 56 | class << self 57 | 58 | # Creates or updates a CloudFormation stack 59 | # @param stack_name [String] The name of the stack to update 60 | # @param options [Hash] 61 | def converge!(stack_name, options = {}) 62 | config(options) 63 | options[:on_failure].upcase! if options[:on_failure] 64 | tmpl = options[:template] || "#{stack_name}.rb" 65 | cfn = options[:aws_options] || {} 66 | 67 | cfn_stack = options[:cfer_client] || Cfer::Cfn::Client.new(cfn.merge(stack_name: stack_name)) 68 | raise Cfer::Util::CferError, "No such template file: #{tmpl}" unless File.exist?(tmpl) || options[:cfer_stack] 69 | stack = 70 | options[:cfer_stack] || 71 | Cfer::stack_from_file(tmpl, 72 | options.merge( 73 | client: cfn_stack, 74 | parameters: generate_final_parameters(options) 75 | ) 76 | ) 77 | 78 | begin 79 | operation = stack.converge!(options) 80 | if options[:follow] && !options[:change] 81 | begin 82 | tail! stack_name, options.merge(cfer_client: cfn_stack) 83 | rescue Interrupt 84 | puts "Caught interrupt. What would you like to do?" 85 | case HighLine.new($stdin, $stderr).choose('Continue', 'Quit', 'Rollback') 86 | when 'Continue' 87 | retry 88 | when 'Rollback' 89 | rollback_opts = { 90 | stack_name: stack_name 91 | } 92 | 93 | case operation 94 | when :created 95 | rollback_opts[:role_arn] = options[:role_arn] if options[:role_arn] 96 | cfn_stack.delete_stack rollback_opts 97 | when :updated 98 | cfn_stack.cancel_update_stack rollback_opts 99 | end 100 | retry 101 | end 102 | end 103 | end 104 | # This is allowed to fail, particularly if we decided to roll back 105 | describe! stack_name, options rescue nil 106 | rescue Aws::CloudFormation::Errors::ValidationError => e 107 | Cfer::LOGGER.info "CFN validation error: #{e.message}" 108 | end 109 | stack 110 | end 111 | 112 | def describe!(stack_name, options = {}) 113 | config(options) 114 | cfn = options[:aws_options] || {} 115 | cfn_stack = options[:cfer_client] || Cfer::Cfn::Client.new(cfn.merge(stack_name: stack_name)) 116 | cfn_metadata = cfn_stack.fetch_metadata 117 | cfn_stack = cfn_stack.fetch_stack 118 | 119 | cfer_version = cfn_metadata.fetch("Cfer", {}).fetch("Version", nil) 120 | if cfer_version 121 | cfer_version_str = [cfer_version["major"], cfer_version["minor"], cfer_version["patch"]].join '.' 122 | cfer_version_str << '-' << cfer_version["pre"] unless cfer_version["pre"].nil? 123 | cfer_version_str << '+' << cfer_version["build"] unless cfer_version["build"].nil? 124 | end 125 | 126 | Cfer::LOGGER.debug "Describe stack: #{cfn_stack}" 127 | Cfer::LOGGER.debug "Describe metadata: #{cfn_metadata}" 128 | 129 | case options[:output_format] 130 | when 'json' 131 | puts render_json(cfn_stack, options) 132 | when 'table', nil 133 | puts "Status: #{cfn_stack[:stack_status]}" 134 | puts "Description: #{cfn_stack[:description]}" if cfn_stack[:description] 135 | puts "Created with Cfer version: #{Semantic::Version.new(cfer_version_str)} (current: #{Cfer::SEMANTIC_VERSION.to_s})" if cfer_version 136 | puts "" 137 | def tablify(list, type) 138 | list ||= [] 139 | list.map { |param| 140 | { 141 | :Type => type.to_s.titleize, 142 | :Key => param[:"#{type}_key"], 143 | :Value => param[:"#{type}_value"] 144 | } 145 | } 146 | end 147 | parameters = tablify(cfn_stack[:parameters] || [], 'parameter') 148 | outputs = tablify(cfn_stack[:outputs] || [], 'output') 149 | tp parameters + outputs, :Type, :Key, {:Value => {:width => 80}} 150 | else 151 | raise Cfer::Util::CferError, "Invalid output format #{options[:output_format]}." 152 | end 153 | cfn_stack 154 | end 155 | 156 | def tail!(stack_name, options = {}, &block) 157 | config(options) 158 | cfn = options[:aws_options] || {} 159 | cfn_client = options[:cfer_client] || Cfer::Cfn::Client.new(cfn.merge(stack_name: stack_name)) 160 | if block 161 | cfn_client.tail(options, &block) 162 | else 163 | cfn_client.tail(options) do |event| 164 | Cfer::LOGGER.info "%s %-30s %-40s %-20s %s" % [event.timestamp, color_map(event.resource_status), event.resource_type, event.logical_resource_id, event.resource_status_reason] 165 | end 166 | end 167 | end 168 | 169 | def generate!(tmpl, options = {}) 170 | config(options) 171 | cfn = options[:aws_options] || {} 172 | 173 | cfn_stack = options[:cfer_client] || Cfer::Cfn::Client.new(cfn) 174 | raise Cfer::Util::CferError, "No such template file: #{tmpl}" unless File.exist?(tmpl) || options[:cfer_stack] 175 | stack = options[:cfer_stack] || Cfer::stack_from_file(tmpl, 176 | options.merge(client: cfn_stack, parameters: generate_final_parameters(options))).to_h 177 | puts render_json(stack, options) 178 | end 179 | 180 | def estimate!(tmpl, options = {}) 181 | config(options) 182 | cfn = options[:aws_options] || {} 183 | 184 | cfn_stack = options[:cfer_client] || Cfer::Cfn::Client.new(cfn) 185 | stack = options[:cfer_stack] || Cfer::stack_from_file(tmpl, 186 | options.merge(client: cfn_stack, parameters: generate_final_parameters(options))) 187 | puts cfn_stack.estimate(stack) 188 | end 189 | 190 | def delete!(stack_name, options = {}) 191 | config(options) 192 | cfn = options[:aws_options] || {} 193 | cfn_stack = options[:cfer_client] || cfn_stack = Cfer::Cfn::Client.new(cfn.merge(stack_name: stack_name)) 194 | 195 | delete_opts = { 196 | stack_name: stack_name 197 | } 198 | delete_opts[:role_arn] = options[:role_arn] if options[:role_arn] 199 | cfn_stack.delete_stack(delete_opts) 200 | 201 | if options[:follow] 202 | tail! stack_name, options.merge(cfer_client: cfn_stack) 203 | end 204 | rescue Aws::CloudFormation::Errors::ValidationError => e 205 | if e.message =~ /Stack .* does not exist/ 206 | raise Cfer::Util::StackDoesNotExistError, e.message 207 | else 208 | raise e 209 | end 210 | end 211 | 212 | # Builds a Cfer::Core::Stack from a Ruby block 213 | # 214 | # @param options [Hash] The stack options 215 | # @param block The block containing the Cfn DSL 216 | # @option options [Hash] :parameters The CloudFormation stack parameters 217 | # @return [Cfer::Core::Stack] The assembled stack object 218 | def stack_from_block(options = {}, &block) 219 | s = Cfer::Core::Stack.new(options) 220 | templatize_errors('block') do 221 | s.build_from_block(&block) 222 | end 223 | s 224 | end 225 | 226 | # Builds a Cfer::Core::Stack from a ruby script 227 | # 228 | # @param file [String] The file containing the Cfn DSL or plain JSON 229 | # @param options [Hash] (see #stack_from_block) 230 | # @return [Cfer::Core::Stack] The assembled stack object 231 | def stack_from_file(file, options = {}) 232 | return stack_from_stdin(options) if file == '-' 233 | 234 | s = Cfer::Core::Stack.new(options) 235 | templatize_errors(file) do 236 | s.build_from_file file 237 | end 238 | s 239 | end 240 | 241 | # Builds a Cfer::Core::Stack from stdin 242 | # 243 | # @param options [Hash] (see #stack_from_block) 244 | # @return [Cfer::Core::Stack] The assembled stack object 245 | def stack_from_stdin(options = {}) 246 | s = Cfer::Core::Stack.new(options) 247 | templatize_errors('STDIN') do 248 | s.build_from_string STDIN.read, 'STDIN' 249 | end 250 | s 251 | end 252 | 253 | private 254 | 255 | def config(options) 256 | Cfer::LOGGER.debug "Options: #{options}" 257 | Cfer::LOGGER.level = Logger::DEBUG if options[:verbose] 258 | 259 | Aws.config.update region: options[:region] if options[:region] 260 | Aws.config.update credentials: Cfer::Cfn::CferCredentialsProvider.new(profile_name: options[:profile]) if options[:profile] 261 | end 262 | 263 | def generate_final_parameters(options) 264 | raise Cfer::Util::CferError, "parameter-environment set but parameter_file not set" \ 265 | if options[:parameter_environment] && options[:parameter_file].nil? 266 | 267 | final_params = HashWithIndifferentAccess.new 268 | 269 | final_params.deep_merge! Cfer::Config.new(cfer: options) \ 270 | .build_from_file(options[:parameter_file]) \ 271 | .to_h if options[:parameter_file] 272 | 273 | if options[:parameter_environment] 274 | raise Cfer::Util::CferError, "no key '#{options[:parameter_environment]}' found in parameters file." \ 275 | unless final_params.key?(options[:parameter_environment]) 276 | 277 | Cfer::LOGGER.debug "Merging in environment key #{options[:parameter_environment]}" 278 | 279 | final_params.deep_merge!(final_params[options[:parameter_environment]]) 280 | end 281 | 282 | final_params.deep_merge!(options[:parameters] || {}) 283 | 284 | Cfer::LOGGER.debug "Final parameters: #{final_params}" 285 | final_params 286 | end 287 | 288 | def render_json(obj, options = {}) 289 | if options[:pretty_print] 290 | puts Cfer::Util::Json.format_json(obj) 291 | else 292 | puts obj.to_json 293 | end 294 | end 295 | 296 | def templatize_errors(base_loc) 297 | yield 298 | rescue Cfer::Util::CferError => e 299 | raise e 300 | rescue SyntaxError => e 301 | raise Cfer::Util::TemplateError.new([]), e.message 302 | rescue StandardError => e 303 | raise e #Cfer::Util::TemplateError.new(convert_backtrace(base_loc, e)), e.message 304 | end 305 | 306 | def convert_backtrace(base_loc, exception) 307 | continue_search = true 308 | exception.backtrace_locations.take_while { |loc| 309 | continue_search = false if loc.path == base_loc 310 | continue_search || loc.path == base_loc 311 | } 312 | end 313 | 314 | 315 | COLORS_MAP = { 316 | 'CREATE_IN_PROGRESS' => { color: :yellow }, 317 | 'DELETE_IN_PROGRESS' => { color: :yellow }, 318 | 'UPDATE_IN_PROGRESS' => { color: :green }, 319 | 320 | 'CREATE_FAILED' => { color: :red, finished: true }, 321 | 'DELETE_FAILED' => { color: :red, finished: true }, 322 | 'UPDATE_FAILED' => { color: :red, finished: true }, 323 | 324 | 'CREATE_COMPLETE' => { color: :green, finished: true }, 325 | 'DELETE_COMPLETE' => { color: :green, finished: true }, 326 | 'UPDATE_COMPLETE' => { color: :green, finished: true }, 327 | 328 | 'DELETE_SKIPPED' => { color: :yellow }, 329 | 330 | 'ROLLBACK_IN_PROGRESS' => { color: :red }, 331 | 332 | 'UPDATE_ROLLBACK_COMPLETE' => { color: :red, finished: true }, 333 | 'ROLLBACK_COMPLETE' => { color: :red, finished: true } 334 | } 335 | 336 | def color_map(str) 337 | if COLORS_MAP.include?(str) 338 | Rainbow(str).send(COLORS_MAP[str][:color]) 339 | else 340 | str 341 | end 342 | end 343 | 344 | def stopped_state?(str) 345 | COLORS_MAP[str][:finished] || false 346 | end 347 | end 348 | end 349 | 350 | %w{ 351 | version.rb 352 | block.rb 353 | config.rb 354 | 355 | util/error.rb 356 | util/json.rb 357 | 358 | core/hooks.rb 359 | core/client.rb 360 | core/functions.rb 361 | core/resource.rb 362 | core/stack.rb 363 | 364 | cfn/cfer_credentials_provider.rb 365 | cfn/client.rb 366 | }.each do |f| 367 | require "#{File.dirname(__FILE__)}/cfer/#{f}" 368 | end 369 | Dir["#{File.dirname(__FILE__)}/cferext/**/*.rb"].each { |f| require(f) } 370 | -------------------------------------------------------------------------------- /lib/cfer/block.rb: -------------------------------------------------------------------------------- 1 | require 'docile' 2 | require 'json' 3 | require 'yaml' 4 | 5 | module Cfer 6 | # Represents the base class of a Cfer DSL 7 | class Block < ActiveSupport::HashWithIndifferentAccess 8 | # Evaluates a DSL directly from a Ruby block, calling pre- and post- hooks. 9 | # @param args [Array] Extra arguments to be passed into the block. 10 | def build_from_block(*args, &block) 11 | pre_block 12 | Docile.dsl_eval(self, *args, &block) if block 13 | post_block 14 | self 15 | end 16 | 17 | # Evaluates a DSL from a Ruby string 18 | # @param args [Array] Extra arguments to be passed into the block 19 | # @param str [String] The Cfer source template to evaluate 20 | # @param file [File] The file that will be reported in any error messages 21 | def build_from_string(*args, str, file) 22 | build_from_block(*args) do 23 | instance_eval str, file 24 | end 25 | self 26 | end 27 | 28 | # Evaluates a DSL from a Ruby script file 29 | # @param args [Array] (see: #build_from_block) 30 | # @param file [File] The Ruby script to evaluate 31 | def build_from_file(*args, file) 32 | build_from_block(*args) do 33 | include_file(file) 34 | end 35 | end 36 | 37 | def include_file(file) 38 | Preconditions.check(file).is_not_nil 39 | raise Cfer::Util::FileDoesNotExistError, "#{file} does not exist." unless File.exist?(file) 40 | 41 | case File.extname(file) 42 | when '.json' 43 | deep_merge! JSON.parse(IO.read(file)) 44 | when '.yml', '.yaml' 45 | deep_merge! YAML.load_file(file) 46 | else 47 | instance_eval File.read(file), file 48 | end 49 | end 50 | 51 | # Executed just before the DSL is evaluated 52 | def pre_block 53 | end 54 | 55 | # Executed just after the DSL is evaluated 56 | def post_block 57 | end 58 | end 59 | 60 | # BlockHash is a Block that responds to DSL-style properties. 61 | class BlockHash < Block 62 | NON_PROXIED_METHODS = [ 63 | :parameters, 64 | :options, 65 | :lookup_output, 66 | :lookup_outputs, 67 | :__docile_undo_fallback__ 68 | ].freeze 69 | 70 | # Directly sets raw properties in the underlying CloudFormation structure. 71 | # @param keyvals [Hash] The properties to set on this object. 72 | def properties(keyvals = {}) 73 | self.merge!(keyvals) 74 | end 75 | 76 | # Gets the current value of a given property 77 | # @param key [String] The name of the property to fetch 78 | def get_property(key) 79 | self.fetch key 80 | end 81 | 82 | def respond_to?(method_sym) 83 | !non_proxied_methods.include?(method_sym) 84 | end 85 | 86 | def method_missing(method_sym, *arguments, &block) 87 | key = camelize_property(method_sym) 88 | properties key => 89 | case arguments.size 90 | when 0 91 | if block 92 | BlockHash.new.build_from_block(&block) 93 | else 94 | raise "Expected a value or block when setting property #{key}" 95 | end 96 | when 1 97 | arguments.first 98 | else 99 | arguments 100 | end 101 | end 102 | 103 | private 104 | def non_proxied_methods 105 | NON_PROXIED_METHODS 106 | end 107 | 108 | def camelize_property(sym) 109 | sym.to_s.camelize.to_sym 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/cfer/cfn/cfer_credentials_provider.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module Cfer 4 | module Cfn 5 | class CferCredentialsProvider < Aws::SharedCredentials 6 | private 7 | 8 | def load_from_path 9 | profile = load_profile 10 | credentials = Aws::Credentials.new( 11 | profile['aws_access_key_id'], 12 | profile['aws_secret_access_key'], 13 | profile['aws_session_token'] 14 | ) 15 | @credentials = 16 | if role_arn = profile['role_arn'] 17 | role_creds = 18 | begin 19 | YAML::load_file('.cfer-role') 20 | rescue 21 | {} 22 | end 23 | 24 | if stored_creds = role_creds[profile_name] 25 | if (Time.now.to_i + 5 * 60) > stored_creds[:expiration].to_i 26 | stored_creds = nil 27 | end 28 | end 29 | 30 | if stored_creds == nil 31 | role_credentials_options = { 32 | role_session_name: [*('A'..'Z')].sample(16).join, 33 | role_arn: role_arn, 34 | credentials: credentials 35 | } 36 | 37 | if profile['mfa_serial'] 38 | role_credentials_options[:serial_number] ||= profile['mfa_serial'] 39 | role_credentials_options[:token_code] ||= HighLine.new($stdin, $stderr).ask('Enter MFA Code:') 40 | end 41 | 42 | creds = Aws::AssumeRoleCredentials.new(role_credentials_options) 43 | stored_creds = { 44 | expiration: creds.expiration, 45 | credentials: creds.credentials 46 | } 47 | role_creds[profile_name] = stored_creds 48 | end 49 | 50 | IO.write('.cfer-role', YAML.dump(role_creds)) 51 | stored_creds[:credentials] 52 | else 53 | credentials 54 | end 55 | end 56 | 57 | def load_profile 58 | if profile = profiles[profile_name] 59 | # Add all options from source profile 60 | if source = profile.delete('source_profile') 61 | profiles[source].merge(profile) 62 | else 63 | profile 64 | end 65 | else 66 | msg = "Profile `#{profile_name}' not found in #{path}" 67 | raise Aws::Errors::NoSuchProfileError, msg 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/cfer/cfn/client.rb: -------------------------------------------------------------------------------- 1 | require_relative '../core/client' 2 | require 'uri' 3 | 4 | module Cfer::Cfn 5 | 6 | class Client < Cfer::Core::Client 7 | attr_reader :name 8 | attr_reader :stack 9 | 10 | def initialize(options) 11 | super(options) 12 | @name = options[:stack_name] 13 | @options = options 14 | @options.delete :stack_name 15 | @cfn = Aws::CloudFormation::Client.new(@options) 16 | flush_cache 17 | end 18 | 19 | def create_stack(*args) 20 | begin 21 | @cfn.create_stack(*args) 22 | rescue Aws::CloudFormation::Errors::AlreadyExistsException 23 | raise Cfer::Util::StackExistsError 24 | end 25 | end 26 | 27 | def responds_to?(method) 28 | @cfn.responds_to? method 29 | end 30 | 31 | def method_missing(method, *args, &block) 32 | @cfn.send(method, *args, &block) 33 | end 34 | 35 | def estimate(stack, options = {}) 36 | estimate_options = upload_or_return_template(stack.to_cfn, options) 37 | response = validate_template(estimate_options) 38 | 39 | estimate_params = [] 40 | response.parameters.each do |tmpl_param| 41 | input_param = stack.input_parameters[tmpl_param.parameter_key] 42 | if input_param 43 | output_val = tmpl_param.no_echo ? '*****' : input_param 44 | Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key}=#{output_val}" 45 | p = { 46 | parameter_key: tmpl_param.parameter_key, 47 | parameter_value: input_param, 48 | use_previous_value: false 49 | } 50 | 51 | estimate_params << p 52 | end 53 | end 54 | 55 | estimate_response = estimate_template_cost(estimate_options.merge(parameters: estimate_params)) 56 | estimate_response.url 57 | end 58 | 59 | def converge(stack, options = {}) 60 | Preconditions.check(@name).is_not_nil 61 | Preconditions.check(stack) { is_not_nil and has_type(Cfer::Core::Stack) } 62 | 63 | template_options = upload_or_return_template(stack.to_cfn, options) 64 | 65 | response = validate_template(template_options) 66 | 67 | create_params = [] 68 | update_params = [] 69 | 70 | previous_parameters = fetch_parameters rescue nil 71 | 72 | current_version = Cfer::SEMANTIC_VERSION 73 | previous_version = fetch_cfer_version rescue nil 74 | 75 | current_hash = stack.git_state.sha rescue nil 76 | previous_hash = fetch_git_hash rescue nil 77 | 78 | # Compare current and previous versions and hashes? 79 | 80 | response.parameters.each do |tmpl_param| 81 | input_param = stack.input_parameters[tmpl_param.parameter_key] 82 | old_param = previous_parameters[tmpl_param.parameter_key] if previous_parameters 83 | 84 | Cfer::LOGGER.debug "== Evaluating Parameter '#{tmpl_param.parameter_key.to_s}':" 85 | Cfer::LOGGER.debug "Input value: #{input_param.to_s || 'nil'}" 86 | Cfer::LOGGER.debug "Previous value: #{old_param.to_s || 'nil'}" 87 | 88 | 89 | if input_param 90 | output_val = tmpl_param.no_echo ? '*****' : input_param 91 | Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key}=#{output_val}" 92 | p = { 93 | parameter_key: tmpl_param.parameter_key, 94 | parameter_value: input_param, 95 | use_previous_value: false 96 | } 97 | 98 | create_params << p 99 | update_params << p 100 | else 101 | if old_param 102 | Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key} is unspecified (unchanged)" 103 | update_params << { 104 | parameter_key: tmpl_param.parameter_key, 105 | use_previous_value: true 106 | } 107 | else 108 | Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key} is unspecified (default)" 109 | end 110 | end 111 | end 112 | 113 | Cfer::LOGGER.debug "===================" 114 | 115 | stack_options = options[:stack_options] || {} 116 | 117 | stack_options.merge! stack_name: name, capabilities: response.capabilities 118 | 119 | stack_options[:on_failure] = options[:on_failure] if options[:on_failure] 120 | stack_options[:timeout_in_minutes] = options[:timeout] if options[:timeout] 121 | stack_options[:role_arn] = options[:role_arn] if options[:role_arn] 122 | stack_options[:notification_arns] = options[:notification_arns] if options[:notification_arns] 123 | stack_options[:enable_termination_protection] = options[:enable_termination_protection] if options[:enable_termination_protection] 124 | 125 | stack_options.merge! parse_stack_policy(:stack_policy, options[:stack_policy]) 126 | 127 | stack_options.merge! template_options 128 | 129 | cfn_stack = 130 | begin 131 | create_stack stack_options.merge parameters: create_params 132 | :created 133 | rescue Cfer::Util::StackExistsError 134 | if options[:change] 135 | create_change_set stack_options.merge change_set_name: options[:change], description: options[:change_description], parameters: update_params 136 | else 137 | stack_options.merge! parse_stack_policy(:stack_policy_during_update, options[:stack_policy_during_update]) 138 | update_stack stack_options.merge parameters: update_params 139 | end 140 | :updated 141 | end 142 | 143 | flush_cache 144 | cfn_stack 145 | end 146 | 147 | # Yields to the given block for each CloudFormation event that qualifies, given the specified options. 148 | # @param options [Hash] The options hash 149 | # @option options [Fixnum] :number The maximum number of already-existing CloudFormation events to yield. 150 | # @option options [Boolean] :follow Set to true to wait until the stack enters a `COMPLETE` or `FAILED` state, yielding events as they occur. 151 | # @option options [Boolean] :no_sleep Don't pause between polling. This is used for tests, and shouldn't be when polling the AWS API. 152 | # @option options [Fixnum] :backoff The exponential backoff factor (default 1.5) 153 | # @option options [Fixnum] :backoff_max_wait The maximum amount of time that exponential backoff will wait before polling agian (default 15s) 154 | def tail(options = {}) 155 | q = [] 156 | event_id_highwater = nil 157 | counter = 0 158 | number = options[:number] || 0 159 | for_each_event name do |fetched_event| 160 | q.unshift fetched_event if counter < number 161 | counter = counter + 1 162 | end 163 | 164 | while q.size > 0 165 | event = q.shift 166 | yield event 167 | event_id_highwater = event.event_id 168 | end 169 | 170 | sleep_time = 1 171 | 172 | running = true 173 | if options[:follow] 174 | while running 175 | sleep_time = [sleep_time * (options[:backoff] || 1), options[:backoff_max_wait] || 15].min 176 | begin 177 | stack_status = describe_stacks(stack_name: name).stacks.first.stack_status 178 | running = running && (/.+_(COMPLETE|FAILED)$/.match(stack_status) == nil) 179 | 180 | yielding = true 181 | for_each_event name do |fetched_event| 182 | if event_id_highwater == fetched_event.event_id 183 | yielding = false 184 | end 185 | 186 | if yielding 187 | q.unshift fetched_event 188 | end 189 | end 190 | rescue Aws::CloudFormation::Errors::Throttling 191 | Cfer::LOGGER.debug "AWS SDK is being throttled..." 192 | # Keep going though. 193 | rescue Aws::CloudFormation::Errors::ValidationError 194 | running = false 195 | end 196 | 197 | while q.size > 0 198 | event = q.shift 199 | yield event 200 | event_id_highwater = event.event_id 201 | sleep_time = 1 202 | end 203 | 204 | sleep sleep_time if running unless options[:no_sleep] 205 | end 206 | end 207 | end 208 | 209 | def stack_cache(stack_name) 210 | @stack_cache[stack_name] ||= {} 211 | end 212 | 213 | def fetch_stack(stack_name = @name) 214 | raise Cfer::Util::StackDoesNotExistError, 'Stack name must be specified' if stack_name == nil 215 | begin 216 | stack_cache(stack_name)[:stack] ||= describe_stacks(stack_name: stack_name).stacks.first.to_h 217 | rescue Aws::CloudFormation::Errors::ValidationError => e 218 | raise Cfer::Util::StackDoesNotExistError, e.message 219 | end 220 | end 221 | 222 | def fetch_summary(stack_name = @name) 223 | begin 224 | stack_cache(stack_name)[:summary] ||= get_template_summary(stack_name: stack_name) 225 | rescue Aws::CloudFormation::Errors::ValidationError => e 226 | raise Cfer::Util::StackDoesNotExistError, e.message 227 | end 228 | end 229 | 230 | def fetch_metadata(stack_name = @name) 231 | md = fetch_summary(stack_name).metadata 232 | stack_cache(stack_name)[:metadata] ||= 233 | if md 234 | JSON.parse(md) 235 | else 236 | {} 237 | end 238 | end 239 | 240 | def remove(stack_name, options = {}) 241 | delete_stack(stack_name) 242 | end 243 | 244 | def fetch_cfer_version(stack_name = @name) 245 | previous_version = Semantic::Version.new('0.0.0') 246 | if previous_version_hash = fetch_metadata(stack_name).fetch('Cfer', {}).fetch('Version', nil) 247 | previous_version_hash.each { |k, v| previous_version.send(k + '=', v) } 248 | previous_version 249 | end 250 | end 251 | 252 | def fetch_git_hash(stack_name = @name) 253 | fetch_metadata(stack_name).fetch('Cfer', {}).fetch('Git', {}).fetch('Rev', nil) 254 | end 255 | 256 | def fetch_parameters(stack_name = @name) 257 | stack_cache(stack_name)[:parameters] ||= cfn_list_to_hash('parameter', fetch_stack(stack_name)[:parameters]) 258 | end 259 | 260 | def fetch_outputs(stack_name = @name) 261 | stack_cache(stack_name)[:outputs] ||= cfn_list_to_hash('output', fetch_stack(stack_name)[:outputs]) 262 | end 263 | 264 | def fetch_output(stack_name, output_name) 265 | fetch_outputs(stack_name)[output_name] || raise(Cfer::Util::CferError, "Stack #{stack_name} has no output named `#{output_name}`") 266 | end 267 | 268 | def fetch_parameter(stack_name, param_name) 269 | fetch_parameters(stack_name)[param_name] || raise(Cfer::Util::CferError, "Stack #{stack_name} has no parameter named `#{param_name}`") 270 | end 271 | 272 | def to_h 273 | @stack.to_h 274 | end 275 | 276 | private 277 | 278 | def upload_or_return_template(cfn_hash, options = {}) 279 | @template_options ||= 280 | if cfn_hash.bytesize <= 51200 && !options[:force_s3] 281 | { template_body: cfn_hash } 282 | else 283 | raise Cfer::Util::CferError, 'Cfer needs to upload the template to S3, but no bucket was specified.' unless options[:s3_path] 284 | 285 | uri = URI(options[:s3_path]) 286 | template = Aws::S3::Object.new bucket_name: uri.host, key: uri.path.reverse.chomp('/').reverse 287 | template.put body: cfn_hash 288 | 289 | template_url = template.public_url 290 | template_url = template_url + '?versionId=' + template.version_id if template.version_id 291 | 292 | { template_url: template_url } 293 | end 294 | end 295 | 296 | def cfn_list_to_hash(attribute, list) 297 | return {} unless list 298 | 299 | key = :"#{attribute}_key" 300 | value = :"#{attribute}_value" 301 | 302 | HashWithIndifferentAccess[ *list.map { |kv| [ kv[key].to_s, kv[value].to_s ] }.flatten ] 303 | end 304 | 305 | def flush_cache 306 | Cfer::LOGGER.debug "*********** FLUSH CACHE ***************" 307 | Cfer::LOGGER.debug "Stack cache: #{@stack_cache}" 308 | Cfer::LOGGER.debug "***************************************" 309 | @stack_cache = {} 310 | end 311 | 312 | def for_each_event(stack_name) 313 | describe_stack_events(stack_name: stack_name).stack_events.each do |event| 314 | yield event 315 | end 316 | end 317 | 318 | # Validates a string as json 319 | # 320 | # @param string [String] 321 | def is_json?(string) 322 | JSON.parse(string) 323 | true 324 | rescue JSON::ParserError 325 | false 326 | end 327 | 328 | # Parses stack-policy-* options as an S3 URL, file to read, or JSON string 329 | # 330 | # @param name [String] Name of option: 'stack_policy' or 'stack_policy_during_update' 331 | # @param value [String] String containing URL, filename or JSON string 332 | # @return [Hash] Hash suitable for merging into options for create_stack or update_stack 333 | def parse_stack_policy(name, value) 334 | Cfer::LOGGER.debug "Using #{name} from: #{value}" 335 | if value.nil? 336 | {} 337 | elsif value.is_a?(Hash) 338 | {"#{name}_body".to_sym => value.to_json} 339 | elsif value.match(/\A#{URI::regexp(%w[http https s3])}\z/) # looks like a URL 340 | {"#{name}_url".to_sym => value} 341 | elsif File.exist?(value) # looks like a file to read 342 | {"#{name}_body".to_sym => File.read(value)} 343 | elsif is_json?(value) # looks like a JSON string 344 | {"#{name}_body".to_sym => value} 345 | else # none of the above 346 | raise Cfer::Util::CferError, "Stack policy must be an S3 url, a filename, or a valid json string" 347 | end 348 | end 349 | end 350 | end 351 | -------------------------------------------------------------------------------- /lib/cfer/cli.rb: -------------------------------------------------------------------------------- 1 | require 'cfer' 2 | require 'cri' 3 | require 'rainbow' 4 | require 'table_print' 5 | 6 | module Cfer 7 | module Cli 8 | CFER_CLI = Cri::Command.define do 9 | name 'cfer' 10 | description 'Toolkit and Ruby DSL for automating infrastructure using AWS CloudFormation' 11 | flag nil, 'verbose', 'Runs Cfer with debug output enabled' 12 | 13 | optional :p, 'profile', 'The AWS profile to use from your credentials file' 14 | optional :r, 'region', 'The AWS region to use' 15 | 16 | optional nil, 'output-format', 'The output format to use when printing a stack [table|json]' 17 | 18 | optional nil, 'parameter', 'Sets a parameter to pass into the stack (format: `name:value`)', multiple: true 19 | optional nil, 'parameter-file', 'A YAML or JSON file with CloudFormation parameters to pass to the stack' 20 | optional nil, 'parameter-environment', 'If parameter_file is set, will merge the subkey of this into the parameter list.' 21 | 22 | flag :v, 'version', 'show the current version of cfer' do |value, cmd| 23 | puts Cfer::VERSION 24 | exit 0 25 | end 26 | 27 | flag :h, 'help', 'show help for this command' do |value, cmd| 28 | puts cmd.help 29 | exit 0 30 | end 31 | end 32 | 33 | CFER_CLI.define_command do 34 | name 'converge' 35 | usage 'converge [OPTIONS] [param=value ...]' 36 | summary 'Create or update a cloudformation stack according to the template' 37 | 38 | optional :t, 'template', 'Override the stack filename (defaults to .rb)' 39 | optional nil, 'on-failure', 'The action to take if the stack creation fails' 40 | optional nil, 'timeout', 'The timeout (in minutes) before the stack operation aborts' 41 | #flag nil, 'git-lock', 'When enabled, Cfer will not converge a stack in a dirty git tree' 42 | optional nil, 43 | 'notification-arns', 44 | 'SNS topic ARN to publish stack related events. This option can be supplied multiple times.', 45 | multiple: true 46 | 47 | optional :s, 'stack-policy', 'Set a new stack policy on create or update of the stack [file|url|json]' 48 | optional :u, 'stack-policy-during-update', 'Set a temporary overriding stack policy during an update [file|url|json]' 49 | optional nil, 'role-arn', 'Pass a specific role ARN for CloudFormation to use (--role-arn in AWS CLI)' 50 | 51 | optional nil, 'change', 'Issues updates as a Cfn change set.' 52 | optional nil, 'change-description', 'The description of this Cfn change' 53 | 54 | optional nil, 's3-path', 'Specifies an S3 path in case the stack is created with a URL.' 55 | flag nil, 'force-s3', 'Forces Cfer to upload the template to S3 and pass CloudFormation a URL.' 56 | 57 | run do |options, args, cmd| 58 | Cfer::Cli.fixup_options(options) 59 | params = {} 60 | options[:number] = 0 61 | options[:follow] = true 62 | #options[:git_lock] = true if options[:git_lock].nil? 63 | 64 | Cfer::Cli.extract_parameters(params, args).each do |arg| 65 | Cfer.converge! arg, options.merge(parameters: params) 66 | end 67 | end 68 | end 69 | 70 | CFER_CLI.define_command do 71 | name 'generate' 72 | usage 'generate [OPTIONS] [param=value ...]' 73 | summary 'Generates a CloudFormation template by evaluating a Cfer template' 74 | 75 | flag nil, 'minified', 'Minifies the JSON when printing output.' 76 | 77 | run do |options, args, cmd| 78 | Cfer::Cli.fixup_options(options) 79 | params = {} 80 | options[:pretty_print] = !options[:minified] 81 | 82 | Cfer::Cli.extract_parameters(params, args).each do |arg| 83 | Cfer.generate! arg, options.merge(parameters: params) 84 | end 85 | end 86 | end 87 | 88 | CFER_CLI.define_command do 89 | name 'tail' 90 | usage 'tail ' 91 | summary 'Follows stack events on standard output as they occur' 92 | 93 | flag :f, 'follow', 'Follow stack events on standard output while the changes are made.' 94 | option :n, 'number', 'Prints the last (n) stack events.', argument: :optional, transform: method(:Integer) 95 | 96 | run do |options, args, cmd| 97 | Cfer::Cli.fixup_options(options) 98 | args.each do |arg| 99 | Cfer.tail! arg, options 100 | end 101 | end 102 | end 103 | 104 | CFER_CLI.define_command do 105 | name 'estimate' 106 | usage 'estimate [OPTIONS] ' 107 | summary 'Prints a link to the Amazon cost caculator estimating the cost of the resulting CloudFormation stack' 108 | 109 | run do |options, args, cmd| 110 | Cfer::Cli.fixup_options(options) 111 | args.each do |arg| 112 | Cfer.estimate! arg, options 113 | end 114 | end 115 | end 116 | 117 | CFER_CLI.define_command do 118 | name 'describe' 119 | usage 'describe ' 120 | summary 'Fetches and prints information about a CloudFormation' 121 | 122 | run do |options, args, cmd| 123 | Cfer::Cli.fixup_options(options) 124 | options[:pretty_print] ||= true 125 | args.each do |arg| 126 | Cfer.describe! arg, options 127 | end 128 | end 129 | end 130 | 131 | CFER_CLI.define_command do 132 | name 'delete' 133 | usage 'delete ' 134 | summary 'Deletes a CloudFormation stack' 135 | 136 | optional nil, 'role-arn', 'Pass a specific role ARN for CloudFormation to use (--role-arn in AWS CLI)' 137 | 138 | run do |options, args, cmd| 139 | Cfer::Cli.fixup_options(options) 140 | options[:number] = 0 141 | options[:follow] = true 142 | args.each do |arg| 143 | Cfer.delete! arg, options 144 | end 145 | end 146 | end 147 | 148 | CFER_CLI.add_command Cri::Command.new_basic_help 149 | 150 | def self.main(args) 151 | Cfer::LOGGER.debug "Cfer version #{Cfer::VERSION}" 152 | begin 153 | CFER_CLI.run(args) 154 | rescue Aws::Errors::NoSuchProfileError => e 155 | Cfer::LOGGER.error "#{e.message}. Specify a valid profile with the --profile option." 156 | exit 1 157 | rescue Aws::Errors::MissingRegionError => e 158 | Cfer::LOGGER.error "Missing region. Specify a valid AWS region with the --region option, or use the AWS_REGION environment variable." 159 | exit 1 160 | rescue Interrupt 161 | Cfer::LOGGER.info 'Caught interrupt. Goodbye.' 162 | rescue Cfer::Util::TemplateError => e 163 | Cfer::LOGGER.fatal "Template error: #{e.message}" 164 | Cfer::LOGGER.fatal Cfer::Cli.format_backtrace(e.template_backtrace) unless e.template_backtrace.empty? 165 | exit 1 166 | rescue Cfer::Util::CferError, Cfer::Util::StackDoesNotExistError => e 167 | Cfer::LOGGER.error "#{e.message}" 168 | exit 1 169 | rescue StandardError => e 170 | Cfer::LOGGER.fatal "#{e.class.name}: #{e.message}" 171 | Cfer::LOGGER.fatal Cfer::Cli.format_backtrace(e.backtrace) unless e.backtrace.empty? 172 | 173 | if Cfer::DEBUG 174 | Pry::rescued(e) 175 | else 176 | #Cfer::Util.bug_report(e) 177 | end 178 | exit 1 179 | end 180 | end 181 | 182 | PARAM_REGEX=/(?.+?)=(?.+)/ 183 | def self.extract_parameters(params, args) 184 | args.reject do |arg| 185 | if match = PARAM_REGEX.match(arg) 186 | name = match[:name] 187 | value = match[:value] 188 | Cfer::LOGGER.debug "Extracting parameter #{name}: #{value}" 189 | params[name] = value 190 | end 191 | end 192 | end 193 | 194 | # Convert options of the form `:'some-option'` into `:some_option`. 195 | # Cfer internally uses the latter format, while Cri options must be specified as the former. 196 | # This approach is better than changing the names of all the options in the CLI. 197 | def self.fixup_options(opts) 198 | opts.keys.map(&:to_s).each do |k| 199 | old_k = k.to_sym 200 | new_k = k.gsub('-', '_').to_sym 201 | val = opts[old_k] 202 | opts[new_k] = (Integer(val) rescue Float(val) rescue val) 203 | opts.delete(old_k) if old_k != new_k 204 | end 205 | end 206 | 207 | private 208 | def self.format_backtrace(bt) 209 | "Backtrace: #{bt.join("\n from ")}" 210 | end 211 | def self.exit_on_failure? 212 | true 213 | end 214 | 215 | end 216 | 217 | 218 | end 219 | -------------------------------------------------------------------------------- /lib/cfer/config.rb: -------------------------------------------------------------------------------- 1 | module Cfer 2 | class Config < BlockHash 3 | def initialize(file = nil, options = nil, &block) 4 | @config_file = file 5 | deep_merge! options if options 6 | instance_eval &block if block 7 | end 8 | 9 | def method_missing(method_sym, *arguments, &block) 10 | key = camelize_property(method_sym) 11 | case arguments.size 12 | when 0 13 | if block 14 | Config.new(@config_file, nil, &block) 15 | else 16 | val = self[key] 17 | val = 18 | case val 19 | when Hash, nil 20 | Config.new(nil, val) 21 | else 22 | val 23 | end 24 | properties key => val 25 | val 26 | end 27 | else 28 | super 29 | end 30 | end 31 | 32 | # Includes config code from one or more files, and evals it in the context of this stack. 33 | # Filenames are relative to the file containing the invocation of this method. 34 | def include_config(*files) 35 | include_base = File.dirname(@config_file) if @config_file 36 | files.each do |file| 37 | path = File.join(include_base, file) if include_base 38 | include_file(path || file) 39 | end 40 | end 41 | 42 | private 43 | def non_proxied_methods 44 | [] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/cfer/console.rb: -------------------------------------------------------------------------------- 1 | require "pry" 2 | require "pry-rescue" 3 | 4 | module Cfer 5 | DEBUG = true 6 | end 7 | 8 | require "cfer/cli" 9 | 10 | Cfer::LOGGER.level = Logger::DEBUG 11 | 12 | Cfer::LOGGER.fatal "Showing FATAL logs" 13 | Cfer::LOGGER.error "Showing ERROR logs" 14 | Cfer::LOGGER.warn "Showing WARN logs" 15 | Cfer::LOGGER.info "Showing INFO logs" 16 | Cfer::LOGGER.debug "Showing DEBUG logs" 17 | 18 | def cfer(*args) 19 | Cfer::Cli::main(args) 20 | end 21 | 22 | -------------------------------------------------------------------------------- /lib/cfer/core/client.rb: -------------------------------------------------------------------------------- 1 | require 'git' 2 | 3 | module Cfer::Core 4 | class Client 5 | attr_reader :git 6 | 7 | def initialize(options) 8 | path = options[:working_directory] || '.' 9 | if File.exist?("#{path}/.git") 10 | @git = Git.open(path) rescue nil 11 | end 12 | end 13 | 14 | def converge 15 | raise Cfer::Util::CferError, 'converge not implemented on this client' 16 | end 17 | 18 | def tail(options = {}, &block) 19 | raise Cfer::Util::CferError, 'tail not implemented on this client' 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/cfer/core/functions.rb: -------------------------------------------------------------------------------- 1 | # Utility methods to make CloudFormation functions feel more like Ruby 2 | module Cfer::Core::Functions 3 | def join(sep, *args) 4 | {"Fn::Join" => [sep, [ *args ].flatten ]} 5 | end 6 | 7 | def ref(r) 8 | {"Ref" => r} 9 | end 10 | 11 | def get_att(r, att) 12 | {"Fn::GetAtt" => [r, att]} 13 | end 14 | 15 | def find_in_map(map_name, key1, key2) 16 | {"Fn::FindInMap" => [map_name, key1, key2]} 17 | end 18 | 19 | def select(i, o) 20 | {"Fn::Select" => [i, o]} 21 | end 22 | 23 | def and(*conds) 24 | {"Fn::And" => conds} 25 | end 26 | 27 | def or(*conds) 28 | {"Fn::Or" => conds} 29 | end 30 | 31 | def equals(a, b) 32 | {"Fn::Equals" => [a, b]} 33 | end 34 | 35 | def if(cond, t, f) 36 | {"Fn::If" => [cond, t, f]} 37 | end 38 | 39 | def not(cond) 40 | {"Fn::Not" => [cond]} 41 | end 42 | 43 | def get_azs(region = '') 44 | {"Fn::GetAZs" => region} 45 | end 46 | 47 | def split(*args) 48 | {"Fn::Split" => [ *args ].flatten } 49 | end 50 | 51 | def cidr(ip_block, count, size_mask) 52 | {"Fn::Cidr" => [ip_block, count, size_mask]} 53 | end 54 | 55 | def sub(str, vals = {}) 56 | {"Fn::Sub" => [str, vals]} 57 | end 58 | 59 | def notification_arns 60 | ref 'AWS::NotificationARNs' 61 | end 62 | end 63 | 64 | module Cfer::Core::Functions::AWS 65 | extend Cfer::Core::Functions 66 | 67 | def self.method_missing(sym, *args) 68 | method = sym.to_s.camelize 69 | raise "AWS::#{method} does not accept arguments" unless args.empty? 70 | ref "AWS::#{method}" 71 | end 72 | end 73 | 74 | module Cfer::Core::Functions::Fn 75 | extend Cfer::Core::Functions 76 | 77 | def self.method_missing(sym, *args) 78 | method = sym.to_s.camelize 79 | raise "Fn::#{method} requires one argument" unless args.size == 1 80 | { "Fn::#{method}" => args.first } 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/cfer/core/hooks.rb: -------------------------------------------------------------------------------- 1 | module Cfer::Core 2 | # Provides support for hooking into resource types, and evaluating code before or after properties are set 3 | module Hooks 4 | def pre_block 5 | eval_hooks self.class.pre_hooks 6 | end 7 | 8 | def post_block 9 | eval_hooks self.class.post_hooks 10 | end 11 | 12 | private def eval_hooks(hooks) 13 | hooks.sort { |a, b| (a[:nice] || 0) <=> (b[:nice] || 0) }.each do |hook| 14 | Docile.dsl_eval(self, &hook[:block]) 15 | end 16 | end 17 | 18 | def self.included(base) 19 | base.extend(ClassMethods) 20 | end 21 | 22 | module ClassMethods 23 | def before(options = {}, &block) 24 | self.pre_hooks << options.merge(block: block) 25 | end 26 | 27 | def after(options = {}, &block) 28 | self.post_hooks << options.merge(block: block) 29 | end 30 | 31 | def pre_hooks 32 | @pre_hooks ||= [] 33 | end 34 | 35 | def post_hooks 36 | @post_hooks ||= [] 37 | end 38 | 39 | def inherited(subclass) 40 | subclass.include Cfer::Core::Hooks 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/cfer/core/resource.rb: -------------------------------------------------------------------------------- 1 | module Cfer::Core 2 | class Resource < Cfer::BlockHash 3 | include Cfer::Core::Hooks 4 | 5 | class Handle 6 | attr_reader :name 7 | def initialize(name) 8 | @name = name.to_s 9 | end 10 | 11 | def ref 12 | Functions::Fn::ref(name) 13 | end 14 | 15 | def method_missing(method) 16 | Functions::Fn::get_att(name, method.to_s.camelize) 17 | end 18 | end 19 | 20 | @@types = {} 21 | 22 | attr_reader :stack 23 | 24 | def initialize(name, type, stack, options, &block) 25 | @name = name 26 | @stack = stack 27 | 28 | self[:Type] = type 29 | self.merge!(options) 30 | self[:Properties] = HashWithIndifferentAccess.new 31 | build_from_block(&block) 32 | end 33 | 34 | def handle 35 | @handle ||= Handle.new(@name) 36 | end 37 | 38 | # Sets a tag on this resource. The resource must support the CloudFormation `Tags` property. 39 | # @param k [String] The name of the tag to set 40 | # @param v [String] The value for this tag 41 | # @param options [Hash] An arbitrary set of additional properties to be added to this tag, for example `PropagateOnLaunch` on `AWS::AutoScaling::AutoScalingGroup` 42 | def tag(k, v, **options) 43 | self[:Properties][:Tags] ||= [] 44 | self[:Properties][:Tags].delete_if { |kv| kv["Key"] == k } 45 | self[:Properties][:Tags].unshift({"Key" => k, "Value" => v}.merge(options)) 46 | end 47 | 48 | # Directly sets raw properties in the underlying CloudFormation structure. 49 | # @param keyvals [Hash] The properties to set on this object. 50 | def properties(keyvals = {}) 51 | self[:Properties].merge!(keyvals) 52 | end 53 | 54 | # Gets the current value of a given property 55 | # @param key [String] The name of the property to fetch 56 | def get_property(key) 57 | self[:Properties].fetch key 58 | end 59 | 60 | class << self 61 | # Fetches the DSL class for a CloudFormation resource type 62 | # @param type [String] The type of resource, for example `AWS::EC2::Instance` 63 | # @return [Class] The DSL class representing this resource type, including all extensions 64 | def resource_class(type) 65 | @@types[type] ||= "CferExt::#{type}".split('::').inject(Object) { |o, c| o.const_get c if o && o.const_defined?(c) } || Class.new(Cfer::Core::Resource) 66 | end 67 | 68 | # Patches code into DSL classes for CloudFormation resources 69 | # @param type [String] The type of resource, for example `AWS::EC2::Instance` 70 | def extend_resource(type, &block) 71 | resource_class(type).class_eval(&block) 72 | end 73 | 74 | # Registers a hook that will be run before properties are set on a resource 75 | # @param type [String] The type of resource, for example `AWS::EC2::Instance` 76 | def before(type, **options, &block) 77 | resource_class(type).pre_hooks << options.merge(block: block) 78 | end 79 | 80 | # Registers a hook that will be run after properties have been set on a resource 81 | # @param type [String] The type of resource, for example `AWS::EC2::Instance` 82 | def after(type, **options, &block) 83 | resource_class(type).post_hooks << options.merge(block: block) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/cfer/core/stack.rb: -------------------------------------------------------------------------------- 1 | module Cfer::Core 2 | 3 | # Defines the structure of a CloudFormation stack 4 | class Stack < Cfer::Block 5 | include Cfer::Core::Functions 6 | include Cfer::Core::Hooks 7 | 8 | # The parameters strictly as passed via command line 9 | attr_reader :input_parameters 10 | 11 | # The fully resolved parameters, including defaults and parameters fetched from an existing stack during an update 12 | attr_reader :parameters 13 | 14 | attr_reader :options 15 | 16 | attr_reader :git_state 17 | 18 | def client 19 | @options[:client] || raise('No client set on this stack') 20 | end 21 | 22 | def converge!(options = {}) 23 | client.converge self, options 24 | end 25 | 26 | def tail!(options = {}, &block) 27 | client.tail self, options, &block 28 | end 29 | 30 | def initialize(options = {}) 31 | self[:AWSTemplateFormatVersion] = '2010-09-09' 32 | self[:Description] = '' 33 | 34 | @options = options 35 | 36 | self[:Metadata] = { 37 | :Cfer => { 38 | :Version => Cfer::SEMANTIC_VERSION.to_h.delete_if { |k, v| v === nil } 39 | } 40 | } 41 | 42 | self[:Parameters] = {} 43 | self[:Mappings] = {} 44 | self[:Conditions] = {} 45 | self[:Resources] = {} 46 | self[:Outputs] = {} 47 | 48 | if options[:client] && git = options[:client].git 49 | begin 50 | @git_state = git.object('HEAD^') 51 | self[:Metadata][:Cfer][:Git] = { 52 | Rev: git_state.sha, 53 | Clean: git.status.changed.empty? 54 | } 55 | rescue => e 56 | Cfer::LOGGER.warn("Unable to add Git information to CloudFormation Metadata. #{e}") 57 | end 58 | end 59 | 60 | @parameters = HashWithIndifferentAccess.new 61 | @input_parameters = HashWithIndifferentAccess.new 62 | 63 | if options[:client] 64 | begin 65 | @parameters.merge! options[:client].fetch_parameters 66 | rescue Cfer::Util::StackDoesNotExistError 67 | Cfer::LOGGER.debug "Can't include current stack parameters because the stack doesn't exist yet." 68 | end 69 | end 70 | 71 | if options[:parameters] 72 | options[:parameters].each do |key, val| 73 | @input_parameters[key] = @parameters[key] = val 74 | end 75 | end 76 | end 77 | 78 | # Sets the description for this CloudFormation stack 79 | def description(desc) 80 | self[:Description] = desc 81 | end 82 | 83 | # Declares a CloudFormation parameter 84 | # 85 | # @param name [String] The parameter name 86 | # @param options [Hash] 87 | # @option options [String] :type The type for the CloudFormation parameter 88 | # @option options [String] :default A value of the appropriate type for the template to use if no value is specified when a stack is created. If you define constraints for the parameter, you must specify a value that adheres to those constraints. 89 | # @option options [String] :no_echo Whether to mask the parameter value whenever anyone makes a call that describes the stack. If you set the value to `true`, the parameter value is masked with asterisks (*****). 90 | # @option options [String] :allowed_values An array containing the list of values allowed for the parameter. 91 | # @option options [String] :allowed_pattern A regular expression that represents the patterns you want to allow for String types. 92 | # @option options [Number] :max_length An integer value that determines the largest number of characters you want to allow for String types. 93 | # @option options [Number] :min_length An integer value that determines the smallest number of characters you want to allow for String types. 94 | # @option options [Number] :max_value A numeric value that determines the largest numeric value you want to allow for Number types. 95 | # @option options [Number] :min_value A numeric value that determines the smallest numeric value you want to allow for Number types. 96 | # @option options [String] :description A string of up to 4000 characters that describes the parameter. 97 | # @option options [String] :constraint_description A string that explains the constraint when the constraint is violated. For example, without a constraint description, a parameter that has an allowed pattern of `[A-Za-z0-9]+` displays the following error message when the user specifies an invalid value: 98 | # 99 | # ```Malformed input-Parameter MyParameter must match pattern [A-Za-z0-9]+``` 100 | # 101 | # By adding a constraint description, such as must only contain upper- and lowercase letters, and numbers, you can display a customized error message: 102 | # 103 | # ```Malformed input-Parameter MyParameter must only contain upper and lower case letters and numbers``` 104 | def parameter(name, **options) 105 | param = {} 106 | options.each do |key, v| 107 | next if v === nil 108 | 109 | k = key.to_s.camelize.to_sym 110 | param[k] = 111 | case k 112 | when :AllowedPattern 113 | if v.class == Regexp 114 | v.source 115 | end 116 | when :Default 117 | @parameters[name] ||= v 118 | end 119 | param[k] ||= v 120 | end 121 | param[:Type] ||= 'String' 122 | self[:Parameters][name] = param 123 | end 124 | 125 | # Sets the mappings block for this stack. See [The CloudFormation Documentation](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html) for more details 126 | def mappings(mappings) 127 | self[:Mappings] = mappings 128 | end 129 | 130 | # Adds a condition to the template. 131 | # @param name [String] The name of the condition. 132 | # @param expr [Hash] The CloudFormation condition to add. See [The Cloudformation Documentation](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html) for more details 133 | def condition(name, expr) 134 | self[:Conditions][name] = expr 135 | end 136 | 137 | # Creates a CloudFormation resource 138 | # @param name [String] The name of the resource (must be alphanumeric) 139 | # @param type [String] The type of CloudFormation resource to create. 140 | # @param options [Hash] Additional attributes to add to the resource block (such as the `UpdatePolicy` for an `AWS::AutoScaling::AutoScalingGroup`) 141 | def resource(name, type, **options, &block) 142 | Preconditions.check_argument(/[[:alnum:]]+/ =~ name, "Resource name must be alphanumeric") 143 | 144 | clazz = Cfer::Core::Resource.resource_class(type) 145 | rc = clazz.new(name, type, self, options, &block) 146 | 147 | self[:Resources][name] = rc 148 | rc.handle 149 | end 150 | 151 | # Adds an output to the CloudFormation stack. 152 | # @param name [String] The Logical ID of the output parameter 153 | # @param value [String] Value to return 154 | # @param options [Hash] Extra options for this output parameter 155 | # @option options [String] :description Information about the value 156 | # @option options [String] :export Name be exported for cross-stack reference 157 | def output(name, value, **options) 158 | opt = options.each_with_object({}) { |(k,v),h| h[k.to_s.capitalize] = v } # capitalize all keys 159 | export = opt.has_key?('Export') ? {'Name' => opt['Export']} : nil 160 | self[:Outputs][name] = opt.merge('Value' => value, 'Export' => export).compact 161 | end 162 | 163 | # Renders the stack into a CloudFormation template. 164 | # @return [String] The final template 165 | def to_cfn 166 | if @options[:pretty_print] 167 | JSON.pretty_generate(to_h) 168 | else 169 | to_h.to_json 170 | end 171 | end 172 | 173 | # Gets the Cfn client, if one exists, or throws an error if one does not 174 | def client 175 | @options[:client] || raise(Cfer::Util::CferError, "Stack has no associated client.") 176 | end 177 | 178 | # Includes template code from one or more files, and evals it in the context of this stack. 179 | # Filenames are relative to the file containing the invocation of this method. 180 | def include_template(*files) 181 | include_base = options[:include_base] || File.dirname(caller.first.split(/:\d/,2).first) 182 | files.each do |file| 183 | path = File.join(include_base, file) 184 | include_file(path) 185 | end 186 | end 187 | 188 | # Looks up a specific output of another CloudFormation stack in the same region. 189 | # @param stack [String] The name of the stack to fetch an output from 190 | # @param out [String] The name of the output to fetch from the stack 191 | def lookup_output(stack, out) 192 | lookup_outputs(stack).fetch(out) 193 | end 194 | 195 | # Looks up a hash of all outputs from another CloudFormation stack in the same region. 196 | # @param stack [String] The name of the stack to fetch outputs from 197 | def lookup_outputs(stack) 198 | client = @options[:client] || raise(Cfer::Util::CferError, "Can not fetch stack outputs without a client") 199 | client.fetch_outputs(stack) 200 | end 201 | 202 | class << self 203 | def extend_stack(&block) 204 | class_eval(&block) 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/cfer/util/error.rb: -------------------------------------------------------------------------------- 1 | module Cfer::Util 2 | require 'highline/import' 3 | class CferError < StandardError 4 | end 5 | 6 | class CferValidationError < CferError 7 | attr_reader :errors 8 | def initialize(errors) 9 | @errors = errors 10 | super(errors) 11 | end 12 | end 13 | 14 | class StackExistsError < CferError 15 | end 16 | 17 | class StackDoesNotExistError < CferError 18 | end 19 | 20 | class FileDoesNotExistError < CferError 21 | end 22 | 23 | class TemplateError < CferError 24 | attr_reader :template_backtrace 25 | 26 | def initialize(template_backtrace) 27 | @template_backtrace = template_backtrace 28 | super 29 | end 30 | end 31 | 32 | def self.bug_report(e) 33 | gather_report e 34 | transmit_report if agree('Would you like to send this information in a bug report? (type yes/no)') 35 | end 36 | 37 | private 38 | def self.gather_report(e) 39 | puts e 40 | end 41 | 42 | def self.transmit_report 43 | puts "Sending report." 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/cfer/util/json.rb: -------------------------------------------------------------------------------- 1 | require 'rainbow' 2 | require 'json' 3 | 4 | module Cfer::Util::Json 5 | class << self 6 | 7 | QUOTE = '"' 8 | LBRACE = Rainbow('{').green 9 | RBRACE = Rainbow('}').green 10 | LBRACKET = Rainbow('[').green 11 | RBRACKET = Rainbow(']').green 12 | COLON = Rainbow(': ').green 13 | 14 | def format_json(item) 15 | case item 16 | when Hash 17 | format_hash(item) 18 | when Array 19 | format_array(item) 20 | when String 21 | format_string(item) 22 | when Numeric 23 | format_number(item) 24 | when TrueClass || FalseClass 25 | format_bool(item) 26 | else 27 | format_string(item.to_s) 28 | end 29 | end 30 | 31 | private 32 | def format_string(s) 33 | s.to_json 34 | end 35 | 36 | def format_number(n) 37 | n.to_json 38 | end 39 | 40 | def format_bool(b) 41 | b.to_json 42 | end 43 | 44 | def format_hash(h) 45 | LBRACE + 46 | if h.empty? 47 | ' ' 48 | else 49 | "\n" + 50 | indent do 51 | h.map { |k, v| format_pair(k, v) }.join(",\n") 52 | end + 53 | "\n" 54 | end + 55 | RBRACE 56 | end 57 | 58 | def format_pair(k, v) 59 | QUOTE + Rainbow(k).bright + QUOTE + COLON + format_json(v) 60 | end 61 | 62 | def format_array(a) 63 | LBRACKET + 64 | if a.empty? 65 | ' ' 66 | else 67 | "\n" + 68 | indent do 69 | a.map { |i| format_json(i) }.join(",\n") 70 | end + 71 | "\n" 72 | end + 73 | RBRACKET 74 | end 75 | 76 | def indent 77 | str = yield 78 | " " + str.gsub(/\n/, "\n ") 79 | end 80 | 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/cfer/version.rb: -------------------------------------------------------------------------------- 1 | module Cfer 2 | VERSION = "1.0.0" 3 | 4 | begin 5 | require 'semantic' 6 | SEMANTIC_VERSION = Semantic::Version.new(VERSION) 7 | rescue LoadError 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/cferext/aws/auto_scaling/auto_scaling_group.rb: -------------------------------------------------------------------------------- 1 | Cfer::Core::Resource.extend_resource "AWS::AutoScaling::AutoScalingGroup" do 2 | def desired_size(size) 3 | desired_capacity size 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /lib/cferext/aws/cloud_formation/wait_condition.rb: -------------------------------------------------------------------------------- 1 | Cfer::Core::Resource.extend_resource 'AWS::CloudFormation::WaitCondition' do 2 | def timeout(t) 3 | properties :Timeout => t 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /lib/cferext/aws/iam/policy.rb: -------------------------------------------------------------------------------- 1 | require 'cferext/aws/iam/policy_generator' 2 | 3 | Cfer::Core::Resource.extend_resource "AWS::IAM::ManagedPolicy" do 4 | include CferExt::AWS::IAM::WithPolicyDocument 5 | end 6 | 7 | Cfer::Core::Resource.extend_resource "AWS::IAM::User" do 8 | include CferExt::AWS::IAM::WithPolicies 9 | end 10 | 11 | Cfer::Core::Resource.extend_resource "AWS::IAM::Group" do 12 | include CferExt::AWS::IAM::WithPolicies 13 | end 14 | 15 | Cfer::Core::Resource.extend_resource "AWS::IAM::Role" do 16 | include CferExt::AWS::IAM::WithPolicies 17 | 18 | def assume_role_policy_document(doc = nil, &block) 19 | doc = CferExt::AWS::IAM.generate_policy(&block) if doc == nil 20 | properties :AssumeRolePolicyDocument => doc 21 | end 22 | end 23 | 24 | Cfer::Core::Resource.extend_resource "AWS::IAM::Policy" do 25 | def policy_document(doc = nil, &block) 26 | doc = CferExt::AWS::IAM.generate_policy(&block) if doc == nil 27 | properties :PolicyDocument => doc 28 | end 29 | end 30 | 31 | -------------------------------------------------------------------------------- /lib/cferext/aws/iam/policy_generator.rb: -------------------------------------------------------------------------------- 1 | require 'docile' 2 | 3 | module CferExt 4 | module AWS 5 | module IAM 6 | class PolicyGenerator < Cfer::Block 7 | def initialize 8 | self[:Version] = '2012-10-17' 9 | self[:Statement] = [] 10 | end 11 | 12 | def statement(**options, &block) 13 | statement = ::Cfer::BlockHash.new(&block) 14 | statement.merge! options 15 | statement.build_from_block(&block) 16 | self[:Statement].unshift statement 17 | end 18 | 19 | def allow(&block) 20 | statement Effect: :Allow, &block 21 | end 22 | 23 | def deny(&block) 24 | statement Effect: :Deny, &block 25 | end 26 | end 27 | 28 | module WithPolicyDocument 29 | def policy_document(doc = nil, &block) 30 | doc = CferExt::AWS::IAM.generate_policy(&block) if doc == nil 31 | self[:Properties][:PolicyDocument] = doc 32 | end 33 | end 34 | 35 | module WithPolicies 36 | def policy(name, doc = nil, &block) 37 | self[:Properties][:Policies] ||= [] 38 | doc = CferExt::AWS::IAM.generate_policy(&block) if doc == nil 39 | get_property(:Policies) << { 40 | PolicyName: name, 41 | PolicyDocument: doc 42 | } 43 | end 44 | end 45 | 46 | def self.generate_policy(&block) 47 | policy = PolicyGenerator.new 48 | policy.build_from_block(&block) 49 | policy 50 | end 51 | 52 | EC2_ASSUME_ROLE_POLICY_DOCUMENT = 53 | CferExt::AWS::IAM.generate_policy do 54 | allow do 55 | principal Service: 'ec2.amazonaws.com' 56 | action 'sts:AssumeRole' 57 | end 58 | end.freeze 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/cferext/aws/kms/key.rb: -------------------------------------------------------------------------------- 1 | Cfer::Core::Resource.extend_resource "AWS::KMS::Key" do 2 | def key_policy(doc = nil, &block) 3 | doc = CferExt::AWS::IAM.generate_policy(&block) if doc == nil 4 | properties :KeyPolicy => doc 5 | end 6 | end 7 | 8 | -------------------------------------------------------------------------------- /lib/cferext/aws/rds/db_instance.rb: -------------------------------------------------------------------------------- 1 | Cfer::Core::Resource.extend_resource 'AWS::RDS::DBInstance' do 2 | def vpc_security_groups(groups) 3 | properties :VPCSecurityGroups => groups 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/cferext/aws/route53/record_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'docile' 2 | 3 | Cfer::Core::Resource.extend_resource "AWS::Route53::RecordSetGroup" do 4 | %w{a aaaa cname mx ns ptr soa spf srv txt}.each do |type| 5 | define_method type.to_sym do |name, records, options = {}| 6 | self[:Properties][:RecordSets] ||= [] 7 | self[:Properties][:RecordSets] << options.merge(Type: type.upcase, Name: name, ResourceRecords: [ records ].flatten) 8 | end 9 | end 10 | end 11 | 12 | -------------------------------------------------------------------------------- /lib/cferext/cfer/stack_validation.rb: -------------------------------------------------------------------------------- 1 | Cfer::Core::Stack.extend_stack do 2 | def validate_stack!(hash) 3 | errors = [] 4 | context = [] 5 | _inner_validate_stack!(hash, errors, context) 6 | 7 | raise Cfer::Util::CferValidationError, errors unless errors.empty? 8 | end 9 | 10 | def _inner_validate_stack!(hash, errors = [], context = []) 11 | case hash 12 | when Hash 13 | hash.each do |k, v| 14 | _inner_validate_stack!(v, errors, context + [k]) 15 | end 16 | when Array 17 | hash.each_index do |i| 18 | _inner_validate_stack!(hash[i], errors, context + [i]) 19 | end 20 | when nil 21 | errors << { 22 | error: "CloudFormation does not allow nulls in templates", 23 | context: context 24 | } 25 | end 26 | end 27 | 28 | def validation_contextualize(err_ctx) 29 | err_ctx.inject("") do |err_str, ctx| 30 | err_str << 31 | case ctx 32 | when String 33 | ".#{ctx}" 34 | when Numeric 35 | "[#{ctx}]" 36 | end 37 | end 38 | end 39 | end 40 | 41 | Cfer::Core::Stack.after(nice: 100) do 42 | begin 43 | validate_stack!(self) 44 | rescue Cfer::Util::CferValidationError => e 45 | Cfer::LOGGER.error "Cfer detected #{e.errors.size > 1 ? 'errors' : 'an error'} when generating the stack:" 46 | e.errors.each do |err| 47 | Cfer::LOGGER.error "* #{err[:error]} in Stack#{validation_contextualize(err[:context])}" 48 | end 49 | raise e 50 | end 51 | end 52 | 53 | -------------------------------------------------------------------------------- /spec/cfer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | 4 | describe Cfer do 5 | it 'sets descriptions' do 6 | stack = create_stack do 7 | description 'test stack' 8 | end 9 | 10 | expect(stack).to have_key :Description 11 | expect(stack[:Description]).to eq('test stack') 12 | end 13 | 14 | it 'reads templates from files' do 15 | stack = Cfer::stack_from_file('spec/support/simple_stack.rb') 16 | 17 | expect(stack[:Parameters]).to have_key :test 18 | expect(stack[:Resources]).to have_key :abc 19 | expect(stack[:Resources][:abc][:Type]).to eq 'Cfer::TestResource' 20 | expect(stack[:Resources][:abc][:SomeAttribute]).to eq 'Value' 21 | end 22 | 23 | it 'reads templates from json files' do 24 | stack = Cfer::stack_from_file('spec/support/simple_stack.json') 25 | 26 | expect(stack[:Parameters]).to have_key :test 27 | expect(stack[:Resources]).to have_key :abc 28 | expect(stack[:Resources][:abc][:Type]).to eq 'Cfer::TestResource' 29 | end 30 | 31 | it 'includes templates from files' do 32 | stack = Cfer::stack_from_file('spec/support/includes_stack.rb', include_base: 'spec/support') 33 | 34 | expect(stack[:Resources]).to have_key :abc 35 | expect(stack[:Resources][:abc][:Type]).to eq 'Cfer::TestResource' 36 | expect(stack[:Resources][:abc][:Properties][:Tags]).to contain_exactly 'Key' => :Name, 'Value' => 'foo' 37 | end 38 | 39 | it 'includes json templates from files' do 40 | stack = Cfer::stack_from_file('spec/support/includes_json_stack.rb', include_base: 'spec/support') 41 | 42 | expect(stack[:Resources]).to have_key :abc 43 | expect(stack[:Resources][:abc][:Type]).to eq 'Cfer::TestResource' 44 | expect(stack[:Resources][:abc][:Properties][:Tags]).to contain_exactly 'Key' => "Name", 'Value' => 'foo' 45 | end 46 | 47 | it 'passes parameters and options' do 48 | stack = create_stack parameters: {:param => 'value'}, option: 'value' do 49 | parameter :param_value, default: parameters[:param] 50 | parameter :option_value, default: options[:option] 51 | end 52 | 53 | expect(stack[:Parameters][:param_value][:Default]).to eq 'value' 54 | expect(stack[:Parameters][:option_value][:Default]).to eq 'value' 55 | end 56 | 57 | it 'handles file and environment parameters' do 58 | options = { 59 | parameters: { 'CLIValue' => 'from_cli' }, 60 | 61 | parameter_file: "#{__dir__}/support/parameters_stack_params.rb", 62 | parameter_environment: 'MyEnv' 63 | } 64 | 65 | merged_parameters = Cfer.send(:generate_final_parameters, options) 66 | 67 | expect(merged_parameters['FileValue']).to eq 'from_file' 68 | expect(merged_parameters['EnvValue']).to eq 'from_env' 69 | expect(merged_parameters['CLIValue']).to eq 'from_cli' 70 | end 71 | 72 | it 'creates parameters' do 73 | stack = create_stack do 74 | parameter :test, Default: 'abc', Description: 'A test parameter' 75 | parameter :regex, AllowedPattern: /[abc]+123/ 76 | parameter :list, AllowedValues: ['a', 'b', 'c'] 77 | end 78 | 79 | expect(stack[:Parameters]).to have_key :test 80 | expect(stack[:Parameters][:test]).to have_key :Default 81 | expect(stack[:Parameters][:test][:Default]).to eq 'abc' 82 | expect(stack[:Parameters][:test][:Description]).to eq 'A test parameter' 83 | expect(stack[:Parameters][:test][:Type]).to eq 'String' 84 | 85 | expect(stack[:Parameters][:regex][:AllowedPattern]).to eq '[abc]+123' 86 | expect(stack[:Parameters][:list][:AllowedValues]).to eq ['a', 'b', 'c'] 87 | end 88 | 89 | it 'creates outputs' do 90 | stack = create_stack do 91 | output :test, 'value' 92 | end 93 | 94 | expect(stack[:Outputs]).to have_key :test 95 | expect(stack[:Outputs][:test][:Value]).to eq 'value' 96 | end 97 | 98 | it 'creates resources with properties' do 99 | stack = create_stack do 100 | resource :test_resource, 'Cfer::TestResource', attribute: 'value' do 101 | property 'value' 102 | property_2 'value1', 'value2' 103 | end 104 | 105 | resource :test_resource_2, 'Cfer::TestResource' do 106 | other_resource Cfer::Core::Functions::Fn::ref(:test_resource) 107 | end 108 | end 109 | 110 | expect(stack[:Resources]).to have_key :test_resource 111 | expect(stack[:Resources][:test_resource][:Type]).to eq 'Cfer::TestResource' 112 | expect(stack[:Resources][:test_resource][:attribute]).to eq 'value' 113 | 114 | expect(stack[:Resources][:test_resource][:Properties][:Property]).to eq 'value' 115 | expect(stack[:Resources][:test_resource][:Properties][:Property2]).to eq ['value1', 'value2'] 116 | 117 | expect(stack[:Resources][:test_resource_2][:Properties][:OtherResource]).to eq 'Ref' => :test_resource 118 | end 119 | 120 | it 'creates resources with tags' do 121 | stack = create_stack do 122 | resource :test_resource, 'Cfer::TestResource' do 123 | tag 'a', 'b', xyz: 'abc' 124 | end 125 | end 126 | 127 | expect(stack[:Resources][:test_resource][:Properties][:Tags]).to contain_exactly 'Key' => 'a', 'Value' => 'b', :xyz => 'abc' 128 | end 129 | 130 | it 'has handles' do 131 | handle = Cfer::Core::Resource::Handle.new("Test") 132 | 133 | expect(JSON.generate(handle.ref)).to eq '{"Ref":"Test"}' 134 | expect(JSON.generate(handle.xyz)).to eq '{"Fn::GetAtt":["Test","Xyz"]}' 135 | end 136 | 137 | it 'creates resources and returns handles' do 138 | stack = create_stack do 139 | test1 = resource :test_resource, 'Cfer::TestResource' do 140 | tag 'a', 'b', xyz: 'abc' 141 | end 142 | 143 | resource :test_resource_2, 'Cfer::TestResource' do 144 | test_resource test1.ref 145 | test_attribute test1.att 146 | 147 | end 148 | end 149 | stack = JSON.parse(stack.to_cfn) 150 | 151 | expect(stack['Resources']['test_resource_2']['Properties']).to eq \ 152 | 'TestResource' => { 'Ref' => 'test_resource' }, 153 | 'TestAttribute' => { 'Fn::GetAtt' => [ 'test_resource', 'Att' ] } 154 | end 155 | 156 | it 'executes hooks in the right order' do 157 | list = [] 158 | 159 | Cfer::Core::Resource.before "Cfer::TestPlugin" do 160 | list << :before_r 161 | end 162 | 163 | Cfer::Core::Resource.after "Cfer::TestPlugin" do 164 | list << :after_r 165 | end 166 | 167 | Cfer::Core::Stack.before do 168 | list << :before_s 169 | end 170 | 171 | Cfer::Core::Stack.after do 172 | list << :after_s 173 | end 174 | 175 | stack = create_stack do 176 | resource :test_resource, 'Cfer::TestPlugin' do 177 | list << :during 178 | end 179 | resource :test_resource_2, 'Cfer::TestPlugin' do 180 | list << :during 181 | end 182 | end 183 | 184 | expect(list).to eq [ 185 | :before_s, 186 | :before_r, 187 | :during, 188 | :after_r, 189 | :before_r, 190 | :during, 191 | :after_r, 192 | :after_s 193 | ] 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /spec/cfn_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Cfer::Cfn::Client do 4 | 5 | it 'creates stacks' do 6 | stack = create_stack parameters: {:key => 'value'}, fetch_stack: true, times: 2 do 7 | parameter :key 8 | end 9 | cfn = stack.client 10 | 11 | expect(cfn).to receive(:validate_template) 12 | .exactly(1).times 13 | .with({template_body: stack.to_cfn}) { 14 | double( 15 | capabilities: [], 16 | parameters: [ 17 | double(parameter_key: 'key', no_echo: false) 18 | ] 19 | ) 20 | } 21 | 22 | expect(cfn).to receive(:create_stack) 23 | .exactly(1).times 24 | .with({ 25 | stack_name: 'test', 26 | template_body: stack.to_cfn, 27 | parameters: [ 28 | { :parameter_key => 'key', :parameter_value => 'value', :use_previous_value => false } 29 | ], 30 | capabilities: [], 31 | stack_policy_body: File.read('spec/support/stack_policy.json') 32 | }) 33 | 34 | Cfer::converge! cfn.name, cfer_client: cfn, cfer_stack: stack, stack_policy: 'spec/support/stack_policy.json', output_format: 'json' 35 | end 36 | 37 | it 'updates stacks' do 38 | stack = create_stack parameters: { :key => 'value' }, fetch_stack: true, times: 2 do 39 | parameter :key 40 | parameter :unchanged_key 41 | 42 | resource :abc, "Cfer::TestResource" do 43 | test_param parameters[:unchanged_key] 44 | end 45 | end 46 | cfn = stack.client 47 | stack_cfn = stack.to_h 48 | 49 | expect(cfn).to receive(:validate_template) 50 | .exactly(1).times 51 | .with({template_body: stack.to_cfn}) { 52 | double( 53 | capabilities: [], 54 | parameters: [ 55 | double(parameter_key: 'key', no_echo: false, default_value: nil), 56 | double(parameter_key: 'unchanged_key', no_echo: false, default_value: nil) 57 | ] 58 | ) 59 | } 60 | 61 | stack_options = { 62 | stack_name: 'test', 63 | template_body: stack.to_cfn, 64 | parameters: [ 65 | { :parameter_key => 'key', :parameter_value => 'value', :use_previous_value => false }, 66 | { :parameter_key => 'unchanged_key', :use_previous_value => true } 67 | ], 68 | capabilities: [], 69 | stack_policy_during_update_body: File.read('spec/support/stack_policy_during_update.json') 70 | } 71 | 72 | expect(cfn).to receive(:create_stack) 73 | .exactly(1).times 74 | .and_raise(Cfer::Util::StackExistsError) 75 | 76 | expect(cfn).to receive(:update_stack) 77 | .exactly(1).times 78 | .with(stack_options) 79 | 80 | expect(stack_cfn["Resources"]["abc"]["Properties"]["TestParam"]).to eq("unchanged_value") 81 | 82 | Cfer::converge! cfn.name, cfer_client: cfn, cfer_stack: stack, stack_policy_during_update: 'spec/support/stack_policy_during_update.json' 83 | end 84 | 85 | it 'deletes stacks' do 86 | cfn = Cfer::Cfn::Client.new stack_name: 'test-stack', region: 'us-east-1' 87 | 88 | expect(cfn).to receive(:delete_stack).with({stack_name: 'test-stack'}) 89 | .exactly(1).times 90 | 91 | Cfer::delete! 'test-stack', cfer_client: cfn 92 | end 93 | 94 | it 'follows logs' do 95 | cfn = Cfer::Cfn::Client.new stack_name: 'test', region: 'us-east-1' 96 | event_list = [ 97 | double('event 1', event_id: 1, timestamp: DateTime.now, resource_status: 'TEST', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'abcd'), 98 | double('event 2', event_id: 2, timestamp: DateTime.now, resource_status: 'TEST2', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'efgh'), 99 | double('event 3', event_id: 3, timestamp: DateTime.now, resource_status: 'TEST3', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'abcd'), 100 | double('event 4', event_id: 4, timestamp: DateTime.now, resource_status: 'TEST4', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'efgh'), 101 | double('event 5', event_id: 5, timestamp: DateTime.now, resource_status: 'TEST5', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'abcd'), 102 | double('event 6', event_id: 6, timestamp: DateTime.now, resource_status: 'TEST_COMPLETE', resource_type: 'Cfer::TestResource', logical_resource_id: 'test_resource', resource_status_reason: 'efgh') 103 | ] 104 | 105 | expect(cfn).to receive(:describe_stack_events) 106 | .exactly(3).times 107 | .with({stack_name: 'test'}) 108 | .and_return( 109 | double(stack_events: event_list.take(2).reverse), 110 | double(stack_events: event_list.take(4).reverse), 111 | double(stack_events: event_list.take(6).reverse) 112 | ) 113 | 114 | expect(cfn).to receive(:describe_stacks) 115 | .exactly(2).times 116 | .with({stack_name: 'test'}) 117 | .and_return( 118 | double(stacks: [ double(:stack_status => 'a status') ]), 119 | double(stacks: [ double(:stack_status => 'TEST_COMPLETE')]) 120 | ) 121 | 122 | yielder = double('yield receiver') 123 | event_list.drop(1).each do |event| 124 | expect(yielder).to receive(:yielded).with(event) 125 | end 126 | 127 | Cfer::tail! cfn.name, cfer_client: cfn, number: 1, follow: true, no_sleep: true do |event| 128 | yielder.yielded event 129 | end 130 | 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Cfer::Cfn::Client do 4 | 5 | it 'fetches parameters' do 6 | stack = create_stack { } 7 | cfn = stack.client 8 | 9 | expect(cfn.fetch_parameters).to eq('parameter' => 'param_value', 'unchanged_key' => 'unchanged_value') 10 | expect(cfn.fetch_outputs).to eq('value' => 'remote_value') 11 | 12 | expect(cfn.fetch_parameter(cfn.name, 'parameter')).to eq('param_value') 13 | expect(cfn.fetch_output(cfn.name, 'value')).to eq('remote_value') 14 | end 15 | 16 | it 'has a git' do 17 | stack = create_stack { } 18 | cfn = stack.client 19 | expect(cfn.git).to be 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /spec/extensions_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | Cfer::Core::Resource.extend_resource 'Cfer::CustomResource' do 4 | def test_value(val) 5 | properties TestValue: val 6 | properties TestValue2: val 7 | end 8 | end 9 | 10 | Cfer::Core::Resource.before 'Cfer::CustomResource', nice: 10 do 11 | properties BeforeValue2: get_property(:BeforeValue) 12 | end 13 | 14 | Cfer::Core::Resource.before 'Cfer::CustomResource' do 15 | properties BeforeValue: 1234 16 | end 17 | 18 | Cfer::Core::Resource.after 'Cfer::CustomResource', nice: 10 do 19 | properties AfterValue2: get_property(:AfterValue) 20 | end 21 | 22 | Cfer::Core::Resource.after 'Cfer::CustomResource' do 23 | properties AfterValue: get_property(:TestValue2) 24 | end 25 | 26 | def describe_resource(type, &block) 27 | stack = create_stack do 28 | resource :test_resource, type do 29 | build_from_block(&block) 30 | end 31 | end 32 | 33 | stack[:Resources][:test_resource][:Properties] 34 | end 35 | 36 | describe CferExt do 37 | it 'supports custom resources' do 38 | rc = describe_resource 'Cfer::CustomResource' do 39 | test_value "asdf" 40 | end 41 | 42 | expect(rc[:BeforeValue]).to eq 1234 43 | expect(rc[:BeforeValue2]).to eq 1234 44 | expect(rc[:TestValue]).to eq "asdf" 45 | expect(rc[:TestValue2]).to eq "asdf" 46 | expect(rc[:AfterValue]).to eq "asdf" 47 | expect(rc[:AfterValue2]).to eq "asdf" 48 | end 49 | 50 | # TODO: Why does this break on Ruby 2? 51 | #it 'extends AWS::CloudFormation::WaitCondition' do 52 | # rc = describe_resource 'AWS::CloudFormation::WaitCondition' do 53 | # timeout 100 54 | # end 55 | # 56 | # expect(rc[:Timeout]).to eq 100 57 | #end 58 | 59 | it 'extends AWS::RDS::DBInstance' do 60 | rc = describe_resource 'AWS::RDS::DBInstance' do 61 | vpc_security_groups :asdf 62 | end 63 | 64 | expect(rc[:VPCSecurityGroups]).to eq :asdf 65 | end 66 | 67 | it 'extends AWS::AutoScaling::AutoScalingGroup' do 68 | rc = describe_resource 'AWS::AutoScaling::AutoScalingGroup' do 69 | desired_size 1 70 | end 71 | 72 | expect(rc[:DesiredCapacity]).to eq 1 73 | end 74 | 75 | it 'extends AWS::RDS::DBInstance' do 76 | rc = describe_resource 'AWS::RDS::DBInstance' do 77 | vpc_security_groups :asdf 78 | end 79 | 80 | expect(rc[:VPCSecurityGroups]).to eq :asdf 81 | end 82 | 83 | it 'extends AWS::KMS::Key' do 84 | rc = describe_resource 'AWS::KMS::Key' do 85 | key_policy do 86 | statement do 87 | effect :Allow 88 | principal AWS: "arn:aws:iam::123456789012:user/Alice" 89 | action '*' 90 | resource '*' 91 | end 92 | end 93 | end 94 | 95 | expect(rc[:KeyPolicy][:Statement].first[:Effect]).to eq(:Allow) 96 | end 97 | 98 | it 'extends AWS::IAM::User, Group and Role' do 99 | %w{User Group Role}.each do |type| 100 | 101 | rc = describe_resource "AWS::IAM::#{type}" do 102 | policy :test_policy do 103 | statement { 104 | effect :Allow 105 | principal AWS: "arn:aws:iam::123456789012:user/Alice" 106 | action '*' 107 | resource '*' 108 | } 109 | end 110 | end 111 | expect(rc[:Policies].first[:PolicyName]).to eq(:test_policy) 112 | expect(rc[:Policies].first[:PolicyDocument][:Statement].first[:Effect]).to eq(:Allow) 113 | end 114 | end 115 | 116 | it 'extends AWS::IAM::Role' do 117 | rc = describe_resource "AWS::IAM::Role" do 118 | assume_role_policy_document do 119 | allow do 120 | principal Service: 'ec2.amazonaws.com' 121 | action 'sts:AssumeRole' 122 | end 123 | end 124 | end 125 | 126 | expect(rc[:AssumeRolePolicyDocument]).to eq(CferExt::AWS::IAM::EC2_ASSUME_ROLE_POLICY_DOCUMENT) 127 | end 128 | 129 | it 'extends AWS::IAM::Policy' do 130 | rc = describe_resource "AWS::IAM::Policy" do 131 | policy_document do 132 | statement { 133 | effect :Allow 134 | principal AWS: "arn:aws:iam::123456789012:user/Alice" 135 | action '*' 136 | resource '*' 137 | } 138 | end 139 | end 140 | expect(rc[:PolicyDocument][:Statement].first[:Effect]).to eq(:Allow) 141 | end 142 | it 'extends AWS::Route53::RecordSetGroup' do 143 | results = [] 144 | 145 | rc = describe_resource "AWS::Route53::RecordSetGroup" do 146 | %w{a aaaa cname mx ns ptr soa spf srv txt}.each do |type| 147 | self.send type, "#{type}.test.com", "record #{type}" 148 | results << { 149 | Type: type.upcase, 150 | Name: "#{type}.test.com", 151 | ResourceRecords: [ "record #{type}" ] 152 | } 153 | end 154 | end 155 | 156 | expect(rc[:RecordSets]).to eq(results) 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/fn_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | FUNCTIONS = Cfer::Core::Functions 4 | 5 | describe Cfer::Core::Functions do 6 | 7 | it 'has a working join function' do 8 | expect(FUNCTIONS::Fn::join('-', ['a', 'b', 'c'])).to eq 'Fn::Join' => ['-', ['a', 'b', 'c']] 9 | expect(FUNCTIONS::Fn.join('-', ['a', 'b', 'c'])).to eq 'Fn::Join' => ['-', ['a', 'b', 'c']] 10 | expect(FUNCTIONS::Fn::join('-', 'a', 'b', 'c')).to eq 'Fn::Join' => ['-', ['a', 'b', 'c']] 11 | expect(FUNCTIONS::Fn.join('-', 'a', 'b', 'c')).to eq 'Fn::Join' => ['-', ['a', 'b', 'c']] 12 | end 13 | 14 | it 'has a working ref function' do 15 | expect(FUNCTIONS::Fn::ref(:abc)).to eq 'Ref' => :abc 16 | expect(FUNCTIONS::Fn.ref(:abc)).to eq 'Ref' => :abc 17 | end 18 | 19 | it 'has a working get_att function' do 20 | expect(FUNCTIONS::Fn::get_att(:stack, :output)).to eq 'Fn::GetAtt' => [:stack, :output] 21 | expect(FUNCTIONS::Fn.get_att(:stack, :output)).to eq 'Fn::GetAtt' => [:stack, :output] 22 | end 23 | 24 | it 'has a working find_in_map function' do 25 | expect(FUNCTIONS::Fn::find_in_map(:map_name, :key1, :key2)).to eq 'Fn::FindInMap' => [:map_name, :key1, :key2] 26 | expect(FUNCTIONS::Fn.find_in_map(:map_name, :key1, :key2)).to eq 'Fn::FindInMap' => [:map_name, :key1, :key2] 27 | end 28 | 29 | it 'has a working select function' do 30 | expect(FUNCTIONS::Fn::select(:list, :item)).to eq 'Fn::Select' => [:list, :item] 31 | expect(FUNCTIONS::Fn.select(:list, :item)).to eq 'Fn::Select' => [:list, :item] 32 | end 33 | 34 | it 'has a working base64 function' do 35 | expect(FUNCTIONS::Fn::base64('value')).to eq 'Fn::Base64' => 'value' 36 | expect(FUNCTIONS::Fn.base64('value')).to eq 'Fn::Base64' => 'value' 37 | end 38 | 39 | it 'has a working and function' do 40 | expect(FUNCTIONS::Fn::and(:and1, :and2, :and3)).to eq 'Fn::And' => [:and1, :and2, :and3] 41 | expect(FUNCTIONS::Fn.and(:and1, :and2, :and3)).to eq 'Fn::And' => [:and1, :and2, :and3] 42 | end 43 | 44 | it 'has a working or function' do 45 | expect(FUNCTIONS::Fn::or(:and1, :and2, :and3)).to eq 'Fn::Or' => [:and1, :and2, :and3] 46 | expect(FUNCTIONS::Fn.or(:and1, :and2, :and3)).to eq 'Fn::Or' => [:and1, :and2, :and3] 47 | end 48 | 49 | it 'has a working equals function' do 50 | expect(FUNCTIONS::Fn::equals(:a, :b)).to eq 'Fn::Equals' => [:a, :b] 51 | expect(FUNCTIONS::Fn.equals(:a, :b)).to eq 'Fn::Equals' => [:a, :b] 52 | end 53 | 54 | it 'has a working if function' do 55 | expect(FUNCTIONS::Fn::if(:cond, :truthy, :falsy)).to eq 'Fn::If' => [:cond, :truthy, :falsy] 56 | expect(FUNCTIONS::Fn.if(:cond, :truthy, :falsy)).to eq 'Fn::If' => [:cond, :truthy, :falsy] 57 | end 58 | 59 | it 'has a working not function' do 60 | expect(FUNCTIONS::Fn::not(:expr)).to eq 'Fn::Not' => [:expr] 61 | expect(FUNCTIONS::Fn.not(:expr)).to eq 'Fn::Not' => [:expr] 62 | end 63 | 64 | it 'has a working get_azs function' do 65 | expect(FUNCTIONS::Fn::get_azs(:region)).to eq 'Fn::GetAZs' => :region 66 | expect(FUNCTIONS::Fn.get_azs(:region)).to eq 'Fn::GetAZs' => :region 67 | end 68 | 69 | it 'has working misc functions' do 70 | expect(FUNCTIONS::Fn::ImportValue(:asdf)).to eq 'Fn::ImportValue' => :asdf 71 | expect(FUNCTIONS::Fn.ImportValue(:asdf)).to eq 'Fn::ImportValue' => :asdf 72 | end 73 | 74 | it 'has a working AccountID intrinsic' do 75 | expect(FUNCTIONS::AWS::account_id).to eq 'Ref' => 'AWS::AccountId' 76 | expect(FUNCTIONS::AWS.account_id).to eq 'Ref' => 'AWS::AccountId' 77 | end 78 | 79 | it 'has a working NotificationARNs intrinsic' do 80 | expect(FUNCTIONS::AWS::notification_arns).to eq 'Ref' => 'AWS::NotificationARNs' 81 | expect(FUNCTIONS::AWS.notification_arns).to eq 'Ref' => 'AWS::NotificationARNs' 82 | end 83 | 84 | it 'has a working NoValue intrinsic' do 85 | expect(FUNCTIONS::AWS::no_value).to eq 'Ref' => 'AWS::NoValue' 86 | expect(FUNCTIONS::AWS.no_value).to eq 'Ref' => 'AWS::NoValue' 87 | end 88 | 89 | it 'has a working Region intrinsic' do 90 | expect(FUNCTIONS::AWS::region).to eq 'Ref' => 'AWS::Region' 91 | expect(FUNCTIONS::AWS.region).to eq 'Ref' => 'AWS::Region' 92 | end 93 | 94 | it 'has a working StackId intrinsic' do 95 | expect(FUNCTIONS::AWS::stack_id).to eq 'Ref' => 'AWS::StackId' 96 | expect(FUNCTIONS::AWS.stack_id).to eq 'Ref' => 'AWS::StackId' 97 | end 98 | 99 | it 'has a working StackName intrinsic' do 100 | expect(FUNCTIONS::AWS::stack_name).to eq 'Ref' => 'AWS::StackName' 101 | expect(FUNCTIONS::AWS.stack_name).to eq 'Ref' => 'AWS::StackName' 102 | end 103 | 104 | it 'has working misc functions' do 105 | expect(FUNCTIONS::AWS::test_thing).to eq 'Ref' => 'AWS::TestThing' 106 | expect(FUNCTIONS::AWS.test_thing).to eq 'Ref' => 'AWS::TestThing' 107 | end 108 | 109 | it 'has a working lookup function' do 110 | cfn = Cfer::Cfn::Client.new(stack_name: 'test', region: 'us-east-1') 111 | setup_describe_stacks cfn, 'other_stack' 112 | stack = create_stack client: cfn, fetch_stack: true do 113 | resource :abc, "Cfer::TestResource" do 114 | test_param lookup_output("other_stack", "value") 115 | end 116 | end 117 | 118 | stack_cfn = stack.to_h 119 | 120 | expect(stack_cfn["Resources"]["abc"]["Properties"]["TestParam"]).to eq("remote_value") 121 | end 122 | 123 | end 124 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'pp' 3 | require 'aws-sdk-cloudformation' 4 | require 'pry' rescue nil 5 | 6 | Aws.config[:stub_responses] = true 7 | 8 | def create_stack(options = {}, &block) 9 | cfn = options[:client] || Cfer::Cfn::Client.new(stack_name: options[:stack_name] || 'test', region: 'us-east-1') 10 | setup_describe_stacks cfn, cfn.name, options[:times] || 1 11 | cfn.fetch_stack 12 | 13 | s = Cfer.stack_from_block(options.merge(client: cfn), &block) 14 | pp s.to_h 15 | s 16 | end 17 | 18 | def setup_describe_stacks(cfn, stack_name = 'test', times = 1) 19 | allow(cfn).to receive(:describe_stacks) 20 | .with(stack_name: stack_name) 21 | .and_return( 22 | double( 23 | stacks: double( 24 | first: double( 25 | to_h: { 26 | :stack_status => 'CREATE_COMPLETE', 27 | :parameters => [ 28 | { 29 | :parameter_key => 'parameter', 30 | :parameter_value => 'param_value' 31 | }, 32 | { 33 | :parameter_key => 'unchanged_key', 34 | :parameter_value => 'unchanged_value' 35 | } 36 | ], 37 | :outputs => [ 38 | { 39 | :output_key => 'value', 40 | :output_value => 'remote_value' 41 | } 42 | ] 43 | } 44 | ) 45 | ) 46 | ) 47 | ) 48 | 49 | allow(cfn).to receive(:get_template_summary) 50 | .with(stack_name: stack_name) 51 | .and_return( 52 | double( 53 | metadata: "{}" 54 | ) 55 | ) 56 | end 57 | 58 | module Cfer 59 | DEBUG = true 60 | end 61 | 62 | require 'cfer' 63 | 64 | Cfer::LOGGER.level = Logger::DEBUG 65 | -------------------------------------------------------------------------------- /spec/support/included_json_stack.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Resources": { 4 | "abc": { 5 | "Type": "Cfer::TestResource", 6 | "Properties": { 7 | "Tags": [ 8 | { 9 | "Key": "Name", 10 | "Value": "foo" 11 | } 12 | ] 13 | } 14 | } 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /spec/support/included_stack.rb: -------------------------------------------------------------------------------- 1 | resource :abc, "Cfer::TestResource" do 2 | tag :Name, TEST_CONSTANT 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/includes_json_stack.rb: -------------------------------------------------------------------------------- 1 | include_template 'included_json_stack.json' 2 | -------------------------------------------------------------------------------- /spec/support/includes_stack.rb: -------------------------------------------------------------------------------- 1 | TEST_CONSTANT = 'foo' 2 | include_template 'included_stack.rb' 3 | -------------------------------------------------------------------------------- /spec/support/parameters_stack.rb: -------------------------------------------------------------------------------- 1 | parameter :DefaultValue, default: "from_cfer" 2 | parameter :FileValue, default: "NOPE_DEFAULT" 3 | parameter :EnvValue, default: "NOPE_DEFAULT" 4 | parameter :CLIValue, default: "NOPE_DEFAULT" -------------------------------------------------------------------------------- /spec/support/parameters_stack_params.rb: -------------------------------------------------------------------------------- 1 | file_value 'from_file' 2 | env_value 'NOPE_FILE' 3 | properties CLIValue: 'NOPE_FILE' 4 | 5 | my_env.properties CLIValue: 'NOPE_ENV' 6 | my_env.env_value 'from_env' 7 | -------------------------------------------------------------------------------- /spec/support/simple_stack.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Parameters": { 4 | "test": { 5 | "Type": "String" 6 | } 7 | }, 8 | "Resources": { 9 | "abc": { 10 | "Type": "Cfer::TestResource" 11 | } 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /spec/support/simple_stack.rb: -------------------------------------------------------------------------------- 1 | parameter :test 2 | 3 | resource :abc, "Cfer::TestResource", SomeAttribute: "Value" 4 | 5 | -------------------------------------------------------------------------------- /spec/support/stack_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Stack": "Policy" 3 | } 4 | -------------------------------------------------------------------------------- /spec/support/stack_policy_during_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "During": "Update" 3 | } 4 | --------------------------------------------------------------------------------