stackup 1.5.0 → 1.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "stackup/error_handling"
2
4
 
3
5
  module Stackup
@@ -51,19 +53,9 @@ module Stackup
51
53
  # @raise [Stackup::NoSuchStack] if the stack doesn't exist
52
54
  #
53
55
  def create(options = {})
54
- options = options.dup
55
- options[:stack_name] = stack.name
56
- options[:change_set_name] = name
57
- options[:change_set_type] = stack.exists? ? "UPDATE" : "CREATE"
58
- force = options.delete(:force)
59
- options[:template_body] = MultiJson.dump(options.delete(:template)) if options[:template]
60
- # optionally override template_body with the original template to preserve formatting (& comments in YAML)
61
- template_orig = options.delete(:template_orig)
62
- options[:template_body] = template_orig if options.delete(:preserve)
63
- options[:parameters] = Parameters.new(options[:parameters]).to_a if options[:parameters]
64
- options[:tags] = normalize_tags(options[:tags]) if options[:tags]
65
- options[:capabilities] ||= ["CAPABILITY_NAMED_IAM"]
66
- delete if force
56
+ options = validate_options(options)
57
+ delete if options.delete(:force)
58
+ allow_empty_change_set = options.delete(:allow_empty_change_set)
67
59
  handling_cf_errors do
68
60
  cf_client.create_change_set(options)
69
61
  loop do
@@ -73,6 +65,10 @@ module Stackup
73
65
  when /COMPLETE/
74
66
  return current.status
75
67
  when "FAILED"
68
+ if allow_empty_change_set && (current.status_reason == "The submitted information didn't contain changes. Submit different information to create a change set.")
69
+ return current.status_reason
70
+ end
71
+
76
72
  logger.error(current.status_reason)
77
73
  raise StackUpdateError, "change-set creation failed" if status == "FAILED"
78
74
  end
@@ -145,6 +141,21 @@ module Stackup
145
141
  stack.send(:wait_poll_interval)
146
142
  end
147
143
 
144
+ def validate_options(original)
145
+ options = original.dup
146
+ options[:stack_name] = stack.name
147
+ options[:change_set_name] = name
148
+ options[:change_set_type] = stack.exists? ? "UPDATE" : "CREATE"
149
+ options[:template_body] = MultiJson.dump(options.delete(:template)) if options[:template]
150
+ # optionally override template_body with the original template to preserve formatting (& comments in YAML)
151
+ template_orig = options.delete(:template_orig)
152
+ options[:template_body] = template_orig if options.delete(:preserve)
153
+ options[:parameters] = Parameters.new(options[:parameters]).to_a if options[:parameters]
154
+ options[:tags] = normalize_tags(options[:tags]) if options[:tags]
155
+ options[:capabilities] ||= ["CAPABILITY_NAMED_IAM"]
156
+ options
157
+ end
158
+
148
159
  end
149
160
 
150
161
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "diffy"
2
4
  require "stackup/utils"
3
5
  require "yaml"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "stackup/errors"
2
4
 
3
5
  module Stackup
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Stackup
2
4
 
