kontena-cli 1.4.0.pre1 → 1.4.0.pre2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 69a344212c91c304c80efd5073e2b3d3e9003b66
4
- data.tar.gz: 04ba668d77961f031b6bdd1bad7cbeacab5dfac9
3
+ metadata.gz: c2f4001bbbebedb3f6e7840825f441c24d125457
4
+ data.tar.gz: 7fa42d09f45e5c62336691c9896ac0928f31635e
5
5
  SHA512:
6
- metadata.gz: 5fb80753ebfd53b27841cedaa60b2de9af188b6448691b7cc5e9f02410e644d44115b8f873295c7cf2eef823fa929911069d7032ebe5334ffbadd46b52d18b35
7
- data.tar.gz: 32129fc7dd6d6ba258238a47799d085d639d5ae0168ee64a90de78756c8984dbfc88885324ce06f5cb4ffe8a76286cb97f119479c3c41e7e4430b6fa6241befc
6
+ metadata.gz: f3d420649dce177b328eb57202875eff8d4929447ee77cd05091aaeda928327678c74f9bda74404b0fa17a95963b68eef549e51d9c871313e36af09ee56c0681
7
+ data.tar.gz: 4e259827e5ed4771a82e1d30267f49fb97e75ba00ec7ad68037f7b4e3f543b8a78084aa350826210c78ff134a89eee876ed97e85736170de2b79fc4200eeae57
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.4.0.pre1
1
+ 1.4.0.pre2
data/kontena-cli.gemspec CHANGED
@@ -33,5 +33,5 @@ Gem::Specification.new do |spec|
33
33
  spec.add_runtime_dependency "semantic", "~> 1.5"
34
34
  spec.add_runtime_dependency "liquid", "~> 4.0.0"
35
35
  spec.add_runtime_dependency "tty-table", "~> 0.8.0"
36
- spec.add_runtime_dependency "websocket-driver-kontena", "0.6.5"
36
+ spec.add_runtime_dependency "kontena-websocket-client", "~> 0.1.0"
37
37
  end
@@ -170,7 +170,10 @@ module Kontena
170
170
  return unless server.token
171
171
  return unless server.token.refresh_token
172
172
  return if server.token.expired?
173
- client = Kontena::Client.new(server.url, server.token)
173
+ client = Kontena::Client.new(server.url, server.token,
174
+ ssl_cert_path: server.ssl_cert_path,
175
+ ssl_subject_cn: server.ssl_subject_cn,
176
+ )
174
177
  logger.debug "Trying to invalidate refresh token on #{server.name}"
175
178
  client.refresh_token
176
179
  rescue => ex
@@ -205,7 +208,9 @@ module Kontena
205
208
 
206
209
  @client = Kontena::Client.new(
207
210
  api_url || require_current_master.url,
208
- token || require_current_master.token
211
+ token || require_current_master.token,
212
+ ssl_cert_path: require_current_master.ssl_cert_path,
213
+ ssl_subject_cn: require_current_master.ssl_subject_cn,
209
214
  )
210
215
  end
211
216
 
@@ -507,6 +507,39 @@ module Kontena
507
507
  super
508
508
  @table[:account] ||= 'master'
509
509
  end
510
+
511
+ def uri
512
+ @uri ||= URI.parse(self.url)
513
+ end
514
+
515
+ # @return [String, nil] path to ~/.kontena/certs/*.pem
516
+ def ssl_cert_path
517
+ path = File.join(Dir.home, '.kontena', 'certs', "#{self.uri.host}.pem")
518
+
519
+ if File.exist?(path) && File.readable?(path)
520
+ return path
521
+ else
522
+ return nil
523
+ end
524
+ end
525
+
526
+ # @return [OpenSSL::X509::Certificate, nil]
527
+ def ssl_cert
528
+ if path = self.ssl_cert_path
529
+ return OpenSSL::X509::Certificate.new(File.read(path))
530
+ else
531
+ return nil
532
+ end
533
+ end
534
+
535
+ # @return [String, nil] ssl cert subject CN=
536
+ def ssl_subject_cn
537
+ if cert = self.ssl_cert
538
+ return cert.subject.to_a.select{|name, data, type| name == 'CN' }.map{|name, data, type| data }.first
539
+ else
540
+ nil
541
+ end
542
+ end
510
543
  end
511
544
 
512
545
  class Token < OpenStruct
@@ -9,42 +9,21 @@ module Kontena::Cli::Containers
9
9
  parameter "CONTAINER_ID", "Container id"
10
10
  parameter "CMD ...", "Command"
