stackup 0.0.9 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: bd4fb7b89612636d7c12bf5de9df53b33d426706
4
- data.tar.gz: 0efb356c797b510c3c73be0feb46f64b883ca5d7
3
+ metadata.gz: 018537d107f8cc0049863b56dd604f8c881217c9
4
+ data.tar.gz: b62b34f99958d38df0d44d4a46af577832eb4174
5
5
  SHA512:
6
- metadata.gz: 6820c01014c27659f323943935b8914c17f2d77be132d5e407c696216a90dce0cc188ed8d6f1e20aaebc4fcf1142b2ced902be47de5c091677c7f1f074e24131
7
- data.tar.gz: 386304c7efb13945816d072f1348ce13fc01da0d3c42a551c637c57ecadcc43baa0a3e90b83b8ad0234c71762cd550edf08a5c6dc73b2a3fb9be3344b89370f3
6
+ metadata.gz: fe03ddbff07fbb42c2781268e131b16dfc08942bdb5b2079d6905f58b720b091d8b7c461b6413c8b1cc42e7c080d7d560a8d2d369b0a9536b033fd9ba609304d
7
+ data.tar.gz: 49a82e6317772a3e32322d65bc438bdcc550b0d6f61cfdf2cc06fcfa0b258135be7247fbe6f31665a6d7b6a33b5608a54a64bcc7d7f49a479a489adf75348ad6
data/Gemfile.lock CHANGED
@@ -1,9 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- stackup (0.0.9)
4
+ stackup (0.1.0)
5
5
  aws-sdk (~> 2.0)
6
6
  clamp (~> 1.0)
7
+ console_logger
8
+ multi_json
7
9
 
8
10
  GEM
9
11
  remote: https://rubygems.org/
@@ -11,17 +13,17 @@ GEM
11
13
  ast (2.0.0)
12
14
  astrolabe (1.3.0)
13
15
  parser (>= 2.2.0.pre.3, < 3.0)
14
- aws-sdk (2.1.21)
15
- aws-sdk-resources (= 2.1.21)
16
- aws-sdk-core (2.1.21)
16
+ aws-sdk (2.1.26)
17
+ aws-sdk-resources (= 2.1.26)
18
+ aws-sdk-core (2.1.26)
17
19
  jmespath (~> 1.0)
18
- aws-sdk-resources (2.1.21)
19
- aws-sdk-core (= 2.1.21)
20
+ aws-sdk-resources (2.1.26)
21
+ aws-sdk-core (= 2.1.26)
20
22
  byebug (6.0.2)
21
23
  clamp (1.0.0)
24
+ console_logger (1.0.0)
22
25
  diff-lcs (1.2.5)
23
- jmespath (1.0.2)
24
- multi_json (~> 1.0)
26
+ jmespath (1.1.3)
25
27
  multi_json (1.11.2)
26
28
  parser (2.2.2.5)
27
29
  ast (>= 1.1, < 3.0)
data/bin/stackup CHANGED
@@ -2,6 +2,95 @@
2
2
 
3
3
  $LOAD_PATH << File.expand_path("../../lib", __FILE__)
4
4
 
5
- require "stackup/cli"
5
+ require "clamp"
6
+ require "console_logger"
7
+ require "stackup/stack"
8
+ require "multi_json"
9
+ require "yaml"
6
10
 
7
- Stackup::CLI.run
11
+ Clamp do
12
+
13
+ option "--debug", :flag, "enable debugging"
14
+
15
+ option ["-f", "--format"], "FORMAT", "output format", :default => "yaml"
16
+
17
+ protected
18
+
19
+ def logger
20
+ @logger ||= ConsoleLogger.new($stdout, debug?)
21
+ end
22
+
23
+ def format_data(data)
24
+ case format.downcase
25
+ when "json"
26
+ MultiJson.dump(data, :pretty => true)
27
+ when "yaml"
28
+ YAML.dump(data)
29
+ end
30
+ end
31
+
32
+ def display_data(data)
33
+ puts format_data(data)
34
+ end
35
+
36
+ subcommand "stack", "Manage a stack." do
37
+
38
+ parameter "NAME", "Name of stack", :attribute_name => :stack_name
39
+
40
+ def run(*args)
41
+ super(*args)
42
+ rescue Stackup::NoSuchStack => e
43
+ signal_error "stack '#{stack_name}' does not exist"
44
+ rescue Stackup::StackUpdateError => e
45
+ signal_error e.message
46
+ end
47
+
48
+ private
49
+
50
+ def stack
51
+ Stackup::Stack.new(stack_name, :logger => logger, :log_level => :debug)
52
+ end
53
+
54
+ def report_change
55
+ change = yield
56
+ puts "Stack #{change}" unless change.nil?
57
+ end
58
+
59
+ subcommand "status", "Print stack status." do
60
+
61
+ def execute
62
+ puts stack.status
63
+ end
64
+
65
+ end
66
+
67
+ subcommand "up", "Create/update the stack" do
68
+
69
+ parameter "TEMPLATE", "CloudFormation template (.json)", :attribute_name => :template_file
70
+
71
+ def execute
72
+ template = File.read(template_file)
73
+ report_change { stack.create_or_update(template) }
74
+ end
75
+
76
+ end
77
+
78
+ subcommand ["down", "delete"], "Remove the stack." do
79
+
80
+ def execute
81
+ report_change { stack.delete }
82
+ end
83
+
84
+ end
85
+
86
+ subcommand "outputs", "Stack outputs." do
87
+
88
+ def execute
89
+ display_data(stack.outputs)
90
+ end
91
+
92
+ end
93
+
94
+ end
95
+
96
+ end
@@ -0,0 +1,15 @@
1
+ module Stackup
2
+
3
+ # Base Stackup Exception class
4
+ class ServiceError < StandardError
5
+ end
6
+
7
+ # Raised when the specified stack does not exist
8
+ class NoSuchStack < ServiceError
9
+ end
10
+
11
+ # Raised to indicate a problem updating a stack
12
+ class StackUpdateError < ServiceError
13
+ end
14
+
15
+ end
data/lib/stackup/stack.rb CHANGED
@@ -1,126 +1,192 @@
1
1
  require "aws-sdk-resources"
