stackup 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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