Module: Kontena::Cli::Helpers::ExecHelper

Included in:
Containers::ExecCommand, Services::ExecCommand
Defined in:
lib/kontena/cli/helpers/exec_helper.rb

Constant Summary collapse

WEBSOCKET_CLIENT_OPTIONS =
{
  connect_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 10.0,
  open_timeout:    ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 10.0,
  ping_interval:   ENV["EXCON_READ_TIMEOUT"]    ? ENV["EXCON_READ_TIMEOUT"].to_f    : 30.0,
  ping_timeout:    ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 10.0,
  close_timeout:   ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 10.0,
  write_timeout:   ENV["EXCON_WRITE_TIMEOUT"]   ? ENV["EXCON_WRITE_TIMEOUT"].to_f   : 10.0,
}

Instance Method Summary collapse

Instance Method Details

#container_exec(id, cmd, **exec_options) ⇒ Integer

Execute command on container using websocket API.

Parameters:

  • id (String)

    Container ID (grid/host/name)

  • cmd (Array<String>)

    command to execute

Returns:

  • (Integer)

    exit code



204
205
206
# 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) {|data| ... } ⇒ Object

Returns EOF on stdin (!tty).

Parameters:

  • ws (Kontena::Websocket::Client)
  • tty (Boolean) (defaults to: nil)

    read stdin in raw mode, sending tty escapes for remote pty

Yields:

  • (data)

Yield Parameters:

  • data (String)

    unicode data from stdin

Returns:

  • EOF on stdin (!tty)

Raises:

  • (ArgumentError)

    not a tty

  • (ArgumentError)

    not a tty



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 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) ⇒ Integer

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

Parameters:

  • paths (String)
  • options (Hash)

    @see Kontena::Websocket::Client

  • cmd (Array<String>)

    command to execute

  • interactive (Boolean) (defaults to: false)

    Interactive TTY on/off

  • shell (Boolean) (defaults to: false)

    Shell on/of

  • tty (Boolean) (defaults to: false)

    TTY on/of

Returns:

  • (Integer)

    exit code



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# 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) ⇒ Integer

Returns exit code.

Parameters:

  • ws (Kontena::Websocket::Client)

Returns:

  • (Integer)

    exit code

Raises:

  • (RuntimeError)

    exec error



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# 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) ⇒ Object

Parameters:

  • ws (Kontena::Websocket::Client)
  • msg (Hash)


95
96
97
98
99
# 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) ⇒ Thread

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

Parameters:

  • ws (Kontena::Websocket::Client)
  • tty (Boolean) (defaults to: nil)

Returns:

  • (Thread)


107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# 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) ⇒ String

Returns:



61
62
63
64
65
66
67
# 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