foreman_remote_execution_core 1.4.3 → 1.5.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: d8a10b6004e15a426a2cc6890ecc2f295497582e3e86b7086582c5a14514ca81
4
- data.tar.gz: 9e9a14d930f04fe54bda984486184b84af2b2f55c17173e884db81ab20a8e8bd
3
+ metadata.gz: f9959aa1c94136ee271de608d9f9767f46e0c6d628f8a043420dfac7e54177c8
4
+ data.tar.gz: 7a961ebb1cf1b044e005f43b3ce727b9966fbcf7c17bfcd21307a20db408b662
5
5
  SHA512:
6
- metadata.gz: c2193c056505e25e362bcf7a76414bf066faaca554acce21fb768e53e118b14dc7978391650032fe113152ea482de56db1ac538fff8448c760f44ebc136a9f5b
7
- data.tar.gz: 4b6c0e548b10fb1b29dae637b01308fea528ff84b4a7caf8fba7986bc69db30fe34dcf5c1da6fecb4defea66e05dc82a5ab3bc43c42a1bcef904ccdf2eb8815d
6
+ metadata.gz: d3d3cbc724bf1d38f8d9e9a2ef0be35fa604ee8482ec7036a3b6d23cd97b86fb405ff7de4bc9b2c988b8036c6b7ef14d9dcbb2fec8899d14c1668620be8b50fe
7
+ data.tar.gz: 5abf6e164e855b066efdd70bf7b0770acc62ed76aa3c742a28f6b546f79f4042e9e98d4c4f59da06b96bcb66061f124f8a4e7e4b2575057c393dc8ae9a3ea707
@@ -1,89 +1,8 @@
1
- require 'foreman_tasks_core'
2
-
3
1
  module ForemanRemoteExecutionCore
4
- extend ForemanTasksCore::SettingsLoader
5
- register_settings([:remote_execution_ssh, :smart_proxy_remote_execution_ssh_core],
6
- :ssh_identity_key_file => '~/.ssh/id_rsa_foreman_proxy',
7
- :ssh_user => 'root',
8
- :remote_working_dir => '/var/tmp',
9
- :local_working_dir => '/var/tmp',
10
- :kerberos_auth => false,
11
- :async_ssh => false,
12
- # When set to nil, makes REX use the runner's default interval
13
- :runner_refresh_interval => nil,
14
- :ssh_log_level => :fatal,
15
- :cleanup_working_dirs => true)
16
-
17
- SSH_LOG_LEVELS = %w(debug info warn error fatal).freeze
18
-
19
- def self.simulate?
20
- %w(yes true 1).include? ENV.fetch('REX_SIMULATE', '').downcase
21
- end
22
-
23
- def self.validate_settings!
24
- super
25
- self.validate_ssh_log_level!
26
- @settings[:ssh_log_level] = @settings[:ssh_log_level].to_sym
27
- end
28
-
29
- def self.validate_ssh_log_level!
30
- wanted_level = @settings[:ssh_log_level].to_s
31
- unless SSH_LOG_LEVELS.include? wanted_level
32
- raise "Wrong value '#{@settings[:ssh_log_level]}' for ssh_log_level, must be one of #{SSH_LOG_LEVELS.join(', ')}"
33
- end
34
-
35
- current = if defined?(::Proxy::SETTINGS)
36
- ::Proxy::SETTINGS.log_level.to_s.downcase
37
- elsif defined?(SmartProxyDynflowCore::SETTINGS)
38
- SmartProxyDynflowCore::SETTINGS.log_level.to_s.downcase
39
- else
40
- Rails.configuration.log_level.to_s
41
- end
2
+ require 'smart_proxy_remote_execution_ssh'
3
+ require 'foreman_remote_execution_core/actions'
42
4
 
