k8s-client-renewed 0.10.5.pre.1

Sign up to get free protection for your applications and to get access to all the features.
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