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