smart_proxy_remote_execution_ssh 0.3.1 → 0.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9532552fe00ed7bf26c3755d710ad2ea87b4ac9255a12851a2a2f20cd6e3afbf
4
- data.tar.gz: 5b2d309cc8bdec3ed2c20d2b0a944c6bc1863a3fd728e7febc5e020dc3f88239
3
+ metadata.gz: 750910e916f0d4ad411cf868636075e597573dd8cca720e414156bcee55331dc
4
+ data.tar.gz: 4003e71f358abc47847fb9a2e4cf3c211189bc915b6b4196dd02d6d21633d2b8
5
5
  SHA512:
6
- metadata.gz: a2359e2706bb4d465fbb98285e48099d0ff3c6f58ab7aae0e4972d101ec2b5663bea62e965be074c784dea19cda7de551d1f27f26aba6c4502d4d96c9151bd7d
7
- data.tar.gz: f7bce64d724947712ee9c2d394802b07f64df2c581d92d36cb3ac4732b9a8609b2c1935e4189a39a0712692c2946111e426eb2ea5452f379906eb3ff379142ee
6
+ metadata.gz: efa2a87ce6a6125f7701979305a0c5fcc1fb3ad2703d5aef140505943789b45648311e46f892791a919a88d757f019485ff45209efc22423cc6543a298224a03
7
+ data.tar.gz: 4411e680ca841903d47b295090c4a194f92dfe91cc58796272d42b9c370ad6a3e6a8bc34973f0d7a85d93fe604788993428d944277b05426194893ecff0a9d51
@@ -1,3 +1,4 @@
1
+ require 'foreman_tasks_core'
1
2
  require 'smart_proxy_remote_execution_ssh/version'
2
3
  require 'smart_proxy_dynflow'
3
4
  require 'smart_proxy_remote_execution_ssh/webrick_ext'
@@ -19,6 +20,8 @@ module Proxy::RemoteExecution
19
20
  unless File.exist?(public_key_file)
20
21
  raise "Ssh public key file #{public_key_file} doesn't exist"
21
22
  end
23
+
24
+ validate_ssh_log_level!
22
25
  end
23
26
 
24
27
  def private_key_file
@@ -28,6 +31,30 @@ module Proxy::RemoteExecution
28
31
  def public_key_file
29
32
  File.expand_path("#{private_key_file}.pub")
30
33
  end
34
+
35
+ def validate_ssh_log_level!
36
+ wanted_level = Plugin.settings.ssh_log_level.to_s
37
+ levels = Plugin::SSH_LOG_LEVELS
38
+ unless levels.include? wanted_level
39
+ raise "Wrong value '#{Plugin.settings.ssh_log_level}' for ssh_log_level, must be one of #{levels.join(', ')}"
40
+ end
41
+
42
+ current = ::Proxy::SETTINGS.log_level.to_s.downcase
43
+
44
+ # regular log levels correspond to upcased ssh logger levels
45
+ ssh, regular = [wanted_level, current].map do |wanted|
46
+ levels.each_with_index.find { |value, _index| value == wanted }.last
47
+ end
48
+
49
+ if ssh < regular
50
+ raise 'ssh_log_level cannot be more verbose than regular log level'
51
+ end
52
+
53
+ Plugin.settings.ssh_log_level = Plugin.settings.ssh_log_level.to_sym
54
+ end
31
55
  end
56
+
57
+ require 'smart_proxy_dynflow_core/task_launcher_registry'
58
+ SmartProxyDynflowCore::TaskLauncherRegistry.register('ssh', ForemanTasksCore::TaskLauncher::Batch)
32
59
  end
33
60
  end
