image_optimize 1.0.0 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +48 -0
- data/LICENSE +13 -0
- data/README.md +128 -0
- data/Rakefile +45 -0
- data/VERSION +1 -0
- data/bin/image_optimize +22 -0
- data/image_optimize.gemspec +24 -0
- data/lib/api_client_15.rb +86 -0
- data/lib/app/image_optimizer.rb +195 -0
- data/lib/image_bundle.rb +19 -0
- data/lib/image_bundle/image_bundle_base.rb +116 -0
- data/lib/image_bundle/image_bundle_ec2.rb +131 -0
- data/lib/image_bundle/image_bundle_ec2_ebs.rb +77 -0
- data/lib/image_bundle/image_bundle_ec2_s3.rb +114 -0
- data/lib/mixins/command.rb +40 -0
- data/lib/mixins/common.rb +23 -0
- data/rightscale/rightscript.sh +189 -0
- data/setup.sh +40 -0
- data/spec/api_client_spec.rb +39 -0
- data/spec/command_spec.rb +43 -0
- data/spec/image_bundle_ec2_spec.rb +87 -0
- data/spec/image_bundle_spec.rb +97 -0
- data/spec/image_optimizer_spec.rb +194 -0
- data/test/image_optimize_test.rb +50 -0
- metadata +47 -6
data/lib/image_bundle.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# Author: cary@rightscale.com
|
2
|
+
# Copyright 2014 RightScale, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'image_bundle/image_bundle_base'
|
18
|
+
require 'image_bundle/image_bundle_ec2_ebs'
|
19
|
+
require 'image_bundle/image_bundle_ec2_s3'
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# Author: cary@rightscale.com
|
2
|
+
# Copyright 2014 RightScale, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
require 'mixins/common'
|
17
|
+
require 'mixins/command'
|
18
|
+
|
19
|
+
# Base class for image utils
|
20
|
+
#
|
21
|
+
module ImageOptimize
|
22
|
+
|
23
|
+
class ImageBundleBase
|
24
|
+
|
25
|
+
include ImageOptimize::Common
|
26
|
+
include ImageOptimize::Command
|
27
|
+
|
28
|
+
def initialize(instance_api_client, full_api_client)
|
29
|
+
|
30
|
+
@dry_run = false
|
31
|
+
|
32
|
+
@log ||= Logger.new(STDOUT)
|
33
|
+
@instance_client = instance_api_client
|
34
|
+
unless @instance_client
|
35
|
+
@log.error"ERROR: you must pass an instance_api_client parameter."
|
36
|
+
end
|
37
|
+
|
38
|
+
@api_client = full_api_client
|
39
|
+
unless @api_client
|
40
|
+
@log.error"ERROR: you must pass a full_api_client parameter."
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Captures a "snapshot" of the current VM configuration
|
45
|
+
def snapshot_instance(name, description)
|
46
|
+
not_implemented
|
47
|
+
end
|
48
|
+
|
49
|
+
# Turn the VM "snapshot" into an launchable image
|
50
|
+
def register_image(name, description)
|
51
|
+
not_implemented
|
52
|
+
end
|
53
|
+
|
54
|
+
def add_image_to_next_instance
|
55
|
+
# get next instance
|
56
|
+
@log.info "Lookup next_instance for the server."
|
57
|
+
next_instance = get_instance_parent.show.next_instance
|
58
|
+
|
59
|
+
unless @dry_run
|
60
|
+
raise "ERROR: image cannot be nil. Be sure to run register_image method first." unless @image
|
61
|
+
# set next instance to use our cached image
|
62
|
+
@log.info "Update next instance to launch with our new image."
|
63
|
+
next_instance.update(:instance => {:image_href => @image.show.href})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def log(logger)
|
68
|
+
@log = logger
|
69
|
+
end
|
70
|
+
|
71
|
+
protected
|
72
|
+
|
73
|
+
# Get the resources associated with this instance
|
74
|
+
def instance
|
75
|
+
@instance ||= @instance_client.get_instance
|
76
|
+
end
|
77
|
+
|
78
|
+
def get_id(resource)
|
79
|
+
# the id is the last part of thr href path
|
80
|
+
resource.href.split('/').last
|
81
|
+
end
|
82
|
+
|
83
|
+
def fail(message="unspecified error occured", exception=nil)
|
84
|
+
if exception
|
85
|
+
@log.debug "Exception: #{exception.message}"
|
86
|
+
@log.debug exception.backtrace.inspect
|
87
|
+
end
|
88
|
+
@log.error "FATAL: #{message}"
|
89
|
+
exit 1
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get the parent for this instance
|
93
|
+
#
|
94
|
+
# The parent may be a Server or ServerArray reosurce.
|
95
|
+
#
|
96
|
+
# XXX: we can't get to the server resource from the instance facing API
|
97
|
+
# the server resource holds the reference to the next instance.
|
98
|
+
# To deal with this we create an instance resource using the user
|
99
|
+
# authenticated @api_client, then grab it's "parent".
|
100
|
+
#
|
101
|
+
# == Returns:
|
102
|
+
# @return [RightApi::Resource] server a server API resource
|
103
|
+
def get_instance_parent
|
104
|
+
@log.info "Lookup RightScale server for this instance."
|
105
|
+
api_cloud = @api_client.clouds(:id => get_id(instance.cloud))
|
106
|
+
api_instance = api_cloud.show.instances.index(:id => get_id(instance))
|
107
|
+
api_instance.show.parent
|
108
|
+
end
|
109
|
+
|
110
|
+
def debug_mode?
|
111
|
+
@log.level == Logger::DEBUG
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
# Author: cary@rightscale.com
|
2
|
+
# Copyright 2014 RightScale, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
module ImageOptimize
|
18
|
+
|
19
|
+
# Base class for snapshoting and registration of EC2 images
|
20
|
+
#
|
21
|
+
class ImageBundleEc2Base < ImageBundleBase
|
22
|
+
|
23
|
+
RETRY_TIMEOUT_SEC = 2 * 60 * 60 # 2 hr
|
24
|
+
|
25
|
+
def initialize(instance_api_client, full_api_client, aws_access_key, aws_secret_key, kernel_id_override = nil)
|
26
|
+
load_meta_data
|
27
|
+
@aws_access_key = aws_access_key
|
28
|
+
@aws_secret_key = aws_secret_key
|
29
|
+
@kernel_override = kernel_id_override
|
30
|
+
super(instance_api_client, full_api_client)
|
31
|
+
end
|
32
|
+
|
33
|
+
def register_image(name=nil, description=nil)
|
34
|
+
cmd = register_command(name, description)
|
35
|
+
@log.info "Running register image command..."
|
36
|
+
unless @dry_run
|
37
|
+
status, cmd_output = execute(cmd)
|
38
|
+
raise "FATAL: unable to register new image" unless status.exitstatus == 0
|
39
|
+
image_uuid = parse_ami(cmd_output)
|
40
|
+
@log.info "New image: #{image_uuid}"
|
41
|
+
|
42
|
+
# wait for image to be available
|
43
|
+
wait_for_image(image_uuid)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
protected
|
48
|
+
|
49
|
+
def parse_ami(command_output)
|
50
|
+
/(ami-[a-z0-9]+)/.match(command_output).captures[0]
|
51
|
+
end
|
52
|
+
|
53
|
+
def register_command(name, description)
|
54
|
+
raise "Not implemented"
|
55
|
+
end
|
56
|
+
|
57
|
+
def wait_for_image(image_uuid)
|
58
|
+
# wait for image to be available
|
59
|
+
@log.info "Query for the new image"
|
60
|
+
start = Time.now
|
61
|
+
@image = get_image(image_uuid)
|
62
|
+
delay_sec = 10
|
63
|
+
Timeout::timeout(RETRY_TIMEOUT_SEC) do
|
64
|
+
while @image == nil do
|
65
|
+
@log.info " Image #{image_uuid} not yet available. Checking again in #{delay_sec} seconds..."
|
66
|
+
sleep delay_sec
|
67
|
+
@image = get_image(image_uuid)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
total = Time.new - start
|
71
|
+
@log.info "Total time waited: #{total}"
|
72
|
+
end
|
73
|
+
|
74
|
+
def get_image(uuid)
|
75
|
+
# use full api client to look for image
|
76
|
+
image = get_cloud.show.images.index(:filter => [ "resource_uid==#{uuid}" ]).first
|
77
|
+
@log.info "Found image #{uuid} named '#{image.name}'" if image
|
78
|
+
image
|
79
|
+
end
|
80
|
+
|
81
|
+
# Get the cloud for this instance
|
82
|
+
#
|
83
|
+
# XXX: we cannot query cloud image via instance API
|
84
|
+
#
|
85
|
+
# == Returns:
|
86
|
+
# @return [RightApi::Resource] server a server API resource
|
87
|
+
def get_cloud
|
88
|
+
cloud_href = instance.cloud.href
|
89
|
+
cloud_id = cloud_href.split('/').last
|
90
|
+
cloud = @api_client.clouds(:id => cloud_id)
|
91
|
+
end
|
92
|
+
|
93
|
+
# detect EC2 region from EC2 metadata
|
94
|
+
#
|
95
|
+
def region
|
96
|
+
ec2_zone = ENV['EC2_PLACEMENT_AVAILABILITY_ZONE'] # ec2_zone = `curl http://169.254.169.254/latest/meta-data/placement/availability-zone/`
|
97
|
+
unless ec2_zone
|
98
|
+
fail("The EC2_PLACEMENT_AVAILABILITY_ZONE environment variable is not defined. Did you load EC2 metadata?")
|
99
|
+
end
|
100
|
+
region = /(.*\-.*\-[1-9]*)/.match(ec2_zone).captures.first
|
101
|
+
@log.info "detected current region is #{region}"
|
102
|
+
region
|
103
|
+
end
|
104
|
+
|
105
|
+
# detect kernel from EC2 metadata
|
106
|
+
#
|
107
|
+
def kernel_aki
|
108
|
+
kernel = @kernel_override # allow user to override
|
109
|
+
kernel ||= ENV['EC2_KERNEL_ID'] # use same kernel as VM is running
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def load_meta_data
|
115
|
+
begin
|
116
|
+
require '/var/spool/cloud/meta-data-cache.rb'
|
117
|
+
rescue LoadError => e
|
118
|
+
puts "FATAL: not a RightScale managed VM, or you already cleaned up your meta-data file."
|
119
|
+
exit 1
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def common_options
|
124
|
+
options = ""
|
125
|
+
options << "--debug " if debug_mode?
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
end
|
130
|
+
|
131
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# Author: cary@rightscale.com
|
2
|
+
# Copyright 2014 RightScale, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'image_bundle/image_bundle_ec2'
|
18
|
+
|
19
|
+
module ImageOptimize
|
20
|
+
|
21
|
+
# Handles the snapshoting and registration of EBS based images
|
22
|
+
#
|
23
|
+
class ImageBundleEc2EBS < ImageBundleEc2Base
|
24
|
+
|
25
|
+
def snapshot_instance(name=nil, description=nil)
|
26
|
+
|
27
|
+
# find root volume
|
28
|
+
#
|
29
|
+
root_volume = nil
|
30
|
+
begin
|
31
|
+
device_name = "/dev/sda"
|
32
|
+
@log.info "Locating root volume attached to #{device_name}..."
|
33
|
+
attachment = instance.volume_attachments.index.select { |va| va.device =~ /#{device_name}/ }.first
|
34
|
+
root_volume = attachment.volume
|
35
|
+
@attachment_device = attachment.device
|
36
|
+
@log.info "Found volume #{root_volume.show.name} attached at #{@attachment_device}"
|
37
|
+
rescue Exception => e
|
38
|
+
fail("FATAL: cannot find root volume. Check device_name which can vary depending on hypervisor and/or kernel. Also instance-store images not currently supported.", e)
|
39
|
+
end
|
40
|
+
|
41
|
+
# create snapshot
|
42
|
+
@log.info "Creating snapshot of server."
|
43
|
+
options = { :parent_volume_href => root_volume.href }
|
44
|
+
options.merge!(:name => name) if name
|
45
|
+
options.merge!(:description => description) if description
|
46
|
+
snapshot = @instance_client.volume_snapshots.create(:volume_snapshot => options)
|
47
|
+
@log.info "Snapshot name '#{name}'" if name
|
48
|
+
|
49
|
+
# wait for snapshot to complete
|
50
|
+
@log.info "Waiting for snapshot to become available"
|
51
|
+
current_state = snapshot.show.state
|
52
|
+
delay_sec = 10
|
53
|
+
Timeout::timeout(RETRY_TIMEOUT_SEC) do
|
54
|
+
while current_state != "available" do
|
55
|
+
@log.info " snapshot state: #{current_state}. try again in #{delay_sec} seconds..."
|
56
|
+
sleep delay_sec
|
57
|
+
current_state = snapshot.show.state
|
58
|
+
end
|
59
|
+
end
|
60
|
+
@log.info "Snapshot is now available"
|
61
|
+
@snapshot_id = snapshot.show.resource_uid
|
62
|
+
end
|
63
|
+
|
64
|
+
def register_command(name=nil, description=nil)
|
65
|
+
unless @snapshot_id
|
66
|
+
fail("@snapshot_id cannot be nil. Be sure to run snapshot_instance first.")
|
67
|
+
end
|
68
|
+
|
69
|
+
# use ec2 tools to register snapshot as an image
|
70
|
+
# TODO: this command only maps in 4 ephemeral devices to the new image. Use metadata to get actual count.
|
71
|
+
cmd = "ec2-register --region #{region} --snapshot #{@snapshot_id} --description '#{@image_description}' --block-device-mapping '/dev/sdb=ephemeral0' --block-device-mapping '/dev/sdc=ephemeral1' --block-device-mapping '/dev/sdd=ephemeral2' --block-device-mapping '/dev/sde=ephemeral3' --kernel #{kernel_aki} --root-device-name #{@attachment_device} --architecture x86_64 --name '#{name}' #{common_options}"
|
72
|
+
cmd
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
# Author: cary@rightscale.com
|
2
|
+
# Copyright 2014 RightScale, Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'image_bundle/image_bundle_ec2'
|
18
|
+
|
19
|
+
module ImageOptimize
|
20
|
+
|
21
|
+
# Handles the snapshoting and registration of EBS based images
|
22
|
+
#
|
23
|
+
class ImageBundleEc2S3 < ImageBundleEc2Base
|
24
|
+
|
25
|
+
BUNDLE_DIR = "/mnt/ephemeral/bundle"
|
26
|
+
|
27
|
+
# x509_key_file : [String] Absolute path to file holding AWS x.509 private key file
|
28
|
+
# x509_cert_file : [String] Absolute path to file holding AWS x.509 certificate file
|
29
|
+
# s3_bucket : [String] place to store image in S3
|
30
|
+
#
|
31
|
+
def initialize(instance_api_client, full_api_client, aws_access_key, aws_secret_key, aws_account_number, x509_key_file, x509_cert_file, s3_bucket, bundle_dir=nil, no_filter=nil, kernel_id = nil)
|
32
|
+
super(instance_api_client, full_api_client, aws_access_key, aws_secret_key, kernel_id)
|
33
|
+
@aws_account_number = aws_account_number
|
34
|
+
raise "no aws account number specified." unless @aws_account_number
|
35
|
+
@key_file = x509_key_file
|
36
|
+
raise "no path to x.509 private key file specified." unless @key_file
|
37
|
+
@cert_file = x509_cert_file
|
38
|
+
raise "no path to x.509 certificate file specified." unless @cert_file
|
39
|
+
|
40
|
+
@bucket = s3_bucket
|
41
|
+
|
42
|
+
# Where to place temporary image bundle before upload
|
43
|
+
@bundle_dir = bundle_dir
|
44
|
+
@bundle_dir ||= BUNDLE_DIR
|
45
|
+
|
46
|
+
# --no-filter
|
47
|
+
@no_filter = true if no_filter
|
48
|
+
end
|
49
|
+
|
50
|
+
# Captures a "snapshot" of the current VM configuration
|
51
|
+
def snapshot_instance(name, description)
|
52
|
+
bundle_image(name)
|
53
|
+
upload_bundle(name)
|
54
|
+
cleanup_bundle
|
55
|
+
end
|
56
|
+
|
57
|
+
# Turn the VM "snapshot" into an launchable image
|
58
|
+
def register_command(name, description)
|
59
|
+
#ec2-register my-s3-bucket/image_bundles/name/image.manifest.xml -n name -O your_access_key_id -W your_secret_access_key
|
60
|
+
cmd = "ec2-register #{@bucket}/image_bundles/#{name}/image.manifest.xml -n #{name} -O #{@aws_access_key} -W #{@aws_secret_key} --region #{region} --architecture x86_64 --description '#{description}' #{common_options}"
|
61
|
+
cmd
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def upload_bundle(name)
|
67
|
+
@log.info "Running bundle upload command..."
|
68
|
+
cmd = "ec2-upload-bundle -b #{@bucket}/image_bundles/#{name} -m '#{@bundle_dir}/image.manifest.xml' -a #{@aws_access_key} -s #{@aws_secret_key} --retry --batch --region #{region} #{common_options}"
|
69
|
+
unless @dry_run
|
70
|
+
status, cmd_output = execute(cmd)
|
71
|
+
fail "FATAL: unable to upload S3 image bundle" unless status.exitstatus == 0
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def bundle_image(name)
|
76
|
+
@log.info "Using #{@bundle_dir} locally to store image bundle."
|
77
|
+
@log.info "Running EC2 Bundle command..."
|
78
|
+
FileUtils.rm_rf(@bundle_dir)
|
79
|
+
FileUtils.mkdir_p(@bundle_dir)
|
80
|
+
cmd="ec2-bundle-vol --privatekey #{@key_file} --cert #{@cert_file} --user #{@aws_account_number} --destination #{@bundle_dir} --arch x86_64 --kernel #{kernel_aki} -B 'ami=sda,root=/dev/sda,ephemeral0=sdb,swap=sda3' #{excludes} --no-inherit #{common_options}"
|
81
|
+
cmd << " --no-filter" if @no_filter
|
82
|
+
unless @dry_run
|
83
|
+
status, cmd_output = execute(cmd)
|
84
|
+
fail "FATAL: unable to bundle new image" unless status.exitstatus == 0
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# What directories should be excluded from bundle
|
89
|
+
# NOTE: be sure to exclude the directory containing the key_file and cert_file x.509 creds
|
90
|
+
#
|
91
|
+
def excludes
|
92
|
+
excludes = ""
|
93
|
+
[ File.dirname(@key_file), File.dirname(@cert_file), "/mnt", @bundle_dir ].uniq.each do |dir|
|
94
|
+
excludes += "--exclude #{dir} "
|
95
|
+
end
|
96
|
+
excludes
|
97
|
+
end
|
98
|
+
|
99
|
+
# remove bundle directory
|
100
|
+
#
|
101
|
+
def cleanup_bundle
|
102
|
+
FileUtils.rm_rf(@bundle_dir)
|
103
|
+
end
|
104
|
+
|
105
|
+
def s3_url(region)
|
106
|
+
if region == "us-east-1"
|
107
|
+
"https://s3.amazonaws.com"
|
108
|
+
else
|
109
|
+
"https://s3-#{region}.amazonaws.com"
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
end
|
114
|
+
end
|