kumogata2 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.
@@ -0,0 +1,587 @@
1
+ class Kumogata2::Client
2
+ include Kumogata2::Logger::Helper
3
+
4
+ def initialize(options)
5
+ @options = options.kind_of?(Hashie::Mash) ? options : Hashie::Mash.new(options)
6
+ @client = Aws::CloudFormation::Client.new
7
+ @resource = Aws::CloudFormation::Resource.new(client: @client)
8
+ @plugin_by_ext = {}
9
+ end
10
+
11
+ def create(path_or_url, stack_name = nil)
12
+ validate_stack_name(stack_name) if stack_name
13
+ template = open_template(path_or_url)
14
+ update_deletion_policy(template, delete_stack: !stack_name)
15
+
16
+ outputs = create_stack(template, stack_name)
17
+
18
+ unless @options.detach?
19
+ post_process(path_or_url, outputs)
20
+ end
21
+ end
22
+
23
+ def update(path_or_url, stack_name)
24
+ validate_stack_name(stack_name)
25
+ template = open_template(path_or_url)
26
+ update_deletion_policy(template, update_metadate: true)
27
+
28
+ outputs = update_stack(template, stack_name)
29
+
30
+ unless @options.detach?
31
+ post_process(path_or_url, outputs)
32
+ end
33
+ end
34
+
35
+ def delete(stack_name)
36
+ validate_stack_name(stack_name)
37
+ @resource.stack(stack_name).stack_status
38
+
39
+ if @options.force? or agree("Are you sure you want to delete `#{stack_name}`? ".yellow)
40
+ delete_stack(stack_name)
41
+ end
42
+ end
43
+
44
+ def validate(path_or_url)
45
+ template = open_template(path_or_url)
46
+ validate_template(template)
47
+ end
48
+
49
+ def list(stack_name = nil)
50
+ validate_stack_name(stack_name) if stack_name
51
+ stacks = describe_stacks(stack_name)
52
+ JSON.pretty_generate(stacks).colorize_as(:json)
53
+ end
54
+
55
+ def export(stack_name)
56
+ validate_stack_name(stack_name)
57
+ template = export_template(stack_name)
58
+ convert0(template)
59
+ end
60
+
61
+ def convert(path_or_url)
62
+ template = open_template(path_or_url)
63
+ convert0(template)
64
+ end
65
+
66
+ def diff(path_or_url1, path_or_url2)
67
+ templates = [path_or_url1, path_or_url2].map do |path_or_url|
68
+ template = nil
69
+
70
+ if path_or_url =~ %r|\Astack://(.*)|
71
+ stack_name = $1 || ''
72
+ validate_stack_name(stack_name)
73
+ template = export_template(stack_name)
74
+ else
75
+ template = open_template(path_or_url)
76
+ end
77
+
78
+ JSON.pretty_generate(template)
79
+ end
80
+
81
+ diff_opts = @options.ignore_all_space? ? '-uw' : '-u'
82
+ opts = {:include_diff_info => true, :diff => diff_opts}
83
+ diff = Diffy::Diff.new(*templates, opts).to_s
84
+
85
+ diff.sub(/^(\e\[\d+m)?\-\-\-(\s+)(\S+)/m) { "#{$1}---#{$2}#{path_or_url1}"}
86
+ .sub(/^(\e\[\d+m)?\+\+\+(\s+)(\S+)/m) { "#{$1}+++#{$2}#{path_or_url2}"}
87
+ end
88
+
89
+ def dry_run(path_or_url, stack_name = nil)
90
+ validate_stack_name(stack_name) if stack_name
91
+ template = open_template(path_or_url)
92
+ update_deletion_policy(template, delete_stack: !stack_name)
93
+ changes = show_change_set(template, stack_name)
94
+ changes = JSON.pretty_generate(changes).colorize_as(:json) if changes
95
+ changes
96
+ end
97
+
98
+ def show_events(stack_name)
99
+ validate_stack_name(stack_name)
100
+ events = describe_events(stack_name)
101
+ JSON.pretty_generate(events).colorize_as(:json)
102
+ end
103
+
104
+ def show_outputs(stack_name)
105
+ validate_stack_name(stack_name)
106
+ outputs = describe_outputs(stack_name)
107
+ JSON.pretty_generate(outputs).colorize_as(:json)
108
+ end
109
+
110
+ def show_resources(stack_name)
111
+ validate_stack_name(stack_name)
112
+ resources = describe_resources(stack_name)
113
+ JSON.pretty_generate(resources).colorize_as(:json)
114
+ end
115
+
116
+ private
117
+
118
+ def create_stack(template, stack_name)
119
+ stack_will_be_deleted = !stack_name
120
+
121
+ unless stack_name
122
+ stack_name = random_stack_name
123
+ end
124
+
125
+ log(:info, "Creating stack: #{stack_name}", color: :cyan)
126
+
127
+ params = {
128
+ stack_name: stack_name,
129
+ template_body: template.to_json,
130
+ parameters: parameters_array,
131
+ }
132
+
133
+ set_api_params(params,
134
+ :disable_rollback,
135
+ :timeout_in_minutes,
136
+ :notification_arns,
137
+ :capabilities,
138
+ :resource_types,
139
+ :on_failure,
140
+ :stack_policy_body,
141
+ :stack_policy_url)
142
+
143
+ stack = @resource.create_stack(params)
144
+
145
+ return if @options.detach?
146
+
147
+ completed = wait(stack, 'CREATE_COMPLETE')
148
+
149
+ unless completed
150
+ raise_stack_error!(stack, 'Create failed')
151
+ end
152
+
153
+ outputs = outputs_for(stack)
154
+ summaries = resource_summaries_for(stack)
155
+
156
+ if stack_will_be_deleted
157
+ delete_stack(stack_name)
158
+ end
159
+
160
+ output_result(stack_name, outputs, summaries)
161
+
162
+ outputs
163
+ end
164
+
165
+ def update_stack(template, stack_name)
166
+ stack = @resource.stack(stack_name)
167
+ stack.stack_status
168
+
169
+ log(:info, "Updating stack: #{stack_name}", color: :green)
170
+
171
+ params = {
172
+ stack_name: stack_name,
173
+ template_body: template.to_json,
174
+ parameters: parameters_array,
175
+ }
176
+
177
+ set_api_params(params,
178
+ :use_previous_template,
179
+ :stack_policy_during_update_body,
180
+ :stack_policy_during_update_url,
181
+ :notification_arns,
182
+ :capabilities,
183
+ :resource_types,
184
+ :stack_policy_body,
185
+ :stack_policy_url)
186
+
187
+ event_log = create_event_log(stack)
188
+ stack.update(params)
189
+
190
+ return if @options.detach?
191
+
192
+ # XXX: Reacquire the stack
193
+ stack = @resource.stack(stack_name)
194
+ completed = wait(stack, 'UPDATE_COMPLETE', event_log)
195
+
196
+ unless completed
197
+ raise_stack_error!(stack, 'Update failed')
198
+ end
199
+
200
+ outputs = outputs_for(stack)
201
+ summaries = resource_summaries_for(stack)
202
+
203
+ output_result(stack_name, outputs, summaries)
204
+
205
+ outputs
206
+ end
207
+
208
+ def delete_stack(stack_name)
209
+ stack = @resource.stack(stack_name)
210
+ stack.stack_status
211
+
212
+ log(:info, "Deleting stack: #{stack_name}", color: :red)
213
+ event_log = create_event_log(stack)
214
+ stack.delete
215
+
216
+ return if @options.detach?
217
+
218
+ completed = false
219
+
220
+ begin
221
+ # XXX: Reacquire the stack
222
+ stack = @resource.stack(stack_name)
223
+ completed = wait(stack, 'DELETE_COMPLETE', event_log)
224
+ rescue Aws::CloudFormation::Errors::ValidationError
225
+ # Handle `Stack does not exist`
226
+ completed = true
227
+ end
228
+
229
+ unless completed
230
+ raise_stack_error!(stack, 'Delete failed')
231
+ end
232
+
233
+ log(:info, 'Success')
234
+ end
235
+
236
+ def validate_template(template)
237
+ @client.validate_template(template_body: template.to_json)
238
+ log(:info, 'Template validated successfully', color: :green)
239
+ end
240
+
241
+ def describe_stacks(stack_name)
242
+ params = {}
243
+ params[:stack_name] = stack_name if stack_name
244
+
245
+ @resource.stacks(params).map do |stack|
246
+ {
247
+ 'StackName' => stack.name,
248
+ 'CreationTime' => stack.creation_time,
249
+ 'StackStatus' => stack.stack_status,
250
+ 'Description' => stack.description,
251
+ }
252
+ end
253
+ end
254
+
255
+ def export_template(stack_name)
256
+ stack = @resource.stack(stack_name)
257
+ stack.stack_status
258
+ template = stack.client.get_template(stack_name: stack_name).template_body
259
+ JSON.parse(template)
260
+ end
261
+
262
+ def show_change_set(template, stack_name)
263
+ output = nil
264
+ change_set_name = [stack_name, SecureRandom.uuid].join('-')
265
+
266
+ log(:info, "Creating ChangeSet: #{change_set_name}", color: :cyan)
267
+
268
+ params = {
269
+ stack_name: stack_name,
270
+ change_set_name: change_set_name,
271
+ template_body: template.to_json,
272
+ parameters: parameters_array,
273
+ }
274
+
275
+ set_api_params(params,
276
+ :use_previous_template,
277
+ :notification_arns,
278
+ :capabilities,
279
+ :resource_types)
280
+
281
+ resp = @client.create_change_set(params)
282
+ change_set_arn = resp.id
283
+
284
+ completed, change_set = wait_change_set(change_set_arn, 'CREATE_COMPLETE')
285
+
286
+ if completed
287
+ output = changes_for(change_set)
288
+ else
289
+ log(:error, "Create ChangeSet failed: #{change_set.status_reason}", color: :red)
290
+ end
291
+
292
+ log(:info, "Deleting ChangeSet: #{change_set_name}", color: :red)
293
+
294
+ @client.delete_change_set(change_set_name: change_set_arn)
295
+
296
+ begin
297
+ completed, _ = wait_change_set(change_set_arn, 'DELETE_COMPLETE')
298
+ rescue Aws::CloudFormation::Errors::ChangeSetNotFound
299
+ # Handle `ChangeSet does not exist`
300
+ completed = true
301
+ end
302
+
303
+ unless completed
304
+ log(:error, "Delete ChangeSet failed: #{change_set.status_reason}", color: :red)
305
+ end
306
+
307
+ output
308
+ end
309
+
310
+ def describe_events(stack_name)
311
+ stack = @resource.stack(stack_name)
312
+ stack.stack_status
313
+ events_for(stack)
314
+ end
315
+
316
+ def describe_outputs(stack_name)
317
+ stack = @resource.stack(stack_name)
318
+ stack.stack_status
319
+ outputs_for(stack)
320
+ end
321
+
322
+ def describe_resources(stack_name)
323
+ stack = @resource.stack(stack_name)
324
+ stack.stack_status
325
+ resource_summaries_for(stack)
326
+ end
327
+
328
+ def convert0(template)
329
+ ext = @options.output_format || 'template'
330
+ plugin = find_or_create_plugin('xxx.' + ext)
331
+
332
+ if plugin
333
+ plugin.dump(template)
334
+ else
335
+ raise "Unknown format: #{ext}"
336
+ end
337
+ end
338
+
339
+ def open_template(path_or_url)
340
+ plugin = find_or_create_plugin(path_or_url)
341
+
342
+ if plugin
343
+ @options.path_or_url = path_or_url
344
+ plugin.parse(open(path_or_url, &:read))
345
+ else
346
+ raise "Unknown format: #{path_or_url}"
347
+ end
348
+ end
349
+
350
+ def find_or_create_plugin(path_or_url)
351
+ ext = File.extname(path_or_url).sub(/\A\./, '')
352
+
353
+ if @plugin_by_ext.has_key?(ext)
354
+ return @plugin_by_ext.fetch(ext)
355
+ end
356
+
357
+ plugin_class = Kumogata2::Plugin.find(ext)
358
+ plugin = plugin_class ? plugin_class.new(@options) : nil
359
+ @plugin_by_ext[ext] = plugin
360
+ end
361
+
362
+ def update_deletion_policy(template, options = {})
363
+ if options[:delete_stack] or @options.deletion_policy_retain?
364
+ template['Resources'].each do |k, v|
365
+ next if /\AAWS::CloudFormation::/ =~ v['Type']
366
+ v['DeletionPolicy'] ||= 'Retain'
367
+
368
+ if options[:update_metadate]
369
+ v['Metadata'] ||= {}
370
+ v['Metadata']['DeletionPolicyUpdateKeyForKumogata'] = "DeletionPolicyUpdateValueForKumogata#{Time.now.to_i}"
371
+ end
372
+ end
373
+ end
374
+ end
375
+
376
+ def validate_stack_name(stack_name)
377
+ unless /\A[a-zA-Z][-a-zA-Z0-9]*\Z/i =~ stack_name
378
+ raise "1 validation error detected: Value '#{stack_name}' at 'stackName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*"
379
+ end
380
+ end
381
+
382
+ def parameters_array
383
+ @options.parameters.map do |key, value|
384
+ {parameter_key: key, parameter_value: value}
385
+ end
386
+ end
387
+
388
+ def set_api_params(params, *keys)
389
+ keys.each do |k|
390
+ opts[k] = @options[k] if @options[k]
391
+ end
392
+ end
393
+
394
+ def wait(stack, complete_status, event_log = {})
395
+ before_wait = proc do |attempts, response|
396
+ print_event_log(stack, event_log)
397
+ end
398
+
399
+ stack.wait_until(before_wait: before_wait, max_attempts: nil, delay: 1) do |s|
400
+ s.stack_status !~ /_IN_PROGRESS\z/
401
+ end
402
+
403
+ print_event_log(stack, event_log)
404
+
405
+ completed = (stack.stack_status == complete_status)
406
+ log(:info, completed ? 'Success' : 'Failure')
407
+
408
+ completed
409
+ end
410
+
411
+ def wait_change_set(change_set_name, complete_status)
412
+ change_set = nil
413
+
414
+ loop do
415
+ change_set = @client.describe_change_set(change_set_name: change_set_name)
416
+
417
+ if change_set.status !~ /(_PENDING|_IN_PROGRESS)\z/
418
+ break
419
+ end
420
+
421
+ sleep 1
422
+ end
423
+
424
+ completed = (change_set.status == complete_status)
425
+ [completed, change_set]
426
+ end
427
+
428
+ def print_event_log(stack, event_log)
429
+ events_for(stack).sort_by {|i| i['Timestamp'] }.each do |event|
430
+ event_id = event['EventId']
431
+
432
+ unless event_log[event_id]
433
+ event_log[event_id] = event
434
+
435
+ timestamp = event['Timestamp']
436
+ summary = {}
437
+
438
+ ['LogicalResourceId', 'ResourceStatus', 'ResourceStatusReason'].map do |k|
439
+ summary[k] = event[k]
440
+ end
441
+
442
+ puts [
443
+ timestamp.getlocal.strftime('%Y/%m/%d %H:%M:%S %Z'),
444
+ summary.to_json.colorize_as(:json),
445
+ ].join(': ')
446
+ end
447
+ end
448
+ end
449
+
450
+ def create_event_log(stack)
451
+ event_log = {}
452
+
453
+ events_for(stack).sort_by {|i| i['Timestamp'] }.each do |event|
454
+ event_id = event['EventId']
455
+ event_log[event_id] = event
456
+ end
457
+
458
+ return event_log
459
+ end
460
+
461
+ def events_for(stack)
462
+ stack.events.map do |event|
463
+ event_hash = {}
464
+
465
+ [
466
+ :event_id,
467
+ :logical_resource_id,
468
+ :physical_resource_id,
469
+ :resource_properties,
470
+ :resource_status,
471
+ :resource_status_reason,
472
+ :resource_type,
473
+ :stack_id,
474
+ :stack_name,
475
+ :timestamp,
476
+ ].each do |k|
477
+ event_hash[Kumogata2::Utils.camelize(k)] = event.send(k)
478
+ end
479
+
480
+ event_hash
481
+ end
482
+ end
483
+
484
+ def outputs_for(stack)
485
+ outputs_hash = {}
486
+
487
+ stack.outputs.each do |output|
488
+ outputs_hash[output.output_key] = output.output_value
489
+ end
490
+
491
+ outputs_hash
492
+ end
493
+
494
+ def resource_summaries_for(stack)
495
+ stack.resource_summaries.map do |summary|
496
+ summary_hash = {}
497
+
498
+ [
499
+ :logical_resource_id,
500
+ :physical_resource_id,
501
+ :resource_type,
502
+ :resource_status,
503
+ :resource_status_reason,
504
+ :last_updated_timestamp
505
+ ].each do |k|
506
+ summary_hash[Kumogata2::Utils.camelize(k)] = summary.send(k)
507
+ end
508
+
509
+ summary_hash
510
+ end
511
+ end
512
+
513
+ def changes_for(change_set)
514
+ change_set.changes.map do |change|
515
+ resource_change = change.resource_change
516
+ change_hash = {}
517
+
518
+ [
519
+ :action,
520
+ :logical_resource_id,
521
+ :physical_resource_id,
522
+ :resource_type,
523
+ ].each do |k|
524
+ change_hash[Kumogata2::Utils.camelize(k)] = resource_change[k]
525
+ end
526
+
527
+ change_hash['Details'] = resource_change.details.map do |detail|
528
+ {
529
+ attribute: detail.target.attribute,
530
+ name: detail.target.name,
531
+ }
532
+ end
533
+
534
+ change_hash
535
+ end
536
+ end
537
+
538
+ def output_result(stack_name, outputs, summaries)
539
+ puts <<-EOS
540
+
541
+ Stack Resource Summaries:
542
+ #{JSON.pretty_generate(summaries).colorize_as(:json)}
543
+
544
+ Outputs:
545
+ #{JSON.pretty_generate(outputs).colorize_as(:json)}
546
+ EOS
547
+
548
+ if @options.result_log?
549
+ puts <<-EOS
550
+
551
+ (Save to `#{@options.result_log}`)
552
+ EOS
553
+
554
+ open(@options.result_log, 'wb') do |f|
555
+ f.puts JSON.pretty_generate({
556
+ 'StackName' => stack_name,
557
+ 'StackResourceSummaries' => summaries,
558
+ 'Outputs' => outputs,
559
+ })
560
+ end
561
+ end
562
+ end
563
+
564
+ def post_process(path_or_url, outputs)
565
+ plugin = find_or_create_plugin(path_or_url)
566
+
567
+ if plugin and plugin.respond_to?(:post)
568
+ plugin.post(outputs)
569
+ end
570
+ end
571
+
572
+ def raise_stack_error!(stack, message)
573
+ errmsgs = [message]
574
+ errmsgs << stack.name
575
+ errmsgs << stack.stack_status_reason if stack.stack_status_reason
576
+ raise errmsgs.join(': ')
577
+ end
578
+
579
+ def random_stack_name
580
+ stack_name = ['kumogata']
581
+ user_host = Kumogata2::Utils.get_user_host
582
+ stack_name << user_host if user_host
583
+ stack_name << SecureRandom.uuid
584
+ stack_name = stack_name.join('-')
585
+ stack_name.gsub(/[^-a-zA-Z0-9]+/, '-').gsub(/-+/, '-')
586
+ end
587
+ end
@@ -0,0 +1,24 @@
1
+ {
2
+ constant: "\e[1;34m",
3
+ float: "\e[36m",
4
+ integer: "\e[36m",
5
+ keyword: "\e[1;31m",
6
+
7
+ key: {
8
+ self: "\e[1;34m",
9
+ char: "\e[1;34m",
10
+ delimiter: "\e[1;34m",
11
+ },
12
+
13
+ string: {
14
+ self: "\e[32m",
15
+ modifier: "\e[1;32m",
16
+ char: "\e[1;32m",
17
+ delimiter: "\e[1;32m",
18
+ escape: "\e[1;32m",
19
+ },
20
+
21
+ error: "\e[0m",
22
+ }.each do |key, value|
23
+ CodeRay::Encoders::Terminal::TOKEN_COLORS[key] = value
24
+ end
@@ -0,0 +1,36 @@
1
+ module Kumogata2::Ext
2
+ module StringExt
3
+ module ClassMethods
4
+ def colorize=(value)
5
+ @colorize = value
6
+ end
7
+
8
+ def colorize
9
+ @colorize
10
+ end
11
+ end # ClassMethods
12
+
13
+ Term::ANSIColor::Attribute.named_attributes.each do |attribute|
14
+ class_eval(<<-EOS, __FILE__, __LINE__ + 1)
15
+ def #{attribute.name}
16
+ if String.colorize
17
+ Term::ANSIColor.send(#{attribute.name.inspect}, self)
18
+ else
19
+ self
20
+ end
21
+ end
22
+ EOS
23
+ end
24
+
25
+ def colorize_as(lang)
26
+ if String.colorize
27
+ CodeRay.scan(self, lang).terminal
28
+ else
29
+ self
30
+ end
31
+ end
32
+ end # StringExt
33
+ end # Kumogata2::Ext
34
+
35
+ String.include(Kumogata2::Ext::StringExt)
36
+ String.extend(Kumogata2::Ext::StringExt::ClassMethods)
@@ -0,0 +1,28 @@
1
+ class Kumogata2::Logger < ::Logger
2
+ include Singleton
3
+
4
+ def initialize
5
+ super($stdout)
6
+
7
+ self.formatter = proc do |severity, datetime, progname, msg|
8
+ "#{msg}\n"
9
+ end
10
+
11
+ self.level = Logger::INFO
12
+ end
13
+
14
+ def set_debug(value)
15
+ self.level = value ? Logger::DEBUG : Logger::INFO
16
+ end
17
+
18
+ module Helper
19
+ def log(level, message, log_options = {})
20
+ globa_options = @options || {}
21
+ message = "[#{level.to_s.upcase}] #{message}" unless level == :info
22
+ message = message.send(log_options[:color]) if log_options[:color]
23
+ logger = globa_options[:logger] || Kumogata2::Logger.instance
24
+ logger.send(level, message)
25
+ end
26
+ module_function :log
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ class Kumogata2::Plugin::JSON
2
+ Kumogata2::Plugin.register(:json, ['json', 'js', 'template'], self)
3
+
4
+ def initialize(options)
5
+ @options = options
6
+ end
7
+
8
+ def parse(str)
9
+ JSON.parse(str)
10
+ end
11
+
12
+ def dump(hash)
13
+ JSON.pretty_generate(hash).colorize_as(:json)
14
+ end
15
+ end