k8s-ruby 0.10.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K8s
4
+ # Per-APIResource type client.
5
+ #
6
+ # Used to get/list/update/patch/delete specific types of resources, optionally in some specific namespace.
7
+ class ResourceClient
8
+ # Common helpers used in both class/instance methods
9
+ module Utils
10
+ # @param selector [NilClass, String, Hash{String => String}]
11
+ # @return [NilClass, String]
12
+ def selector_query(selector)
13
+ case selector
14
+ when nil
15
+ nil
16
+ when String
17
+ selector
18
+ when Hash
19
+ selector.map{ |k, v| "#{k}=#{v}" }.join ','
20
+ else
21
+ fail "Invalid selector type. #{selector.inspect}"
22
+ end
23
+ end
24
+
25
+ # @param options [Hash]
26
+ # @return [Hash, NilClass]
27
+ def make_query(options)
28
+ query = options.compact
29
+
30
+ return nil if query.empty?
31
+
32
+ query
33
+ end
34
+ end
35
+
36
+ include Utils
37
+ extend Utils
38
+
39
+ # Pipeline list requests for multiple resource types.
40
+ #
41
+ # Returns flattened array with mixed resource kinds.
42
+ #
43
+ # @param resources [Array<K8s::ResourceClient>]
44
+ # @param transport [K8s::Transport]
45
+ # @param namespace [String, nil]
46
+ # @param labelSelector [nil, String, Hash{String => String}]
47
+ # @param fieldSelector [nil, String, Hash{String => String}]
48
+ # @param skip_forbidden [Boolean] skip resources that return HTTP 403 errors
49
+ # @return [Array<K8s::Resource>]
50
+ def self.list(resources, transport, namespace: nil, labelSelector: nil, fieldSelector: nil, skip_forbidden: false)
51
+ api_paths = resources.map{ |resource| resource.path(namespace: namespace) }
52
+ api_lists = transport.gets(
53
+ *api_paths,
54
+ response_class: K8s::API::MetaV1::List,
55
+ query: make_query(
56
+ 'labelSelector' => selector_query(labelSelector),
57
+ 'fieldSelector' => selector_query(fieldSelector)
58
+ ),
59
+ skip_forbidden: skip_forbidden
60
+ )
61
+
62
+ resources.zip(api_lists).map { |resource, api_list| api_list ? resource.process_list(api_list) : [] }.flatten
63
+ end
64
+
65
+ # @param transport [K8s::Transport]
66
+ # @param api_client [K8s::APIClient]
67
+ # @param api_resource [K8s::API::MetaV1::APIResource]
68
+ # @param namespace [String]
69
+ # @param resource_class [Class]
70
+ def initialize(transport, api_client, api_resource, namespace: nil, resource_class: K8s::Resource)
71
+ @transport = transport
72
+ @api_client = api_client
73
+ @api_resource = api_resource
74
+ @namespace = namespace
75
+ @resource_class = resource_class
76
+
77
+ if @api_resource.name.include? '/'
78
+ @resource, @subresource = @api_resource.name.split('/', 2)
79
+ else
80
+ @resource = @api_resource.name
81
+ @subresource = nil
82
+ end
83
+
84
+ fail "Resource #{api_resource.name} is not namespaced" unless api_resource.namespaced || !namespace
85
+ end
86
+
87
+ # @return [String]
88
+ def api_version
89
+ @api_client.api_version
90
+ end
91
+
92
+ # @return [String] resource or resource/subresource
93
+ def name
94
+ @api_resource.name
95
+ end
96
+
97
+ # @return [String, nil]
98
+ attr_reader :namespace
99
+
100
+ # @return [String]
101
+ attr_reader :resource
102
+
103
+ # @return [Boolean]
104
+ def subresource?
105
+ !!@subresource
106
+ end
107
+
108
+ # @return [String, nil]
109
+ attr_reader :subresource
110
+
111
+ # @return [String]
112
+ def kind
113
+ @api_resource.kind
114
+ end
115
+
116
+ # @return [class] K8s::Resource
117
+ attr_reader :resource_class
118
+
119
+ # @return [Boolean]
120
+ def namespaced?
121
+ !!@api_resource.namespaced
122
+ end
123
+
124
+ # @param name [NilClass, String]
125
+ # @param subresource [String, NilClass]
126
+ # @param namespace [String, NilClass]
127
+ # @return [String]
128
+ def path(name = nil, subresource: @subresource, namespace: @namespace)
129
+ namespace_part = namespace ? ['namespaces', namespace] : []
130
+
131
+ if name && subresource
132
+ @api_client.path(*namespace_part, @resource, name, subresource)
133
+ elsif name
134
+ @api_client.path(*namespace_part, @resource, name)
135
+ else
136
+ @api_client.path(*namespace_part, @resource)
137
+ end
138
+ end
139
+
140
+ # @return [Bool]
141
+ def create?
142
+ @api_resource.verbs.include? 'create'
143
+ end
144
+
145
+ # @param resource [#metadata] with metadata.namespace and metadata.name set
146
+ # @return [Object] instance of resource_class
147
+ def create_resource(resource)
148
+ @transport.request(
149
+ method: 'POST',
150
+ path: path(namespace: resource.metadata.namespace),
151
+ request_object: resource,
152
+ response_class: @resource_class
153
+ )
154
+ end
155
+
156
+ # @return [Bool]
157
+ def get?
158
+ @api_resource.verbs.include? 'get'
159
+ end
160
+
161
+ # @param name [String]
162
+ # @param namespace [String, NilClass]
163
+ # @return [Object] instance of resource_class
164
+ def get(name, namespace: @namespace)
165
+ @transport.request(
166
+ method: 'GET',
167
+ path: path(name, namespace: namespace),
168
+ response_class: @resource_class
169
+ )
170
+ end
171
+
172
+ # @param resource [resource_class]
173
+ # @return [Object] instance of resource_class
174
+ def get_resource(resource)
175
+ @transport.request(
176
+ method: 'GET',
177
+ path: path(resource.metadata.name, namespace: resource.metadata.namespace),
178
+ response_class: @resource_class
179
+ )
180
+ end
181
+
182
+ # @return [Bool]
183
+ def list?
184
+ @api_resource.verbs.include? 'list'
185
+ end
186
+
187
+ # @param list [K8s::API::MetaV1::List]
188
+ # @return [Array<Object>] array of instances of resource_class
189
+ def process_list(list)
190
+ list.items.map { |item|
191
+ # list items omit kind/apiVersion
192
+ @resource_class.new(item.merge('apiVersion' => list.apiVersion, 'kind' => @api_resource.kind))
193
+ }
194
+ end
195
+
196
+ # @param labelSelector [nil, String, Hash{String => String}]
197
+ # @param fieldSelector [nil, String, Hash{String => String}]
198
+ # @param namespace [nil, String]
199
+ # @return [Array<Object>] array of instances of resource_class
200
+ def list(labelSelector: nil, fieldSelector: nil, namespace: @namespace)
201
+ list = meta_list(labelSelector: labelSelector, fieldSelector: fieldSelector, namespace: namespace)
202
+ process_list(list)
203
+ end
204
+
205
+ # @param labelSelector [nil, String, Hash{String => String}]
206
+ # @param fieldSelector [nil, String, Hash{String => String}]
207
+ # @param namespace [nil, String]
208
+ # @return [K8s::API::MetaV1::List]
209
+ def meta_list(labelSelector: nil, fieldSelector: nil, namespace: @namespace)
210
+ @transport.request(
211
+ method: 'GET',
212
+ path: path(namespace: namespace),
213
+ response_class: K8s::API::MetaV1::List,
214
+ query: make_query(
215
+ 'labelSelector' => selector_query(labelSelector),
216
+ 'fieldSelector' => selector_query(fieldSelector)
217
+ )
218
+ )
219
+ end
220
+
221
+ # @param labelSelector [nil, String, Hash{String => String}]
222
+ # @param fieldSelector [nil, String, Hash{String => String}]
223
+ # @param resourceVersion [nil, String]
224
+ # @param timeout [nil, Integer]
225
+ # @yield [K8S::API::MetaV1::WatchEvent]
226
+ # @raise [Excon::Error]
227
+ def watch(labelSelector: nil, fieldSelector: nil, resourceVersion: nil, timeout: nil, namespace: @namespace)
228
+ method = 'GET'
229
+ path = path(namespace: namespace)
230
+ parser = Yajl::Parser.new
231
+ parser.on_parse_complete = lambda do |data|
232
+ event = K8s::API::MetaV1::WatchEvent.new(data)
233
+ yield event
234
+ end
235
+ @transport.request(
236
+ method: method,
237
+ path: path,
238
+ read_timeout: nil,
239
+ query: make_query(
240
+ 'labelSelector' => selector_query(labelSelector),
241
+ 'fieldSelector' => selector_query(fieldSelector),
242
+ 'resourceVersion' => resourceVersion,
243
+ 'watch' => '1',
244
+ 'timeoutSeconds' => timeout
245
+ ),
246
+ response_block: lambda do |chunk, _, _|
247
+ parser << chunk
248
+ end
249
+ )
250
+ end
251
+
252
+ # @return [Boolean]
253
+ def update?
254
+ @api_resource.verbs.include? 'update'
255
+ end
256
+
257
+ # @param resource [#metadata] with metadata.resourceVersion set
258
+ # @return [Object] instance of resource_class
259
+ def update_resource(resource)
260
+ @transport.request(
261
+ method: 'PUT',
262
+ path: path(resource.metadata.name, namespace: resource.metadata.namespace),
263
+ request_object: resource,
264
+ response_class: @resource_class
265
+ )
266
+ end
267
+
268
+ # @return [Boolean]
269
+ def patch?
270
+ @api_resource.verbs.include? 'patch'
271
+ end
272
+
273
+ # @param name [String]
274
+ # @param obj [#to_json]
275
+ # @param namespace [String, nil]
276
+ # @param strategic_merge [Boolean] use kube Strategic Merge Patch instead of standard Merge Patch (arrays of objects are merged by name)
277
+ # @return [Object] instance of resource_class
278
+ def merge_patch(name, obj, namespace: @namespace, strategic_merge: true)
279
+ @transport.request(
280
+ method: 'PATCH',
281
+ path: path(name, namespace: namespace),
282
+ content_type: strategic_merge ? 'application/strategic-merge-patch+json' : 'application/merge-patch+json',
283
+ request_object: obj,
284
+ response_class: @resource_class
285
+ )
286
+ end
287
+
288
+ # @param name [String]
289
+ # @param ops [Hash] json-patch operations
290
+ # @param namespace [String, nil]
291
+ # @return [Object] instance of resource_class
292
+ def json_patch(name, ops, namespace: @namespace)
293
+ @transport.request(
294
+ method: 'PATCH',
295
+ path: path(name, namespace: namespace),
296
+ content_type: 'application/json-patch+json',
297
+ request_object: ops,
298
+ response_class: @resource_class
299
+ )
300
+ end
301
+
302
+ # @return [Boolean]
303
+ def delete?
304
+ @api_resource.verbs.include? 'delete'
305
+ end
306
+
307
+ # @param name [String]
308
+ # @param namespace [String, nil]
309
+ # @param propagationPolicy [String, nil] The propagationPolicy to use for the API call. Possible values include “Orphan”, “Foreground”, or “Background”
310
+ # @return [K8s::API::MetaV1::Status]
311
+ def delete(name, namespace: @namespace, propagationPolicy: nil)
312
+ @transport.request(
313
+ method: 'DELETE',
314
+ path: path(name, namespace: namespace),
315
+ query: make_query(
316
+ 'propagationPolicy' => propagationPolicy
317
+ ),
318
+ response_class: @resource_class # XXX: documented as returning Status
319
+ )
320
+ end
321
+
322
+ # @param namespace [String, nil]
323
+ # @param labelSelector [nil, String, Hash{String => String}]
324
+ # @param fieldSelector [nil, String, Hash{String => String}]
325
+ # @param propagationPolicy [String, nil]
326
+ # @return [Array<Object>] array of instances of resource_class
327
+ def delete_collection(namespace: @namespace, labelSelector: nil, fieldSelector: nil, propagationPolicy: nil)
328
+ list = @transport.request(
329
+ method: 'DELETE',
330
+ path: path(namespace: namespace),
331
+ query: make_query(
332
+ 'labelSelector' => selector_query(labelSelector),
333
+ 'fieldSelector' => selector_query(fieldSelector),
334
+ 'propagationPolicy' => propagationPolicy
335
+ ),
336
+ response_class: K8s::API::MetaV1::List # XXX: documented as returning Status
337
+ )
338
+ process_list(list)
339
+ end
340
+
341
+ # @param resource [resource_class] with metadata
342
+ # @param options [Hash]
343
+ # @see #delete for possible options
344
+ # @return [K8s::API::MetaV1::Status]
345
+ def delete_resource(resource, **options)
346
+ delete(resource.metadata.name, namespace: resource.metadata.namespace, **options)
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K8s
4
+ class Ruby
5
+ # Updated on releases using semver.
6
+ VERSION = "0.10.5"
7
+ end
8
+ end
@@ -0,0 +1,186 @@
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_hash, @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[@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 ? resource.metadata.labels[@label] : nil
157
+ resource_checksum = resource.metadata.annotations ? resource.metadata.annotations[@checksum_annotation] : nil
158
+
159
+ logger.debug { "List resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource_checksum}" }
160
+
161
+ if resource_label != name
162
+ # apiserver did not respect labelSelector
163
+ elsif resource.metadata&.ownerReferences && !resource.metadata.ownerReferences.empty?
164
+ logger.info "Server resource #{resource.apiVersion}:#{resource.apiKind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} has ownerReferences and will be kept"
165
+ elsif keep_resources && keep_resource?(resource)
166
+ # resource is up-to-date
167
+ else
168
+ logger.info "Delete resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace}"
169
+ begin
170
+ client.delete_resource(resource, propagationPolicy: 'Background')
171
+ rescue K8s::Error::NotFound => e
172
+ # assume aliased objects in multiple API groups, like for Deployments
173
+ # alternatively, a custom resource whose definition was already deleted earlier
174
+ logger.debug { "Ignoring #{e} : #{e.message}" }
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ # Delete all stack resources
181
+ # @param client [K8s::Client]
182
+ def delete(client)
183
+ prune(client, keep_resources: false)
184
+ end
185
+ end
186
+ end