cloudformation-ruby-dsl 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,9 @@
1
+ {
2
+ "Mappings": {
3
+ "bar": {
4
+ "foo": "bar"
5
+ }
6
+ }
7
+
8
+ }
9
+
@@ -0,0 +1,8 @@
1
+ {
2
+ "Mappings": {
3
+ "john": {
4
+ "gregory": "smith"
5
+ }
6
+ }
7
+
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "Mappings": {
3
+ "gregory": {
4
+ "smith": "john"
5
+ }
6
+ }
7
+
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "Mappings": {
3
+ "smith": {
4
+ "john": "gregory"
5
+ }
6
+ }
7
+
8
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ 'Mappings' => {
3
+ 'blah' => { 'baz' => 'blah' }
4
+ }
5
+ }
@@ -0,0 +1,5 @@
1
+ column0 column1 column2 column3 column4
2
+ foo baz qux quux
3
+ foo corge grault garply
4
+ bar waldo fred
5
+ bar plugh xyzzy thud
@@ -0,0 +1,5 @@
1
+ ---
2
+ Mappings:
3
+ blah:
4
+ foo:
5
+ bar: yo
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bash
2
+ echo "put initialization script here"
@@ -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,2 @@
1
+ require "cloudformation-ruby-dsl/version"
2
+
@@ -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