cuffsert 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,129 @@
1
+ require 'cuffsert/cfarguments'
2
+ require 'cuffsert/cfstates'
3
+ require 'cuffsert/cli_args'
4
+ require 'cuffsert/confirmation'
5
+ require 'cuffsert/messages'
6
+ require 'cuffsert/metadata'
7
+ require 'cuffsert/presenters'
8
+ require 'cuffsert/rxcfclient'
9
+ require 'rx'
10
+ require 'uri'
11
+
12
+ # TODO:
13
+ # - Stop using file: that we anyway need to special-case in cfarguments
14
+ # - default value for meta.metadata when stack_path is local file
15
+ # - selector and metadata are mandatory and need guards accordingly
16
+ # - validate_and_urlify belongs in metadata.rb
17
+ # - execute should use helpers and not know details of statuses
18
+ # - update 'abort' should delete cheangeset and emit the result
19
+
20
+ module CuffSert
21
+ def self.validate_and_urlify(stack_path)
22
+ if stack_path =~ /^[A-Za-z0-9]+:/
23
+ stack_uri = URI.parse(stack_path)
24
+ else
25
+ normalized = File.expand_path(stack_path)
26
+ unless File.exist?(normalized)
27
+ raise "Local file #{normalized} does not exist"
28
+ end
29
+ stack_uri = URI.join('file:///', normalized)
30
+ end
31
+ unless ['s3', 'file'].include?(stack_uri.scheme)
32
+ raise "Uri #{stack_uri.scheme} is not supported"
33
+ end
34
+ stack_uri
35
+ end
36
+
37
+ def self.create_stack(client, meta, confirm_create)
38
+ cfargs = CuffSert.as_create_stack_args(meta)
39
+ Rx::Observable.concat(
40
+ Rx::Observable.of([:create, meta.stackname]),
41
+ Rx::Observable.defer do
42
+ if confirm_create.call(meta, :create, nil)
43
+ client.create_stack(cfargs)
44
+ else
45
+ Abort.new('User abort!').as_observable
46
+ end
47
+ end
48
+ )
49
+ end
50
+
51
+ def self.update_stack(client, meta, confirm_update)
52
+ cfargs = CuffSert.as_update_change_set(meta)
53
+ client.prepare_update(cfargs)
54
+ .last
55
+ .flat_map do |change_set|
56
+ Rx::Observable.concat(
57
+ Rx::Observable.of(change_set),
58
+ Rx::Observable.defer {
59
+ if change_set[:status] == 'FAILED'
60
+ client.abort_update(change_set[:change_set_id])
61
+ elsif confirm_update.call(meta, :update, change_set)
62
+ client.update_stack(change_set[:stack_id], change_set[:change_set_id])
63
+ else
64
+ Rx::Observable.concat(
65
+ client.abort_update(change_set[:change_set_id]),
66
+ Abort.new('User abort!').as_observable
67
+ )
68
+ end
69
+ }
70
+ )
71
+ end
72
+ end
73
+
74
+ def self.recreate_stack(client, stack, meta, confirm_recreate)
75
+ crt_args = CuffSert.as_create_stack_args(meta)
76
+ del_args = CuffSert.as_delete_stack_args(stack)
77
+ Rx::Observable.concat(
78
+ Rx::Observable.of([:recreate, stack]),
79
+ Rx::Observable.defer do
80
+ if confirm_recreate.call(meta, :recreate, stack)
81
+ Rx::Observable.concat(
82
+ client.delete_stack(del_args),
83
+ client.create_stack(crt_args)
84
+ )
85
+ else
86
+ CuffSert::Abort.new('User abort!').as_observable
87
+ end
88
+ end
89
+ )
90
+ end
91
+
92
+ def self.execute(meta, confirm_update, force_replace: false, client: RxCFClient.new)
93
+ sources = []
94
+ found = client.find_stack_blocking(meta)
95
+
96
+ if found && INPROGRESS_STATES.include?(found[:stack_status])
97
+ sources << Abort.new('Stack operation already in progress').as_observable
98
+ elsif found.nil?
99
+ sources << self.create_stack(client, meta, confirm_update)
100
+ elsif found[:stack_status] == 'ROLLBACK_COMPLETE' || force_replace
101
+ sources << self.recreate_stack(client, found, meta, confirm_update)
102
+ else
103
+ sources << self.update_stack(client, meta, confirm_update)
104
+ end
105
+ Rx::Observable.concat(*sources)
106
+ end
107
+
108
+ def self.make_renderer(cli_args)
109
+ if cli_args[:output] == :json
110
+ JsonRenderer.new(STDOUT, STDERR, cli_args)
111
+ else
112
+ ProgressbarRenderer.new(STDOUT, STDERR, cli_args)
113
+ end
114
+ end
115
+
116
+ def self.run(argv)
117
+ cli_args = CuffSert.parse_cli_args(argv)
118
+ meta = CuffSert.build_meta(cli_args)
119
+ if cli_args[:stack_path].nil? || cli_args[:stack_path].size != 1
120
+ raise 'Requires exactly one stack path'
121
+ end
122
+ stack_path = cli_args[:stack_path][0]
123
+ meta.stack_uri = CuffSert.validate_and_urlify(stack_path)
124
+ events = CuffSert.execute(meta, CuffSert.method(:confirmation),
125
+ force_replace: cli_args[:force_replace])
126
+ renderer = CuffSert.make_renderer(cli_args)
127
+ RendererPresenter.new(events, renderer)
128
+ end
129
+ end
@@ -0,0 +1,18 @@
1
+ module CuffSert
2
+ class Abort
3
+ attr_reader :message
4
+
5
+ def initialize(message)
6
+ @message = message
7
+ end
8
+
9
+ def ===(other)
10
+ # For the benefit of value_matches? and regex
11
+ other.is_a?(Abort) && (other.message === @message || @message === other.message)
12
+ end
13
+
14
+ def as_observable
15
+ Rx::Observable.just(self)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,90 @@
1
+ require 'yaml'
2
+
3
+ module CuffSert
4
+ class StackConfig
5
+ attr_accessor :stackname, :selected_path, :op_mode, :stack_uri
6
+ attr_accessor :suffix, :parameters, :tags
7
+
8
+ def initialize
9
+ @selected_path = []
10
+ @op_mode = :normal
11
+ @parameters = {}
12
+ @tags = {}
13
+ end
14
+
15
+ def append_path(lmnt)
16
+ @selected_path << lmnt
17
+ end
18
+
19
+ def update_from(metadata)
20
+ @stackname = metadata[:stackname] || @stackname
21
+ @suffix = metadata[:suffix] || @suffix
22
+ @parameters.merge!(metadata[:parameters] || {})
23
+ @tags.merge!(metadata[:tags] || {})
24
+ self
25
+ end
26
+
27
+ def stackname
28
+ @stackname || (@selected_path + [*@suffix]).join('-')
29
+ end
30
+ end
31
+
32
+ def self.load_config(io)
33
+ config = YAML.load(io)
34
+ raise 'config does not seem to be a YAML hash?' unless config.is_a?(Hash)
35
+ config = symbolize_keys(config)
36
+ format = config.delete(:format)
37
+ raise 'Please include Format: v1' if format.nil? || format.downcase != 'v1'
38
+ config
39
+ end
40
+
41
+ def self.meta_for_path(metadata, path, target = StackConfig.new)
42
+ target.update_from(metadata)
43
+ candidate, path = path
44
+ key = candidate || metadata[:defaultpath]
45
+ variants = metadata[:variants]
46
+ if key.nil?
47
+ raise "No DefaultPath found for #{variants.keys}" unless variants.nil?
48
+ return target
49
+ end
50
+ target.append_path(key)
51
+
52
+ raise "Missing variants section as expected by #{key}" if variants.nil?
53
+ new_meta = variants[key.to_sym]
54
+ raise "#{key.inspect} not found in variants" if new_meta.nil?
55
+ self.meta_for_path(new_meta, path, target)
56
+ end
57
+
58
+ def self.build_meta(cli_args)
59
+ io = open(cli_args[:metadata])
60
+ config = CuffSert.load_config(io)
61
+ default = self.meta_defaults(cli_args)
62
+ meta = CuffSert.meta_for_path(config, cli_args[:selector], default)
63
+ CuffSert.cli_overrides(meta, cli_args)
64
+ end
65
+
66
+ private_class_method
67
+
68
+ def self.meta_defaults(cli_args)
69
+ default = StackConfig.new
70
+ default.suffix = File.basename(cli_args[:metadata], '.yml')
71
+ default
72
+ end
73
+
74
+ def self.cli_overrides(meta, cli_args)
75
+ meta.update_from(cli_args[:overrides])
76
+ meta.op_mode = cli_args[:op_mode] || meta.op_mode
77
+ meta
78
+ end
79
+
80
+ def self.symbolize_keys(hash)
81
+ hash.each_with_object({}) do |(k, v), h|
82
+ k = k.downcase.to_sym
83
+ if k == :tags || k == :parameters
84
+ h[k] = v.each_with_object({}) { |e, h| h[e['Name']] = e['Value'] }
85
+ else
86
+ h[k] = v.is_a?(Hash) ? symbolize_keys(v) : v
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,313 @@
1
+ require 'aws-sdk'
2
+ require 'colorize'
3
+ require 'cuffsert/cfstates'
4
+ require 'cuffsert/messages'
5
+ require 'rx'
6
+
7
+ # TODO: Animate in-progress states
8
+ # - Introduce a Done message and stop printing in on_complete
9
+ # - Present the error message in change_set properly - and abort
10
+ # - badness goes to stderr
11
+ # - change sets should present modification details indented under each entry
12
+ # - property direct modification
13
+ # - properties through parameter change
14
+ # - indirect change through other resource ("causing_entity": "Lb.DNSName")
15
+
16
+ module CuffSert
17
+ class BasePresenter
18
+ def initialize(events)
19
+ events.subscribe(
20
+ method(:on_event),
21
+ method(:on_error),
22
+ method(:on_complete)
23
+ )
24
+ end
25
+
26
+ def on_error(err)
27
+ STDERR.puts'Error:'
28
+ STDERR.puts err
29
+ STDERR.puts err.backtrace.join("\n\t")
30
+ end
31
+
32
+ def on_complete
33
+ end
34
+
35
+ def update_width(width)
36
+ end
37
+ end
38
+
39
+ class RawPresenter < BasePresenter
40
+ def on_event(event)
41
+ puts event.inspect
42
+ end
43
+
44
+ def on_complete
45
+ puts 'Done.'
46
+ end
47
+ end
48
+
49
+ class RendererPresenter < BasePresenter
50
+ def initialize(events, renderer)
51
+ @resources = []
52
+ @index = {}
53
+ @renderer = renderer
54
+ super(events)
55
+ end
56
+
57
+ def on_event(event)
58
+ # Workaround for now
59
+ event = event.data if event.class == Seahorse::Client::Response
60
+
61
+ case event
62
+ when Aws::CloudFormation::Types::StackEvent
63
+ on_stack_event(event)
64
+ when Aws::CloudFormation::Types::DescribeChangeSetOutput
65
+ on_change_set(event)
66
+ # when [:recreate, Aws::CloudFormation::Types::Stack]
67
+ when Array
68
+ on_stack(*event)
69
+ when ::CuffSert::Abort
70
+ @renderer.abort(event)
71
+ else
72
+ puts event
73
+ end
74
+ end
75
+
76
+ def on_complete
77
+ @renderer.done
78
+ end
79
+
80
+ private
81
+
82
+ def on_change_set(change_set)
83
+ @renderer.change_set(change_set.to_h)
84
+ end
85
+
86
+ def on_stack_event(event)
87
+ resource = lookup_stack_resource(event)
88
+ update_resource_states(resource, event)
89
+ @renderer.event(event, resource)
90
+ @renderer.clear
91
+ @resources.each { |resource| @renderer.resource(resource) }
92
+ clear_resources if is_completed_stack_event(event)
93
+ end
94
+
95
+ def on_stack(event, stack)
96
+ @renderer.stack(event, stack)
97
+ end
98
+
99
+ def lookup_stack_resource(event)
100
+ rid = event[:logical_resource_id]
101
+ unless (pos = @index[rid])
102
+ pos = @index[rid] = @resources.size
103
+ @resources << make_resource(event)
104
+ end
105
+ @resources[pos]
106
+ end
107
+
108
+ def make_resource(event)
109
+ event.to_h
110
+ .reject { |k, _| k == :timestamp }
111
+ .merge!(:states => [])
112
+ end
113
+
114
+ def update_resource_states(resource, event)
115
+ resource[:states] = resource[:states]
116
+ .reject { |state| state == :progress }
117
+ .take(1) << CuffSert.state_category(event[:resource_status])
118
+ end
119
+
120
+ def is_completed_stack_event(event)
121
+ event[:resource_type] == 'AWS::CloudFormation::Stack' &&
122
+ FINAL_STATES.include?(event[:resource_status])
123
+ end
124
+
125
+ def clear_resources
126
+ @resources.clear
127
+ @index.clear
128
+ end
129
+ end
130
+
131
+ class BaseRenderer
132
+ def initialize(output = STDOUT, error = STDERR, options = {})
133
+ @output = output
134
+ @error = error
135
+ @verbosity = options[:verbosity] || 1
136
+ end
137
+
138
+ def change_set(change_set) ; end
139
+ def event(event, resource) ; end
140
+ def clear ; end
141
+ def resource(resource) ; end
142
+ def abort(message) ; end
143
+ def done ; end
144
+ end
145
+
146
+ class JsonRenderer < BaseRenderer
147
+ def change_set(change_set)
148
+ @output.write(change_set.to_h.to_json) unless @verbosity < 1
149
+ end
150
+
151
+ def event(event, resource)
152
+ @output.write(event.to_h.to_json) unless @verbosity < 1
153
+ end
154
+
155
+ def stack(event, stack)
156
+ @output.write(stack.to_json) unless @verbosity < 1
157
+ end
158
+
159
+ def abort(event)
160
+ @error.write(event.message + "\n") unless @verbosity < 1
161
+ end
162
+ end
163
+
164
+ ACTION_ORDER = ['Add', 'Modify', 'Replace?', 'Replace!', 'Remove']
165
+
166
+ class ProgressbarRenderer < BaseRenderer
167
+ def change_set(change_set)
168
+ @output.write(sprintf("Updating stack %s\n", change_set[:stack_name]))
169
+ change_set[:changes].sort do |l, r|
170
+ lr = l[:resource_change]
171
+ rr = r[:resource_change]
172
+ [
173
+ ACTION_ORDER.index(action(lr)),
174
+ lr[:logical_resource_id]
175
+ ] <=> [
176
+ ACTION_ORDER.index(action(rr)),
177
+ rr[:logical_resource_id]
178
+ ]
179
+ end.map do |change|
180
+ rc = change[:resource_change]
181
+ sprintf("%s[%s] %-10s %s\n",
182
+ rc[:logical_resource_id],
183
+ rc[:resource_type],
184
+ action_color(action(rc)),
185
+ scope_desc(rc)
186
+ )
187
+ end.each { |row| @output.write(row) }
188
+ end
189
+
190
+ def action(rc)
191
+ if rc[:action] == 'Modify'
192
+ if ['True', 'Always'].include?(rc[:replacement])
193
+ 'Replace!'
194
+ elsif ['False', 'Never'].include?(rc[:replacement])
195
+ 'Modify'
196
+ elsif rc[:replacement] == 'Conditional'
197
+ 'Replace?'
198
+ else
199
+ "#{rc[:action]}/#{rc[:replacement]}"
200
+ end
201
+ else
202
+ rc[:action]
203
+ end
204
+ end
205
+
206
+ def action_color(action)
207
+ action.colorize(
208
+ case action
209
+ when 'Add' then :green
210
+ when 'Modify' then :yellow
211
+ else :red
212
+ end
213
+ )
214
+ end
215
+
216
+ def scope_desc(rc)
217
+ (rc[:scope] || []).map do |scope|
218
+ case scope
219
+ when 'Properties'
220
+ properties = rc[:details]
221
+ .select { |detail| detail[:target][:attribute] == 'Properties' }
222
+ .map { |detail| detail[:target][:name] }
223
+ .uniq
224
+ .join(", ")
225
+ sprintf("Properties: %s", properties)
226
+ else
227
+ rc[:scope]
228
+ end
229
+ end
230
+ .join("; ")
231
+ end
232
+
233
+ def event(event, resource)
234
+ return if @verbosity == 0
235
+ return if resource[:states][-1] != :bad && @verbosity <= 1
236
+ color, _ = interpret_states(resource)
237
+ message = sprintf('%s %s %s[%s] %s',
238
+ event[:resource_status],
239
+ event[:timestamp].strftime('%H:%M:%S%z'),
240
+ event[:logical_resource_id],
241
+ event[:resource_type].sub(/.*::/, ''),
242
+ event[:resource_status_reason] || ""
243
+ ).colorize(color)
244
+ @output.write("\r#{message}\n")
245
+ end
246
+
247
+ def stack(event, stack)
248
+ case event
249
+ when :create
250
+ @output.write("Creating stack #{stack}\n")
251
+ when :recreate
252
+ message = sprintf(
253
+ "Deleting and re-creating stack %s",
254
+ stack[:stack_name]
255
+ )
256
+ @output.write(message.colorize(:red) + "\n")
257
+ else
258
+ puts event, stack
259
+ end
260
+ end
261
+
262
+ def clear
263
+ @output.write("\r") unless @verbosity < 1
264
+ end
265
+
266
+ def resource(resource)
267
+ return if @verbosity < 1
268
+ color, symbol = interpret_states(resource)
269
+ table = {
270
+ :check => "+",
271
+ :tripple_dot => ".", # "\u2026"
272
+ :cross => "!",
273
+ :qmark => "?",
274
+ }
275
+
276
+ @output.write(table[symbol].colorize(
277
+ :color => :white,
278
+ :background => color
279
+ ))
280
+ end
281
+
282
+ def abort(event)
283
+ @error.write(event.message.colorize(:red) + "\n") unless @verbosity < 1
284
+ end
285
+
286
+ def done
287
+ @output.write("\nDone.\n".colorize(:green)) unless @verbosity < 1
288
+ end
289
+
290
+ private
291
+
292
+ def interpret_states(resource)
293
+ case resource[:states]
294
+ when [:progress]
295
+ [:yellow, :tripple_dot]
296
+ when [:good]
297
+ [:green, :check]
298
+ when [:bad]
299
+ [:red, :cross]
300
+ when [:good, :progress]
301
+ [:light_white, :tripple_dot]
302
+ when [:bad, :progress]
303
+ [:red, :tripple_dot]
304
+ when [:good, :good], [:bad, :good]
305
+ [:light_white, :check]
306
+ when [:good, :bad], [:bad, :bad]
307
+ [:red, :qmark]
308
+ else
309
+ raise "Unexpected :states in #{resource.inspect}"
310
+ end
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,116 @@
1
+ require 'aws-sdk'
2
+ require 'cuffsert/cfstates'
3
+ require 'rx'
4
+
5
+ module CuffSert
6
+ class RxCFClient
7
+ def initialize(
8
+ aws_cf = Aws::CloudFormation::Client.new(retry_limit: 8),
9
+ pause: 5,
10
+ max_items: 1000)
11
+ @cf = aws_cf
12
+ @max_items = max_items
13
+ @pause = pause
14
+ end
15
+
16
+ def find_stack_blocking(meta)
17
+ name = meta.stackname
18
+ @cf.describe_stacks(stack_name: name)[:stacks][0]
19
+ rescue Aws::CloudFormation::Errors::ValidationError
20
+ nil
21
+ end
22
+
23
+ def create_stack(cfargs)
24
+ Rx::Observable.create do |observer|
25
+ start_time = record_start_time
26
+ stack_id = @cf.create_stack(cfargs)[:stack_id]
27
+ stack_events(stack_id, start_time) do |event|
28
+ observer.on_next(event)
29
+ end
30
+ observer.on_completed
31
+ end
32
+ end
33
+
34
+ def prepare_update(cfargs)
35
+ Rx::Observable.create do |observer|
36
+ change_set_id = @cf.create_change_set(cfargs)[:id]
37
+ loop do
38
+ change_set = @cf.describe_change_set(change_set_name: change_set_id)
39
+ observer.on_next(change_set)
40
+ break if FINAL_STATES.include?(change_set[:status])
41
+ end
42
+ observer.on_completed
43
+ end
44
+ end
45
+
46
+ def update_stack(stack_id, change_set_id)
47
+ Rx::Observable.create do |observer|
48
+ start_time = record_start_time
49
+ @cf.execute_change_set(change_set_name: change_set_id)
50
+ stack_events(stack_id, start_time) do |event|
51
+ observer.on_next(event)
52
+ end
53
+ observer.on_completed
54
+ end
55
+ end
56
+
57
+ def abort_update(change_set_id)
58
+ Rx::Observable.create do |observer|
59
+ @cf.delete_change_set(change_set_name: change_set_id)
60
+ observer.on_completed
61
+ end
62
+ end
63
+
64
+ def delete_stack(cfargs)
65
+ eventid_cache = Set.new
66
+ Rx::Observable.create do |observer|
67
+ start_time = record_start_time
68
+ @cf.delete_stack(cfargs)
69
+ stack_events(cfargs[:stack_name], start_time) do |event|
70
+ observer.on_next(event)
71
+ end
72
+ observer.on_completed
73
+ end
74
+ .select do |event|
75
+ eventid_cache.add?(event[:event_id])
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def record_start_time
82
+ # Please make sure your machine has NTP :p
83
+ DateTime.now - 5.0 / 86400
84
+ end
85
+
86
+ def stack_finished?(stack_id, event)
87
+ event[:physical_resource_id] == stack_id &&
88
+ FINAL_STATES.include?(event[:resource_status])
89
+ end
90
+
91
+ def flatten_events(stack_id)
92
+ @cf.describe_stack_events(stack_name: stack_id).each do |events|
93
+ for event in events[:stack_events]
94
+ yield event
95
+ end
96
+ end
97
+ end
98
+
99
+ def stack_events(stack_id, start_time)
100
+ eventid_cache = Set.new
101
+ loop do
102
+ events = []
103
+ done = false
104
+ flatten_events(stack_id) do |event|
105
+ break if event[:timestamp].to_datetime < start_time
106
+ next unless eventid_cache.add?(event[:event_id])
107
+ events.unshift(event)
108
+ done = true if stack_finished?(stack_id, event)
109
+ end
110
+ events.each { |event| yield event }
111
+ break if done
112
+ sleep(@pause)
113
+ end
114
+ end
115
+ end
116
+ end