k8s-client 0.4.2 → 0.5.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.
data/lib/k8s/logging.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'logger'
2
4
 
3
5
  module K8s
@@ -51,8 +53,8 @@ module K8s
51
53
  # @return [Logger]
52
54
  def logger(target: LOG_TARGET, level: nil)
53
55
  @logger ||= Logger.new(target).tap do |logger|
54
- logger.progname = self.name
55
- logger.level = level || self.log_level || K8s::Logging.log_level || LOG_LEVEL
56
+ logger.progname = name
57
+ logger.level = level || log_level || K8s::Logging.log_level || LOG_LEVEL
56
58
  end
57
59
  end
58
60
  end
data/lib/k8s/resource.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'deep_merge'
2
4
  require 'recursive-open-struct'
3
5
  require 'hashdiff'
@@ -11,7 +13,7 @@ module K8s
11
13
  # @param data [Hash]
12
14
  # @return [self]
13
15
  def self.from_json(data)
14
- return new(data)
16
+ new(data)
15
17
  end
16
18
 
17
19
  # @param filename [String] file path
@@ -27,9 +29,9 @@ module K8s
27
29
 
28
30
  if stat.directory?
29
31
  # recurse
30
- Dir.glob("#{path}/*.{yml,yaml}").sort.map { |dir| self.from_files(dir) }.flatten
32
+ Dir.glob("#{path}/*.{yml,yaml}").sort.map { |dir| from_files(dir) }.flatten
31
33
  else
32
- ::YAML.load_stream(File.read(path), path).map{|doc| new(doc) }
34
+ ::YAML.load_stream(File.read(path), path).map{ |doc| new(doc) }
33
35
  end
34
36
  end
35
37
 
@@ -67,7 +69,7 @@ module K8s
67
69
  end
68
70
 
69
71
  def checksum
70
- @checksum ||= Digest::MD5.hexdigest(Marshal::dump(to_hash))
72
+ @checksum ||= Digest::MD5.hexdigest(Marshal.dump(to_hash))
71
73
  end
72
74
 
73
75
  def merge_patch_ops(attrs, config_annotation)
@@ -78,7 +80,7 @@ module K8s
78
80
  #
79
81
  # @return [Hash]
80
82
  def current_config(config_annotation)
81
- current_cfg = self.metadata.annotations&.dig(config_annotation)
83
+ current_cfg = metadata.annotations&.dig(config_annotation)
82
84
  return {} unless current_cfg
83
85
 
84
86
  current_hash = JSON.parse(current_cfg)
@@ -89,11 +91,11 @@ module K8s
89
91
  end
90
92
 
91
93
  def can_patch?(config_annotation)
92
- !!self.metadata.annotations&.dig(config_annotation)
94
+ !!metadata.annotations&.dig(config_annotation)
93
95
  end
94
96
 
95
97
  def stringify_hash(hash)
96
- JSON.load(JSON.dump(hash))
98
+ JSON.parse(JSON.dump(hash))
97
99
  end
98
100
  end
99
101
  end
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module K8s
2
4
  # Per-APIResource type client.
3
5
  #
4
6
  # Used to get/list/update/patch/delete specific types of resources, optionally in some specific namespace.
5
7
  class ResourceClient
6
-
7
8
  # Common helpers used in both class/instance methods
8
9
  module Utils
9
10
  # @param selector [nil, String, Hash{String => String}]
@@ -15,7 +16,7 @@ module K8s
15
16
  when String
16
17
  selector
17
18
  when Hash
18
- selector.map{|k, v| "#{k}=#{v}"}.join ','
19
+ selector.map{ |k, v| "#{k}=#{v}" }.join ','
19
20
  else
20
21
  fail "Invalid selector type. #{selector.inspect}"
21
22
  end
@@ -47,17 +48,18 @@ module K8s
47
48
  # @param skip_forbidden [Boolean] skip resources that return HTTP 403 errors
