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.
- checksums.yaml +7 -0
- data/.gitignore +30 -0
- data/.travis.yml +10 -0
- data/Gemfile +4 -0
- data/LICENSE +13 -0
- data/OWNERS +3 -0
- data/PULL_REQUEST_TEMPLATE.md +30 -0
- data/README.md +128 -0
- data/Rakefile +9 -0
- data/bin/cfntemplate-to-ruby +347 -0
- data/cloudformation-ruby-dsl.gemspec +46 -0
- data/docs/Contributing.md +22 -0
- data/docs/Releasing.md +20 -0
- data/examples/cloudformation-ruby-script.rb +274 -0
- data/examples/maps/domains.txt +4 -0
- data/examples/maps/map.json +9 -0
- data/examples/maps/map.rb +5 -0
- data/examples/maps/map.yaml +5 -0
- data/examples/maps/more_maps/map1.json +8 -0
- data/examples/maps/more_maps/map2.json +8 -0
- data/examples/maps/more_maps/map3.json +8 -0
- data/examples/maps/table.txt +5 -0
- data/examples/maps/vpc.txt +25 -0
- data/examples/simple_template.rb +21 -0
- data/examples/userdata.sh +4 -0
- data/initial_contributions.md +5 -0
- data/lib/cloudformation-ruby-dsl/cfntemplate.rb +731 -0
- data/lib/cloudformation-ruby-dsl/dsl.rb +380 -0
- data/lib/cloudformation-ruby-dsl/spotprice.rb +50 -0
- data/lib/cloudformation-ruby-dsl/table.rb +123 -0
- data/lib/cloudformation-ruby-dsl/version.rb +21 -0
- data/lib/cloudformation-ruby-dsl.rb +2 -0
- data/spec/spec_helper.rb +161 -0
- data/spec/validation_spec.rb +26 -0
- metadata +234 -0
@@ -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
|