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.
@@ -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