48
49
  # @return [Array<K8s::Resource>]
49
50
  def self.list(resources, transport, namespace: nil, labelSelector: nil, fieldSelector: nil, skip_forbidden: false)
50
- api_paths = resources.map{|resource| resource.path(namespace: namespace) }
51
- api_lists = transport.gets(*api_paths,
52
- response_class: K8s::API::MetaV1::List,
53
- query: make_query(
54
- 'labelSelector' => selector_query(labelSelector),
55
- 'fieldSelector' => selector_query(fieldSelector),
56
- ),
57
- skip_forbidden: skip_forbidden,
58
- )
59
-
60
- resources.zip(api_lists).map {|resource, api_list| api_list ? resource.process_list(api_list) : [] }.flatten
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
61
63
  end
62
64
 
63
65
  # @param transport [K8s::Transport]
@@ -78,7 +80,7 @@ module K8s
78
80
  @subresource = nil
79
81
  end
80
82
 
81
- fail "Resource #{api_resource.name} is not namespaced" if namespace unless api_resource.namespaced
83
+ fail "Resource #{api_resource.name} is not namespaced" unless api_resource.namespaced || !namespace
82
84
  end
83
85
 
84
86
  # @return [String]
@@ -92,14 +94,10 @@ module K8s
92
94
  end
93
95
 
94
96
  # @return [String, nil]
95
- def namespace
96
- @namespace
97
- end
97
+ attr_reader :namespace
98
98
 
99
99
  # @return [String]
100
- def resource
101
- @resource
102
- end
100
+ attr_reader :resource
103
101
 
104
102
  # @return [Boolean]
105
103
  def subresource?
@@ -107,9 +105,7 @@ module K8s
107
105
  end
108
106
 
109
107
  # @return [String, nil]
110
- def subresource
111
- @subresource
112
- end
108
+ attr_reader :subresource
113
109
 
114
110
  # @return [String]
115
111
  def kind
@@ -117,9 +113,7 @@ module K8s
117
113
  end
118
114
 
119
115
  # @return [class] K8s::Resource
120
- def resource_class
121
- @resource_class
122
- end
116
+ attr_reader :resource_class
123
117
 
124
118
  # @return [Bool]
125
119
  def namespaced?
@@ -149,9 +143,9 @@ module K8s
149
143
  def create_resource(resource)
150
144
  @transport.request(
151
145
  method: 'POST',
152
- path: self.path(namespace: resource.metadata.namespace),
146
+ path: path(namespace: resource.metadata.namespace),
153
147
  request_object: resource,
154
- response_class: @resource_class,
148
+ response_class: @resource_class
155
149
  )
156
150
  end
157
151
 
@@ -164,8 +158,8 @@ module K8s
164
158
  def get(name, namespace: @namespace)
165
159
  @transport.request(
166
160
  method: 'GET',
167
- path: self.path(name, namespace: namespace),
168
- response_class: @resource_class,
161
+ path: path(name, namespace: namespace),
162
+ response_class: @resource_class
169
163
  )
170
164
  end
171
165
 
@@ -174,8 +168,8 @@ module K8s
174
168
  def get_resource(resource)
175
169
  @transport.request(
176
170
  method: 'GET',
177
- path: self.path(resource.metadata.name, namespace: resource.metadata.namespace),
178
- response_class: @resource_class,
171
+ path: path(resource.metadata.name, namespace: resource.metadata.namespace),
172
+ response_class: @resource_class
179
173
  )
180
174
  end
181
175
 
@@ -187,7 +181,7 @@ module K8s
187
181
  # @param list [K8s::API::MetaV1::List]
188
182
  # @return [Array<resource_class>]
189
183
  def process_list(list)
