k8s-ruby 0.10.5

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.
@@ -0,0 +1,385 @@
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['cmd-path']} #{auth_provider['cmd-args']}`.strip
98
+ if auth_provider['token-key']
99
+ json_path = JsonPath.new(auth_provider['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
+ request(
364
+ method: 'GET',
365
+ path: self.path(*path),
366
+ **options
367
+ )
368
+ end
369
+
370
+ # @param paths [Array<String>]
371
+ # @param options [Hash] @see #request
372
+ # @return [Array<response_class, Hash, NilClass>]
373
+ def gets(*paths, **options)
374
+ requests(
375
+ *paths.map do |path|
376
+ {
377
+ method: 'GET',
378
+ path: self.path(path)
379
+ }
380
+ end,
381
+ **options
382
+ )
383
+ end
384
+ end
385
+ end
@@ -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