11
11
 
12
- option ["--shell"], :flag, "Execute as a shell command"
13
- option ["-i", "--interactive"], :flag, "Keep stdin open"
14
- option ["-t", "--tty"], :flag, "Allocate a pseudo-TTY"
12
+ option ["--shell"], :flag, "Execute as a shell command", default: false
13
+ option ["-i", "--interactive"], :flag, "Keep stdin open", default: false
14
+ option ["-t", "--tty"], :flag, "Allocate a pseudo-TTY", default: false
15
+
16
+ requires_current_master
17
+ requires_current_grid
15
18
 
16
19
  def execute
17
- exit_with_error "the input device is not a TTY" if tty? && !STDIN.tty?
20
+ exit_status = container_exec("#{current_grid}/#{self.container_id}", self.cmd_list,
21
+ interactive: interactive?,
22
+ shell: shell?,
23
+ tty: tty?,
24
+ )
18
25
 
19
- require_api_url
20
- token = require_token
21
- cmd = JSON.dump({cmd: cmd_list})
22
- queue = Queue.new
23
- stdin_reader = nil
24
- url = ws_url("#{current_grid}/#{container_id}", interactive: interactive?, shell: shell?, tty: tty?)
25
- ws = connect(url, token)
26
- ws.on :message do |msg|
27
- data = parse_message(msg)
28
- queue << data if data.is_a?(Hash)
29
- end
30
- ws.on :open do
31
- ws.text(cmd)
32
- stdin_reader = self.stream_stdin_to_ws(ws, tty: self.tty?) if self.interactive?
33
- end
34
- ws.on :close do |e|
35
- if e.reason.include?('code: 404')
36
- queue << {'exit' => 1, 'message' => 'Not found'}
37
- else
38
- queue << {'exit' => 1}
39
- end
40
- end
41
- ws.connect
42
- while msg = queue.pop
43
- self.handle_message(msg)
44
- end
45
- rescue SystemExit
46
- stdin_reader.kill if stdin_reader
47
- raise
26
+ exit exit_status unless exit_status.zero?
48
27
  end
49
28
  end
50
29
  end
@@ -1,87 +1,190 @@
1
- require_relative '../../websocket/client'
1
+ require 'io/console'
2
+ require 'kontena-websocket-client'
2
3
 
3
4
  module Kontena::Cli::Helpers
4
5
  module ExecHelper
5
6
 
6
- # @param [WebSocket::Client::Simple] ws
7
- # @return [Thread]
8
- def stream_stdin_to_ws(ws, tty: nil)
9
- require 'io/console'
10
- Thread.new {
11
- if tty
12
- STDIN.raw {
13
- while char = STDIN.readpartial(1024)
14
- ws.text(JSON.dump({ stdin: char }))
15
- end
16
- }
17
- else
18
- while char = STDIN.gets
19
- ws.text(JSON.dump({ stdin: char }))
7
+ websocket_log_level = if ENV["DEBUG"] == 'websocket'
8
+ Logger::DEBUG
9
+ elsif ENV["DEBUG"]
10
+ Logger::INFO
11
+ else
12
+ Logger::WARN
13
+ end
14
+
15
+ Kontena::Websocket::Logging.initialize_logger(STDERR, websocket_log_level)
16
+
17
+ WEBSOCKET_CLIENT_OPTIONS = {
18
+ connect_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 5.0,
19
+ open_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 5.0,
20
+ ping_interval: ENV["EXCON_READ_TIMEOUT"] ? ENV["EXCON_READ_TIMEOUT"].to_f : 30.0,
21
+ ping_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 5.0,
22
+ close_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_f : 5.0,
23
+ write_timeout: ENV["EXCON_WRITE_TIMEOUT"] ? ENV["EXCON_WRITE_TIMEOUT"].to_f : 5.0,
24
+ }
25
+
26
+ # @param ws [Kontena::Websocket::Client]
27
+ # @param tty [Boolean] read stdin in raw mode, sending tty escapes for remote pty
28
+ # @raise [ArgumentError] not a tty
29
+ # @yield [data]
30
+ # @yieldparam data [String] data from stdin
31
+ # @raise [ArgumentError] not a tty
32
+ # @return EOF on stdin (!tty)
33
+ def read_stdin(tty: nil)
34
+ if tty
35
+ raise ArgumentError, "the input device is not a TTY" unless STDIN.tty?
36
+
37
+ STDIN.raw { |io|
38
+ # we do not expect EOF on a TTY, ^D sends a tty escape to close the pty instead
39
+ loop do
40
+ # raises EOFError, SyscallError or IOError
41
+ yield io.readpartial(1024)
20
42
  end
21
- ws.text(JSON.dump({ stdin: nil }))
43
+ }
44
+ else
45
+ # line-buffered
46
+ while line = STDIN.gets
47
+ yield line
22
48
  end
23
- }
49
+ end
24
50
  end
