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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.relaxed.yml +176 -0
- data/.rubocop.yml +57 -0
- data/Dockerfile +11 -0
- data/Gemfile +6 -0
- data/LICENSE +201 -0
- data/README.md +214 -0
- data/Rakefile +16 -0
- data/bin/k8s-client +337 -0
- data/docker-compose.yaml +10 -0
- data/k8s-ruby.gemspec +40 -0
- data/lib/k8s-ruby.rb +4 -0
- data/lib/k8s/api.rb +31 -0
- data/lib/k8s/api/metav1.rb +26 -0
- data/lib/k8s/api/metav1/api_group.rb +26 -0
- data/lib/k8s/api/metav1/api_resource.rb +26 -0
- data/lib/k8s/api/metav1/list.rb +20 -0
- data/lib/k8s/api/metav1/object.rb +53 -0
- data/lib/k8s/api/metav1/status.rb +34 -0
- data/lib/k8s/api/metav1/watch_event.rb +20 -0
- data/lib/k8s/api/version.rb +17 -0
- data/lib/k8s/api_client.rb +116 -0
- data/lib/k8s/client.rb +283 -0
- data/lib/k8s/config.rb +208 -0
- data/lib/k8s/error.rb +66 -0
- data/lib/k8s/logging.rb +87 -0
- data/lib/k8s/resource.rb +117 -0
- data/lib/k8s/resource_client.rb +349 -0
- data/lib/k8s/ruby/version.rb +8 -0
- data/lib/k8s/stack.rb +186 -0
- data/lib/k8s/transport.rb +385 -0
- data/lib/k8s/util.rb +139 -0
- metadata +265 -0
@@ -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
|
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
|