43
- # regular log levels correspond to upcased ssh logger levels
44
- ssh, regular = [wanted_level, current].map do |wanted|
45
- SSH_LOG_LEVELS.each_with_index.find { |value, _index| value == wanted }.last
46
- end
47
-
48
- if ssh < regular
49
- raise 'ssh_log_level cannot be more verbose than regular log level'
50
- end
51
- end
52
-
53
- def self.runner_class
54
- @runner_class ||= if simulate?
55
- FakeScriptRunner
56
- elsif settings[:async_ssh]
57
- PollingScriptRunner
58
- else
59
- ScriptRunner
60
- end
61
- end
62
-
63
- if ForemanTasksCore.dynflow_present?
64
- require 'foreman_tasks_core/runner'
65
- require 'foreman_remote_execution_core/log_filter'
66
- if simulate?
67
- # Load the fake implementation of the script runner if debug is enabled
68
- require 'foreman_remote_execution_core/fake_script_runner'
69
- else
70
- require 'foreman_remote_execution_core/script_runner'
71
- require 'foreman_remote_execution_core/polling_script_runner'
72
- end
73
- require 'foreman_remote_execution_core/dispatcher'
74
- require 'foreman_remote_execution_core/actions'
75
-
76
- # rubocop:disable Lint/SuppressedException
77
- begin
78
- require 'smart_proxy_dynflow_core/task_launcher_registry'
79
- rescue LoadError
80
- end
81
- # rubocop:enable Lint/SuppressedException
82
-
83
- if defined?(::SmartProxyDynflowCore)
84
- SmartProxyDynflowCore::TaskLauncherRegistry.register('ssh', ForemanTasksCore::TaskLauncher::Batch)
85
- end
5
+ def self.settings
6
+ Proxy::RemoteExecution::Ssh::Plugin.settings
86
7
  end
87
-
88
- require 'foreman_remote_execution_core/version'
89
8
  end
@@ -1,20 +1,6 @@
1
- require 'foreman_tasks_core/shareable_action'
2
-
3
1
  module ForemanRemoteExecutionCore
4
2
  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
- ForemanRemoteExecutionCore.runner_class.build(input.merge(additional_options),
12
- suspended_action: suspended_action)
13
- end
14
-
15
- def runner_dispatcher
16
- ForemanRemoteExecutionCore::Dispatcher.instance
17
- end
18
- end
3
+ require 'smart_proxy_remote_execution_ssh/actions/run_script'
4
+ RunScript = Proxy::RemoteExecution::Ssh::Actions::RunScript
19
5
  end
20
6
  end
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecutionCore
2
- VERSION = '1.4.3'.freeze
2
+ VERSION = '1.5.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.3
4
+ version: 1.5.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: 2021-05-31 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: bcrypt_pbkdf
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: smart_proxy_remote_execution_ssh
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 0.4.0
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 0.4.0
69
83
  description: " Ssh remote execution provider code sharable between Foreman and Foreman-Proxy\n"
70
84
  email:
71
85
  - inecas@redhat.com
@@ -76,13 +90,6 @@ files:
76
90
  - LICENSE
77
91
  - lib/foreman_remote_execution_core.rb
78
92
  - lib/foreman_remote_execution_core/actions.rb
79
- - lib/foreman_remote_execution_core/async_scripts/control.sh
80
- - lib/foreman_remote_execution_core/async_scripts/retrieve.sh
81
- - lib/foreman_remote_execution_core/dispatcher.rb
82
- - lib/foreman_remote_execution_core/fake_script_runner.rb
83
- - lib/foreman_remote_execution_core/log_filter.rb
84
- - lib/foreman_remote_execution_core/polling_script_runner.rb
85
- - lib/foreman_remote_execution_core/script_runner.rb
86
93
  - lib/foreman_remote_execution_core/version.rb
87
94
  homepage: https://github.com/theforeman/foreman_remote_execution
88
95
  licenses:
