cloudformation-ruby-dsl 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +29 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +201 -0
- data/README.md +68 -0
- data/Rakefile +1 -0
- data/bin/cfntemplate-to-ruby +327 -0
- data/cloudformation-ruby-dsl.gemspec +36 -0
- data/examples/cloudformation-ruby-script.rb +195 -0
- data/examples/maps/json_map.json +9 -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/ruby_map.rb +5 -0
- data/examples/maps/text_table.txt +5 -0
- data/examples/maps/yaml_map.yaml +5 -0
- data/examples/userdata.sh +2 -0
- data/initial_contributions.md +3 -0
- data/lib/cloudformation-ruby-dsl.rb +2 -0
- data/lib/cloudformation-ruby-dsl/cfntemplate.rb +406 -0
- data/lib/cloudformation-ruby-dsl/spotprice.rb +50 -0
- data/lib/cloudformation-ruby-dsl/table.rb +93 -0
- data/lib/cloudformation-ruby-dsl/version.rb +21 -0
- metadata +100 -0
@@ -0,0 +1,36 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
# Copyright 2013-2014 Bazaarvoice, Inc.
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
lib = File.expand_path('../lib', __FILE__)
|
18
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
19
|
+
require 'cloudformation-ruby-dsl/version'
|
20
|
+
|
21
|
+
Gem::Specification.new do |gem|
|
22
|
+
gem.name = "cloudformation-ruby-dsl"
|
23
|
+
gem.version = Cfn::Ruby::Dsl::VERSION
|
24
|
+
gem.authors = ["Shawn Smith", "Dave Barcelo", "Morgan Fletcher", "Csongor Gyuricza", "Igor Polishchuk", "Nathaniel Eliot", "Jona Fenocchi", "Tony Cui"]
|
25
|
+
gem.email = ["Shawn.Smith@bazaarvoice.com", "Dave.Barcelo@bazaarvoice.com", "Morgan.Fletcher@bazaarvoice.com", "Csongor.Gyuricza@bazaarvoice.com", "Igor.Polishchuk@bazaarvoice.com", "Nathaniel.Eliot@bazaarvoice.com", "Jona.Fenocchi@bazaarvoice.com", "Tony.Cui@bazaarvoice.com"]
|
26
|
+
gem.description = %q{Ruby DSL library that provides a wrapper around the cfn-cmd.}
|
27
|
+
gem.summary = %q{Ruby DSL library that provides a wrapper around the cfn-cmd. Written by [Bazaarvoice](http://www.bazaarvoice.com).}
|
28
|
+
gem.homepage = "http://github.com/bazaarvoice/cloudformation-ruby-dsl"
|
29
|
+
|
30
|
+
gem.files = `git ls-files`.split($/)
|
31
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
32
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
33
|
+
gem.require_paths = %w{lib bin}
|
34
|
+
|
35
|
+
gem.add_runtime_dependency 'detabulator'
|
36
|
+
end
|
@@ -0,0 +1,195 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# Copyright 2013-2014 Bazaarvoice, Inc.
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
require 'bundler/setup'
|
18
|
+
require 'cloudformation-ruby-dsl/cfntemplate'
|
19
|
+
require 'cloudformation-ruby-dsl/table'
|
20
|
+
|
21
|
+
# Note: this is only intended to demonstrate the cloudformation-ruby-dsl. It compiles
|
22
|
+
# and validates correctly, but won't produce a viable Cloudformation stack.
|
23
|
+
|
24
|
+
template do
|
25
|
+
|
26
|
+
parameter 'Label',
|
27
|
+
:Description => 'The label to apply to the servers.',
|
28
|
+
:Type => 'String',
|
29
|
+
:MinLength => '2',
|
30
|
+
:MaxLength => '25',
|
31
|
+
:AllowedPattern => '[_a-zA-Z0-9]*',
|
32
|
+
:ConstraintDescription => 'Maximum length of the Label parameter may not exceed 25 characters and may only contain letters, numbers and underscores.',
|
33
|
+
# The :Immutable attribute is a Ruby CFN extension. It affects the behavior of the '<template> cfn-update-stack ...'
|
34
|
+
# operation in that a stack update may not change the values of parameters marked w/:Immutable => true.
|
35
|
+
:Immutable => true
|
36
|
+
|
37
|
+
parameter 'InstanceType',
|
38
|
+
:Description => 'EC2 instance type',
|
39
|
+
:Type => 'String',
|
40
|
+
:Default => 'm2.xlarge',
|
41
|
+
:AllowedValues => %w(t1.micro m1.small m1.medium m1.large m1.xlarge m2.xlarge m2.2xlarge m2.4xlarge c1.medium c1.xlarge),
|
42
|
+
:ConstraintDescription => 'Must be a valid EC2 instance type.'
|
43
|
+
|
44
|
+
parameter 'ImageId',
|
45
|
+
:Description => 'EC2 Image ID',
|
46
|
+
:Type => 'String',
|
47
|
+
:Default => 'ami-255bbc4c',
|
48
|
+
:AllowedPattern => 'ami-[a-f0-9]{8}',
|
49
|
+
:ConstraintDescription => 'Must be ami-XXXXXXXX (where X is a hexadecimal digit)'
|
50
|
+
|
51
|
+
parameter 'KeyPairName',
|
52
|
+
:Description => 'Name of KeyPair to use.',
|
53
|
+
:Type => 'String',
|
54
|
+
:MinLength => '1',
|
55
|
+
:MaxLength => '40',
|
56
|
+
:Default => parameters['Label']
|
57
|
+
|
58
|
+
parameter 'EmailAddress',
|
59
|
+
:Type => 'String',
|
60
|
+
:Description => 'Email address at which to send notification events.'
|
61
|
+
|
62
|
+
mapping 'InlineExampleMap',
|
63
|
+
:team1 => {
|
64
|
+
:name => 'test1',
|
65
|
+
:email => 'test1@example.com',
|
66
|
+
},
|
67
|
+
:team2 => {
|
68
|
+
:name => 'test2',
|
69
|
+
:email => 'test2@example.com',
|
70
|
+
}
|
71
|
+
|
72
|
+
# Generates mappings from external files with various formats.
|
73
|
+
mapping 'JsonExampleMap', 'maps/json_map.json'
|
74
|
+
|
75
|
+
mapping 'RubyExampleMap', 'maps/ruby_map.rb'
|
76
|
+
|
77
|
+
mapping 'YamlExampleMap', 'maps/yaml_map.yaml'
|
78
|
+
|
79
|
+
# Loads JSON mappings dynamically from example directory.
|
80
|
+
Dir.entries('maps/more_maps').each_with_index do |path, index|
|
81
|
+
next if path == "." or path == ".."
|
82
|
+
mapping "ExampleMap#{index - 1}", "maps/more_maps/#{path}"
|
83
|
+
end
|
84
|
+
|
85
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns a
|
86
|
+
# set of nested maps, where the key for the map at level n is the key at index n in the specified keys,
|
87
|
+
# except for the last key in the specified keys which is used to determine the value of the leaf-level map.
|
88
|
+
text = Table.load 'maps/text_table.txt'
|
89
|
+
mapping 'TableExampleMap',
|
90
|
+
text.get_map({ :column0 => 'foo' }, :column1, :column2, :column3)
|
91
|
+
|
92
|
+
# The tag type is a Ruby CFN extension. These tags are excised from the template and used to generate a series of --tag arguments
|
93
|
+
# which are passed to cfn-cmd. They do not ultimately appear in the expanded Cloudformation template. The diff subcommand will
|
94
|
+
# compare tags with the running stack and identify any changes, but cfn-update-stack will do the diff and throw an error on any
|
95
|
+
# changes. The tags are propagated to all resources created by the stack, including the stack itself.
|
96
|
+
#
|
97
|
+
# Amazon has set the following restrictions on CloudFormation tags:
|
98
|
+
# => limit 10
|
99
|
+
# => immutable (you may not cfn-update-stack with new tags or different values for existing tags -- they will be rejected)
|
100
|
+
#
|
101
|
+
# Additionally, cfn-cmd throws an error if your tag value contains spaces. This limitation will be lifted when we move from cfn-cmd
|
102
|
+
# to the new unified CLI.
|
103
|
+
tag :MyTag => 'MyValue'
|
104
|
+
|
105
|
+
resource 'SecurityGroup', :Type => 'AWS::EC2::SecurityGroup', :Properties => {
|
106
|
+
:GroupDescription => 'Lets any vpc traffic in.',
|
107
|
+
:SecurityGroupIngress => {:IpProtocol => '-1', :FromPort => '0', :ToPort => '65535', :CidrIp => "10.0.0.0/8"}
|
108
|
+
}
|
109
|
+
|
110
|
+
resource "ASG", :Type => 'AWS::AutoScaling::AutoScalingGroup', :Properties => {
|
111
|
+
:AvailabilityZones => 'us-east-1',
|
112
|
+
:HealthCheckType => 'EC2',
|
113
|
+
:LaunchConfigurationName => ref('LaunchConfig'),
|
114
|
+
:MinSize => 1,
|
115
|
+
:MaxSize => 5,
|
116
|
+
:NotificationConfiguration => {
|
117
|
+
:TopicARN => ref('EmailSNSTopic'),
|
118
|
+
:NotificationTypes => %w(autoscaling:EC2_INSTANCE_LAUNCH autoscaling:EC2_INSTANCE_LAUNCH_ERROR autoscaling:EC2_INSTANCE_TERMINATE autoscaling:EC2_INSTANCE_TERMINATE_ERROR),
|
119
|
+
},
|
120
|
+
:Tags => [
|
121
|
+
{
|
122
|
+
:Key => 'Name',
|
123
|
+
# Grabs a value in an external map file.
|
124
|
+
:Value => find_in_map('TableExampleMap', 'corge', 'grault'),
|
125
|
+
:PropagateAtLaunch => 'true',
|
126
|
+
},
|
127
|
+
{
|
128
|
+
:Key => 'Label',
|
129
|
+
:Value => parameters['Label'],
|
130
|
+
:PropagateAtLaunch => 'true',
|
131
|
+
}
|
132
|
+
],
|
133
|
+
}
|
134
|
+
|
135
|
+
resource 'EmailSNSTopic', :Type => 'AWS::SNS::Topic', :Properties => {
|
136
|
+
:Subscription => [
|
137
|
+
{
|
138
|
+
:Endpoint => ref('EmailAddress'),
|
139
|
+
:Protocol => 'email',
|
140
|
+
},
|
141
|
+
],
|
142
|
+
}
|
143
|
+
|
144
|
+
resource 'WaitConditionHandle', :Type => 'AWS::CloudFormation::WaitConditionHandle', :Properties => {}
|
145
|
+
|
146
|
+
resource 'WaitCondition', :Type => 'AWS::CloudFormation::WaitCondition', :DependsOn => 'ASG', :Properties => {
|
147
|
+
:Handle => ref('WaitConditionHandle'),
|
148
|
+
:Timeout => 1200,
|
149
|
+
:Count => "1"
|
150
|
+
}
|
151
|
+
|
152
|
+
resource 'LaunchConfig', :Type => 'AWS::AutoScaling::LaunchConfiguration', :Properties => {
|
153
|
+
:ImageId => parameters['ImageId'],
|
154
|
+
:KeyName => ref('KeyPairName'),
|
155
|
+
:IamInstanceProfile => ref('InstanceProfile'),
|
156
|
+
:InstanceType => ref('InstanceType'),
|
157
|
+
:InstanceMonitoring => 'false',
|
158
|
+
:SecurityGroups => [ref('SecurityGroup')],
|
159
|
+
:BlockDeviceMappings => [
|
160
|
+
{:DeviceName => '/dev/sdb', :VirtualName => 'ephemeral0'},
|
161
|
+
{:DeviceName => '/dev/sdc', :VirtualName => 'ephemeral1'},
|
162
|
+
{:DeviceName => '/dev/sdd', :VirtualName => 'ephemeral2'},
|
163
|
+
{:DeviceName => '/dev/sde', :VirtualName => 'ephemeral3'},
|
164
|
+
],
|
165
|
+
# Loads an external userdata script.
|
166
|
+
:UserData => base64(interpolate(file('userdata.sh'))),
|
167
|
+
}
|
168
|
+
|
169
|
+
resource 'InstanceProfile', :Type => 'AWS::IAM::InstanceProfile', :Properties => {
|
170
|
+
:Path => '/',
|
171
|
+
:Roles => [ ref('InstanceRole') ],
|
172
|
+
}
|
173
|
+
|
174
|
+
resource 'InstanceRole', :Type => 'AWS::IAM::Role', :Properties => {
|
175
|
+
:AssumeRolePolicyDocument => {
|
176
|
+
:Statement => [
|
177
|
+
{
|
178
|
+
:Effect => 'Allow',
|
179
|
+
:Principal => { :Service => [ 'ec2.amazonaws.com' ] },
|
180
|
+
:Action => [ 'sts:AssumeRole' ],
|
181
|
+
},
|
182
|
+
],
|
183
|
+
},
|
184
|
+
:Path => '/',
|
185
|
+
}
|
186
|
+
|
187
|
+
output 'EmailSNSTopicARN',
|
188
|
+
:Value => ref('EmailSNSTopic'),
|
189
|
+
:Description => 'ARN of SNS Topic used to send emails on events.'
|
190
|
+
|
191
|
+
output 'MappingLookup',
|
192
|
+
:Value => find_in_map('TableExampleMap', 'corge', 'grault'),
|
193
|
+
:Description => 'An example map lookup.'
|
194
|
+
|
195
|
+
end.exec!
|
@@ -0,0 +1,3 @@
|
|
1
|
+
Several people were instrumental in building this project during its incubation stage. Because the process of open sourcing involved squashing the repository to remove confidential information, some of their contributions have been lost from the history that Github displays.
|
2
|
+
|
3
|
+
- [Nathaniel Eliot](https://github.com/temujin9): converted from raw library to gem, refactored table code, cleaned and prepared code for open sourcing
|
@@ -0,0 +1,406 @@
|
|
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
|
+
unless RUBY_VERSION >= '1.9'
|
16
|
+
# This script uses Ruby 1.9 functions such as Enumerable.slice_before and Enumerable.chunk
|
17
|
+
$stderr.puts "This script requires ruby 1.9+. On OS/X use Homebrew to install ruby 1.9:"
|
18
|
+
$stderr.puts " brew install ruby"
|
19
|
+
exit(2)
|
20
|
+
end
|
21
|
+
|
22
|
+
require 'rubygems'
|
23
|
+
require 'csv'
|
24
|
+
require 'json'
|
25
|
+
require 'yaml'
|
26
|
+
require 'erb'
|
27
|
+
|
28
|
+
# A custom converter to replace the String "(nil)" with NilClass
|
29
|
+
CSV::Converters[:nil_to_nil] = lambda do |field|
|
30
|
+
field && field == '(nil)' ? nil : field
|
31
|
+
end
|
32
|
+
|
33
|
+
############################# Command-line and "cfn-cmd" Support
|
34
|
+
|
35
|
+
# Parse command-line arguments based on cfn-cmd syntax (cfn-create-stack etc.) and return the parameters and region
|
36
|
+
def cfn_parse_args
|
37
|
+
parameters = {}
|
38
|
+
region = 'us-east-1'
|
39
|
+
ARGV.slice_before(/^--/).each do |name, value|
|
40
|
+
next unless value
|
41
|
+
case name
|
42
|
+
when '--parameters'
|
43
|
+
parameters = Hash[value.split(/;/).map { |pair| pair.split(/=/, 2) }]
|
44
|
+
when '--region'
|
45
|
+
region = value
|
46
|
+
end
|
47
|
+
end
|
48
|
+
[parameters, region]
|
49
|
+
end
|
50
|
+
|
51
|
+
def cfn_cmd(template)
|
52
|
+
action = ARGV[0]
|
53
|
+
unless %w(expand diff cfn-validate-template cfn-create-stack cfn-update-stack).include? action
|
54
|
+
$stderr.puts "usage: #{$PROGRAM_NAME} <expand|diff|cfn-validate-template|cfn-create-stack|cfn-update-stack>"
|
55
|
+
exit(2)
|
56
|
+
end
|
57
|
+
unless (ARGV & %w(--template-file --template-url)).empty?
|
58
|
+
$stderr.puts "#{File.basename($PROGRAM_NAME)}: The --template-file and --template-url command-line options are not allowed."
|
59
|
+
exit(2)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Find parameters where extension attribute :Immutable is true then remove it from the
|
63
|
+
# cfn template since we can't pass it to CloudFormation.
|
64
|
+
immutable_parameters = template.excise_parameter_attribute!(:Immutable)
|
65
|
+
|
66
|
+
# Tag CloudFormation stacks based on :Tags defined in the template
|
67
|
+
cfn_tags = template.excise_tags!
|
68
|
+
# The command line string looks like: --tag "Key=key; Value=value" --tag "Key2=key2; Value2=value"
|
69
|
+
cfn_tags_options = cfn_tags.sort.map { |tag| ["--tag", "Key=%s; Value=%s" % tag.split('=')] }.flatten
|
70
|
+
|
71
|
+
template_string = JSON.pretty_generate(template)
|
72
|
+
|
73
|
+
# example: <template.rb> cfn-create-stack my-stack-name --parameters "Env=prod" --region eu-west-1
|
74
|
+
# Execute the AWS CLI cfn-cmd command to validate/create/update a CloudFormation stack.
|
75
|
+
temp_file = File.absolute_path("#{$PROGRAM_NAME}.expanded.json")
|
76
|
+
File.write(temp_file, template_string)
|
77
|
+
|
78
|
+
cmdline = ['cfn-cmd'] + ARGV + ['--template-file', temp_file] + cfn_tags_options
|
79
|
+
|
80
|
+
case action
|
81
|
+
when 'expand'
|
82
|
+
# Write the pretty-printed JSON template to stdout.
|
83
|
+
# example: <template.rb> expand --parameters "Env=prod" --region eu-west-1
|
84
|
+
puts template_string
|
85
|
+
|
86
|
+
exit(true)
|
87
|
+
|
88
|
+
when 'diff'
|
89
|
+
# example: <template.rb> diff my-stack-name --parameters "Env=prod" --region eu-west-1
|
90
|
+
# Diff the current template for an existing stack with the expansion of this template.
|
91
|
+
|
92
|
+
# The --parameters and --tag options were used to expand the template but we don't need them anymore. Discard.
|
93
|
+
_, cfn_options = extract_options(ARGV[1..-1], %w(), %w(--parameters --tag))
|
94
|
+
|
95
|
+
# Separate the remaining command-line options into options for 'cfn-cmd' and options for 'diff'.
|
96
|
+
cfn_options, diff_options = extract_options(cfn_options, %w(),
|
97
|
+
%w(--stack-name --region --parameters --connection-timeout -I --access-key-id -S --secret-key -K --ec2-private-key-file-path -U --url))
|
98
|
+
|
99
|
+
# If the first argument is a stack name then shift it from diff_options over to cfn_options.
|
100
|
+
if diff_options[0] && !(/^-/ =~ diff_options[0])
|
101
|
+
cfn_options.unshift(diff_options.shift)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Run CloudFormation commands to describe the existing stack
|
105
|
+
cfn_options_string = cfn_options.map { |arg| "'#{arg}'" }.join(' ')
|
106
|
+
old_template_string = exec_capture_stdout("cfn-cmd cfn-get-template #{cfn_options_string}")
|
107
|
+
old_stack_attributes = exec_describe_stack(cfn_options_string)
|
108
|
+
old_tags_string = old_stack_attributes["TAGS"]
|
109
|
+
old_parameters_string = old_stack_attributes["PARAMETERS"]
|
110
|
+
|
111
|
+
# Sort the tag strings alphabetically to make them easily comparable
|
112
|
+
old_tags_string = (old_tags_string || '').split(';').sort.map { |tag| %Q(TAG "#{tag}"\n) }.join
|
113
|
+
tags_string = cfn_tags.sort.map { |tag| "TAG \"#{tag}\"\n" }.join
|
114
|
+
|
115
|
+
# Sort the parameter strings alphabetically to make them easily comparable
|
116
|
+
old_parameters_string = (old_parameters_string || '').split(';').sort.map { |param| %Q(PARAMETER "#{param}"\n) }.join
|
117
|
+
parameters_string = template.parameters.sort.map { |key, value| "PARAMETER \"#{key}=#{value}\"\n" }.join
|
118
|
+
|
119
|
+
# Diff the expanded template with the template from CloudFormation.
|
120
|
+
old_temp_file = File.absolute_path("#{$PROGRAM_NAME}.current.json")
|
121
|
+
new_temp_file = File.absolute_path("#{$PROGRAM_NAME}.expanded.json")
|
122
|
+
File.write(old_temp_file, old_tags_string + old_parameters_string + old_template_string)
|
123
|
+
File.write(new_temp_file, tags_string + parameters_string + %Q(TEMPLATE "#{template_string}\n"\n))
|
124
|
+
|
125
|
+
# Compare templates
|
126
|
+
system(*["diff"] + diff_options + [old_temp_file, new_temp_file])
|
127
|
+
|
128
|
+
File.delete(old_temp_file)
|
129
|
+
File.delete(new_temp_file)
|
130
|
+
|
131
|
+
exit(true)
|
132
|
+
|
133
|
+
when 'cfn-validate-template'
|
134
|
+
# The cfn-validate-template command doesn't support --parameters so remove it if it was provided for template expansion.
|
135
|
+
_, cmdline = extract_options(cmdline, %w(), %w(--parameters --tag))
|
136
|
+
|
137
|
+
when 'cfn-update-stack'
|
138
|
+
# Pick out the subset of cfn-update-stack options that apply to cfn-describe-stacks.
|
139
|
+
cfn_options, other_options = extract_options(ARGV[1..-1], %w(),
|
140
|
+
%w(--stack-name --region --connection-timeout -I --access-key-id -S --secret-key -K --ec2-private-key-file-path -U --url))
|
141
|
+
|
142
|
+
# If the first argument is a stack name then shift it over to cfn_options.
|
143
|
+
if other_options[0] && !(/^-/ =~ other_options[0])
|
144
|
+
cfn_options.unshift(other_options.shift)
|
145
|
+
end
|
146
|
+
|
147
|
+
# Run CloudFormation command to describe the existing stack
|
148
|
+
cfn_options_string = cfn_options.map { |arg| "'#{arg}'" }.join(' ')
|
149
|
+
old_stack_attributes = exec_describe_stack(cfn_options_string)
|
150
|
+
|
151
|
+
# If updating a stack and some parameters are marked as immutable, fail if the new parameters don't match the old ones.
|
152
|
+
if not immutable_parameters.empty?
|
153
|
+
old_parameters_string = old_stack_attributes["PARAMETERS"]
|
154
|
+
old_parameters = Hash[(old_parameters_string || '').split(';').map { |pair| pair.split('=', 2) }]
|
155
|
+
new_parameters = template.parameters
|
156
|
+
|
157
|
+
immutable_parameters.sort.each do |param|
|
158
|
+
if old_parameters[param].to_s != new_parameters[param].to_s
|
159
|
+
$stderr.puts "Error: cfn-update-stack may not update immutable parameter " +
|
160
|
+
"'#{param}=#{old_parameters[param]}' to '#{param}=#{new_parameters[param]}'."
|
161
|
+
exit(false)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Tags are immutable in CloudFormation. The cfn-update-stack command doesn't support --tag options, so remove
|
167
|
+
# the argument (if it exists) and validate against the existing stack to ensure tags haven't changed.
|
168
|
+
# Compare the sorted arrays for an exact match
|
169
|
+
old_cfn_tags = old_stack_attributes['TAGS'].split(';').sort rescue [] # Use empty Array if .split fails
|
170
|
+
if cfn_tags != old_cfn_tags
|
171
|
+
$stderr.puts "CloudFormation stack tags do not match and cannot be updated. You must either use the same tags or create a new stack." +
|
172
|
+
"\n" + (old_cfn_tags - cfn_tags).map {|tag| "< #{tag}" }.join("\n") +
|
173
|
+
"\n" + "---" +
|
174
|
+
"\n" + (cfn_tags - old_cfn_tags).map {|tag| "> #{tag}"}.join("\n")
|
175
|
+
exit(false)
|
176
|
+
end
|
177
|
+
_, cmdline = extract_options(cmdline, %w(), %w(--tag))
|
178
|
+
end
|
179
|
+
|
180
|
+
# Execute command cmdline
|
181
|
+
unless system(*cmdline)
|
182
|
+
$stderr.puts "\nExecution of 'cfn-cmd' failed. To facilitate debugging, the generated JSON template " +
|
183
|
+
"file was not deleted. You may delete the file manually if it isn't needed: #{temp_file}"
|
184
|
+
exit(false)
|
185
|
+
end
|
186
|
+
|
187
|
+
File.delete(temp_file)
|
188
|
+
|
189
|
+
exit(true)
|
190
|
+
end
|
191
|
+
|
192
|
+
def exec_describe_stack cfn_options_string
|
193
|
+
csv_data = exec_capture_stdout("cfn-cmd cfn-describe-stacks #{cfn_options_string} --headers --show-long")
|
194
|
+
CSV.parse_line(csv_data, :headers => true, :converters => :nil_to_nil)
|
195
|
+
end
|
196
|
+
|
197
|
+
def exec_capture_stdout command
|
198
|
+
stdout = `#{command}`
|
199
|
+
unless $?.success?
|
200
|
+
$stderr.puts stdout unless stdout.empty? # cfn-cmd sometimes writes error messages to stdout
|
201
|
+
exit(false)
|
202
|
+
end
|
203
|
+
stdout
|
204
|
+
end
|
205
|
+
|
206
|
+
def extract_options(args, opts_no_val, opts_1_val)
|
207
|
+
args = args.clone
|
208
|
+
opts = []
|
209
|
+
rest = []
|
210
|
+
while (arg = args.shift) != nil
|
211
|
+
if opts_no_val.include?(arg)
|
212
|
+
opts.push(arg)
|
213
|
+
elsif opts_1_val.include?(arg)
|
214
|
+
opts.push(arg)
|
215
|
+
opts.push(arg) if (arg = args.shift) != nil
|
216
|
+
else
|
217
|
+
rest.push(arg)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
[opts, rest]
|
221
|
+
end
|
222
|
+
|
223
|
+
############################# Generic DSL
|
224
|
+
|
225
|
+
class JsonObjectDSL
|
226
|
+
def initialize(&block)
|
227
|
+
@dict = {}
|
228
|
+
instance_eval &block
|
229
|
+
end
|
230
|
+
|
231
|
+
def value(values)
|
232
|
+
@dict.update(values)
|
233
|
+
end
|
234
|
+
|
235
|
+
def default(key, value)
|
236
|
+
@dict[key] ||= value
|
237
|
+
end
|
238
|
+
|
239
|
+
def to_json(*args)
|
240
|
+
@dict.to_json(*args)
|
241
|
+
end
|
242
|
+
|
243
|
+
def print()
|
244
|
+
puts JSON.pretty_generate(self)
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
############################# CloudFormation DSL
|
249
|
+
|
250
|
+
# Main entry point
|
251
|
+
def template(&block)
|
252
|
+
TemplateDSL.new(&block)
|
253
|
+
end
|
254
|
+
|
255
|
+
# Core interpreter for the DSL
|
256
|
+
class TemplateDSL < JsonObjectDSL
|
257
|
+
attr_reader :parameters, :aws_region
|
258
|
+
|
259
|
+
def initialize()
|
260
|
+
@parameters, @aws_region = cfn_parse_args
|
261
|
+
super
|
262
|
+
end
|
263
|
+
|
264
|
+
def exec!()
|
265
|
+
cfn_cmd(self)
|
266
|
+
end
|
267
|
+
|
268
|
+
def parameter(name, options)
|
269
|
+
default(:Parameters, {})[name] = options
|
270
|
+
@parameters[name] ||= options[:Default]
|
271
|
+
end
|
272
|
+
|
273
|
+
# Find parameters where the specified attribute is true then remove the attribute from the cfn template.
|
274
|
+
def excise_parameter_attribute!(attribute)
|
275
|
+
marked_parameters = []
|
276
|
+
@dict.fetch(:Parameters, {}).each do |param, options|
|
277
|
+
if options.delete(attribute.to_sym) or options.delete(attribute.to_s)
|
278
|
+
marked_parameters << param
|
279
|
+
end
|
280
|
+
end
|
281
|
+
marked_parameters
|
282
|
+
end
|
283
|
+
|
284
|
+
def mapping(name, options)
|
285
|
+
# if options is a string and a valid file then the script will process the external file.
|
286
|
+
default(:Mappings, {})[name] = \
|
287
|
+
if options.is_a?(Hash); options
|
288
|
+
elsif options.is_a?(String); load_from_file(options)['Mappings']
|
289
|
+
else; raise("Options for mapping #{name} is neither a string or a hash. Error!")
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
def load_from_file(filename)
|
294
|
+
file = File.open(filename)
|
295
|
+
|
296
|
+
begin
|
297
|
+
# Figure out what the file extension is and process accordingly.
|
298
|
+
contents = case File.extname(filename)
|
299
|
+
when ".rb"; eval(file.read)
|
300
|
+
when ".json"; JSON.load(file)
|
301
|
+
when ".yaml"; YAML::load(file)
|
302
|
+
else; raise("Do not recognize extension of #{filename}.")
|
303
|
+
end
|
304
|
+
ensure
|
305
|
+
file.close
|
306
|
+
end
|
307
|
+
contents
|
308
|
+
end
|
309
|
+
|
310
|
+
def excise_tags!
|
311
|
+
tags = []
|
312
|
+
@dict.fetch(:Tags, {}).each do | tag_name, tag_value |
|
313
|
+
tags << "#{tag_name}=#{tag_value}"
|
314
|
+
end
|
315
|
+
@dict.delete(:Tags)
|
316
|
+
tags
|
317
|
+
end
|
318
|
+
|
319
|
+
def tag(tag)
|
320
|
+
tag.each do | name, value |
|
321
|
+
default(:Tags, {})[name] = value
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
def condition(name, options) default(:Conditions, {})[name] = options end
|
326
|
+
|
327
|
+
def resource(name, options) default(:Resources, {})[name] = options end
|
328
|
+
|
329
|
+
def output(name, options) default(:Outputs, {})[name] = options end
|
330
|
+
|
331
|
+
def find_in_map(map, key, name)
|
332
|
+
# Eagerly evaluate mappings when all keys are known at template expansion time
|
333
|
+
if map.is_a?(String) && key.is_a?(String) && name.is_a?(String)
|
334
|
+
# We don't know whether the map was built with string keys or symbol keys. Try both.
|
335
|
+
def get(map, key) map[key] || map.fetch(key.to_sym) end
|
336
|
+
get(get(@dict.fetch(:Mappings).fetch(map), key), name)
|
337
|
+
else
|
338
|
+
{ :'Fn::FindInMap' => [ map, key, name ] }
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
def base64(value) { :'Fn::Base64' => value } end
|
344
|
+
|
345
|
+
def find_in_map(map, key, name) { :'Fn::FindInMap' => [ map, key, name ] } end
|
346
|
+
|
347
|
+
def get_att(resource, attribute) { :'Fn::GetAtt' => [ resource, attribute ] } end
|
348
|
+
|
349
|
+
def get_azs(region = '') { :'Fn::GetAZs' => region } end
|
350
|
+
|
351
|
+
def join(delim, *list)
|
352
|
+
case list.length
|
353
|
+
when 0 then ''
|
354
|
+
when 1 then list[0]
|
355
|
+
else join_list(delim,list)
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Variant of join that matches the native CFN syntax.
|
360
|
+
def join_list(delim, list) { :'Fn::Join' => [ delim, list ] } end
|
361
|
+
|
362
|
+
def select(index, list) { :'Fn::Select' => [ index, list ] } end
|
363
|
+
|
364
|
+
def ref(name) { :Ref => name } end
|
365
|
+
|
366
|
+
def no_value() ref("AWS::NoValue") end
|
367
|
+
|
368
|
+
# Read the specified file and return its value as a string literal
|
369
|
+
def file(filename) File.read(File.absolute_path(filename, File.dirname($PROGRAM_NAME))) end
|
370
|
+
|
371
|
+
# Interpolates a string like "NAME={{ref('Service')}}" and returns a CloudFormation "Fn::Join"
|
372
|
+
# operation to collect the results. Anything between {{ and }} is interpreted as a Ruby expression
|
373
|
+
# and eval'd. This is especially useful with Ruby "here" documents.
|
374
|
+
def interpolate(string)
|
375
|
+
list = []
|
376
|
+
while string.length > 0
|
377
|
+
head, match, string = string.partition(/\{\{.*?\}\}/)
|
378
|
+
list << head if head.length > 0
|
379
|
+
list << eval(match[2..-3]) if match.length > 0
|
380
|
+
end
|
381
|
+
|
382
|
+
# Split out strings in an array by newline, for visibility
|
383
|
+
list = list.flat_map {|value| value.is_a?(String) ? value.lines.to_a : value }
|
384
|
+
join('', *list)
|
385
|
+
end
|
386
|
+
|
387
|
+
def join_interpolate(delim, string)
|
388
|
+
$stderr.puts "join_interpolate(delim,string) has been deprecated; use interpolate(string) instead"
|
389
|
+
interpolate(string)
|
390
|
+
end
|
391
|
+
|
392
|
+
# This class is used by erb templates so they can access the parameters passed
|
393
|
+
class Namespace
|
394
|
+
attr_accessor :params
|
395
|
+
def initialize(hash)
|
396
|
+
@params = hash
|
397
|
+
end
|
398
|
+
def get_binding
|
399
|
+
binding
|
400
|
+
end
|
401
|
+
end
|
402
|
+
|
403
|
+
# Combines the provided ERB template with optional parameters
|
404
|
+
def erb_template(filename, params = {})
|
405
|
+
ERB.new(file(filename), nil, '-').result(Namespace.new(params).get_binding)
|
406
|
+
end
|