25
51
 
26
- # @param [Hash] msg
27
- def handle_message(msg)
28
- if msg.has_key?('exit')
29
- if msg['message']
30
- exit_with_error(msg['message'])
31
- else
32
- exit msg['exit'].to_i
33
- end
34
- elsif msg.has_key?('stream')
35
- if msg['stream'] == 'stdout'
36
- $stdout << msg['chunk']
37
- else
38
- $stderr << msg['chunk']
52
+ # @return [String]
53
+ def websocket_url(path, query = nil)
54
+ url = URI.parse(require_current_master.url)
55
+ url.scheme = url.scheme.sub('http', 'ws')
56
+ url.path = '/v1/' + path
57
+ url.query = (query && !query.empty?) ? URI.encode_www_form(query) : nil
58
+ url.to_s
59
+ end
60
+
61
+ # @param ws [Kontena::Websocket::Client]
62
+ # @return [Integer] exit code
63
+ def websocket_exec_read(ws)
64
+ ws.read do |msg|
65
+ msg = JSON.parse(msg)
66
+
67
+ logger.debug "websocket exec read: #{msg.inspect}"
68
+
69
+ if msg.has_key?('exit')
70
+ # breaks the read loop
71
+ return msg['exit'].to_i
72
+ elsif msg.has_key?('stream')
73
+ if msg['stream'] == 'stdout'
74
+ $stdout << msg['chunk']
75
+ else
76
+ $stderr << msg['chunk']
77
+ end
39
78
  end
40
79
  end
41
80
  end
42
81
 
43
- # @param [Websocket::Frame::Incoming] msg
44
- def parse_message(msg)
45
- JSON.parse(msg.data)
46
- rescue JSON::ParserError
47
- nil
82
+ # @param ws [Kontena::Websocket::Client]
83
+ # @param msg [Hash]
84
+ def websocket_exec_write(ws, msg)
85
+ logger.debug "websocket exec write: #{msg.inspect}"
86
+
87
+ ws.send(JSON.dump(msg))
88
+ end
89
+
90
+ # Start thread to read from stdin, and write to websocket.
91
+ # Closes websocket on stdin read errors.
92
+ #
93
+ # @param ws [Kontena::Websocket::Client]
94
+ # @param tty [Boolean]
95
+ # @return [Thread]
96
+ def websocket_exec_write_thread(ws, tty: nil)
97
+ Thread.new do
98
+ begin
99
+ read_stdin(tty: tty) do |stdin|
100
+ websocket_exec_write(ws, 'stdin' => stdin)
101
+ end
102
+ websocket_exec_write(ws, 'stdin' => nil) # EOF
103
+ rescue => exc
104
+ logger.error exc
105
+ ws.close(1001, "stdin read #{exc.class}: #{exc}")
106
+ end
107
+ end
48
108
  end
49
109
 
50
- # @param container_id [String] The container id
110
+ # Connect to server websocket, send from stdin, and write out messages
111
+ #
112
+ # @param paths [String]
113
+ # @param options [Hash] @see Kontena::Websocket::Client
114
+ # @param cmd [Array<String>] command to execute
51
115
  # @param interactive [Boolean] Interactive TTY on/off
52
116
  # @param shell [Boolean] Shell on/of
53
117
  # @param tty [Boolean] TTY on/of
54
- # @return [String]
55
- def ws_url(container_id, interactive: false, shell: false, tty: false)
56
- require 'uri' unless Object.const_defined?(:URI)
57
- extend Kontena::Cli::Common unless self.respond_to?(:require_current_master)
118
+ # @return [Integer] exit code
119
+ def websocket_exec(path, cmd, interactive: false, shell: false, tty: false)
120
+ exit_status = nil
121
+ write_thread = nil
58
122
 
59
- url = URI.parse(require_current_master.url)
60
- url.scheme = url.scheme.sub('http', 'ws')
61
- url.path = "/v1/containers/#{container_id}/exec"
62
- if shell || interactive || tty
63
- query = {}
64
- query.merge!(interactive: true) if interactive
65
- query.merge!(shell: true) if shell
66
- query.merge!(tty: true) if tty
67
- url.query = URI.encode_www_form(query)
68
- end
69
- url.to_s
70
- end
123
+ query = {}
124
+ query[:interactive] = interactive if interactive
125
+ query[:shell] = shell if shell
126
+ query[:tty] = tty if tty
71
127
 