@@ -1,110 +0,0 @@
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
@@ -1,151 +0,0 @@
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
@@ -1,12 +0,0 @@
1
- require 'foreman_tasks_core/runner/dispatcher'
2
-
3
- module ForemanRemoteExecutionCore
4
- class Dispatcher < ::ForemanTasksCore::Runner::Dispatcher
5
-
6
- def refresh_interval
7
- @refresh_interval ||= ForemanRemoteExecutionCore.settings[:runner_refresh_interval] ||
8
- ForemanRemoteExecutionCore.runner_class::DEFAULT_REFRESH_INTERVAL
9
- end
10
-
11
- end
12
- end
@@ -1,87 +0,0 @@
1
- module ForemanRemoteExecutionCore
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 = <<-END.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
- END
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 == 0 || fail_chance < (Random.rand * 100).round
81
- 0
82
- else
83
- fail_exitcode
84
- end
85
- end
86
- end
87
- end
@@ -1,14 +0,0 @@
1
- module ForemanRemoteExecutionCore
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,136 +0,0 @@
1
- require 'base64'
2
-
3
- module ForemanRemoteExecutionCore
4
- class PollingScriptRunner < ScriptRunner
5
-
6
- DEFAULT_REFRESH_INTERVAL = 60
7
-
8
- def self.load_script(name)
9
- script_dir = File.expand_path('../async_scripts', __FILE__)
10
- File.read(File.join(script_dir, name))
11
- end
12
-
13
- # The script that controls the flow of the job, able to initiate update or
14
- # finish on the task, or take over the control over script lifecycle
15
- CONTROL_SCRIPT = load_script('control.sh')
16
-
17
- # The script always outputs at least one line
18
- # First line of the output either has to begin with
19
- # "RUNNING" or "DONE $EXITCODE"
20
- # The following lines are treated as regular output
21
- RETRIEVE_SCRIPT = load_script('retrieve.sh')
22
-
23
- def initialize(options, user_method, suspended_action: nil)
24
- super(options, user_method, suspended_action: suspended_action)
25
- @callback_host = options[:callback_host]
26
- @task_id = options[:uuid]
27
- @step_id = options[:step_id]
28
- @otp = ForemanTasksCore::OtpManager.generate_otp(@task_id)
29
- end
30
-
31
- def prepare_start
32
- super
33
- @base_dir = File.dirname @remote_script
34
- upload_control_scripts
35
- end
36
-
37
- def initialization_script
38
- close_stdin = '</dev/null'
39
- close_fds = close_stdin + ' >/dev/null 2>/dev/null'
40
- main_script = "(#{@remote_script} #{close_stdin} 2>&1; echo $?>#{@base_dir}/init_exit_code) >#{@base_dir}/output"
41
- control_script_finish = "#{@control_script_path} init-script-finish"
42
- <<-SCRIPT.gsub(/^ +\| /, '')
43
- | export CONTROL_SCRIPT="#{@control_script_path}"
44
- | sh -c '#{main_script}; #{control_script_finish}' #{close_fds} &
45
- | echo $! > '#{@base_dir}/pid'
46
- SCRIPT
47
- end
48
-
49
- def trigger(*args)
50
- run_sync(*args)
51
- end
52
-
53
- def refresh
54
- err = output = nil
55
- begin
56
- _, output, err = run_sync("#{@user_method.cli_command_prefix} #{@retrieval_script}")
57
- rescue => e
58
- @logger.info("Error while connecting to the remote host on refresh: #{e.message}")
59
- end
60
- return if output.nil? || output.empty?
61
-
62
- lines = output.lines
63
- result = lines.shift.match(/^DONE (\d+)?/)
64
- publish_data(lines.join, 'stdout') unless lines.empty?
65
- publish_data(err, 'stderr') unless err.empty?
66
- if result
67
- exitcode = result[1] || 0
68
- publish_exit_status(exitcode.to_i)
69
- cleanup
70
- end
71
- ensure
72
- destroy_session
73
- end
74
-
75
- def external_event(event)
76
- data = event.data
77
- if data['manual_mode']
78
- load_event_updates(data)
79
- else
80
- # getting the update from automatic mode - reaching to the host to get the latest update
81
- return run_refresh
82
- end
83
- ensure
84
- destroy_session
85
- end
86
-
87
- def close
88
- super
89
- ForemanTasksCore::OtpManager.drop_otp(@task_id, @otp) if @otp
90
- end
91
-
92
- def upload_control_scripts
93
- return if @control_scripts_uploaded
94
-
95
- cp_script_to_remote(env_script, 'env.sh')
96
- @control_script_path = cp_script_to_remote(CONTROL_SCRIPT, 'control.sh')
97
- @retrieval_script = cp_script_to_remote(RETRIEVE_SCRIPT, 'retrieve.sh')
98
- @control_scripts_uploaded = true
99
- end
100
-
101
- # Script setting the dynamic values to env variables: it's sourced from other control scripts
102
- def env_script
103
- <<-SCRIPT.gsub(/^ +\| /, '')
104
- | CALLBACK_HOST="#{@callback_host}"
105
- | TASK_ID="#{@task_id}"
106
- | STEP_ID="#{@step_id}"
107
- | OTP="#{@otp}"
108
- SCRIPT
109
- end
110
-
111
- private
112
-
113
- # Generates updates based on the callback data from the manual mode
114
- def load_event_updates(event_data)
115
- continuous_output = ForemanTasksCore::ContinuousOutput.new
116
- if event_data.key?('output')
117
- lines = Base64.decode64(event_data['output']).sub(/\A(RUNNING|DONE).*\n/, '')
118
- continuous_output.add_output(lines, 'stdout')
119
- end
120
- cleanup if event_data['exit_code']
121
- new_update(continuous_output, event_data['exit_code'])
122
- end
123
-
124
- def cleanup
125
- run_sync("rm -rf \"#{remote_command_dir}\"") if @cleanup_working_dirs
126
- end
127
-
128
- def destroy_session
129
- if @session
130
- @logger.debug("Closing session with #{@ssh_user}@#{@host}")
131
- @session.close
132
- @session = nil
133
- end
134
- end
135
- end
136
- end
@@ -1,471 +0,0 @@
1
- require 'net/ssh'
2
- require 'fileutils'
3
-
4
- # rubocop:disable Lint/SuppressedException
5
- begin
6
- require 'net/ssh/krb'
7
- rescue LoadError; end
8
- # rubocop:enable Lint/SuppressedException:
9
-
10
- module ForemanRemoteExecutionCore
11
- class EffectiveUserMethod
12
- attr_reader :effective_user, :ssh_user, :effective_user_password, :password_sent
13
-
14
- def initialize(effective_user, ssh_user, effective_user_password)
15
- @effective_user = effective_user
16
- @ssh_user = ssh_user
17
- @effective_user_password = effective_user_password.to_s
18
- @password_sent = false
19
- end
20
-
21
- def on_data(received_data, ssh_channel)
22
- if received_data.match(login_prompt)
23
- ssh_channel.send_data(effective_user_password + "\n")
24
- @password_sent = true
25
- end
26
- end
27
-
28
- def filter_password?(received_data)
29
- !@effective_user_password.empty? && @password_sent && received_data.match(Regexp.escape(@effective_user_password))
30
- end
31
-
32
- def sent_all_data?
33
- effective_user_password.empty? || password_sent
34
- end
35
-
36
- def reset
37
- @password_sent = false
38
- end
39
-
40
- def cli_command_prefix
41
- end
42
-
43
- def login_prompt
44
- end
45
- end
46
-
47
- class SudoUserMethod < EffectiveUserMethod
48
- LOGIN_PROMPT = 'rex login: '.freeze
49
-
50
- def login_prompt
51
- LOGIN_PROMPT
52
- end
53
-
54
- def cli_command_prefix
55
- "sudo -p '#{LOGIN_PROMPT}' -u #{effective_user} "
56
- end
57
- end
58
-
59
- class DzdoUserMethod < EffectiveUserMethod
60
- LOGIN_PROMPT = /password/i.freeze
61
-
62
- def login_prompt
63
- LOGIN_PROMPT
64
- end
65
-
66
- def cli_command_prefix
67
- "dzdo -u #{effective_user} "
68
- end
69
- end
70
-
71
- class SuUserMethod < EffectiveUserMethod
72
- LOGIN_PROMPT = /Password: /i.freeze
73
-
74
- def login_prompt
75
- LOGIN_PROMPT
76
- end
77
-
78
- def cli_command_prefix
79
- "su - #{effective_user} -c "
80
- end
81
- end
82
-
83
- class NoopUserMethod
84
- def on_data(_, _)
85
- end
86
-
87
- def filter_password?(received_data)
88
- false
89
- end
90
-
91
- def sent_all_data?
92
- true
93
- end
94
-
95
- def cli_command_prefix
96
- end
97
-
98
- def reset
99
- end
100
- end
101
-
102
- class ScriptRunner < ForemanTasksCore::Runner::Base
103
- attr_reader :execution_timeout_interval
104
-
105
- EXPECTED_POWER_ACTION_MESSAGES = ['restart host', 'shutdown host'].freeze
106
- DEFAULT_REFRESH_INTERVAL = 1
107
- MAX_PROCESS_RETRIES = 4
108
-
109
- def initialize(options, user_method, suspended_action: nil)
110
- super suspended_action: suspended_action
111
- @host = options.fetch(:hostname)
112
- @script = options.fetch(:script)
113
- @ssh_user = options.fetch(:ssh_user, 'root')
114
- @ssh_port = options.fetch(:ssh_port, 22)
115
- @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
116
- @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
117
- @host_public_key = options.fetch(:host_public_key, nil)
118
- @verify_host = options.fetch(:verify_host, nil)
119
- @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)
120
-
121
- @client_private_key_file = settings.fetch(:ssh_identity_key_file)
122
- @local_working_dir = options.fetch(:local_working_dir, settings.fetch(:local_working_dir))
123
- @remote_working_dir = options.fetch(:remote_working_dir, settings.fetch(:remote_working_dir))
124
- @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.fetch(:cleanup_working_dirs))
125
- @user_method = user_method
126
- end
127
-
128
- def self.build(options, suspended_action:)
129
- effective_user = options.fetch(:effective_user, nil)
130
- ssh_user = options.fetch(:ssh_user, 'root')
131
- effective_user_method = options.fetch(:effective_user_method, 'sudo')
132
-
133
- user_method = if effective_user.nil? || effective_user == ssh_user
134
- NoopUserMethod.new
135
- elsif effective_user_method == 'sudo'
136
- SudoUserMethod.new(effective_user, ssh_user,
137
- options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
138
- elsif effective_user_method == 'dzdo'
139
- DzdoUserMethod.new(effective_user, ssh_user,
140
- options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
141
- elsif effective_user_method == 'su'
142
- SuUserMethod.new(effective_user, ssh_user,
143
- options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
144
- else
145
- raise "effective_user_method '#{effective_user_method}' not supported"
146
- end
147
-
148
- new(options, user_method, suspended_action: suspended_action)
149
- end
150
-
151
- def start
152
- prepare_start
153
- script = initialization_script
154
- logger.debug("executing script:\n#{indent_multiline(script)}")
155
- trigger(script)
156
- rescue => e
157
- logger.error("error while initalizing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
158
- publish_exception('Error initializing command', e)
159
- end
160
-
161
- def trigger(*args)
162
- run_async(*args)
163
- end
164
-
165
- def prepare_start
166
- @remote_script = cp_script_to_remote
167
- @output_path = File.join(File.dirname(@remote_script), 'output')
168
- @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
169
- end
170
-
171
- # the script that initiates the execution
172
- def initialization_script
173
- su_method = @user_method.instance_of?(ForemanRemoteExecutionCore::SuUserMethod)
174
- # pipe the output to tee while capturing the exit code in a file
175
- <<-SCRIPT.gsub(/^\s+\| /, '')
176
- | 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}
177
- | exit \\$(cat #{@exit_code_path})"
178
- SCRIPT
179
- end
180
-
181
- def refresh
182
- return if @session.nil?
183
-
184
- with_retries do
185
- with_disconnect_handling do
186
- @session.process(0)
187
- end
188
- end
189
- ensure
190
- check_expecting_disconnect
191
- end
192
-
193
- def kill
194
- if @session
195
- run_sync("pkill -f #{remote_command_file('script')}")
196
- else
197
- logger.debug('connection closed')
198
- end
199
- rescue => e
200
- publish_exception('Unexpected error', e, false)
201
- end
202
-
203
- def timeout
204
- @logger.debug('job timed out')
205
- super
206
- end
207
-
208
- def timeout_interval
209
- execution_timeout_interval
210
- end
211
-
212
- def with_retries
213
- tries = 0
214
- begin
215
- yield
216
- rescue => e
217
- logger.error("Unexpected error: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
218
- tries += 1
219
- if tries <= MAX_PROCESS_RETRIES
220
- logger.error('Retrying')
221
- retry
222
- else
223
- publish_exception('Unexpected error', e)
224
- end
225
- end
226
- end
227
-
228
- def with_disconnect_handling
229
- yield
230
- rescue IOError, Net::SSH::Disconnect => e
231
- @session.shutdown!
232
- check_expecting_disconnect
233
- if @expecting_disconnect
234
- publish_exit_status(0)
235
- else
236
- publish_exception('Unexpected disconnect', e)
237
- end
238
- end
239
-
240
- def close
241
- run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
242
- rescue => e
243
- publish_exception('Error when removing remote working dir', e, false)
244
- ensure
245
- @session.close if @session && !@session.closed?
246
- FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
247
- end
248
-
249
- def publish_data(data, type)
250
- super(data.force_encoding('UTF-8'), type)
251
- end
252
-
253
- private
254
-
255
- def indent_multiline(string)
256
- string.lines.map { |line| " | #{line}" }.join
257
- end
258
-
259
- def should_cleanup?
260
- @session && !@session.closed? && @cleanup_working_dirs
261
- end
262
-
263
- def session
264
- @session ||= begin
265
- @logger.debug("opening session to #{@ssh_user}@#{@host}")
266
- Net::SSH.start(@host, @ssh_user, ssh_options)
267
- end
268
- end
269
-
270
- def ssh_options
271
- ssh_options = {}
272
- ssh_options[:port] = @ssh_port if @ssh_port
273
- ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
274
- ssh_options[:password] = @ssh_password if @ssh_password
275
- ssh_options[:passphrase] = @key_passphrase if @key_passphrase
276
- ssh_options[:keys_only] = true
277
- # if the host public key is contained in the known_hosts_file,
278
- # verify it, otherwise, if missing, import it and continue
279
- ssh_options[:paranoid] = true
280
- ssh_options[:auth_methods] = available_authentication_methods
281
- ssh_options[:user_known_hosts_file] = prepare_known_hosts if @host_public_key
282
- ssh_options[:number_of_password_prompts] = 1
283
- ssh_options[:verbose] = settings[:ssh_log_level]
284
- ssh_options[:logger] = ForemanRemoteExecutionCore::LogFilter.new(SmartProxyDynflowCore::Log.instance)
285
- return ssh_options
286
- end
287
-
288
- def settings
289
- ForemanRemoteExecutionCore.settings
290
- end
291
-
292
- # Initiates run of the remote command and yields the data when
293
- # available. The yielding doesn't happen automatically, but as
294
- # part of calling the `refresh` method.
295
- def run_async(command)
296
- raise 'Async command already in progress' if @started
297
-
298
- @started = false
299
- @user_method.reset
300
-
301
- session.open_channel do |channel|
302
- channel.request_pty
303
- channel.on_data do |ch, data|
304
- publish_data(data, 'stdout') unless @user_method.filter_password?(data)
305
- @user_method.on_data(data, ch)
306
- end
307
- channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
308
- # standard exit of the command
309
- channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
310
- # on signal: sending the signal value (such as 'TERM')
311
- channel.on_request('exit-signal') do |ch, data|
312
- publish_exit_status(data.read_string)
313
- ch.close
314
- # wait for the channel to finish so that we know at the end
315
- # that the session is inactive
316
- ch.wait
317
- end
318
- channel.exec(command) do |_, success|
319
- @started = true
320
- raise('Error initializing command') unless success
321
- end
322
- end
323
- session.process(0) { !run_started? }
324
- return true
325
- end
326
-
327
- def run_started?
328
- @started && @user_method.sent_all_data?
329
- end
330
-
331
- def run_sync(command, stdin = nil)
332
- stdout = ''
333
- stderr = ''
334
- exit_status = nil
335
- started = false
336
-
337
- channel = session.open_channel do |ch|
338
- ch.on_data do |c, data|
339
- stdout.concat(data)
340
- end
341
- ch.on_extended_data { |_, _, data| stderr.concat(data) }
342
- ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
343
- # Send data to stdin if we have some
344
- ch.send_data(stdin) unless stdin.nil?
345
- # on signal: sending the signal value (such as 'TERM')
346
- ch.on_request('exit-signal') do |_, data|
347
- exit_status = data.read_string
348
- ch.close
349
- ch.wait
350
- end
351
- ch.exec command do |_, success|
352
- raise 'could not execute command' unless success
353
-
354
- started = true
355
- end
356
- end
357
- session.process(0) { !started }
358
- # Closing the channel without sending any data gives us SIGPIPE
359
- channel.close unless stdin.nil?
360
- channel.wait
361
- return exit_status, stdout, stderr
362
- end
363
-
364
- def prepare_known_hosts
365
- path = local_command_file('known_hosts')
366
- if @host_public_key
367
- write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
368
- end
369
- return path
370
- end
371
-
372
- def local_command_dir
373
- File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
374
- end
375
-
376
- def local_command_file(filename)
377
- File.join(local_command_dir, filename)
378
- end
379
-
380
- def remote_command_dir
381
- File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
382
- end
383
-
384
- def remote_command_file(filename)
385
- File.join(remote_command_dir, filename)
386
- end
387
-
388
- def ensure_local_directory(path)
389
- if File.exist?(path)
390
- raise "#{path} expected to be a directory" unless File.directory?(path)
391
- else
392
- FileUtils.mkdir_p(path)
393
- end
394
- return path
395
- end
396
-
397
- def cp_script_to_remote(script = @script, name = 'script')
398
- path = remote_command_file(name)
399
- @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
400
- upload_data(sanitize_script(script), path, 555)
401
- end
402
-
403
- def upload_data(data, path, permissions = 555)
404
- ensure_remote_directory File.dirname(path)
405
- # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
406
- # This is used to write to $path with elevated permissions, solutions using cat and output redirection
407
- # would not work, because the redirection would happen in the non-elevated shell.
408
- command = "tee '#{path}' >/dev/null && chmod '#{permissions}' '#{path}'"
409
-
410
- @logger.debug("Sending data to #{path} on remote host:\n#{data}")
411
- status, _out, err = run_sync(command, data)
412
-
413
- @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
414
- if status != 0
415
- raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
416
- end
417
-
418
- path
419
- end
420
-
421
- def upload_file(local_path, remote_path)
422
- mode = File.stat(local_path).mode.to_s(8)[-3..-1]
423
- @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
424
- upload_data(File.read(local_path), remote_path, mode)
425
- end
426
-
427
- def ensure_remote_directory(path)
428
- exit_code, _output, err = run_sync("mkdir -p #{path}")
429
- if exit_code != 0
430
- raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
431
- end
432
- end
433
-
434
- def sanitize_script(script)
435
- script.tr("\r", '')
436
- end
437
-
438
- def write_command_file_locally(filename, content)
439
- path = local_command_file(filename)
440
- ensure_local_directory(File.dirname(path))
441
- File.write(path, content)
442
- return path
443
- end
444
-
445
- # when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot)
446
- # or it's an error. When it's expected, we expect the script to produce 'restart host' as
447
- # its last command output
448
- def check_expecting_disconnect
449
- last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
450
- return unless last_output
451
-
452
- if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
453
- @expecting_disconnect = true
454
- end
455
- end
456
-
457
- def available_authentication_methods
458
- methods = %w(publickey) # Always use pubkey auth as fallback
459
- if settings[:kerberos_auth]
460
- if defined? Net::SSH::Kerberos
461
- methods << 'gssapi-with-mic'
462
- else
463
- @logger.warn('Kerberos authentication requested but not available')
464
- end
465
- end
466
- methods.unshift('password') if @ssh_password
467
-
468
- methods
469
- end
470
- end
471
- end