├── .circleci └── config.yml ├── .gitignore ├── Gemfile ├── Guardfile ├── README.md ├── Rakefile ├── bin ├── _guard-core ├── cfn-flow ├── guard └── rake ├── cfn-flow.gemspec ├── example └── cfn-flow.yml ├── lib ├── cfn-flow.rb ├── cfn_flow.rb └── cfn_flow │ ├── cached_stack.rb │ ├── cli.rb │ ├── event_presenter.rb │ ├── git.rb │ ├── stack_params.rb │ ├── template.rb │ └── version.rb └── spec ├── cfn_flow ├── cached_stack_spec.rb ├── cli_spec.rb ├── event_presenter_spec.rb ├── stack_params_spec.rb └── template_spec.rb ├── cfn_flow_spec.rb ├── data ├── cfn-flow.yml ├── erb-test.yml ├── invalid.json ├── invalid.yml ├── sqs.template └── sqs.yml └── helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test_223: 4 | docker: 5 | - image: ruby:2.2.5 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | - v1-bundle-225-{{ checksum "cfn-flow.gemspec" }} 11 | - v1-bundle-225 12 | - run: 13 | name: Get packages 14 | command: "bundle check --path=vendor/bundle || bundle install --path vendor/bundle" 15 | - save_cache: 16 | key: v1-bundle-225-{{ checksum "cfn-flow.gemspec" }} 17 | paths: 18 | - ./vendor/bundle 19 | - run: 20 | name: Run tests 21 | command: bundle exec rake test 22 | test_230: 23 | docker: 24 | - image: ruby:2.3.0 25 | steps: 26 | - checkout 27 | - restore_cache: 28 | keys: 29 | - v1-bundle-230-{{ checksum "cfn-flow.gemspec" }} 30 | - v1-bundle-230 31 | - run: 32 | name: Get packages 33 | command: "bundle check --path=vendor/bundle || bundle install --path vendor/bundle" 34 | - save_cache: 35 | key: v1-bundle-230-{{ checksum "cfn-flow.gemspec" }} 36 | paths: 37 | - ./vendor/bundle 38 | - run: 39 | name: Run tests 40 | command: bundle exec rake test 41 | 42 | workflows: 43 | version: 2 44 | test: 45 | jobs: 46 | - test_223 47 | - test_230 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /lib/bundler/man/ 26 | 27 | # for a library or gem, you might want to ignore these files since the code is 28 | # intended to run in multiple environments; otherwise, check them in: 29 | Gemfile.lock 30 | # .ruby-version 31 | # .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development do 6 | gem 'pry' 7 | gem 'guard' # NB: this is necessary in newer versions 8 | gem 'guard-minitest' 9 | end 10 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard :minitest do 5 | # with Minitest::Spec 6 | watch(%r{^spec/(.*)_spec\.rb$}) 7 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 8 | watch(%r{^spec/helper\.rb$}) { 'spec' } 9 | end 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cfn-flow 2 | `cfn-flow` is a command-line tool for developing [AWS CloudFormation](https://aws.amazon.com/cloudformation/) templates and deploying stacks. 3 | 4 | It provides a *simple*, *standard*, and *flexible* process for using CloudFormation, ideal for DevOps-style organizations. 5 | 6 | 7 | 8 | ## Table of Contents 9 | 10 | - [Opinions](#opinions) 11 | - [Installation](#installation) 12 | - [Key concepts](#key-concepts) 13 | - [Services](#services) 14 | - [Environments](#environments) 15 | - [Deploying](#deploying) 16 | - [AWS credentials](#aws-credentials) 17 | - [Configuration](#configuration) 18 | - [UX improvements](#ux-improvements) 19 | - [YAML > JSON](#yaml--json) 20 | - [Embedded ruby in `cfn-flow.yml`](#embedded-ruby-in-cfn-flowyml) 21 | - [Usage](#usage) 22 | - [Working with stacks](#working-with-stacks) 23 | - [Deploy (launch) a stack](#deploy-launch-a-stack) 24 | - [List stacks for your service or environment](#list-stacks-for-your-service-or-environment) 25 | - [Inspect a stack](#inspect-a-stack) 26 | - [Show stack events](#show-stack-events) 27 | - [Delete a stack](#delete-a-stack) 28 | - [Common workflows](#common-workflows) 29 | - [Deploying to production](#deploying-to-production) 30 | - [Launching a development environment](#launching-a-development-environment) 31 | - [Working with templates](#working-with-templates) 32 | - [Validate templates](#validate-templates) 33 | - [Publish templates to S3](#publish-templates-to-s3) 34 | - [License](#license) 35 | 36 | 37 | 38 | ## Opinions 39 | 40 | `cfn-flow` introduces a consistent, convenient workflow that encourages good template organization 41 | and deploy practices. 42 | 43 | 1. *Optimize for happiness.* The workflow should be easy & enjoyable to use. 44 | 2. *Optimize for onboarding.* The workflow should be simple to learn, understand, & debug. 45 | 3. *Auditable changes.* Know who changed what when & why. Leverage git history. 46 | 4. *Immutable releases.* The code in a release never changes. To make a change, 47 | launch a new stack. 48 | 49 | The features & implementation of `cfn-flow` itself must also be simple. This follows the Unix philosophy of "[worse is 50 | better](http://www.jwz.org/doc/worse-is-better.html)". `cfn-flow` values a simple design and implementation, and being composable with other workflows over handling every edge case out of the box. 51 | 52 | See [this introductory blog post](https://www.kickstarter.com/backing-and-hacking/introducing-cfn-flow-a-practical-workflow-for-aws-cloudformation) for our motivation behind `cfn-flow`. 53 | 54 | ## Installation 55 | 56 | Via [rubygems](https://rubygems.org/gems/cfn-flow): 57 | ``` 58 | gem install cfn-flow 59 | ``` 60 | 61 | The `git` command is also needed. 62 | 63 | ## Key concepts 64 | 65 | `cfn-flow` works from a directory containing a `cfn-flow.yml` config file, and a CloudFormation template. 66 | Presumably your app code is in the same directory, but it doesn't have to be. 67 | 68 | There are two key concepts for `cfn-flow`: **services** and **environments**. 69 | 70 | #### Services 71 | 72 | A service comprises a set of resources that change together. 73 | Each service has its own `cfn-flow.yml` config file. A service 74 | can be instantiated as several distinct environments. 75 | 76 | For example, a `WebApp` service could have a CloudFormation template that 77 | creates an ELB, LaunchConfig, and AutoScalingGroup resources. 78 | 79 | All the resources in a service change together. Deploying the `WebApp` 80 | service to an environment will create a new ELB, LaunchConfig, and AutoScalingGroup. 81 | 82 | Resources that *do not* change across deploys are not part of the service (from 83 | `cfn-flow`'s perspective). 84 | Say all `WebApp` EC2 servers connect to a long-running RDS database. That 85 | database is not part of the cfn-flow service because it is re-used across 86 | deploys. The database is a *backing resource* the service uses; not part 87 | of the service itself. 88 | 89 | #### Environments 90 | 91 | An environment is an particular instantiation of a service. For example, you 92 | could deploy your `WebApp` service to both a `development` and `production` environment. 93 | 94 | `cfn-flow` is designed to support arbitrary environments like git supports 95 | arbitrary branches. 96 | 97 | **Pro tip:** Use the `CFN_FLOW_ENVIRONMENT` environment variable in 98 | `cfn-flow.yml` config to use the environment in your template parameters. 99 | See [Configuration](#configuration) for examples. 100 | 101 | #### Deploying 102 | 103 | Deployments consist of launching a *new stack* in a particular environment, then 104 | shutting down the old stack. For example: 105 | 106 | ``` 107 | cfn-flow deploy ENVIRONMENT --cleanup 108 | ``` 109 | 110 | This follows the [red/black](http://techblog.netflix.com/2013/08/deploying-netflix-api.html) 111 | or [blue/green](http://martinfowler.com/bliki/BlueGreenDeployment.html) 112 | deployment pattern. 113 | 114 | After verifying the new stack is working correctly, the deployer is expected to 115 | delete the old stack. 116 | 117 | To roll back a bad deploy, simply delete the *new* stack, while the *old* 118 | stack is running. 119 | 120 | Although CloudFormation supports updating existing stacks, `cfn-flow` prefers 121 | launching immutable stacks. Stack updates are more difficult to test than new stacks; and there's less chance of a deployment error disrupting or breaking important resources. 122 | 123 | #### AWS credentials 124 | 125 | Set your AWS credentials so they can be found by the AWS SDK for Ruby ([details here](http://docs.aws.amazon.com/AWSSdkDocsRuby/latest/DeveloperGuide/set-up-creds.html)), e.g. using the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables. 126 | 127 | ## Configuration 128 | 129 | `cfn-flow` looks for `./cfn-flow.yml` for stack and template configuration. 130 | You can override this path by setting the `CFN_FLOW_CONFIG_PATH` environment 131 | variable to another path. 132 | 133 | Here's a minimal `cfn-flow.yml` config file: 134 | 135 | ```yaml 136 | # Required service name 137 | service: MyService 138 | 139 | # Minimal configuration for launching the stack. 140 | stack: 141 | # Stack name uses embedded ruby to support dynamic values 142 | stack_name: MyService-<%= Time.now.to_i %> 143 | # Required: *either* template_url or template_body 144 | # NB: template_body is a local path to the template 145 | template_body: path/to/template.json 146 | # Alternatively: 147 | # template_url: https://MyS3Bucket.s3.amazonaws.com/MyPrefix/release/abc123/template.json 148 | ``` 149 | 150 | And here's a maximal config file: 151 | 152 | ```yaml 153 | # Example cfn-flow.yml 154 | 155 | service: MyService 156 | 157 | # Set the AWS region here to override or avoid setting the AWS_REGION env var 158 | region: us-east-1 159 | 160 | ## 161 | # Templates 162 | # 163 | # These define where templates will get published. 164 | # $ cfn-flow publish --release my-cfn-template.json 165 | # Published url: https://MyS3Bucket.s3.amazonaws.com/My/S3/Prefix//my-cfn-template.json 166 | templates: 167 | bucket: MyS3Bucket 168 | s3_prefix: 'My/S3/Prefix' 169 | 170 | ## 171 | # Stacks 172 | # 173 | # These are the arguments passed when launching a new stack. 174 | # It's nearly identical to the create_stack args in the ruby sdk, except 175 | # parameters and tags are hashes. See http://amzn.to/1M0nBuq 176 | 177 | stack: 178 | # Use the CFN_FLOW_ENVIRONMENT var & git sha in stack name 179 | stack_name: MyService-<%= ENV['CFN_FLOW_ENVIRONMENT'] %>-<%= `git rev-parse --short HEAD`.chomp %> 180 | # NB: template_body is a local path to the template 181 | template_body: path/to/template.yml 182 | template_url: http://... 183 | parameters: 184 | # Your parameters, e.g.: 185 | vpcid: vpc-1234 186 | ami: ami-abcd 187 | 188 | ## 189 | # Use outputs from other stacks 190 | 191 | # This set the `load_balancer` parameter to the value of the 192 | # `elbname` output of `my-elb-stack` 193 | load_balancer: 194 | stack: my-elb-stack 195 | output: elbname 196 | 197 | # If you don't specify the output name, it's assumed to be same 198 | # as the parameter key: 199 | ssh_security_group: 200 | stack: my-bastion-stack 201 | 202 | disable_rollback: true, 203 | timeout_in_minutes: 1, 204 | notification_arns: ["NotificationARN"], 205 | capabilities: ["CAPABILITY_IAM"], # This stack does IAM stuff 206 | on_failure: "DO_NOTHING", # either DO_NOTHING, ROLLBACK, DELETE 207 | stack_policy_body: "StackPolicyBody", 208 | stack_policy_url: "StackPolicyURL", 209 | tags: 210 | # Whatever you want. 211 | # Note that `cfn-flow` automatically adds two tags: `CfnFlowService` and `CfnFlowEnvironment` 212 | TagKey: TagValue 213 | # Who launched this stack 214 | Deployer: <%= ENV['USER'] %> 215 | # Tag production and development environments for accounting 216 | BillingType: <%= ENV['CFN_FLOW_ENVIRONMENT'] == 'production' ? 'production' : 'development' %> 217 | ``` 218 | 219 | ## UX improvements 220 | 221 | `cfn-flow` includes a few developer-friendly features: 222 | 223 | #### YAML > JSON 224 | 225 | `cfn-flow` lets you write templates in either JSON or 226 | [YAML](http://www.yaml.org). YAML is a superset of JSON that allows a terser, 227 | less cluttered syntax, inline comments, and code re-use with anchors (like 228 | variables). YAML templates are transparently converted to JSON when uploaded to 229 | S3 for use in CloudFormation stacks. 230 | 231 | Note that you can use JSON snippets inside YAML templates. JSON is always valid 232 | YAML. 233 | 234 | #### Embedded ruby in `cfn-flow.yml` 235 | 236 | To allow dynamic/programmatic attributes, use 237 | [ERB](https://en.wikipedia.org/wiki/ERuby) in `cfn-flow.yml`. For example: 238 | 239 | ```yaml 240 | stack: 241 | name: my-stack-<%= Time.now.to_i %> 242 | ... 243 | parameters: 244 | git_sha: <%= `git rev-parse --verify HEAD`.chomp %> 245 | ``` 246 | 247 | #### Use stack outputs as parameters 248 | `cfn-flow` lets you easily reference stack outputs as parameters for new stacks. 249 | 250 | ```yaml 251 | # cfn-flow.yml 252 | stack: 253 | parameters: 254 | # Set my-param to the `my-param` output of `another-stack` 255 | my-param: 256 | stack: another-stack 257 | 258 | # Set my-param to the `my-output` output of `another-stack` 259 | my-param: 260 | stack: another-stack 261 | output: my-output 262 | ``` 263 | 264 | ## Usage 265 | 266 | Getting help: 267 | 268 | ``` 269 | # Get help 270 | cfn-flow help 271 | 272 | cfn-flow help COMMAND 273 | # E.g.: 274 | cfn-flow help deploy 275 | ``` 276 | 277 | Launch a CloudFormation stack: 278 | ``` 279 | cfn-flow deploy production 280 | ``` 281 | 282 | ### Working with stacks 283 | 284 | `cfn-flow` automatically sets two tags on any stack it launches: 285 | 286 | Name | Example value 287 | --- | --- 288 | CfnFlowService | `myapp` 289 | CfnFlowEnvironment | `production` 290 | 291 | These tags let `cfn-flow` associate stacks back to services & environments. 292 | 293 | #### Deploy (launch) a stack 294 | 295 | ``` 296 | cfn-flow deploy ENVIRONMENT 297 | ``` 298 | 299 | Launches a stack in ENVIRONMENT. E.g. `cfn-flow deploy production` 300 | 301 | Add the `--cleanup` option to be prompted to shut down other stacks in the environment. 302 | 303 | #### List stacks for your service or environment 304 | 305 | ``` 306 | cfn-flow list [ENVIRONMENT] 307 | ``` 308 | 309 | Show all stacks running in your service, or just in an ENVIRONMENT. 310 | 311 | ``` 312 | # For example: 313 | $ cfn-flow list production 314 | 315 | myapp-production-aaa (CREATE_COMPLETE) 316 | myapp-production-bbb (CREATE_FAILED) 317 | ``` 318 | 319 | #### Inspect a stack 320 | 321 | ``` 322 | cfn-flow show STACK 323 | ``` 324 | 325 | Show the status of STACK. 326 | 327 | #### Show stack events 328 | 329 | ``` 330 | cfn-flow events STACK 331 | ``` 332 | 333 | List events for STACK 334 | 335 | Use the `--poll` option to poll for new events until the stack status is no 336 | longer `*_IN_PROGRESS` 337 | 338 | #### Delete a stack 339 | 340 | ``` 341 | cfn-flow delete STACK 342 | ``` 343 | 344 | Deletes a stack. 345 | 346 | ``` 347 | # For example: 348 | $ cfn-flow delete myapp-production-aaa 349 | ``` 350 | 351 | ### Common workflows 352 | 353 | #### Deploying to production 354 | 355 | ``` 356 | # Launch a new stack for the current git commit 357 | $ cfn-flow deploy production 358 | Launching stack myapp-production-abc123 359 | # ... wait for it to be ready 360 | 361 | # See the other stacks 362 | $ cfn-deploy list production 363 | 364 | myapp-production-abc123 CREATE_COMPLETE 365 | myapp-production-xyz987 CREATE_COMPLETE 366 | 367 | # Shut down the old stack 368 | $ cfn-flow delete myapp-production-xyz987 369 | ``` 370 | 371 | ### Launching a development environment 372 | 373 | Launch a new stack for `myenv` environment 374 | 375 | ``` 376 | cfn-flow deploy myenv 377 | ``` 378 | 379 | ### Working with templates 380 | 381 | #### Validate templates 382 | 383 | ``` 384 | cfn-flow validate TEMPLATE [...] 385 | ``` 386 | 387 | Validates CloudFormation templates; does not persist to S3. 388 | 389 | ``` 390 | # For example: 391 | $ cfn-flow validate path/to/template.yml 392 | ``` 393 | 394 | #### Publish templates to S3 395 | 396 | ``` 397 | cfn-flow publish TEMPLATE [...] 398 | ``` 399 | 400 | Publish templates to S3 with immutable release names, or overwrite "dev names" 401 | for quicker testing. 402 | 403 | **Note:** Publishing to S3 is only needed if you want to use [nested stack resources](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-stack.html), 404 | (that is, stacks that include other stacks). 405 | 406 | ``` 407 | # For example: 408 | $ cfn-flow publish path/to/template.yml 409 | # validates & uploads templates to dev path 410 | # Env var CFN_FLOW_DEV_NAME=aaron 411 | # E.g. https://mybucket.s3.amazonaws.com/myprefix/dev/aaron/mytemplate.yml 412 | 413 | $ cfn-flow upload --release 414 | # validates & uploads templates for current git sha 415 | # E.g. https://mybucket.s3.amazonaws.com/myprefix/deadbeef/mytemplate.yml 416 | 417 | $ cfn-flow upload --release=v1.0.0 418 | # Upload templates for an arbitrary release name 419 | # E.g. https://mybucket.s3.amazonaws.com/myprefix/v1.0.0/mytemplate.yml 420 | ``` 421 | 422 | ## License 423 | 424 | Copyright Kickstarter, Inc. 425 | 426 | Released under an [MIT License](http://opensource.org/licenses/MIT). 427 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "bundler/gem_tasks" 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new do |t| 7 | t.pattern = "spec/**/*_spec.rb" 8 | end 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /bin/_guard-core: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application '_guard-core' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require "pathname" 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | load Gem.bin_path("guard", "_guard-core") 17 | -------------------------------------------------------------------------------- /bin/cfn-flow: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'cfn-flow' 4 | CfnFlow::CLI.start 5 | -------------------------------------------------------------------------------- /bin/guard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'guard' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require "pathname" 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | load Gem.bin_path("guard", "guard") 17 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'rake' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require 'pathname' 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require 'rubygems' 14 | require 'bundler/setup' 15 | 16 | load Gem.bin_path('rake', 'rake') 17 | -------------------------------------------------------------------------------- /cfn-flow.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib/', __FILE__) 3 | $:.unshift lib unless $:.include?(lib) 4 | require 'cfn_flow/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'cfn-flow' 8 | s.version = CfnFlow::VERSION 9 | s.license = 'MIT' 10 | 11 | s.authors = ["Aaron Suggs"] 12 | s.description = "A practical worflow for AWS CloudFormation" 13 | s.email = "aaron@ktheory.com" 14 | 15 | s.files = Dir.glob("{bin,lib}/**/*") + %w(Rakefile README.md) 16 | s.executables = ['cfn-flow'] 17 | s.homepage = 'http://github.com/kickstarter/cfn-flow' 18 | s.rdoc_options = ["--charset=UTF-8"] 19 | s.require_paths = ["lib"] 20 | s.summary = "A CLI for CloudFormation templates" 21 | s.test_files = Dir.glob("spec/**/*") 22 | 23 | s.required_ruby_version = '>= 2.2.0' 24 | 25 | s.add_dependency 'aws-sdk', '>= 2.1.8', '~> 2.1' 26 | s.add_dependency 'thor', '~> 0.18' 27 | s.add_dependency 'multi_json' 28 | 29 | s.add_development_dependency 'minitest' 30 | s.add_development_dependency 'rake' 31 | #s.add_development_dependency 'appraisal' 32 | end 33 | -------------------------------------------------------------------------------- /example/cfn-flow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Example cfn-flow 3 | service: MyService 4 | region: us-east-1 5 | 6 | ## 7 | # Templates 8 | # 9 | # These define where templates will get published. 10 | # $ cfn-flow publish --release my-cfn-template.json 11 | # ... 12 | # Published: https://MyS3Bucket.s3.amazonaws.com/My/S3/Prefix/abc123/my-cfn-template.json 13 | templates: 14 | bucket: MyS3Bucket 15 | s3_prefix: /My/S3/Prefix 16 | 17 | stack: 18 | stack_name: MyService-<%= Time.now.to_i %> # required 19 | template_body: "TemplateBody", 20 | template_url: "TemplateURL", 21 | parameters: 22 | ParameterKey: ParameterValue 23 | disable_rollback: true, 24 | timeout_in_minutes: 1, 25 | notification_arns: ["NotificationARN"], 26 | capabilities: ["CAPABILITY_IAM"], # accepts CAPABILITY_IAM 27 | on_failure: "DO_NOTHING", # accepts DO_NOTHING, ROLLBACK, DELETE 28 | stack_policy_body: "StackPolicyBody", 29 | stack_policy_url: "StackPolicyURL", 30 | tags: 31 | TagKey: TagValue 32 | Deployer: <%= ENV['USER'] %> 33 | -------------------------------------------------------------------------------- /lib/cfn-flow.rb: -------------------------------------------------------------------------------- 1 | require 'cfn_flow' 2 | -------------------------------------------------------------------------------- /lib/cfn_flow.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'aws-sdk' 3 | require 'multi_json' 4 | require 'yaml' 5 | require 'erb' 6 | 7 | module CfnFlow 8 | class << self 9 | 10 | ## 11 | # Configuration 12 | def config_path 13 | ENV['CFN_FLOW_CONFIG_PATH'] || 'cfn-flow.yml' 14 | end 15 | 16 | def load_config 17 | @config = YAML.load( 18 | ERB.new( File.read(config_path) ).result(binding) 19 | ) 20 | end 21 | 22 | def config_loaded? 23 | @config.is_a? Hash 24 | end 25 | 26 | def config 27 | load_config unless config_loaded? 28 | @config 29 | end 30 | 31 | def service 32 | unless config.key?('service') 33 | raise Thor::Error.new("No service name in #{config_path}. Add 'service: my_app_name'.") 34 | end 35 | config['service'] 36 | end 37 | 38 | def stack_params(environment) 39 | unless config['stack'].is_a? Hash 40 | raise Thor::Error.new("No stack defined in #{config_path}. Add 'stack: ...'.") 41 | end 42 | params = StackParams.expanded(config['stack']) 43 | 44 | params. 45 | add_tag('CfnFlowService' => service). 46 | add_tag('CfnFlowEnvironment' => environment) 47 | end 48 | 49 | def template_s3_bucket 50 | unless config['templates'].is_a?(Hash) && config['templates']['s3_bucket'] 51 | raise Thor::Error.new("No s3_bucket defined for templates in #{config_path}. Add 'templates: { s3_bucket: ... }'.") 52 | end 53 | 54 | config['templates']['s3_bucket'] 55 | end 56 | 57 | def template_s3_prefix 58 | unless config['templates'].is_a?(Hash) 59 | raise Thor::Error.new("No templates defined in #{config_path}. Add 'templates: ... '.") 60 | end 61 | 62 | # Ok for this to be '' 63 | config['templates']['s3_prefix'] 64 | end 65 | 66 | ## 67 | # Aws Clients 68 | def cfn_client 69 | @cfn_client ||= Aws::CloudFormation::Client.new(region: config['region'] || ENV['AWS_REGION']) 70 | end 71 | 72 | def cfn_resource 73 | # NB: increase default retry limit to avoid throttling errors iterating over stacks. 74 | # See https://github.com/aws/aws-sdk-ruby/issues/705 75 | @cfn_resource ||= Aws::CloudFormation::Resource.new( 76 | region: config['region'] || ENV['AWS_REGION'], 77 | retry_limit: 10 78 | ) 79 | end 80 | 81 | # Clear aws sdk clients & config (for tests) 82 | def clear! 83 | @config = @cfn_client = @cfn_resource = nil 84 | CachedStack.stack_cache.clear 85 | end 86 | 87 | # Exit with status code = 1 when raising a Thor::Error 88 | # Override thor default 89 | def exit_on_failure? 90 | if instance_variable_defined?(:@exit_on_failure) 91 | @exit_on_failure 92 | else 93 | true 94 | end 95 | end 96 | 97 | def exit_on_failure=(value) 98 | @exit_on_failure = value 99 | end 100 | end 101 | end 102 | 103 | require 'cfn_flow/cached_stack' 104 | require 'cfn_flow/stack_params' 105 | require 'cfn_flow/template' 106 | require 'cfn_flow/git' 107 | require 'cfn_flow/event_presenter' 108 | require 'cfn_flow/cli' 109 | require 'cfn_flow/version' 110 | -------------------------------------------------------------------------------- /lib/cfn_flow/cached_stack.rb: -------------------------------------------------------------------------------- 1 | module CfnFlow 2 | class CachedStack 3 | 4 | class MissingOutput < StandardError; end 5 | 6 | def self.stack_cache 7 | @stack_cache ||= {} 8 | end 9 | 10 | def self.get_output(stack:, output:) 11 | new(stack).output(output) 12 | end 13 | 14 | attr_reader :stack_name 15 | 16 | def initialize(stack_name) 17 | @stack_name = stack_name 18 | end 19 | 20 | def output(name) 21 | output = stack_cache.outputs.detect{|out| out.output_key == name } 22 | unless output 23 | raise MissingOutput.new("Can't find outpout #{name} for stack #{stack_name}") 24 | end 25 | output.output_value 26 | end 27 | 28 | def stack_cache 29 | self.class.stack_cache[stack_name] ||= CfnFlow.cfn_resource.stack(stack_name).load 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/cfn_flow/cli.rb: -------------------------------------------------------------------------------- 1 | module CfnFlow 2 | class CLI < Thor 3 | 4 | def self.exit_on_failure? 5 | CfnFlow.exit_on_failure? 6 | end 7 | 8 | ## 9 | # Template methods 10 | 11 | desc 'validate TEMPLATE [...]', 'Validates templates' 12 | def validate(*templates) 13 | 14 | if templates.empty? 15 | raise Thor::RequiredArgumentMissingError.new('You must specify a template to validate') 16 | end 17 | 18 | templates.map{|path| Template.new(path) }.each do |template| 19 | say "Validating #{template.local_path}... " 20 | template.validate! 21 | say 'valid.', :green 22 | end 23 | rescue Aws::CloudFormation::Errors::ValidationError => e 24 | raise Thor::Error.new("Invalid template. Message: #{e.message}") 25 | rescue CfnFlow::Template::Error => e 26 | raise Thor::Error.new("Error loading template. (#{e.class}) Message: #{e.message}") 27 | end 28 | 29 | desc 'publish TEMPLATE [...]', 'Validate & upload templates' 30 | method_option 'dev-name', type: :string, desc: 'Personal development prefix' 31 | method_option :release, type: :string, desc: 'Upload release', lazy_default: true 32 | method_option :verbose, type: :boolean, desc: 'Verbose output', default: false 33 | def publish(*templates) 34 | if templates.empty? 35 | raise Thor::RequiredArgumentMissingError.new('You must specify a template to publish') 36 | end 37 | 38 | validate(*templates) 39 | 40 | release = publish_release 41 | templates.each do |path| 42 | t = Template.new(path) 43 | 44 | say "Publishing #{t.local_path} to #{t.url(release)}" 45 | t.upload(release) 46 | end 47 | end 48 | 49 | ## 50 | # Stack methods 51 | 52 | desc 'deploy ENVIRONMENT', 'Launch a stack' 53 | method_option :cleanup, type: :boolean, desc: 'Prompt to shutdown other stacks in ENVIRONMENT after launching' 54 | def deploy(environment) 55 | # Export environment as an env var so it can be interpolated in config 56 | ENV['CFN_FLOW_ENVIRONMENT'] = environment 57 | 58 | begin 59 | params = CfnFlow.stack_params(environment) 60 | stack = CfnFlow.cfn_resource.create_stack(params) 61 | rescue Aws::CloudFormation::Errors::ValidationError => e 62 | raise Thor::Error.new(e.message) 63 | end 64 | 65 | say "Launching stack #{stack.name}" 66 | 67 | # Invoke events 68 | say "Polling for events..." 69 | invoke :events, [stack.name], ['--poll'] 70 | 71 | say "Stack Outputs:" 72 | invoke :show, [stack.name], ['--format=outputs-table'] 73 | 74 | # Optionally cleanup other stacks in this environment 75 | if options[:cleanup] 76 | puts "Finding stacks to clean up" 77 | list_stacks_in_service.select {|s| 78 | s.name != stack.name && \ 79 | s.tags.any? {|tag| tag.key == 'CfnFlowEnvironment' && tag.value == environment } 80 | }.map(&:name).each do |name| 81 | delete(name) 82 | end 83 | end 84 | end 85 | 86 | desc 'update ENVIRONMENT STACK', 'Updates a stack (use sparingly for mutable infrastructure)' 87 | def update(environment, name) 88 | # Export environment as an env var so it can be interpolated in config 89 | ENV['CFN_FLOW_ENVIRONMENT'] = environment 90 | 91 | stack = find_stack_in_service(name) 92 | 93 | # Check that environment matches 94 | unless stack.tags.any?{|tag| tag.key == 'CfnFlowEnvironment' && tag.value == environment } 95 | raise Thor::Error.new "Stack #{name} is not tagged for environment #{environment}" 96 | end 97 | 98 | begin 99 | params = CfnFlow.stack_params(environment) 100 | params.delete(:tags) # No allowed for Stack#update 101 | stack.update(params) 102 | rescue Aws::CloudFormation::Errors::ValidationError => e 103 | raise Thor::Error.new(e.message) 104 | end 105 | 106 | say "Updating stack #{stack.name}" 107 | 108 | # NB: there's a potential race condition where polling for events would 109 | # see the last complete state before the stack has a chance to begin updating. 110 | # Consider putting a sleep, wait_for an UPDATE_IN_PROGRESS state beforehand, 111 | # or look for events newer than the last event before updating. 112 | 113 | # Invoke events 114 | say "Polling for events..." 115 | invoke :events, [stack.name], ['--poll'] 116 | 117 | say "Stack Outputs:" 118 | invoke :show, [stack.name], ['--format=outputs-table'] 119 | end 120 | 121 | desc 'list [ENVIRONMENT]', 'List running stacks in all environments, or ENVIRONMENT' 122 | method_option 'no-header', type: :boolean, desc: 'Do not print column headers' 123 | def list(environment=nil) 124 | stacks = list_stacks_in_service 125 | if environment 126 | stacks.select! do |stack| 127 | stack.tags.any? {|tag| tag.key == 'CfnFlowEnvironment' && tag.value == environment } 128 | end 129 | end 130 | 131 | return if stacks.empty? 132 | 133 | table_header = options['no-header'] ? [] : [['NAME', 'ENVIRONMENT', 'STATUS', 'CREATED']] 134 | table_data = stacks.map do |s| 135 | env_tag = s.tags.detect {|tag| tag.key == 'CfnFlowEnvironment'} 136 | env = env_tag ? env_tag.value : 'NONE' 137 | 138 | [ s.name, env, s.stack_status, s.creation_time ] 139 | end 140 | 141 | print_table(table_header + table_data) 142 | end 143 | 144 | desc 'show STACK', 'Show details about STACK' 145 | method_option :format, type: :string, default: 'yaml', enum: %w(yaml json outputs-table), desc: "Format in which to display the stack." 146 | def show(name) 147 | formatters = { 148 | 'json' => ->(stack) { say MultiJson.dump(stack.data.to_hash, pretty: true) }, 149 | 'yaml' => ->(stack) { say stack.data.to_hash.to_yaml }, 150 | 'outputs-table' => ->(stack) do 151 | outputs = stack.outputs.to_a 152 | if outputs.any? 153 | table_header = [['KEY', 'VALUE', 'DESCRIPTION']] 154 | table_data = outputs.map do |s| 155 | [ s.output_key, s.output_value, s.description ] 156 | end 157 | 158 | print_table(table_header + table_data) 159 | else 160 | say "No stack outputs to show." 161 | end 162 | end 163 | } 164 | stack = find_stack_in_service(name) 165 | formatters[options[:format]].call(stack) 166 | end 167 | 168 | desc 'events STACK', 'List events for STACK' 169 | method_option :poll, type: :boolean, desc: 'Poll for new events until the stack is complete' 170 | method_option 'no-header', type: :boolean, desc: 'Do not print column headers' 171 | def events(name) 172 | stack = find_stack_in_service(name) 173 | 174 | say EventPresenter.header unless options['no-header'] 175 | EventPresenter.present(stack.events) {|p| say p } 176 | 177 | if options[:poll] 178 | # Display events until we're COMPLETE/FAILED 179 | delay = (ENV['CFN_FLOW_EVENT_POLL_INTERVAL'] || 2).to_i 180 | begin 181 | stack.wait_until(max_attempts: -1, delay: delay) do |s| 182 | EventPresenter.present(s.events) {|p| say p } 183 | # Wait until the stack status ends with _FAILED or _COMPLETE 184 | s.stack_status.match(/_(FAILED|COMPLETE)$/) 185 | end 186 | rescue Aws::CloudFormation::Errors::ValidationError 187 | # The stack was deleted. Keep on trucking. 188 | end 189 | end 190 | end 191 | 192 | desc 'delete STACK', 'Shut down STACK' 193 | method_option :force, type: :boolean, default: false, desc: 'Shut down without confirmation' 194 | def delete(name) 195 | stack = find_stack_in_service(name) 196 | if options[:force] || yes?("Are you sure you want to shut down #{name}?", :red) 197 | stack.delete 198 | say "Deleted stack #{name}" 199 | 200 | say "Polling for events..." 201 | invoke :events, [stack.name], ['--poll'] 202 | end 203 | end 204 | 205 | ## 206 | # Version command 207 | desc "version", "Prints the version information" 208 | def version 209 | say CfnFlow::VERSION 210 | end 211 | map %w(-v --version) => :version 212 | 213 | private 214 | def find_stack_in_service(name) 215 | stack = CfnFlow.cfn_resource.stack(name).load 216 | unless stack.tags.any? {|tag| tag.key == 'CfnFlowService' && tag.value == CfnFlow.service } 217 | raise Thor::Error.new "Stack #{name} is not tagged for service #{CfnFlow.service}" 218 | end 219 | stack 220 | rescue Aws::CloudFormation::Errors::ValidationError => e 221 | # Handle missing stacks: 'Stack with id blah does not exist' 222 | raise Thor::Error.new(e.message) 223 | end 224 | 225 | def list_stacks_in_service 226 | CfnFlow.cfn_resource.stacks.select do |stack| 227 | stack.tags.any? {|tag| tag.key == 'CfnFlowService' && tag.value == CfnFlow.service } 228 | end 229 | end 230 | 231 | def publish_release 232 | # Add the release or dev name to the prefix 233 | if options[:release] 234 | release = options[:release] == true ? CfnFlow::Git.sha : options[:release] 235 | 'release/' + release 236 | elsif options['dev-name'] 237 | 'dev/' + options['dev-name'] 238 | elsif ENV['CFN_FLOW_DEV_NAME'] 239 | 'dev/' + ENV['CFN_FLOW_DEV_NAME'] 240 | else 241 | raise Thor::Error.new("Must specify --release or --dev-name; or set CFN_FLOW_DEV_NAME env var") 242 | end 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /lib/cfn_flow/event_presenter.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | module CfnFlow 3 | class EventPresenter 4 | 5 | ## 6 | # Class methods 7 | def self.seen_event_ids 8 | @seen_event_ids ||= Set.new 9 | end 10 | 11 | # Yields each new event present to +block+ 12 | def self.present(raw_events, &block) 13 | raw_events.to_a.reverse.sort_by(&:timestamp). 14 | reject {|e| seen_event_ids.include?(e.id) }. 15 | map {|e| yield new(e) } 16 | end 17 | 18 | def self.header 19 | %w(status logical_resource_id resource_type reason) * "\t" 20 | end 21 | 22 | ## 23 | # Instance methods 24 | attr_accessor :event 25 | def initialize(event) 26 | @event = event 27 | self.class.seen_event_ids << event.id 28 | end 29 | 30 | def to_s 31 | [ 32 | event.resource_status, 33 | event.logical_resource_id, 34 | event.resource_type, 35 | event.resource_status_reason 36 | ].compact * "\t" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/cfn_flow/git.rb: -------------------------------------------------------------------------------- 1 | # Git helper module 2 | # TODO: extract as plugin 3 | class CfnFlow::Git 4 | class << self 5 | 6 | def sha 7 | command = "git rev-parse --verify HEAD" 8 | result = `#{command}`.chomp 9 | unless $?.success? 10 | raise Thor::Error.new("Error running `#{command}`") 11 | end 12 | result 13 | end 14 | 15 | def check_status 16 | unless `git status -s`.empty? 17 | raise Thor::Error.new("Git working directory is not clean. Please commit or reset changes in order to release.") 18 | end 19 | unless $?.success? 20 | raise Thor::Error.new("Error running `git status`") 21 | end 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cfn_flow/stack_params.rb: -------------------------------------------------------------------------------- 1 | module CfnFlow 2 | # Extend hash with some special behavior to generate the 3 | # style of hash aws-sdk expects 4 | class StackParams < Hash 5 | 6 | def self.expanded(hash) 7 | self[hash]. 8 | with_symbolized_keys. 9 | with_expanded_parameters. 10 | with_expanded_tags. 11 | with_expanded_template_body 12 | end 13 | 14 | def with_symbolized_keys 15 | self.reduce(StackParams.new) do |accum, (key, value)| 16 | accum.merge(key.to_sym => value) 17 | end 18 | end 19 | 20 | def with_expanded_parameters 21 | return self unless self[:parameters].is_a? Hash 22 | 23 | expanded_params = self[:parameters].map do |key,value| 24 | { parameter_key: key, parameter_value: fetch_value(key, value) } 25 | end 26 | 27 | self.merge(parameters: expanded_params) 28 | end 29 | 30 | def with_expanded_tags 31 | return self unless self[:tags].is_a? Hash 32 | 33 | tags = self[:tags].map do |key, value| 34 | {key: key, value: value} 35 | end 36 | 37 | self.merge(tags: tags) 38 | end 39 | 40 | def add_tag(hash) 41 | new_tags = hash.map do |k,v| 42 | {key: k, value: v } 43 | end 44 | tags = (self[:tags] || []) + new_tags 45 | self.merge(tags: tags) 46 | end 47 | 48 | def with_expanded_template_body 49 | return self unless self[:template_body].is_a? String 50 | body = CfnFlow::Template.new(self[:template_body]).to_json 51 | self.merge(template_body: body) 52 | rescue CfnFlow::Template::Error 53 | # Do nothing 54 | self 55 | end 56 | 57 | def fetch_value(key, value) 58 | # Dereference stack output params 59 | if value.is_a?(Hash) && value.key?('stack') 60 | stack_name = value['stack'] 61 | stack_output_name = value['output'] || key 62 | 63 | value = CachedStack.get_output(stack: stack_name, output: stack_output_name) 64 | else 65 | value 66 | end 67 | end 68 | private :fetch_value 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/cfn_flow/template.rb: -------------------------------------------------------------------------------- 1 | module CfnFlow 2 | class Template 3 | 4 | # Tag for JSON/YAML loading errors 5 | module Error; end 6 | 7 | attr_reader :local_path 8 | def initialize(local_path) 9 | @local_path = local_path 10 | end 11 | 12 | def yaml? 13 | local_path.end_with?('.yml') 14 | end 15 | 16 | def json? 17 | ! yaml? 18 | end 19 | 20 | # Determine if this file is a CFN template 21 | def is_cfn_template? 22 | local_data.is_a?(Hash) && local_data.key?('Resources') 23 | end 24 | 25 | # Returns a response object if valid, or raises an 26 | # Aws::CloudFormation::Errors::ValidationError with an error message 27 | def validate! 28 | cfn.validate_template(template_body: to_json) 29 | end 30 | 31 | ## 32 | # S3 methods 33 | def key(release) 34 | # Replace leading './' in local_path 35 | clean_path = local_path.sub(/\A\.\//, '') 36 | File.join(*[s3_prefix, release, clean_path].compact) 37 | end 38 | 39 | def s3_object(release) 40 | Aws::S3::Object.new(bucket, key(release)) 41 | end 42 | 43 | def url(release) 44 | s3_object(release).public_url 45 | end 46 | 47 | def upload(release) 48 | s3_object(release).put(body: to_json) 49 | end 50 | 51 | def local_data 52 | data = ERB.new(File.read(local_path)).result(binding) 53 | # We *could* load JSON as YAML, but that would generate confusing errors 54 | # in the case of a JSON syntax error. 55 | @local_data ||= yaml? ? YAML.load(data) : MultiJson.load(data) 56 | rescue Exception => error 57 | # Tag & re-raise any error 58 | error.extend(CfnFlow::Template::Error) 59 | raise error 60 | end 61 | 62 | def to_json 63 | @to_json ||= MultiJson.dump(local_data, pretty: true) 64 | end 65 | 66 | def bucket 67 | CfnFlow.template_s3_bucket 68 | end 69 | 70 | def s3_prefix 71 | CfnFlow.template_s3_prefix 72 | end 73 | 74 | private 75 | def cfn 76 | CfnFlow.cfn_client 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/cfn_flow/version.rb: -------------------------------------------------------------------------------- 1 | module CfnFlow 2 | VERSION = '0.11.1' 3 | end 4 | -------------------------------------------------------------------------------- /spec/cfn_flow/cached_stack_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper' 2 | 3 | describe 'CfnFlow::CachedStack' do 4 | subject { CfnFlow::CachedStack } 5 | 6 | describe '.stack_cache' do 7 | it 'defaults to a hash' do 8 | subject.stack_cache.must_equal({}) 9 | end 10 | end 11 | 12 | describe '.get_output' do 13 | let(:output_value) { 'myvalue' } 14 | 15 | before do 16 | Aws.config[:cloudformation]= { 17 | stub_responses: { 18 | describe_stacks: { stacks: [ stub_stack_data.merge(outputs: [{ output_key: "myoutput", output_value: output_value } ]) ] } 19 | } 20 | } 21 | end 22 | 23 | it 'returns the output' do 24 | subject.get_output(stack: 'mystack', output: 'myoutput').must_equal output_value 25 | end 26 | 27 | it 'has required kwargs' do 28 | -> { subject.get_output }.must_raise(ArgumentError) 29 | end 30 | end 31 | 32 | describe 'an instance' do 33 | subject { CfnFlow::CachedStack.new('mystack') } 34 | let(:output_value) { 'myvalue' } 35 | 36 | before do 37 | Aws.config[:cloudformation]= { 38 | stub_responses: { 39 | describe_stacks: { stacks: [ stub_stack_data.merge(outputs: [{ output_key: "myoutput", output_value: output_value } ]) ] } 40 | } 41 | } 42 | end 43 | 44 | it "should return the output value" do 45 | subject.output('myoutput').must_equal output_value 46 | end 47 | 48 | describe "with a missing output" do 49 | it "should raise an error" do 50 | -> { subject.output("no-such-output") }.must_raise(CfnFlow::CachedStack::MissingOutput) 51 | end 52 | end 53 | 54 | describe "with a missing stack" do 55 | 56 | subject { CfnFlow::CachedStack.new('no-such-stack') } 57 | before do 58 | Aws.config[:cloudformation]= { 59 | stub_responses: { 60 | describe_stacks: 'ValidationError' 61 | } 62 | } 63 | end 64 | 65 | it "should raise an error" do 66 | -> { subject.output('blah') }.must_raise(Aws::CloudFormation::Errors::ValidationError) 67 | end 68 | end 69 | end 70 | 71 | end 72 | -------------------------------------------------------------------------------- /spec/cfn_flow/cli_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper' 2 | 3 | describe 'CfnFlow::CLI' do 4 | let(:cli) { CfnFlow::CLI } 5 | let(:template) { 'spec/data/sqs.yml' } 6 | 7 | before do 8 | ENV.update({ 9 | 'CFN_FLOW_BUCKET' => 'test-bucket', 10 | 'CFN_FLOW_FROM' => 'spec/data', 11 | 'CFN_FLOW_TO' => 'test' 12 | }) 13 | end 14 | 15 | describe '#validate' do 16 | it 'succeeds' do 17 | out, err = capture_io { cli.start [:validate, template] } 18 | err.must_be :empty? 19 | out.must_match "Validating #{template}... valid." 20 | end 21 | 22 | it 'can have multiple templates' do 23 | out, _ = capture_io { cli.start [:validate, template, 'spec/data/sqs.template'] } 24 | out.split("\n").size.must_equal 2 25 | end 26 | 27 | it 'can fail with malformed templates' do 28 | _, err = capture_io { cli.start [:validate, 'no/such/template'] } 29 | err.must_match 'Error loading template' 30 | err.must_match 'Errno::ENOENT' 31 | end 32 | 33 | it 'can fail with validation error' do 34 | Aws.config[:cloudformation] = {stub_responses: {validate_template: 'ValidationError'}} 35 | _, err = capture_io { cli.start [:validate, template] } 36 | err.must_match "Invalid template" 37 | end 38 | 39 | it 'fails when no templates are passed' do 40 | out, err = capture_io { cli.start [:validate] } 41 | out.must_equal '' 42 | err.must_match 'You must specify a template to validate' 43 | end 44 | end 45 | 46 | describe '#publish' do 47 | it 'succeeds' do 48 | out, err = capture_io { cli.start [:publish, template] } 49 | err.must_equal '' 50 | out.must_match "Validating #{template}... valid." 51 | out.must_match "Publishing #{template}" 52 | end 53 | 54 | it 'can have multiple templates' do 55 | out, _ = capture_io { cli.start [:publish, template, 'spec/data/sqs.template'] } 56 | # 2 lines for validating, 2 for publishing 57 | out.split("\n").size.must_equal 4 58 | end 59 | 60 | it 'uses the dev-name' do 61 | out, _ = capture_io { cli.start [:publish, template] } 62 | out.must_match("dev/#{ENV['CFN_FLOW_DEV_NAME']}") 63 | end 64 | 65 | it 'can take a dev-name argument' do 66 | name = 'a-new-dev-name' 67 | out, _ = capture_io { cli.start [:publish, template, '--dev-name', name] } 68 | out.must_match("dev/#{name}") 69 | end 70 | 71 | describe 'with --release' do 72 | it 'defaults to git sha' do 73 | sha = CfnFlow::Git.sha 74 | out, _ = capture_io { cli.start [:publish, template, '--release'] } 75 | out.must_match CfnFlow::Template.new(template).url("release/#{sha}") 76 | end 77 | 78 | it 'can take a value' do 79 | release = 'v2.0' 80 | out, _ = capture_io { cli.start [:publish, template, '--release', release] } 81 | out.must_match CfnFlow::Template.new(template).url("release/#{release}") 82 | end 83 | end 84 | 85 | it 'can fail with malformed templates' do 86 | _, err = capture_io { cli.start [:publish, 'no/such/template'] } 87 | err.must_match 'Error loading template' 88 | err.must_match 'Errno::ENOENT' 89 | end 90 | 91 | it 'can fail with validation error' do 92 | Aws.config[:cloudformation] = {stub_responses: {validate_template: 'ValidationError'}} 93 | _, err = capture_io { cli.start [:publish, template] } 94 | err.must_match "Invalid template" 95 | end 96 | 97 | it 'fails when no templates are passed' do 98 | out, err = capture_io { cli.start [:publish] } 99 | out.must_equal '' 100 | err.must_match 'You must specify a template to publish' 101 | end 102 | 103 | it 'fails with no release' do 104 | ENV.delete('CFN_FLOW_DEV_NAME') 105 | _, err = capture_io { cli.start [:publish, template] } 106 | err.must_match 'Must specify --release or --dev-name' 107 | end 108 | end 109 | 110 | describe '#deploy' do 111 | 112 | it 'succeeds' do 113 | Aws.config[:cloudformation]= { 114 | stub_responses: { 115 | describe_stacks: { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] }, 116 | describe_stack_events: { stack_events: [ stub_event_data ] }, 117 | } 118 | } 119 | out, err = capture_io { cli.start [:deploy, 'test-env'] } 120 | 121 | out.must_match "Launching stack #{CfnFlow.config['stack']['stack_name']}" 122 | out.must_match "Polling for events..." 123 | out.must_match "CREATE_COMPLETE" 124 | out.wont_match 'Finding stacks to cleanup' 125 | err.must_equal '' 126 | end 127 | 128 | it 'exposes the environmont as an env var' do 129 | Aws.config[:cloudformation]= { 130 | stub_responses: { 131 | describe_stacks: { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] }, 132 | describe_stack_events: { stack_events: [ stub_event_data ] }, 133 | } 134 | } 135 | _ = capture_io { cli.start [:deploy, 'test-env'] } 136 | ENV['CFN_FLOW_ENVIRONMENT'].must_equal 'test-env' 137 | end 138 | 139 | it 'can fail with a validation error' do 140 | Aws.config[:cloudformation]= { 141 | stub_responses: { create_stack: 'ValidationError' } 142 | } 143 | 144 | out, err = capture_io { cli.start [:deploy, 'test-env'] } 145 | out.must_equal '' 146 | err.must_match 'error' 147 | 148 | end 149 | 150 | it 'can cleanup' do 151 | 152 | # Stubbing hacks alert! 153 | # The first two times we call :describe_stacks, return the stack we launch. 154 | # The third time, we're loading 'another-stack' to clean it up 155 | stack_stubs = [ 156 | { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] }, 157 | { stacks: [ stub_stack_data(stack_name: 'cfn-flow-spec-stack') ] }, 158 | { stacks: [ stub_stack_data(stack_name: 'another-stack') ] } 159 | ] 160 | Aws.config[:cloudformation]= { 161 | stub_responses: { 162 | describe_stacks: stack_stubs, 163 | describe_stack_events: { stack_events: [ stub_event_data ] }, 164 | } 165 | } 166 | 167 | Thor::LineEditor.stub :readline, "yes" do 168 | out, err = capture_io { cli.start [:deploy, 'production', '--cleanup'] } 169 | out.must_match 'Finding stacks to clean up' 170 | out.must_match 'Deleted stack another-stack' 171 | err.must_equal '' 172 | end 173 | end 174 | 175 | end 176 | 177 | describe '#update' do 178 | it 'fails with no args' do 179 | out, err = capture_io { cli.start [:update] } 180 | out.must_equal '' 181 | err.must_match(/ERROR.+no arguments/) 182 | end 183 | 184 | it 'returns an error when stack is not in service' do 185 | stack_data = stub_stack_data 186 | stack_data[:tags][0][:value] = 'none-such-service' 187 | Aws.config[:cloudformation]= { 188 | stub_responses: { 189 | describe_stacks: { stacks: [ stack_data ] } 190 | } 191 | } 192 | out, err = capture_io { cli.start [:update, 'production', 'none-such-stack'] } 193 | out.must_equal '' 194 | err.must_match "not tagged for service #{CfnFlow.service}" 195 | end 196 | 197 | it 'returns an error when stack environment does not match' do 198 | stack_data = stub_stack_data 199 | Aws.config[:cloudformation]= { 200 | stub_responses: { 201 | describe_stacks: { stacks: [ stack_data ] } 202 | } 203 | } 204 | out, err = capture_io { cli.start [:update, 'none-such-env', 'mystack'] } 205 | out.must_equal '' 206 | err.must_match "not tagged for environment none-such-env" 207 | end 208 | 209 | it 'succeeds' do 210 | stack_name = CfnFlow.config['stack']['stack_name'] 211 | 212 | Aws.config[:cloudformation]= { 213 | stub_responses: { 214 | describe_stacks: { stacks: [ stub_stack_data(stack_name: stack_name, stack_status: 'UPDATE_COMPLETE') ] }, 215 | describe_stack_events: { stack_events: [ stub_event_data(resource_status: 'UPDATE_COMPLETE') ] }, 216 | } 217 | } 218 | out, err = capture_io { cli.start [:update, 'production', stack_name] } 219 | 220 | out.must_match "Updating stack #{stack_name}" 221 | out.must_match "Polling for events..." 222 | out.must_match "UPDATE_COMPLETE" 223 | out.must_match "Stack Outputs:" 224 | err.must_equal '' 225 | end 226 | end 227 | 228 | describe '#list' do 229 | it 'has no output with no stacks' do 230 | out, err = capture_io { cli.start [:list] } 231 | out.must_equal '' 232 | err.must_equal '' 233 | end 234 | 235 | describe 'with one stack' do 236 | before do 237 | Aws.config[:cloudformation]= { 238 | stub_responses: { 239 | describe_stacks: { stacks: [ stub_stack_data ] } 240 | } 241 | } 242 | end 243 | it 'should print the stack' do 244 | out, err = capture_io { cli.start [:list] } 245 | out.must_match(/mystack\s+production\s+CREATE_COMPLETE\s+#{memo_now.utc}/) 246 | err.must_equal '' 247 | end 248 | 249 | it 'should print the header' do 250 | out, _ = capture_io { cli.start [:list] } 251 | out.must_match(/NAME\s+ENVIRONMENT\s+STATUS\s+CREATED/) 252 | end 253 | 254 | it 'should print stacks when passed an environment' do 255 | out, _ = capture_io { cli.start [:list, 'production'] } 256 | out.must_match 'mystack' 257 | 258 | out, _ = capture_io { cli.start [:list, 'none-such-env'] } 259 | out.must_equal '' 260 | end 261 | 262 | it 'should not print the header with option[no-header]' do 263 | out, _ = capture_io { cli.start [:list, '--no-header'] } 264 | out.wont_match(/NAME\s+ENVIRONMENT\s+STATUS/) 265 | end 266 | end 267 | 268 | describe 'with stacks in a different service' do 269 | before do 270 | Aws.config[:cloudformation]= { 271 | stub_responses: { 272 | describe_stacks: { 273 | stacks: [ 274 | { stack_name: "mystack", 275 | stack_status: 'CREATE_COMPLETE', 276 | creation_time: memo_now, 277 | tags: [ 278 | {key: 'CfnFlowService', value: 'none-such-service'}, 279 | {key: 'CfnFlowEnvironment', value: 'production'} 280 | ] 281 | } 282 | ] 283 | } 284 | } 285 | } 286 | end 287 | 288 | it 'has no output' do 289 | out, _ = capture_io { cli.start [:list] } 290 | out.must_equal '' 291 | end 292 | end 293 | end 294 | 295 | describe '#show' do 296 | describe 'with a stack' do 297 | before do 298 | Aws.config[:cloudformation]= { 299 | stub_responses: { 300 | describe_stacks: { stacks: [ stub_stack_data ] } 301 | } 302 | } 303 | end 304 | 305 | it 'should print in yaml' do 306 | out, err = capture_io { cli.start [:show, 'mystack'] } 307 | expected = CfnFlow.cfn_resource.stack('mystack').data.to_hash.to_yaml 308 | out.must_equal expected 309 | err.must_equal '' 310 | end 311 | 312 | it 'handles json format' do 313 | out, _ = capture_io { cli.start [:show, 'mystack', '--format=json'] } 314 | expected = MultiJson.dump(CfnFlow.cfn_resource.stack('mystack').data.to_hash, pretty: true) + "\n" 315 | out.must_equal expected 316 | end 317 | 318 | describe 'outputs-table format' do 319 | it 'handles shows a table when there are events' do 320 | out, _ = capture_io { cli.start [:show, 'mystack', '--format=outputs-table'] } 321 | out.must_match(/KEY\s+VALUE\s+DESCRIPTION/) 322 | out.must_match(/mykey\s+myvalue\s+My Output/) 323 | end 324 | 325 | it 'handles no events' do 326 | Aws.config[:cloudformation]= { 327 | stub_responses: { 328 | describe_stacks: { stacks: [ stub_stack_data(outputs: nil) ] } 329 | } 330 | } 331 | out, _ = capture_io { cli.start [:show, 'mystack', '--format=outputs-table'] } 332 | out.must_match "No stack outputs to show." 333 | 334 | end 335 | end 336 | end 337 | 338 | it 'returns an error with missing stacks' do 339 | Aws.config[:cloudformation]= { 340 | stub_responses: { describe_stacks: 'ValidationError' } 341 | } 342 | out, err = capture_io { cli.start [:show, 'none-such-stack'] } 343 | out.must_equal '' 344 | err.must_match 'error' 345 | end 346 | 347 | it 'returns an error when stack is not in service' do 348 | stack_data = stub_stack_data 349 | stack_data[:tags][0][:value] = 'none-such-service' 350 | Aws.config[:cloudformation]= { 351 | stub_responses: { 352 | describe_stacks: { stacks: [ stack_data ] } 353 | } 354 | } 355 | out, err = capture_io { cli.start [:show, 'none-such-stack'] } 356 | out.must_equal '' 357 | err.must_match "not tagged for service #{CfnFlow.service}" 358 | end 359 | end 360 | 361 | describe '#events' do 362 | before do 363 | Aws.config[:cloudformation] = { 364 | stub_responses: { 365 | describe_stack_events: { stack_events: [ stub_event_data ] }, 366 | describe_stacks: { stacks: [ stub_stack_data ] } 367 | } 368 | } 369 | end 370 | 371 | it 'should show the header by default' do 372 | out, _ = capture_io { cli.start [:events, 'mystack'] } 373 | out.must_match CfnFlow::EventPresenter.header 374 | end 375 | 376 | it 'can omit header' do 377 | out, _ = capture_io { cli.start [:events, '--no-headers', 'mystack'] } 378 | out.wont_match CfnFlow::EventPresenter.header 379 | end 380 | 381 | it 'should show an event' do 382 | out, err = capture_io { cli.start [:events, 'mystack'] } 383 | 384 | out.must_match CfnFlow::EventPresenter.new(stub_event).to_s 385 | err.must_equal '' 386 | end 387 | 388 | describe 'with polling' do 389 | before do 390 | Aws.config[:cloudformation] = { 391 | stub_responses: { 392 | describe_stack_events: [ 393 | { stack_events: [ stub_event_data(resource_status: 'CREATE_IN_PROGRESS') ] }, 394 | { stack_events: [ stub_event_data(resource_status: 'CREATE_COMPLETE') ] } 395 | ], 396 | describe_stacks: [ 397 | { stacks: [ stub_stack_data(stack_status: 'CREATE_IN_PROGRESS') ] }, 398 | { stacks: [ stub_stack_data(stack_status: 'CREATE_COMPLETE') ] }, 399 | ] 400 | } 401 | } 402 | end 403 | 404 | it 'should not poll by default' do 405 | out, _ = capture_io { cli.start [:events, '--no-header', 'mystack'] } 406 | out.must_match 'CREATE_IN_PROGRESS' 407 | out.wont_match 'CREATE_COMPLETE' 408 | end 409 | 410 | it 'will poll until complete' do 411 | out, _ = capture_io { 412 | cli.start [:events, '--no-header', '--poll', 'mystack'] 413 | } 414 | out.must_match 'CREATE_IN_PROGRESS' 415 | out.must_match 'CREATE_COMPLETE' 416 | end 417 | end 418 | 419 | end 420 | 421 | describe '#delete' do 422 | describe 'with a stack' do 423 | before do 424 | Aws.config[:cloudformation] = { 425 | stub_responses: { 426 | describe_stacks: { stacks: [ stub_stack_data ] }, 427 | describe_stack_events: { stack_events: [ stub_event_data ] } 428 | } 429 | } 430 | end 431 | 432 | it 'deletes the stack' do 433 | Thor::LineEditor.stub :readline, "yes" do 434 | out, err = capture_io { cli.start [:delete, 'mystack'] } 435 | out.must_match "Deleted stack mystack" 436 | out.must_match "Polling for events..." 437 | err.must_equal '' 438 | end 439 | end 440 | 441 | it 'does not delete the stack if you say no' do 442 | Thor::LineEditor.stub :readline, "no" do 443 | out, err = capture_io { cli.start [:delete, 'mystack'] } 444 | out.must_equal '' 445 | err.must_equal '' 446 | end 447 | end 448 | 449 | it 'does not ask when --force is set' do 450 | out, err = capture_io { cli.start [:delete, '--force', 'mystack'] } 451 | out.must_match "Deleted stack mystack" 452 | err.must_equal '' 453 | end 454 | end 455 | 456 | it 'returns an error for a stack in another service' do 457 | Aws.config[:cloudformation] = { 458 | stub_responses: { describe_stacks: { stacks: [ stub_stack_data(tags: []) ] } } 459 | } 460 | out, err = capture_io { cli.start [:delete, 'wrong-stack'] } 461 | out.must_equal '' 462 | err.must_match 'Stack wrong-stack is not tagged for service' 463 | end 464 | end 465 | 466 | describe '#version' do 467 | let(:version) { CfnFlow::VERSION + "\n" } 468 | it 'prints the version' do 469 | out, _ = capture_io { cli.start [:version] } 470 | out.must_equal version 471 | end 472 | 473 | it 'handles -v argument' do 474 | out, _ = capture_io { cli.start ['-v'] } 475 | out.must_equal version 476 | end 477 | 478 | it 'handles --version argument' do 479 | out, _ = capture_io { cli.start ['--version'] } 480 | out.must_equal version 481 | end 482 | 483 | end 484 | end 485 | -------------------------------------------------------------------------------- /spec/cfn_flow/event_presenter_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper' 2 | 3 | describe 'EventPresenter' do 4 | 5 | subject { CfnFlow::EventPresenter } 6 | after { CfnFlow::EventPresenter.seen_event_ids.clear } 7 | 8 | let(:event) { stub_event } 9 | let(:event_with_reason) { stub_event(resource_status_reason: 'stubbed-reason') } 10 | 11 | describe '.seen_event_ids' do 12 | it 'should be a set' do 13 | subject.seen_event_ids.must_be_kind_of Set 14 | end 15 | end 16 | 17 | describe '.present' do 18 | it 'should present the right number of events' do 19 | events = [event, event_with_reason] 20 | result = subject.present(events) {|e| e} 21 | 22 | result.size.must_equal 2 23 | result.each {|e| e.must_be_kind_of CfnFlow::EventPresenter } 24 | end 25 | 26 | it 'should omit dupe events' do 27 | subject.present([event]) {} 28 | subject.present([event]) {}.must_equal [] 29 | end 30 | 31 | it 'should render the status' do 32 | out, _ = capture_io do 33 | subject.present([event]) { |e| puts e.to_s } 34 | end 35 | 36 | out.must_match event.resource_status 37 | end 38 | end 39 | 40 | describe '#initialize' do 41 | it 'should add to .seen_event_ids' do 42 | subject.new(event) 43 | subject.seen_event_ids.include?(event.id).must_equal true 44 | end 45 | end 46 | 47 | describe '#to_s' do 48 | it 'should show the appropriate details' do 49 | str = subject.new(event).to_s 50 | str.must_match event.logical_resource_id 51 | end 52 | 53 | it 'should show a reason' do 54 | str = subject.new(event_with_reason).to_s 55 | str.must_match event_with_reason.resource_status_reason 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/cfn_flow/stack_params_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper' 2 | 3 | describe 'CfnFlow::StackParams' do 4 | subject { CfnFlow::StackParams } 5 | 6 | it 'should be a hash' do 7 | subject.new.must_be_kind_of Hash 8 | end 9 | 10 | describe '.expanded' do 11 | it "returns a StackParams hash" do 12 | subject.expanded({}).must_be_kind_of subject 13 | end 14 | end 15 | 16 | describe '#with_symbolized_keys' do 17 | it 'works' do 18 | subject[{'foo' => 1, :bar => true}].with_symbolized_keys.must_equal({foo: 1, bar: true}) 19 | end 20 | end 21 | 22 | describe '#with_expanded_parameters' do 23 | it 'reformats parameters hash to array of hashes' do 24 | hash = { 25 | parameters: { 'k1' => 'v1', 'k2' => 'v2' } 26 | } 27 | 28 | expected = { 29 | parameters: [ 30 | {parameter_key: 'k1', parameter_value: 'v1'}, 31 | {parameter_key: 'k2', parameter_value: 'v2'} 32 | ] 33 | } 34 | 35 | subject[hash].with_expanded_parameters.must_equal expected 36 | end 37 | 38 | describe 'with stack outputs' do 39 | let(:output_key) { 'my-output-key' } 40 | let(:output_value) { 'my-output-value' } 41 | 42 | before do 43 | Aws.config[:cloudformation]= { 44 | stub_responses: { 45 | describe_stacks: { stacks: [ stub_stack_data.merge(outputs: [{ output_key: output_key, output_value: output_value } ]) ] } 46 | } 47 | } 48 | end 49 | 50 | it 'fetches stack outputs with explicit output key' do 51 | hash = { 52 | parameters: { 53 | 'my-key' => { 'stack' => 'my-stack', 'output' => output_key} 54 | } 55 | } 56 | expected = { 57 | parameters: [ {parameter_key: 'my-key', parameter_value: output_value} ] 58 | } 59 | 60 | subject[hash].with_expanded_parameters.must_equal expected 61 | end 62 | 63 | it 'fetches stack outputs with implicit output key' do 64 | hash = { 65 | parameters: { 66 | output_key => { 'stack' => 'my-stack'} 67 | } 68 | } 69 | expected = { 70 | parameters: [ {parameter_key: output_key, parameter_value: output_value} ] 71 | } 72 | 73 | subject[hash].with_expanded_parameters.must_equal expected 74 | end 75 | end 76 | end 77 | 78 | describe '#with_expanded_tags' do 79 | it 'expands tags hash to array of hashes' do 80 | hash = {tags: {'k' => 'v'} } 81 | expected = {tags: [{key: 'k', value: 'v'}]} 82 | subject[hash].with_expanded_tags.must_equal expected 83 | end 84 | end 85 | 86 | describe '#add_tag' do 87 | it 'sets an empty tag hash' do 88 | subject.new.add_tag('k' => 'v').must_equal({tags: [{key: 'k', value: 'v'}]}) 89 | 90 | end 91 | it 'appends to existing tag hash' do 92 | orig = subject[{tags: [{key: 'k1', value: 'v1'}] }] 93 | expected = {tags: [{key: 'k1', value: 'v1'}, {key: 'k2', value: 'v2'}] } 94 | 95 | orig.add_tag('k2' => 'v2').must_equal expected 96 | 97 | end 98 | end 99 | 100 | describe '#with_expanded_template_body' do 101 | it 'does not expand invalid templates' do 102 | hash = { template_body: 'spec/data/invalid.yml' } 103 | subject[hash].with_expanded_template_body.must_equal hash 104 | end 105 | 106 | it 'expands valid template paths' do 107 | template_path = 'spec/data/sqs.template' 108 | result = subject[template_body: template_path].with_expanded_template_body 109 | 110 | result.must_equal({template_body: CfnFlow::Template.new(template_path).to_json}) 111 | end 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /spec/cfn_flow/template_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../helper' 2 | 3 | describe 'CfnFlow::Template' do 4 | 5 | let(:template) { 6 | CfnFlow::Template.new('spec/data/sqs.template') 7 | } 8 | 9 | let(:yml_template) { 10 | CfnFlow::Template.new('spec/data/sqs.yml') 11 | } 12 | 13 | let(:not_a_template) { 14 | CfnFlow::Template.new('spec/data/cfn-flow.yml') 15 | } 16 | 17 | let(:release) { 'deadbeef' } 18 | 19 | describe '#initialize' do 20 | subject { CfnFlow::Template } 21 | 22 | it('succeeds') do 23 | subject.new('f').must_be_kind_of CfnFlow::Template 24 | end 25 | 26 | it('requires args') do 27 | -> { subject.new }.must_raise(ArgumentError) 28 | end 29 | end 30 | 31 | describe '#yaml?' do 32 | it 'works' do 33 | yml_template.yaml?.must_equal true 34 | yml_template.json?.must_equal false 35 | template.yaml?.must_equal false 36 | template.json?.must_equal true 37 | end 38 | end 39 | 40 | describe '#is_cfn_template?' do 41 | it 'works' do 42 | yml_template.is_cfn_template?.must_equal true 43 | template.is_cfn_template?.must_equal true 44 | not_a_template.is_cfn_template?.must_equal false 45 | end 46 | end 47 | 48 | describe '#bucket' do 49 | it 'uses CfnFlow.template_s3_bucket' do 50 | template.bucket.must_equal CfnFlow.template_s3_bucket 51 | end 52 | it 'has the correct value' do 53 | template.bucket.must_equal 'test-bucket' 54 | end 55 | end 56 | 57 | describe '#s3_prefix' do 58 | it 'uses CfnFlow.template_s3_prefix' do 59 | template.s3_prefix.must_equal CfnFlow.template_s3_prefix 60 | end 61 | it 'has the correct value' do 62 | template.s3_prefix.must_equal 'test-prefix' 63 | end 64 | end 65 | 66 | describe '#key' do 67 | it 'has the correct value' do 68 | expected = File.join(template.s3_prefix, release, template.local_path) 69 | template.key(release).must_equal expected 70 | end 71 | 72 | it "removes leading './'" do 73 | CfnFlow::Template.new('./foo').key(release).must_equal "test-prefix/#{release}/foo" 74 | end 75 | 76 | it "can have a empty s3_prefix" do 77 | CfnFlow.instance_variable_set(:@config, {'templates' => {'s3_bucket' => 'foo'}}) 78 | expected = File.join(release, template.local_path) 79 | template.key(release).must_equal expected 80 | end 81 | end 82 | 83 | describe '#s3_object' do 84 | it 'is an S3::Object' do 85 | subject = template.s3_object(release) 86 | subject.must_be_kind_of Aws::S3::Object 87 | subject.bucket.name.must_equal template.bucket 88 | subject.key.must_equal template.key(release) 89 | end 90 | end 91 | 92 | describe '#url' do 93 | it 'is the correct S3 url' do 94 | uri = URI.parse(template.url(release)) 95 | uri.scheme.must_equal 'https' 96 | uri.host.must_match(/\A#{template.bucket}\.s3\..+\.amazonaws\.com\z/) 97 | uri.path.must_equal('/' + template.key(release)) 98 | end 99 | end 100 | 101 | describe '#upload' do 102 | it 'succeeds' do 103 | template.upload(release) 104 | end 105 | end 106 | 107 | describe '#local_data' do 108 | it 'should read valid data' do 109 | template.local_data.must_be_kind_of Hash 110 | template.local_data.must_be_kind_of Hash 111 | end 112 | 113 | it 'should parse ERB' do 114 | CfnFlow::Template.new('spec/data/erb-test.yml').local_data.must_equal({'foo' => 3}) 115 | end 116 | 117 | it 'should raise an error on invalid json data' do 118 | -> { CfnFlow::Template.new('spec/data/invalid.json').local_data }.must_raise CfnFlow::Template::Error 119 | end 120 | 121 | it 'should raise an error on invalid YAML data' do 122 | -> { CfnFlow::Template.new('spec/data/invalid.yml').local_data }.must_raise CfnFlow::Template::Error 123 | end 124 | it 'should raise an on a missing file' do 125 | -> { CfnFlow::Template.new('no/such/file').local_data }.must_raise CfnFlow::Template::Error 126 | end 127 | end 128 | 129 | describe '#to_json' do 130 | it 'should work' do 131 | template.to_json.must_equal MultiJson.dump(template.local_data, pretty: true) 132 | end 133 | end 134 | 135 | describe '#validate!' do 136 | it 'succeeds' do 137 | template.validate! 138 | end 139 | it 'can raise an error' do 140 | Aws.config[:cloudformation] = {stub_responses: {validate_template: 'ValidationError'}} 141 | -> { template.validate! }.must_raise Aws::CloudFormation::Errors::ValidationError 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/cfn_flow_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'helper' 2 | 3 | describe 'CfnFlow' do 4 | subject { CfnFlow } 5 | 6 | describe '.config_path' do 7 | it 'should be ./cfn-flow.yml by default' do 8 | ENV.delete('CFN_FLOW_CONFIG_PATH') 9 | subject.config_path.must_equal 'cfn-flow.yml' 10 | end 11 | 12 | it 'can be overridden with ENV[CFN_FLOW_CONFIG_PATH]' do 13 | ENV['CFN_FLOW_CONFIG_PATH'] = 'foo/bar' 14 | subject.config_path.must_equal 'foo/bar' 15 | end 16 | end 17 | 18 | describe '.config_loaded?' do 19 | it 'should be false by default' do 20 | subject.config_loaded?.must_equal false 21 | end 22 | 23 | it 'should be true after loading' do 24 | subject.load_config 25 | subject.config_loaded?.must_equal true 26 | end 27 | end 28 | 29 | describe '.config' do 30 | it('should be a hash') { subject.config.must_be_kind_of(Hash) } 31 | end 32 | 33 | describe '.service' do 34 | it('raises an error when missing') do 35 | subject.instance_variable_set(:@config, {}) 36 | error = -> { subject.service }.must_raise(Thor::Error) 37 | error.message.must_match 'No service name' 38 | end 39 | 40 | it('returns the service') do 41 | subject.instance_variable_set(:@config, {'service' => 'RoflScaler'}) 42 | subject.service.must_equal 'RoflScaler' 43 | end 44 | end 45 | 46 | describe '.stack_params' do 47 | it('raises an error when missing') do 48 | subject.instance_variable_set(:@config, {}) 49 | error = -> { subject.stack_params('env') }.must_raise(Thor::Error) 50 | error.message.must_match 'No stack defined' 51 | end 52 | 53 | it('returns a StackParams hash') do 54 | subject.instance_variable_set(:@config, {'service' => 'myservice', 'stack' => {}}) 55 | subject.stack_params('env').must_be_kind_of CfnFlow::StackParams 56 | end 57 | 58 | it 'appends CfnFlow tags' do 59 | subject.instance_variable_set(:@config, {'service' => 'myservice', 'stack' => {}}) 60 | expected = [ 61 | { key: 'CfnFlowService', value: 'myservice' }, 62 | { key: 'CfnFlowEnvironment', value: 'env' } 63 | ] 64 | 65 | subject.stack_params('env')[:tags].must_equal expected 66 | end 67 | end 68 | 69 | describe '.template_s3_bucket' do 70 | it('raises an error when missing') do 71 | subject.instance_variable_set(:@config, {}) 72 | error = -> { subject.template_s3_bucket }.must_raise(Thor::Error) 73 | error.message.must_match 'No s3_bucket defined' 74 | 75 | subject.instance_variable_set(:@config, {'templates' => {}}) 76 | error = -> { subject.template_s3_bucket }.must_raise(Thor::Error) 77 | error.message.must_match 'No s3_bucket defined' 78 | end 79 | 80 | it 'succeeds' do 81 | subject.instance_variable_set(:@config, {'templates' => {'s3_bucket' => 'hello'}}) 82 | subject.template_s3_bucket.must_equal 'hello' 83 | end 84 | end 85 | 86 | describe '.template_s3_prefix' do 87 | it('raises an error when missing') do 88 | subject.instance_variable_set(:@config, {}) 89 | error = -> { subject.template_s3_prefix }.must_raise(Thor::Error) 90 | error.message.must_match 'No templates defined' 91 | end 92 | 93 | it 'succeeds' do 94 | subject.instance_variable_set(:@config, {'templates' => {'s3_prefix' => 'hello'}}) 95 | subject.template_s3_prefix.must_equal 'hello' 96 | end 97 | 98 | it 'can be nil' do 99 | subject.instance_variable_set(:@config, {'templates' => {}}) 100 | subject.template_s3_prefix.must_equal nil 101 | end 102 | end 103 | 104 | describe '.cfn_client' do 105 | it 'should work' do 106 | subject.cfn_client.must_be_kind_of Aws::CloudFormation::Client 107 | end 108 | 109 | describe 'aws region' do 110 | it 'should default to the env region' do 111 | ENV['AWS_REGION'] = 'env-region' 112 | subject.cfn_client.config.region.must_equal 'env-region' 113 | end 114 | 115 | it 'can be overridden with config' do 116 | ENV['AWS_REGION'] = 'env-region' 117 | subject.instance_variable_set(:@config, {'region' => 'config-region' }) 118 | subject.cfn_client.config.region.must_equal 'config-region' 119 | end 120 | end 121 | 122 | end 123 | 124 | describe '.cfn_resource' do 125 | it 'should work' do 126 | subject.cfn_resource.must_be_kind_of Aws::CloudFormation::Resource 127 | end 128 | 129 | it 'should set a retry_limit' do 130 | subject.cfn_resource.client.config.retry_limit.must_equal 10 131 | end 132 | 133 | describe 'aws region' do 134 | it 'should default to the env region' do 135 | ENV['AWS_REGION'] = 'env-region' 136 | subject.cfn_client.config.region.must_equal 'env-region' 137 | end 138 | 139 | it 'can be overridden with config' do 140 | ENV['AWS_REGION'] = 'env-region' 141 | subject.instance_variable_set(:@config, {'region' => 'config-region' }) 142 | subject.cfn_client.config.region.must_equal 'config-region' 143 | end 144 | end 145 | end 146 | 147 | describe '.exit_on_failure?' do 148 | it 'is true by default' do 149 | CfnFlow.remove_instance_variable(:@exit_on_failure) if CfnFlow.instance_variable_defined?(:@exit_on_failure) 150 | CfnFlow.exit_on_failure?.must_equal true 151 | end 152 | 153 | it 'can be set' do 154 | CfnFlow.exit_on_failure = false 155 | CfnFlow.exit_on_failure?.must_equal false 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/data/cfn-flow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | service: cfn-flow-specs 3 | templates: 4 | s3_bucket: test-bucket 5 | s3_prefix: test-prefix 6 | 7 | stack: 8 | stack_name: cfn-flow-spec-stack 9 | template_body: sqs.yml 10 | parameters: 11 | AlarmEMail: test@example.com 12 | tags: 13 | test_tag: hello world 14 | -------------------------------------------------------------------------------- /spec/data/erb-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: <%= 1+2 %> 3 | -------------------------------------------------------------------------------- /spec/data/invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | -------------------------------------------------------------------------------- /spec/data/invalid.yml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: 3 | bar 4 | -------------------------------------------------------------------------------- /spec/data/sqs.template: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion" : "2010-09-09", 3 | 4 | "Description" : "AWS CloudFormation Sample Template SQS_With_CloudWatch_Alarms: Sample template showing how to create an SQS queue with AWS CloudWatch alarms on queue depth. **WARNING** This template creates an Amazon SQS Queue and one or more Amazon CloudWatch alarms. You will be billed for the AWS resources used if you create a stack from this template.", 5 | 6 | "Parameters" : { 7 | "AlarmEMail": { 8 | "Description": "EMail address to notify if there are any operational issues", 9 | "Type": "String", 10 | "AllowedPattern": "([a-zA-Z0-9_\\-\\.]+)@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.)|(([a-zA-Z0-9\\-]+\\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\\]?)", 11 | "ConstraintDescription": "must be a valid email address." 12 | } 13 | }, 14 | 15 | "Resources" : { 16 | "MyQueue" : { 17 | "Type" : "AWS::SQS::Queue", 18 | "Properties" : { 19 | } 20 | }, 21 | 22 | "AlarmTopic": { 23 | "Type": "AWS::SNS::Topic", 24 | "Properties": { 25 | "Subscription": [{ 26 | "Endpoint": { "Ref": "AlarmEMail" }, 27 | "Protocol": "email" 28 | }] 29 | } 30 | }, 31 | 32 | "QueueDepthAlarm": { 33 | "Type": "AWS::CloudWatch::Alarm", 34 | "Properties": { 35 | "AlarmDescription": "Alarm if queue depth grows beyond 10 messages", 36 | "Namespace": "AWS/SQS", 37 | "MetricName": "ApproximateNumberOfMessagesVisible", 38 | "Dimensions": [{ 39 | "Name": "QueueName", 40 | "Value" : { "Fn::GetAtt" : ["MyQueue", "QueueName"] } 41 | }], 42 | "Statistic": "Sum", 43 | "Period": "300", 44 | "EvaluationPeriods": "1", 45 | "Threshold": "10", 46 | "ComparisonOperator": "GreaterThanThreshold", 47 | "AlarmActions": [{ "Ref": "AlarmTopic" }], 48 | "InsufficientDataActions": [{ "Ref": "AlarmTopic" }] 49 | } 50 | } 51 | }, 52 | "Outputs" : { 53 | "QueueURL" : { 54 | "Description" : "URL of newly created SQS Queue", 55 | "Value" : { "Ref" : "MyQueue" } 56 | }, 57 | "QueueARN" : { 58 | "Description" : "ARN of newly created SQS Queue", 59 | "Value" : { "Fn::GetAtt" : ["MyQueue", "Arn"]} 60 | }, 61 | "QueueName" : { 62 | "Description" : "Name newly created SQS Queue", 63 | "Value" : { "Fn::GetAtt" : ["MyQueue", "QueueName"]} 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /spec/data/sqs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: '2010-09-09' 3 | Description: 'AWS CloudFormation Sample Template SQS_With_CloudWatch_Alarms: Sample 4 | template showing how to create an SQS queue with AWS CloudWatch alarms on queue 5 | depth. **WARNING** This template creates an Amazon SQS Queue and one or more Amazon 6 | CloudWatch alarms. You will be billed for the AWS resources used if you create a 7 | stack from this template.' 8 | Parameters: 9 | AlarmEMail: 10 | Description: EMail address to notify if there are any operational issues 11 | Type: String 12 | AllowedPattern: "([a-zA-Z0-9_\\-\\.]+)@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.)|(([a-zA-Z0-9\\-]+\\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\\]?)" 13 | ConstraintDescription: must be a valid email address. 14 | Resources: 15 | MyQueue: 16 | Type: AWS::SQS::Queue 17 | Properties: {} 18 | AlarmTopic: 19 | Type: AWS::SNS::Topic 20 | Properties: 21 | Subscription: 22 | - Endpoint: 23 | Ref: AlarmEMail 24 | Protocol: email 25 | QueueDepthAlarm: 26 | Type: AWS::CloudWatch::Alarm 27 | Properties: 28 | AlarmDescription: Alarm if queue depth grows beyond 10 messages 29 | Namespace: AWS/SQS 30 | MetricName: ApproximateNumberOfMessagesVisible 31 | Dimensions: 32 | - Name: QueueName 33 | Value: 34 | Fn::GetAtt: 35 | - MyQueue 36 | - QueueName 37 | Statistic: Sum 38 | Period: '300' 39 | EvaluationPeriods: '1' 40 | Threshold: '10' 41 | ComparisonOperator: GreaterThanThreshold 42 | AlarmActions: 43 | - Ref: AlarmTopic 44 | InsufficientDataActions: 45 | - Ref: AlarmTopic 46 | Outputs: 47 | QueueURL: 48 | Description: URL of newly created SQS Queue 49 | Value: 50 | Ref: MyQueue 51 | QueueARN: 52 | Description: ARN of newly created SQS Queue 53 | Value: 54 | Fn::GetAtt: 55 | - MyQueue 56 | - Arn 57 | QueueName: 58 | Description: Name newly created SQS Queue 59 | Value: 60 | Fn::GetAtt: 61 | - MyQueue 62 | - QueueName 63 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "minitest/autorun" 4 | require "minitest/pride" 5 | 6 | begin 7 | require 'pry' 8 | rescue LoadError 9 | # NBD. 10 | end 11 | 12 | require "cfn-flow" 13 | 14 | Aws.config[:stub_responses] = true 15 | ENV['AWS_REGION'] = 'us-east-1' 16 | ENV['AWS_ACCESS_KEY_ID'] = 'test-key' 17 | ENV['AWS_SECRET_ACCESS_KEY'] = 'test-secret' 18 | ENV['CFN_FLOW_DEV_NAME'] = 'cfn-flow-specs' 19 | ENV['CFN_FLOW_CONFIG_PATH'] = 'spec/data/cfn-flow.yml' 20 | ENV['CFN_FLOW_EVENT_POLL_INTERVAL'] = '0' 21 | 22 | class Minitest::Spec 23 | before do 24 | # Reset env between tests: 25 | @orig_env = ENV.to_hash 26 | 27 | # Disable exit on failure so CLI tests don't bomb out 28 | CfnFlow.exit_on_failure = false 29 | end 30 | 31 | after do 32 | # Reset env 33 | ENV.clear 34 | ENV.update(@orig_env) 35 | 36 | # Reset stubs 37 | CfnFlow.clear! 38 | Aws.config.delete(:cloudformation) 39 | end 40 | 41 | def memo_now 42 | @now = Time.now 43 | end 44 | 45 | def stub_stack_data(attrs = {}) 46 | { 47 | stack_name: "mystack", 48 | stack_status: 'CREATE_COMPLETE', 49 | creation_time: memo_now, 50 | tags: [ 51 | {key: 'CfnFlowService', value: CfnFlow.service}, 52 | {key: 'CfnFlowEnvironment', value: 'production'} 53 | ], 54 | outputs: [ output_key: 'mykey', output_value: 'myvalue', description: 'My Output' ] 55 | }.merge(attrs) 56 | end 57 | 58 | def stub_event_data(attrs = {}) 59 | { 60 | stack_id: 'mystack', 61 | stack_name: 'mystack', 62 | event_id: SecureRandom.hex, 63 | resource_status: 'CREATE_COMPLETE', 64 | logical_resource_id: 'stubbed-resource-id', 65 | resource_type: 'stubbed-resource-type', 66 | timestamp: Time.now 67 | }.merge(attrs) 68 | end 69 | 70 | def stub_event(attrs = {}) 71 | data = stub_event_data(attrs) 72 | id = data.delete(:event_id) 73 | Aws::CloudFormation::Event.new(id: id, data: data) 74 | end 75 | 76 | end 77 | --------------------------------------------------------------------------------