cardtapp-cloudformation-ruby-dsl 0.0.1.pre.pre1

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,731 @@
1
+ # Copyright 2013-2014 Bazaarvoice, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'cloudformation-ruby-dsl/dsl'
16
+
17
+ unless RUBY_VERSION >= '1.9'
18
+ # This script uses Ruby 1.9 functions such as Enumerable.slice_before and Enumerable.chunk
19
+ $stderr.puts "This script requires ruby 1.9+. On OS/X use Homebrew to install ruby 1.9:"
20
+ $stderr.puts " brew install ruby"
21
+ exit(2)
22
+ end
23
+
24
+ require 'rubygems'
25
+ require 'json'
26
+ require 'yaml'
27
+ require 'erb'
28
+ require 'aws-sdk'
29
+ require 'diffy'
30
+ require 'highline/import'
31
+
32
+ ############################# AWS SDK Support
33
+
34
+ class AwsClients
35
+ attr_accessor :cfn_client_instance
36
+
37
+ def initialize(args)
38
+ Aws.config[:region] = args[:region] if args.key?(:region)
39
+ # Profile definition was replaced with environment variables
40
+ if args.key?(:aws_profile) && !(args[:aws_profile].nil? || args[:aws_profile].empty?)
41
+ ENV['AWS_PROFILE'] = args[:aws_profile]
42
+ ENV['AWS_ACCESS_KEY'] = nil
43
+ ENV['AWS_ACCESS_KEY_ID'] = nil
44
+ ENV['AMAZON_ACCESS_KEY_ID'] = nil
45
+ end
46
+ # Following line can be uncommented only when Amazon will provide the stable version of this functionality.
47
+ # Aws.config[:credentials] = Aws::SharedCredentials.new(profile_name: args[:aws_profile]) unless args[:aws_profile].nil?
48
+ end
49
+
50
+ def cfn_client
51
+ if @cfn_client_instance == nil
52
+ # credentials are loaded from the environment; see http://docs.aws.amazon.com/sdkforruby/api/Aws/CloudFormation/Client.html
53
+ @cfn_client_instance = Aws::CloudFormation::Client.new(
54
+ # we don't validate parameters because the aws-ruby-sdk gets a number parameter and expects it to be a string and fails the validation
55
+ # see: https://github.com/aws/aws-sdk-ruby/issues/848
56
+ validate_params: false
57
+ )
58
+ end
59
+ @cfn_client_instance
60
+ end
61
+
62
+ def s3_client
63
+ if @s3_client_instance == nil
64
+ @s3_client_instance = Aws::S3::Client.new()
65
+ end
66
+ @s3_client_instance
67
+ end
68
+ end
69
+
70
+ # utility class to deserialize Structs as JSON
71
+ # borrowed from http://ruhe.tumblr.com/post/565540643/generate-json-from-ruby-struct
72
+ class Struct
73
+ def to_map
74
+ map = Hash.new
75
+ self.members.each { |m| map[m] = self[m] }
76
+ map
77
+ end
78
+
79
+ def to_json(*a)
80
+ to_map.to_json(*a)
81
+ end
82
+ end
83
+
84
+ ############################# Command-line support
85
+
86
+ # Parse command-line arguments and return the parameters and region
87
+ def parse_args
88
+ args = {
89
+ :stack_name => nil,
90
+ :parameters => {},
91
+ :interactive => false,
92
+ :region => default_region,
93
+ :profile => nil,
94
+ :nopretty => false,
95
+ :s3_bucket => nil,
96
+ }
97
+ ARGV.slice_before(/^--/).each do |name, value|
98
+ case name
99
+ when '--stack-name'
100
+ args[:stack_name] = value
101
+ when '--parameters'
102
+ args[:parameters] = Hash[value.split(/;/).map { |pair| parts = pair.split(/=/, 2); [ parts[0], Parameter.new(parts[1]) ] }]
103
+ when '--interactive'
104
+ args[:interactive] = true
105
+ when '--region'
106
+ args[:region] = value
107
+ when '--profile'
108
+ args[:profile] = value
109
+ when '--nopretty'
110
+ args[:nopretty] = true
111
+ when '--s3-bucket'
112
+ args[:s3_bucket] = value
113
+ end
114
+ end
115
+
116
+ args
117
+ end
118
+
119
+ def validate_action(action)
120
+ valid = %w[
121
+ help
122
+ expand
123
+ diff
124
+ validate
125
+ create
126
+ update
127
+ cancel-update
128
+ delete
129
+ describe
130
+ describe-resource
131
+ get-template
132
+ ]
133
+ removed = %w[
134
+ cfn-list-stack-resources
135
+ cfn-list-stacks
136
+ ]
137
+ deprecated = {
138
+ "cfn-validate-template" => "validate",
139
+ "cfn-create-stack" => "create",
140
+ "cfn-update-stack" => "update",
141
+ "cfn-cancel-update-stack" => "cancel-update",
142
+ "cfn-delete-stack" => "delete",
143
+ "cfn-describe-stack-events" => "describe",
144
+ "cfn-describe-stack-resources" => "describe",
145
+ "cfn-describe-stack-resource" => "describe-resource",
146
+ "cfn-get-template" => "get-template"
147
+ }
148
+ if deprecated.keys.include? action
149
+ replacement = deprecated[action]
150
+ $stderr.puts "WARNING: '#{action}' is deprecated and will be removed in a future version. Please use '#{replacement}' instead."
151
+ action = replacement
152
+ end
153
+ unless valid.include? action
154
+ if removed.include? action
155
+ $stderr.puts "ERROR: native command #{action} is no longer supported by cloudformation-ruby-dsl."
156
+ end
157
+ $stderr.puts "usage: #{$PROGRAM_NAME} <#{valid.join('|')}>"
158
+ exit(2)
159
+ end
160
+ action
161
+ end
162
+
163
+ def cfn(template)
164
+ aws_clients = AwsClients.new({:region => template.aws_region, :aws_profile => template.aws_profile})
165
+ cfn_client = aws_clients.cfn_client
166
+ s3_client = aws_clients.s3_client
167
+
168
+ action = validate_action( ARGV[0] )
169
+
170
+ # Find parameters where extension attributes are true then remove them from the
171
+ # cfn template since we can't pass it to CloudFormation.
172
+ excised_parameters = template.excise_parameter_attributes!([:Immutable, :UsePreviousValue])
173
+
174
+ # Tag CloudFormation stacks based on :Tags defined in the template.
175
+ # Remove them from the template as well, so that the template is valid.
176
+ cfn_tags = template.excise_tags!
177
+
178
+ # Find tags where extension attribute `:Immutable` is true then remove it from the
179
+ # tag's properties hash since it can't be passed to CloudFormation.
180
+ immutable_tags = template.get_tag_attribute(cfn_tags, :Immutable)
181
+
182
+ cfn_tags.each {|k, v| cfn_tags[k] = v[:Value].to_s}
183
+
184
+ template_string = generate_template(template)
185
+
186
+ # Derive stack name from ARGV
187
+ _, options = extract_options(ARGV[1..-1], %w(--nopretty), %w(--profile --stack-name --region --parameters --tag --s3-bucket))
188
+ # If the first argument is not an option and stack_name is undefined, assume it's the stack name
189
+ # The second argument, if present, is the resource name used by the describe-resource command
190
+ if template.stack_name.nil?
191
+ stack_name = options.shift if options[0] && !(/^-/ =~ options[0])
192
+ resource_name = options.shift if options[0] && !(/^-/ =~ options[0])
193
+ else
194
+ stack_name = template.stack_name
195
+ end
196
+
197
+ case action
198
+ when 'help'
199
+ begin
200
+ # Give some basic usage.
201
+ help_string=%q(
202
+ ## Usage
203
+
204
+ To convert existing JSON templates to use the DSL, run
205
+
206
+ cfntemplate-to-ruby [EXISTING_CFN] > [NEW_NAME.rb]
207
+
208
+ You may need to preface this with `bundle exec` if you installed via Bundler.
209
+
210
+ Make the resulting file executable (`chmod +x [NEW_NAME.rb]`). It can respond to the following subcommands (which are listed if you run without parameters):
211
+ - `expand`: output the JSON template to the command line (takes optional `--nopretty` to minimize the output)
212
+ - `diff`: compare an existing stack with your template. Produces following exit codes:
213
+ ```
214
+ 0 - no differences, nothing to update
215
+ 1 - stack does not exist, template Validation error
216
+ 2 - there are differences between an existing stack and your template
217
+ ```
218
+ - `validate`: run validation against the stack definition
219
+ - `create`: create a new stack from the output
220
+ - `update`: update an existing stack from the output. Produces following exit codes:
221
+ ```
222
+ 0 - update finished successfully
223
+ 1 - no updates to perform, stack doesn't exist, unable to update immutable parameter or tag, AWS ServiceError exception
224
+ ```
225
+ - `cancel-update`: cancel updating a stack
226
+ - `delete`: delete a stack (with prompt)
227
+ - `describe`: get output of an existing stack and output it (takes optional `--nopretty` to minimize output)
228
+ - `describe-resource`: given two arguments: stack-name and logical-resource-id, get output from a stack concerning the specific resource (takes optional `--nopretty` to minimize output)
229
+ - `get-template`: get entire template output of an existing stack
230
+
231
+ Command line options similar to cloudformation commands, but parsed by the dsl.
232
+ --profile --stack-name --region --parameters --tag --s3-bucket
233
+
234
+ Any other parameters are passed directly onto cloudformation. (--disable-rollback for instance)
235
+
236
+ Using the ruby scripts:
237
+ template.rb create --stack-name my_stack --parameters "BucketName=bucket-s3-static;SnsQueue=mysnsqueue"
238
+
239
+ )
240
+ puts help_string
241
+ exit(true)
242
+ end
243
+
244
+ when 'expand'
245
+ # Write the pretty-printed JSON template to stdout and exit. [--nopretty] option writes output with minimal whitespace
246
+ # example: <template.rb> expand --parameters "Env=prod" --region eu-west-1 --nopretty
247
+ template_string
248
+
249
+ when 'diff'
250
+ # example: <template.rb> diff my-stack-name --parameters "Env=prod" --region eu-west-1
251
+ # Diff the current template for an existing stack with the expansion of this template.
252
+
253
+ # `diff` operation exit codes are:
254
+ # 0 - no differences are found. Outputs nothing to make it easy to use the output of the diff call from within other scripts.
255
+ # 1 - produced by any ValidationError exception (e.g. "Stack with id does not exist")
256
+ # 2 - there are changes to update (tags, params, template)
257
+ # If you want output of the entire file, simply use this option with a large number, i.e., -U 10000
258
+ # In fact, this is what Diffy does by default; we just don't want that, and we can't support passing arbitrary options to diff
259
+ # because Diffy's "context" configuration is mutually exclusive with the configuration to pass arbitrary options to diff
260
+ if !options.include? '-U'
261
+ options.push('-U', '0')
262
+ end
263
+
264
+ # Ensure a stack name was provided
265
+ if stack_name.empty?
266
+ $stderr.puts "Error: a stack name is required"
267
+ exit(false)
268
+ end
269
+
270
+ # describe the existing stack
271
+ begin
272
+ old_template_body = cfn_client.get_template({stack_name: stack_name}).template_body
273
+ rescue Aws::CloudFormation::Errors::ValidationError => e
274
+ $stderr.puts "Error: #{e}"
275
+ exit(false)
276
+ end
277
+
278
+ # parse the string into a Hash, then convert back into a string; this is the only way Ruby JSON lets us pretty print a JSON string
279
+ old_template = JSON.pretty_generate(JSON.parse(old_template_body))
280
+ # there is only ever one stack, since stack names are unique
281
+ old_attributes = cfn_client.describe_stacks({stack_name: stack_name}).stacks[0]
282
+ old_tags = old_attributes.tags
283
+ old_parameters = Hash[old_attributes.parameters.map { |p| [p.parameter_key, p.parameter_value]}]
284
+
285
+ # Sort the tag strings alphabetically to make them easily comparable
286
+ old_tags_string = old_tags.map { |tag| %Q(TAG "#{tag.key}=#{tag.value}"\n) }.sort.join
287
+ tags_string = cfn_tags.map { |k, v| %Q(TAG "#{k.to_s}=#{v}"\n) }.sort.join
288
+
289
+ # Sort the parameter strings alphabetically to make them easily comparable
290
+ old_parameters_string = old_parameters.sort.map { |key, value| %Q(PARAMETER "#{key}=#{value}"\n) }.join
291
+ parameters_string = template.parameters.sort.map do |key, value|
292
+ value = old_parameters[key] if value.empty? && value.use_previous_value && !old_parameters[key].to_s.empty?
293
+ value = value.default if value.empty? && !value.default.to_s.empty?
294
+ "PARAMETER \"#{key}=#{value}\"\n"
295
+ end.join
296
+
297
+ # set default diff options
298
+ Diffy::Diff.default_options.merge!(
299
+ :diff => "#{options.join(' ')}",
300
+ )
301
+ # set default diff output
302
+ Diffy::Diff.default_format = :color
303
+
304
+ tags_diff = Diffy::Diff.new(old_tags_string, tags_string).to_s.strip!
305
+ params_diff = Diffy::Diff.new(old_parameters_string, parameters_string).to_s.strip!
306
+ template_diff = Diffy::Diff.new(old_template, template_string).to_s.strip!
307
+
308
+ if !tags_diff.empty?
309
+ puts "====== Tags ======"
310
+ puts tags_diff
311
+ puts "=================="
312
+ puts
313
+ end
314
+
315
+ if !params_diff.empty?
316
+ puts "====== Parameters ======"
317
+ puts params_diff
318
+ puts "========================"
319
+ puts
320
+ end
321
+
322
+ if !template_diff.empty?
323
+ puts "====== Template ======"
324
+ puts template_diff
325
+ puts "======================"
326
+ puts
327
+ end
328
+
329
+ if tags_diff.empty? && params_diff.empty? && template_diff.empty?
330
+ exit(true)
331
+ else
332
+ exit(2)
333
+ end
334
+
335
+ when 'validate'
336
+ begin
337
+ validation_payload = {}
338
+ if template.s3_bucket.nil? then
339
+ validation_payload = {template_body: template_string}
340
+ else
341
+ template_path = "#{Time.now.strftime("%s")}/#{stack_name}.json"
342
+ # assumption: JSON is the only supported serialization format (YAML not allowed)
343
+ template_url = "https://s3.amazonaws.com/#{template.s3_bucket}/#{template_path}"
344
+ begin
345
+ s3_client.put_object({
346
+ bucket: template.s3_bucket,
347
+ key: template_path,
348
+ # canned ACL for authorized users to read the bucket (that should be *this* IAM role!)
349
+ acl: "private",
350
+ body: template_string,
351
+ })
352
+ rescue Aws::S3::Errors::ServiceError => e
353
+ $stderr.puts "Failed to upload stack template to S3: #{e}"
354
+ exit(false)
355
+ end
356
+ validation_payload = {template_url: template_url}
357
+ end
358
+ valid = cfn_client.validate_template(validation_payload)
359
+ if valid.successful?
360
+ puts "Validation successful"
361
+ exit(true)
362
+ end
363
+ rescue Aws::CloudFormation::Errors::ValidationError => e
364
+ $stderr.puts "Validation error: #{e}"
365
+ exit(false)
366
+ end
367
+
368
+ when 'create'
369
+ begin
370
+
371
+ # Apply any default parameter values
372
+ apply_parameter_defaults(template.parameters)
373
+
374
+ # default options (not overridable)
375
+ create_stack_opts = {
376
+ stack_name: stack_name,
377
+ parameters: template.parameters.map { |k,v| {parameter_key: k, parameter_value: v}}.to_a,
378
+ tags: cfn_tags.map { |k,v| {"key" => k.to_s, "value" => v} }.to_a,
379
+ capabilities: ["CAPABILITY_NAMED_IAM"],
380
+ }
381
+
382
+ # If the user supplied the --s3-bucket option and
383
+ # access to the bucket, upload the template body to S3
384
+ if template.s3_bucket.nil? then
385
+ create_stack_opts["template_body"] = template_string
386
+ else
387
+ template_path = "#{Time.now.strftime("%s")}/#{stack_name}.json"
388
+ # assumption: JSON is the only supported serialization format (YAML not allowed)
389
+ template_url = "https://s3.amazonaws.com/#{template.s3_bucket}/#{template_path}"
390
+ begin
391
+ s3_client.put_object({
392
+ bucket: template.s3_bucket,
393
+ key: template_path,
394
+ # canned ACL for authorized users to read the bucket (that should be *this* IAM role!)
395
+ acl: "private",
396
+ body: template_string,
397
+ })
398
+ rescue Aws::S3::Errors::ServiceError => e
399
+ $stderr.puts "Failed to upload stack template to S3: #{e}"
400
+ exit(false)
401
+ end
402
+ create_stack_opts["template_url"] = template_url
403
+ end
404
+
405
+ # fill in options from the command line
406
+ extra_options = parse_arg_array_as_hash(options)
407
+ create_stack_opts = extra_options.merge(create_stack_opts)
408
+
409
+ # remove custom options
410
+ create_stack_opts.delete(:interactive)
411
+
412
+ # create stack
413
+ create_result = cfn_client.create_stack(create_stack_opts)
414
+ if create_result.successful?
415
+ puts create_result.stack_id
416
+ exit(true)
417
+ end
418
+ rescue Aws::CloudFormation::Errors::ServiceError => e
419
+ $stderr.puts "Failed to create stack: #{e}"
420
+ exit(false)
421
+ end
422
+
423
+ when 'cancel-update'
424
+ begin
425
+ cancel_update_result = cfn_client.cancel_update_stack({stack_name: stack_name})
426
+ if cancel_update_result.successful?
427
+ $stderr.puts "Canceled updating stack #{stack_name}."
428
+ exit(true)
429
+ end
430
+ rescue Aws::CloudFormation::Errors::ServiceError => e
431
+ $stderr.puts "Failed to cancel updating stack: #{e}"
432
+ exit(false)
433
+ end
434
+
435
+ when 'delete'
436
+ begin
437
+ if HighLine.agree("Really delete #{stack_name} in #{cfn_client.config.region}? [Y/n]")
438
+ delete_result = cfn_client.delete_stack({stack_name: stack_name})
439
+ if delete_result.successful?
440
+ $stderr.puts "Deleted stack #{stack_name}."
441
+ exit(true)
442
+ end
443
+ else
444
+ $stderr.puts "Canceled deleting stack #{stack_name}."
445
+ exit(true)
446
+ end
447
+ rescue Aws::CloudFormation::Errors::ServiceError => e
448
+ $stderr.puts "Failed to delete stack: #{e}"
449
+ exit(false)
450
+ end
451
+
452
+ when 'describe'
453
+ begin
454
+ describe_stack = cfn_client.describe_stacks({stack_name: stack_name})
455
+ describe_stack_resources = cfn_client.describe_stack_resources({stack_name: stack_name})
456
+ if describe_stack.successful? and describe_stack_resources.successful?
457
+ stacks = {}
458
+ stack_resources = {}
459
+ describe_stack_resources.stack_resources.each { |stack_resource|
460
+ if stack_resources[stack_resource.stack_name].nil?
461
+ stack_resources[stack_resource.stack_name] = []
462
+ end
463
+ stack_resources[stack_resource.stack_name].push({
464
+ logical_resource_id: stack_resource.logical_resource_id,
465
+ physical_resource_id: stack_resource.physical_resource_id,
466
+ resource_type: stack_resource.resource_type,
467
+ timestamp: stack_resource.timestamp,
468
+ resource_status: stack_resource.resource_status,
469
+ resource_status_reason: stack_resource.resource_status_reason,
470
+ description: stack_resource.description,
471
+ })
472
+ }
473
+ describe_stack.stacks.each { |stack| stacks[stack.stack_name] = stack.to_map.merge!({resources: stack_resources[stack.stack_name]}) }
474
+ unless template.nopretty
475
+ puts JSON.pretty_generate(stacks)
476
+ else
477
+ puts JSON.generate(stacks)
478
+ end
479
+ exit(true)
480
+ end
481
+ rescue Aws::CloudFormation::Errors::ServiceError => e
482
+ $stderr.puts "Failed describe stack #{stack_name}: #{e}"
483
+ exit(false)
484
+ end
485
+
486
+ when 'describe-resource'
487
+ begin
488
+ describe_stack_resource = cfn_client.describe_stack_resource({
489
+ stack_name: stack_name,
490
+ logical_resource_id: resource_name,
491
+ })
492
+ if describe_stack_resource.successful?
493
+ unless template.nopretty
494
+ puts JSON.pretty_generate(describe_stack_resource.stack_resource_detail)
495
+ else
496
+ puts JSON.generate(describe_stack_resource.stack_resource_detail)
497
+ end
498
+ exit(true)
499
+ end
500
+ rescue Aws::CloudFormation::Errors::ServiceError => e
501
+ $stderr.puts "Failed get stack resource details: #{e}"
502
+ exit(false)
503
+ end
504
+
505
+ when 'get-template'
506
+ begin
507
+ get_template_result = cfn_client.get_template({stack_name: stack_name})
508
+ template_body = JSON.parse(get_template_result.template_body)
509
+ if get_template_result.successful?
510
+ unless template.nopretty
511
+ puts JSON.pretty_generate(template_body)
512
+ else
513
+ puts JSON.generate(template_body)
514
+ end
515
+ exit(true)
516
+ end
517
+ rescue Aws::CloudFormation::Errors::ServiceError => e
518
+ $stderr.puts "Failed get stack template: #{e}"
519
+ exit(false)
520
+ end
521
+
522
+ when 'update'
523
+
524
+ # Run CloudFormation command to describe the existing stack
525
+ old_stack = cfn_client.describe_stacks({stack_name: stack_name}).stacks
526
+
527
+ # this might happen if, for example, stack_name is an empty string and the Cfn client returns ALL stacks
528
+ if old_stack.length > 1
529
+ $stderr.puts "Error: found too many stacks with this name. There should only be one."
530
+ exit(false)
531
+ else
532
+ # grab the first (and only) result
533
+ old_stack = old_stack[0]
534
+ end
535
+
536
+ # If updating a stack and some parameters or tags are marked as immutable, set the variable to true.
537
+ immutables_exist = nil
538
+
539
+ old_parameters = Hash[old_stack.parameters.map { |p| [p.parameter_key, p.parameter_value]}]
540
+ new_parameters = template.parameters
541
+ excised_parameters.each do |extension_attribute, parameters|
542
+ if !parameters.empty?
543
+ parameters.sort.each do |param|
544
+ if old_parameters[param] != new_parameters[param] && old_parameters.key?(param)
545
+ case extension_attribute
546
+ when :Immutable
547
+ if !excised_parameters[:UsePreviousValue].include?(param)
548
+ $stderr.puts "Error: unable to update immutable parameter " +
549
+ "'#{param}=#{old_parameters[param]}' to '#{param}=#{new_parameters[param]}'."
550
+ immutables_exist = true
551
+ end
552
+ when :UsePreviousValue
553
+ if !immutables_exist && new_parameters[param].empty?
554
+ $stderr.puts "Using previous parameter " +
555
+ "'#{param}=#{old_parameters[param]}'."
556
+ new_parameters[param] = Parameter.new(old_parameters[param])
557
+ new_parameters[param].use_previous_value = true
558
+ end
559
+ end
560
+ end
561
+ end
562
+ end
563
+ end
564
+
565
+ if not immutable_tags.empty?
566
+ old_cfn_tags = Hash[old_stack.tags.map { |t| [t.key, t.value]}]
567
+ cfn_tags_ary = Hash[cfn_tags.map { |k,v| [k, v]}]
568
+ immutable_tags.sort.each do |tag|
569
+ if old_cfn_tags[tag].to_s != cfn_tags_ary[tag].to_s && old_cfn_tags.key?(tag)
570
+ $stderr.puts "Error: unable to update immutable tag " +
571
+ "'#{tag}=#{old_cfn_tags[tag]}' to '#{tag}=#{cfn_tags_ary[tag]}'."
572
+ immutables_exist = true
573
+ end
574
+ end
575
+ end
576
+
577
+ # Fail if some parameters or tags were marked as immutable.
578
+ if immutables_exist
579
+ exit(false)
580
+ end
581
+
582
+ # Apply any default parameter values
583
+ apply_parameter_defaults(template.parameters)
584
+
585
+ # Compare the sorted arrays of parameters for an exact match and print difference.
586
+ old_parameters = old_stack.parameters.map { |p| [p.parameter_key, p.parameter_value]}.sort
587
+ new_parameters = template.parameters.sort
588
+ if new_parameters != old_parameters
589
+ puts "\nCloudFormation stack parameters that do not match and will be updated:" +
590
+ "\n" + (old_parameters - new_parameters).map {|param| "< #{param}" }.join("\n") +
591
+ "\n" + "---" +
592
+ "\n" + (new_parameters - old_parameters).map {|param| "> #{param}"}.join("\n")
593
+ end
594
+
595
+ # Compare the sorted arrays of tags for an exact match and print difference.
596
+ old_cfn_tags = old_stack.tags.map { |t| [t.key, t.value]}.sort
597
+ cfn_tags_ary = cfn_tags.map { |k,v| [k, v]}.sort
598
+ if cfn_tags_ary != old_cfn_tags
599
+ puts "\nCloudFormation stack tags that do not match and will be updated:" +
600
+ "\n" + (old_cfn_tags - cfn_tags_ary).map {|tag| "< #{tag}" }.join("\n") +
601
+ "\n" + "---" +
602
+ "\n" + (cfn_tags_ary - old_cfn_tags).map {|tag| "> #{tag}"}.join("\n")
603
+ end
604
+
605
+ # update the stack
606
+ begin
607
+
608
+ # default options (not overridable)
609
+ update_stack_opts = {
610
+ stack_name: stack_name,
611
+ parameters: template.parameters.map { |k,v| (v.use_previous_value && old_parameters.include?([k,v])) ? {parameter_key: k, use_previous_value: v.use_previous_value.to_s} : {parameter_key: k, parameter_value: v}}.to_a,
612
+ tags: cfn_tags.map { |k,v| {"key" => k.to_s, "value" => v.to_s} }.to_a,
613
+ capabilities: ["CAPABILITY_NAMED_IAM"],
614
+ }
615
+
616
+ # if the the user supplies a bucket bucket and
617
+ # access to it, upload the template body
618
+ if template.s3_bucket.nil? then
619
+ update_stack_opts["template_body"] = template_string
620
+ else
621
+ template_path = "#{Time.now.strftime("%s")}/#{stack_name}.json"
622
+ # assumption: JSON is the only supported serialization format (YAML not allowed)
623
+ template_url = "https://s3.amazonaws.com/#{template.s3_bucket}/#{template_path}"
624
+ s3_client.put_object({
625
+ bucket: template.s3_bucket,
626
+ key: template_path,
627
+ # canned ACL for authorized users to read the bucket (that should be *this* IAM role!)
628
+ acl: "private",
629
+ body: template_string,
630
+ })
631
+ update_stack_opts["template_url"] = template_url
632
+ end
633
+
634
+ # fill in options from the command line
635
+ extra_options = parse_arg_array_as_hash(options)
636
+ update_stack_opts = extra_options.merge(update_stack_opts)
637
+
638
+ # remove custom options
639
+ update_stack_opts.delete(:interactive)
640
+
641
+ # update the stack
642
+ update_result = cfn_client.update_stack(update_stack_opts)
643
+ if update_result.successful?
644
+ puts update_result.stack_id
645
+ exit(true)
646
+ end
647
+ rescue Aws::CloudFormation::Errors::ServiceError => e
648
+ $stderr.puts "Failed to update stack: #{e}"
649
+ exit(false)
650
+ end
651
+
652
+ end
653
+ end
654
+
655
+ # extract options and arguments from a command line string
656
+ #
657
+ # Example:
658
+ #
659
+ # desired, unknown = extract_options("arg1 --option withvalue --optionwithoutvalue", %w(--option), %w())
660
+ #
661
+ # puts desired => Array{"arg1", "--option", "withvalue"}
662
+ # puts unknown => Array{}
663
+ #
664
+ # @param args
665
+ # the Array of arguments (split the command line string by whitespace)
666
+ # @param opts_no_val
667
+ # the Array of options with no value, i.e., --force
668
+ # @param opts_1_val
669
+ # the Array of options with exaclty one value, i.e., --retries 3
670
+ # @returns
671
+ # an Array of two Arrays.
672
+ # The first array contains all the options that were extracted (both those with and without values) as a flattened enumerable.
673
+ # The second array contains all the options that were not extracted.
674
+ def extract_options(args, opts_no_val, opts_1_val)
675
+ args = args.clone
676
+ opts = []
677
+ rest = []
678
+ while (arg = args.shift) != nil
679
+ if opts_no_val.include?(arg)
680
+ opts.push(arg)
681
+ elsif opts_1_val.include?(arg)
682
+ opts.push(arg)
683
+ opts.push(arg) if (arg = args.shift) != nil
684
+ else
685
+ rest.push(arg)
686
+ end
687
+ end
688
+ [opts, rest]
689
+ end
690
+
691
+ # convert an array of option strings to a hash
692
+ # example input: ["--option", "value", "--optionwithnovalue"]
693
+ # example output: {:option => "value", :optionwithnovalue: true}
694
+ def parse_arg_array_as_hash(options)
695
+ result = {}
696
+ options.slice_before(/\A--[a-zA-Z_-]\S/).each { |o|
697
+ key = ((o[0].sub '--', '').gsub '-', '_').downcase.to_sym
698
+ value = if o.length > 1 then o.drop(1) else true end
699
+ value = value[0] if value.is_a?(Array) and value.length == 1
700
+ result[key] = value
701
+ }
702
+ result
703
+ end
704
+
705
+ # Apply the default value for any parameter not assigned by the user
706
+ def apply_parameter_defaults(parameters)
707
+ parameters.each do |k, v|
708
+ if v.empty?
709
+ parameters[k] = Parameter.new(v.default)
710
+ $stderr.puts "Using default parameter value " +
711
+ "'#{k}=#{parameters[k]}'."
712
+ end
713
+ end
714
+ end
715
+
716
+ ##################################### Additional dsl logic
717
+ # Core interpreter for the DSL
718
+ class TemplateDSL < JsonObjectDSL
719
+ def exec!
720
+ puts cfn(self)
721
+ end
722
+ def exec
723
+ cfn(self)
724
+ end
725
+ end
726
+
727
+ # Main entry point
728
+ def template(&block)
729
+ options = parse_args
730
+ raw_template(options, &block)
731
+ end