2
- require "stackup/monitor"
2
+ require "logger"
3
+ require "stackup/errors"
4
+ require "stackup/stack_watcher"
3
5
 
4
6
  module Stackup
5
- class Stack
6
7
 
7
- SUCESS_STATES = ["CREATE_COMPLETE", "DELETE_COMPLETE", "UPDATE_COMPLETE"]
8
- FAILURE_STATES = ["CREATE_FAILED", "DELETE_FAILED", "ROLLBACK_COMPLETE", "ROLLBACK_FAILED", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_FAILED"]
9
- END_STATES = SUCESS_STATES + FAILURE_STATES
8
+ # An abstraction of a CloudFormation stack.
9
+ #
10
+ class Stack
10
11
 
11
- def initialize(name, client_options = {})
12
- @cf = Aws::CloudFormation::Client.new
13
- @stack = Aws::CloudFormation::Stack.new(:name => name, :client => cf)
14
- @monitor = Stackup::Monitor.new(@stack)
15
- @monitor.new_events # drain previous events
12
+ def initialize(name, client = {}, options = {})
13
+ client = Aws::CloudFormation::Client.new(client) if client.is_a?(Hash)
16
14
  @name = name
15
+ @cf_client = client
16
+ @watcher = Stackup::StackWatcher.new(cf_stack)
17
+ options.each do |key, value|
18
+ public_send("#{key}=", value)
19
+ end
17
20
  end
18
21
 
19
- attr_reader :stack, :name, :cf, :monitor
22
+ attr_reader :name, :cf_client, :watcher
20
23
 
21
- def status
22
- stack.stack_status
23
- rescue Aws::CloudFormation::Errors::ValidationError
24
- nil
24
+ # Register a handler for reporting of stack events
25
+ # @param [Proc] event_handler
26
+ #
27
+ def on_event(event_handler = nil, &block)
28
+ event_handler ||= block
29
+ fail ArgumentError, "no event_handler provided" if event_handler.nil?
30
+ @event_handler = event_handler
25
31
  end
26
32
 
27
- def exists?
28
- !!status
33
+ # @return [String] the current stack status
34
+ # @raise [Stackup::NoSuchStack] if the stack doesn't exist
35
+ #
36
+ def status
37
+ cf_stack.stack_status
38
+ rescue Aws::CloudFormation::Errors::ValidationError => e
39
+ handle_validation_error(e)
29
40
  end
30
41
 
31
- def create(template, parameters)
32
- cf.create_stack(:stack_name => name,
33
- :template_body => template,
34
- :disable_rollback => true,
35
- :capabilities => ["CAPABILITY_IAM"],
36
- :parameters => parameters)
37
- status = wait_for_events
38
-
39
- fail CreateError, "stack creation failed" unless status == "CREATE_COMPLETE"
42
+ # @return [boolean] true iff the stack exists
43
+ #
44
+ def exists?
45
+ status
40
46
  true
41
-
42
- rescue ::Aws::CloudFormation::Errors::ValidationError
43
- return false
47
+ rescue NoSuchStack
48
+ false
44
49
  end
45
50
 
46
- class CreateError < StandardError
51
+ # Create or update the stack.
52
+ #
53
+ # @param [String] template template JSON
54
+ # @param [Array<Hash>] parameters template parameters
55
+ # @return [Symbol] `:created` or `:updated` if successful
56
+ # @raise [Stackup::StackUpdateError] if operation fails
57
+ #
58
+ def create_or_update(template, parameters = [])
59
+ delete if ALMOST_DEAD_STATUSES.include?(status)
60
+ update(template, parameters)
61
+ rescue NoSuchStack
62
+ create(template, parameters)
47
63
  end
48
64
 
49
- def update(template, parameters)
50
- return false unless exists?
51
- if stack.stack_status == "CREATE_FAILED"
52
- puts "Stack is in CREATE_FAILED state so must be manually deleted before it can be updated"
53
- return false
54
- end
55
- if stack.stack_status == "ROLLBACK_COMPLETE"
56
- deleted = delete
57
- return false if !deleted
58
- end
59
- cf.update_stack(:stack_name => name, :template_body => template, :parameters => parameters, :capabilities => ["CAPABILITY_IAM"])
65
+ alias_method :up, :create_or_update
60
66
 
61
- status = wait_for_events
62
- fail UpdateError, "stack update failed" unless status == "UPDATE_COMPLETE"
63
- true
67
+ ALMOST_DEAD_STATUSES = %w(CREATE_FAILED ROLLBACK_COMPLETE)
64
68
 
65
- rescue ::Aws::CloudFormation::Errors::ValidationError => e
66
- if e.message == "No updates are to be performed."
67
- puts e.message
68
- return false
69
+ # Delete the stack.
70
+ #
71
+ # @param [String] template template JSON
72
+ # @param [Array<Hash>] parameters template parameters
73
+ # @return [Symbol] `:deleted` if successful
74
+ # @raise [Stackup::StackUpdateError] if operation fails
75
+ #
76
+ def delete
77
+ return nil unless exists?
78
+ status = modify_stack do
79
+ cf_client.delete_stack(:stack_name => name)
69
80
  end
70
- raise e
81
+ fail StackUpdateError, "stack delete failed" unless status.nil?
82
+ rescue NoSuchStack
83
+ :deleted
71
84
  end
72
85
 
73
- class UpdateError < StandardError
86
+ alias_method :down, :delete
87
+
88
+ # Get stack outputs.
89
+ #
90
+ # @return [Hash<String, String>]
91
+ # mapping of logical resource-name to physical resource-name
92
+ # @raise [Stackup::NoSuchStack] if the stack doesn't exist
93
+ #
94
+ def outputs
95
+ {}.tap do |h|
96
+ cf_stack.outputs.each do |output|
97
+ h[output.output_key] = output.output_value
98
+ end
99
+ end
100
+ rescue Aws::CloudFormation::Errors::ValidationError => e
101
+ handle_validation_error(e)
74
102
  end
75
103
 
76
- def delete
77
- return false unless exists?
78
- cf.delete_stack(:stack_name => name)
79
- status = wait_for_events
80
- fail UpdateError, "stack delete failed" unless status == "DELETE_COMPLETE"
81
- true
82
- rescue Aws::CloudFormation::Errors::ValidationError
83
- puts "Stack does not exist."
104
+ private
105
+
106
+ def create(template, parameters)
107
+ status = modify_stack do
108
+ cf_client.create_stack(
109
+ :stack_name => name,
110
+ :template_body => template,
111
+ :capabilities => ["CAPABILITY_IAM"],
112
+ :parameters => parameters
113
+ )
114
+ end
115
+ fail StackUpdateError, "stack creation failed" unless status == "CREATE_COMPLETE"
116
+ :created
84
117
  end
85
118
 
86
- def deploy(template, parameters = [])
87
- if exists?
88
- update(template, parameters)
89
- else
90
- create(template, parameters)
119
+ def update(template, parameters)
120
+ status = modify_stack do
121
+ cf_client.update_stack(:stack_name => name, :template_body => template, :parameters => parameters, :capabilities => ["CAPABILITY_IAM"])
91
122
  end
92
- rescue Aws::CloudFormation::Errors::ValidationError => e
93
- puts e.message
123
+ fail StackUpdateError, "stack update failed" unless status == "UPDATE_COMPLETE"
124
+ :updated
125
+ rescue NoUpdateRequired
126
+ nil
94
127
  end
95
128
 
96
- def outputs
97
- puts stack.outputs.flat_map { |output| "#{output.output_key} - #{output.output_value}" }
129
+ def logger
130
+ @logger ||= (cf_client.config[:logger] || Logger.new($stdout))
98
131
  end
99
132
 
100
- def valid?(template)
101
- response = cf.validate_template(template)
102
- response[:code].nil?
133
+ def cf_stack
134
+ Aws::CloudFormation::Stack.new(:name => name, :client => cf_client)
103
135
  end
104
136
 
105
- private
137
+ def event_handler
138
+ @event_handler ||= lambda do |e|
139
+ fields = [e.logical_resource_id, e.resource_status, e.resource_status_reason]
140
+ logger.info(fields.compact.join(" - "))
141
+ end
142
+ end
143
+
144
+ # Execute a block, reporting stack events, until the stack is stable.
145
+ #
146
+ # @return the final stack status
147
+ #
148
+ def modify_stack
149
+ watcher.zero
150
+ yield
151
+ wait_until_stable
152
+ rescue Aws::CloudFormation::Errors::ValidationError => e
153
+ handle_validation_error(e)
154
+ end
106
155
 
107
156
  # Wait (displaying stack events) until the stack reaches a stable state.
108
157
  #
109
- def wait_for_events
158
+ # @return the final stack status
159
+ #
160
+ def wait_until_stable
110
161
  loop do
111
- display_new_events
112
- stack.reload
162
+ report_new_events
163
+ cf_stack.reload
113
164
  return status if status.nil? || status =~ /_(COMPLETE|FAILED)$/
114
- sleep(2)
165
+ sleep(5)
115
166
  end
116
167
  end
117
168
 
118
- def display_new_events
119
- monitor.new_events.each do |e|
120
- ts = e.timestamp.localtime.strftime("%H:%M:%S")
121
- fields = [e.logical_resource_id, e.resource_status, e.resource_status_reason]
122
- puts("[#{ts}] #{fields.compact.join(' - ')}")
169
+ def report_new_events
170
+ watcher.new_events.each do |e|
171
+ event_handler.call(e)
172
+ end
173
+ end
174
+
175
+ def handle_validation_error(e)
176
+ case e.message
177
+ when "No updates are to be performed."
178
+ fail NoUpdateRequired, "no updates are required"
179
+ when / does not exist$/
180
+ fail NoSuchStack, "no such stack: #{name}"
181
+ else
182
+ raise e
123
183
  end
124
184
  end
185
+
186
+ # Raised when a stack is already up-to-date
187
+ class NoUpdateRequired < StandardError
188
+ end
189
+
125
190
  end
191
+
126
192
  end
@@ -0,0 +1,41 @@
1
+ require "aws-sdk-core"
2
+
3
+ module Stackup
4
+
5
+ class StackWatcher
6
+
7
+ def initialize(stack)
8
+ @stack = stack
9
+ @processed_event_ids = Set.new
10
+ end
11
+
12
+ attr_accessor :stack
13
+
14
+ # Yield all events since the last call
15
+ #
16
+ def new_events
17
+ [].tap do |events|
18
+ stack.events.each do |event|
19
+ break if @processed_event_ids.include?(event.event_id)
20
+ events.unshift(event)
21
+ @processed_event_ids.add(event.event_id)
22
+ end
23
+ end
24
+ rescue ::Aws::CloudFormation::Errors::ValidationError
25
+ []
26
+ end
27
+
28
+ # Consume all new events
29
+ #
30
+ def zero
31
+ new_events
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ attr_accessor :processed_event_ids
38
+
39
+ end
40
+
41
+ end
Binary file
@@ -2,234 +2,298 @@ require "spec_helper"
2
2
 
3
3
  describe Stackup::Stack do
4
4
 
5
- let(:stack) { described_class.new("stack_name") }
6
-
7
- let(:cf_stack) { instance_double("Aws::CloudFormation::Stack",
8
- :stack_status => stack_status) }
9
- let(:cf_client) { instance_double("Aws::CloudFormation::Client") }
5
+ let(:cf_client) do
6
+ client_options = { :stub_responses => true }
7
+ if ENV.key?("AWS_DEBUG")
8
+ client_options[:logger] = Logger.new(STDOUT)
9
+ client_options[:log_level] = :debug
10
+ end
11
+ Aws::CloudFormation::Client.new(client_options)
12
+ end
10
13
 
11
- let(:template) { double(String) }
12
- let(:parameters) { [] }
13
- let(:stack_status) { nil }
14
+ let(:stack_name) { "stack_name" }
15
+ let(:unique_stack_id) { "ID:#{stack_name}" }
14
16
 
15
- let(:response) { Seahorse::Client::Http::Response.new }
17
+ subject(:stack) { described_class.new(stack_name, cf_client) }
16
18
 
17
19
  before do
18
- allow(Aws::CloudFormation::Client).to receive(:new).and_return(cf_client)
19
- allow(Aws::CloudFormation::Stack).to receive(:new).and_return(cf_stack)
20
- allow(cf_stack).to receive(:events).and_return([])
20
+ cf_client.stub_responses(:describe_stacks, *describe_stacks_responses)
21
+ allow(stack).to receive(:sleep).at_most(5).times
22
+ # partial stubbing, to support spying
23
+ allow(cf_client).to receive(:create_stack).and_call_original
24
+ allow(cf_client).to receive(:delete_stack).and_call_original
25
+ allow(cf_client).to receive(:update_stack).and_call_original
21
26
  end
22
27
 
23
- describe "#create" do
28
+ def service_error(code, message)
29
+ {
30
+ :status_code => 400,
31
+ :headers => {},
32
+ :body => "<ErrorResponse><Error><Code>#{code}</Code><Message>#{message}</Message></Error></ErrorResponse>"
33
+ }
34
+ end
24
35
 
25
- subject(:created) { stack.create(template, parameters) }
36
+ def stack_does_not_exist
37
+ service_error("ValidationError", "Stack with id #{stack_name} does not exist")
38
+ end
26
39
 
27
- before do
28
- allow(cf_client).to receive(:create_stack).and_return(response)
40
+ def no_update_required
41
+ service_error("ValidationError", "No updates are to be performed.")
42
+ end
43
+
44
+ def stack_description(stack_status)
45
+ {
46
+ :stacks => [
47
+ {
48
+ :creation_time => Time.now - 100,
49
+ :stack_id => unique_stack_id,
50
+ :stack_name => stack_name,
51
+ :stack_status => stack_status
52
+ }
53
+ ]
54
+ }
55
+ end
56
+
57
+ context "before stack exists" do
58
+
59
+ let(:describe_stacks_responses) do
60
+ [
61
+ stack_does_not_exist
62
+ ]
29
63
  end
30
64
 
31
- context "when stack gets successfully created" do
32
- before do
33
- allow(stack).to receive(:wait_for_events).and_return("CREATE_COMPLETE")
65
+ describe "#exists?" do
66
+ it "is false" do
67
+ expect(stack.exists?).to be false
34
68
  end
35
- it { expect(created).to be true }
36
69
  end
37
70
 
38
- context "when stack creation fails" do
39
- before do
40
- allow(stack).to receive(:wait_for_events).and_return("CREATE_FAILED")
71
+ describe "#status" do
72
+ it "raises a NoSuchStack error" do
73
+ expect { stack.status }.to raise_error(Stackup::NoSuchStack)
41
74
  end
42
- it { expect{ created }.to raise_error Stackup::Stack::CreateError }
43
75
  end
44
76
 
45
- end
46
-
47
- describe "#update" do
48
- subject(:updated) { stack.update(template, parameters) }
49
-
50
- context "when there is no existing stack" do
51
- before do
52
- allow(stack).to receive(:exists?).and_return(false)
77
+ describe "#delete" do
78
+ it "returns nil" do
79
+ expect(stack.delete).to be_nil
53
80
  end
54
- it { expect(updated).to be false }
55
81
  end
56
82
 
57
- context "when there is an existing stack" do
58
- before do
59
- allow(stack).to receive(:exists?).and_return(true)
60
- allow(cf_client).to receive(:update_stack).and_return(response)
61
- allow(stack).to receive(:wait_for_events).and_return("UPDATE_COMPLETE")
83
+ describe "#create_or_update" do
84
+
85
+ let(:template) { "stack template" }
86
+
87
+ def create_or_update
88
+ stack.create_or_update(template)
62
89
  end
63
90
 
64
- context "in a successfully deployed state" do
65
- before do
66
- allow(cf_stack).to receive(:stack_status).and_return("CREATE_COMPLETE")
91
+ context "successful" do
92
+
93
+ let(:describe_stacks_responses) do
94
+ super() + [
95
+ stack_description("CREATE_IN_PROGRESS"),
96
+ stack_description("CREATE_COMPLETE")
97
+ ]
67
98
  end
68
99
 
69
- context "when stack gets successfully updated" do
70
- it { expect(updated).to be true }
100
+ it "calls :create_stack" do
101
+ expected_args = {
102
+ :stack_name => stack_name,
103
+ :template_body => template
104
+ }
105
+ create_or_update
106
+ expect(cf_client).to have_received(:create_stack)
107
+ .with(hash_including(expected_args))
71
108
  end
72
109
 
73
- context "when stack update fails" do
74
- before do
75
- allow(stack).to receive(:wait_for_events).and_return("UPDATE_FAILED")
76
- end
77
- it { expect{ updated }.to raise_error Stackup::Stack::UpdateError }
110
+ it "returns :created" do
111
+ expect(create_or_update).to eq(:created)
78
112
  end
113
+
79
114
  end
80
115
 
81
- context "in a ROLLBACK_COMPLETE state" do
82
- before do
83
- allow(cf_stack).to receive(:stack_status).and_return("ROLLBACK_COMPLETE")
116
+ context "unsuccessful" do
117
+
118
+ let(:describe_stacks_responses) do
119
+ super() + [
120
+ stack_description("CREATE_IN_PROGRESS"),
121
+ stack_description("CREATE_FAILED")
122
+ ]
123
+ end
124
+
125
+ it "raises a StackUpdateError" do
126
+ expect { create_or_update }
127
+ .to raise_error(Stackup::StackUpdateError)
84
128
  end
85
129
 
86
- context "when deleting existing stack succeeds" do
130
+ end
87
131
 
88
- it "deletes the existing stack" do
89
- allow(response).to receive(:[]).with(:stack_id).and_return("1")
90
- expect(stack).to receive(:delete).and_return(true)
91
- stack.update(template, parameters)
92
- end
132
+ end
93
133
 
94
- context "when stack gets successfully updated" do
95
- before do
96
- allow(response).to receive(:[]).with(:stack_id).and_return("1")
97
- allow(stack).to receive(:delete).and_return(true)
98
- end
99
- it { expect(updated).to be true }
100
- end
134
+ end
101
135
 
102
- context "when stack update fails" do
103
- before do
104
- allow(response).to receive(:[]).with(:stack_id).and_return("1")
105
- allow(stack).to receive(:delete).and_return(false)
106
- end
107
- it { expect(updated).to be false }
108
- end
109
- end
136
+ context "with existing stack" do
110
137
 
111
- context "when deleting existing stack fails" do
112
- before do
113
- allow(stack).to receive(:delete).and_return(false)
114
- end
115
- it { expect(updated).to be false }
116
- end
138
+ let(:stack_status) { "CREATE_COMPLETE" }
139
+
140
+ let(:describe_stacks_responses) do
141
+ [
142
+ stack_description(stack_status)
143
+ ]
144
+ end
145
+
146
+ describe "#exists?" do
147
+ it "is true" do
148
+ expect(stack.exists?).to be true
149
+ end
150
+ end
151
+
152
+ describe "#status" do
153
+ it "returns the stack status" do
154
+ expect(stack.status).to eq(stack_status)
117
155
  end
156
+ end
157
+
158
+ describe "#delete" do
118
159
 
119
- context "in a CREATE_FAILED state" do
120
- before do
121
- allow(cf_stack).to receive(:stack_status).and_return("CREATE_FAILED")
160
+ context "if successful" do
161
+
162
+ let(:describe_stacks_responses) do
163
+ super() + [
164
+ stack_description("DELETE_IN_PROGRESS"),
165
+ stack_does_not_exist
166
+ ]
122
167
  end
123
168
 
124
- it "does not try to delete the existing stack" do
125
- allow(response).to receive(:[]).with(:stack_id).and_return("1")
126
- expect(stack).not_to receive(:delete)
127
- stack.update(template, parameters)
169
+ it "calls delete_stack" do
170
+ stack.delete
171
+ expect(cf_client).to have_received(:delete_stack)
172
+ .with(hash_including(:stack_name => stack_name))
128
173
  end
129
174
 
130
- it "does not try to delete the existing stack" do
131
- allow(response).to receive(:[]).with(:stack_id).and_return("1")
132
- expect(cf_client).not_to receive(:delete_stack)
133
- expect(updated).to be false
175
+ it "returns :deleted" do
176
+ expect(stack.delete).to eq(:deleted)
134
177
  end
178
+
135
179
  end
136
- end
137
- end
138
180
 
139
- describe "#deploy" do
181
+ context "if unsuccessful" do
140
182
 
141
- subject(:deploy) { stack.deploy(template, parameters) }
183
+ let(:describe_stacks_responses) do
184
+ super() + [
185
+ stack_description("DELETE_IN_PROGRESS"),
186
+ stack_description("DELETE_FAILED")
187
+ ]
188
+ end
142
189
 
143
- context "when stack already exists" do
190
+ it "raises a StackUpdateError" do
191
+ expect { stack.delete }.to raise_error(Stackup::StackUpdateError)
192
+ end
144
193
 
145
- before do
146
- allow(stack).to receive(:exists?).and_return(true)
147
- allow(cf_stack).to receive(:stack_status).and_return("CREATE_COMPLETE")
148
- allow(cf_client).to receive(:update_stack).and_return({ stack_id: "stack-name" })
149
194
  end
150
195
 
151
- it "updates the stack" do
152
- expect(stack).to receive(:update)
153
- deploy
154
- end
155
196
  end
156
197
 
157
- context "when stack does not exist" do
198
+ describe "#create_or_update" do
158
199
 
159
- before do
160
- allow(stack).to receive(:exists?).and_return(false)
161
- allow(cf_client).to receive(:create_stack).and_return({ stack_id: "stack-name" })
162
- end
200
+ let(:template) { "stack template" }
163
201
 
164
- it "creates a new stack" do
165
- expect(stack).to receive(:create)
166
- deploy
202
+ def create_or_update
203
+ stack.create_or_update(template)
167
204
  end
168
- end
169
- end
170
205
 
206
+ context "successful" do
171
207
 
172
- describe "#delete" do
208
+ let(:describe_stacks_responses) do
209
+ super() + [
210
+ stack_description("UPDATE_IN_PROGRESS"),
211
+ stack_description("UPDATE_COMPLETE")
212
+ ]
213
+ end
173
214
 
174
- subject(:deleted) { stack.delete }
215
+ it "calls :update_stack" do
216
+ expected_args = {
217
+ :stack_name => stack_name,
218
+ :template_body => template
219
+ }
220
+ create_or_update
221
+ expect(cf_client).to have_received(:update_stack)
222
+ .with(hash_including(expected_args))
223
+ end
175
224
 
176
- context "there is no existing stack" do
177
- before do
178
- allow(stack).to receive(:exists?).and_return false
179
- end
225
+ it "returns :updated" do
226
+ expect(create_or_update).to eq(:updated)
227
+ end
180
228
 
181
- it { expect(deleted).to be false }
229
+ context "if no updates are required" do
182
230
 
183
- it "does not try to delete the stack" do
184
- expect(cf_client).not_to receive(:delete_stack)
185
- end
186
- end
231
+ before do
232
+ cf_client.stub_responses(:update_stack, no_update_required)
233
+ end
187
234
 
188
- context "there is an existing stack" do
189
- before do
190
- allow(stack).to receive(:exists?).and_return true
191
- allow(cf_client).to receive(:delete_stack)
192
- end
235
+ it "returns nil" do
236
+ expect(create_or_update).to be_nil
237
+ end
193
238
 
194
- context "deleting the stack succeeds" do
195
- before do
196
- allow(stack).to receive(:wait_for_events).and_return("DELETE_COMPLETE")
197
239
  end
198
- it { expect(deleted).to be true }
240
+
199
241
  end
200
242
 
201
- context "deleting the stack fails" do
202
- before do
203
- allow(stack).to receive(:wait_for_events).and_return("DELETE_FAILED")
243
+ context "unsuccessful" do
244
+
245
+ let(:describe_stacks_responses) do
246
+ super() + [
247
+ stack_description("UPDATE_IN_PROGRESS"),
248
+ stack_description("UPDATE_ROLLBACK_COMPLETE")
249
+ ]
250
+ end
251
+
252
+ it "raises a StackUpdateError" do
253
+ expect { create_or_update }.to raise_error(Stackup::StackUpdateError)
204
254
  end
205
- it { expect{ deleted }.to raise_error(Stackup::Stack::UpdateError) }
255
+
206
256
  end
257
+
207
258
  end
208
- end
209
259
 
260
+ %w(CREATE_FAILED ROLLBACK_COMPLETE).each do |initial_status|
261
+ context "when status is #{initial_status}" do
210
262
 
211
- context "validate" do
212
- it "should be valid if cf validate say so" do
213
- allow(cf_client).to receive(:validate_template).and_return({})
214
- expect(stack.valid?(template)).to be true
215
- end
263
+ let(:stack_status) { initial_status }
216
264
 
217
- it "should be invalid if cf validate say so" do
218
- allow(cf_client).to receive(:validate_template).and_return(:code => "404")
219
- expect(stack.valid?(template)).to be false
220
- end
265
+ describe "#create_or_update" do
221
266
 
222
- end
267
+ let(:template) { "stack template" }
223
268
 
224
- context "deployed" do
225
- it "should be true if it is already deployed" do
226
- allow(cf_stack).to receive(:stack_status).and_return("CREATE_COMPLETE")
227
- expect(stack.exists?).to be true
228
- end
269
+ def create_or_update
270
+ stack.create_or_update(template)
271
+ end
272
+
273
+ let(:describe_stacks_responses) do
274
+ super() + [
275
+ stack_description("DELETE_IN_PROGRESS"),
276
+ stack_does_not_exist,
277
+ stack_description("CREATE_IN_PROGRESS"),
278
+ stack_description("CREATE_COMPLETE")
279
+ ]
280
+ end
229
281
 
230
- it "should be false if it is not deployed" do
231
- allow(cf_stack).to receive(:stack_status).and_raise(Aws::CloudFormation::Errors::ValidationError.new("1", "2"))
232
- expect(stack.exists?).to be false
282
+ before do
283
+ cf_client.stub_responses(:update_stack, stack_does_not_exist)
284
+ end
285
+
286
+ it "calls :delete_stack, then :create_stack first" do
287
+ create_or_update
288
+ expect(cf_client).to have_received(:delete_stack)
289
+ expect(cf_client).to have_received(:create_stack)
290
+ end
291
+
292
+ end
293
+
294
+ end
233
295
  end
296
+
234
297
  end
298
+
235
299
  end
@@ -0,0 +1,91 @@
1
+ require "spec_helper"
2
+
3
+ require "stackup/stack_watcher"
4
+
5
+ describe Stackup::StackWatcher do
6
+
7
+ let(:stack) { instance_double(Aws::CloudFormation::Stack, :events => events) }
8
+ let(:events) { [] }
9
+
10
+ subject(:monitor) { described_class.new(stack) }
11
+
12
+ def add_event(description)
13
+ @event_id ||= 0
14
+ event = instance_double(
15
+ Aws::CloudFormation::Event,
16
+ :event_id => @event_id, :resource_status_reason => description
17
+ )
18
+ events.unshift(event)
19
+ @event_id += 1
20
+ end
21
+
22
+ context "with a empty set of events" do
23
+
24
+ describe "#new_events" do
25
+
26
+ it "is empty" do
27
+ expect(subject.new_events).to be_empty
28
+ end
29
+
30
+ end
31
+
32
+ end
33
+
34
+ context "when the stack does not exist" do
35
+
36
+ before do
37
+ allow(stack).to receive(:events) do
38
+ fail Aws::CloudFormation::Errors::ValidationError.new("test", "no such stack")
39
+ end
40
+ end
41
+
42
+ describe "#new_events" do
43
+
44
+ it "is empty" do
45
+ expect(subject.new_events).to be_empty
46
+ end
47
+
48
+ end
49
+
50
+ end
51
+
52
+ def new_event_reasons
53
+ subject.new_events.map(&:resource_status_reason)
54
+ end
55
+
56
+ context "when the stack has existing events" do
57
+
58
+ before do
59
+ add_event("earlier")
60
+ add_event("later")
61
+ end
62
+
63
+ describe "#new_events" do
64
+
65
+ it "returns the events in the order they occurred" do
66
+ expect(new_event_reasons).to eq(["earlier", "later"])
67
+ end
68
+
69
+ end
70
+
71
+ context "and more events occur" do
72
+
73
+ before do
74
+ subject.new_events
75
+ add_event("even")
76
+ add_event("more")
77
+ end
78
+
79
+ describe "#new_events" do
80
+
81
+ it "returns only the new events" do
82
+ expect(new_event_reasons).to eq(["even", "more"])
83
+ end
84
+
85
+ end
86
+
87
+ end
88
+
89
+ end
90
+
91
+ end
data/stackup.gemspec CHANGED
@@ -4,10 +4,10 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  Gem::Specification.new do |spec|
5
5
 
6
6
  spec.name = "stackup"
7
- spec.version = "0.0.9"
7
+ spec.version = "0.1.0"
8
8
  spec.authors = ["Arvind Kunday", "Mike Williams"]
9
9
  spec.email = ["arvind.kunday@rea-group.com", "mike.williams@rea-group.com"]
10
- spec.summary = "Tools for deployment to AWS"
10
+ spec.summary = "Manage CloudFormation stacks"
11
11
  spec.homepage = "https://github.com/realestate-com-au/stackup"
12
12
  spec.license = "MIT"
13
13
 
@@ -17,5 +17,7 @@ Gem::Specification.new do |spec|
17
17
 
18
18
  spec.add_dependency "aws-sdk", "~> 2.0"
19
19
  spec.add_dependency "clamp", "~> 1.0"
20
+ spec.add_dependency "console_logger"
21
+ spec.add_dependency "multi_json"
20
22
 
21
23
  end
@@ -34,6 +34,46 @@
34
34
  }
