kumogata 0.1.0
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 +20 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +127 -0
- data/Rakefile +6 -0
- data/bin/kumogata +48 -0
- data/kumogata.gemspec +31 -0
- data/lib/kumogata/argument_parser.rb +142 -0
- data/lib/kumogata/client.rb +324 -0
- data/lib/kumogata/ext/string_ext.rb +41 -0
- data/lib/kumogata/logger.rb +23 -0
- data/lib/kumogata/version.rb +3 -0
- data/lib/kumogata.rb +19 -0
- data/packer/CentOS-6.4-x86_64-with_Updates-Asia_Pacific.json +26 -0
- data/packer/Ubuntu-12.04.4-LTS-x86_64-ebs-Asia_Pacific.json +28 -0
- data/spec/kumogata_convert_spec.rb +96 -0
- data/spec/kumogata_create_spec.rb +183 -0
- data/spec/kumogata_delete_spec.rb +20 -0
- data/spec/kumogata_update_spec.rb +121 -0
- data/spec/kumogata_validate_spec.rb +135 -0
- data/spec/spec_helper.rb +68 -0
- metadata +214 -0
@@ -0,0 +1,324 @@
|
|
1
|
+
class Kumogata::Client
|
2
|
+
def initialize(options)
|
3
|
+
@options = options
|
4
|
+
@options = Hashie::Mash.new(@options) unless @options.kind_of?(Hashie::Mash)
|
5
|
+
@cloud_formation = AWS::CloudFormation.new
|
6
|
+
end
|
7
|
+
|
8
|
+
def create(path_or_url, stack_name = nil)
|
9
|
+
@options.delete_stack = false if stack_name
|
10
|
+
template = open_template(path_or_url)
|
11
|
+
|
12
|
+
if @options.delete_stack?
|
13
|
+
template['Resources'].each do |k, v|
|
14
|
+
v['DeletionPolicy'] = 'Retain'
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
create_stack(template, stack_name)
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate(path_or_url)
|
23
|
+
template = open_template(path_or_url)
|
24
|
+
validate_template(template)
|
25
|
+
nil
|
26
|
+
end
|
27
|
+
|
28
|
+
def convert(path_or_url)
|
29
|
+
template = open_template(path_or_url)
|
30
|
+
|
31
|
+
if ruby_template?(path_or_url)
|
32
|
+
JSON.pretty_generate(template)
|
33
|
+
else
|
34
|
+
devaluate_template(template).chomp
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def update(path_or_url, stack_name)
|
39
|
+
template = open(path_or_url) do |f|
|
40
|
+
evaluate_template(f)
|
41
|
+
end
|
42
|
+
|
43
|
+
update_stack(template, stack_name)
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
|
47
|
+
def delete(stack_name)
|
48
|
+
if @options.force? or agree("Aare you sure you want to delete `#{stack_name}`? ".yellow)
|
49
|
+
delete_stack(stack_name)
|
50
|
+
end
|
51
|
+
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
|
55
|
+
def list(stack_name = nil)
|
56
|
+
stacks = describe_stacks(stack_name)
|
57
|
+
JSON.pretty_generate(stacks)
|
58
|
+
end
|
59
|
+
|
60
|
+
private ###########################################################
|
61
|
+
|
62
|
+
def open_template(path_or_url)
|
63
|
+
open(path_or_url) do |f|
|
64
|
+
if ruby_template?(path_or_url)
|
65
|
+
evaluate_template(f)
|
66
|
+
else
|
67
|
+
JSON.parse(f.read)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def ruby_template?(path_or_url)
|
73
|
+
File.extname(path_or_url) == '.rb'
|
74
|
+
end
|
75
|
+
|
76
|
+
def evaluate_template(template)
|
77
|
+
key_converter = proc do |key|
|
78
|
+
key = key.to_s
|
79
|
+
key.gsub!('__', '::') if @options.replace_underscore?
|
80
|
+
key
|
81
|
+
end
|
82
|
+
|
83
|
+
value_converter = proc do |v|
|
84
|
+
case v
|
85
|
+
when Hash, Array
|
86
|
+
v
|
87
|
+
else
|
88
|
+
v.to_s
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
Dslh.eval(template.read, {
|
93
|
+
:key_conv => key_converter,
|
94
|
+
:value_conv => value_converter,
|
95
|
+
:scope_hook => method(:define_template_func),
|
96
|
+
:filename => template.path,
|
97
|
+
})
|
98
|
+
end
|
99
|
+
|
100
|
+
def devaluate_template(template)
|
101
|
+
exclude_key = proc do |k|
|
102
|
+
k = k.to_s.gsub('::', '__')
|
103
|
+
k !~ /\A[_a-z]\w+\Z/i and k !~ %r|\A/\S*\Z|
|
104
|
+
end
|
105
|
+
|
106
|
+
key_conv = proc do |k|
|
107
|
+
k = k.to_s
|
108
|
+
|
109
|
+
if k =~ %r|\A/\S*\Z|
|
110
|
+
proc do |v, nested|
|
111
|
+
if nested
|
112
|
+
"_path(#{k.inspect}) #{v}"
|
113
|
+
else
|
114
|
+
"_path #{k.inspect}, #{v}"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
else
|
118
|
+
k.gsub('::', '__')
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
Dslh.deval(template, :key_conv => key_conv, :exclude_key => exclude_key)
|
123
|
+
end
|
124
|
+
|
125
|
+
def define_template_func(scope)
|
126
|
+
scope.instance_eval do
|
127
|
+
def _user_data(data)
|
128
|
+
data.strip_lines.encode64
|
129
|
+
end
|
130
|
+
|
131
|
+
def _path(path, value = nil, &block)
|
132
|
+
if block
|
133
|
+
value = Dslh::ScopeBlock.nest(binding, 'block')
|
134
|
+
end
|
135
|
+
|
136
|
+
@__hash__[path] = value
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def describe_stacks(stack_name)
|
142
|
+
AWS.memoize do
|
143
|
+
stacks = @cloud_formation.stacks
|
144
|
+
stacks = stacks.select {|i| i.name == stack_name } if stack_name
|
145
|
+
|
146
|
+
stacks.map do |stack|
|
147
|
+
{
|
148
|
+
'StackName' => stack.name,
|
149
|
+
'CreationTime' => stack.creation_time,
|
150
|
+
'StackStatus' => stack.status,
|
151
|
+
'Description' => stack.description,
|
152
|
+
}
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def create_stack(template, stack_name)
|
158
|
+
stack_name = stack_name || 'kumogata-' + UUIDTools::UUID.timestamp_create
|
159
|
+
|
160
|
+
Kumogata.logger.info("Creating stack: #{stack_name}".cyan)
|
161
|
+
stack = @cloud_formation.stacks.create(stack_name, template.to_json, build_create_options)
|
162
|
+
|
163
|
+
unless while_in_progress(stack, 'CREATE_COMPLETE')
|
164
|
+
errmsgs = ['Create failed']
|
165
|
+
errmsgs << stack_name
|
166
|
+
errmsgs << sstack.tatus_reason if stack.status_reason
|
167
|
+
raise errmsgs.join(': ')
|
168
|
+
end
|
169
|
+
|
170
|
+
outputs = outputs_for(stack)
|
171
|
+
summaries = resource_summaries_for(stack)
|
172
|
+
|
173
|
+
if @options.delete_stack?
|
174
|
+
delete_stack(stack_name)
|
175
|
+
end
|
176
|
+
|
177
|
+
output_result(stack_name, outputs, summaries)
|
178
|
+
end
|
179
|
+
|
180
|
+
def update_stack(template, stack_name)
|
181
|
+
stack = @cloud_formation.stacks[stack_name]
|
182
|
+
stack.status
|
183
|
+
stack.update(build_update_options(template.to_json))
|
184
|
+
|
185
|
+
Kumogata.logger.info("Updating stack: #{stack_name}".green)
|
186
|
+
|
187
|
+
unless while_in_progress(stack, 'UPDATE_COMPLETE')
|
188
|
+
errmsgs = ['Update failed']
|
189
|
+
errmsgs << stack_name
|
190
|
+
errmsgs << sstack.tatus_reason if stack.status_reason
|
191
|
+
raise errmsgs.join(': ')
|
192
|
+
end
|
193
|
+
|
194
|
+
outputs = outputs_for(stack)
|
195
|
+
summaries = resource_summaries_for(stack)
|
196
|
+
output_result(stack_name, outputs, summaries)
|
197
|
+
end
|
198
|
+
|
199
|
+
def delete_stack(stack_name)
|
200
|
+
stack = @cloud_formation.stacks[stack_name]
|
201
|
+
stack.status
|
202
|
+
|
203
|
+
Kumogata.logger.info("Deleting stack: #{stack_name}".red)
|
204
|
+
stack.delete
|
205
|
+
|
206
|
+
completed = false
|
207
|
+
|
208
|
+
begin
|
209
|
+
completed = while_in_progress(stack, 'DELETE_COMPLETE')
|
210
|
+
rescue AWS::CloudFormation::Errors::ValidationError
|
211
|
+
# Handle `Stack does not exist`
|
212
|
+
completed = true
|
213
|
+
Kumogata.logger.info('Successfully')
|
214
|
+
end
|
215
|
+
|
216
|
+
unless completed
|
217
|
+
errmsgs = ['Delete failed']
|
218
|
+
errmsgs << stack_name
|
219
|
+
errmsgs << sstack.tatus_reason if stack.status_reason
|
220
|
+
raise errmsgs.join(': ')
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def while_in_progress(stack, complete_status)
|
225
|
+
while stack.status =~ /_IN_PROGRESS\Z/
|
226
|
+
print '.'.intense_black unless @options.debug?
|
227
|
+
sleep 1
|
228
|
+
end
|
229
|
+
|
230
|
+
completed = (stack.status == complete_status)
|
231
|
+
Kumogata.logger.info(completed ? 'Successfully' : 'Failed')
|
232
|
+
return completed
|
233
|
+
end
|
234
|
+
|
235
|
+
def build_create_options
|
236
|
+
opts = {}
|
237
|
+
add_parameters(opts)
|
238
|
+
|
239
|
+
[:capabilities, :disable_rollback, :notify, :timeout].each do |k|
|
240
|
+
opts[k] = @options[k] if @options[k]
|
241
|
+
end
|
242
|
+
|
243
|
+
return opts
|
244
|
+
end
|
245
|
+
|
246
|
+
def build_update_options(template)
|
247
|
+
opts = {:template => template}
|
248
|
+
add_parameters(opts)
|
249
|
+
return opts
|
250
|
+
end
|
251
|
+
|
252
|
+
def add_parameters(hash)
|
253
|
+
if @options.parameters?
|
254
|
+
parameters = {}
|
255
|
+
|
256
|
+
@options.parameters.each do |i|
|
257
|
+
key, value = i.split('=', 2)
|
258
|
+
parameters[key] = value
|
259
|
+
end
|
260
|
+
|
261
|
+
hash[:parameters] = parameters
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def validate_template(template)
|
266
|
+
result = @cloud_formation.validate_template(template.to_json)
|
267
|
+
|
268
|
+
if result[:code]
|
269
|
+
raise result.values_at(:code, :message).join(': ')
|
270
|
+
end
|
271
|
+
|
272
|
+
Kumogata.logger.info('Template validated successfully'.green)
|
273
|
+
end
|
274
|
+
|
275
|
+
def outputs_for(stack)
|
276
|
+
outputs_hash = {}
|
277
|
+
|
278
|
+
stack.outputs.each do |output|
|
279
|
+
outputs_hash[output.key] = output.value
|
280
|
+
end
|
281
|
+
|
282
|
+
return outputs_hash
|
283
|
+
end
|
284
|
+
|
285
|
+
def resource_summaries_for(stack)
|
286
|
+
stack.resource_summaries.map do |summary|
|
287
|
+
summary_hash = {}
|
288
|
+
|
289
|
+
[
|
290
|
+
:logical_resource_id,
|
291
|
+
:physical_resource_id,
|
292
|
+
:resource_type,
|
293
|
+
:resource_status,
|
294
|
+
:resource_status_reason,
|
295
|
+
:last_updated_timestamp
|
296
|
+
].each do |k|
|
297
|
+
summary_hash[k.to_s.camelcase] = summary[k]
|
298
|
+
end
|
299
|
+
|
300
|
+
summary_hash
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
def output_result(stack_name, outputs, summaries)
|
305
|
+
puts <<-EOS
|
306
|
+
|
307
|
+
Outputs:
|
308
|
+
#{JSON.pretty_generate(outputs)}
|
309
|
+
|
310
|
+
Stack Resource Summaries:
|
311
|
+
#{JSON.pretty_generate(summaries)}
|
312
|
+
|
313
|
+
(Save to `#{@options.result_log}`)
|
314
|
+
EOS
|
315
|
+
|
316
|
+
open(@options.result_log, 'wb') do |f|
|
317
|
+
f.puts JSON.pretty_generate({
|
318
|
+
'StackName' => stack_name,
|
319
|
+
'Outputs' => outputs,
|
320
|
+
'StackResourceSummaries' => summaries,
|
321
|
+
})
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'term/ansicolor'
|
2
|
+
|
3
|
+
class String
|
4
|
+
@@colorize = false
|
5
|
+
|
6
|
+
class << self
|
7
|
+
def colorize=(value)
|
8
|
+
@@colorize = value
|
9
|
+
end
|
10
|
+
|
11
|
+
def colorize
|
12
|
+
@@colorize
|
13
|
+
end
|
14
|
+
end # of class methods
|
15
|
+
|
16
|
+
Term::ANSIColor::Attribute.named_attributes.map do |attribute|
|
17
|
+
class_eval(<<-EOS, __FILE__, __LINE__ + 1)
|
18
|
+
def #{attribute.name}
|
19
|
+
if @@colorize
|
20
|
+
Term::ANSIColor.send(#{attribute.name.inspect}, self)
|
21
|
+
else
|
22
|
+
self
|
23
|
+
end
|
24
|
+
end
|
25
|
+
EOS
|
26
|
+
end
|
27
|
+
|
28
|
+
def camelcase
|
29
|
+
self.split(/[-_]/).map {|str|
|
30
|
+
str[0, 1].upcase + str[1..-1].downcase
|
31
|
+
}.join
|
32
|
+
end
|
33
|
+
|
34
|
+
def encode64
|
35
|
+
Base64.encode64(self).delete("\n")
|
36
|
+
end
|
37
|
+
|
38
|
+
def strip_lines
|
39
|
+
self.strip.split("\n").map {|i| i.strip }.join("\n")
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Kumogata
|
2
|
+
def self.logger
|
3
|
+
Kumogata::Logger.instance
|
4
|
+
end
|
5
|
+
|
6
|
+
class Logger < ::Logger
|
7
|
+
include Singleton
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
super($stdout)
|
11
|
+
|
12
|
+
self.formatter = proc do |severity, datetime, progname, msg|
|
13
|
+
"#{msg}\n"
|
14
|
+
end
|
15
|
+
|
16
|
+
self.level = Logger::INFO
|
17
|
+
end
|
18
|
+
|
19
|
+
def set_debug(value)
|
20
|
+
self.level = value ? Logger::DEBUG : Logger::INFO
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/kumogata.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
module Kumogata; end
|
2
|
+
require 'kumogata/version'
|
3
|
+
|
4
|
+
require 'aws-sdk'
|
5
|
+
require 'base64'
|
6
|
+
require 'dslh'
|
7
|
+
require 'hashie'
|
8
|
+
require 'highline/import'
|
9
|
+
require 'json'
|
10
|
+
require 'logger'
|
11
|
+
require 'open-uri'
|
12
|
+
require 'optparse'
|
13
|
+
require 'singleton'
|
14
|
+
require 'uuidtools'
|
15
|
+
|
16
|
+
require 'kumogata/argument_parser'
|
17
|
+
require 'kumogata/client'
|
18
|
+
require 'kumogata/ext/string_ext'
|
19
|
+
require 'kumogata/logger'
|
@@ -0,0 +1,26 @@
|
|
1
|
+
{
|
2
|
+
"variables": {
|
3
|
+
"aws_access_key": "{{env `AWS_ACCESS_KEY_ID`}}",
|
4
|
+
"aws_secret_key": "{{env `AWS_SECRET_ACCESS_KEY`}}"
|
5
|
+
},
|
6
|
+
"builders": [{
|
7
|
+
"type": "amazon-ebs",
|
8
|
+
"ami_name": "CentOS-6.4-x86_64-with_Updates-{{timestamp}}",
|
9
|
+
"access_key": "{{user `aws_access_key`}}",
|
10
|
+
"secret_key": "{{user `aws_secret_key`}}",
|
11
|
+
"region": "ap-northeast-1",
|
12
|
+
"source_ami": "ami-31e86030",
|
13
|
+
"instance_type": "t1.micro",
|
14
|
+
"ssh_username": "root",
|
15
|
+
"ssh_timeout": "3m"
|
16
|
+
}],
|
17
|
+
"provisioners": [{
|
18
|
+
"type": "shell",
|
19
|
+
"inline": [
|
20
|
+
"yum install -y http://ftp.iij.ad.jp/pub/linux/fedora/epel/6/i386/epel-release-6-8.noarch.rpm",
|
21
|
+
"yum install -y cloud-init",
|
22
|
+
"yum install -y https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.amzn1.noarch.rpm",
|
23
|
+
"rm -rf /root/.ssh"
|
24
|
+
]
|
25
|
+
}]
|
26
|
+
}
|
@@ -0,0 +1,28 @@
|
|
1
|
+
{
|
2
|
+
"variables": {
|
3
|
+
"aws_access_key": "{{env `AWS_ACCESS_KEY_ID`}}",
|
4
|
+
"aws_secret_key": "{{env `AWS_SECRET_ACCESS_KEY`}}"
|
5
|
+
},
|
6
|
+
"builders": [{
|
7
|
+
"type": "amazon-ebs",
|
8
|
+
"ami_name": "Ubuntu-12.04.4-LTS-x86_64-ebs-{{timestamp}}",
|
9
|
+
"access_key": "{{user `aws_access_key`}}",
|
10
|
+
"secret_key": "{{user `aws_secret_key`}}",
|
11
|
+
"region": "ap-northeast-1",
|
12
|
+
"source_ami": "ami-f381f5f2",
|
13
|
+
"instance_type": "t1.micro",
|
14
|
+
"ssh_username": "ubuntu",
|
15
|
+
"ssh_timeout": "3m"
|
16
|
+
}],
|
17
|
+
"provisioners": [{
|
18
|
+
"type": "shell",
|
19
|
+
"inline": [
|
20
|
+
"sudo apt-get -y install python-setuptools",
|
21
|
+
"wget -P /tmp https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz",
|
22
|
+
"sudo mkdir -p /tmp/aws-cfn-bootstrap-latest",
|
23
|
+
"sudo tar xvfz /tmp/aws-cfn-bootstrap-latest.tar.gz --strip-components=1 -C /tmp/aws-cfn-bootstrap-latest",
|
24
|
+
"sudo easy_install /tmp/aws-cfn-bootstrap-latest/",
|
25
|
+
"sudo rm -rf /tmp/aws-cfn-bootstrap-latest"
|
26
|
+
]
|
27
|
+
}]
|
28
|
+
}
|
@@ -0,0 +1,96 @@
|
|
1
|
+
describe 'Kumogata::Client#convert' do
|
2
|
+
it 'convert Ruby template to JSON template' do
|
3
|
+
template = <<-EOS
|
4
|
+
Resources do
|
5
|
+
myEC2Instance do
|
6
|
+
Type "AWS::EC2::Instance"
|
7
|
+
Properties do
|
8
|
+
ImageId "ami-XXXXXXXX"
|
9
|
+
InstanceType "t1.micro"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
Outputs do
|
15
|
+
AZ do
|
16
|
+
Value do
|
17
|
+
Fn__GetAtt "myEC2Instance", "AvailabilityZone"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
EOS
|
22
|
+
|
23
|
+
json_template = run_client(:convert, :template => template)
|
24
|
+
|
25
|
+
expect(json_template).to eq((<<-EOS).chomp)
|
26
|
+
{
|
27
|
+
"Resources": {
|
28
|
+
"myEC2Instance": {
|
29
|
+
"Type": "AWS::EC2::Instance",
|
30
|
+
"Properties": {
|
31
|
+
"ImageId": "ami-XXXXXXXX",
|
32
|
+
"InstanceType": "t1.micro"
|
33
|
+
}
|
34
|
+
}
|
35
|
+
},
|
36
|
+
"Outputs": {
|
37
|
+
"AZ": {
|
38
|
+
"Value": {
|
39
|
+
"Fn::GetAtt": [
|
40
|
+
"myEC2Instance",
|
41
|
+
"AvailabilityZone"
|
42
|
+
]
|
43
|
+
}
|
44
|
+
}
|
45
|
+
}
|
46
|
+
}
|
47
|
+
EOS
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'convert Ruby template to JSON template' do
|
51
|
+
template = <<-EOS
|
52
|
+
{
|
53
|
+
"Resources": {
|
54
|
+
"myEC2Instance": {
|
55
|
+
"Type": "AWS::EC2::Instance",
|
56
|
+
"Properties": {
|
57
|
+
"ImageId": "ami-07f68106",
|
58
|
+
"InstanceType": "t1.micro"
|
59
|
+
}
|
60
|
+
}
|
61
|
+
},
|
62
|
+
"Outputs": {
|
63
|
+
"AZ": {
|
64
|
+
"Value": {
|
65
|
+
"Fn::GetAtt": [
|
66
|
+
"myEC2Instance",
|
67
|
+
"AvailabilityZone"
|
68
|
+
]
|
69
|
+
}
|
70
|
+
}
|
71
|
+
}
|
72
|
+
}
|
73
|
+
EOS
|
74
|
+
|
75
|
+
ruby_template = run_client(:convert, :template => template, :template_ext => '.template')
|
76
|
+
|
77
|
+
expect(ruby_template).to eq((<<-EOS).chomp)
|
78
|
+
Resources do
|
79
|
+
myEC2Instance do
|
80
|
+
Type "AWS::EC2::Instance"
|
81
|
+
Properties do
|
82
|
+
ImageId "ami-07f68106"
|
83
|
+
InstanceType "t1.micro"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
Outputs do
|
88
|
+
AZ do
|
89
|
+
Value do
|
90
|
+
Fn__GetAtt "myEC2Instance", "AvailabilityZone"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
EOS
|
95
|
+
end
|
96
|
+
end
|