k8s-client 0.1.0

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