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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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