k8s-client-renewed 0.10.5.pre.1

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.
data/lib/k8s/stack.rb ADDED
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module K8s
6
+ # Usage: customize the LABEL and CHECKSUM_ANNOTATION
7
+ class Stack
8
+ include Logging
9
+
10
+ # Label used to identify resources belonging to this stack
11
+ LABEL = 'k8s.kontena.io/stack'
12
+
13
+ # Annotation used to identify resource versions
14
+ CHECKSUM_ANNOTATION = 'k8s.kontena.io/stack-checksum'
15
+
16
+ # Annotation used to identify last applied configuration
17
+ LAST_CONFIG_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'
18
+
19
+ # List of apiVersion:kind combinations to skip for stack prune
20
+ # These would lead to stack prune misbehaving if not skipped.
21
+ PRUNE_IGNORE = [
22
+ 'v1:ComponentStatus', # apiserver ignores GET /v1/componentstatuses?labelSelector=... and returns all resources
23
+ 'v1:Endpoints' # inherits stack label from service, but not checksum annotation
24
+ ].freeze
25
+
26
+ # @param name [String] unique name for stack
27
+ # @param path [String] load resources from YAML files
28
+ # @param options [Hash] see Stack#initialize
29
+ # @return [K8s::Stack]
30
+ def self.load(name, path, **options)
31
+ resources = K8s::Resource.from_files(path)
32
+ new(name, resources, **options)
33
+ end
34
+
35
+ # @param name [String] unique name for stack
36
+ # @param path [String] load resources from YAML files
37
+ # @param client [K8s::Client] apply using client
38
+ # @param prune [Boolean] delete old resources
39
+ # @param options [Hash] see Stack#initialize
40
+ # @return [K8s::Stack]
41
+ def self.apply(name, path, client, prune: true, **options)
42
+ load(name, path, **options).apply(client, prune: prune)
43
+ end
44
+
45
+ # Remove any installed stack resources.
46
+ #
47
+ # @param name [String] unique name for stack
48
+ # @param client [K8s::Client] apply using client
49
+ def self.delete(name, client, **options)
50
+ new(name, **options).delete(client)
51
+ end
52
+
53
+ attr_reader :name, :resources
54
+
55
+ # @param name [String]
56
+ # @param resources [Array<K8s::Resource>]
57
+ # @param debug [Boolean]
58
+ # @param label [String]
59
+ # @param checksum_annotation [String]
60
+ # @param last_config_annotation [String]
61
+ def initialize(name, resources = [], debug: false, label: self.class::LABEL, checksum_annotation: self.class::CHECKSUM_ANNOTATION, last_configuration_annotation: self.class::LAST_CONFIG_ANNOTATION)
62
+ @name = name
63
+ @resources = resources
64
+ @keep_resources = {}
65
+ @label = label
66
+ @checksum_annotation = checksum_annotation
67
+ @last_config_annotation = last_configuration_annotation
68
+
69
+ logger! progname: name, debug: debug
70
+ end
71
+
72
+ # @param resource [K8s::Resource] to apply
73
+ # @param base_resource [K8s::Resource] DEPRECATED
74
+ # @return [K8s::Resource]
75
+ # rubocop:disable Lint/UnusedMethodArgument
76
+ def prepare_resource(resource, base_resource: nil)
77
+ # TODO: base_resource is not used anymore, kept for backwards compatibility for a while
78
+
79
+ # calculate checksum only from the "local" source
80
+ checksum = resource.checksum
81
+
82
+ # add stack metadata
83
+ resource.merge(
84
+ metadata: {
85
+ labels: { @label => name },
86
+ annotations: {
87
+ @checksum_annotation => checksum,
88
+ @last_config_annotation => Util.recursive_compact(resource.to_h).to_json
89
+ }
90
+ }
91
+ )
92
+ end
93
+ # rubocop:enable Lint/UnusedMethodArgument
94
+
95
+ # @param client [K8s::Client]
96
+ # @return [Array<K8s::Resource>]
97
+ def apply(client, prune: true)
98
+ server_resources = client.get_resources(resources)
99
+
100
+ resources.zip(server_resources).map do |resource, server_resource|
101
+ if !server_resource
102
+ logger.info "Create resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource.checksum}"
103
+ keep_resource! client.create_resource(prepare_resource(resource))
104
+ elsif server_resource.metadata&.annotations&.dig(@checksum_annotation) != resource.checksum
105
+ logger.info "Update resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource.checksum}"
106
+ r = prepare_resource(resource)
107
+ if server_resource.can_patch?(@last_config_annotation)
108
+ keep_resource! client.patch_resource(server_resource, server_resource.merge_patch_ops(r.to_h, @last_config_annotation))
109
+ else
110
+ # try to update with PUT
111
+ keep_resource! client.update_resource(server_resource.merge(prepare_resource(resource)))
112
+ end
113
+ else
114
+ logger.info "Keep resource #{server_resource.apiVersion}:#{server_resource.kind}/#{server_resource.metadata.name} in namespace #{server_resource.metadata.namespace} with checksum=#{server_resource.metadata.annotations[@checksum_annotation]}"
115
+ keep_resource! server_resource
116
+ end
117
+ end
118
+
119
+ prune(client, keep_resources: true) if prune
120
+ end
121
+
122
+ # key MUST NOT include resource.apiVersion: the same kind can be aliased in different APIs
123
+ # @param resource [K8s::Resource]
124
+ # @return [K8s::Resource]
125
+ def keep_resource!(resource)
126
+ @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] = resource.metadata&.annotations.dig(@checksum_annotation)
127
+ end
128
+
129
+ # @param resource [K8s::Resource]
130
+ # @return [Boolean]
131
+ def keep_resource?(resource)
132
+ keep_annotation = @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"]
133
+ return false unless keep_annotation
134
+
135
+ keep_annotation == resource.metadata&.annotations&.dig(@checksum_annotation)
136
+ end
137
+
138
+ # Delete all stack resources that were not applied
139
+ # @param client [K8s::Client]
140
+ # @param keep_resources [NilClass, Boolean]
141
+ # @param skip_forbidden [Boolean]
142
+ def prune(client, keep_resources:, skip_forbidden: true)
143
+ # using skip_forbidden: assume we can't create resource types that we are forbidden to list, so we don't need to prune them either
144
+ client.list_resources(labelSelector: { @label => name }, skip_forbidden: skip_forbidden).sort do |a, b|
145
+ # Sort resources so that namespaced objects are deleted first
146
+ if a.metadata.namespace == b.metadata.namespace
147
+ 0
148
+ elsif a.metadata.namespace.nil? && !b.metadata.namespace.nil?
149
+ 1
150
+ else
151
+ -1
152
+ end
153
+ end.each do |resource|
154
+ next if PRUNE_IGNORE.include? "#{resource.apiVersion}:#{resource.kind}"
155
+
156
+ resource_label = resource.metadata&.labels&.dig(@label)
157
+ resource_checksum = resource.metadata&.annotations&.dig(@checksum_annotation)
158
+
159
+ logger.debug { "List resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource_checksum}" }
160
+
161
+
162
+ if resource_label != name
163
+ # apiserver did not respect labelSelector
164
+ elsif resource.metadata&.ownerReferences && !resource.metadata.ownerReferences.empty?
165
+ logger.info "Server resource #{resource.apiVersion}:#{resource.apiKind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} has ownerReferences and will be kept"
166
+ elsif keep_resources && keep_resource?(resource)
167
+ # resource is up-to-date
168
+ else
169
+ logger.info "Delete resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace}"
170
+ begin
171
+ client.delete_resource(resource, propagationPolicy: 'Background')
172
+ rescue K8s::Error::NotFound => e
173
+ # assume aliased objects in multiple API groups, like for Deployments
174
+ # alternatively, a custom resource whose definition was already deleted earlier
175
+ logger.debug { "Ignoring #{e} : #{e.message}" }
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ # Delete all stack resources
182
+ # @param client [K8s::Client]
183
+ def delete(client)
184
+ prune(client, keep_resources: false)
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,380 @@
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
+ K8s::JSONParser.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: K8s::Resource)
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 = K8s::JSONParser.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
+ response_class.new(response_data)
255
+ else
256
+ error_class = K8s::Error::HTTP_STATUS_ERRORS[response.status] || K8s::Error::API
257
+
258
+ if response_data.is_a?(Hash) && response_data['kind'] == 'Status'
259
+ status = K8s::Resource.new(response_data)
260
+
261
+ raise error_class.new(method, path, response.status, response.reason_phrase, status)
262
+ elsif response_data
263
+ raise error_class.new(method, path, response.status, "#{response.reason_phrase}: #{response_data}")
264
+ else
265
+ raise error_class.new(method, path, response.status, response.reason_phrase)
266
+ end
267
+ end
268
+ end
269
+
270
+ # @param response_class [Class] coerce into response class using #new
271
+ # @param options [Hash] @see Excon#request
272
+ # @return [response_class, Hash]
273
+ def request(response_class: K8s::Resource, **options)
274
+ if options[:method] == 'DELETE' && need_delete_body?
275
+ options[:request_object] = options.delete(:query)
276
+ end
277
+
278
+ excon_options = request_options(**options)
279
+
280
+ start = Time.now
281
+ excon_client = options[:response_block] ? build_excon : excon
282
+ response = excon_client.request(**excon_options)
283
+ t = Time.now - start
284
+
285
+ obj = options[:response_block] ? {} : parse_response(response, options, response_class: response_class)
286
+ rescue K8s::Error::API => e
287
+ logger.warn { "#{format_request(options)} => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
288
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
289
+ logger.debug { "Response: #{response.body}" }
290
+ raise
291
+ else
292
+ logger.info { "#{format_request(options)} => HTTP #{response.status}: <#{obj.class}> in #{'%.3f' % t}s" }
293
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
294
+ logger.debug { "Response: #{response.body}" }
295
+ obj
296
+ end
297
+
298
+ # @param options [Array<Hash>] @see #request
299
+ # @param skip_missing [Boolean] return nil for HTTP 404 responses
300
+ # @param skip_forbidden [Boolean] return nil for HTTP 403 responses
301
+ # @param retry_errors [Boolean] retry with non-pipelined request for HTTP 503 responses
302
+ # @param common_options [Hash] @see #request, merged with the per-request options
303
+ # @return [Array<response_class, Hash, NilClass>]
304
+ def requests(*options, skip_missing: false, skip_forbidden: false, retry_errors: true, **common_options)
305
+ return [] if options.empty? # excon chokes
306
+
307
+ start = Time.now
308
+ responses = excon.requests(
309
+ options.map{ |opts| request_options(**common_options.merge(opts)) }
310
+ )
311
+ t = Time.now - start
312
+
313
+ objects = responses.zip(options).map{ |response, request_options|
314
+ response_class = request_options[:response_class] || common_options[:response_class] || K8s::Resource
315
+
316
+ begin
317
+ parse_response(response, request_options,
318
+ response_class: response_class)
319
+ rescue K8s::Error::NotFound
320
+ raise unless skip_missing
321
+
322
+ nil
323
+ rescue K8s::Error::Forbidden
324
+ raise unless skip_forbidden
325
+
326
+ nil
327
+ rescue K8s::Error::ServiceUnavailable => e
328
+ raise unless retry_errors
329
+
330
+ logger.warn { "Retry #{format_request(request_options)} => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
331
+
332
+ # only retry the failed request, not the entire pipeline
333
+ request(response_class: response_class, **common_options.merge(request_options))
334
+ end
335
+ }
336
+ rescue K8s::Error => e
337
+ logger.warn { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP #{e.code} #{e.reason} in #{'%.3f' % t}s" }
338
+ raise
339
+ else
340
+ logger.info { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP [#{responses.map(&:status).join ', '}] in #{'%.3f' % t}s" }
341
+ objects
342
+ end
343
+
344
+ # @return [K8s::Resource]
345
+ def version
346
+ @version ||= get('/version')
347
+ end
348
+
349
+ # @return [Boolean] true if delete options should be sent as bode of the DELETE request
350
+ def need_delete_body?
351
+ @need_delete_body ||= Gem::Version.new(version.gitVersion.match(/^v*((\d|\.)*)/)[1]) < DELETE_OPTS_BODY_VERSION_MIN
352
+ end
353
+
354
+ # @param path [Array<String>] @see #path
355
+ # @param options [Hash] @see #request
356
+ # @return [Array<response_class, Hash, NilClass>]
357
+ def get(*path, **options)
358
+ request(
359
+ method: 'GET',
360
+ path: self.path(*path),
361
+ **options
362
+ )
363
+ end
364
+
365
+ # @param paths [Array<String>]
366
+ # @param options [Hash] @see #request
367
+ # @return [Array<response_class, Hash, NilClass>]
368
+ def gets(*paths, **options)
369
+ requests(
370
+ *paths.map do |path|
371
+ {
372
+ method: 'GET',
373
+ path: self.path(path)
374
+ }
375
+ end,
376
+ **options
377
+ )
378
+ end
379
+ end
380
+ end