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.
@@ -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,9 @@
1
+ module Proxy::RemoteExecution
2
+ module Ssh
3
+ class Api < ::Sinatra::Base
4
+ get "/pubkey" do
5
+ File.read(Ssh.public_key_file)
6
+ end
7
+ end
8
+ end
9
+ 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