190
- list.items.map {|item|
184
+ list.items.map { |item|
191
185
  # list items omit kind/apiVersion
192
186
  @resource_class.new(item.merge('apiVersion' => list.apiVersion, 'kind' => @api_resource.kind))
193
187
  }
@@ -199,12 +193,12 @@ module K8s
199
193
  def list(labelSelector: nil, fieldSelector: nil, namespace: @namespace)
200
194
  list = @transport.request(
201
195
  method: 'GET',
202
- path: self.path(namespace: namespace),
196
+ path: path(namespace: namespace),
203
197
  response_class: K8s::API::MetaV1::List,
204
198
  query: make_query(
205
199
  'labelSelector' => selector_query(labelSelector),
206
- 'fieldSelector' => selector_query(fieldSelector),
207
- ),
200
+ 'fieldSelector' => selector_query(fieldSelector)
201
+ )
208
202
  )
209
203
  process_list(list)
210
204
  end
@@ -219,9 +213,9 @@ module K8s
219
213
  def update_resource(resource)
220
214
  @transport.request(
221
215
  method: 'PUT',
222
- path: self.path(resource.metadata.name, namespace: resource.metadata.namespace),
216
+ path: path(resource.metadata.name, namespace: resource.metadata.namespace),
223
217
  request_object: resource,
224
- response_class: @resource_class,
218
+ response_class: @resource_class
225
219
  )
226
220
  end
227
221
 
@@ -238,10 +232,10 @@ module K8s
238
232
  def merge_patch(name, obj, namespace: @namespace, strategic_merge: true)
239
233
  @transport.request(
240
234
  method: 'PATCH',
241
- path: self.path(name, namespace: namespace),
235
+ path: path(name, namespace: namespace),
242
236
  content_type: strategic_merge ? 'application/strategic-merge-patch+json' : 'application/merge-patch+json',
243
237
  request_object: obj,
244
- response_class: @resource_class,
238
+ response_class: @resource_class
245
239
  )
246
240
  end
247
241
 
@@ -252,10 +246,10 @@ module K8s
252
246
  def json_patch(name, ops, namespace: @namespace)
253
247
  @transport.request(
254
248
  method: 'PATCH',
255
- path: self.path(name, namespace: namespace),
249
+ path: path(name, namespace: namespace),
256
250
  content_type: 'application/json-patch+json',
257
251
  request_object: ops,
258
- response_class: @resource_class,
252
+ response_class: @resource_class
259
253
  )
260
254
  end
261
255
 
@@ -271,7 +265,7 @@ module K8s
271
265
  def delete(name, namespace: @namespace, propagationPolicy: nil)
272
266
  @transport.request(
273
267
  method: 'DELETE',
274
- path: self.path(name, namespace: namespace),
268
+ path: path(name, namespace: namespace),
275
269
  query: make_query(
276
270
  'propagationPolicy' => propagationPolicy
277
271
  ),
@@ -286,11 +280,11 @@ module K8s
286
280
  def delete_collection(namespace: @namespace, labelSelector: nil, fieldSelector: nil, propagationPolicy: nil)
287
281
  list = @transport.request(
288
282
  method: 'DELETE',
289
- path: self.path(namespace: namespace),
283
+ path: path(namespace: namespace),
290
284
  query: make_query(
291
285
  'labelSelector' => selector_query(labelSelector),
292
286
  'fieldSelector' => selector_query(fieldSelector),
293
- 'propagationPolicy' => propagationPolicy,
287
+ 'propagationPolicy' => propagationPolicy
294
288
  ),
295
289
  response_class: K8s::API::MetaV1::List, # XXX: documented as returning Status
296
290
  )
data/lib/k8s/stack.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'securerandom'
2
4
 
3
5
  module K8s
@@ -19,7 +21,7 @@ module K8s
19
21
  PRUNE_IGNORE = [
20
22
  'v1:ComponentStatus', # apiserver ignores GET /v1/componentstatuses?labelSelector=... and returns all resources
21
23
  'v1:Endpoints', # inherits stack label from service, but not checksum annotation
22
- ]
24
+ ].freeze
23
25
 