@@ -0,0 +1,20 @@
1
+ require 'foreman_tasks_core/shareable_action'
2
+
3
+ module Proxy::RemoteExecution::Ssh
4
+ module Actions
5
+ class RunScript < ForemanTasksCore::Runner::Action
6
+ def initiate_runner
7
+ additional_options = {
8
+ :step_id => run_step_id,
9
+ :uuid => execution_plan_id,
10
+ }
11
+ Proxy::RemoteExecution::Ssh::Plugin.runner_class.build(input.merge(additional_options),
12
+ suspended_action: suspended_action)
13
+ end
14
+
15
+ def runner_dispatcher
16
+ Dispatcher.instance
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,110 @@
1
+ #!/bin/sh
2
+ #
3
+ # Control script for the remote execution jobs.
4
+ #
5
+ # The initial script calls `$CONTROL_SCRIPT init-script-finish` once the original script exits.
6
+ # In automatic mode, the exit code is sent back to the proxy on `init-script-finish`.
7
+ #
8
+ # What the script provides is also a manual mode, where the author of the rex script can take
9
+ # full control of the job lifecycle. This allows keeping the marked as running even when
10
+ # the initial script finishes.
11
+ #
12
+ # The manual mode is turned on by calling `$CONTROL_SCRIPT manual-control`. After calling this,
13
+ # one can call `echo message | $CONTROL_SCRIPT update` to send output to the remote execution jobs
14
+ # and `$CONTROL_SCRIPT finish 0` once finished (with 0 as exit code) to send output to the remote execution jobs
15
+ # and `$CONTROL_SCRIPT finish 0` once finished (with 0 as exit code)
16
+ BASE_DIR="$(dirname "$(readlink -f "$0")")"
17
+
18
+ if ! command -v curl >/dev/null; then
19
+ echo 'curl is required' >&2
20
+ exit 1
21
+ fi
22
+
23
+ # send the callback data to proxy
24
+ update() {
25
+ "$BASE_DIR/retrieve.sh" push_update
26
+ }
27
+
28
+ # wait for named pipe $1 to retrieve data. If $2 is provided, it serves as timeout
29
+ # in seconds on how long to wait when reading.
30
+ wait_for_pipe() {
31
+ pipe_path=$1
32
+ if [ -n "$2" ]; then
33
+ timeout="-t $2"
34
+ fi
35
+ if read $timeout <>"$pipe_path"; then
36
+ rm "$pipe_path"
37
+ return 0
38
+ else
39
+ return 1
40
+ fi
41
+ }
42
+
43
+ # function run in background, when receiving update data via STDIN.
44
+ periodic_update() {
45
+ interval=1
46
+ # reading some data from periodic_update_control signals we're done
47
+ while ! wait_for_pipe "$BASE_DIR/periodic_update_control" "$interval"; do
48
+ update
49
+ done
50
+ # one more update before we finish
51
+ update
52
+ # signal the main process that we are finished
53
+ echo > "$BASE_DIR/periodic_update_finished"
54
+ }
55
+
56
+ # signal the periodic_update process that the main process is finishing
57
+ periodic_update_finish() {
58
+ if [ -e "$BASE_DIR/periodic_update_control" ]; then
59
+ echo > "$BASE_DIR/periodic_update_control"
60
+ fi
61
+ }
62
+
63
+ ACTION=${1:-finish}
64
+
65
+ case "$ACTION" in
66
+ init-script-finish)
67
+ if ! [ -e "$BASE_DIR/manual_mode" ]; then
68
+ # make the exit code of initialization script the exit code of the whole job
69
+ cp init_exit_code exit_code
70
+ update
71
+ fi
72
+ ;;
73
+ finish)
74
+ # take exit code passed via the command line, with fallback
75
+ # to the exit code of the initialization script
76
+ exit_code=${2:-$(cat "$BASE_DIR/init_exit_code")}
77
+ echo $exit_code > "$BASE_DIR/exit_code"
78
+ update
79
+ if [ -e "$BASE_DIR/manual_mode" ]; then
80
+ rm "$BASE_DIR/manual_mode"
81
+ fi
82
+ ;;
83
+ update)
84
+ # read data from input when redirected though a pipe
85
+ if ! [ -t 0 ]; then
86
+ # couple of named pipes to coordinate the main process with the periodic_update
87
+ mkfifo "$BASE_DIR/periodic_update_control"
88
+ mkfifo "$BASE_DIR/periodic_update_finished"
89
+ trap "periodic_update_finish" EXIT
90
+ # run periodic update as separate process to keep sending updates in output to server
91
+ periodic_update &
92
+ # redirect the input into output
93
+ tee -a "$BASE_DIR/output"
94
+ periodic_update_finish
95
+ # ensure the periodic update finished before we return
96
+ wait_for_pipe "$BASE_DIR/periodic_update_finished"
97
+ else
98
+ update
99
+ fi
100
+ ;;
101
+ # mark the script to be in manual mode: this means the script author needs to use `update` and `finish`
102
+ # commands to send output to the remote execution job or mark it as finished.
103
+ manual-mode)
104
+ touch "$BASE_DIR/manual_mode"
105
+ ;;
106
+ *)
107
+ echo "Unknown action $ACTION"
108
+ exit 1
109
+ ;;
110
+ esac
@@ -0,0 +1,151 @@
1
+ #!/bin/sh
2
+
3
+ if ! pgrep --help 2>/dev/null >/dev/null; then
4
+ echo DONE 1
5
+ echo "pgrep is required" >&2
6
+ exit 1
7
+ fi
8
+
9
+ BASE_DIR="$(dirname "$(readlink -f "$0")")"
10
+
11
+ # load the data required for generating the callback
12
+ . "$BASE_DIR/env.sh"
13
+ URL_PREFIX="$CALLBACK_HOST/dynflow/tasks/$TASK_ID"
14
+ AUTH="$TASK_ID:$OTP"
15
+ CURL="curl --silent --show-error --fail --max-time 10"
16
+
17
+ MY_LOCK_FILE="$BASE_DIR/retrieve_lock.$$"
18
+ MY_PID=$$
19
+ echo $MY_PID >"$MY_LOCK_FILE"
20
+ LOCK_FILE="$BASE_DIR/retrieve_lock"
21
+ TMP_OUTPUT_FILE="$BASE_DIR/tmp_output"
22
+
23
+ RUN_TIMEOUT=30 # for how long can the script hold the lock
24
+ WAIT_TIMEOUT=60 # for how long the script is trying to acquire the lock
25
+ START_TIME=$(date +%s)
26
+
27
+ fail() {
28
+ echo RUNNING
29
+ echo "$1"
30
+ exit 1
31
+ }
32
+
33
+ acquire_lock() {
34
+ # try to acquire lock by creating the file (ln should be atomic an fail in case
35
+ # another process succeeded first). We also check the content of the lock file,
36
+ # in case our process won when competing over the lock while invalidating
37
+ # the lock on timeout.
38
+ ln "$MY_LOCK_FILE" "$LOCK_FILE" 2>/dev/null || [ "$(head -n1 "$LOCK_FILE")" = "$MY_PID" ]
39
+ return $?
40
+ }
41
+
42
+ # acquiring the lock before proceeding, to ensure only one instance of the script is running
43
+ while ! acquire_lock; do
44
+ # we failed to create retrieve_lock - assuming there is already another retrieve script running
45
+ current_pid=$(head -n1 "$LOCK_FILE")
46
+ if [ -z "$current_pid" ]; then
47
+ continue
48
+ fi
49
+ # check whether the lock is not too old (compared to $RUN_TIMEOUT) and try to kill
50
+ # if it is, so that we don't have a stalled processes here
51
+ lock_lines_count=$(wc -l < "$LOCK_FILE")
52
+ current_lock_time=$(stat --format "%Y" "$LOCK_FILE")
53
+ current_time=$(date +%s)
54
+
55
+ if [ "$(( current_time - START_TIME ))" -gt "$WAIT_TIMEOUT" ]; then
56
+ # We were waiting for the lock for too long - just give up
57
+ fail "Wait time exceeded $WAIT_TIMEOUT"
58
+ elif [ "$(( current_time - current_lock_time ))" -gt "$RUN_TIMEOUT" ]; then
59
+ # The previous lock it hold for too long - re-acquiring procedure
60
+ if [ "$lock_lines_count" -gt 1 ]; then
61
+ # there were multiple processes waiting for lock without resolution
62
+ # longer than the $RUN_TIMEOUT - we reset the lock file and let processes
63
+ # to compete
64
+ echo "RETRY" > "$LOCK_FILE"
65
+ fi
66
+ if [ "$current_pid" != "RETRY" ]; then
67
+ # try to kill the currently stalled process
68
+ kill -9 "$current_pid" 2>/dev/null
69
+ fi
70
+ # try to add our process as one candidate
71
+ echo $MY_PID >> "$LOCK_FILE"
72
+ if [ "$( head -n2 "$LOCK_FILE" | tail -n1 )" = "$MY_PID" ]; then
73
+ # our process won the competition for the new lock: it is the first pid
74
+ # after the original one in the lock file - take ownership of the lock
75
+ # next iteration only this process will get through
76
+ echo $MY_PID >"$LOCK_FILE"
77
+ fi
78
+ else
79
+ # still waiting for the original owner to finish
80
+ sleep 1
81
+ fi
82
+ done
83
+
84
+ release_lock() {
85
+ rm "$MY_LOCK_FILE"
86
+ rm "$LOCK_FILE"
87
+ }
88
+ # ensure the release the lock at exit
89
+ trap "release_lock" EXIT
90
+
91
+ # make sure we clear previous tmp output file
92
+ if [ -e "$TMP_OUTPUT_FILE" ]; then
93
+ rm "$TMP_OUTPUT_FILE"
94
+ fi
95
+
96
+ pid=$(cat "$BASE_DIR/pid")
97
+ [ -f "$BASE_DIR/position" ] || echo 1 > "$BASE_DIR/position"
98
+ position=$(cat "$BASE_DIR/position")
99
+
100
+ prepare_output() {
101
+ if [ -e "$BASE_DIR/manual_mode" ] || ([ -n "$pid" ] && pgrep -P "$pid" >/dev/null 2>&1); then
102
+ echo RUNNING
103
+ else
104
+ echo "DONE $(cat "$BASE_DIR/exit_code" 2>/dev/null)"
105
+ fi
106
+ [ -f "$BASE_DIR/output" ] || exit 0
107
+ tail --bytes "+${position}" "$BASE_DIR/output" > "$TMP_OUTPUT_FILE"
108
+ cat "$TMP_OUTPUT_FILE"
109
+ }
110
+
111
+ # prepare the callback payload
112
+ payload() {
113
+ if [ -n "$1" ]; then
114
+ exit_code="$1"
115
+ else
116
+ exit_code=null
117
+ fi
118
+
119
+ if [ -e "$BASE_DIR/manual_mode" ]; then
120
+ manual_mode=true
121
+ output=$(prepare_output | base64 -w0)
122
+ else
123
+ manual_mode=false
124
+ fi
125
+
126
+ echo "{ \"exit_code\": $exit_code,"\
127
+ " \"step_id\": \"$STEP_ID\","\
128
+ " \"manual_mode\": $manual_mode,"\
129
+ " \"output\": \"$output\" }"
130
+ }
131
+
132
+ if [ "$1" = "push_update" ]; then
133
+ if [ -e "$BASE_DIR/exit_code" ]; then
134
+ exit_code="$(cat "$BASE_DIR/exit_code")"
135
+ action="done"
136
+ else
137
+ exit_code=""
138
+ action="update"
139
+ fi
140
+ $CURL -X POST -d "$(payload $exit_code)" -u "$AUTH" "$URL_PREFIX"/$action 2>>"$BASE_DIR/curl_stderr"
141
+ success=$?
142
+ else
143
+ prepare_output
144
+ success=$?
145
+ fi
146
+
147
+ if [ "$success" = 0 ] && [ -e "$TMP_OUTPUT_FILE" ]; then
148
+ # in case the retrieval was successful, move the position of the cursor to be read next time
149
+ bytes=$(wc --bytes < "$TMP_OUTPUT_FILE")
150
+ expr "${position}" + "${bytes}" > "$BASE_DIR/position"
151
+ fi
@@ -0,0 +1,10 @@
1
+ require 'foreman_tasks_core/runner/dispatcher'
2
+
3
+ module Proxy::RemoteExecution::Ssh
4
+ class Dispatcher < ::ForemanTasksCore::Runner::Dispatcher
5
+ def refresh_interval
6
+ @refresh_interval ||= Plugin.settings[:runner_refresh_interval] ||
7
+ Plugin.runner_class::DEFAULT_REFRESH_INTERVAL
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ class LogFilter < ::Logger
3
+ def initialize(base_logger)
4
+ @base_logger = base_logger
5
+ end
6
+
7
+ def add(severity, *args, &block)
8
+ severity ||= ::Logger::UNKNOWN
9
+ return true if @base_logger.nil? || severity < @level
10
+
11
+ @base_logger.add(severity, *args, &block)
12
+ end
13
+ end
14
+ end
@@ -1,5 +1,7 @@
1
1
  module Proxy::RemoteExecution::Ssh