72
- # @param [String] url
73
- # @param [String] token
74
- # @return [WebSocket::Client::Simple]
75
- def connect(url, token)
76
- options = {
77
- headers: {
128
+ server = require_current_master
129
+ url = websocket_url(path, query)
130
+ token = require_token
131
+ options = WEBSOCKET_CLIENT_OPTIONS.dup
132
+ options[:headers] = {
78
133
  'Authorization' => "Bearer #{token.access_token}"
79
- }
80
134
  }
81
- if ENV['SSL_IGNORE_ERRORS'].to_s == 'true'
82
- options[:verify_mode] = ::OpenSSL::SSL::VERIFY_NONE
135
+ options[:ssl_params] = {
136
+ verify_mode: ENV['SSL_IGNORE_ERRORS'].to_s == 'true' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
137
+ ca_file: server.ssl_cert_path,
138
+ }
139
+ options[:ssl_hostname] = server.ssl_subject_cn
140
+
141
+ logger.debug { "websocket exec connect... #{url}" }
142
+
143
+ # we do not expect CloseError, because the server will send an 'exit' message first,
144
+ # and we return before seeing the close frame
145
+ # TODO: handle HTTP 404 errors
146
+ Kontena::Websocket::Client.connect(url, **options) do |ws|
147
+ logger.debug { "websocket exec open" }
148
+
149
+ # first frame contains exec command
150
+ websocket_exec_write(ws, 'cmd' => cmd)
151
+
152
+ if interactive
153
+ # start new thread to write from stdin to websocket
154
+ write_thread = websocket_exec_write_thread(ws, tty: tty)
155
+ end
156
+
157
+ # blocks reading from websocket, returns with exec exit code
158
+ exit_status = websocket_exec_read(ws)
159
+
160
+ fail ws.close_reason unless exit_status
161
+ end
162
+
163
+ rescue Kontena::Websocket::Error => exc
164
+ exit_with_error(exc)
165
+
166
+ rescue => exc
167
+ logger.error { "websocket exec error: #{exc}" }
168
+ raise
169
+
170
+ else
171
+ logger.debug { "websocket exec exit: #{exit_status}"}
172
+ return exit_status
173
+
174
+ ensure
175
+ if write_thread
176
+ write_thread.kill
177
+ write_thread.join
83
178
  end
84
- Kontena::Websocket::Client.new(url, options)
179
+ end
180
+
181
+ # Execute command on container using websocket API.
182
+ #
183
+ # @param id [String] Container ID (grid/host/name)
184
+ # @param cmd [Array<String>] command to execute
185
+ # @return [Integer] exit code
186
+ def container_exec(id, cmd, **exec_options)
187
+ websocket_exec("containers/#{id}/exec", cmd, **exec_options)
85
188
  end
86
189
  end
87
190
  end
@@ -10,14 +10,23 @@ module Kontena::Cli::Services
10
10
  include Kontena::Cli::Helpers::ExecHelper
11
11
  include ServicesHelper
12
12
 
13
+ class ExecExit < StandardError
14
+ attr_reader :exit_status
15
+
16
+ def initialize(exit_status, message = nil)
17
+ super(message)
18
+ @exit_status = exit_status
19
+ end
20
+ end
21
+
13
22
  parameter "NAME", "Service name"
14
23
  parameter "CMD ...", "Command"
15
24
 
16
25
  option ["--instance"], "INSTANCE", "Exec on given numbered instance, default first running" do |value| Integer(value) end
17
26
  option ["-a", "--all"], :flag, "Exec on all running instances"
18
- option ["--shell"], :flag, "Execute as a shell command"
19
- option ["-i", "--interactive"], :flag, "Keep stdin open"
20
- option ["-t", "--tty"], :flag, "Allocate a pseudo-TTY"
27
+ option ["--shell"], :flag, "Execute as a shell command", default: false
28
+ option ["-i", "--interactive"], :flag, "Keep stdin open", default: false
29
+ option ["-t", "--tty"], :flag, "Allocate a pseudo-TTY", default: false
21
30
  option ["--skip"], :flag, "Skip failed instances when executing --all"
22
31
  option ["--silent"], :flag, "Do not show exec status"
23
32
  option ["--verbose"], :flag, "Show exec status"
@@ -39,7 +48,7 @@ module Kontena::Cli::Services
39
48
  ret = true
40
49
  service_containers.each do |container|
41
50
  if container['status'] == 'running'
42
- if !exec_container(container)
51
+ if !execute_container(container)
43
52
  ret = false
44
53
  end
45
54
  else
@@ -52,105 +61,45 @@ module Kontena::Cli::Services
52
61
  exit_with_error "Service #{name} does not have container instance #{instance}"
53
62
  elsif container['status'] != 'running'
54
63
  exit_with_error "Service #{name} container #{container['name']} is not running, it is #{container['status']}"
55
- elsif interactive?
56
- interactive_exec(container)
57
64
  else
58
- exec_container(container)
65
+ execute_container(container)
59
66
  end
60
67
  else
61
- if interactive?
62
- interactive_exec(running_containers.first)
63
- else
64
- exec_container(running_containers.first)
65
- end
68
+ execute_container(running_containers.first)
66
69
  end
67
70
  end
68
71
 
69
- # Exits if exec returns with non-zero
70
- # @param [Hash] container
71
- def exec_container(container)
72
- exit_status = nil
73
- if !silent? && (verbose? || all?)
74
- spinner "Executing command on #{container['name']}" do
75
- exit_status = normal_exec(container)
76
-
77
- raise Kontena::Cli::SpinAbort if exit_status != 0
78
- end
72
+ # Run block with spinner by default if --all, or when using --verbose.
73
+ # Do not use spinner if --silent.
74
+ def maybe_spinner(msg, &block)
75
+ if (all? || verbose?) && !silent?
76
+ spinner(msg, &block)
79
77
  else
80
- exit_status = normal_exec(container)
78
+ yield
81
79
  end
82
-
83
- exit exit_status if exit_status != 0 && !skip?
84
-
85
- return exit_status == 0
86
80
  end
87
81
 
88
82
  # @param [Hash] container
89
- # @return [Boolean]
90
- def normal_exec(container)
91
- base = self
92
- cmd = JSON.dump({ cmd: cmd_list })
93
- exit_status = nil
94
- token = require_token
95
- ws = connect(url(container['id']), token)
96
- ws.on :message do |msg|
97
- data = base.parse_message(msg)
98
- if data
99
- if data['exit']
100
- exit_status = data['exit'].to_i
101
- elsif data['stream'] == 'stdout'
102
- $stdout << data['chunk']
103
- else
104
- $stderr << data['chunk']
105
- end
106
- end
107
- end
108
- ws.on :open do
109
- ws.text(cmd)
83
+ # @raise [SystemExit] if exec exits with non-zero status, and not --skip
84
+ # @return [true] exit exit status zero
85
+ # @return [false] exit exit status non-zero and --skip
86
+ def execute_container(container)
87
+ maybe_spinner "Executing command on #{container['name']}" do
88
+ exit_status = container_exec(container['id'], self.cmd_list,
89
+ interactive: interactive?,
90
+ shell: shell?,
91
+ tty: tty?,
92
+ )
93
+ raise ExecExit.new(exit_status) unless exit_status.zero?
110
94
  end
111
- ws.on :close do |e|
112
- exit_status = 1 if exit_status.nil? && e.code != 1000
113
- end
114
- ws.connect
115
-
116
- sleep 0.01 until !exit_status.nil?
117
-
118
- exit_status
119
- end
120
-
121
- # @param [Hash] container
122
- def interactive_exec(container)
123
- token = require_token
124
- cmd = JSON.dump({ cmd: cmd_list })
125
- queue = Queue.new
126
- stdin_stream = nil
127
- ws = connect(url(container['id']), token)
128
- ws.on :message do |msg|
129
- data = self.parse_message(msg)
130
- queue << data if data.is_a?(Hash)
131
- end
132
- ws.on :open do
133
- ws.text(cmd)
134
- stdin_stream = self.stream_stdin_to_ws(ws, tty: self.tty?)
135
- end
136
- ws.on :close do |e|
137
- if e.code != 1000
138
- queue << {'exit' => 1}
139
- else
140
- queue << {'exit' => 0}
141
- end
142
- end
143
- ws.connect
144
- while msg = queue.pop
145
- self.handle_message(msg)
95
+ rescue ExecExit => exc
96
+ if skip?
97
+ return false
98
+ else
99
+ exit exc.exit_status
146
100
  end
147
- rescue SystemExit
148
- stdin_stream.kill if stdin_stream
149
- raise
150
- end
151
-
152
- def url(container_id)
153
- ws_url(container_id, shell: shell?, interactive: interactive?, tty: tty?)
101
+ else
102
+ return true
154
103
  end
155
104
  end
156
105
  end