module Kontena::Cli::Helpers::ExecHelper

Constants

WEBSOCKET_CLIENT_OPTIONS

Public Instance Methods

container_exec(id, cmd, **exec_options) click to toggle source

Execute command on container using websocket API.

@param id [String] Container ID (grid/host/name) @param cmd [Array<String>] command to execute @return [Integer] exit code

# File lib/kontena/cli/helpers/exec_helper.rb, line 204
def container_exec(id, cmd, **exec_options)
  websocket_exec("containers/#{id}/exec", cmd, **exec_options)
end
read_stdin(tty: nil) { |chunk| ... } click to toggle source

@param ws [Kontena::Websocket::Client] @param tty [Boolean] read stdin in raw mode, sending tty escapes for remote pty @raise [ArgumentError] not a tty @yield [data] @yieldparam data [String] unicode data from stdin @raise [ArgumentError] not a tty @return EOF on stdin (!tty)

# File lib/kontena/cli/helpers/exec_helper.rb, line 34
def read_stdin(tty: nil)
  if tty
    raise ArgumentError, "the input device is not a TTY" unless STDIN.tty?

    STDIN.raw { |io|
      # we do not expect EOF on a TTY, ^D sends a tty escape to close the pty instead
      loop do
        # raises EOFError, SyscallError or IOError
        chunk = io.readpartial(1024)

        # STDIN.raw does not use the ruby external_encoding, it returns binary strings (ASCII-8BIT encoding)
        # however, we use websocket text frames with JSON, which expects unicode strings encodable as UTF-8, and does not handle arbitrary binary data
        # assume all stdin input is using ruby's external_encoding... the JSON.dump will fail if not.
        chunk.force_encoding(Encoding.default_external)

        yield chunk
      end
    }
  else
    # line-buffered, using the default external_encoding (probably UTF-8)
    while line = STDIN.gets
      yield line
    end
  end
end
websocket_exec(path, cmd, interactive: false, shell: false, tty: false) click to toggle source

Connect to server websocket, send from stdin, and write out messages

@param paths [String] @param options [Hash] @see Kontena::Websocket::Client @param cmd [Array<String>] command to execute @param interactive [Boolean] Interactive TTY on/off @param shell [Boolean] Shell on/of @param tty [Boolean] TTY on/of @return [Integer] exit code

# File lib/kontena/cli/helpers/exec_helper.rb, line 137
def websocket_exec(path, cmd, interactive: false, shell: false, tty: false)
  exit_status = nil
  write_thread = nil

  query = {}
  query[:interactive] = interactive if interactive
  query[:shell] = shell if shell
  query[:tty] = tty if tty

  server = require_current_master
  url = websocket_url(path, query)
  token = require_token
  options = WEBSOCKET_CLIENT_OPTIONS.dup
  options[:headers] = {
      'Authorization' => "Bearer #{token.access_token}"
  }
  options[:ssl_params] = {
      verify_mode: ENV['SSL_IGNORE_ERRORS'].to_s == 'true' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
      ca_file: server.ssl_cert_path,
  }
  options[:ssl_hostname] = server.ssl_subject_cn

  logger.debug { "websocket exec connect... #{url}" }

  # we do not expect CloseError, because the server will send an 'exit' message first,
  # and we return before seeing the close frame
  # TODO: handle HTTP 404 errors
  Kontena::Websocket::Client.connect(url, **options) do |ws|
    logger.debug { "websocket exec open" }

    # first frame contains exec command
    websocket_exec_write(ws, 'cmd' => cmd)

    if interactive
      # start new thread to write from stdin to websocket
      write_thread = websocket_exec_write_thread(ws, tty: tty)
    end

    # blocks reading from websocket, returns with exec exit code
    exit_status = websocket_exec_read(ws)

    fail ws.close_reason unless exit_status
  end

rescue Kontena::Websocket::Error => exc
  exit_with_error(exc)

rescue => exc
  logger.error { "websocket exec error: #{exc}" }
  raise

else
  logger.debug { "websocket exec exit: #{exit_status}"}
  return exit_status

ensure
  if write_thread
    write_thread.kill
    write_thread.join
  end
end
websocket_exec_read(ws) click to toggle source

@param ws [Kontena::Websocket::Client] @raise [RuntimeError] exec error @return [Integer] exit code

# File lib/kontena/cli/helpers/exec_helper.rb, line 72
def websocket_exec_read(ws)
  ws.read do |msg|
    msg = JSON.parse(msg)

    logger.debug "websocket exec read: #{msg.inspect}"

    if msg.has_key?('error')
      raise msg['error']
    elsif msg.has_key?('exit')
      # breaks the read loop
      return msg['exit'].to_i
    elsif msg.has_key?('stream')
      if msg['stream'] == 'stdout'
        $stdout << msg['chunk']
      else
        $stderr << msg['chunk']
      end
    end
  end
end
websocket_exec_write(ws, msg) click to toggle source

@param ws [Kontena::Websocket::Client] @param msg [Hash]

# File lib/kontena/cli/helpers/exec_helper.rb, line 95
def websocket_exec_write(ws, msg)
  logger.debug "websocket exec write: #{msg.inspect}"

  ws.send(JSON.dump(msg))
end
websocket_exec_write_thread(ws, tty: nil) click to toggle source

Start thread to read from stdin, and write to websocket. Closes websocket on stdin read errors.

@param ws [Kontena::Websocket::Client] @param tty [Boolean] @return [Thread]

# File lib/kontena/cli/helpers/exec_helper.rb, line 107
def websocket_exec_write_thread(ws, tty: nil)
  Thread.new do
    begin
      if tty
        console_height, console_width = TTY::Screen.size
        websocket_exec_write(ws, 'tty_size' => {
          width: console_width, height: console_height
        })
      end
      read_stdin(tty: tty) do |stdin|
        logger.debug "websocket exec stdin with encoding=#{stdin.encoding}: #{stdin.inspect}"
        websocket_exec_write(ws, 'stdin' => stdin)
      end
      websocket_exec_write(ws, 'stdin' => nil) # EOF
    rescue => exc
      logger.error exc
      ws.close(1001, "stdin read #{exc.class}: #{exc}")
    end
  end
end
websocket_url(path, query = nil) click to toggle source

@return [String]

# File lib/kontena/cli/helpers/exec_helper.rb, line 61
def websocket_url(path, query = nil)
  url = URI.parse(require_current_master.url)
  url.scheme = url.scheme.sub('http', 'ws')
  url.path = '/v1/' + path
  url.query = (query && !query.empty?) ? URI.encode_www_form(query) : nil
  url.to_s
end