cloudformation-ruby-dsl-addedvars 1.2.4
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 +29 -0
- data/Gemfile +4 -0
- data/LICENSE +13 -0
- data/OWNERS +4 -0
- data/README.md +125 -0
- data/Rakefile +1 -0
- data/bin/aws-sdk-patch +3 -0
- data/bin/cfntemplate-to-ruby +345 -0
- data/cloudformation-ruby-dsl-addedvars.gemspec +42 -0
- data/docs/Contributing.md +21 -0
- data/docs/Releasing.md +20 -0
- data/examples/cloudformation-ruby-script.rb +232 -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/userdata.sh +4 -0
- data/initial_contributions.md +5 -0
- data/lib/cloudformation-ruby-dsl-addedvars.rb +1 -0
- data/lib/cloudformation-ruby-dsl-addedvars/cfntemplate.rb +595 -0
- data/lib/cloudformation-ruby-dsl-addedvars/dsl.rb +270 -0
- data/lib/cloudformation-ruby-dsl-addedvars/spotprice.rb +50 -0
- data/lib/cloudformation-ruby-dsl-addedvars/table.rb +123 -0
- data/lib/cloudformation-ruby-dsl-addedvars/version.rb +21 -0
- data/share/aws-sdk-patch.sh +108 -0
- metadata +192 -0
@@ -0,0 +1,270 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
############################ Utility functions
|
4
|
+
|
5
|
+
# Formats a template as JSON
|
6
|
+
def generate_template(template)
|
7
|
+
format_json template, !template.nopretty
|
8
|
+
end
|
9
|
+
|
10
|
+
def generate_json(data, pretty = true)
|
11
|
+
# Raw formatting
|
12
|
+
return JSON.generate(data) unless pretty
|
13
|
+
|
14
|
+
# Pretty formatting
|
15
|
+
JSON.pretty_generate(data)
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
############################# Generic DSL
|
20
|
+
|
21
|
+
class JsonObjectDSL
|
22
|
+
def initialize(&block)
|
23
|
+
@dict = {}
|
24
|
+
instance_eval &block
|
25
|
+
end
|
26
|
+
|
27
|
+
def value(values)
|
28
|
+
@dict.update(values)
|
29
|
+
end
|
30
|
+
|
31
|
+
def default(key, value)
|
32
|
+
@dict[key] ||= value
|
33
|
+
end
|
34
|
+
|
35
|
+
def to_json(*args)
|
36
|
+
@dict.to_json(*args)
|
37
|
+
end
|
38
|
+
|
39
|
+
def print()
|
40
|
+
puts JSON.pretty_generate(self)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
############################# CloudFormation DSL
|
45
|
+
|
46
|
+
# Main entry point
|
47
|
+
def raw_template(parameters = {}, stack_name = nil, aws_region = default_region, aws_profile = nil, nopretty = false, &block)
|
48
|
+
TemplateDSL.new(parameters, stack_name, aws_region, aws_profile, nopretty, &block)
|
49
|
+
end
|
50
|
+
|
51
|
+
def default_region
|
52
|
+
ENV['EC2_REGION'] || ENV['AWS_DEFAULT_REGION'] || 'us-east-1'
|
53
|
+
end
|
54
|
+
|
55
|
+
# Core interpreter for the DSL
|
56
|
+
class TemplateDSL < JsonObjectDSL
|
57
|
+
attr_reader :parameters, :aws_region, :nopretty, :stack_name, :aws_profile
|
58
|
+
|
59
|
+
def initialize(parameters = {}, stack_name = nil, aws_region = default_region, aws_profile = nil, nopretty = false)
|
60
|
+
@parameters = parameters
|
61
|
+
@stack_name = stack_name
|
62
|
+
@aws_region = aws_region
|
63
|
+
@aws_profile = aws_profile
|
64
|
+
@nopretty = nopretty
|
65
|
+
super()
|
66
|
+
end
|
67
|
+
|
68
|
+
def exec!()
|
69
|
+
cfn(self)
|
70
|
+
end
|
71
|
+
|
72
|
+
def parameter(name, options)
|
73
|
+
default(:Parameters, {})[name] = options
|
74
|
+
@parameters[name] ||= options[:Default]
|
75
|
+
end
|
76
|
+
|
77
|
+
# Find parameters where the specified attribute is true then remove the attribute from the cfn template.
|
78
|
+
def excise_parameter_attribute!(attribute)
|
79
|
+
marked_parameters = []
|
80
|
+
@dict.fetch(:Parameters, {}).each do |param, options|
|
81
|
+
if options.delete(attribute.to_sym) or options.delete(attribute.to_s)
|
82
|
+
marked_parameters << param
|
83
|
+
end
|
84
|
+
end
|
85
|
+
marked_parameters
|
86
|
+
end
|
87
|
+
|
88
|
+
def mapping(name, options)
|
89
|
+
# if options is a string and a valid file then the script will process the external file.
|
90
|
+
default(:Mappings, {})[name] = \
|
91
|
+
if options.is_a?(Hash); options
|
92
|
+
elsif options.is_a?(String); load_from_file(options)['Mappings'][name]
|
93
|
+
else; raise("Options for mapping #{name} is neither a string or a hash. Error!")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def load_from_file(filename)
|
98
|
+
file = File.open(filename)
|
99
|
+
|
100
|
+
begin
|
101
|
+
# Figure out what the file extension is and process accordingly.
|
102
|
+
contents = case File.extname(filename)
|
103
|
+
when ".rb"; eval(file.read, nil, filename)
|
104
|
+
when ".json"; JSON.load(file)
|
105
|
+
when ".yaml"; YAML::load(file)
|
106
|
+
else; raise("Do not recognize extension of #{filename}.")
|
107
|
+
end
|
108
|
+
ensure
|
109
|
+
file.close
|
110
|
+
end
|
111
|
+
contents
|
112
|
+
end
|
113
|
+
|
114
|
+
# Find tags where the specified attribute is true then remove this attribute.
|
115
|
+
def get_tag_attribute(tags, attribute)
|
116
|
+
marked_tags = []
|
117
|
+
tags.each do |tag, options|
|
118
|
+
if options.delete(attribute.to_sym) or options.delete(attribute.to_s)
|
119
|
+
marked_tags << tag
|
120
|
+
end
|
121
|
+
end
|
122
|
+
marked_tags
|
123
|
+
end
|
124
|
+
|
125
|
+
def excise_tags!
|
126
|
+
tags = @dict.fetch(:Tags, {})
|
127
|
+
@dict.delete(:Tags)
|
128
|
+
tags
|
129
|
+
end
|
130
|
+
|
131
|
+
def tag(tag, *args)
|
132
|
+
if (tag.is_a?(String) || tag.is_a?(Symbol)) && !args.empty?
|
133
|
+
default(:Tags, {})[tag.to_s] = args[0]
|
134
|
+
# For backward-compatibility, transform `tag_name=>value` format to `tag_name, :Value=>value, :Immutable=>true`
|
135
|
+
# Tags declared this way remain immutable and won't be updated.
|
136
|
+
elsif tag.is_a?(Hash) && tag.size == 1 && args.empty?
|
137
|
+
$stderr.puts "WARNING: #{tag} tag declaration format is deprecated and will be removed in a future version. Please use resource-like style instead."
|
138
|
+
tag.each do |name, value|
|
139
|
+
default(:Tags, {})[name.to_s] = {:Value => value, :Immutable => true}
|
140
|
+
end
|
141
|
+
else
|
142
|
+
$stderr.puts "Error: #{tag} tag validation error. Please verify tag's declaration format."
|
143
|
+
exit(false)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def condition(name, options) default(:Conditions, {})[name] = options end
|
148
|
+
|
149
|
+
def resource(name, options) default(:Resources, {})[name] = options end
|
150
|
+
|
151
|
+
def output(name, options) default(:Outputs, {})[name] = options end
|
152
|
+
|
153
|
+
def find_in_map(map, key, name)
|
154
|
+
# Eagerly evaluate mappings when all keys are known at template expansion time
|
155
|
+
if map.is_a?(String) && key.is_a?(String) && name.is_a?(String)
|
156
|
+
# We don't know whether the map was built with string keys or symbol keys. Try both.
|
157
|
+
def get(map, key) map[key] || map.fetch(key.to_sym) end
|
158
|
+
get(get(@dict.fetch(:Mappings).fetch(map), key), name)
|
159
|
+
else
|
160
|
+
{ :'Fn::FindInMap' => [ map, key, name ] }
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def base64(value) { :'Fn::Base64' => value } end
|
166
|
+
|
167
|
+
def find_in_map(map, key, name) { :'Fn::FindInMap' => [ map, key, name ] } end
|
168
|
+
|
169
|
+
def get_att(resource, attribute) { :'Fn::GetAtt' => [ resource, attribute ] } end
|
170
|
+
|
171
|
+
def get_azs(region = '') { :'Fn::GetAZs' => region } end
|
172
|
+
|
173
|
+
def join(delim, *list)
|
174
|
+
case list.length
|
175
|
+
when 0 then ''
|
176
|
+
else join_list(delim,list)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Variant of join that matches the native CFN syntax.
|
181
|
+
def join_list(delim, list) { :'Fn::Join' => [ delim, list ] } end
|
182
|
+
|
183
|
+
def equal(one, two) { :'Fn::Equals' => [one, two] } end
|
184
|
+
|
185
|
+
def fn_not(condition) { :'Fn::Not' => [condition] } end
|
186
|
+
|
187
|
+
def fn_or(*condition_list)
|
188
|
+
case condition_list.length
|
189
|
+
when 0..1 then raise "fn_or needs at least 2 items."
|
190
|
+
when 2..10 then { :'Fn::Or' => condition_list }
|
191
|
+
else raise "fn_or needs a list of 2-10 items that evaluate to true/false."
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def fn_and(*condition_list)
|
196
|
+
case condition_list.length
|
197
|
+
when 0..1 then raise "fn_and needs at least 2 items."
|
198
|
+
when 2..10 then { :'Fn::And' => condition_list }
|
199
|
+
else raise "fn_and needs a list of 2-10 items that evaluate to true/false."
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def fn_if(cond, if_true, if_false) { :'Fn::If' => [cond, if_true, if_false] } end
|
204
|
+
|
205
|
+
def not_equal(one, two) fn_not(equal(one,two)) end
|
206
|
+
|
207
|
+
def select(index, list) { :'Fn::Select' => [ index, list ] } end
|
208
|
+
|
209
|
+
def ref(name) { :Ref => name } end
|
210
|
+
|
211
|
+
def aws_account_id() ref("AWS::AccountId") end
|
212
|
+
|
213
|
+
def aws_notification_arns() ref("AWS::NotificationARNs") end
|
214
|
+
|
215
|
+
def aws_no_value() ref("AWS::NoValue") end
|
216
|
+
|
217
|
+
def aws_stack_id() ref("AWS::StackId") end
|
218
|
+
|
219
|
+
def aws_stack_name() ref("AWS::StackName") end
|
220
|
+
|
221
|
+
# deprecated, for backward compatibility
|
222
|
+
def no_value()
|
223
|
+
warn_deprecated('no_value()', 'aws_no_value()')
|
224
|
+
aws_no_value()
|
225
|
+
end
|
226
|
+
|
227
|
+
# Read the specified file and return its value as a string literal
|
228
|
+
def file(filename) File.read(File.absolute_path(filename, File.dirname($PROGRAM_NAME))) end
|
229
|
+
|
230
|
+
# Interpolates a string like "NAME={{ref('Service')}}" and returns a CloudFormation "Fn::Join"
|
231
|
+
# operation to collect the results. Anything between {{ and }} is interpreted as a Ruby expression
|
232
|
+
# and eval'd. This is especially useful with Ruby "here" documents.
|
233
|
+
# Local variables may also be exposed to the string via the `locals` hash.
|
234
|
+
def interpolate(string, locals={})
|
235
|
+
list = []
|
236
|
+
while string.length > 0
|
237
|
+
head, match, string = string.partition(/\{\{.*?\}\}/)
|
238
|
+
list << head if head.length > 0
|
239
|
+
list << eval(match[2..-3], nil, 'interpolated string') if match.length > 0
|
240
|
+
end
|
241
|
+
|
242
|
+
# Split out strings in an array by newline, for visibility
|
243
|
+
list = list.flat_map {|value| value.is_a?(String) ? value.lines.to_a : value }
|
244
|
+
join('', *list)
|
245
|
+
end
|
246
|
+
|
247
|
+
def join_interpolate(delim, string)
|
248
|
+
$stderr.puts "join_interpolate(delim,string) has been deprecated; use interpolate(string) instead"
|
249
|
+
interpolate(string)
|
250
|
+
end
|
251
|
+
|
252
|
+
# This class is used by erb templates so they can access the parameters passed
|
253
|
+
class Namespace
|
254
|
+
attr_accessor :params
|
255
|
+
def initialize(hash)
|
256
|
+
@params = hash
|
257
|
+
end
|
258
|
+
def get_binding
|
259
|
+
binding
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
# Combines the provided ERB template with optional parameters
|
264
|
+
def erb_template(filename, params = {})
|
265
|
+
ERB.new(file(filename), nil, '-').result(Namespace.new(params).get_binding)
|
266
|
+
end
|
267
|
+
|
268
|
+
def warn_deprecated(old, new)
|
269
|
+
$stderr.puts "Warning: '#{old}' has been deprecated. Please update your template to use '#{new}' instead."
|
270
|
+
end
|
@@ -0,0 +1,50 @@
|
|
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
|
+
# This list of prices was sourced from the on-demand prices current as of 12/3/2012.
|
16
|
+
# We expect the actual price we pay per instance to be roughly 1/10 the prices below.
|
17
|
+
SPOT_PRICES_BY_INSTANCE_TYPE = {
|
18
|
+
"m1.small" => 0.065,
|
19
|
+
"m1.medium" => 0.130,
|
20
|
+
"m1.large" => 0.260,
|
21
|
+
"m1.xlarge" => 0.520,
|
22
|
+
"m3.xlarge" => 0.580,
|
23
|
+
"m3.2xlarge" => 1.160,
|
24
|
+
"t1.micro" => 0.020,
|
25
|
+
"m2.xlarge" => 0.450,
|
26
|
+
"m2.2xlarge" => 0.900,
|
27
|
+
"m2.4xlarge" => 1.800,
|
28
|
+
"c1.medium" => 0.165,
|
29
|
+
"c1.xlarge" => 0.660,
|
30
|
+
"cc1.4xlarge" => 1.300,
|
31
|
+
"cc2.8xlarge" => 2.400,
|
32
|
+
"cg1.4xlarge" => 2.100,
|
33
|
+
"hi1.4xlarge" => 3.100,
|
34
|
+
"hs1.8xlarge" => 4.600,
|
35
|
+
"cr1.8xlarge" => 4.000,
|
36
|
+
}
|
37
|
+
|
38
|
+
def spot_price(spot_price_string, instance_type)
|
39
|
+
case spot_price_string
|
40
|
+
when 'false', '' then aws_no_value()
|
41
|
+
when 'true' then spot_price_for_instance_type(instance_type)
|
42
|
+
else spot_price_string
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def spot_price_for_instance_type(instance_type)
|
47
|
+
# Add 10% to ensure that we have a small buffer against current spot prices increasing
|
48
|
+
# to the on-demand prices, which theoretically could happen often.
|
49
|
+
SPOT_PRICES_BY_INSTANCE_TYPE[instance_type] * 1.10
|
50
|
+
end
|
@@ -0,0 +1,123 @@
|
|
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 'detabulator'
|
16
|
+
|
17
|
+
class Table
|
18
|
+
def self.load(filename)
|
19
|
+
self.new File.read filename
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(table_as_text)
|
23
|
+
raw_header, *raw_data = Detabulator.new.detabulate table_as_text
|
24
|
+
@header = raw_header.map(&:to_sym)
|
25
|
+
@records = raw_data.map { |row| Hash[@header.zip(row)] }
|
26
|
+
end
|
27
|
+
|
28
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns
|
29
|
+
# the single distinct value from those rows for the specified key.
|
30
|
+
def get(key, predicate)
|
31
|
+
distinct_values(filter(predicate), key, false)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Select the headers as list. Argument(s) will be excluded from output.
|
35
|
+
def get_header(*exclude)
|
36
|
+
@header.reject{ |key| key if exclude.include?(key) }
|
37
|
+
end
|
38
|
+
|
39
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns
|
40
|
+
# all distinct values from those rows for the specified key.
|
41
|
+
def get_list(key, predicate)
|
42
|
+
distinct_values(filter(predicate), key, true)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns a
|
46
|
+
# hash of hashes, where the key for the top-level hash is the key paramter and the second-level hash keys are
|
47
|
+
# those in the keys paramter. This is useful when you want multiple column values for a given row.
|
48
|
+
def get_multihash(key, predicate, *keys)
|
49
|
+
build_nested_hash(filter(predicate), key, keys)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns a
|
53
|
+
# set of nested maps, where the key for the map at level n is the key at index n in the specified keys,
|
54
|
+
# except for the last key in the specified keys which is used to determine the value of the leaf-level map.
|
55
|
+
# In the simple case where keys is a list of 2 elements, this returns a map from key[0] to key[1].
|
56
|
+
def get_map(predicate, *keys)
|
57
|
+
build_nested_map(filter(predicate), keys, false)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Selects all rows in the table which match the name/value pairs of the predicate object and returns a
|
61
|
+
# set of nested maps, where the key for the map at level n is the key at index n in the specified keys,
|
62
|
+
# except for the last key in the specified keys which is used to determine the list of values in the
|
63
|
+
# leaf-level map. In the simple case where keys is a list of 2 elements, this returns a map from key[0]
|
64
|
+
# to a list of values for key[1].
|
65
|
+
def get_multimap(predicate, *keys)
|
66
|
+
build_nested_map(filter(predicate), keys, true)
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
# Return the subset of records that match the predicate for all keys in the predicate.
|
72
|
+
# The predicate is expected to be a map of key/value or key/[value,...] pairs.
|
73
|
+
def filter(predicate)
|
74
|
+
def matches(predicate_value, record_value)
|
75
|
+
if predicate_value.is_a?(Array); predicate_value.include?(record_value)
|
76
|
+
else; predicate_value == record_value
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
@records.select { |record| predicate.all? { |key, value| matches(value, record[key]) } }
|
81
|
+
end
|
82
|
+
|
83
|
+
def build_nested_hash(records, key, keys)
|
84
|
+
hash = {}
|
85
|
+
records.each do |record|
|
86
|
+
hash[record[key]] = {}
|
87
|
+
keys.each do |hash_key|
|
88
|
+
hash[record[key]][hash_key] = record[hash_key]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
return hash
|
92
|
+
end
|
93
|
+
|
94
|
+
def build_nested_map(records, path, multi)
|
95
|
+
key, *rest = path
|
96
|
+
if rest.empty?
|
97
|
+
# Build the leaf level of the data structure
|
98
|
+
distinct_values(records, key, multi)
|
99
|
+
else
|
100
|
+
# Return a hash keyed by the distinct values of the first key and values are the result of a
|
101
|
+
# recursive invocation of arrange() with the rest of the keys
|
102
|
+
result = {}
|
103
|
+
records.group_by do |record|
|
104
|
+
record[key]
|
105
|
+
end.map do |value, group|
|
106
|
+
result[value] = build_nested_map(group, rest, multi)
|
107
|
+
end
|
108
|
+
result
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def distinct_values(records, key, multi)
|
113
|
+
values = records.map { |record| record[key] }.uniq
|
114
|
+
if multi
|
115
|
+
# In a multimap the leaf level is a list of string values
|
116
|
+
values
|
117
|
+
else
|
118
|
+
# In a non-multimap the leaf level is a single string value
|
119
|
+
raise "Multiple distinct values for the same key '#{key}': #{records.inspect}" if values.length > 1
|
120
|
+
values[0]
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,21 @@
|
|
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
|
+
module Cfn
|
16
|
+
module Ruby
|
17
|
+
module Dsl
|
18
|
+
VERSION = "1.2.4"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|