require "kontena/cli/helpers/exec_helper" describe Kontena::Cli::Helpers::ExecHelper do include ClientHelpers let(:master_url) { 'http://master.example.com/' } let(:described_class) do Class.new do include Kontena::Cli::Common include Kontena::Cli::Helpers::ExecHelper end end subject { described_class.new } let(:default_options) { Kontena::Cli::Helpers::ExecHelper::WEBSOCKET_CLIENT_OPTIONS } let(:logger) { instance_double(Logger) } before do allow(subject).to receive(:logger).and_return(logger) allow(logger).to receive(:debug) Thread.abort_on_exception = true end describe '#read_stdin' do context 'without tty' do it 'yields lines from stdin.gets until eof' do expect($stdin).to receive(:gets).and_return("line 1\n") expect($stdin).to receive(:gets).and_return("line 2\n") expect($stdin).to receive(:gets).and_return(nil) expect{|b|subject.read_stdin(&b)}.to yield_successive_args( "line 1\n", "line 2\n" ) end end context 'with tty' do let(:stdin_raw) { instance_double(IO) } before do allow(STDIN).to receive(:raw) do |&block| block.call(stdin_raw) end end it 'yields from stdin.readpartial in raw mode until raising error' do expect(stdin_raw).to receive(:readpartial).and_return("f") expect(stdin_raw).to receive(:readpartial).and_return("oo") expect(stdin_raw).to receive(:readpartial).and_return("\n") expect(stdin_raw).to receive(:readpartial).and_raise(EOFError) expect{|b| subject.read_stdin(tty: true, &b)}.to yield_successive_args( "f", "oo", "\n", ).and raise_error(EOFError) end it 'yields chunks of UTF-8 input that are JSON-encodable' do data = "f\u00e5\u00e5".encode('UTF-8').force_encoding('ASCII-8BIT') expect(stdin_raw).to receive(:readpartial).and_return(data[0..2]) expect(stdin_raw).to receive(:readpartial).and_return(data[3..4]) expect(stdin_raw).to receive(:readpartial).and_raise(EOFError) chunks = [] expect{subject.read_stdin(tty: true) do |chunk| chunks << JSON.dump('stdin' => chunk) end}.to raise_error(EOFError) expect(chunks).to eq [ '{"stdin":"f' + "\u00e5" + '"}', '{"stdin":"' + "\u00e5" + '"}', ] end it 'yields tty control characters that are JSON-encodable' do data = "\x03\x04".force_encoding('ASCII-8BIT') expect(stdin_raw).to receive(:readpartial).and_return(data[0]) expect(stdin_raw).to receive(:readpartial).and_return(data[1]) expect(stdin_raw).to receive(:readpartial).and_raise(EOFError) chunks = [] expect{subject.read_stdin(tty: true) do |chunk| chunks << JSON.dump('stdin' => chunk) end}.to raise_error(EOFError) expect(chunks).to eq [ '{"stdin":"\\u0003"}', '{"stdin":"\\u0004"}', ] end end end describe '#websocket_exec' do let(:websocket_url) { 'ws://master.example.com/v1/containers/test-grid/host-node/service-1/exec' } let(:websocket_headers) { { 'Authorization' => 'Bearer 1234567', }} let(:websocket_options) { { headers: websocket_headers, ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER, ca_file: nil, }, ssl_hostname: nil, **default_options }} let(:websocket_client) { instance_double(Kontena::Websocket::Client) } let(:write_thread) { instance_double(Thread) } before do allow(Kontena::Websocket::Client).to receive(:connect).with(websocket_url, websocket_options).and_yield(websocket_client) end it 'connects and reads messages to stdout until exit success' do expect(websocket_client).to receive(:send).with('{"cmd":["test"]}') expect(websocket_client).to receive(:read) do |&block| block.call('{"stream": "stdout", "chunk": "test\n"}') block.call('{"exit": 0}') end expect{ exit_status = subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test' ]) expect(exit_status).to eq 0 }.to output("test\n").to_stdout end it 'connects and reads messages to stderr until exit error' do expect(websocket_client).to receive(:send).with('{"cmd":["test-error"]}') expect(websocket_client).to receive(:read) do |&block| block.call('{"stream": "stderr", "chunk": "error\n"}') block.call('{"exit": 1}') end expect{ exit_status = subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-error' ]) expect(exit_status).to eq 1 }.to output("error\n").to_stderr end context 'with shell' do let(:websocket_url) { 'ws://master.example.com/v1/containers/test-grid/host-node/service-1/exec?shell=true' } it 'connects with the shell query param' do expect(websocket_client).to receive(:send).with('{"cmd":["test-shell"]}') expect(subject).to receive(:websocket_exec_read).and_return(0) subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-shell' ], shell: true) end end context 'with https master' do let(:master_url) { 'https://master.example.com/' } let(:ssl_cert_path) { nil } let(:ssl_subject_cn) { nil } let(:master) { double(:master, url: master_url, ssl_cert_path: ssl_cert_path, ssl_subject_cn: ssl_subject_cn, ) } let(:websocket_url) { 'wss://master.example.com/v1/containers/test-grid/host-node/service-1/exec' } let(:websocket_options) { { headers: websocket_headers, ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER, ca_file: nil, }, ssl_hostname: nil, **default_options } } before do allow(subject).to receive(:require_current_master).and_return(master) allow(ENV).to receive(:[]).with('SSL_IGNORE_ERRORS').and_return(nil) end it 'verifies SSL by default' do expect(Kontena::Websocket::Client).to receive(:connect).with(websocket_url, websocket_options).and_raise(Kontena::Websocket::SSLVerifyError.new(OpenSSL::X509::V_OK, nil, nil, "...")) expect{ subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-ssl' ]) }.to exit_with_error.and output(/certificate verify failed/).to_stderr end context 'with a kontena cli cert' do let(:ssl_cert_path) { '~/.kontena/certs/test.pem' } let(:ssl_subject_cn) { 'Test' } let(:websocket_options) { { headers: websocket_headers, ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER, ca_file: '~/.kontena/certs/test.pem', }, ssl_hostname: 'Test', **default_options } } it 'uses the cert' do expect(Kontena::Websocket::Client).to receive(:connect).with(websocket_url, websocket_options).and_raise(Kontena::Websocket::Error, 'testing') expect{ subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-ssl' ]) }.to exit_with_error.and output(/testing/).to_stderr end end context 'with SSL_IGNORE_ERRORS' do let(:websocket_url) { 'wss://master.example.com/v1/containers/test-grid/host-node/service-1/exec' } let(:websocket_options) { { headers: websocket_headers, ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_NONE, ca_file: nil, }, ssl_hostname: nil, **default_options } } before do allow(ENV).to receive(:[]).with('SSL_IGNORE_ERRORS').and_return('true') end it 'connects without ssl verify' do expect(websocket_client).to receive(:send).with('{"cmd":["test-shell"]}') expect(subject).to receive(:websocket_exec_read).and_return(0) subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-shell' ]) end end end context 'with interactive' do let(:websocket_url) { 'ws://master.example.com/v1/containers/test-grid/host-node/service-1/exec?interactive=true' } it 'connects and sends messages from stdin' do stdin_eof = false expect(STDIN).to receive(:gets).once.and_return "test 1\n" expect(STDIN).to receive(:gets).once.and_return "test 2\n" expect(STDIN).to receive(:gets).once.and_return nil expect(STDIN).to_not receive(:gets) expect(websocket_client).to receive(:send).with('{"cmd":["test-interactive"]}') expect(websocket_client).to receive(:send).with('{"stdin":"test 1\n"}') expect(websocket_client).to receive(:send).with('{"stdin":"test 2\n"}') expect(websocket_client).to receive(:send).with('{"stdin":null}') do stdin_eof = true end expect(websocket_client).to receive(:read) do |&block| sleep 0.1 until stdin_eof block.call('{"exit": 0}') end exit_status = subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-interactive' ], interactive: true) expect(exit_status).to eq 0 end end context 'with interactive tty' do let(:websocket_url) { 'ws://master.example.com/v1/containers/test-grid/host-node/service-1/exec?interactive=true&tty=true' } it 'connects and sends messages from stdin' do stdin_eol = false allow(TTY::Screen).to receive(:size).and_return([100, 100]) expect(websocket_client).to receive(:send).once.with('{"tty_size":{"width":100,"height":100}}') expect(websocket_client).to receive(:send).once.with('{"cmd":["test-tty"]}') expect(subject).to receive(:read_stdin).once.with(tty: true) do |&block| expect(websocket_client).to receive(:send).once.with('{"stdin":"f"}') block.call 'f' expect(websocket_client).to receive(:send).once.with('{"stdin":"oo"}') block.call 'oo' expect(websocket_client).to receive(:send).once.with('{"stdin":"\n"}') block.call "\n" expect(websocket_client).to_not receive(:send) stdin_eol = true sleep end expect(websocket_client).to receive(:read) do |&block| sleep 0.1 until stdin_eol block.call('{"stream": "stdout", "chunk": "ok\n"}') block.call('{"exit": 0}') end expect{ exit_status = subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-tty' ], interactive: true, tty: true) expect(exit_status).to eq 0 }.to output("ok\n").to_stdout end end context 'with interactive stdin read errors' do let(:websocket_url) { 'ws://master.example.com/v1/containers/test-grid/host-node/service-1/exec?interactive=true' } it 'closes websocket and raises from connect block' do stdin_err = false expect(websocket_client).to receive(:send).with('{"cmd":["test-close"]}') expect(STDIN).to receive(:gets).once.and_return "test\n" expect(websocket_client).to receive(:send).with('{"stdin":"test\n"}') expect(STDIN).to receive(:gets).once.and_raise Errno::EIO expect(logger).to receive(:error).with(Errno::EIO) expect(websocket_client).to receive(:close).with(1001, "stdin read Errno::EIO: Input/output error") do stdin_err = true end expect(STDIN).to_not receive(:gets) expect(websocket_client).to receive(:read) do |&block| sleep 0.1 until stdin_err end expect(websocket_client).to receive(:close_reason).and_return "stdin read Errno::EIO: Input/output error" expect(logger).to receive(:error) expect{ subject.websocket_exec('containers/test-grid/host-node/service-1/exec', [ 'test-close' ], interactive: true) }.to raise_error(RuntimeError, "stdin read Errno::EIO: Input/output error") end end end describe '#websocket_url' do it 'returns a websocket URL without query params' do expect(subject.websocket_url('containers/test-grid/host-node/service-1')).to eq 'ws://master.example.com/v1/containers/test-grid/host-node/service-1' end it 'returns a websocket URL with query params' do expect(subject.websocket_url('containers/test-grid/host-node/service-1', shell: true)).to eq 'ws://master.example.com/v1/containers/test-grid/host-node/service-1?shell=true' end context 'without a trailing slash in the master url' do let(:master_url) { 'http://master2.example.com' } # TODO: https it 'returns a websocket URL' do expect(subject.websocket_url('containers/test-grid/host-node/service-1', shell: true)).to eq 'ws://master2.example.com/v1/containers/test-grid/host-node/service-1?shell=true' end end end describe '#container_exec' do it 'uses the exec url for a container id' do expect(subject).to receive(:websocket_exec).with('containers/test-grid/host-node/service-1/exec', ['test'], shell: true) subject.container_exec('test-grid/host-node/service-1', [ 'test' ], shell: true) end end end