24
26
  # @param name [String] unique name for stack
25
27
  # @param path [String] load resources from YAML files
@@ -59,23 +61,27 @@ module K8s
59
61
  end
60
62
 
61
63
  # @param resource [K8s::Resource] to apply
62
- # @param base_resource [K8s::Resource] preserve existing attributes from base resource
64
+ # @param base_resource [K8s::Resource] DEPRECATED
63
65
  # @return [K8s::Resource]
66
+ # rubocop:disable Lint/UnusedMethodArgument
64
67
  def prepare_resource(resource, base_resource: nil)
65
- # XXX: base_resource is not really used anymore, kept for backwards compatibility for a while
68
+ # TODO: base_resource is not used anymore, kept for backwards compatibility for a while
66
69
 
67
70
  # calculate checksum only from the "local" source
68
71
  checksum = resource.checksum
69
72
 
70
73
  # add stack metadata
71
- resource.merge(metadata: {
72
- labels: { @label => name },
73
- annotations: {
74
- @checksum_annotation => checksum,
75
- @last_config_annotation => resource.to_json
76
- },
77
- })
74
+ resource.merge(
75
+ metadata: {
76
+ labels: { @label => name },
77
+ annotations: {
78
+ @checksum_annotation => checksum,
79
+ @last_config_annotation => resource.to_json
80
+ }
81
+ }
82
+ )
78
83
  end
84
+ # rubocop:enable Lint/UnusedMethodArgument
79
85
 
80
86
  # @return [Array<K8s::Resource>]
81
87
  def apply(client, prune: true)
@@ -107,14 +113,15 @@ module K8s
107
113
  def keep_resource!(resource)
108
114
  @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] = resource.metadata.annotations[@checksum_annotation]
109
115
  end
116
+
110
117
  def keep_resource?(resource)
111
118
  @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] == resource.metadata.annotations[@checksum_annotation]
112
119
  end
113
120
 
114
121
  # Delete all stack resources that were not applied
115
- def prune(client, keep_resources: , skip_forbidden: true)
122
+ def prune(client, keep_resources:, skip_forbidden: true)
116
123
  # 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
117
- client.list_resources(labelSelector: {@label => name}, skip_forbidden: skip_forbidden).sort{ |a,b|
124
+ client.list_resources(labelSelector: { @label => name }, skip_forbidden: skip_forbidden).sort do |a, b|
118
125
  # Sort resources so that namespaced objects are deleted first
119
126
  if a.metadata.namespace == b.metadata.namespace
120
127
  0
@@ -123,7 +130,7 @@ module K8s
123
130
  else
124
131
  -1
125
132
  end
126
- }.each do |resource|
133
+ end.each do |resource|
127
134
  next if PRUNE_IGNORE.include? "#{resource.apiVersion}:#{resource.kind}"
128
135
 
129
136
  resource_label = resource.metadata.labels ? resource.metadata.labels[@label] : nil
@@ -139,9 +146,10 @@ module K8s
139
146
  logger.info "Delete resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace}"
140
147
  begin
141
148
  client.delete_resource(resource, propagationPolicy: 'Background')
142
- rescue K8s::Error::NotFound
149
+ rescue K8s::Error::NotFound => ex
143
150
  # assume aliased objects in multiple API groups, like for Deployments
144
151
  # alternatively, a custom resource whose definition was already deleted earlier
152
+ logger.debug { "Ignoring #{ex} : #{ex.message}" }
145
153
  end
146
154
  end
147
155
  end
data/lib/k8s/transport.rb CHANGED
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'excon'
2
4
  require 'json'
5
+ require 'jsonpath'
3
6
 
4
7
  module K8s
5
8
  # Excon-based HTTP transport handling request/response body JSON encoding
@@ -11,13 +14,13 @@ module K8s
11
14
  # Excon middlewares for requests
