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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.relaxed.yml +176 -0
- data/.rubocop.yml +57 -0
- data/.travis.yml +20 -0
- data/Dockerfile +11 -0
- data/Gemfile +6 -0
- data/LICENSE +201 -0
- data/README.md +233 -0
- data/Rakefile +16 -0
- data/docker-compose.yaml +10 -0
- data/k8s-client.gemspec +39 -0
- data/lib/k8s-client.rb +3 -0
- data/lib/k8s/api_client.rb +115 -0
- data/lib/k8s/client.rb +282 -0
- data/lib/k8s/client/version.rb +8 -0
- data/lib/k8s/config.rb +168 -0
- data/lib/k8s/error.rb +66 -0
- data/lib/k8s/json_parser.rb +97 -0
- data/lib/k8s/json_parser/yajl.rb +44 -0
- data/lib/k8s/logging.rb +87 -0
- data/lib/k8s/resource.rb +111 -0
- data/lib/k8s/resource_client.rb +349 -0
- data/lib/k8s/stack.rb +187 -0
- data/lib/k8s/transport.rb +380 -0
- data/lib/k8s/util.rb +178 -0
- data/lib/k8s/watch_event.rb +12 -0
- metadata +224 -0
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
|