2
2
  class Plugin < Proxy::Plugin
3
+ SSH_LOG_LEVELS = %w[debug info warn error fatal].freeze
4
+
3
5
  http_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
4
6
  https_rackup_path File.expand_path("http_config.ru", File.expand_path("../", __FILE__))
5
7
 
@@ -9,7 +11,11 @@ module Proxy::RemoteExecution::Ssh
9
11
  :remote_working_dir => '/var/tmp',
10
12
  :local_working_dir => '/var/tmp',
11
13
  :kerberos_auth => false,
12
- :async_ssh => false
14
+ :async_ssh => false,
15
+ # When set to nil, makes REX use the runner's default interval
16
+ # :runner_refresh_interval => nil,
17
+ :ssh_log_level => :fatal,
18
+ :cleanup_working_dirs => true
13
19
 
14
20
  plugin :ssh, Proxy::RemoteExecution::Ssh::VERSION
15
21
  after_activation do
@@ -17,17 +23,27 @@ module Proxy::RemoteExecution::Ssh
17
23
  require 'smart_proxy_remote_execution_ssh/version'
18
24
  require 'smart_proxy_remote_execution_ssh/cockpit'
19
25
  require 'smart_proxy_remote_execution_ssh/api'
20
-
21
- begin
22
- require 'smart_proxy_dynflow_core'
23
- require 'foreman_remote_execution_core'
24
- ForemanRemoteExecutionCore.initialize_settings(Proxy::RemoteExecution::Ssh::Plugin.settings.to_h)
25
- rescue LoadError # rubocop:disable Lint/HandleExceptions
26
- # Dynflow core is not available in the proxy, will be handled
27
- # by standalone Dynflow core
28
- end
26
+ require 'smart_proxy_remote_execution_ssh/actions/run_script'
27
+ require 'smart_proxy_remote_execution_ssh/dispatcher'
28
+ require 'smart_proxy_remote_execution_ssh/log_filter'
29
+ require 'smart_proxy_remote_execution_ssh/runners'
30
+ require 'smart_proxy_dynflow_core'
29
31
 