12
15
  EXCON_MIDDLEWARES = [
13
16
  # XXX: necessary? redirected requests omit authz headers?
14
- Excon::Middleware::RedirectFollower,
17
+ Excon::Middleware::RedirectFollower
15
18
  ] + Excon.defaults[:middlewares]
16
19
 
17
20
  # Default request headers
18
21
  REQUEST_HEADERS = {
19
- 'Accept' => 'application/json',
20
- }
22
+ 'Accept' => 'application/json'
23
+ }.freeze
21
24
 
22
25
  # Construct transport from kubeconfig
23
26
  #
@@ -67,6 +70,16 @@ module K8s
67
70
  logger.debug "Using config with .user.token=..."
68
71
 
69
72
  options[:auth_token] = token
73
+ elsif config.user.auth_provider && auth_provider = config.user.auth_provider.config
74
+ logger.debug "Using config with .user.auth-provider.name=#{config.user.auth_provider.name}"
75
+
76
+ auth_data = `#{auth_provider['cmd-path']} #{auth_provider['cmd-args']}`.strip
77
+ if auth_provider['token-key']
78
+ json_path = JsonPath.new(auth_provider['token-key'][1...-1])
79
+ options[:auth_token] = json_path.first(auth_data)
80
+ else
81
+ options[:auth_token] = auth_data
82
+ end
70
83
  end
71
84
 
72
85
  logger.info "Using config with server=#{server}"
@@ -81,10 +94,11 @@ module K8s
81
94
  host = ENV['KUBERNETES_SERVICE_HOST']
82
95
  port = ENV['KUBERNETES_SERVICE_PORT_HTTPS']
83
96
 