35
35
  ]
36
36
  }
37
+ },
38
+ "role2": {
39
+ "Type": "AWS::IAM::Role",
40
+ "Properties": {
41
+ "AssumeRolePolicyDocument": {
42
+ "Version" : "2012-10-17",
43
+ "Statement": [
44
+ {
45
+ "Effect": "Allow",
46
+ "Principal": {
47
+ "Service": [ "ec2.amazonaws.com" ]
48
+ },
49
+ "Action": [ "sts:AssumeRole" ]
50
+ }
51
+ ]
52
+ },
53
+ "Path": "/blah/",
54
+ "Policies": [
55
+ {
56
+ "PolicyName": "rooty",
57
+ "PolicyDocument": {
58
+ "Version" : "2012-10-17",
59
+ "Statement": [
60
+ {
61
+ "Effect": "Allow",
62
+ "Action": "*",
63
+ "Resource": "*"
64
+ }
65
+ ]
66
+ }
67
+ }
68
+ ]
69
+ }
70
+ }
71
+ },
72
+ "Outputs": {
73
+ "role": {
74
+ "Value": {
75
+ "Ref": "role"
76
+ }
37
77
  }
38
78
  }
39
79
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stackup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arvind Kunday
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2015-10-06 00:00:00.000000000 Z
12
+ date: 2015-10-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: aws-sdk
@@ -39,6 +39,34 @@ dependencies:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '1.0'
42
+ - !ruby/object:Gem::Dependency
43
+ name: console_logger
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :runtime
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: multi_json
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
42
70
  description:
