smart_proxy_remote_execution_ssh_core 0.0.13
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/LICENSE +674 -0
- data/README.md +57 -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_core.rb +49 -0
- data/lib/smart_proxy_remote_execution_ssh_core/command_action.rb +86 -0
- data/lib/smart_proxy_remote_execution_ssh_core/command_update.rb +78 -0
- data/lib/smart_proxy_remote_execution_ssh_core/connector.rb +151 -0
- data/lib/smart_proxy_remote_execution_ssh_core/dispatcher.rb +97 -0
- data/lib/smart_proxy_remote_execution_ssh_core/session.rb +215 -0
- data/lib/smart_proxy_remote_execution_ssh_core/settings.rb +41 -0
- data/lib/smart_proxy_remote_execution_ssh_core/version.rb +9 -0
- data/settings.d/remote_execution_ssh.yml.example +5 -0
- data/settings.d/smart_proxy_remote_execution_ssh.yml.example +5 -0
- metadata +201 -0
data/README.md
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
[](http://ci.theforeman.org/job/test_plugin_smart_proxy_remote_execution_ssh_master)
|
2
|
+
[](https://rubygems.org/gems/smart_proxy_remote_execution_ssh)
|
3
|
+
[](https://codeclimate.com/github/theforeman/smart_proxy_remote_execution_ssh)
|
4
|
+
[](https://github.com/theforeman/smart_proxy_remote_execution_ssh/blob/master/LICENSE)
|
5
|
+
|
6
|
+
# Smart-proxy Ssh plugin
|
7
|
+
|
8
|
+
This a plugin for foreman smart-proxy allowing using ssh for the
|
9
|
+
[remote execution](http://theforeman.github.io/foreman_remote_execution/)
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your smart proxy bundler.d/ssh.rb gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'smart_proxy_dynflow', :git => 'https://github.com/iNecas/smart_proxy_dynflow.git'
|
17
|
+
gem 'smart_proxy_ssh', :git => 'https://github.com/iNecas/smart_proxy_ssh.git'
|
18
|
+
```
|
19
|
+
|
20
|
+
Enable the plugins in your smart proxy:
|
21
|
+
|
22
|
+
```bash
|
23
|
+
cat > config/settings.d/dynflow.yml <<EOF
|
24
|
+
---
|
25
|
+
:enabled: true
|
26
|
+
EOF
|
27
|
+
|
28
|
+
cat > config/settings.d/remote_execution_ssh.yml <<EOF
|
29
|
+
---
|
30
|
+
:enabled: true
|
31
|
+
EOF
|
32
|
+
```
|
33
|
+
|
34
|
+
Install the dependencies
|
35
|
+
|
36
|
+
$ bundle
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
To configure this plugin you can use template from settings.d/remote_execution_ssh.yml.example.
|
41
|
+
You must place remote_execution_ssh.yml config file (based on this template) to your
|
42
|
+
smart-proxy config/settings.d/ directory.
|
43
|
+
|
44
|
+
Also, you need to have the `dynflow` plugin enabled to be able to
|
45
|
+
trigger the tasks.
|
46
|
+
|
47
|
+
The simplest thing one can do is just to trigger a command:
|
48
|
+
|
49
|
+
```
|
50
|
+
curl http://my-proxy.example.com:9292/dynflow/tasks \
|
51
|
+
-X POST -H 'Content-Type: application/json'\
|
52
|
+
-d '{"action_name": "Proxy::RemoteExecution::Ssh::CommandAction",
|
53
|
+
"action_input": {"task_id" : "1234'$RANDOM'",
|
54
|
+
"script": "/usr/bin/ls",
|
55
|
+
"hostname": "localhost",
|
56
|
+
"effective_user": "root"}}'
|
57
|
+
```
|
@@ -0,0 +1 @@
|
|
1
|
+
gem 'pry'
|
@@ -0,0 +1 @@
|
|
1
|
+
gem 'smart_proxy_remote_execution_ssh'
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'smart_proxy_remote_execution_ssh_core/settings'
|
2
|
+
require 'smart_proxy_remote_execution_ssh_core/version'
|
3
|
+
require 'smart_proxy_dynflow_core'
|
4
|
+
require 'smart_proxy_remote_execution_ssh_core/command_action'
|
5
|
+
require 'smart_proxy_remote_execution_ssh_core/command_update'
|
6
|
+
require 'smart_proxy_remote_execution_ssh_core/connector'
|
7
|
+
require 'smart_proxy_remote_execution_ssh_core/dispatcher'
|
8
|
+
require 'smart_proxy_remote_execution_ssh_core/session'
|
9
|
+
|
10
|
+
module Proxy
|
11
|
+
module RemoteExecution
|
12
|
+
module Ssh
|
13
|
+
class << self
|
14
|
+
def initialize
|
15
|
+
unless private_key_file
|
16
|
+
raise "settings for `ssh_identity_key` not set"
|
17
|
+
end
|
18
|
+
|
19
|
+
unless File.exist?(private_key_file)
|
20
|
+
raise "Ssh public key file #{private_key_file} doesn't exist.\n"\
|
21
|
+
"You can generate one with `ssh-keygen -t rsa -b 4096 -f #{private_key_file} -N ''`"
|
22
|
+
end
|
23
|
+
|
24
|
+
unless File.exist?(public_key_file)
|
25
|
+
raise "Ssh public key file #{public_key_file} doesn't exist"
|
26
|
+
end
|
27
|
+
|
28
|
+
@dispatcher = Proxy::RemoteExecution::Ssh::Dispatcher.spawn('proxy-ssh-dispatcher',
|
29
|
+
:clock => SmartProxyDynflowCore::Core.instance.world.clock,
|
30
|
+
:logger => SmartProxyDynflowCore::Core.instance.world.logger)
|
31
|
+
end
|
32
|
+
|
33
|
+
def dispatcher
|
34
|
+
@dispatcher || initialize
|
35
|
+
end
|
36
|
+
|
37
|
+
def private_key_file
|
38
|
+
File.expand_path(Settings.instance.ssh_identity_key_file)
|
39
|
+
end
|
40
|
+
|
41
|
+
def public_key_file
|
42
|
+
File.expand_path("#{private_key_file}.pub")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
SmartProxyDynflowCore::Core.after_initialize { Proxy::RemoteExecution::Ssh.initialize }
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Proxy
|
2
|
+
module RemoteExecution
|
3
|
+
module Ssh
|
4
|
+
class CommandAction < ::Dynflow::Action
|
5
|
+
include Dynflow::Action::Cancellable
|
6
|
+
include ::SmartProxyDynflowCore::Callback::PlanHelper
|
7
|
+
|
8
|
+
def plan(input)
|
9
|
+
if callback = input['callback']
|
10
|
+
input[:task_id] = callback['task_id']
|
11
|
+
else
|
12
|
+
input[:task_id] ||= SecureRandom.uuid
|
13
|
+
end
|
14
|
+
plan_with_callback(input)
|
15
|
+
end
|
16
|
+
|
17
|
+
def run(event = nil)
|
18
|
+
case event
|
19
|
+
when nil
|
20
|
+
init_run
|
21
|
+
when CommandUpdate
|
22
|
+
process_update(event)
|
23
|
+
when Dynflow::Action::Cancellable::Cancel
|
24
|
+
kill_run
|
25
|
+
when Dynflow::Action::Skip
|
26
|
+
# do nothing
|
27
|
+
else
|
28
|
+
raise "Unexpected event #{event.inspect}"
|
29
|
+
end
|
30
|
+
rescue => e
|
31
|
+
action_logger.error(e)
|
32
|
+
process_update(CommandUpdate.new(CommandUpdate.encode_exception("Proxy error", e)))
|
33
|
+
end
|
34
|
+
|
35
|
+
def finalize
|
36
|
+
# To mark the task as a whole as failed
|
37
|
+
error! "Script execution failed" if failed_run?
|
38
|
+
end
|
39
|
+
|
40
|
+
def rescue_strategy_for_self
|
41
|
+
Dynflow::Action::Rescue::Skip
|
42
|
+
end
|
43
|
+
|
44
|
+
def command
|
45
|
+
@command ||= Dispatcher::Command.new(:id => input[:task_id],
|
46
|
+
:host => input[:hostname],
|
47
|
+
:ssh_user => input[:ssh_user] || 'root',
|
48
|
+
:effective_user => input[:effective_user],
|
49
|
+
:script => input[:script],
|
50
|
+
:effective_user_method => input[:effective_user_method],
|
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 process_update(update)
|
72
|
+
output[:result].concat(update.buffer_to_hash)
|
73
|
+
if update.exit_status
|
74
|
+
finish_run(update)
|
75
|
+
else
|
76
|
+
suspend
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def failed_run?
|
81
|
+
output[:exit_status] != 0
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module Proxy
|
2
|
+
module RemoteExecution
|
3
|
+
module Ssh
|
4
|
+
# update sent back to the suspended action
|
5
|
+
class CommandUpdate
|
6
|
+
attr_reader :buffer, :exit_status
|
7
|
+
|
8
|
+
def initialize(buffer)
|
9
|
+
@buffer = buffer
|
10
|
+
extract_exit_status
|
11
|
+
end
|
12
|
+
|
13
|
+
def extract_exit_status
|
14
|
+
@buffer.delete_if do |data|
|
15
|
+
if data.is_a? StatusData
|
16
|
+
@exit_status = data.data
|
17
|
+
true
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def buffer_to_hash
|
23
|
+
buffer.map(&:to_hash)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.encode_exception(description, exception, fatal = true)
|
27
|
+
ret = [DebugData.new("#{description}\n#{exception.class} #{exception.message}")]
|
28
|
+
ret << StatusData.new('EXCEPTION') if fatal
|
29
|
+
return ret
|
30
|
+
end
|
31
|
+
|
32
|
+
class Data
|
33
|
+
attr_reader :data, :timestamp
|
34
|
+
|
35
|
+
def initialize(data, timestamp = Time.now)
|
36
|
+
@data = data
|
37
|
+
@data = @data.force_encoding('UTF-8') if @data.is_a? String
|
38
|
+
@timestamp = timestamp
|
39
|
+
end
|
40
|
+
|
41
|
+
def data_type
|
42
|
+
raise NotImplemented
|
43
|
+
end
|
44
|
+
|
45
|
+
def to_hash
|
46
|
+
{ :output_type => data_type,
|
47
|
+
:output => data,
|
48
|
+
:timestamp => timestamp.to_f }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class StdoutData < Data
|
53
|
+
def data_type
|
54
|
+
:stdout
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class StderrData < Data
|
59
|
+
def data_type
|
60
|
+
:stderr
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
class DebugData < Data
|
65
|
+
def data_type
|
66
|
+
:debug
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class StatusData < Data
|
71
|
+
def data_type
|
72
|
+
:status
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'net/scp'
|
3
|
+
|
4
|
+
module Proxy
|
5
|
+
module RemoteExecution
|
6
|
+
module Ssh
|
7
|
+
# Service that handles running external commands for Actions::Command
|
8
|
+
# Dynflow action. It runs just one (actor) thread for all the commands
|
9
|
+
# running in the system and updates the Dynflow actions periodically.
|
10
|
+
class Connector
|
11
|
+
MAX_PROCESS_RETRIES = 3
|
12
|
+
|
13
|
+
def initialize(host, user, options = {})
|
14
|
+
@host = host
|
15
|
+
@user = user
|
16
|
+
@logger = options[:logger] || Logger.new($stderr)
|
17
|
+
@client_private_key_file = options[:client_private_key_file]
|
18
|
+
@known_hosts_file = options[:known_hosts_file]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Initiates run of the remote command and yields the data when
|
22
|
+
# available. The yielding doesn't happen automatically, but as
|
23
|
+
# part of calling the `refresh` method.
|
24
|
+
def async_run(command)
|
25
|
+
started = false
|
26
|
+
session.open_channel do |channel|
|
27
|
+
channel.request_pty
|
28
|
+
|
29
|
+
channel.on_data { |ch, data| yield CommandUpdate::StdoutData.new(data) }
|
30
|
+
|
31
|
+
channel.on_extended_data { |ch, type, data| yield CommandUpdate::StderrData.new(data) }
|
32
|
+
|
33
|
+
# standard exit of the command
|
34
|
+
channel.on_request("exit-status") { |ch, data| yield CommandUpdate::StatusData.new(data.read_long) }
|
35
|
+
|
36
|
+
# on signal: sedning the signal value (such as 'TERM')
|
37
|
+
channel.on_request("exit-signal") do |ch, data|
|
38
|
+
yield(CommandUpdate::StatusData.new(data.read_string))
|
39
|
+
ch.close
|
40
|
+
# wait for the channel to finish so that we know at the end
|
41
|
+
# that the session is inactive
|
42
|
+
ch.wait
|
43
|
+
end
|
44
|
+
|
45
|
+
channel.exec(command) do |ch, success|
|
46
|
+
started = true
|
47
|
+
unless success
|
48
|
+
CommandUpdate.encode_exception("Error initializing command #{command}", e).each do |data|
|
49
|
+
yield data
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
session.process(0) until started
|
55
|
+
return true
|
56
|
+
end
|
57
|
+
|
58
|
+
def run(command)
|
59
|
+
output = ""
|
60
|
+
exit_status = nil
|
61
|
+
channel = session.open_channel do |ch|
|
62
|
+
ch.on_data { |data| output.concat(data) }
|
63
|
+
|
64
|
+
ch.on_extended_data { |_, _, data| output.concat(data) }
|
65
|
+
|
66
|
+
ch.on_request("exit-status") { |_, data| exit_status = data.read_long }
|
67
|
+
|
68
|
+
# on signal: sedning the signal value (such as 'TERM')
|
69
|
+
ch.on_request("exit-signal") do |_, data|
|
70
|
+
exit_status = data.read_string
|
71
|
+
ch.close
|
72
|
+
ch.wait
|
73
|
+
end
|
74
|
+
|
75
|
+
ch.exec command do |_, success|
|
76
|
+
raise "could not execute command" unless success
|
77
|
+
end
|
78
|
+
end
|
79
|
+
channel.wait
|
80
|
+
return exit_status, output
|
81
|
+
end
|
82
|
+
|
83
|
+
# calls the callback registered in the `async_run` when some data
|
84
|
+
# for the session are available
|
85
|
+
def refresh
|
86
|
+
return if @session.nil?
|
87
|
+
tries = 0
|
88
|
+
begin
|
89
|
+
session.process(0)
|
90
|
+
rescue Net::SSH::Disconnect => e
|
91
|
+
session.shutdown!
|
92
|
+
raise e
|
93
|
+
rescue => e
|
94
|
+
@logger.error("Error while processing ssh channel: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
|
95
|
+
tries += 1
|
96
|
+
if tries <= MAX_PROCESS_RETRIES
|
97
|
+
retry
|
98
|
+
else
|
99
|
+
raise e
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def upload_file(local_path, remote_path)
|
105
|
+
ensure_remote_directory(File.dirname(remote_path))
|
106
|
+
scp = Net::SCP.new(session)
|
107
|
+
upload_channel = scp.upload(local_path, remote_path)
|
108
|
+
upload_channel.wait
|
109
|
+
ensure
|
110
|
+
if upload_channel
|
111
|
+
upload_channel.close
|
112
|
+
upload_channel.wait
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def ensure_remote_directory(path)
|
117
|
+
exit_code, output = run("mkdir -p #{path}")
|
118
|
+
if exit_code != 0
|
119
|
+
raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{output}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def close
|
124
|
+
@logger.debug("closing session to #{@user}@#{@host}")
|
125
|
+
@session.close unless @session.nil? || @session.closed?
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
|
130
|
+
def session
|
131
|
+
@session ||= begin
|
132
|
+
@logger.debug("opening session to #{@user}@#{@host}")
|
133
|
+
Net::SSH.start(@host, @user, ssh_options)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def ssh_options
|
138
|
+
ssh_options = {}
|
139
|
+
ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
|
140
|
+
ssh_options[:user_known_hosts_file] = @known_hosts_file if @known_hosts_file
|
141
|
+
ssh_options[:keys_only] = true
|
142
|
+
# if the host public key is contained in the known_hosts_file,
|
143
|
+
# verify it, otherwise, if missing, import it and continue
|
144
|
+
ssh_options[:paranoid] = true
|
145
|
+
ssh_options[:auth_methods] = ["publickey"]
|
146
|
+
return ssh_options
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'smart_proxy_remote_execution_ssh_core/session'
|
2
|
+
|
3
|
+
module Proxy
|
4
|
+
module RemoteExecution
|
5
|
+
module Ssh
|
6
|
+
# Service that handles running external commands for Actions::Command
|
7
|
+
# Dynflow action. It runs just one (actor) thread for all the commands
|
8
|
+
# running in the system and updates the Dynflow actions periodically.
|
9
|
+
class Dispatcher < ::Dynflow::Actor
|
10
|
+
# command comming from action
|
11
|
+
class Command
|
12
|
+
attr_reader :id, :host, :ssh_user, :effective_user, :effective_user_method, :script, :host_public_key, :suspended_action
|
13
|
+
|
14
|
+
def initialize(data)
|
15
|
+
validate!(data)
|
16
|
+
|
17
|
+
@id = data[:id]
|
18
|
+
@host = data[:host]
|
19
|
+
@ssh_user = data[:ssh_user]
|
20
|
+
@effective_user = data[:effective_user]
|
21
|
+
@effective_user_method = data[:effective_user_method] || 'su'
|
22
|
+
@script = data[:script]
|
23
|
+
@host_public_key = data[:host_public_key]
|
24
|
+
@suspended_action = data[:suspended_action]
|
25
|
+
end
|
26
|
+
|
27
|
+
def validate!(data)
|
28
|
+
required_fields = [:id, :host, :ssh_user, :script, :suspended_action]
|
29
|
+
missing_fields = required_fields.find_all { |f| !data[f] }
|
30
|
+
raise ArgumentError, "Missing fields: #{missing_fields}" unless missing_fields.empty?
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def initialize(options = {})
|
35
|
+
@clock = options[:clock] || Dynflow::Clock.spawn('proxy-dispatcher-clock')
|
36
|
+
@logger = options[:logger] || Logger.new($stderr)
|
37
|
+
|
38
|
+
@session_args = { :logger => @logger,
|
39
|
+
:clock => @clock,
|
40
|
+
:connector_class => options[:connector_class] || Connector,
|
41
|
+
:local_working_dir => options[:local_working_dir] || Settings.instance.local_working_dir,
|
42
|
+
:remote_working_dir => options[:remote_working_dir] || Settings.instance.remote_working_dir,
|
43
|
+
:client_private_key_file => Settings.instance.ssh_identity_key_file,
|
44
|
+
:refresh_interval => options[:refresh_interval] || 1 }
|
45
|
+
|
46
|
+
@sessions = {}
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize_command(command)
|
50
|
+
@logger.debug("initalizing command [#{command}]")
|
51
|
+
open_session(command)
|
52
|
+
rescue => exception
|
53
|
+
handle_command_exception(command, exception)
|
54
|
+
end
|
55
|
+
|
56
|
+
def kill(command)
|
57
|
+
@logger.debug("killing command [#{command}]")
|
58
|
+
session = @sessions[command.id]
|
59
|
+
session.tell(:kill) if session
|
60
|
+
rescue => exception
|
61
|
+
handle_command_exception(command, exception, false)
|
62
|
+
end
|
63
|
+
|
64
|
+
def finish_command(command)
|
65
|
+
close_session(command)
|
66
|
+
rescue => exception
|
67
|
+
handle_command_exception(command, exception)
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
def handle_command_exception(command, exception, fatal = true)
|
73
|
+
@logger.error("error while dispatching command #{command} to session:"\
|
74
|
+
"#{exception.class} #{exception.message}:\n #{exception.backtrace.join("\n")}")
|
75
|
+
command_data = CommandUpdate.encode_exception("Failed to dispatch the command", exception, fatal)
|
76
|
+
command.suspended_action << CommandUpdate.new(command_data)
|
77
|
+
close_session(command) if fatal
|
78
|
+
end
|
79
|
+
|
80
|
+
def open_session(command)
|
81
|
+
raise "Session already opened for command #{command}" if @sessions[command.id]
|
82
|
+
options = { :name => "proxy-ssh-session-#{command.host}-#{command.ssh_user}-#{command.id}",
|
83
|
+
:args => [@session_args.merge(:command => command)],
|
84
|
+
:supervise => true }
|
85
|
+
@sessions[command.id] = Proxy::RemoteExecution::Ssh::Session.spawn(options)
|
86
|
+
end
|
87
|
+
|
88
|
+
def close_session(command)
|
89
|
+
session = @sessions.delete(command.id)
|
90
|
+
return unless session
|
91
|
+
@logger.debug("closing session for command [#{command}], #{@sessions.size} session(s) left ")
|
92
|
+
session.tell([:start_termination, Concurrent.future])
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|