84
- new("https://#{host}:#{port}",
97
+ new(
98
+ "https://#{host}:#{port}",
85
99
  ssl_verify_peer: true,
86
100
  ssl_ca_file: '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt',
87
- auth_token: File.read('/var/run/secrets/kubernetes.io/serviceaccount/token'),
101
+ auth_token: File.read('/var/run/secrets/kubernetes.io/serviceaccount/token')
88
102
  )
89
103
  end
90
104
 
@@ -103,7 +117,8 @@ module K8s
103
117
 
104
118
  # @return [Excon::Connection]
105
119
  def excon
106
- @excon ||= Excon.new(@server,
120
+ @excon ||= Excon.new(
121
+ @server,
107
122
  persistent: true,
108
123
  middlewares: EXCON_MIDDLEWARES,
109
124
  headers: REQUEST_HEADERS,
@@ -145,6 +160,7 @@ module K8s
145
160
  if options[:query]
146
161
  path += Excon::Utils.query_string(options)
147
162
  end
163
+
148
164
  if obj = options[:request_object]
149
165
  body = "<#{obj.class.name}>"
150
166
  end
@@ -178,11 +194,9 @@ module K8s
178
194
  raise K8s::Error::API.new(method, path, response.status, "Invalid JSON response: #{response_data.inspect}")
179
195
  end
180
196
 
181
- if response_class
182
- return response_class.from_json(response_data)
183
- else
184
- return response_data # Hash
185
- end
197
+ return response_data unless response_class
198
+
199
+ response_class.from_json(response_data)
186
200
  else
187
201
  error_class = K8s::Error::HTTP_STATUS_ERRORS[response.status] || K8s::Error::API
188
202
 
@@ -208,18 +222,17 @@ module K8s
208
222
  t = Time.now - start
209
223
 
210
224
  obj = parse_response(response, options,
211
- response_class: response_class,
212
- )
225
+ response_class: response_class)
213
226
  rescue K8s::Error::API => exc
214
- logger.warn { "#{format_request(options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s"}
215
- logger.debug { "Request: #{excon_options[:body]}"} if excon_options[:body]
216
- logger.debug { "Response: #{response.body}"}
227
+ logger.warn { "#{format_request(options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s" }
228
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
229
+ logger.debug { "Response: #{response.body}" }
217
230
  raise
218
231
  else
219
- logger.info { "#{format_request(options)} => HTTP #{response.status}: <#{obj.class}> in #{'%.3f' % t}s"}
220
- logger.debug { "Request: #{excon_options[:body]}"} if excon_options[:body]
221
- logger.debug { "Response: #{response.body}"}
222
- return obj
232
+ logger.info { "#{format_request(options)} => HTTP #{response.status}: <#{obj.class}> in #{'%.3f' % t}s" }
233
+ logger.debug { "Request: #{excon_options[:body]}" } if excon_options[:body]
234
+ logger.debug { "Response: #{response.body}" }
235
+ obj
223
236
  end
224
237
 
225
238
  # @param options [Array<Hash>] @see #request
@@ -233,46 +246,39 @@ module K8s
233
246
 
234
247
  start = Time.now
235
248
  responses = excon.requests(
236
- options.map{|options| request_options(**common_options.merge(options))}
249
+ options.map{ |opts| request_options(**common_options.merge(opts)) }
237
250
  )
238
251
  t = Time.now - start
239
252
 
240
- objects = responses.zip(options).map{|response, request_options|
253
+ objects = responses.zip(options).map{ |response, request_options|
241
254
  response_class = request_options[:response_class] || common_options[:response_class]
242
255
 
243
256
  begin
244
257
  parse_response(response, request_options,
245
- response_class: response_class,
246
- )
258
+ response_class: response_class)
247
259
  rescue K8s::Error::NotFound
248
- if skip_missing
249
- nil
250
- else
251
- raise
252
- end
260
+ raise unless skip_missing
261
+
262
+ nil
253
263
  rescue K8s::Error::Forbidden
254
- if skip_forbidden
255
- nil
256
- else
257
- raise
258
- end
264
+ raise unless skip_forbidden
265
+
266
+ nil
259
267
  rescue K8s::Error::ServiceUnavailable => exc
260
- if retry_errors
261
- logger.warn { "Retry #{format_request(request_options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s" }
262
-
263
- # only retry the failed request, not the entire pipeline
264
- request(response_class: response_class, **common_options.merge(request_options))
265
- else
266
- raise
267
- end
268
+ raise unless retry_errors
269
+
270
+ logger.warn { "Retry #{format_request(request_options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s" }
271
+
272
+ # only retry the failed request, not the entire pipeline
273
+ request(response_class: response_class, **common_options.merge(request_options))
268
274
  end
269
275
  }
270
276
  rescue K8s::Error => exc
271
- logger.warn { "[#{options.map{|o| format_request(o)}.join ', '}] => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s"}
277
+ logger.warn { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s" }
272
278
  raise
273
279
  else
274
- logger.info { "[#{options.map{|o| format_request(o)}.join ', '}] => HTTP [#{responses.map{|r| r.status}.join ', '}] in #{'%.3f' % t}s" }
275
- return objects
280
+ logger.info { "[#{options.map{ |o| format_request(o) }.join ', '}] => HTTP [#{responses.map(&:status).join ', '}] in #{'%.3f' % t}s" }
281
+ objects
276
282
  end
277
283
 
278
284
  # @param path [Array<String>] @see #path
@@ -281,17 +287,20 @@ module K8s
281
287
  request(
282
288
  method: 'GET',
283
289
  path: self.path(*path),
284
- **options,
290
+ **options
285
291
  )
286
292
  end
287
293
 
288
294
  # @param paths [Array<String>]
289
295
  # @param options [Hash] @see #request
290
296
  def gets(*paths, **options)
291
- requests(*paths.map{|path| {
292
- method: 'GET',
293
- path: self.path(path),
294
- } },
297
+ requests(
298
+ *paths.map do |path|
299
+ {
300
+ method: 'GET',
301
+ path: self.path(path)
302
+ }
303
+ end,
295
304
  **options
296
305
  )
297
306
  end