43
71
  email:
44
72
  - arvind.kunday@rea-group.com
@@ -54,14 +82,15 @@ files:
54
82
  - Rakefile
55
83
  - bin/stackup
56
84
  - lib/stackup.rb
57
- - lib/stackup/cli.rb
58
- - lib/stackup/monitor.rb
85
+ - lib/stackup/errors.rb
59
86
  - lib/stackup/stack.rb
87
+ - lib/stackup/stack_watcher.rb
60
88
  - pkg/stackup-0.0.1.gem
61
89
  - pkg/stackup-0.0.8.gem
90
+ - pkg/stackup-0.0.9.gem
62
91
  - spec/spec_helper.rb
63
- - spec/stackup/monitor_spec.rb
64
92
  - spec/stackup/stack_spec.rb
93
+ - spec/stackup/stack_watcher_spec.rb
65
94
  - stackup.gemspec
66
95
  - woollyams/sample-template.json
67
96
  homepage: https://github.com/realestate-com-au/stackup
@@ -87,5 +116,5 @@ rubyforge_project:
87
116
  rubygems_version: 2.4.8
88
117
  signing_key:
89
118
  specification_version: 4
90
- summary: Tools for deployment to AWS
119
+ summary: Manage CloudFormation stacks
91
120
  test_files: []
