k8s-ruby2 0.10.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'excon'
4
+ require 'json'
5
+ require 'jsonpath'
6
+
7
+ module K8s
8
+ # Excon-based HTTP transport handling request/response body JSON encoding
9
+ class Transport
10
+ include Logging
11
+
12
+ quiet! # do not log warnings by default
13
+
14
+ # Excon middlewares for requests
15
+ EXCON_MIDDLEWARES = [
16
+ # XXX: necessary? redirected requests omit authz headers?
17
+ Excon::Middleware::RedirectFollower
18
+ ] + Excon.defaults[:middlewares]
19
+
20
+ # Default request headers
21
+ REQUEST_HEADERS = {
22
+ 'Accept' => 'application/json'
23
+ }.freeze
24
+
25
+ # Min version of Kube API for which delete options need to be sent as request body
26
+ DELETE_OPTS_BODY_VERSION_MIN = Gem::Version.new('1.11')
27
+
28
+ # Construct transport from kubeconfig
29
+ #
30
+ # @param config [K8s::Config]
31
+ # @param server [String] override cluster.server from config
32
+ # @param overrides @see #initialize
33
+ # @return [K8s::Transport]
34
+ def self.config(config, server: nil, **overrides)
35
+ options = {}
36
+
37
+ server ||= config.cluster.server
38
+
39
+ if config.cluster.insecure_skip_tls_verify
40
+ logger.debug "Using config with .cluster.insecure_skip_tls_verify"
41
+
42
+ options[:ssl_verify_peer] = false
43
+ end
44
+
45
+ if path = config.cluster.certificate_authority
46
+ logger.debug "Using config with .cluster.certificate_authority"
47
+
48
+ options[:ssl_ca_file] = path
49
+ end
50
+
51
+ if data = config.cluster.certificate_authority_data
52
+ logger.debug "Using config with .cluster.certificate_authority_data"
53
+
54
+ ssl_cert_store = options[:ssl_cert_store] = OpenSSL::X509::Store.new
55
+ ssl_cert_store.add_cert(OpenSSL::X509::Certificate.new(Base64.decode64(data)))
56
+ end
57
+
58
+ if (cert = config.user.client_certificate) && (key = config.user.client_key)
59
+ logger.debug "Using config with .user.client_certificate/client_key"
60
+
61
+ options[:client_cert] = cert
62
+ options[:client_key] = key
63
+ end
64
+
65
+ if (cert_data = config.user.client_certificate_data) && (key_data = config.user.client_key_data)
66
+ logger.debug "Using config with .user.client_certificate_data/client_key_data"
67
+
68
+ options[:client_cert_data] = Base64.decode64(cert_data)
69
+ options[:client_key_data] = Base64.decode64(key_data)
70
+ end
71
+
72
+ if token = config.user.token
73
+ logger.debug "Using config with .user.token=..."
74
+
75
+ options[:auth_token] = token
76
+ elsif config.user.auth_provider && auth_provider = config.user.auth_provider.config
77
+ logger.debug "Using config with .user.auth-provider.name=#{config.user.auth_provider.name}"
78
+ options[:auth_token] = token_from_auth_provider(auth_provider)
79
+ elsif exec_conf = config.user.exec
80
+ logger.debug "Using config with .user.exec.command=#{exec_conf.command}"
81
+ options[:auth_token] = token_from_exec(exec_conf)
82
+ elsif config.user.username && config.user.password
83
+ logger.debug "Using config with .user.password=..."
84
+
85
+ options[:auth_username] = config.user.username
86
+ options[:auth_password] = config.user.password
87
+ end
88
+
89
+ logger.info "Using config with server=#{server}"
90
+
91
+ new(server, **options, **overrides)
92
+ end
93
+
94
+ # @param auth_provider [K8s::Config::UserAuthProvider]
95
+ # @return [String]
96
+ def self.token_from_auth_provider(auth_provider)
97
+ auth_data = `#{auth_provider['config']['cmd-path']} #{auth_provider['config']['cmd-args']}`.strip
98
+ if auth_provider['config']['token-key']
99
+ json_path = JsonPath.new(auth_provider['config']['token-key'][1...-1])
100
+ json_path.first(auth_data)
101
+ else
102
+ auth_data
103
+ end
104
+ end
105
+
106
+ # @param exec_conf [K8s::Config::UserExec]
107
+ # @return [String]
108
+ def self.token_from_exec(exec_conf)
109
+ cmd = [exec_conf.command]
110
+ cmd += exec_conf.args if exec_conf.args
111
+ orig_env = ENV.to_h
112
+ if envs = exec_conf.env
113
+ envs.each do |env|
114
+ ENV[env['name']] = env['value']
115
+ end
116
+ end
117
+ auth_json = `#{cmd.join(' ')}`.strip
118
+ ENV.replace(orig_env)
119
+
120
+ JSON.parse(auth_json).dig('status', 'token')
121
+ end
122
+
123
+ # In-cluster config within a kube pod, using the kubernetes service envs and serviceaccount secrets
124
+ #
125
+ # @param options [Hash] see #new
126
+ # @return [K8s::Transport]
127
+ # @raise [K8s::Error::Config] when the environment variables KUBERNETES_SEVICE_HOST and KUBERNETES_SERVICE_PORT_HTTPS are not set
128
+ # @raise [Errno::ENOENT,Errno::EACCES] when /var/run/secrets/kubernetes.io/serviceaccount/ca.crt or /var/run/secrets/kubernetes.io/serviceaccount/token can not be read
129
+ def self.in_cluster_config(**options)
130
+ host = ENV['KUBERNETES_SERVICE_HOST'].to_s
131
+ raise(K8s::Error::Configuration, "in_cluster_config failed: KUBERNETES_SERVICE_HOST environment not set") if host.empty?
132
+
133
+ port = ENV['KUBERNETES_SERVICE_PORT_HTTPS'].to_s
134
+ raise(K8s::Error::Configuration, "in_cluster_config failed: KUBERNETES_SERVICE_PORT_HTTPS environment not set") if port.empty?
135
+
136
+ new(
137
+ "https://#{host}:#{port}",
138
+ ssl_verify_peer: options.key?(:ssl_verify_peer) ? options.delete(:ssl_verify_peer) : true,
139
+ ssl_ca_file: options.delete(:ssl_ca_file) || File.join((ENV['TELEPRESENCE_ROOT'] || '/'), 'var/run/secrets/kubernetes.io/serviceaccount/ca.crt'),
140
+ auth_token: options.delete(:auth_token) || File.read(File.join((ENV['TELEPRESENCE_ROOT'] || '/'), 'var/run/secrets/kubernetes.io/serviceaccount/token')),
141
+ **options
142
+ )
143
+ end
144
+
145
+ attr_reader :server, :options, :path_prefix
146
+
147
+ # @param server [String] URL with protocol://host:port (paths are preserved as well)
148
+ # @param auth_token [String] optional Authorization: Bearer token
149
+ # @param auth_username [String] optional Basic authentication username
150
+ # @param auth_password [String] optional Basic authentication password
151
+ # @param options [Hash] @see Excon.new
152
+ def initialize(server, auth_token: nil, auth_username: nil, auth_password: nil, **options)
153
+ uri = URI.parse(server)
154
+ @server = "#{uri.scheme}://#{uri.host}:#{uri.port}"
155
+ @path_prefix = File.join('/', uri.path, '/') # add leading and/or trailing slashes
156
+ @auth_token = auth_token
157
+ @auth_username = auth_username
158
+ @auth_password = auth_password
159
+ @options = options
160
+
161
+ logger! progname: @server
162
+ end
163
+
164
+ # @return [Excon::Connection]
165
+ def excon
166
+ @excon ||= build_excon
167
+ end
168
+
169
+ # @return [Excon::Connection]
170
+ def build_excon
171
+ Excon.new(
172
+ @server,
173
+ persistent: true,
174
+ middlewares: EXCON_MIDDLEWARES,
175
+ headers: REQUEST_HEADERS,
176
+ **@options
177
+ )
178
+ end
179
+
180
+ # @param parts [Array<String>] join path parts together to build the full URL
181
+ # @return [String]
182
+ def path(*parts)
183
+ joined_parts = File.join(*parts)
184
+ joined_parts.start_with?(path_prefix) ? joined_parts : File.join(path_prefix, joined_parts)
185
+ end
186
+
187
+ # @param request_object [Object] include request body using to_json
188
+ # @param content_type [String] request body content-type
189
+ # @param options [Hash] @see Excon#request
190
+ # @return [Hash]
191
+ def request_options(request_object: nil, content_type: 'application/json', **options)
192
+ options[:headers] ||= {}
193
+
194
+ if @auth_token
195
+ options[:headers]['Authorization'] = "Bearer #{@auth_token}"
196
+ elsif @auth_username && @auth_password
197
+ options[:headers]['Authorization'] = "Basic #{Base64.strict_encode64("#{@auth_username}:#{@auth_password}")}"
198
+ end
199
+
200
+ if request_object
201
+ options[:headers]['Content-Type'] = content_type
202
+ options[:body] = request_object.to_json
203
+ end
204
+
205
+ options
206
+ end
207
+
208
+ # @param options [Hash] as passed to Excon#request
209
+ # @return [String]
210
+ def format_request(options)
211
+ method = options[:method]
212
+ path = options[:path]
213
+ body = nil
214
+
215
+ if options[:query]
216
+ path += Excon::Utils.query_string(options)
217
+ end
218
+
219
+ if obj = options[:request_object]
220
+ body = "<#{obj.class.name}>"
221
+ end
222
+
223
+ [method, path, body].compact.join " "
224
+ end
225
+
226
+ # @param response [Hash] as returned by Excon#request
227
+ # @param request_options [Hash] as passed to Excon#request
228
+ # @param response_class [Class] coerce into response body using #new
229
+ # @raise [K8s::Error]
230
+ # @raise [Excon::Error] TODO: wrap
231
+ # @return [response_class, Hash]
232
+ def parse_response(response, request_options, response_class: nil)
233
+ method = request_options[:method]
234
+ path = request_options[:path]
235
+ content_type = response.headers['Content-Type']&.split(';', 2)&.first
236
+
237
+ case content_type
238
+ when 'application/json'
239
+ response_data = Yajl::Parser.parse(response.body)
240
+
241
+ when 'text/plain'
242
+ response_data = response.body # XXX: broken if status 2xx
243
+ else
244
+ raise K8s::Error::API.new(method, path, response.status, "Invalid response Content-Type: #{response.headers['Content-Type'].inspect}")
245
+ end
246
+
247
+ if response.status.between? 200, 299
248
+ return response_data if content_type == 'text/plain'
249
+
250
+ unless response_data.is_a? Hash
251
+ raise K8s::Error::API.new(method, path, response.status, "Invalid JSON response: #{response_data.inspect}")
252
+ end
253
+
254
+ return response_data unless response_class
255
+
256
+ response_class.new(response_data)
257
+ else
258
+ error_class = K8s::Error::HTTP_STATUS_ERRORS[response.status] || K8s::Error::API
259
+
260
+ if response_data.is_a?(Hash) && response_data['kind'] == 'Status'
261
+ status = K8s::API::MetaV1::Status.new(response_data)
262
+
263
+ raise error_class.new(method, path, response.status, response.reason_phrase, status)
264
+ elsif response_data
265
+ raise error_class.new(method, path, response.status, "#{response.reason_phrase}: #{response_data}")
266
+ else
267
+ raise error_class.new(method, path, response.status, response.reason_phrase)
268
+ end
269
+ end
270
+ end
271
+
272
+ # @param response_class [Class] coerce into response class using #new
273
+ # @param options [Hash] @see Excon#request
274
+ # @return [response_class, Hash]
275
+ def request(response_class: nil, **options)
276
+ if options[:method] == 'DELETE' && need_delete_body?
277
+ options[:request_object] = options.delete(:query)
278
+ end
279
+
280
+ excon_options = request_options(**options)
281
+
282
+ start = Time.now
283
+ excon_client = options[:response_block] ? build_excon : excon
284
+ response = excon_client.request(**excon_options)
285
+ t = Time.now - start
286
+
287
+ obj = options[:response_block] ? {} : parse_response(response, options, response_class: response_class)
288
+ rescue K8s::Error::API => e
289
+ logger.warn { "#{format_request(options)} => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
290
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
291
+ logger.debug { "Response: #{response.body}" }
292
+ raise
293
+ else
294
+ logger.info { "#{format_request(options)} => HTTP #{response.status}: <#{obj.class}> in #{'%.3f' % t}s" }
295
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
296
+ logger.debug { "Response: #{response.body}" }
297
+ obj
298
+ end
299
+
300
+ # @param options [Array<Hash>] @see #request
301
+ # @param skip_missing [Boolean] return nil for HTTP 404 responses
302
+ # @param skip_forbidden [Boolean] return nil for HTTP 403 responses
303
+ # @param retry_errors [Boolean] retry with non-pipelined request for HTTP 503 responses
304
+ # @param common_options [Hash] @see #request, merged with the per-request options
305
+ # @return [Array<response_class, Hash, NilClass>]
306
+ def requests(*options, skip_missing: false, skip_forbidden: false, retry_errors: true, **common_options)
307
+ return [] if options.empty? # excon chokes
308
+
309
+ start = Time.now
310
+ responses = excon.requests(
311
+ options.map{ |opts| request_options(**common_options.merge(opts)) }
312
+ )
313
+ t = Time.now - start
314
+
315
+ objects = responses.zip(options).map{ |response, request_options|
316
+ response_class = request_options[:response_class] || common_options[:response_class]
317
+
318
+ begin
319
+ parse_response(response, request_options,
320
+ response_class: response_class)
321
+ rescue K8s::Error::NotFound
322
+ raise unless skip_missing
323
+
324
+ nil
325
+ rescue K8s::Error::Forbidden
326
+ raise unless skip_forbidden
327
+
328
+ nil
329
+ rescue K8s::Error::ServiceUnavailable => e
330
+ raise unless retry_errors
331
+
332
+ logger.warn { "Retry #{format_request(request_options)} => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
333
+
334
+ # only retry the failed request, not the entire pipeline
335
+ request(response_class: response_class, **common_options.merge(request_options))
336
+ end
337
+ }
338
+ rescue K8s::Error => e
339
+ logger.warn { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
340
+ raise
341
+ else
342
+ logger.info { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP [#{responses.map(&:status).join ', '}] in #{'%.3f' % t}s" }
343
+ objects
344
+ end
345
+
346
+ # @return [K8s::API::Version]
347
+ def version
348
+ @version ||= get(
349
+ '/version',
350
+ response_class: K8s::API::Version
351
+ )
352
+ end
353
+
354
+ # @return [Boolean] true if delete options should be sent as bode of the DELETE request
355
+ def need_delete_body?
356
+ @need_delete_body ||= Gem::Version.new(version.gitVersion.match(/^v*((\d|\.)*)/)[1]) < DELETE_OPTS_BODY_VERSION_MIN
357
+ end
358
+
359
+ # @param path [Array<String>] @see #path
360
+ # @param options [Hash] @see #request
361
+ # @return [Array<response_class, Hash, NilClass>]
362
+ def get(*path, **options)
363
+ options = options.merge({ method: 'GET', path: self.path(*path) })
364
+ request(**options)
365
+ end
366
+
367
+ # @param paths [Array<String>]
368
+ # @param options [Hash] @see #request
369
+ # @return [Array<response_class, Hash, NilClass>]
370
+ def gets(*paths, **options)
371
+ requests(
372
+ *paths.map do |path|
373
+ {
374
+ method: 'GET',
375
+ path: self.path(path)
376
+ }
377
+ end,
378
+ **options
379
+ )
380
+ end
381
+ end
382
+ end
data/lib/k8s/util.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K8s
4
+ # Miscellaneous helpers
5
+ module Util
6
+ module HashDeepMerge
7
+ refine Hash do
8
+ # @param other [Hash]
9
+ # @param overwrite_arrays [Boolean] when encountering an array, replace the array with the new array
10
+ # @param union_arrays [Boolean] when encountering an array, use Array#union to combine with the existing array
11
+ # @param keep_existing [Boolean] prefer old value over new value
12
+ # @param merge_nil_values [Boolean] overwrite an existing value with a nil value
13
+ # @param merge_non_hash [Boolean] calls .merge on objects that respond to .merge
14
+ def deep_merge(other, overwrite_arrays: true, union_arrays: false, keep_existing: false, merge_nil_values: false, merge_non_hash: false)
15
+ merge(other) do |key, old_value, new_value|
16
+ case old_value
17
+ when Hash
18
+ raise "#{key} : #{new_value.class.name} can not be merged into a Hash" unless new_value.is_a?(Hash)
19
+
20
+ old_value.deep_merge(
21
+ new_value,
22
+ overwrite_arrays: overwrite_arrays,
23
+ union_arrays: union_arrays,
24
+ keep_existing: keep_existing,
25
+ merge_nil_values: merge_nil_values,
26
+ merge_non_hash: merge_non_hash
27
+ )
28
+ when Array
29
+ if overwrite_arrays
30
+ new_value
31
+ elsif union_arrays
32
+ raise "#{key} : #{new_value.class.name} can not be merged into an Array" unless new_value.is_a?(Array)
33
+
34
+ old_value | new_value
35
+ else
36
+ old_value + new_value
37
+ end
38
+ else
39
+ if keep_existing
40
+ old_value
41
+ elsif new_value.nil? && merge_nil_values
42
+ nil
43
+ elsif merge_non_hash && old_value.respond_to?(:merge)
44
+ old_value.merge(new_value)
45
+ else
46
+ new_value.nil? ? old_value : new_value
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def deep_merge!(other, **options)
53
+ replace(deep_merge(other, **options))
54
+ end
55
+ end
56
+ end
57
+
58
+ PATH_TR_MAP = { '~' => '~0', '/' => '~1' }.freeze
59
+ PATH_REGEX = %r{(/|~(?!1))}.freeze
60
+
61
+ # Yield with all non-nil args, returning matching array with corresponding return values or nils.
62
+ #
63
+ # Args must be usable as hash keys. Duplicate args will all map to the same return value.
64
+ #
65
+ # @param args [Array<nil, Object>]
66
+ # @yield args
67
+ # @yieldparam args [Array<Object>] omitting any nil values
68
+ # @return [Array<nil, Object>] matching args array 1:1, containing yielded values for non-nil args
69
+ def self.compact_map(args)
70
+ func_args = args.compact
71
+
72
+ values = yield func_args
73
+
74
+ # Hash{arg => value}
75
+ value_map = Hash[func_args.zip(values)]
76
+
77
+ args.map{ |arg| value_map[arg] }
78
+ end
79
+
80
+ # Recursive compact for Hash/Array
81
+ #
82
+ # @param hash_or_array [Hash,Array]
83
+ # @return [Hash,Array]
84
+ def self.recursive_compact(hash_or_array)
85
+ p = proc do |*args|
86
+ v = args.last
87
+ v.delete_if(&p) if v.respond_to?(:delete_if) && !v.is_a?(Array)
88
+ v.nil? || v.respond_to?(:empty?) && (v.empty? && (v.is_a?(Hash) || v.is_a?(Array)))
89
+ end
90
+
91
+ hash_or_array.delete_if(&p)
92
+ end
93
+
94
+ # Produces a set of json-patch operations so that applying
95
+ # the operations on a, gives you the results of b
96
+ # Used in correctly patching the Kube resources on stack updates
97
+ #
98
+ # @param patch_to [Hash] Hash to compute patches against
99
+ # @param patch_from [Hash] New Hash to compute patches "from"
100
+ def self.json_patch(patch_to, patch_from)
101
+ diffs = Hashdiff.diff(patch_to, patch_from, array_path: true)
102
+ ops = []
103
+ # Each diff is like:
104
+ # ["+", ["spec", "selector", "food"], "kebab"]
105
+ # ["-", ["spec", "selector", "drink"], "pepsi"]
106
+ # or
107
+ # ["~", ["spec", "selector", "drink"], "old value", "new value"]
108
+ # the path elements can be symbols too, depending on the input hashes
109
+ diffs.each do |diff|
110
+ operator = diff[0]
111
+ # substitute '/' with '~1' and '~' with '~0'
112
+ # according to RFC 6901
113
+ path = diff[1].map { |p| p.to_s.gsub(PATH_REGEX, PATH_TR_MAP) }
114
+ if operator == '-'
115
+ ops << {
116
+ op: "remove",
117
+ path: "/" + path.join('/')
118
+ }
119
+ elsif operator == '+'
120
+ ops << {
121
+ op: "add",
122
+ path: "/" + path.join('/'),
123
+ value: diff[2]
124
+ }
125
+ elsif operator == '~'
126
+ ops << {
127
+ op: "replace",
128
+ path: "/" + path.join('/'),
129
+ value: diff[3]
130
+ }
131
+ else
132
+ raise "Unknown diff operator: #{operator}!"
133
+ end
134
+ end
135
+
136
+ ops
137
+ end
138
+ end
139
+ end
data/lib/k8s-ruby.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'k8s/ruby/version'
4
+ require_relative 'k8s/client'