cuffsert 0.9.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.
@@ -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