├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── bin └── stacker ├── lib ├── stacker.rb └── stacker │ ├── cli.rb │ ├── differ.rb │ ├── logging.rb │ ├── region.rb │ ├── resolvers │ ├── file_resolver.rb │ ├── resolver.rb │ └── stack_output_resolver.rb │ ├── stack.rb │ ├── stack │ ├── capabilities.rb │ ├── component.rb │ ├── errors.rb │ ├── parameter.rb │ ├── parameters.rb │ └── template.rb │ └── version.rb └── stacker.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.swp 3 | 4 | Gemfile.lock 5 | 6 | .bundle/ 7 | vendor/ 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 CoTap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | stacker 2 | ======= 3 | 4 | Easily assemble CloudFormation stacks with interdependencies. 5 | 6 | ## Usage 7 | 8 | ```sh 9 | $ stacker -h 10 | Commands: 11 | stacker diff [STACK_NAME] # Show outstanding stack differences 12 | stacker dump [STACK_NAME] # Download stack template 13 | stacker fmt [STACK_NAME] # Re-format template JSON 14 | stacker help [COMMAND] # Describe available commands or one specific command 15 | stacker init [PATH] # Create stacker project directories 16 | stacker list # List stacks 17 | stacker show STACK_NAME # Show details of a stack 18 | stacker status [STACK_NAME] # Show stack status 19 | stacker update [STACK_NAME] # Create or update stack 20 | 21 | Options: 22 | [--path=project path] # Default: STACKER_PATH or './' 23 | [--region=AWS region name] # Default: STACKER_REGION or 'us-east-1' 24 | [--allow-destructive=allow destructive updates], [--no-allow-destructive] 25 | ``` 26 | 27 | ## Examples 28 | 29 | ### Project Structure 30 | 31 | ``` 32 | acme-cloudformation 33 | |-- regions 34 | | |-- us-east-1.yml 35 | | `-- us-west-1.yml 36 | `-- templates 37 | |-- API.json 38 | |-- Database.json 39 | |-- PrivateSubnets.json 40 | |-- PublicSubnets.json 41 | `-- VPC.json 42 | ``` 43 | 44 | ### Region File 45 | 46 | ```yaml 47 | --- 48 | defaults: 49 | parameters: 50 | AmiImageId: 'ami-1234abcd' 51 | CidrBlock: '10.0' 52 | VPCId: 53 | Stack: VPC 54 | Output: VPCId # depend on an output from another stack 55 | InternetGateway: 56 | Stack: VPC 57 | Output: InternetGateway 58 | PublicSubnets: 59 | - Stack: PublicSubnetA 60 | Output: SubnetId 61 | - Stack: PublicSubnetB 62 | Output: SubnetId 63 | - Stack: PublicSubnetC 64 | Output: SubnetId 65 | 66 | stacks: 67 | - name: VPC 68 | 69 | - name: PublicSubnetA 70 | template_name: PublicSubnet 71 | parameters: 72 | AZ: us-east-1a 73 | 74 | - name: PublicSubnetB 75 | template_name: PublicSubnet 76 | parameters: 77 | AZ: us-east-1b 78 | 79 | - name: PublicSubnetC 80 | template_name: PublicSubnet 81 | parameters: 82 | AZ: us-east-1c 83 | 84 | - name: PrivateSubnets 85 | 86 | - name: API 87 | capabilities: 'CAPABILITY_IAM' # give permission to create IAM resources 88 | parameters: 89 | ChefRunList: 'role[api]' 90 | 91 | - name: DBPrimary 92 | parameters: 93 | ChefRunList: 'role[db-primary]' 94 | InstanceImageId: 'ami-d3adb33f' 95 | SubnetId: 96 | Stack: PublicSubnetA 97 | Output: SubnetId 98 | template_name: Database # use template with a different name 99 | 100 | - name: DBReplica 101 | parameters: 102 | ChefRunList: 'role[db-replica]' 103 | template_name: Database 104 | 105 | ``` 106 | 107 | ## Authors 108 | 109 | Martin Cozzi () and Evan Owen () 110 | 111 | ## License 112 | 113 | (The MIT License) 114 | 115 | © 2016 Cotap, Inc. 116 | 117 | Permission is hereby granted, free of charge, to any person obtaining a copy 118 | of this software and associated documentation files (the “Software”), to deal 119 | in the Software without restriction, including without limitation the rights 120 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 121 | copies of the Software, and to permit persons to whom the Software is 122 | furnished to do so, subject to the following conditions: 123 | 124 | The above copyright notice and this permission notice shall be included in all 125 | copies or substantial portions of the Software. 126 | 127 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 128 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 129 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 130 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 131 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 132 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 133 | SOFTWARE. 134 | -------------------------------------------------------------------------------- /bin/stacker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'stacker' 5 | require 'stacker/cli' 6 | 7 | trap('INT') { puts ''; exit } 8 | 9 | Stacker::Cli.start ARGV 10 | -------------------------------------------------------------------------------- /lib/stacker.rb: -------------------------------------------------------------------------------- 1 | require 'stacker/region' 2 | require 'stacker/stack' 3 | require 'stacker/logging' 4 | require 'stacker/version' 5 | -------------------------------------------------------------------------------- /lib/stacker/cli.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | require 'stacker' 3 | require 'thor' 4 | require 'yaml' 5 | 6 | module Stacker 7 | class Cli < Thor 8 | include Thor::Actions 9 | 10 | default_path = ENV['STACKER_PATH'] || '.' 11 | default_region = ENV['STACKER_REGION'] || 'us-east-1' 12 | 13 | method_option :path, type: :string, default: default_path, 14 | banner: 'project path' 15 | 16 | method_option :region, type: :string, default: default_region, 17 | banner: 'AWS region name' 18 | 19 | method_option :allow_destructive, type: :boolean, default: false, 20 | banner: 'allow destructive updates' 21 | 22 | def initialize(*args); super(*args) end 23 | 24 | desc "init [PATH]", "Create stacker project directories" 25 | def init path = nil 26 | init_project path || options['path'] 27 | end 28 | 29 | desc "list", "List stacks" 30 | def list 31 | Stacker.logger.inspect region.stacks.map(&:name) 32 | end 33 | 34 | desc "show STACK_NAME", "Show details of a stack" 35 | def show stack_name 36 | with_one_or_all stack_name do |stack| 37 | Stacker.logger.inspect( 38 | 'Description' => stack.description, 39 | 'Status' => stack.status, 40 | 'Updated' => stack.last_updated_time || stack.creation_time, 41 | 'Capabilities' => stack.capabilities.remote, 42 | 'Parameters' => stack.parameters.remote, 43 | 'Outputs' => stack.outputs 44 | ) 45 | end 46 | end 47 | 48 | desc "status [STACK_NAME]", "Show stack status" 49 | def status stack_name = nil 50 | with_one_or_all(stack_name) do |stack| 51 | Stacker.logger.debug stack.status.indent 52 | end 53 | end 54 | 55 | desc "diff [STACK_NAME]", "Show outstanding stack differences" 56 | def diff stack_name = nil 57 | with_one_or_all(stack_name) do |stack| 58 | resolve stack 59 | next unless full_diff stack 60 | end 61 | end 62 | 63 | desc "update [STACK_NAME]", "Create or update stack" 64 | def update stack_name = nil 65 | with_one_or_all(stack_name) do |stack| 66 | resolve stack 67 | 68 | if stack.exists? 69 | next unless full_diff stack 70 | 71 | if yes? "Update remote template with these changes (y/n)?" 72 | time = Benchmark.realtime do 73 | stack.update allow_destructive: options['allow_destructive'] 74 | end 75 | Stacker.logger.info formatted_time stack_name, 'updated', time 76 | else 77 | Stacker.logger.warn 'Update skipped' 78 | end 79 | else 80 | if yes? "#{stack.name} does not exist. Create it (y/n)?" 81 | time = Benchmark.realtime do 82 | stack.create 83 | end 84 | Stacker.logger.info formatted_time stack_name, 'created', time 85 | else 86 | Stacker.logger.warn 'Create skipped' 87 | end 88 | end 89 | end 90 | end 91 | 92 | desc "dump [STACK_NAME]", "Download stack template" 93 | def dump stack_name = nil 94 | with_one_or_all(stack_name) do |stack| 95 | if stack.exists? 96 | diff = stack.template.diff :down, :color 97 | next Stacker.logger.warn 'Stack up-to-date' if diff.length == 0 98 | 99 | Stacker.logger.debug "\n" + diff.indent 100 | if yes? "Update local template with these changes (y/n)?" 101 | stack.template.dump 102 | else 103 | Stacker.logger.warn 'Pull skipped' 104 | end 105 | else 106 | Stacker.logger.warn "#{stack.name} does not exist" 107 | end 108 | end 109 | end 110 | 111 | desc "fmt [STACK_NAME]", "Re-format template JSON" 112 | def fmt stack_name = nil 113 | with_one_or_all(stack_name) do |stack| 114 | if stack.template.exists? 115 | Stacker.logger.warn 'Formatting...' 116 | stack.template.write 117 | else 118 | Stacker.logger.warn "#{stack.name} does not exist" 119 | end 120 | end 121 | end 122 | 123 | private 124 | 125 | def init_project path 126 | project_path = File.expand_path path 127 | 128 | %w[ regions templates ].each do |dir| 129 | directory_path = File.join project_path, dir 130 | unless Dir.exists? directory_path 131 | Stacker.logger.debug "Creating directory at #{directory_path}" 132 | FileUtils.mkdir_p directory_path 133 | end 134 | end 135 | 136 | region_path = File.join project_path, 'regions', 'us-east-1.yml' 137 | unless File.exists? region_path 138 | Stacker.logger.debug "Creating region file at #{region_path}" 139 | File.open(region_path, 'w+') { |f| f.print <<-YAML } 140 | defaults: 141 | parameters: 142 | CidrBlock: '10.0' 143 | stacks: 144 | - name: VPC 145 | YAML 146 | end 147 | end 148 | 149 | def formatted_time stack, action, benchmark 150 | "Stack #{stack} #{action} in: #{(benchmark / 60).floor} min and #{(benchmark % 60).round} seconds." 151 | end 152 | 153 | def full_diff stack 154 | templ_diff = stack.template.diff :color 155 | param_diff = stack.parameters.diff :color 156 | 157 | if (templ_diff + param_diff).length == 0 158 | Stacker.logger.warn 'Stack up-to-date' 159 | return false 160 | end 161 | 162 | Stacker.logger.info "\n#{templ_diff.indent}\n" if templ_diff.length > 0 163 | Stacker.logger.info "\n#{param_diff.indent}\n" if param_diff.length > 0 164 | 165 | true 166 | end 167 | 168 | def region 169 | @region ||= begin 170 | config_path = File.join working_path, 'regions', "#{options['region']}.yml" 171 | if File.exists? config_path 172 | begin 173 | config = YAML.load_file(config_path) 174 | rescue Psych::SyntaxError => err 175 | Stacker.logger.fatal err.message 176 | exit 1 177 | end 178 | 179 | defaults = config.fetch 'defaults', {} 180 | stacks = config.fetch 'stacks', {} 181 | 182 | Region.new options['region'], defaults, stacks, templates_path 183 | else 184 | Stacker.logger.fatal "#{options['region']}.yml does not exist. Please configure or use stacker init" 185 | exit 1 186 | end 187 | end 188 | end 189 | 190 | def resolve stack 191 | return {} if stack.parameters.dependencies.none? 192 | 193 | Stacker.logger.debug 'Resolving dependencies...' 194 | stack.parameters.resolved 195 | end 196 | 197 | def with_one_or_all stack_name = nil, &block 198 | yield_with_stack = proc do |stack| 199 | Stacker.logger.info "#{stack.name}:" 200 | yield stack 201 | Stacker.logger.info '' 202 | end 203 | 204 | if stack_name 205 | yield_with_stack.call region.stack(stack_name) 206 | else 207 | region.stacks.each(&yield_with_stack) 208 | end 209 | 210 | rescue Stacker::Stack::StackPolicyError => err 211 | if options['allow_destructive'] 212 | Stacker.logger.fatal err.message 213 | else 214 | Stacker.logger.fatal 'Stack update policy prevents replacing or destroying resources.' 215 | Stacker.logger.warn 'Try running again with \'--allow-destructive\'' 216 | end 217 | exit 1 218 | rescue Stacker::Stack::Error => err 219 | Stacker.logger.fatal err.message 220 | exit 1 221 | end 222 | 223 | def templates_path 224 | File.join working_path, 'templates' 225 | end 226 | 227 | def working_path 228 | File.expand_path options['path'] 229 | end 230 | 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /lib/stacker/differ.rb: -------------------------------------------------------------------------------- 1 | require 'diffy' 2 | require 'json' 3 | 4 | module Stacker 5 | module Differ 6 | 7 | module_function 8 | 9 | def diff one, two, *args 10 | down = args.include? :down 11 | 12 | diff = Diffy::Diff.new( 13 | (down ? one : two) + "\n", 14 | (down ? two : one) + "\n", 15 | context: 3, 16 | include_diff_info: true 17 | ).to_s(*args.select { |arg| arg == :color }) 18 | 19 | diff.gsub(/^(\x1B.+)?(\-{3}|\+{3}).+\n/, '').strip 20 | end 21 | 22 | def json_diff one, two, *args 23 | diff JSON.pretty_generate(one), JSON.pretty_generate(two), *args 24 | end 25 | 26 | def yaml_diff one, two, *args 27 | diff YAML.dump(one), YAML.dump(two), *args 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/stacker/logging.rb: -------------------------------------------------------------------------------- 1 | require 'coderay' 2 | require 'delegate' 3 | require 'indentation' 4 | require 'rainbow' 5 | 6 | module Stacker 7 | module_function 8 | 9 | class << self 10 | 11 | class PrettyLogger < SimpleDelegator 12 | def initialize logger 13 | super 14 | 15 | old_formatter = logger.formatter 16 | 17 | logger.formatter = proc do |level, time, prog, msg| 18 | unless msg.start_with?("\e") 19 | color = case level 20 | when 'FATAL' then :red 21 | when 'WARN' then :yellow 22 | when 'INFO' then :blue 23 | when 'DEBUG' then '333333' 24 | else :default 25 | end 26 | msg = msg.color(color) 27 | end 28 | 29 | old_formatter.call level, time, prog, msg 30 | end 31 | end 32 | 33 | %w[ debug info warn fatal ].each do |level| 34 | define_method level do |msg, opts = {}| 35 | if opts.include? :highlight 36 | msg = CodeRay.scan(msg, opts[:highlight]).terminal 37 | end 38 | __getobj__.__send__ level, msg 39 | end 40 | end 41 | 42 | def inspect object 43 | info object.to_yaml[4..-1].strip.indent, highlight: :yaml 44 | end 45 | end 46 | 47 | def logger= logger 48 | @logger = PrettyLogger.new logger 49 | end 50 | 51 | def logger 52 | @logger ||= begin 53 | logger = Logger.new STDOUT 54 | logger.level = Logger::DEBUG 55 | logger.formatter = proc { |_, _, _, msg| "#{msg}\n" } 56 | PrettyLogger.new logger 57 | end 58 | end 59 | 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/stacker/region.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk' 2 | require 'stacker/stack' 3 | 4 | module Stacker 5 | class Region 6 | 7 | attr_reader :name, :defaults, :stacks, :templates_path 8 | 9 | def initialize(name, defaults, stacks, templates_path) 10 | @name = name 11 | @defaults = defaults 12 | @stacks = stacks.map do |options| 13 | begin 14 | Stack.new self, options.fetch('name'), options 15 | rescue KeyError => err 16 | Stacker.logger.fatal "Malformed YAML: #{err.message}" 17 | exit 1 18 | end 19 | end 20 | @templates_path = templates_path 21 | end 22 | 23 | def client 24 | @client ||= AWS::CloudFormation.new region: name 25 | end 26 | 27 | def stack name 28 | stacks.find { |s| s.name == name } || Stack.new(self, name) 29 | end 30 | 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/stacker/resolvers/file_resolver.rb: -------------------------------------------------------------------------------- 1 | require 'stacker/resolvers/resolver' 2 | 3 | module Stacker 4 | module Resolvers 5 | 6 | class FileResolver < Resolver 7 | 8 | def resolve 9 | IO.read ref 10 | end 11 | 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/stacker/resolvers/resolver.rb: -------------------------------------------------------------------------------- 1 | module Stacker 2 | module Resolvers 3 | 4 | class Resolver 5 | 6 | attr_reader :ref, :region 7 | 8 | def initialize ref, region 9 | @ref = ref 10 | @region = region 11 | end 12 | 13 | def resolve 14 | raise NotImplementedError 15 | end 16 | 17 | end 18 | 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/stacker/resolvers/stack_output_resolver.rb: -------------------------------------------------------------------------------- 1 | require 'stacker/resolvers/resolver' 2 | 3 | module Stacker 4 | module Resolvers 5 | 6 | class StackOutputResolver < Resolver 7 | 8 | def resolve 9 | stack = region.stack ref.fetch('Stack') 10 | stack.outputs.fetch ref.fetch('Output') 11 | end 12 | 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/stacker/stack.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/hash/keys' 2 | require 'active_support/core_ext/hash/slice' 3 | require 'active_support/core_ext/module/delegation' 4 | require 'aws-sdk' 5 | require 'memoist' 6 | require 'stacker/stack/errors' 7 | require 'stacker/stack/capabilities' 8 | require 'stacker/stack/parameters' 9 | require 'stacker/stack/template' 10 | 11 | module Stacker 12 | class Stack 13 | 14 | extend Memoist 15 | 16 | CLIENT_METHODS = %w[ 17 | creation_time 18 | description 19 | exists? 20 | last_updated_time 21 | status 22 | status_reason 23 | ] 24 | 25 | SAFE_UPDATE_POLICY = <<-JSON 26 | { 27 | "Statement" : [ 28 | { 29 | "Effect" : "Deny", 30 | "Action" : ["Update:Replace", "Update:Delete"], 31 | "Principal" : "*", 32 | "Resource" : "*" 33 | }, 34 | { 35 | "Effect" : "Allow", 36 | "Action" : "Update:*", 37 | "Principal" : "*", 38 | "Resource" : "*" 39 | } 40 | ] 41 | } 42 | JSON 43 | 44 | attr_reader :region, :name, :options 45 | 46 | def initialize region, name, options = {} 47 | @region, @name, @options = region, name, options 48 | end 49 | 50 | def client 51 | @client ||= region.client.stacks[name] 52 | end 53 | 54 | delegate *CLIENT_METHODS, to: :client 55 | memoize *CLIENT_METHODS 56 | 57 | %w[complete failed in_progress].each do |stage| 58 | define_method(:"#{stage}?") { status =~ /#{stage.upcase}/ } 59 | end 60 | 61 | def template 62 | @template ||= Template.new self 63 | end 64 | 65 | def parameters 66 | @parameters ||= Parameters.new self 67 | end 68 | 69 | def capabilities 70 | @capabilities ||= Capabilities.new self 71 | end 72 | 73 | def outputs 74 | @outputs ||= begin 75 | return {} unless complete? 76 | Hash[client.outputs.map { |output| [ output.key, output.value ] }] 77 | end 78 | end 79 | 80 | def create blocking = true 81 | if exists? 82 | Stacker.logger.warn 'Stack already exists' 83 | return 84 | end 85 | 86 | if parameters.missing.any? 87 | raise MissingParameters.new( 88 | "Required parameters missing: #{parameters.missing.join ', '}" 89 | ) 90 | end 91 | 92 | Stacker.logger.info 'Creating stack' 93 | 94 | region.client.stacks.create( 95 | name, 96 | template.local, 97 | parameters: parameters.resolved, 98 | capabilities: capabilities.local 99 | ) 100 | 101 | wait_while_status 'CREATE_IN_PROGRESS' if blocking 102 | rescue AWS::CloudFormation::Errors::ValidationError => err 103 | raise Error.new err.message 104 | end 105 | 106 | def update options = {} 107 | options.assert_valid_keys(:blocking, :allow_destructive) 108 | 109 | blocking = options.fetch(:blocking, true) 110 | allow_destructive = options.fetch(:allow_destructive, false) 111 | 112 | if parameters.missing.any? 113 | raise MissingParameters.new( 114 | "Required parameters missing: #{parameters.missing.join ', '}" 115 | ) 116 | end 117 | 118 | Stacker.logger.info 'Updating stack' 119 | 120 | update_params = { 121 | template: template.local, 122 | parameters: parameters.resolved, 123 | capabilities: capabilities.local 124 | } 125 | 126 | unless allow_destructive 127 | update_params[:stack_policy_during_update_body] = SAFE_UPDATE_POLICY 128 | end 129 | 130 | client.update(update_params) 131 | 132 | wait_while_status 'UPDATE_IN_PROGRESS' if blocking 133 | rescue AWS::CloudFormation::Errors::ValidationError => err 134 | case err.message 135 | when /does not exist/ 136 | raise DoesNotExistError.new err.message 137 | when /No updates/ 138 | raise UpToDateError.new err.message 139 | else 140 | raise Error.new err.message 141 | end 142 | end 143 | 144 | private 145 | 146 | def report_status 147 | case status 148 | when /_COMPLETE$/ 149 | Stacker.logger.info "#{name} Status => #{status}" 150 | when /_ROLLBACK_IN_PROGRESS$/ 151 | failure_event = client.events.enum(limit: 30).find do |event| 152 | event.resource_status =~ /_FAILED$/ 153 | end 154 | failure_reason = failure_event.resource_status_reason 155 | if failure_reason =~ /stack policy/ 156 | raise StackPolicyError.new failure_reason 157 | else 158 | Stacker.logger.fatal "#{name} Status => #{status}" 159 | raise Error.new "Failure Reason: #{failure_reason}" 160 | end 161 | else 162 | Stacker.logger.debug "#{name} Status => #{status}" 163 | end 164 | end 165 | 166 | def wait_while_status wait_status 167 | while flush_cache('status') && status == wait_status 168 | report_status 169 | sleep 5 170 | end 171 | report_status 172 | end 173 | 174 | end 175 | end 176 | 177 | -------------------------------------------------------------------------------- /lib/stacker/stack/capabilities.rb: -------------------------------------------------------------------------------- 1 | require 'stacker/stack/component' 2 | 3 | module Stacker 4 | class Stack 5 | class Capabilities < Component 6 | 7 | def local 8 | @local ||= Array(stack.options.fetch 'capabilities', []) 9 | end 10 | 11 | def remote 12 | @remote ||= client.capabilities 13 | end 14 | 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/stacker/stack/component.rb: -------------------------------------------------------------------------------- 1 | require 'stacker/stack' 2 | 3 | module Stacker 4 | class Stack 5 | # an abstract base class for stack components (template, parameters) 6 | class Component 7 | 8 | attr_reader :stack 9 | 10 | def initialize stack 11 | @stack = stack 12 | end 13 | 14 | private 15 | 16 | def client 17 | stack.client 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/stacker/stack/errors.rb: -------------------------------------------------------------------------------- 1 | require 'jsonlint' 2 | 3 | module Stacker 4 | class Stack 5 | 6 | class Error < StandardError; end 7 | class StackPolicyError < Error; end 8 | class DoesNotExistError < Error; end 9 | class MissingParameters < Error; end 10 | class UpToDateError < Error; end 11 | 12 | class TemplateSyntaxError < Error 13 | 14 | def initialize(path) 15 | @path = path 16 | end 17 | 18 | def message 19 | < err 37 | if err.message =~ /does not exist/ 38 | raise DoesNotExistError.new err.message 39 | else 40 | raise Error.new err.message 41 | end 42 | end 43 | 44 | def diff *args 45 | Differ.json_diff local, remote, *args 46 | end 47 | memoize :diff 48 | 49 | def write value = local 50 | File.write path, JSONFormatter.format(value) 51 | end 52 | 53 | def dump 54 | write remote 55 | end 56 | 57 | private 58 | 59 | def path 60 | @path ||= File.join( 61 | stack.region.templates_path, 62 | "#{stack.options.fetch('template_name', stack.name)}.json" 63 | ) 64 | end 65 | 66 | class JSONFormatter 67 | STR = '\"[^\"]+\"' 68 | 69 | def self.format object 70 | formatted = JSON.pretty_generate object 71 | 72 | # put empty arrays on a single line 73 | formatted.gsub! /: \[\s*\]/m, ': []' 74 | 75 | # put { "Ref": ... } on a single line 76 | formatted.gsub! /\{\s+\"Ref\"\:\s+(?#{STR})\s+\}/m, 77 | '{ "Ref": \\k }' 78 | 79 | # put { "Fn::GetAtt": ... } on a single line 80 | formatted.gsub! /\{\s+\"Fn::GetAtt\"\: \[\s+(?#{STR}),\s+(?#{STR})\s+\]\s+\}/m, 81 | '{ "Fn::GetAtt": [ \\k, \\k ] }' 82 | 83 | formatted + "\n" 84 | end 85 | end 86 | 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/stacker/version.rb: -------------------------------------------------------------------------------- 1 | module Stacker 2 | VERSION = '0.4.0' 3 | end 4 | -------------------------------------------------------------------------------- /stacker.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path '../lib', __FILE__ 2 | require 'stacker/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'stacker' 6 | s.version = Stacker::VERSION 7 | s.summary = 'Easily assemble CloudFormation stacks.' 8 | s.description = 'Stacker helps you assemple and manage CloudFormation stacks and dependencies.' 9 | s.license = 'MIT' 10 | 11 | s.files = Dir['lib/**/*'] 12 | s.executables = Dir['bin/*'].map{ |f| File.basename(f) } 13 | 14 | s.has_rdoc = false 15 | 16 | s.authors = ['Cotap, Inc.'] 17 | s.email = %w[martin@cotap.com evan@cotap.com] 18 | s.homepage = 'https://github.com/cotap/stacker' 19 | 20 | s.required_ruby_version = '>= 1.9.3' 21 | 22 | s.add_dependency 'activesupport', '~> 4.0' 23 | s.add_dependency 'aws-sdk', '~> 1.25' 24 | s.add_dependency 'coderay', '~> 1.1' 25 | s.add_dependency 'diffy', '~> 3.0' 26 | s.add_dependency 'indentation', '~> 0.0' 27 | s.add_dependency 'jsonlint', '~> 0.2.0' 28 | s.add_dependency 'memoist', '~> 0.14' 29 | s.add_dependency 'rainbow', '~> 1.1' 30 | s.add_dependency 'thor', '~> 0.18' 31 | 32 | s.add_development_dependency 'rake', '~> 10.4' 33 | s.add_development_dependency 'rspec', '~> 2.99' 34 | end 35 | --------------------------------------------------------------------------------