class Proxy::RemoteExecution::Ssh::Runners::ScriptRunner

rubocop:disable Metrics/ClassLength

Constants

DEFAULT_REFRESH_INTERVAL
EXPECTED_POWER_ACTION_MESSAGES

Attributes

execution_timeout_interval[R]

Public Class Methods

build(options, suspended_action:) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 121
def self.build(options, suspended_action:)
  effective_user = options.fetch(:effective_user, nil)
  ssh_user = options.fetch(:ssh_user, 'root')
  effective_user_method = options.fetch(:effective_user_method, 'sudo')

  user_method = if effective_user.nil? || effective_user == ssh_user
                  NoopUserMethod.new
                elsif effective_user_method == 'sudo'
                  SudoUserMethod.new(effective_user, ssh_user,
                                     options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
                elsif effective_user_method == 'dzdo'
                  DzdoUserMethod.new(effective_user, ssh_user,
                                     options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
                elsif effective_user_method == 'su'
                  SuUserMethod.new(effective_user, ssh_user,
                                   options.fetch(:secrets, {}).fetch(:effective_user_password, nil))
                else
                  raise "effective_user_method '#{effective_user_method}' not supported"
                end

  new(options, user_method, suspended_action: suspended_action)
end
new(options, user_method, suspended_action: nil) click to toggle source
Calls superclass method
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 100
def initialize(options, user_method, suspended_action: nil)
  super suspended_action: suspended_action
  @host = options.fetch(:hostname)
  @script = options.fetch(:script)
  @ssh_user = options.fetch(:ssh_user, 'root')
  @ssh_port = options.fetch(:ssh_port, 22)
  @ssh_password = options.fetch(:secrets, {}).fetch(:ssh_password, nil)
  @key_passphrase = options.fetch(:secrets, {}).fetch(:key_passphrase, nil)
  @host_public_key = options.fetch(:host_public_key, nil)
  @verify_host = options.fetch(:verify_host, nil)
  @execution_timeout_interval = options.fetch(:execution_timeout_interval, nil)

  @client_private_key_file = settings.ssh_identity_key_file
  @local_working_dir = options.fetch(:local_working_dir, settings.local_working_dir)
  @remote_working_dir = options.fetch(:remote_working_dir, settings.remote_working_dir.shellescape)
  @socket_working_dir = options.fetch(:socket_working_dir, settings.socket_working_dir)
  @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.cleanup_working_dirs)
  @first_execution = options.fetch(:first_execution, false)
  @user_method = user_method
end

Public Instance Methods

close() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 242
def close
  run_sync("rm -rf #{remote_command_dir}") if should_cleanup?
rescue StandardError => e
  publish_exception('Error when removing remote working dir', e, false)
ensure
  close_session if @process_manager
  FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
end
close_session() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 232
def close_session
  raise 'Control socket file does not exist' unless File.exist?(socket_file)
  @logger.debug("Sending exit request for session #{@ssh_user}@#{@host}")
  args = ['/usr/bin/ssh', @host, "-o", "ControlPath=#{socket_file}", "-O", "exit"].flatten
  pm = Proxy::Dynflow::ProcessManager.new(args)
  pm.on_stdout { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
  pm.on_stderr { |data| @logger.debug "[close_session]: #{data.chomp}"; data }
  pm.run!
end
establish_connection() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 175
def establish_connection
  # run_sync ['-f', '-N'] would be cleaner, but ssh does not close its
  # stderr which trips up the process manager which expects all FDs to be
  # closed
  ensure_remote_command(
    'true',
    error: 'Failed to establish connection to remote host, exit code: %{exit_code}'
  )
end
initialization_script() click to toggle source

the script that initiates the execution

# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 194
    def initialization_script
      su_method = @user_method.instance_of?(SuUserMethod)
      # pipe the output to tee while capturing the exit code in a file
      <<~SCRIPT
        sh <<EOF | /usr/bin/tee #{@output_path}
        #{@remote_script_wrapper} #{@user_method.cli_command_prefix}#{su_method ? "'#{@remote_script} < /dev/null '" : "#{@remote_script} < /dev/null"}
        echo \\$?>#{@exit_code_path}
        EOF
        exit $(cat #{@exit_code_path})
      SCRIPT
    end
kill() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 213
def kill
  if @process_manager&.started?
    run_sync("pkill -P $(cat #{@pid_path})")
  else
    logger.debug('connection closed')
  end
rescue StandardError => e
  publish_exception('Unexpected error', e, false)
end
preflight_checks() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 161
def preflight_checks
  ensure_remote_command(cp_script_to_remote("#!/bin/sh\nexec true", 'test'),
    error: 'Failed to execute script on remote machine, exit code: %{exit_code}.'
  )
  unless @user_method.is_a? NoopUserMethod
    path = cp_script_to_remote("#!/bin/sh\nexec #{@user_method.cli_command_prefix} true", 'effective-user-test')
    ensure_remote_command(path,
                          error: 'Failed to change to effective user, exit code: %{exit_code}',
                          tty: true,
                          user_method: @user_method,
                          close_stdin: false)
  end
end
prepare_start() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 185
def prepare_start
  @remote_script = cp_script_to_remote
  @output_path = File.join(File.dirname(@remote_script), 'output')
  @exit_code_path = File.join(File.dirname(@remote_script), 'exit_code')
  @pid_path = File.join(File.dirname(@remote_script), 'pid')
  @remote_script_wrapper = upload_data("echo $$ > #{@pid_path}; exec \"$@\";", File.join(File.dirname(@remote_script), 'script-wrapper'), 555)
end
publish_data(data, type, pm = nil) click to toggle source
Calls superclass method
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 251
def publish_data(data, type, pm = nil)
  pm ||= @process_manager
  super(data.force_encoding('UTF-8'), type) unless @user_method.filter_password?(data)
  @user_method.on_data(data, pm.stdin) if pm
end
refresh() click to toggle source
Calls superclass method
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 206
def refresh
  return if @process_manager.nil?
  super
ensure
  check_expecting_disconnect
end
start() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 144
def start
  Proxy::RemoteExecution::Utils.prune_known_hosts!(@host, @ssh_port, logger) if @first_execution
  establish_connection
  preflight_checks
  prepare_start
  script = initialization_script
  logger.debug("executing script:\n#{indent_multiline(script)}")
  trigger(script)
rescue StandardError, NotImplementedError => e
  logger.error("error while initializing command #{e.class} #{e.message}:\n #{e.backtrace.join("\n")}")
  publish_exception('Error initializing command', e)
end
timeout() click to toggle source
Calls superclass method
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 223
def timeout
  @logger.debug('job timed out')
  super
end
timeout_interval() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 228
def timeout_interval
  execution_timeout_interval
end
trigger(*args) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 157
def trigger(*args)
  run_async(*args)
end

Private Instance Methods

available_authentication_methods() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 433
def available_authentication_methods
  methods = %w[publickey] # Always use pubkey auth as fallback
  methods << 'gssapi-with-mic' if settings[:kerberos_auth]
  methods.unshift('password') if @ssh_password
  methods
end
check_expecting_disconnect() click to toggle source

when a remote server disconnects, it's hard to tell if it was on purpose (when calling reboot) or it's an error. When it's expected, we expect the script to produce 'restart host' as its last command output

# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 424
def check_expecting_disconnect
  last_output = @continuous_output.raw_outputs.find { |d| d['output_type'] == 'stdout' }
  return unless last_output

  if EXPECTED_POWER_ACTION_MESSAGES.any? { |message| last_output['output'] =~ /^#{message}/ }
    @expecting_disconnect = true
  end
end
cp_script_to_remote(script = @script, name = 'script') click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 369
def cp_script_to_remote(script = @script, name = 'script')
  path = remote_command_file(name)
  @logger.debug("copying script to #{path}:\n#{indent_multiline(script)}")
  upload_data(sanitize_script(script), path, 555)
end
ensure_local_directory(path) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 360
def ensure_local_directory(path)
  if File.exist?(path)
    raise "#{path} expected to be a directory" unless File.directory?(path)
  else
    FileUtils.mkdir_p(path)
  end
  return path
end
ensure_remote_command(cmd, error: nil, **kwargs) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 403
def ensure_remote_command(cmd, error: nil, **kwargs)
  if (pm = run_sync(cmd, **kwargs)).status != 0
    msg = error || 'Failed to run command %{command} on remote machine, exit code: %{exit_code}'
    raise(msg % { command: cmd, exit_code: pm.status })
  end
end
ensure_remote_directory(path) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 397
def ensure_remote_directory(path)
  ensure_remote_command("mkdir -p #{path}",
    error: "Unable to create directory #{path} on remote system, exit code: %{exit_code}"
  )
end
get_args(command, with_pty = false, quiet: false) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 289
def get_args(command, with_pty = false, quiet: false)
  args = []
  args = [{'SSHPASS' => @key_passphrase}, '/usr/bin/sshpass', '-P', 'passphrase', '-e'] if @key_passphrase
  args = [{'SSHPASS' => @ssh_password}, '/usr/bin/sshpass', '-e'] if @ssh_password
  args += ['/usr/bin/ssh', @host, ssh_options(with_pty, quiet: quiet), command].flatten
end
indent_multiline(string) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 259
def indent_multiline(string)
  string.lines.map { |line| "  | #{line}" }.join
end
local_command_dir() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 340
def local_command_dir
  File.join(@local_working_dir, 'foreman-proxy', "foreman-ssh-cmd-#{@id}")
end
local_command_file(filename) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 344
def local_command_file(filename)
  File.join(ensure_local_directory(local_command_dir), filename)
end
prepare_known_hosts() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 332
def prepare_known_hosts
  path = local_command_file('known_hosts')
  if @host_public_key
    write_command_file_locally('known_hosts', "#{@host} #{@host_public_key}")
  end
  return path
end
remote_command_dir() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 352
def remote_command_dir
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
end
remote_command_file(filename) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 356
def remote_command_file(filename)
  File.join(remote_command_dir, filename)
end
run_async(command) click to toggle source

Initiates run of the remote command and yields the data when available. The yielding doesn't happen automatically, but as part of calling the `refresh` method.

# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 299
def run_async(command)
  raise 'Async command already in progress' if @process_manager&.started?

  @user_method.reset
  initialize_command(*get_args(command, true, quiet: true))

  true
end
run_started?() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 308
def run_started?
  @process_manager&.started? && @user_method.sent_all_data?
end
run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 312
def run_sync(command, stdin: nil, close_stdin: true, tty: false, user_method: nil)
  pm = Proxy::Dynflow::ProcessManager.new(get_args(command, tty))
  callback = proc do |data|
    data.each_line do |line|
      logger.debug(line.chomp) if user_method.nil? || !user_method.filter_password?(line)
      user_method.on_data(data, pm.stdin) if user_method
    end
    ''
  end
  pm.on_stdout(&callback)
  pm.on_stderr(&callback)
  pm.start!
  unless pm.status
    pm.stdin.io.puts(stdin) if stdin
    pm.stdin.io.close if close_stdin
    pm.run!
  end
  pm
end
sanitize_script(script) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 410
def sanitize_script(script)
  script.tr("\r", '')
end
settings() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 285
def settings
  Proxy::RemoteExecution::Ssh::Plugin.settings
end
should_cleanup?() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 263
def should_cleanup?
  @process_manager && @cleanup_working_dirs
end
socket_file() click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 348
def socket_file
  File.join(ensure_local_directory(@socket_working_dir), @id)
end
ssh_options(with_pty = false, quiet: false) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 267
def ssh_options(with_pty = false, quiet: false)
  ssh_options = []
  ssh_options << "-tt" if with_pty
  ssh_options << "-o User=#{@ssh_user}"
  ssh_options << "-o Port=#{@ssh_port}" if @ssh_port
  ssh_options << "-o IdentityFile=#{@client_private_key_file}" if @client_private_key_file
  ssh_options << "-o IdentitiesOnly=yes"
  ssh_options << "-o StrictHostKeyChecking=no"
  ssh_options << "-o PreferredAuthentications=#{available_authentication_methods.join(',')}"
  ssh_options << "-o UserKnownHostsFile=#{prepare_known_hosts}" if @host_public_key
  ssh_options << "-o NumberOfPasswordPrompts=1"
  ssh_options << "-o LogLevel=#{quiet ? 'quiet' : settings[:ssh_log_level]}"
  ssh_options << "-o ControlMaster=auto"
  ssh_options << "-o ControlPath=#{socket_file}"
  ssh_options << "-o ControlPersist=yes"
  ssh_options << "-o ProxyCommand=none"
end
upload_data(data, path, permissions = 555) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 375
def upload_data(data, path, permissions = 555)
  ensure_remote_directory File.dirname(path)
  # We use tee here to pipe stdin coming from ssh to a file at $path, while silencing its output
  # This is used to write to $path with elevated permissions, solutions using cat and output redirection
  # would not work, because the redirection would happen in the non-elevated shell.
  command = "tee #{path} >/dev/null && chmod #{permissions} #{path}"

  @logger.debug("Sending data to #{path} on remote host:\n#{data}")
  ensure_remote_command(command,
    stdin: data,
    error: "Unable to upload file to #{path} on remote system, exit code: %{exit_code}"
  )

  path
end
upload_file(local_path, remote_path) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 391
def upload_file(local_path, remote_path)
  mode = File.stat(local_path).mode.to_s(8)[-3..-1]
  @logger.debug("Uploading local file: #{local_path} as #{remote_path} with #{mode} permissions")
  upload_data(File.read(local_path), remote_path, mode)
end
write_command_file_locally(filename, content) click to toggle source
# File lib/smart_proxy_remote_execution_ssh/runners/script_runner.rb, line 414
def write_command_file_locally(filename, content)
  path = local_command_file(filename)
  ensure_local_directory(File.dirname(path))
  File.write(path, content)
  return path
end