k8s-client 0.1.0

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,137 @@
1
+ require 'securerandom'
2
+
3
+ module K8s
4
+ # Usage: customize the LABEL and CHECKSUM_ANNOTATION
5
+ class Stack
6
+ include Logging
7
+
8
+ LABEL = 'k8s.kontena.io/stack'
9
+ CHECKSUM_ANNOTATION = 'k8s.kontena.io/stack-checksum'
10
+ PRUNE_IGNORE = [
11
+ 'v1:ComponentStatus', # apiserver ignores GET /v1/componentstatuses?labelSelector=... and returns all resources
12
+ 'v1:Endpoints', # inherits stack label from service, but not checksum annotation
13
+ ]
14
+
15
+ # @param name [String] unique name for stack
16
+ # @param path [String] load resources from YAML files
17
+ # @return [K8s::Stack]
18
+ def self.load(name, path, **options)
19
+ resources = K8s::Resource.from_files(path)
20
+ new(name, resources, **options)
21
+ end
22
+
23
+ # @param name [String] unique name for stack
24
+ # @param path [String] load resources from YAML files
25
+ # @param client [K8s::Client] apply using client
26
+ # @param prune [Boolean] delete old resources
27
+ def self.apply(name, path, client, prune: true, **options)
28
+ load(name, path, **options).apply(client, prune: prune)
29
+ end
30
+
31
+ # Remove any installed stack resources.
32
+ #
33
+ # @param name [String] unique name for stack
34
+ # @param client [K8s::Client] apply using client
35
+ def self.delete(name, client, **options)
36
+ new(name, **options).delete(client)
37
+ end
38
+
39
+ attr_reader :name, :resources
40
+
41
+ def initialize(name, resources = [], debug: false, label: LABEL, checksum_annotation: CHECKSUM_ANNOTATION)
42
+ @name = name
43
+ @resources = resources
44
+ @keep_resources = {}
45
+ @label = label
46
+ @checksum_annotation = checksum_annotation
47
+
48
+ logger! progname: name, debug: debug
49
+ end
50
+
51
+ def checksum
52
+ @checksum ||= SecureRandom.hex(16)
53
+ end
54
+
55
+ # @param resource [K8s::Resource] to apply
56
+ # @param base_resource [K8s::Resource] preserve existing attributes from base resource
57
+ # @return [K8s::Resource]
58
+ def prepare_resource(resource, base_resource: nil)
59
+ if base_resource
60
+ resource = base_resource.merge(resource)
61
+ end
62
+
63
+ # add stack metadata
64
+ resource.merge(metadata: {
65
+ labels: { @label => name },
66
+ annotations: { @checksum_annotation => checksum },
67
+ })
68
+ end
69
+
70
+ # @return [Array<K8s::Resource>]
71
+ def apply(client, prune: true)
72
+ server_resources = client.get_resources(resources)
73
+
74
+ resources.zip(server_resources).map do |resource, server_resource|
75
+ if server_resource
76
+ # keep server checksum for comparison
77
+ # NOTE: this will not compare equal for resources with arrays containing hashes with default values applied by the server
78
+ # however, that will just cause extra PUTs, so it doesn't have any functional effects
79
+ compare_resource = server_resource.merge(resource).merge(metadata: {
80
+ labels: { @label => name },
81
+ })
82
+ end
83
+
84
+ if !server_resource
85
+ logger.info "Create resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{checksum}"
86
+ keep_resource! client.create_resource(prepare_resource(resource))
87
+ elsif server_resource != compare_resource
88
+ logger.info "Update resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{checksum}"
89
+ keep_resource! client.update_resource(prepare_resource(resource, base_resource: server_resource))
90
+ else
91
+ logger.info "Keep resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{compare_resource.metadata.annotations[@checksum_annotation]}"
92
+ keep_resource! compare_resource
93
+ end
94
+ end
95
+
96
+ prune(client, keep_resources: true) if prune
97
+ end
98
+
99
+ # key MUST NOT include resource.apiVersion: the same kind can be aliased in different APIs
100
+ def keep_resource!(resource)
101
+ @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] = resource.metadata.annotations[@checksum_annotation]
102
+ end
103
+ def keep_resource?(resource)
104
+ @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] == resource.metadata.annotations[@checksum_annotation]
105
+ end
106
+
107
+ # Delete all stack resources that were not applied
108
+ def prune(client, keep_resources: )
109
+ client.list_resources(labelSelector: {@label => name}).each do |resource|
110
+ next if PRUNE_IGNORE.include? "#{resource.apiVersion}:#{resource.kind}"
111
+
112
+ resource_label = resource.metadata.labels ? resource.metadata.labels[@label] : nil
113
+ resource_checksum = resource.metadata.annotations ? resource.metadata.annotations[@checksum_annotation] : nil
114
+
115
+ logger.debug { "List resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource_checksum}" }
116
+
117
+ if resource_label != name
118
+ # apiserver did not respect labelSelector
119
+ elsif keep_resources && keep_resource?(resource)
120
+ # resource is up-to-date
121
+ else
122
+ logger.info "Delete resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace}"
123
+ begin
124
+ client.delete_resource(resource)
125
+ rescue K8s::Error::NotFound
126
+ # assume aliased objects in multiple API groups, like for Deployments
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ # Delete all stack resources
133
+ def delete(client)
134
+ prune(client, keep_resources: false)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,251 @@
1
+ require 'excon'
2
+ require 'json'
3
+
4
+ module K8s
5
+ class Transport
6
+ include Logging
7
+
8
+ quiet! # do not log warnings by default
9
+
10
+ EXCON_MIDDLEWARES = [
11
+ # XXX: necessary? redirected requests omit authz headers?
12
+ Excon::Middleware::RedirectFollower,
13
+ ] + Excon.defaults[:middlewares]
14
+
15
+ REQUEST_HEADERS = {
16
+ 'Accept' => 'application/json',
17
+ }
18
+
19
+ # Construct transport from kubeconfig
20
+ #
21
+ # @param config [Phraos::Kube::Config]
22
+ # @return [K8s::Transport]
23
+ def self.config(config)
24
+ options = {}
25
+
26
+ if config.cluster.insecure_skip_tls_verify
27
+ logger.debug "Using config with .cluster.insecure_skip_tls_verify"
28
+
29
+ options[:ssl_verify_peer] = false
30
+ end
31
+
32
+ if path = config.cluster.certificate_authority
33
+ logger.debug "Using config with .cluster.certificate_authority"
34
+
35
+ options[:ssl_ca_file] = path
36
+ end
37
+
38
+ if data = config.cluster.certificate_authority_data
39
+ logger.debug "Using config with .cluster.certificate_authority_data"
40
+
41
+ ssl_cert_store = options[:ssl_cert_store] = OpenSSL::X509::Store.new
42
+ ssl_cert_store.add_cert(OpenSSL::X509::Certificate.new(Base64.decode64(data)))
43
+ end
44
+
45
+ if (cert = config.user.client_certificate) && (key = config.user.client_key)
46
+ logger.debug "Using config with .user.client_certificate/client_key"
47
+
48
+ options[:client_cert] = cert
49
+ options[:client_key] = key
50
+ end
51
+
52
+ if (cert_data = config.user.client_certificate_data) && (key_data = config.user.client_key_data)
53
+ logger.debug "Using config with .user.client_certificate_data/client_key_data"
54
+
55
+ options[:client_cert_data] = Base64.decode64(cert_data)
56
+ options[:client_key_data] = Base64.decode64(key_data)
57
+ end
58
+
59
+ logger.info "Using config with server=#{config.cluster.server}"
60
+
61
+ new(config.cluster.server, **options)
62
+ end
63
+
64
+ # In-cluster config within a kube pod, using the kubernetes service envs and serviceaccount secrets
65
+ #
66
+ # @return [K8s::Transport]
67
+ def self.in_cluster_config
68
+ host = ENV['KUBERNETES_SERVICE_HOST']
69
+ port = ENV['KUBERNETES_SERVICE_PORT_HTTPS']
70
+
71
+ new("https://#{host}:#{port}",
72
+ ssl_verify_peer: true,
73
+ ssl_ca_file: '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt',
74
+ auth_token: File.read('/var/run/secrets/kubernetes.io/serviceaccount/token'),
75
+ )
76
+ end
77
+
78
+ attr_reader :server, :options
79
+
80
+ # @param server [String] URL with protocol://host:port - any /path is ignored
81
+ def initialize(server, auth_token: nil, **options)
82
+ @server = server
83
+ @auth_token = auth_token
84
+ @options = options
85
+
86
+ logger! progname: @server
87
+ end
88
+
89
+ # @return [Excon::Connection]
90
+ def excon
91
+ @excon ||= Excon.new(@server,
92
+ persistent: true,
93
+ middlewares: EXCON_MIDDLEWARES,
94
+ headers: REQUEST_HEADERS,
95
+ **@options
96
+ )
97
+ end
98
+
99
+ # @return [String]
100
+ def path(*path)
101
+ File.join('/', *path)
102
+ end
103
+
104
+ # @return [Hash]
105
+ def request_options(request_object: nil, **options)
106
+ options[:headers] ||= {}
107
+
108
+ if @auth_token
109
+ options[:headers]['Authorization'] = "Bearer #{@auth_token}"
110
+ end
111
+
112
+ if request_object
113
+ options[:headers]['Content-Type'] = 'application/json'
114
+ options[:body] = request_object.to_json
115
+ end
116
+
117
+ options
118
+ end
119
+
120
+ def format_request(options)
121
+ method = options[:method]
122
+ path = options[:path]
123
+ body = nil
124
+
125
+ if options[:query]
126
+ path += Excon::Utils.query_string(options)
127
+ end
128
+ if obj = options[:request_object]
129
+ body = "<#{obj.class.name}>"
130
+ end
131
+
132
+ [method, path, body].compact.join " "
133
+ end
134
+
135
+ # @raise [K8s::Error]
136
+ # @raise [Excon::Error] TODO: wrap
137
+ # @return [response_class, Hash]
138
+ def parse_response(response, request_options, response_class: nil)
139
+ method = request_options[:method]
140
+ path = request_options[:path]
141
+ content_type, = response.headers['Content-Type'].split(';')
142
+
143
+ case content_type
144
+ when 'application/json'
145
+ response_data = JSON.parse(response.body)
146
+
147
+ when 'text/plain'
148
+ response_data = response.body # XXX: broken if status 2xx
149
+ else
150
+ raise K8s::Error::API.new(method, path, response.status, "Invalid response Content-Type: #{response.headers['Content-Type']}")
151
+ end
152
+
153
+ if response.status.between? 200, 299
154
+ unless response_data.is_a? Hash
155
+ raise K8s::Error::API.new(method, path, response.status, "Invalid JSON response: #{response_data.inspect}")
156
+ end
157
+
158
+ if response_class
159
+ return response_class.from_json(response_data)
160
+ else
161
+ return response_data # Hash
162
+ end
163
+ else
164
+ error_class = K8s::Error::HTTP_STATUS_ERRORS[response.status] || K8s::Error::API
165
+
166
+ if response_data.is_a?(Hash) && response_data['kind'] == 'Status'
167
+ status = K8s::API::MetaV1::Status.new(response_data)
168
+
169
+ raise error_class.new(method, path, response.status, response.reason_phrase, status)
170
+ else
171
+ raise error_class.new(method, path, response.status, response.reason_phrase)
172
+ end
173
+ end
174
+ end
175
+
176
+ def request(response_class: nil, **options)
177
+ excon_options = request_options(**options)
178
+
179
+ start = Time.now
180
+ response = excon.request(**excon_options)
181
+ t = Time.now - start
182
+
183
+ obj = parse_response(response, options,
184
+ response_class: response_class,
185
+ )
186
+ rescue K8s::Error::API => exc
187
+ logger.warn { "#{format_request(options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s"}
188
+ logger.debug { "Request: #{excon_options[:body]}"} if excon_options[:body]
189
+ logger.debug { "Response: #{response.body}"}
190
+ raise
191
+ else
192
+ logger.info { "#{format_request(options)} => HTTP #{response.status}: <#{obj.class}> in #{'%.3f' % t}s"}
193
+ logger.debug { "Request: #{excon_options[:body]}"} if excon_options[:body]
194
+ logger.debug { "Response: #{response.body}"}
195
+ return obj
196
+ end
197
+
198
+ # @param options [Array<Hash>]
199
+ # @param skip_missing [Boolean] return nil for 404
200
+ # @return [Array<response_class, Hash, nil>]
201
+ def requests(*options, response_class: nil, skip_missing: false)
202
+ return [] if options.empty? # excon chokes
203
+
204
+ start = Time.now
205
+ responses = excon.requests(
206
+ options.map{|options| request_options(**options)}
207
+ )
208
+ t = Time.now - start
209
+
210
+ objects = responses.zip(options).map{|response, request_options|
211
+ begin
212
+ parse_response(response, request_options,
213
+ response_class: request_options[:response_class] || response_class,
214
+ )
215
+ rescue K8s::Error::NotFound
216
+ if skip_missing
217
+ nil
218
+ else
219
+ raise
220
+ end
221
+ end
222
+ }
223
+ rescue K8s::Error => exc
224
+ logger.warn { "[#{options.map{|o| format_request(o)}.join ', '}] => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s"}
225
+ raise
226
+ else
227
+ logger.info { "[#{options.map{|o| format_request(o)}.join ', '}] => HTTP [#{responses.map{|r| r.status}.join ', '}] in #{'%.3f' % t}s" }
228
+ return objects
229
+ end
230
+
231
+ # @param *path [String]
232
+ def get(*path, **options)
233
+ request(
234
+ method: 'GET',
235
+ path: self.path(*path),
236
+ **options,
237
+ )
238
+ end
239
+
240
+ # @param *paths [String]
241
+ def gets(*paths, response_class: nil, **options)
242
+ requests(*paths.map{|path| {
243
+ method: 'GET',
244
+ path: self.path(path),
245
+ **options,
246
+ } },
247
+ response_class: response_class,
248
+ )
249
+ end
250
+ end
251
+ end
metadata ADDED
@@ -0,0 +1,186 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: k8s-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kontena, Inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-08-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: excon
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.62.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.62.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-struct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.5.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.5.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: deep_merge
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.2.1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.2.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: recursive-open-struct
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.1.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.1.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.16'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.16'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '10.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '10.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.7'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.7'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 3.4.2
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 3.4.2
125
+ description:
126
+ email:
127
+ - info@kontena.io
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".gitignore"
133
+ - ".rspec"
134
+ - ".travis.yml"
135
+ - Dockerfile
136
+ - Gemfile
137
+ - LICENSE
138
+ - README.md
139
+ - Rakefile
140
+ - bin/k8s-client
141
+ - docker-compose.yaml
142
+ - k8s-client.gemspec
143
+ - lib/k8s-client.rb
144
+ - lib/k8s/api.rb
145
+ - lib/k8s/api/metav1.rb
146
+ - lib/k8s/api/metav1/api_group.rb
147
+ - lib/k8s/api/metav1/api_resource.rb
148
+ - lib/k8s/api/metav1/list.rb
149
+ - lib/k8s/api/metav1/object.rb
150
+ - lib/k8s/api/metav1/status.rb
151
+ - lib/k8s/api/version.rb
152
+ - lib/k8s/api_client.rb
153
+ - lib/k8s/client.rb
154
+ - lib/k8s/client/version.rb
155
+ - lib/k8s/config.rb
156
+ - lib/k8s/error.rb
157
+ - lib/k8s/logging.rb
158
+ - lib/k8s/resource.rb
159
+ - lib/k8s/resource_client.rb
160
+ - lib/k8s/stack.rb
161
+ - lib/k8s/transport.rb
162
+ homepage: https://github.com/kontena/k8s-client
163
+ licenses:
164
+ - Apache-2.0
165
+ metadata: {}
166
+ post_install_message:
167
+ rdoc_options: []
168
+ require_paths:
169
+ - lib
170
+ required_ruby_version: !ruby/object:Gem::Requirement
171
+ requirements:
172
+ - - "~>"
173
+ - !ruby/object:Gem::Version
174
+ version: '2.4'
175
+ required_rubygems_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ requirements: []
181
+ rubyforge_project:
182
+ rubygems_version: 2.6.14
183
+ signing_key:
184
+ specification_version: 4
185
+ summary: Kubernetes client library
186
+ test_files: []