class ForemanRemoteExecutionCore::ScriptRunner

Constants

DEFAULT_REFRESH_INTERVAL
EXPECTED_POWER_ACTION_MESSAGES
MAX_PROCESS_RETRIES

Attributes

execution_timeout_interval[R]

Public Class Methods

build(options, suspended_action:) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 128
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/foreman_remote_execution_core/script_runner.rb, line 109
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.fetch(:ssh_identity_key_file)
  @local_working_dir = options.fetch(:local_working_dir, settings.fetch(:local_working_dir))
  @remote_working_dir = options.fetch(:remote_working_dir, settings.fetch(:remote_working_dir))
  @cleanup_working_dirs = options.fetch(:cleanup_working_dirs, settings.fetch(:cleanup_working_dirs))
  @user_method = user_method
end

Public Instance Methods

close() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 240
def close
  run_sync("rm -rf \"#{remote_command_dir}\"") if should_cleanup?
rescue => e
  publish_exception('Error when removing remote working dir', e, false)
ensure
  @session.close if @session && !@session.closed?
  FileUtils.rm_rf(local_command_dir) if Dir.exist?(local_command_dir) && @cleanup_working_dirs
end
initialization_script() click to toggle source

the script that initiates the execution

# File lib/foreman_remote_execution_core/script_runner.rb, line 172
    def initialization_script
      su_method = @user_method.instance_of?(ForemanRemoteExecutionCore::SuUserMethod)
      # pipe the output to tee while capturing the exit code in a file
      <<-SCRIPT.gsub(/^\s+\| /, '')
      | 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}
      | exit \\$(cat #{@exit_code_path})"
      SCRIPT
    end
kill() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 193
def kill
  if @session
    run_sync("pkill -f #{remote_command_file('script')}")
  else
    logger.debug('connection closed')
  end
rescue => e
  publish_exception('Unexpected error', e, false)
end
prepare_start() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 165
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')
end
publish_data(data, type) click to toggle source
Calls superclass method
# File lib/foreman_remote_execution_core/script_runner.rb, line 249
def publish_data(data, type)
  super(data.force_encoding('UTF-8'), type)
end
refresh() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 181
def refresh
  return if @session.nil?

  with_retries do
    with_disconnect_handling do
      @session.process(0)
    end
  end
ensure
  check_expecting_disconnect
end
start() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 151
def start
  prepare_start
  script = initialization_script
  logger.debug("executing script:\n#{indent_multiline(script)}")
  trigger(script)
rescue => e
  logger.error("error while initalizing 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/foreman_remote_execution_core/script_runner.rb, line 203
def timeout
  @logger.debug('job timed out')
  super
end
timeout_interval() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 208
def timeout_interval
  execution_timeout_interval
end
trigger(*args) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 161
def trigger(*args)
  run_async(*args)
end
with_disconnect_handling() { || ... } click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 228
def with_disconnect_handling
  yield
rescue IOError, Net::SSH::Disconnect => e
  @session.shutdown!
  check_expecting_disconnect
  if @expecting_disconnect
    publish_exit_status(0)
  else
    publish_exception('Unexpected disconnect', e)
  end
end
with_retries() { || ... } click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 212
def with_retries
  tries = 0
  begin
    yield
  rescue => e
    logger.error("Unexpected error: #{e.class} #{e.message}\n #{e.backtrace.join("\n")}")
    tries += 1
    if tries <= MAX_PROCESS_RETRIES
      logger.error('Retrying')
      retry
    else
      publish_exception('Unexpected error', e)
    end
  end
end

Private Instance Methods

available_authentication_methods() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 457
def available_authentication_methods
  methods = %w(publickey) # Always use pubkey auth as fallback
  if settings[:kerberos_auth]
    if defined? Net::SSH::Kerberos
      methods << 'gssapi-with-mic'
    else
      @logger.warn('Kerberos authentication requested but not available')
    end
  end
  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/foreman_remote_execution_core/script_runner.rb, line 448
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/foreman_remote_execution_core/script_runner.rb, line 397
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/foreman_remote_execution_core/script_runner.rb, line 388
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_directory(path) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 427
def ensure_remote_directory(path)
  exit_code, _output, err = run_sync("mkdir -p #{path}")
  if exit_code != 0
    raise "Unable to create directory on remote system #{path}: exit code: #{exit_code}\n #{err}"
  end
end
indent_multiline(string) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 255
def indent_multiline(string)
  string.lines.map { |line| "  | #{line}" }.join
end
local_command_dir() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 372
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/foreman_remote_execution_core/script_runner.rb, line 376
def local_command_file(filename)
  File.join(local_command_dir, filename)
end
prepare_known_hosts() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 364
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/foreman_remote_execution_core/script_runner.rb, line 380
def remote_command_dir
  File.join(@remote_working_dir, "foreman-ssh-cmd-#{id}")
end
remote_command_file(filename) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 384
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/foreman_remote_execution_core/script_runner.rb, line 295
def run_async(command)
  raise 'Async command already in progress' if @started

  @started = false
  @user_method.reset

  session.open_channel do |channel|
    channel.request_pty
    channel.on_data do |ch, data|
      publish_data(data, 'stdout') unless @user_method.filter_password?(data)
      @user_method.on_data(data, ch)
    end
    channel.on_extended_data { |ch, type, data| publish_data(data, 'stderr') }
    # standard exit of the command
    channel.on_request('exit-status') { |ch, data| publish_exit_status(data.read_long) }
    # on signal: sending the signal value (such as 'TERM')
    channel.on_request('exit-signal') do |ch, data|
      publish_exit_status(data.read_string)
      ch.close
      # wait for the channel to finish so that we know at the end
      # that the session is inactive
      ch.wait
    end
    channel.exec(command) do |_, success|
      @started = true
      raise('Error initializing command') unless success
    end
  end
  session.process(0) { !run_started? }
  return true
end
run_started?() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 327
def run_started?
  @started && @user_method.sent_all_data?
end
run_sync(command, stdin = nil) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 331
def run_sync(command, stdin = nil)
  stdout = ''
  stderr = ''
  exit_status = nil
  started = false

  channel = session.open_channel do |ch|
    ch.on_data do |c, data|
      stdout.concat(data)
    end
    ch.on_extended_data { |_, _, data| stderr.concat(data) }
    ch.on_request('exit-status') { |_, data| exit_status = data.read_long }
    # Send data to stdin if we have some
    ch.send_data(stdin) unless stdin.nil?
    # on signal: sending the signal value (such as 'TERM')
    ch.on_request('exit-signal') do |_, data|
      exit_status = data.read_string
      ch.close
      ch.wait
    end
    ch.exec command do |_, success|
      raise 'could not execute command' unless success

      started = true
    end
  end
  session.process(0) { !started }
  # Closing the channel without sending any data gives us SIGPIPE
  channel.close unless stdin.nil?
  channel.wait
  return exit_status, stdout, stderr
end
sanitize_script(script) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 434
def sanitize_script(script)
  script.tr("\r", '')
end
session() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 263
def session
  @session ||= begin
                 @logger.debug("opening session to #{@ssh_user}@#{@host}")
                 Net::SSH.start(@host, @ssh_user, ssh_options)
               end
end
settings() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 288
def settings
  ForemanRemoteExecutionCore.settings
end
should_cleanup?() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 259
def should_cleanup?
  @session && !@session.closed? && @cleanup_working_dirs
end
ssh_options() click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 270
def ssh_options
  ssh_options = {}
  ssh_options[:port] = @ssh_port if @ssh_port
  ssh_options[:keys] = [@client_private_key_file] if @client_private_key_file
  ssh_options[:password] = @ssh_password if @ssh_password
  ssh_options[:passphrase] = @key_passphrase if @key_passphrase
  ssh_options[:keys_only] = true
  # if the host public key is contained in the known_hosts_file,
  # verify it, otherwise, if missing, import it and continue
  ssh_options[:paranoid] = true
  ssh_options[:auth_methods] = available_authentication_methods
  ssh_options[:user_known_hosts_file] = prepare_known_hosts if @host_public_key
  ssh_options[:number_of_password_prompts] = 1
  ssh_options[:verbose] = settings[:ssh_log_level]
  ssh_options[:logger] = ForemanRemoteExecutionCore::LogFilter.new(SmartProxyDynflowCore::Log.instance)
  return ssh_options
end
upload_data(data, path, permissions = 555) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 403
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}")
  status, _out, err = run_sync(command, data)

  @logger.warn("Output on stderr while uploading #{path}:\n#{err}") unless err.empty?
  if status != 0
    raise "Unable to upload file to #{path} on remote system: exit code: #{status}"
  end

  path
end
upload_file(local_path, remote_path) click to toggle source
# File lib/foreman_remote_execution_core/script_runner.rb, line 421
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/foreman_remote_execution_core/script_runner.rb, line 438
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