3
5
  # Base Stackup Exception class
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clamp"
4
+ require "console_logger"
5
+ require "multi_json"
6
+ require "securerandom"
7
+ require "stackup"
8
+ require "stackup/differ"
9
+ require "stackup/source"
10
+ require "stackup/version"
11
+ require "stackup/yaml"
12
+
13
+ module Stackup
14
+
15
+ class MainCommand < Clamp::Command
16
+
17
+ option ["-L", "--list"], :flag, "list stacks" do
18
+ list_stacks
19
+ exit 0
20
+ end
21
+
22
+ option ["-Y", "--yaml"], :flag, "output data in YAML format"
23
+
24
+ option ["--region"], "REGION", "set region" do |arg|
25
+ raise ArgumentError, "#{arg.inspect} doesn't look like a region" unless arg =~ /^[a-z]{2}-[a-z]+-\d$/
26
+
27
+ arg
28
+ end
29
+
30
+ option ["--with-role"], "ROLE_ARN", "assume this role",
31
+ :attribute_name => :role_arn
32
+
33
+ option ["--retry-limit"], "N", "maximum number of retries for API calls",
34
+ :environment_variable => "AWS_API_RETRY_LIMIT" do |arg|
35
+ Integer(arg)
36
+ end
37
+
38
+ option ["--[no-]wait"], :flag, "wait for stack updates to complete",
39
+ :default => true
40
+
41
+ option ["--wait-poll-interval"], "N", "polling interval (in seconds) while waiting for updates",
42
+ :default => 5, &method(:Integer)
43
+
44
+ option "--debug", :flag, "enable debugging"
45
+
46
+ option ["--version"], :flag, "display version" do
47
+ puts "stackup v#{Stackup::VERSION}"
48
+ exit 0
49
+ end
50
+
51
+ parameter "NAME", "Name of stack", :attribute_name => :stack_name
52
+
53
+ def run(arguments)
54
+ super(arguments)
55
+ rescue Stackup::Source::ReadError => e
56
+ signal_error e.message
57
+ rescue Stackup::ServiceError => e
58
+ signal_error e.message
59
+ rescue Aws::Errors::MissingCredentialsError
60
+ signal_error "no credentials provided"
61
+ rescue Aws::Errors::ServiceError => e
62
+ signal_error e.message
63
+ end
64
+
65
+ private
66
+
67
+ def logger
68
+ @logger ||= ConsoleLogger.new($stdout, debug?)
69
+ end
70
+
71
+ def format_data(data)
72
+ if yaml?
73
+ YAML.dump(data)
74
+ else
75
+ MultiJson.dump(data, :pretty => true)
76
+ end
77
+ end
78
+
79
+ def display_data(data)
80
+ puts format_data(data)
81
+ end
82
+
83
+ def role_arn=(arg)
84
+ raise ArgumentError, "#{arg.inspect} doesn't look like a role ARN" unless arg =~ %r{^arn:aws:iam::\d+:role/}
85
+
86
+ @role_arn = arg
87
+ end
88
+
89
+ def stackup
90
+ Stackup(aws_config)
91
+ end
92
+
93
+ def base_aws_config
94
+ {
95
+ :log_level => :debug,
96
+ :logger => logger,
97
+ :region => region,
98
+ :retry_limit => retry_limit
99
+ }.reject { |_k, v| v.nil? }
100
+ end
101
+
102
+ def aws_config
103
+ return base_aws_config unless role_arn
104
+
105
+ assumed_credentials = Aws::AssumeRoleCredentials.new(
106
+ :client => Aws::STS::Client.new(base_aws_config),
107
+ :role_arn => role_arn,
108
+ :role_session_name => "stackup-#{SecureRandom.hex(8)}"
109
+ )
110
+ base_aws_config.merge(:credentials => assumed_credentials)
111
+ end
112
+
113
+ def stack
114
+ stackup.stack(stack_name, :wait => wait?, :wait_poll_interval => wait_poll_interval)
115
+ end
116
+
117
+ def list_stacks
118
+ stackup.stack_names.each do |name|
119
+ puts name
120
+ end
121
+ end
122
+
123
+ def report_change
124
+ final_status = yield
125
+ puts final_status unless final_status.nil?
126
+ end
127
+
128
+ subcommand "status", "Print stack status." do
129
+
130
+ def execute
131
+ puts stack.status
132
+ end
133
+
134
+ end
135
+
136
+ module HasParameters
137
+
138
+ extend Clamp::Option::Declaration
139
+
140
+ option ["-p", "--parameters"], "FILE", "parameters file (last wins)",
141
+ :multivalued => true,
142
+ :attribute_name => :parameter_sources,
143
+ &Stackup::Source.method(:new)
144
+
145
+ option ["-o", "--override"], "PARAM=VALUE", "parameter overrides",
146
+ :multivalued => true,
147
+ :attribute_name => :override_list
148
+
149
+ private
150
+
151
+ def parameters
152
+ parameters_from_files.merge(parameter_overrides)
153
+ end
154
+
155
+ def parameters_from_files
156
+ parameter_sources.map do |src|
157
+ Stackup::Parameters.new(src.data).to_hash
158
+ end.inject({}, :merge)
159
+ end
160
+
161
+ def parameter_overrides
162
+ {}.tap do |result|
163
+ override_list.each do |override|
164
+ key, value = override.split("=", 2)
165
+ result[key] = value
166
+ end
167
+ end
168
+ end
169
+
170
+ end
171
+
172
+ subcommand "up", "Create/update the stack." do
173
+
174
+ option ["-t", "--template"], "FILE", "template source",
175
+ :attribute_name => :template_source,
176
+ &Stackup::Source.method(:new)
177
+
178
+ option ["-T", "--use-previous-template"], :flag,
179
+ "reuse the existing template"
180
+
181
+ option ["-P", "--preserve-template-formatting"], :flag,
182
+ "do not normalise the template when calling the Cloudformation APIs; useful for preserving YAML and comments"
183
+
184
+ include HasParameters
185
+
186
+ option "--tags", "FILE", "stack tags file",
187
+ :attribute_name => :tag_source,
188
+ &Stackup::Source.method(:new)
189
+
190
+ option "--policy", "FILE", "stack policy file",
191
+ :attribute_name => :policy_source,
192
+ &Stackup::Source.method(:new)
193
+
194
+ option "--service-role-arn", "SERVICE_ROLE_ARN", "cloudformation service role ARN" do |arg|
195
+ raise ArgumentError, "#{arg.inspect} doesn't look like a role ARN" unless arg =~ %r{^arn:aws:iam::\d+:role/}
196
+
197
+ arg
198
+ end
199
+
200
+ option "--on-failure", "ACTION",
201
+ "when stack creation fails: DO_NOTHING, ROLLBACK, or DELETE",
202
+ :default => "ROLLBACK"
203
+
204
+ option "--capability", "CAPABILITY", "cloudformation capability",
205
+ :multivalued => true, :default => ["CAPABILITY_NAMED_IAM"]
206
+
207
+ def execute
208
+ signal_usage_error "Specify either --template or --use-previous-template" unless template_source || use_previous_template?
209
+ options = {}
210
+ if template_source
211
+ if template_source.s3?
212
+ options[:template_url] = template_source.location
213
+ else
214
+ options[:template] = template_source.data
215
+ options[:template_orig] = template_source.body
216
+ end
217
+ end
218
+ options[:on_failure] = on_failure
219
+ options[:parameters] = parameters
220
+ options[:tags] = tag_source.data if tag_source
221
+ if policy_source
222
+ if policy_source.s3?
223
+ options[:stack_policy_url] = policy_source.location
224
+ else
225
+ options[:stack_policy] = policy_source.data
226
+ end
227
+ end
228
+ options[:role_arn] = service_role_arn if service_role_arn
229
+ options[:use_previous_template] = use_previous_template?
230
+ options[:capabilities] = capability_list
231
+ options[:preserve] = preserve_template_formatting?
232
+ report_change do
233
+ stack.create_or_update(options)
234
+ end
235
+ end
236
+
237
+ end
238
+
239
+ subcommand ["change-sets"], "List change-sets." do
240
+
241
+ def execute
242
+ stack.change_set_summaries.each do |change_set|
243
+ puts [
244
+ pad(change_set.change_set_name, 36),
245
+ pad(change_set.status, 20),
246
+ pad(change_set.execution_status, 24)
247
+ ].join(" ")
248
+ end
249
+ end
250
+
251
+ def pad(s, width)
252
+ (s || "").ljust(width)
253
+ end
254
+
255
+ end
256
+
257
+ subcommand ["change-set"], "Change-set operations." do
258
+
259
+ option "--name", "NAME", "Name of change-set",
260
+ :attribute_name => :change_set_name,
261
+ :default => "pending"
262
+
263
+ subcommand "create", "Create a change-set." do
264
+
265
+ option ["-d", "--description"], "DESC",
266
+ "Change-set description"
267
+
268
+ option ["-t", "--template"], "FILE", "template source",
269
+ :attribute_name => :template_source,
270
+ &Stackup::Source.method(:new)
271
+
272
+ option ["-T", "--use-previous-template"], :flag,
273
+ "reuse the existing template"
274
+
275
+ option ["-P", "--preserve-template-formatting"], :flag,
276
+ "do not normalise the template when calling the Cloudformation APIs; useful for preserving YAML and comments"
277
+
278
+ option ["--force"], :flag,
279
+ "replace existing change-set of the same name"
280
+
281
+ option ["--no-fail-on-empty-change-set"], :flag, "don't fail on empty change-set",
282
+ :attribute_name => :allow_empty_change_set
283
+
284
+ include HasParameters
285
+
286
+ option "--tags", "FILE", "stack tags file",
287
+ :attribute_name => :tag_source,
288
+ &Stackup::Source.method(:new)
289
+
290
+ option "--service-role-arn", "SERVICE_ROLE_ARN", "cloudformation service role ARN" do |arg|
291
+ raise ArgumentError, "#{arg.inspect} doesn't look like a role ARN" unless arg =~ %r{^arn:aws:iam::\d+:role/}
292
+
293
+ arg
294
+ end
295
+
296
+ option "--capability", "CAPABILITY", "cloudformation capability",
297
+ :multivalued => true, :default => ["CAPABILITY_NAMED_IAM"]
298
+
299
+ def execute
300
+ signal_usage_error "Specify either --template or --use-previous-template" unless template_source || use_previous_template?
301
+ options = {}
302
+ if template_source
303
+ if template_source.s3?
304
+ options[:template_url] = template_source.location
305
+ else
306
+ options[:template] = template_source.data
307
+ options[:template_orig] = template_source.body
308
+ end
309
+ end
310
+ options[:parameters] = parameters
311
+ options[:description] = description if description
312
+ options[:tags] = tag_source.data if tag_source
313
+ options[:role_arn] = service_role_arn if service_role_arn
314
+ options[:use_previous_template] = use_previous_template?
315
+ options[:force] = force?
316
+ options[:allow_empty_change_set] = allow_empty_change_set?
317
+ options[:capabilities] = capability_list
318
+ options[:preserve] = preserve_template_formatting?
319
+ report_change do
320
+ change_set.create(options)
321
+ end
322
+ end
323
+
324
+ end
325
+
326
+ subcommand "changes", "Describe the change-set." do
327
+
328
+ def execute
329
+ display_data(change_set.describe.changes.map(&:to_h))
330
+ end
331
+
332
+ end
333
+
334
+ subcommand "inspect", "Show full change-set details." do
335
+
336
+ def execute
337
+ display_data(change_set.describe.to_h)
338
+ end
339
+
340
+ end
341
+
342
+ subcommand ["apply", "execute"], "Apply the change-set." do
343
+
344
+ def execute
345
+ report_change do
346
+ change_set.execute
347
+ end
348
+ end
349
+
350
+ end
351
+
352
+ subcommand "delete", "Delete the change-set." do
353
+
354
+ def execute
355
+ report_change do
356
+ change_set.delete
357
+ end
358
+ end
359
+
360
+ end
361
+
362
+ def change_set
363
+ stack.change_set(change_set_name)
364
+ end
365
+
366
+ end
367
+
368
+ subcommand "diff", "Compare template/params to current stack." do
369
+
370
+ option "--diff-format", "FORMAT", "'text', 'color', or 'html'", :default => "color"
371
+
372
+ option ["-C", "--context-lines"], "LINES", "number of lines of context to show", :default => 10_000
373
+
374
+ option ["-t", "--template"], "FILE", "template source",
375
+ :attribute_name => :template_source,
376
+ &Stackup::Source.method(:new)
377
+
378
+ include HasParameters
379
+
380
+ option "--tags", "FILE", "stack tags file",
381
+ :attribute_name => :tag_source,
382
+ &Stackup::Source.method(:new)
383
+
384
+ def execute
385
+ current = {}
386
+ planned = {}
387
+ if template_source
388
+ current["Template"] = stack.template
389
+ planned["Template"] = template_source.data
390
+ end
391
+ unless parameter_sources.empty?
392
+ current["Parameters"] = existing_parameters.sort.to_h
393
+ planned["Parameters"] = new_parameters.sort.to_h
394
+ end
395
+ if tag_source
396
+ current["Tags"] = stack.tags.sort.to_h
397
+ planned["Tags"] = tag_source.data.sort.to_h
398
+ end
399
+ signal_usage_error "specify '--template' or '--parameters'" if planned.empty?
400
+ puts differ.diff(current, planned, context_lines)
401
+ end
402
+
403
+ def differ
404
+ Stackup::Differ.new(diff_format, &method(:format_data))
405
+ end
406
+
407
+ def existing_parameters
408
+ @existing_parameters ||= stack.parameters
409
+ end
410
+
411
+ def new_parameters
412
+ existing_parameters.merge(parameters)
413
+ end
414
+
415
+ end
416
+
417
+ subcommand ["down", "delete"], "Remove the stack." do
418
+
419
+ def execute
420
+ report_change do
421
+ stack.delete
422
+ end
423
+ end
424
+
425
+ end
426
+
427
+ subcommand "cancel-update", "Cancel the update in-progress." do
428
+
429
+ def execute
430
+ report_change do
431
+ stack.cancel_update
432
+ end
433
+ end
434
+
435
+ end
436
+
437
+ subcommand "wait", "Wait until stack is stable." do
438
+
439
+ def execute
440
+ puts stack.wait
441
+ end
442
+
443
+ end
444
+
445
+ subcommand "events", "List stack events." do
446
+
447
+ option ["-f", "--follow"], :flag, "follow new events"
448
+ option ["--data"], :flag, "display events as data"
449
+
450
+ def execute
451
+ stack.watch(false) do |watcher|
452
+ loop do
453
+ watcher.each_new_event do |event|
454
+ display_event(event)
455
+ end
456
+ break unless follow?
457
+
458
+ sleep 5
459
+ end
460
+ end
461
+ end
462
+
463
+ def display_event(e)
464
+ if data?
465
+ display_data(event_data(e))
466
+ else
467
+ puts event_summary(e)
468
+ end
469
+ end
470
+
471
+ def event_data(e)
472
+ {
473
+ "timestamp" => e.timestamp.localtime,
474
+ "logical_resource_id" => e.logical_resource_id,
475
+ "physical_resource_id" => e.physical_resource_id,
476
+ "resource_status" => e.resource_status,
477
+ "resource_status_reason" => e.resource_status_reason
478
+ }.reject { |_k, v| blank?(v) }
479
+ end
480
+
481
+ def blank?(v)
482
+ v.nil? || v.respond_to?(:empty?) && v.empty?
483
+ end
484
+
485
+ def event_summary(e)
486
+ summary = "[#{e.timestamp.localtime.iso8601}] #{e.logical_resource_id}"
487
+ summary += " - #{e.resource_status}"
488
+ summary += " - #{e.resource_status_reason}" if e.resource_status_reason
489
+ summary
490
+ end
491
+
492
+ end
493
+
494
+ subcommand "template", "Display stack template." do
495
+
496
+ def execute
497
+ display_data(stack.template)
498
+ end
499
+
500
+ end
501
+
502
+ subcommand ["parameters", "params"], "Display stack parameters." do
503
+
504
+ def execute
505
+ display_data(stack.parameters)
506
+ end
507
+
508
+ end
509
+
510
+ subcommand "tags", "Display stack tags." do
511
+
512
+ def execute
513
+ display_data(stack.tags)
514
+ end
515
+
516
+ end
517
+
518
+ subcommand "resources", "Display stack resources." do
519
+
520
+ def execute
521
+ display_data(stack.resources)
522
+ end
523
+
524
+ end
525
+
526
+ subcommand "outputs", "Display stack outputs." do
527
+
528
+ def execute
529
+ display_data(stack.outputs)
530
+ end
531
+
532
+ end
533
+
534
+ subcommand "inspect", "Display stack particulars." do
535
+
536
+ def execute
537
+ data = {
538
+ "Status" => stack.status,
539
+ "Parameters" => stack.parameters,
540
+ "Tags" => stack.tags,
541
+ "Resources" => stack.resources,
542
+ "Outputs" => stack.outputs
543
+ }
544
+ display_data(data)
545
+ end
546
+
547
+ end
548
+
549
+ end
550
+
551
+ end