cloudformation-ruby-dsl-addedvars 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- 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
|