data/lib/stackup/cli.rb DELETED
@@ -1,54 +0,0 @@
1
- require "clamp"
2
- require "json"
3
- require "stackup/stack"
4
-
5
- module Stackup
6
-
7
- class CLI < Clamp::Command
8
-
9
- subcommand "stack", "Manage a stack." do
10
-
11
- parameter "NAME", "Name of stack", :attribute_name => :stack_name
12
-
13
- private
14
-
15
- def stack
16
- Stackup::Stack.new(stack_name)
17
- end
18
-
19
- subcommand "status", "Print stack status." do
20
-
21
- def execute
22
- puts stack.status
23
- end
24
-
25
- end
26
-
27
- subcommand "deploy", "Create/update the stack" do
28
-
29
- parameter "TEMPLATE", "CloudFormation template (.json)", :attribute_name => :template_file
30
-
31
- def execute
32
- template = File.read(template_file)
33
- stack.deploy(template)
34
- end
35
-
36
- end
37
-
38
- subcommand "delete", "Remove the stack." do
39
- def execute
40
- stack.delete
41
- end
42
- end
43
-
44
- subcommand "outputs", "Stack outputs." do
45
- def execute
46
- stack.outputs
47
- end
48
- end
49
-
50
- end
51
-
52
- end
53
-
54
- end
@@ -1,33 +0,0 @@
1
- require "aws-sdk-core"
2
-
3
- module Stackup
4
- class Monitor
5
-
6
- attr_accessor :stack, :events
7
- def initialize(stack)
8
- @stack = stack
9
- @events = Set.new
10
- end
11
-
12
- def new_events
13
- stack.events.take_while do |event|
14
- !seen?(event)
15
- end.reverse
16
- rescue ::Aws::CloudFormation::Errors::ValidationError => e
17
- []
18
- end
19
-
20
- private
21
-
22
- def seen?(event)
23
- event_id = event.id
24
- if events.include?(event_id)
25
- true
26
- else
27
- events.add(event_id)
28
- false
29
- end
30
- end
31
-
32
- end
33
- end
@@ -1,24 +0,0 @@
1
- require "spec_helper"
2
-
3
- describe Stackup::Monitor do
4
-
5
- let(:stack) { instance_double(Aws::CloudFormation::Stack, :events => events) }
6
- let(:monitor) { described_class.new(stack) }
7
-
8
- let(:event) { instance_double(Aws::CloudFormation::Event, :id => "1") }
9
- let(:events) { [event] }
10
-
11
- before do
12
- allow(event).to receive(:event_id).and_return("1")
13
- end
14
-
15
- it "should add the event if it is non-existent" do
16
- expect(monitor.new_events.size).to eq(1)
17
- end
18
-
19
- it "should skip the event if it has been shown" do
20
- expect(monitor.new_events.size).to eq(1)
21
- expect(monitor.new_events.size).to eq(0)
22
- end
23
-
24
- end