stackup 1.5.0 → 1.7.1

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