smart_proxy_remote_execution_ssh_core 0.0.13
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://img.shields.io/jenkins/s/http/ci.theforeman.org/test_plugin_smart_proxy_remote_execution_ssh_master.svg)](http://ci.theforeman.org/job/test_plugin_smart_proxy_remote_execution_ssh_master)
|
2
|
+
[![Gem Version](https://img.shields.io/gem/v/smart_proxy_remote_execution_ssh.svg)](https://rubygems.org/gems/smart_proxy_remote_execution_ssh)
|
3
|
+
[![Code Climate](https://codeclimate.com/github/theforeman/smart_proxy_remote_execution_ssh/badges/gpa.svg)](https://codeclimate.com/github/theforeman/smart_proxy_remote_execution_ssh)
|
4
|
+
[![GPL License](https://img.shields.io/github/license/theforeman/smart_proxy_remote_execution_ssh.svg)](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
|