smart_proxy_remote_execution_ssh 0.0.1
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 +15 -0
- data/Gemfile +10 -0
- data/LICENSE +674 -0
- data/bundler.d/Gemfile.local.rb +1 -0
- data/bundler.d/remote_execution_ssh.rb +1 -0
- data/lib/smart_proxy_remote_execution_ssh.rb +44 -0
- data/lib/smart_proxy_remote_execution_ssh/api.rb +9 -0
- data/lib/smart_proxy_remote_execution_ssh/command_action.rb +75 -0
- data/lib/smart_proxy_remote_execution_ssh/connector.rb +182 -0
- data/lib/smart_proxy_remote_execution_ssh/dispatcher.rb +235 -0
- data/lib/smart_proxy_remote_execution_ssh/http_config.ru +4 -0
- data/lib/smart_proxy_remote_execution_ssh/plugin.rb +15 -0
- data/lib/smart_proxy_remote_execution_ssh/version.rb +7 -0
- data/settings.d/ssh.yml.example +4 -0
- metadata +200 -0
@@ -0,0 +1 @@
|
|
1
|
+
gem 'pry'
|
@@ -0,0 +1 @@
|
|
1
|
+
gem 'smart_proxy_remote_execution_ssh'
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'smart_proxy_dynflow'
|
2
|
+
|
3
|
+
require 'smart_proxy_remote_execution_ssh/version'
|
4
|
+
require 'smart_proxy_remote_execution_ssh/plugin'
|
5
|
+
|
6
|
+
require 'smart_proxy_remote_execution_ssh/dispatcher'
|
7
|
+
require 'smart_proxy_remote_execution_ssh/command_action'
|
8
|
+
|
9
|
+
require 'smart_proxy_remote_execution_ssh/api'
|
10
|
+
|
11
|
+
module Proxy::RemoteExecution
|
12
|
+
module Ssh
|
13
|
+
class << self
|
14
|
+
attr_reader :dispatcher
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
unless private_key_file
|
18
|
+
raise "settings for `ssh_identity_key` not set"
|
19
|
+
end
|
20
|
+
|
21
|
+
unless File.exist?(private_key_file)
|
22
|
+
raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
|
23
|
+
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
|
24
|
+
end
|
25
|
+
|
26
|
+
unless File.exist?(public_key_file)
|
27
|
+
raise "Ssh public key file #{public_key_file} doesn't exist"
|
28
|
+
end
|
29
|
+
|
30
|
+
@dispatcher = Proxy::RemoteExecution::Ssh::Dispatcher.spawn('proxy-ssh-dispatcher',
|
31
|
+
:clock => Proxy::Dynflow.instance.world.clock,
|
32
|
+
:logger => Proxy::Dynflow.instance.world.logger)
|
33
|
+
end
|
34
|
+
|
35
|
+
def private_key_file
|
36
|
+
File.expand_path(Ssh::Plugin.settings.ssh_identity_key_file)
|
37
|
+
end
|
38
|
+
|
39
|
+
def public_key_file
|
40
|
+
File.expand_path("#{private_key_file}.pub")
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Proxy::RemoteExecution::Ssh
|
2
|
+
class CommandAction < ::Dynflow::Action
|
3
|
+
include Dynflow::Action::Cancellable
|
4
|
+
include ::Proxy::Dynflow::Callback::PlanHelper
|
5
|
+
|
6
|
+
def plan(input)
|
7
|
+
if callback = input['callback']
|
8
|
+
input[:task_id] = callback['task_id']
|
9
|
+
else
|
10
|
+
input[:task_id] ||= SecureRandom.uuid
|
11
|
+
end
|
12
|
+
plan_with_callback(input)
|
13
|
+
end
|
14
|
+
|
15
|
+
def run(event = nil)
|
16
|
+
case event
|
17
|
+
when nil
|
18
|
+
init_run
|
19
|
+
when Dispatcher::CommandUpdate
|
20
|
+
update = event
|
21
|
+
output[:result].concat(update.buffer_to_hash)
|
22
|
+
if update.exit_status
|
23
|
+
finish_run(update)
|
24
|
+
else
|
25
|
+
suspend
|
26
|
+
end
|
27
|
+
when Dynflow::Action::Cancellable::Cancel
|
28
|
+
kill_run
|
29
|
+
when Dynflow::Action::Skip
|
30
|
+
# do nothing
|
31
|
+
else
|
32
|
+
raise "Unexpected event #{event.inspect}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def finalize
|
37
|
+
# To mark the task as a whole as failed
|
38
|
+
error! "Script execution failed" if failed_run?
|
39
|
+
end
|
40
|
+
|
41
|
+
def rescue_strategy_for_self
|
42
|
+
Dynflow::Action::Rescue::Skip
|
43
|
+
end
|
44
|
+
|
45
|
+
def command
|
46
|
+
@command ||= Dispatcher::Command.new(:id => input[:task_id],
|
47
|
+
:host => input[:hostname],
|
48
|
+
:ssh_user => 'root',
|
49
|
+
:effective_user => input[:effective_user],
|
50
|
+
:script => input[:script],
|
51
|
+
:host_public_key => input[:host_public_key],
|
52
|
+
:verify_host => input[:verify_host],
|
53
|
+
:suspended_action => suspended_action)
|
54
|
+
end
|
55
|
+
|
56
|
+
def init_run
|
57
|
+
output[:result] = []
|
58
|
+
Proxy::RemoteExecution::Ssh.dispatcher.tell([:initialize_command, command])
|
59
|
+
suspend
|
60
|
+
end
|
61
|
+
|
62
|
+
def kill_run
|
63
|
+
Proxy::RemoteExecution::Ssh.dispatcher.tell([:kill, command])
|
64
|
+
suspend
|
65
|
+
end
|
66
|
+
|
67
|
+
def finish_run(update)
|
68
|
+
output[:exit_status] = update.exit_status
|
69
|
+
end
|
70
|
+
|
71
|
+
def failed_run?
|
72
|
+
output[:exit_status] != 0
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'net/scp'
|
3
|
+
|
4
|
+
module Proxy::RemoteExecution::Ssh
|
5
|
+
# Service that handles running external commands for Actions::Command
|
6
|
+
# Dynflow action. It runs just one (actor) thread for all the commands
|
7
|
+
# running in the system and updates the Dynflow actions periodically.
|
8
|
+
class Connector
|
9
|
+
class Data
|
10
|
+
attr_reader :data, :timestamp
|
11
|
+
|
12
|
+
def initialize(data, timestamp = Time.now)
|
13
|
+
@data = data
|
14
|
+
@timestamp = timestamp
|
15
|
+
end
|
16
|
+
|
17
|
+
def data_type
|
18
|
+
raise NotImplemented
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class StdoutData < Data
|
23
|
+
def data_type
|
24
|
+
:stdout
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
class StderrData < Data
|
29
|
+
def data_type
|
30
|
+
:stderr
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class DebugData < Data
|
35
|
+
def data_type
|
36
|
+
:debug
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class StatusData < Data
|
41
|
+
def data_type
|
42
|
+
:status
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
MAX_PROCESS_RETRIES = 3
|
47
|
+
|
48
|
+
def initialize(host, user, options = {})
|
49
|
+
@host = host
|
50
|
+
@user = user
|
51
|
+
@logger = options[:logger] || Logger.new($stderr)
|
52
|
+
@client_private_key_file = options[:client_private_key_file]
|
53
|
+
@known_hosts_file = options[:known_hosts_file]
|
54
|
+
end
|
55
|
+
|
56
|
+
# Initiates run of the remote command and yields the data when
|
57
|
+
# available. The yielding doesn't happen automatically, but as
|
58
|
+
# part of calling the `refresh` method.
|
59
|
+
def async_run(command)
|
60
|
+
started = false
|
61
|
+
session.open_channel do |channel|
|
62
|
+
channel.on_data { |ch, data| yield StdoutData.new(data) }
|
63
|
+
|
64
|
+
channel.on_extended_data { |ch, type, data| yield StderrData.new(data) }
|
65
|
+
|
66
|
+
# standard exit of the command
|
67
|
+
channel.on_request("exit-status") { |ch, data| yield StatusData.new(data.read_long) }
|
68
|
+
|
69
|
+
# on signal: sedning the signal value (such as 'TERM')
|
70
|
+
channel.on_request("exit-signal") do |ch, data|
|
71
|
+
yield(StatusData.new(data.read_string))
|
72
|
+
ch.close
|
73
|
+
# wait for the channel to finish so that we know at the end
|
74
|
+
# that the session is inactive
|
75
|
+
ch.wait
|
76
|
+
end
|
77
|
+
|
78
|
+
channel.exec(command) do |ch, success|
|
79
|
+
started = true
|
80
|
+
unless success
|
81
|
+
yield DebugData.new("FAILED: couldn't execute command (ssh.channel.exec)")
|
82
|
+
yield StatusData.new("INIT_ERROR")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
session.process(0) until started
|
87
|
+
return true
|
88
|
+
end
|
89
|
+
|
90
|
+
def run(command)
|
91
|
+
output = ""
|
92
|
+
exit_status = nil
|
93
|
+
channel = session.open_channel do |ch|
|
94
|
+
ch.on_data { |data| output.concat(data) }
|
95
|
+
|
96
|
+
ch.on_extended_data { |_, _, data| output.concat(data) }
|
97
|
+
|
98
|
+
ch.on_request("exit-status") { |_, data| exit_status = data.read_long }
|
99
|
+
|
100
|
+
# on signal: sedning the signal value (such as 'TERM')
|
101
|
+
ch.on_request("exit-signal") do |_, data|
|
102
|
+
exit_status = data.read_string
|
103
|
+
ch.close
|
104
|
+
ch.wait
|
105
|
+
end
|
106
|
+
|
107
|
+
ch.exec command do |_, success|
|
108
|
+
raise "could not execute command" unless success
|
109
|
+
end
|
110
|
+
end
|
111
|
+
channel.wait
|
112
|
+
return exit_status, output
|
113
|
+
end
|
114
|
+
|
115
|
+
# calls the callback registered in the `async_run` when some data
|
116
|
+
# for the session are available
|
117
|
+
def refresh
|
118
|
+
return if @session.nil?
|
119
|
+
tries = 0
|
120
|
+
begin
|
121
|
+
session.process(0)
|
122
|
+
rescue => e
|
123
|
+
@logger.error("Error while processing ssh channel: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
|
124
|
+
tries += 1
|
125
|
+
if tries <= MAX_PROCESS_RETRIES
|
126
|
+
retry
|
127
|
+
else
|
128
|
+
raise e
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def upload_file(local_path, remote_path)
|
134
|
+
ensure_remote_directory(File.dirname(remote_path))
|
135
|
+
scp = Net::SCP.new(session)
|
136
|
+
upload_channel = scp.upload(local_path, remote_path)
|
137
|
+
upload_channel.wait
|
138
|
+
ensure
|
139
|
+
if upload_channel
|
140
|
+
upload_channel.close
|
141
|
+
upload_channel.wait
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def ensure_remote_directory(path)
|
146
|
+
exit_code, output = run("mkdir -p #{path}")
|
147
|
+
if exit_code != 0
|
148
|
+
raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{output}"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def inactive?
|
153
|
+
@session.nil? || @session.channels.empty?
|
154
|
+
end
|
155
|
+
|
156
|
+
def close
|
157
|
+
@logger.debug("closing session to #{@user}@#{@host}")
|
158
|
+
@session.close unless @session.nil? || @session.closed?
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def session
|
164
|
+
@session ||= begin
|
165
|
+
@logger.debug("opening session to #{@user}@#{@host}")
|
166
|
+
Net::SSH.start(@host, @user, ssh_options)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def ssh_options
|
171
|
+
ssh_options = {}
|
172
|
+
ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
|
173
|
+
ssh_options[:user_known_hosts_file] = @known_hosts_file if @known_hosts_file
|
174
|
+
ssh_options[:keys_only] = true
|
175
|
+
# if the host public key is contained in the known_hosts_file,
|
176
|
+
# verify it, otherwise, if missing, import it and continue
|
177
|
+
ssh_options[:paranoid] = true
|
178
|
+
ssh_options[:auth_methods] = ["publickey"]
|
179
|
+
return ssh_options
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,235 @@
|
|
1
|
+
require 'smart_proxy_remote_execution_ssh/connector'
|
2
|
+
|
3
|
+
module Proxy::RemoteExecution::Ssh
|
4
|
+
# Service that handles running external commands for Actions::Command
|
5
|
+
# Dynflow action. It runs just one (actor) thread for all the commands
|
6
|
+
# running in the system and updates the Dynflow actions periodically.
|
7
|
+
class Dispatcher < ::Dynflow::Actor
|
8
|
+
# command comming from action
|
9
|
+
class Command
|
10
|
+
attr_reader :id, :host, :ssh_user, :effective_user, :script, :host_public_key, :suspended_action
|
11
|
+
|
12
|
+
def initialize(data)
|
13
|
+
validate!(data)
|
14
|
+
|
15
|
+
@id = data[:id]
|
16
|
+
@host = data[:host]
|
17
|
+
@ssh_user = data[:ssh_user]
|
18
|
+
@effective_user = data[:effective_user]
|
19
|
+
@script = data[:script]
|
20
|
+
@host_public_key = data[:host_public_key]
|
21
|
+
@suspended_action = data[:suspended_action]
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate!(data)
|
25
|
+
required_fields = [:id, :host, :ssh_user, :script, :suspended_action]
|
26
|
+
missing_fields = required_fields.find_all { |f| !data[f] }
|
27
|
+
raise ArgumentError, "Missing fields: #{missing_fields}" unless missing_fields.empty?
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# update sent back to the suspended action
|
32
|
+
class CommandUpdate
|
33
|
+
attr_reader :buffer, :exit_status
|
34
|
+
|
35
|
+
def initialize(buffer, exit_status)
|
36
|
+
@buffer = buffer
|
37
|
+
@exit_status = exit_status
|
38
|
+
end
|
39
|
+
|
40
|
+
def buffer_to_hash
|
41
|
+
buffer.map do |buffer_data|
|
42
|
+
{ :output_type => buffer_data.data_type,
|
43
|
+
:output => buffer_data.data,
|
44
|
+
:timestamp => buffer_data.timestamp.to_f }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize(options = {})
|
50
|
+
@clock = options[:clock] || Dynflow::Clock.spawn('proxy-dispatcher-clock')
|
51
|
+
@logger = options[:logger] || Logger.new($stderr)
|
52
|
+
@connector_class = options[:connector_class] || Connector
|
53
|
+
@local_working_dir = options[:local_working_dir] || '/tmp/foreman-proxy-ssh/server'
|
54
|
+
@remote_working_dir = options[:remote_working_dir] || '/tmp/foreman-proxy-ssh/client'
|
55
|
+
@refresh_interval = options[:refresh_interval] || 1
|
56
|
+
@client_private_key_file = Proxy::RemoteExecution::Ssh.private_key_file
|
57
|
+
|
58
|
+
@connectors = {}
|
59
|
+
@command_buffer = Hash.new { |h, k| h[k] = [] }
|
60
|
+
@refresh_planned = false
|
61
|
+
end
|
62
|
+
|
63
|
+
def initialize_command(command)
|
64
|
+
@logger.debug("initalizing command [#{command}]")
|
65
|
+
connector = self.connector_for_command(command)
|
66
|
+
remote_script = cp_script_to_remote(connector, command)
|
67
|
+
if command.effective_user && command.effective_user != command.ssh_user
|
68
|
+
su_prefix = "su - #{command.effective_user} -c "
|
69
|
+
end
|
70
|
+
output_path = File.join(File.dirname(remote_script), 'output')
|
71
|
+
|
72
|
+
connector.async_run("#{su_prefix}#{remote_script} | /usr/bin/tee #{output_path}") do |data|
|
73
|
+
command_buffer(command) << data
|
74
|
+
end
|
75
|
+
rescue => e
|
76
|
+
@logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
|
77
|
+
command_buffer(command).concat([Connector::DebugData.new("Exception: #{e.class} #{e.message}"),
|
78
|
+
Connector::StatusData.new('INIT_ERROR')])
|
79
|
+
ensure
|
80
|
+
plan_next_refresh
|
81
|
+
end
|
82
|
+
|
83
|
+
def refresh
|
84
|
+
finished_commands = []
|
85
|
+
refresh_connectors
|
86
|
+
|
87
|
+
@command_buffer.each do |command, buffer|
|
88
|
+
unless buffer.empty?
|
89
|
+
status = refresh_command_buffer(command, buffer)
|
90
|
+
if status
|
91
|
+
finished_commands << command
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
finished_commands.each { |command| finish_command(command) }
|
97
|
+
close_inactive_connectors
|
98
|
+
ensure
|
99
|
+
@refresh_planned = false
|
100
|
+
plan_next_refresh
|
101
|
+
end
|
102
|
+
|
103
|
+
def refresh_command_buffer(command, buffer)
|
104
|
+
status = nil
|
105
|
+
@logger.debug("command #{command} got new output: #{buffer.inspect}")
|
106
|
+
buffer.delete_if do |data|
|
107
|
+
if data.is_a? Connector::StatusData
|
108
|
+
status = data.data
|
109
|
+
true
|
110
|
+
end
|
111
|
+
end
|
112
|
+
command.suspended_action << CommandUpdate.new(buffer, status)
|
113
|
+
clear_command(command)
|
114
|
+
if status
|
115
|
+
@logger.debug("command [#{command}] finished with status #{status}")
|
116
|
+
return status
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def kill(command)
|
121
|
+
@logger.debug("killing command [#{command}]")
|
122
|
+
connector_for_command(command).run("pkill -f #{remote_command_file(command, 'script')}")
|
123
|
+
end
|
124
|
+
|
125
|
+
protected
|
126
|
+
|
127
|
+
def connector_for_command(command, only_if_exists = false)
|
128
|
+
if connector = @connectors[[command.host, command.ssh_user]]
|
129
|
+
return connector
|
130
|
+
end
|
131
|
+
return nil if only_if_exists
|
132
|
+
@connectors[[command.host, command.ssh_user]] = open_connector(command)
|
133
|
+
end
|
134
|
+
|
135
|
+
def local_command_dir(command)
|
136
|
+
File.join(@local_working_dir, command.id)
|
137
|
+
end
|
138
|
+
|
139
|
+
def local_command_file(command, filename)
|
140
|
+
File.join(local_command_dir(command), filename)
|
141
|
+
end
|
142
|
+
|
143
|
+
def remote_command_dir(command)
|
144
|
+
File.join(@remote_working_dir, command.id)
|
145
|
+
end
|
146
|
+
|
147
|
+
def remote_command_file(command, filename)
|
148
|
+
File.join(remote_command_dir(command), filename)
|
149
|
+
end
|
150
|
+
|
151
|
+
def ensure_local_directory(path)
|
152
|
+
if File.exist?(path)
|
153
|
+
raise "#{path} expected to be a directory" unless File.directory?(path)
|
154
|
+
else
|
155
|
+
FileUtils.mkdir_p(path)
|
156
|
+
end
|
157
|
+
return path
|
158
|
+
end
|
159
|
+
|
160
|
+
def cp_script_to_remote(connector, command)
|
161
|
+
local_script_file = write_command_file_locally(command, 'script', command.script)
|
162
|
+
File.chmod(0777, local_script_file)
|
163
|
+
remote_script_file = remote_command_file(command, 'script')
|
164
|
+
connector.upload_file(local_script_file, remote_script_file)
|
165
|
+
return remote_script_file
|
166
|
+
end
|
167
|
+
|
168
|
+
def write_command_file_locally(command, filename, content)
|
169
|
+
path = local_command_file(command, filename)
|
170
|
+
ensure_local_directory(File.dirname(path))
|
171
|
+
File.write(path, content)
|
172
|
+
return path
|
173
|
+
end
|
174
|
+
|
175
|
+
def open_connector(command)
|
176
|
+
options = { :logger => @logger }
|
177
|
+
options[:known_hosts_file] = prepare_known_hosts(command)
|
178
|
+
options[:client_private_key_file] = @client_private_key_file
|
179
|
+
@connector_class.new(command.host, command.ssh_user, options)
|
180
|
+
end
|
181
|
+
|
182
|
+
def prepare_known_hosts(command)
|
183
|
+
path = local_command_file(command, 'known_hosts')
|
184
|
+
if command.host_public_key
|
185
|
+
write_command_file_locally(command, 'known_hosts', "#{command.host} #{command.host_public_key}")
|
186
|
+
end
|
187
|
+
return path
|
188
|
+
end
|
189
|
+
|
190
|
+
def close_inactive_connectors
|
191
|
+
@connectors.delete_if do |_, connector|
|
192
|
+
if connector.inactive?
|
193
|
+
connector.close
|
194
|
+
true
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def refresh_connectors
|
200
|
+
@logger.debug("refreshing #{@connectors.size} connectors")
|
201
|
+
|
202
|
+
@connectors.values.each do |connector|
|
203
|
+
begin
|
204
|
+
connector.refresh
|
205
|
+
rescue => e
|
206
|
+
@command_buffer.each do |command, buffer|
|
207
|
+
if connector_for_command(command, false)
|
208
|
+
buffer << Connector::DebugData.new("Exception: #{e.class} #{e.message}")
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def command_buffer(command)
|
216
|
+
@command_buffer[command]
|
217
|
+
end
|
218
|
+
|
219
|
+
def clear_command(command)
|
220
|
+
@command_buffer[command] = []
|
221
|
+
end
|
222
|
+
|
223
|
+
def finish_command(command)
|
224
|
+
@command_buffer.delete(command)
|
225
|
+
end
|
226
|
+
|
227
|
+
def plan_next_refresh
|
228
|
+
if @connectors.any? && !@refresh_planned
|
229
|
+
@logger.debug("planning to refresh")
|
230
|
+
@clock.ping(reference, Time.now + @refresh_interval, :refresh)
|
231
|
+
@refresh_planned = true
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|