sparkle_formation 3.0.30 → 3.0.32
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -0
- data/lib/sparkle_formation.rb +23 -23
- data/lib/sparkle_formation/aws.rb +1 -1
- data/lib/sparkle_formation/composition.rb +7 -7
- data/lib/sparkle_formation/error.rb +2 -2
- data/lib/sparkle_formation/function_struct.rb +22 -22
- data/lib/sparkle_formation/provider.rb +7 -7
- data/lib/sparkle_formation/provider/aws.rb +28 -28
- data/lib/sparkle_formation/provider/azure.rb +7 -7
- data/lib/sparkle_formation/provider/google.rb +16 -16
- data/lib/sparkle_formation/provider/heat.rb +6 -6
- data/lib/sparkle_formation/provider/terraform.rb +7 -7
- data/lib/sparkle_formation/resources.rb +18 -18
- data/lib/sparkle_formation/resources/aws.rb +216 -126
- data/lib/sparkle_formation/resources/aws_resources.json +3463 -1601
- data/lib/sparkle_formation/resources/azure.rb +6 -6
- data/lib/sparkle_formation/resources/google.rb +7 -7
- data/lib/sparkle_formation/resources/heat.rb +2 -2
- data/lib/sparkle_formation/resources/rackspace.rb +2 -2
- data/lib/sparkle_formation/resources/terraform.rb +6 -6
- data/lib/sparkle_formation/sparkle.rb +32 -32
- data/lib/sparkle_formation/sparkle_attribute.rb +10 -10
- data/lib/sparkle_formation/sparkle_attribute/aws.rb +30 -30
- data/lib/sparkle_formation/sparkle_attribute/azure.rb +39 -39
- data/lib/sparkle_formation/sparkle_attribute/google.rb +19 -19
- data/lib/sparkle_formation/sparkle_attribute/heat.rb +16 -16
- data/lib/sparkle_formation/sparkle_attribute/rackspace.rb +1 -1
- data/lib/sparkle_formation/sparkle_attribute/terraform.rb +41 -41
- data/lib/sparkle_formation/sparkle_collection.rb +4 -4
- data/lib/sparkle_formation/sparkle_collection/rainbow.rb +3 -3
- data/lib/sparkle_formation/sparkle_formation.rb +31 -31
- data/lib/sparkle_formation/sparkle_struct.rb +5 -5
- data/lib/sparkle_formation/translation.rb +32 -32
- data/lib/sparkle_formation/translation/heat.rb +126 -126
- data/lib/sparkle_formation/translation/rackspace.rb +118 -118
- data/lib/sparkle_formation/utils.rb +5 -5
- data/lib/sparkle_formation/version.rb +1 -1
- data/sparkle_formation.gemspec +1 -1
- metadata +7 -7
@@ -13,9 +13,9 @@ class SparkleFormation
|
|
13
13
|
# @return [Array<String, Object>] name and new value
|
14
14
|
def rackspace_server_network_interfaces_mapping(value, args = {})
|
15
15
|
networks = [value].flatten.map do |item|
|
16
|
-
{:uuid => item[
|
16
|
+
{:uuid => item["NetworkInterfaceId"]}
|
17
17
|
end
|
18
|
-
[
|
18
|
+
["networks", networks]
|
19
19
|
end
|
20
20
|
|
21
21
|
# Translate override to provide finalization of resources
|
@@ -32,19 +32,19 @@ class SparkleFormation
|
|
32
32
|
# multiple listeners (ports) have been defined resulting in
|
33
33
|
# multiple isolated LB resources
|
34
34
|
def complete_launch_config_lb_setups
|
35
|
-
translated[
|
36
|
-
resource[
|
35
|
+
translated["resources"].find_all do |resource_name, resource|
|
36
|
+
resource["type"] == "Rackspace::AutoScale::Group"
|
37
37
|
end.each do |name, value|
|
38
|
-
if lbs = value[
|
38
|
+
if lbs = value["properties"].delete("load_balancers")
|
39
39
|
lbs.each do |lb_ref|
|
40
40
|
lb_name = resource_name(lb_ref)
|
41
|
-
lb_resource = translated[
|
42
|
-
vip_resources = translated[
|
43
|
-
k.match(/#{lb_name}Vip\d+/) && v[
|
41
|
+
lb_resource = translated["resources"][lb_name]
|
42
|
+
vip_resources = translated["resources"].find_all do |k, v|
|
43
|
+
k.match(/#{lb_name}Vip\d+/) && v["type"] == "Rackspace::Cloud::LoadBalancer"
|
44
44
|
end
|
45
|
-
value[
|
45
|
+
value["properties"]["launchConfiguration"]["args"].tap do |lnch_config|
|
46
46
|
lb_instance = {
|
47
|
-
|
47
|
+
"loadBalancerId" => lb_ref,
|
48
48
|
}
|
49
49
|
# @note search for a port defined within parameters
|
50
50
|
# that matches naming of LB ID for when they are
|
@@ -52,99 +52,99 @@ class SparkleFormation
|
|
52
52
|
# Be sure to document this in user docs since it's
|
53
53
|
# weird but needed
|
54
54
|
if lb_resource
|
55
|
-
lb_instance[
|
55
|
+
lb_instance["port"] = lb_resource["cache_instance_port"]
|
56
56
|
else
|
57
57
|
key = parameters.keys.find_all do |k|
|
58
|
-
if k.end_with?(
|
59
|
-
lb_ref.values.first.start_with?(k.sub(
|
58
|
+
if k.end_with?("Port")
|
59
|
+
lb_ref.values.first.start_with?(k.sub("Instance", "").sub(/Port$/, ""))
|
60
60
|
end
|
61
61
|
end
|
62
62
|
key = key.detect do |k|
|
63
|
-
k.downcase.include?(
|
63
|
+
k.downcase.include?("instance")
|
64
64
|
end || key.first
|
65
65
|
if key
|
66
|
-
lb_instance[
|
66
|
+
lb_instance["port"] = {"get_param" => key}
|
67
67
|
else
|
68
68
|
raise "Failed to translate load balancer configuartion. No port found! (#{lb_ref})"
|
69
69
|
end
|
70
70
|
end
|
71
|
-
lnch_config[
|
71
|
+
lnch_config["loadBalancers"] = [lb_instance]
|
72
72
|
vip_resources.each do |vip_name, vip_resource|
|
73
|
-
lnch_config[
|
74
|
-
|
75
|
-
|
73
|
+
lnch_config["loadBalancers"].push(
|
74
|
+
"loadBalancerId" => {
|
75
|
+
"Ref" => vip_name,
|
76
76
|
},
|
77
|
-
|
77
|
+
"port" => vip_resource["cache_instance_port"],
|
78
78
|
)
|
79
79
|
end
|
80
80
|
end
|
81
81
|
end
|
82
82
|
end
|
83
83
|
end
|
84
|
-
translated[
|
85
|
-
resource[
|
86
|
-
!resource[
|
84
|
+
translated["resources"].find_all do |resource_name, resource|
|
85
|
+
resource["type"] == "Rackspace::Cloud::LoadBalancer" &&
|
86
|
+
!resource["properties"]["nodes"].empty?
|
87
87
|
end.each do |resource_name, resource|
|
88
|
-
resource[
|
88
|
+
resource["properties"]["nodes"].map! do |node_ref|
|
89
89
|
{
|
90
|
-
|
90
|
+
"addresses" => [
|
91
91
|
{
|
92
|
-
|
92
|
+
"get_attr" => [
|
93
93
|
resource_name(node_ref),
|
94
|
-
|
94
|
+
"accessIPv4",
|
95
95
|
],
|
96
96
|
},
|
97
97
|
],
|
98
|
-
|
99
|
-
|
98
|
+
"port" => resource["cache_instance_port"],
|
99
|
+
"condition" => "ENABLED",
|
100
100
|
}
|
101
101
|
end
|
102
102
|
end
|
103
|
-
translated[
|
104
|
-
resource[
|
103
|
+
translated["resources"].values.find_all do |resource|
|
104
|
+
resource["type"] == "Rackspace::Cloud::LoadBalancer"
|
105
105
|
end.each do |resource|
|
106
|
-
resource.delete(
|
106
|
+
resource.delete("cache_instance_port")
|
107
107
|
end
|
108
108
|
true
|
109
109
|
end
|
110
110
|
|
111
111
|
# Rackspace translation mapping
|
112
112
|
MAP = Heat::MAP
|
113
|
-
MAP[:resources][
|
114
|
-
MAP[:resources][
|
115
|
-
MAP[:resources][
|
116
|
-
asg[:name] =
|
113
|
+
MAP[:resources]["AWS::EC2::Instance"][:name] = "Rackspace::Cloud::Server"
|
114
|
+
MAP[:resources]["AWS::EC2::Instance"][:properties]["NetworkInterfaces"] = :rackspace_server_network_interfaces_mapping # rubocop:disable Metrics/LineLength
|
115
|
+
MAP[:resources]["AWS::AutoScaling::AutoScalingGroup"].tap do |asg|
|
116
|
+
asg[:name] = "Rackspace::AutoScale::Group"
|
117
117
|
asg[:finalizer] = :rackspace_asg_finalizer
|
118
118
|
asg[:properties].tap do |props|
|
119
|
-
props[
|
120
|
-
props[
|
121
|
-
props[
|
122
|
-
props[
|
119
|
+
props["MaxSize"] = "maxEntities"
|
120
|
+
props["MinSize"] = "minEntities"
|
121
|
+
props["LoadBalancerNames"] = "load_balancers"
|
122
|
+
props["LaunchConfigurationName"] = :delete
|
123
123
|
end
|
124
124
|
end
|
125
|
-
MAP[:resources][
|
126
|
-
subnet[:name] =
|
125
|
+
MAP[:resources]["AWS::EC2::Subnet"] = {}.tap do |subnet|
|
126
|
+
subnet[:name] = "Rackspace::Cloud::Network"
|
127
127
|
subnet[:finalizer] = :rackspace_subnet_finalizer
|
128
128
|
subnet[:properties] = {
|
129
|
-
|
129
|
+
"CidrBlock" => "cidr",
|
130
130
|
}
|
131
131
|
end
|
132
|
-
MAP[:resources][
|
133
|
-
:name =>
|
132
|
+
MAP[:resources]["AWS::ElasticLoadBalancing::LoadBalancer"] = {
|
133
|
+
:name => "Rackspace::Cloud::LoadBalancer",
|
134
134
|
:finalizer => :rackspace_lb_finalizer,
|
135
135
|
:properties => {
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
136
|
+
"LoadBalancerName" => "name",
|
137
|
+
"Instances" => "nodes",
|
138
|
+
"Listeners" => "listeners",
|
139
|
+
"HealthCheck" => "health_check",
|
140
140
|
},
|
141
141
|
}
|
142
142
|
|
143
143
|
# Attribute map for autoscaling group server properties
|
144
144
|
RACKSPACE_ASG_SRV_MAP = {
|
145
|
-
|
146
|
-
|
147
|
-
|
145
|
+
"imageRef" => "image",
|
146
|
+
"flavorRef" => "flavor",
|
147
|
+
"networks" => "networks",
|
148
148
|
}
|
149
149
|
|
150
150
|
# Finalizer for the rackspace load balancer resource. This
|
@@ -160,58 +160,58 @@ class SparkleFormation
|
|
160
160
|
#
|
161
161
|
# @todo make virtualIp creation allow servnet/multiple?
|
162
162
|
def rackspace_lb_finalizer(resource_name, new_resource, old_resource)
|
163
|
-
listeners = new_resource[
|
163
|
+
listeners = new_resource["Properties"].delete("listeners") || []
|
164
164
|
source_listener = listeners.shift
|
165
165
|
if source_listener
|
166
|
-
new_resource[
|
167
|
-
if [
|
168
|
-
new_resource[
|
166
|
+
new_resource["Properties"]["port"] = source_listener["LoadBalancerPort"]
|
167
|
+
if ["HTTP", "HTTPS"].include?(source_listener["Protocol"])
|
168
|
+
new_resource["Properties"]["protocol"] = source_listener["Protocol"]
|
169
169
|
else
|
170
|
-
new_resource[
|
170
|
+
new_resource["Properties"]["protocol"] = "TCP_CLIENT_FIRST"
|
171
171
|
end
|
172
|
-
new_resource[
|
172
|
+
new_resource["cache_instance_port"] = source_listener["InstancePort"]
|
173
173
|
end
|
174
|
-
new_resource[
|
175
|
-
new_resource[
|
176
|
-
health_check = new_resource[
|
174
|
+
new_resource["Properties"]["virtualIps"] = ["type" => "PUBLIC", "ipVersion" => "IPV4"]
|
175
|
+
new_resource["Properties"]["nodes"] = [] unless new_resource["Properties"]["nodes"]
|
176
|
+
health_check = new_resource["Properties"].delete("health_check")
|
177
177
|
health_check = nil
|
178
178
|
if health_check
|
179
|
-
new_resource[
|
180
|
-
check[
|
181
|
-
check[
|
182
|
-
check[
|
183
|
-
check_target = dereference_processor(health_check[
|
184
|
-
check_args = check_target.split(
|
179
|
+
new_resource["Properties"]["healthCheck"] = {}.tap do |check|
|
180
|
+
check["timeout"] = health_check["Timeout"]
|
181
|
+
check["attemptsBeforeDeactivation"] = health_check["UnhealthyThreshold"]
|
182
|
+
check["delay"] = health_check["Interval"]
|
183
|
+
check_target = dereference_processor(health_check["Target"])
|
184
|
+
check_args = check_target.split(":")
|
185
185
|
check_type = check_args.shift
|
186
|
-
if check_type ==
|
187
|
-
check[
|
188
|
-
check[
|
186
|
+
if check_type == "HTTP" || check_type == "HTTPS"
|
187
|
+
check["type"] = check_type
|
188
|
+
check["path"] = check_args.last
|
189
189
|
else
|
190
|
-
check[
|
190
|
+
check["type"] = "TCP_CLIENT_FIRST"
|
191
191
|
end
|
192
192
|
end
|
193
193
|
end
|
194
194
|
unless listeners.empty?
|
195
195
|
listeners.each_with_index do |listener, idx|
|
196
|
-
port = listener[
|
197
|
-
proto = [
|
196
|
+
port = listener["LoadBalancerPort"]
|
197
|
+
proto = ["HTTP", "HTTPS"].include?(listener["Protocol"]) ? listener["Protocol"] : "TCP_CLIENT_FIRST"
|
198
198
|
vip_name = "#{resource_name}Vip#{idx}"
|
199
199
|
vip_resource = MultiJson.load(MultiJson.dump(new_resource))
|
200
|
-
vip_resource[
|
201
|
-
vip_resource[
|
202
|
-
vip_resource[
|
203
|
-
vip_resource[
|
204
|
-
|
205
|
-
|
200
|
+
vip_resource["Properties"]["name"] = vip_name
|
201
|
+
vip_resource["Properties"]["protocol"] = proto
|
202
|
+
vip_resource["Properties"]["port"] = port
|
203
|
+
vip_resource["Properties"]["virtualIps"] = [
|
204
|
+
"id" => {
|
205
|
+
"get_attr" => [
|
206
206
|
resource_name,
|
207
|
-
|
207
|
+
"virtualIps",
|
208
208
|
0,
|
209
|
-
|
209
|
+
"id",
|
210
210
|
],
|
211
211
|
},
|
212
212
|
]
|
213
|
-
vip_resource[
|
214
|
-
translated[
|
213
|
+
vip_resource["cache_instance_port"] = listener["InstancePort"]
|
214
|
+
translated["Resources"][vip_name] = vip_resource
|
215
215
|
end
|
216
216
|
end
|
217
217
|
end
|
@@ -225,26 +225,26 @@ class SparkleFormation
|
|
225
225
|
# @param old_resource [Hash]
|
226
226
|
# @return [Object]
|
227
227
|
def rackspace_asg_finalizer(resource_name, new_resource, old_resource)
|
228
|
-
new_resource[
|
229
|
-
if lbs = new_resource[
|
230
|
-
properties[
|
228
|
+
new_resource["Properties"] = {}.tap do |properties|
|
229
|
+
if lbs = new_resource["Properties"].delete("load_balancers")
|
230
|
+
properties["load_balancers"] = lbs
|
231
231
|
end
|
232
|
-
properties[
|
233
|
-
properties[
|
234
|
-
launch_config_name = resource_name(old_resource[
|
235
|
-
config_resource = original[
|
236
|
-
config_resource[
|
232
|
+
properties["groupConfiguration"] = new_resource["Properties"].merge("name" => resource_name)
|
233
|
+
properties["launchConfiguration"] = {}.tap do |config|
|
234
|
+
launch_config_name = resource_name(old_resource["Properties"]["LaunchConfigurationName"])
|
235
|
+
config_resource = original["Resources"][launch_config_name]
|
236
|
+
config_resource["Type"] = "AWS::EC2::Instance"
|
237
237
|
translated = resource_translation(launch_config_name, config_resource)
|
238
|
-
config[
|
239
|
-
lnch_args[
|
240
|
-
srv[
|
238
|
+
config["args"] = {}.tap do |lnch_args|
|
239
|
+
lnch_args["server"] = {}.tap do |srv|
|
240
|
+
srv["name"] = launch_config_name
|
241
241
|
RACKSPACE_ASG_SRV_MAP.each do |k, v|
|
242
|
-
srv[k] = translated[
|
242
|
+
srv[k] = translated["Properties"][v]
|
243
243
|
end
|
244
|
-
srv[
|
244
|
+
srv["personality"] = build_personality(config_resource)
|
245
245
|
end
|
246
246
|
end
|
247
|
-
config[
|
247
|
+
config["type"] = "launch_server"
|
248
248
|
end
|
249
249
|
end
|
250
250
|
end
|
@@ -257,7 +257,7 @@ class SparkleFormation
|
|
257
257
|
# @param old_resource [Hash]
|
258
258
|
# @return [Object]
|
259
259
|
def rackspace_subnet_finalizer(resource_name, new_resource, old_resource)
|
260
|
-
new_resource[
|
260
|
+
new_resource["Properties"]["label"] = resource_name
|
261
261
|
end
|
262
262
|
|
263
263
|
# Custom mapping for server user data. Removes data formatting
|
@@ -297,8 +297,8 @@ class SparkleFormation
|
|
297
297
|
:serialization_number_of_chunks,
|
298
298
|
DEFAULT_NUMBER_OF_CHUNKS
|
299
299
|
)
|
300
|
-
init = resource[
|
301
|
-
content = MultiJson.dump(
|
300
|
+
init = resource["Metadata"]["AWS::CloudFormation::Init"]
|
301
|
+
content = MultiJson.dump("AWS::CloudFormation::Init" => init)
|
302
302
|
# Break out our content to extract items required during stack
|
303
303
|
# execution (template functions, refs, and the like)
|
304
304
|
raw_result = content.scan(/(?=(\{\s*"(Ref|Fn::[A-Za-z]+)"((?:[^{}]++|\{\g<3>\})++)\}))/).map(&:first)
|
@@ -346,17 +346,17 @@ class SparkleFormation
|
|
346
346
|
# The result set is the final formatted content that
|
347
347
|
# now needs to be split and assigned to files
|
348
348
|
result_set << new_content unless new_content.empty?
|
349
|
-
leftovers =
|
349
|
+
leftovers = ""
|
350
350
|
|
351
351
|
# Determine optimal chuck sizing and check if viable
|
352
352
|
calculated_chunk_size = (content.size.to_f / num_personality_files).ceil
|
353
353
|
if calculated_chunk_size > max_chunk_size
|
354
|
-
logger.error
|
354
|
+
logger.error "ERROR: Unable to split personality files within defined bounds!"
|
355
355
|
logger.error " Maximum chunk size: #{max_chunk_size.inspect}"
|
356
356
|
logger.error " Maximum personality files: #{num_personality_files.inspect}"
|
357
357
|
logger.error " Calculated chunk size: #{calculated_chunk_size}"
|
358
358
|
logger.error "-> Content: #{content.inspect}"
|
359
|
-
raise ArgumentError.new
|
359
|
+
raise ArgumentError.new "Unable to split personality files within defined bounds"
|
360
360
|
end
|
361
361
|
|
362
362
|
# Do the split!
|
@@ -367,7 +367,7 @@ class SparkleFormation
|
|
367
367
|
file_content = []
|
368
368
|
unless leftovers.empty?
|
369
369
|
result_set.unshift leftovers
|
370
|
-
leftovers =
|
370
|
+
leftovers = ""
|
371
371
|
end
|
372
372
|
item = nil
|
373
373
|
# @todo need better way to determine length of objects since
|
@@ -395,8 +395,8 @@ class SparkleFormation
|
|
395
395
|
end
|
396
396
|
end
|
397
397
|
files["/etc/sprkl/#{file_index}.cfg"] = {
|
398
|
-
|
399
|
-
|
398
|
+
"Fn::Join" => [
|
399
|
+
"",
|
400
400
|
file_content.flatten,
|
401
401
|
],
|
402
402
|
}
|
@@ -408,36 +408,36 @@ class SparkleFormation
|
|
408
408
|
if parts.size > num_personality_files
|
409
409
|
logger.warn "Failed to split files within defined range! (Max files: #{num_personality_files} " \
|
410
410
|
"Actual files: #{parts.size})"
|
411
|
-
logger.warn
|
411
|
+
logger.warn "Appending to last file and hoping for the best!"
|
412
412
|
parts = parts.to_a
|
413
413
|
extras = parts.slice!(4, parts.length)
|
414
414
|
tail_name, tail_contents = parts.pop
|
415
415
|
parts = Hash[parts]
|
416
416
|
parts[tail_name] = {
|
417
|
-
|
418
|
-
|
417
|
+
"Fn::Join" => [
|
418
|
+
"",
|
419
419
|
extras.map(&:last).unshift(tail_contents),
|
420
420
|
],
|
421
421
|
}
|
422
422
|
end
|
423
|
-
parts[
|
423
|
+
parts["/etc/cloud/cloud.cfg.d/99_s.cfg"] = RUNNER
|
424
424
|
parts
|
425
425
|
end
|
426
426
|
|
427
427
|
FN_MAPPING = {
|
428
|
-
|
428
|
+
"Fn::GetAtt" => "get_attr",
|
429
429
|
# 'Fn::Join' => 'list_join' # TODO: why is this not working?
|
430
430
|
}
|
431
431
|
|
432
432
|
FN_ATT_MAPPING = {
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
433
|
+
"AWS::EC2::Instance" => {
|
434
|
+
"PrivateDnsName" => "accessIPv4", # @todo - need srv net name for access via nets
|
435
|
+
"PublicDnsName" => "accessIPv4",
|
436
|
+
"PrivateIp" => "accessIPv4", # @todo - need srv net name for access via nets
|
437
|
+
"PublicIp" => "accessIPv4",
|
438
438
|
},
|
439
|
-
|
440
|
-
|
439
|
+
"AWS::ElasticLoadBalancing::LoadBalancer" => {
|
440
|
+
"DNSName" => "PublicIp",
|
441
441
|
},
|
442
442
|
}
|
443
443
|
|
@@ -15,13 +15,13 @@ class SparkleFormation
|
|
15
15
|
def __t_check(val, types)
|
16
16
|
types = [types] unless types.is_a?(Array)
|
17
17
|
if types.none? { |t| val.is_a?(t) }
|
18
|
-
ignore_paths = Gem::Specification.find_by_name(
|
18
|
+
ignore_paths = Gem::Specification.find_by_name("sparkle_formation").full_require_paths
|
19
19
|
file_name, line_no = ::Kernel.caller.detect do |l|
|
20
20
|
ignore_paths.none? { |i_path| l.include?(i_path) }
|
21
|
-
end.split(
|
22
|
-
file_name = file_name.to_s.sub(::Dir.pwd,
|
21
|
+
end.split(":")[0, 2]
|
22
|
+
file_name = file_name.to_s.sub(::Dir.pwd, ".")
|
23
23
|
::Kernel.raise TypeError.new "Received invalid value type `#{val.class}`! " \
|
24
|
-
"(Allowed types: `#{types.join(
|
24
|
+
"(Allowed types: `#{types.join("`, `")}`) -> #{file_name} @ line #{line_no}"
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
@@ -49,7 +49,7 @@ class SparkleFormation
|
|
49
49
|
# @param string [String]
|
50
50
|
# @return [String]
|
51
51
|
def camel(string)
|
52
|
-
string.to_s.split(
|
52
|
+
string.to_s.split("_").map { |k| "#{k.slice(0, 1).upcase}#{k.slice(1, k.length)}" }.join
|
53
53
|
end
|
54
54
|
|
55
55
|
# Snake case (underscore) string
|