30
32
  Proxy::RemoteExecution::Ssh.validate!
31
33
  end
34
+
35
+ def self.simulate?
36
+ @simulate ||= %w[yes true 1].include? ENV.fetch('REX_SIMULATE', '').downcase
37
+ end
38
+
39
+ def self.runner_class
40
+ @runner_class ||= if simulate?
41
+ Runners::FakeScriptRunner
42
+ elsif settings[:async_ssh]
43
+ Runners::PollingScriptRunner
44
+ else
45
+ Runners::ScriptRunner
46
+ end
47
+ end
32
48
  end
33
49
  end
@@ -0,0 +1,7 @@
1
+ module Proxy::RemoteExecution::Ssh
2
+ module Runners
3
+ require 'smart_proxy_remote_execution_ssh/runners/script_runner'
4
+ require 'smart_proxy_remote_execution_ssh/runners/polling_script_runner'
5
+ require 'smart_proxy_remote_execution_ssh/runners/fake_script_runner'
6
+ end
7
+ end
@@ -0,0 +1,87 @@
1
+ module Proxy::RemoteExecution::Ssh::Runners
2
+ class FakeScriptRunner < ForemanTasksCore::Runner::Base
3
+ DEFAULT_REFRESH_INTERVAL = 1
4
+
5
+ @data = []
6
+
7
+ class << self
8
+ attr_accessor :data
9
+
10
+ def load_data(path = nil)
11
+ if path.nil?
12
+ @data = <<-BANNER.gsub(/^\s+\| ?/, '').lines
13
+ | ====== Simulated Remote Execution ======
14
+ |
15
+ | This is an output of a simulated remote
16
+ | execution run. It should run for about
17
+ | 5 seconds and finish successfully.
18
+ BANNER
19
+ else
20
+ File.open(File.expand_path(path), 'r') do |f|
21
+ @data = f.readlines.map(&:chomp)
22
+ end
23
+ end
24
+ @data.freeze
25
+ end
26
+
27
+ def build(options, suspended_action:)
28
+ new(options, suspended_action: suspended_action)
29
+ end
30
+ end
31
+
32
+ def initialize(*args)
33
+ super
34
+ # Load the fake output the first time its needed
35
+ self.class.load_data(ENV['REX_SIMULATE_PATH']) unless self.class.data.frozen?
36
+ @position = 0
37
+ end
38
+
39
+ def start
40
+ refresh
41
+ end
42
+
43
+ # Do one step
44
+ def refresh
45
+ if done?
46
+ finish
47
+ else
48
+ step
49
+ end
50
+ end
51
+
52
+ def kill
53
+ finish
54
+ end
55
+
56
+ private
57
+
58
+ def finish
59
+ publish_exit_status exit_code
60
+ end
61
+
62
+ def step
63
+ publish_data(next_chunk, 'stdout')
64
+ end
65
+
66
+ def done?
67
+ @position == self.class.data.count
68
+ end
69
+
70
+ def next_chunk
71
+ output = self.class.data[@position]
72
+ @position += 1
73
+ output
74
+ end
75
+
76
+ # Decide if the execution should fail or not
77
+ def exit_code
78
+ fail_chance = ENV.fetch('REX_SIMULATE_FAIL_CHANCE', 0).to_i
79
+ fail_exitcode = ENV.fetch('REX_SIMULATE_EXIT', 0).to_i
80
+ if fail_exitcode.zero? || fail_chance < (Random.rand * 100).round
81
+ 0
82
+ else
83
+ fail_exitcode
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,140 @@
1
+ require 'base64'
2
+
3
+ module Proxy::RemoteExecution::Ssh::Runners
4
+ class PollingScriptRunner < ScriptRunner
5
+ DEFAULT_REFRESH_INTERVAL = 60
6
+
7
+ def self.load_script(name)
8
+ script_dir = File.expand_path('../async_scripts', __dir__)
9
+ File.read(File.join(script_dir, name))
10
+ end
11
+
12
+ # The script that controls the flow of the job, able to initiate update or
13
+ # finish on the task, or take over the control over script lifecycle
14
+ CONTROL_SCRIPT = load_script('control.sh')
15
+
16
+ # The script always outputs at least one line
17
+ # First line of the output either has to begin with
18
+ # "RUNNING" or "DONE $EXITCODE"
19
+ # The following lines are treated as regular output
20
+ RETRIEVE_SCRIPT = load_script('retrieve.sh')
21
+
22
+ def initialize(options, user_method, suspended_action: nil)
23
+ super(options, user_method, suspended_action: suspended_action)
24
+ @callback_host = options[:callback_host]
25
+ @task_id = options[:uuid]
26
+ @step_id = options[:step_id]
27
+ @otp = ForemanTasksCore::OtpManager.generate_otp(@task_id)
28
+ end
29
+
30
+ def prepare_start
31
+ super
32
+ @base_dir = File.dirname @remote_script
33
+ upload_control_scripts
34
+ end
35
+
36
+ def initialization_script
37
+ close_stdin = '</dev/null'
38
+ close_fds = close_stdin + ' >/dev/null 2>/dev/null'
39
+ main_script = "(#{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
40
+ control_script_finish = "#{@control_script_path} init-script-finish"
41
+ <<-SCRIPT.gsub(/^ +\| /, '')
42
+ | export CONTROL_SCRIPT="#{@control_script_path}"
43
+ | sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
44
+ | echo $! > '#{@base_dir}/pid'
45
+ SCRIPT
46
+ end
47
+
48
+ def trigger(*args)
49
+ run_sync(*args)
50
+ end
51
+
52
+ def refresh
53
+ err = output = nil
54
+ begin
55
+ _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
56
+ rescue StandardError => e
57
+ @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
58
+ end
59
+
60
+ process_retrieved_data(output, err)
61
+ ensure
62
+ destroy_session
63
+ end
64
+
65
+ def process_retrieved_data(output, err)
66
+ return if output.nil? || output.empty?
67
+
68
+ lines = output.lines
69
+ result = lines.shift.match(/^DONE (\d+)?/)
70
+ publish_data(lines.join, 'stdout') unless lines.empty?
71
+ publish_data(err, 'stderr') unless err.empty?
72
+ if result
73
+ exitcode = result[1] || 0
74
+ publish_exit_status(exitcode.to_i)
75
+ cleanup
76
+ end
77
+ end
78
+
79
+ def external_event(event)
80
+ data = event.data
81
+ if data['manual_mode']
82
+ load_event_updates(data)
83
+ else
84
+ # getting the update from automatic mode - reaching to the host to get the latest update
85
+ return run_refresh
86
+ end
87
+ ensure
88
+ destroy_session
89
+ end
90
+
91
+ def close
92
+ super
93
+ ForemanTasksCore::OtpManager.drop_otp(@task_id, @otp) if @otp
94
+ end
95
+
96
+ def upload_control_scripts
97
+ return if @control_scripts_uploaded
98
+
99
+ cp_script_to_remote(env_script, 'env.sh')
100
+ @control_script_path = cp_script_to_remote(CONTROL_SCRIPT, 'control.sh')
101
+ @retrieval_script = cp_script_to_remote(RETRIEVE_SCRIPT, 'retrieve.sh')
102
+ @control_scripts_uploaded = true
103
+ end
104
+
105
+ # Script setting the dynamic values to env variables: it's sourced from other control scripts
106
+ def env_script
107
+ <<-SCRIPT.gsub(/^ +\| /, '')
108
+ | CALLBACK_HOST="#{@callback_host}"
109
+ | TASK_ID="#{@task_id}"
110
+ | STEP_ID="#{@step_id}"
111
+ | OTP="#{@otp}"
112
+ SCRIPT
113
+ end
114
+
115
+ private
116
+
117
+ # Generates updates based on the callback data from the manual mode
118
+ def load_event_updates(event_data)
119
+ continuous_output = ForemanTasksCore::ContinuousOutput.new
120
+ if event_data.key?('output')
121
+ lines = Base64.decode64(event_data['output']).sub(/\A(RUNNING|DONE).*\n/, '')
122
+ continuous_output.add_output(lines, 'stdout')
123
+ end
124
+ cleanup if event_data['exit_code']
125
+ new_update(continuous_output, event_data['exit_code'])
126
+ end
127
+
128
+ def cleanup
129
+ run_sync("rm -rf \"#{remote_command_dir}\"") if @cleanup_working_dirs
130
+ end
131
+
132
+ def destroy_session
133
+ if @session
134
+ @logger.debug("Closing session with #{@ssh_user}@#{@host}")
135
+ @session.close
136
+ @session = nil
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,469 @@
1
+ require 'net/ssh'
2
+ require 'fileutils'
3
+
4
+ # Rubocop can't make up its mind what it wants
5
+ # rubocop:disable Lint/SuppressedException, Lint/RedundantCopDisableDirective
6
+ begin
7
+ require 'net/ssh/krb'
8
+ rescue LoadError; end
9
+ # rubocop:enable Lint/SuppressedException, Lint/RedundantCopDisableDirective
10
+
11
+ module Proxy::RemoteExecution::Ssh::Runners
12
+ class EffectiveUserMethod
13
+ attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent
14
+
15
+ def initialize(effective_user, ssh_user, effective_user_password)
16
+ @effective_user = effective_user
17
+ @ssh_user = ssh_user
18
+ @effective_user_password = effective_user_password.to_s
19
+ @password_sent = false
20
+ end
21
+
22
+ def on_data(received_data, ssh_channel)
23
+ if received_data.match(login_prompt)
24
+ ssh_channel.send_data(effective_user_password + "\n")
25
+ @password_sent = true
26
+ end
27
+ end
28
+
29
+ def filter_password?(received_data)
30
+ !@effective_user_password.empty? && @password_sent && received_data.match(Regexp.escape(@effective_user_password))
31
+ end
32
+
33
+ def sent_all_data?
34
+ effective_user_password.empty? || password_sent
35
+ end
36
+
37
+ def reset
38
+ @password_sent = false
39
+ end
40
+
41
+ def cli_command_prefix; end
42
+
43
+ def login_prompt; end
44
+ end
45
+
46
+ class SudoUserMethod < EffectiveUserMethod
47
+ LOGIN_PROMPT = 'rex login: '.freeze
48
+
49
+ def login_prompt
50
+ LOGIN_PROMPT
51
+ end
52
+
53
+ def cli_command_prefix
54
+ "sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
55
+ end
56
+ end
57
+
58
+ class DzdoUserMethod < EffectiveUserMethod
59
+ LOGIN_PROMPT = /password/i.freeze
60
+
61
+ def login_prompt
62
+ LOGIN_PROMPT
63
+ end
64
+
65
+ def cli_command_prefix
66
+ "dzdo -u #{effective_user} "
67
+ end
68
+ end
69
+
70
+ class SuUserMethod < EffectiveUserMethod
71
+ LOGIN_PROMPT = /Password: /i.freeze
72
+
73
+ def login_prompt
74
+ LOGIN_PROMPT
75
+ end
76
+
77
+ def cli_command_prefix
78
+ "su - #{effective_user} -c "
79
+ end
80
+ end
81
+
82
+ class NoopUserMethod
83
+ def on_data(_, _); end
84
+
85
+ def filter_password?(received_data)
86
+ false
87
+ end
88
+
89
+ def sent_all_data?
90
+ true
91
+ end
92
+
93
+ def cli_command_prefix; end
94
+
95
+ def reset; end
96
+ end
97
+
98
+ # rubocop:disable Metrics/ClassLength
99
+ class ScriptRunner < ForemanTasksCore::Runner::Base
100
+ attr_reader :execution_timeout_interval
101
+
102
+ EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
103
+ DEFAULT_REFRESH_INTERVAL = 1
104
+ MAX_PROCESS_RETRIES = 4
105
+
106
+ def initialize(options, user_method, suspended_action: nil)
107
+ super suspended_action: suspended_action
108
+ @host = options.fetch(:hostname)
109
+ @script = options.fetch(:script)
110
+ @ssh_user = options.fetch(:ssh_user, 'root')
111
+ @ssh_port = options.fetch(:ssh_port, 22)
112
+ @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
113
+ @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
114
+ @host_public_key = options.fetch(:host_public_key, nil)
115
+ @verify_host = options.fetch(:verify_host, nil)
116
+ @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
117
+
118
+ @client_private_key_file = settings.ssh_identity_key_file
119
+ @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
120
+ @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir)
121
+ @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
122
+ @user_method = user_method
123
+ end
124
+
125
+ def self.build(options, suspended_action:)
126
+ effective_user = options.fetch(:effective_user, nil)
127
+ ssh_user = options.fetch(:ssh_user, 'root')
128
+ effective_user_method = options.fetch(:effective_user_method, 'sudo')
129
+
130
+ user_method = if effective_user.nil? || effective_user == ssh_user
131
+ NoopUserMethod.new
132
+ elsif effective_user_method == 'sudo'
133
+ SudoUserMethod.new(effective_user, ssh_user,
134
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
135
+ elsif effective_user_method == 'dzdo'
136
+ DzdoUserMethod.new(effective_user, ssh_user,
137
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
138
+ elsif effective_user_method == 'su'
139
+ SuUserMethod.new(effective_user, ssh_user,
140
+ options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
141
+ else
142
+ raise "effective_user_method '#{effective_user_method}' not supported"
143
+ end
144
+
145
+ new(options, user_method, suspended_action: suspended_action)
146
+ end
147
+
148
+ def start
149
+ prepare_start
150
+ script = initialization_script
151
+ logger.debug("executing script:\n#{indent_multiline(script)}")
152
+ trigger(script)
153
+ rescue StandardError => e
154
+ logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
155
+ publish_exception('Error initializing command', e)
156
+ end
157
+
158
+ def trigger(*args)
159
+ run_async(*args)
160
+ end
161
+
162
+ def prepare_start
163
+ @remote_script = cp_script_to_remote
164
+ @output_path = File.join(File.dirname(@remote_script), 'output')
165
+ @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
166
+ end
167
+
168
+ # the script that initiates the execution
169
+ def initialization_script
170
+ su_method = @user_method.instance_of?(SuUserMethod)
171
+ # pipe the output to tee while capturing the exit code in a file
172
+ <<-SCRIPT.gsub(/^\s+\| /, '')
173
+ | sh -c "(#{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}; echo \\$?>#{@exit_code_path}) | /usr/bin/tee #{@output_path}
174
+ | exit \\$(cat #{@exit_code_path})"
175
+ SCRIPT
176
+ end
177
+
178
+ def refresh
179
+ return if @session.nil?
180
+
181
+ with_retries do
182
+ with_disconnect_handling do
183
+ @session.process(0)
184
+ end
185
+ end
186
+ ensure
187
+ check_expecting_disconnect
188
+ end
189
+
190
+ def kill
191
+ if @session
192
+ run_sync("pkill -f #{remote_command_file('script')}")
193
+ else
194
+ logger.debug('connection closed')
195
+ end
196
+ rescue StandardError => e
197
+ publish_exception('Unexpected error', e, false)
198
+ end
199
+
200
+ def timeout
201
+ @logger.debug('job timed out')
202
+ super
203
+ end
204
+
205
+ def timeout_interval
206
+ execution_timeout_interval
207
+ end
208
+
209
+ def with_retries
210
+ tries = 0
211
+ begin
212
+ yield
213
+ rescue StandardError => e
214
+ logger.error("Unexpected error: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
215
+ tries += 1
216
+ if tries <= MAX_PROCESS_RETRIES
217
+ logger.error('Retrying')
218
+ retry
219
+ else
220
+ publish_exception('Unexpected error', e)
221
+ end
222
+ end
223
+ end
224
+
225
+ def with_disconnect_handling
226
+ yield
227
+ rescue IOError, Net::SSH::Disconnect => e
228
+ @session.shutdown!
229
+ check_expecting_disconnect
230
+ if @expecting_disconnect
231
+ publish_exit_status(0)
232
+ else
233
+ publish_exception('Unexpected disconnect', e)
234
+ end
235
+ end
236
+
237
+ def close
238
+ run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
239
+ rescue StandardError => e
240
+ publish_exception('Error when removing remote working dir', e, false)
241
+ ensure
242
+ @session.close if @session && !@session.closed?
243
+ FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
244
+ end
245
+
246
+ def publish_data(data, type)
247
+ super(data.force_encoding('UTF-8'), type)
248
+ end
249
+
250
+ private
251
+
252
+ def indent_multiline(string)
253
+ string.lines.map { |line| " | #{line}" }.join
254
+ end
255
+
256
+ def should_cleanup?
257
+ @session && !@session.closed? && @cleanup_working_dirs
258
+ end
259
+
260
+ def session
261
+ @session ||= begin
262
+ @logger.debug("opening session to #{@ssh_user}@#{@host}")
263
+ Net::SSH.start(@host, @ssh_user, ssh_options)
264
+ end
265
+ end
266
+
267
+ def ssh_options
268
+ ssh_options = {}
269
+ ssh_options[:port] = @ssh_port if @ssh_port
270
+ ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
271
+ ssh_options[:password] = @ssh_password if @ssh_password
272
+ ssh_options[:passphrase] = @key_passphrase if @key_passphrase
273
+ ssh_options[:keys_only] = true
274
+ # if the host public key is contained in the known_hosts_file,
275
+ # verify it, otherwise, if missing, import it and continue
276
+ ssh_options[:paranoid] = true
277
+ ssh_options[:auth_methods] = available_authentication_methods
278
+ ssh_options[:user_known_hosts_file] = prepare_known_hosts if @host_public_key
279
+ ssh_options[:number_of_password_prompts] = 1
280
+ ssh_options[:verbose] = settings[:ssh_log_level]
281
+ ssh_options[:logger] = Proxy::RemoteExecution::Ssh::LogFilter.new(SmartProxyDynflowCore::Log.instance)
282
+ return ssh_options
283
+ end
284
+
285
+ def settings
286
+ Proxy::RemoteExecution::Ssh::Plugin.settings
287
+ end
288
+
289
+ # Initiates run of the remote command and yields the data when
290
+ # available. The yielding doesn't happen automatically, but as
291
+ # part of calling the `refresh` method.
292
+ def run_async(command)
293
+ raise 'Async command already in progress' if @started
294
+
295
+ @started = false
296
+ @user_method.reset
297
+
298
+ session.open_channel do |channel|
299
+ channel.request_pty
300
+ channel.on_data do |ch, data|
301
+ publish_data(data, 'stdout') unless @user_method.filter_password?(data)
302
+ @user_method.on_data(data, ch)
303
+ end
304
+ channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
305
+ # standard exit of the command
306
+ channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
307
+ # on signal: sending the signal value (such as 'TERM')
308
+ channel.on_request('exit-signal') do |ch, data|
309
+ publish_exit_status(data.read_string)
310
+ ch.close
311
+ # wait for the channel to finish so that we know at the end
312
+ # that the session is inactive
313
+ ch.wait
314
+ end
315
+ channel.exec(command) do |_, success|
316
+ @started = true
317
+ raise('Error initializing command') unless success
318
+ end
319
+ end
320
+ session.process(0) { !run_started? }
321
+ return true
322
+ end
323
+
324
+ def run_started?
325
+ @started && @user_method.sent_all_data?
326
+ end
327
+
328
+ def run_sync(command, stdin = nil)
329
+ stdout = ''
330
+ stderr = ''
331
+ exit_status = nil
332
+ started = false
333
+
334
+ channel = session.open_channel do |ch|
335
+ ch.on_data do |c, data|
336
+ stdout.concat(data)
337
+ end
338
+ ch.on_extended_data { |_, _, data| stderr.concat(data) }
339
+ ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
340
+ # Send data to stdin if we have some
341
+ ch.send_data(stdin) unless stdin.nil?
342
+ # on signal: sending the signal value (such as 'TERM')
343
+ ch.on_request('exit-signal') do |_, data|
344
+ exit_status = data.read_string
345
+ ch.close
346
+ ch.wait
347
+ end
348
+ ch.exec command do |_, success|
349
+ raise 'could not execute command' unless success
350
+
351
+ started = true
352
+ end
353
+ end
354
+ session.process(0) { !started }
355
+ # Closing the channel without sending any data gives us SIGPIPE
356
+ channel.close unless stdin.nil?
357
+ channel.wait
358
+ return exit_status, stdout, stderr
359
+ end
360
+
361
+ def prepare_known_hosts
362
+ path = local_command_file('known_hosts')
363
+ if @host_public_key
364
+ write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
365
+ end
366
+ return path
367
+ end
368
+
369
+ def local_command_dir
370
+ File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
371
+ end
372
+
373
+ def local_command_file(filename)
374
+ File.join(local_command_dir, filename)
375
+ end
376
+
377
+ def remote_command_dir
378
+ File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
379
+ end
380
+
381
+ def remote_command_file(filename)
382
+ File.join(remote_command_dir, filename)
383
+ end
384
+
385
+ def ensure_local_directory(path)
386
+ if File.exist?(path)
387
+ raise "#{path} expected to be a directory" unless File.directory?(path)
388
+ else
389
+ FileUtils.mkdir_p(path)
390
+ end
391
+ return path
392
+ end
393
+
394
+ def cp_script_to_remote(script = @script, name = 'script')
395
+ path = remote_command_file(name)
396
+ @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
397
+ upload_data(sanitize_script(script), path, 555)
398
+ end
399
+
400
+ def upload_data(data, path, permissions = 555)
401
+ ensure_remote_directory File.dirname(path)
402
+ # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
403
+ # This is used to write to $path with elevated permissions, solutions using cat and output redirection
404
+ # would not work, because the redirection would happen in the non-elevated shell.
405
+ command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
406
+
407
+ @logger.debug("Sending data to #{path} on remote host:\n#{data}")
408
+ status, _out, err = run_sync(command, data)
409
+
410
+ @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
411
+ if status != 0
412
+ raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
413
+ end
414
+
415
+ path
416
+ end
417
+
418
+ def upload_file(local_path, remote_path)
419
+ mode = File.stat(local_path).mode.to_s(8)[-3..-1]
420
+ @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
421
+ upload_data(File.read(local_path), remote_path, mode)
422
+ end
423
+
424
+ def ensure_remote_directory(path)
425
+ exit_code, _output, err = run_sync("mkdir -p #{path}")
426
+ if exit_code != 0
427
+ raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
428
+ end
429
+ end
430
+
431
+ def sanitize_script(script)
432
+ script.tr("\r", '')
433
+ end
434
+
435
+ def write_command_file_locally(filename, content)
436
+ path = local_command_file(filename)
437
+ ensure_local_directory(File.dirname(path))
438
+ File.write(path, content)
439
+ return path
440
+ end
441
+
442
+ # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
443
+ # or it's an error. When it's expected, we expect the script to produce 'restart host' as
444
+ # its last command output
445
+ def check_expecting_disconnect
446
+ last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
447
+ return unless last_output
448
+
449
+ if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
450
+ @expecting_disconnect = true
451
+ end
452
+ end
453
+
454
+ def available_authentication_methods
455
+ methods = %w[publickey] # Always use pubkey auth as fallback
456
+ if settings[:kerberos_auth]
457
+ if defined? Net::SSH::Kerberos
458
+ methods << 'gssapi-with-mic'
459
+ else
460
+ @logger.warn('Kerberos authentication requested but not available')
461
+ end
462
+ end
463
+ methods.unshift('password') if @ssh_password
464
+
465
+ methods
466
+ end
467
+ end
468
+ # rubocop:enable Metrics/ClassLength
469
+ end
@@ -1,7 +1,7 @@
1
1
  module Proxy
2
2
  module RemoteExecution
3
3
  module Ssh
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
6
6
  end
7
7
  end
@@ -15,3 +15,6 @@
15
15
  # one of :debug, :info, :warn, :error, :fatal
16
16
  # must be lower than general log level
17
17
  # :ssh_log_level: fatal
18
+
19
+ # Remove working directories on job completion
20
+ # :cleanup_working_dirs: true
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_proxy_remote_execution_ssh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Nečas
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 1980-01-01 00:00:00.000000000 Z
11
+ date: 2021-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: 0.82.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: foreman-tasks-core
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 0.3.1
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 0.3.1
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: smart_proxy_dynflow
113
127
  requirement: !ruby/object:Gem::Requirement
@@ -149,10 +163,19 @@ files:
149
163
  - README.md
150
164
  - bundler.plugins.d/remote_execution_ssh.rb
151
165
  - lib/smart_proxy_remote_execution_ssh.rb
166
+ - lib/smart_proxy_remote_execution_ssh/actions/run_script.rb
152
167
  - lib/smart_proxy_remote_execution_ssh/api.rb
168
+ - lib/smart_proxy_remote_execution_ssh/async_scripts/control.sh
169
+ - lib/smart_proxy_remote_execution_ssh/async_scripts/retrieve.sh
153
170
  - lib/smart_proxy_remote_execution_ssh/cockpit.rb
171
+ - lib/smart_proxy_remote_execution_ssh/dispatcher.rb
154
172
  - lib/smart_proxy_remote_execution_ssh/http_config.ru
173
+ - lib/smart_proxy_remote_execution_ssh/log_filter.rb
155
174
  - lib/smart_proxy_remote_execution_ssh/plugin.rb
175
+ - lib/smart_proxy_remote_execution_ssh/runners.rb
176
+ - lib/smart_proxy_remote_execution_ssh/runners/fake_script_runner.rb
177
+ - lib/smart_proxy_remote_execution_ssh/runners/polling_script_runner.rb
178
+ - lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb
156
179
  - lib/smart_proxy_remote_execution_ssh/version.rb
157
180
  - lib/smart_proxy_remote_execution_ssh/webrick_ext.rb
158
181
  - settings.d/remote_execution_ssh.yml.example