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.
@@ -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