hetzner-k3s 0.5.7 → 0.6.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,486 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hetzner
4
+ class Configuration
5
+ GITHUB_DELIM_LINKS = ','
6
+ GITHUB_LINK_REGEX = /<([^>]+)>; rel="([^"]+)"/
7
+
8
+ attr_reader :hetzner_client
9
+
10
+ def initialize(options:)
11
+ @options = options
12
+ @errors = []
13
+
14
+ validate_configuration_file
15
+ end
16
+
17
+ def validate(action:)
18
+ validate_token
19
+
20
+ if valid_token?
21
+ validate_cluster_name
22
+ validate_kubeconfig_path
23
+
24
+ case action
25
+ when :create
26
+ validate_create
27
+ when :delete
28
+ validate_kubeconfig_path_must_exist
29
+ when :upgrade
30
+ validate_upgrade
31
+ end
32
+ end
33
+
34
+ errors.flatten!
35
+
36
+ return if errors.empty?
37
+
38
+ puts 'Some information in the configuration file requires your attention:'
39
+
40
+ errors.each do |error|
41
+ puts " - #{error}"
42
+ end
43
+
44
+ exit 1
45
+ end
46
+
47
+ def self.available_releases
48
+ @available_releases ||= begin
49
+ releases = []
50
+
51
+ response, page_releases = fetch_releases('https://api.github.com/repos/k3s-io/k3s/tags?per_page=100')
52
+ releases = page_releases
53
+ link_header = response.headers['link']
54
+
55
+ until link_header.nil?
56
+ next_page_url = extract_next_github_page_url(link_header)
57
+
58
+ break if next_page_url.nil?
59
+
60
+ response, page_releases = fetch_releases(next_page_url)
61
+
62
+ releases += page_releases
63
+
64
+ link_header = response.headers['link']
65
+ end
66
+
67
+ releases.sort
68
+ end
69
+ rescue StandardError
70
+ if defined? errors
71
+ errors << 'Cannot fetch the releases with Github API, please try again later. This may be due to API rate limits.'
72
+ else
73
+ puts 'Cannot fetch the releases with Github API, please try again later. This may be due to API rate limits.'
74
+ end
75
+ end
76
+
77
+ def hetzner_token
78
+ return @token unless @token.nil?
79
+
80
+ @token = ENV.fetch('HCLOUD_TOKEN', configuration['hetzner_token'])
81
+ end
82
+
83
+ def [](key)
84
+ configuration[key]
85
+ end
86
+
87
+ def fetch(key, default)
88
+ configuration.fetch(key, default)
89
+ end
90
+
91
+ def raw
92
+ configuration
93
+ end
94
+
95
+ private_class_method
96
+
97
+ def self.fetch_releases(url)
98
+ response = HTTParty.get(url)
99
+ [response, JSON.parse(response.body).map { |hash| hash['name'] }]
100
+ end
101
+
102
+ def self.extract_next_github_page_url(link_header)
103
+ link_header.split(GITHUB_DELIM_LINKS).each do |link|
104
+ GITHUB_LINK_REGEX.match(link.strip) do |match|
105
+ url_part = match[1]
106
+ meta_part = match[2]
107
+ next if !url_part || !meta_part
108
+ return url_part if meta_part == 'next'
109
+ end
110
+ end
111
+
112
+ nil
113
+ end
114
+
115
+ def self.assign_url_part(meta_part, url_part)
116
+ case meta_part
117
+ when 'next'
118
+ url_part
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ attr_reader :configuration, :errors, :options
125
+
126
+ def validate_create
127
+ validate_public_ssh_key
128
+ validate_private_ssh_key
129
+ validate_ssh_allowed_networks
130
+ validate_api_allowed_networks
131
+ validate_masters_location
132
+ # validate_k3s_version
133
+ validate_masters
134
+ validate_worker_node_pools
135
+ validate_verify_host_key
136
+ validate_additional_packages
137
+ validate_post_create_commands
138
+ validate_kube_api_server_args
139
+ validate_kube_scheduler_args
140
+ validate_kube_controller_manager_args
141
+ validate_kube_cloud_controller_manager_args
142
+ validate_kubelet_args
143
+ validate_kube_proxy_args
144
+ validate_existing_network
145
+ end
146
+
147
+ def validate_upgrade
148
+ validate_kubeconfig_path_must_exist
149
+ # validate_new_k3s_version
150
+ end
151
+
152
+ def validate_public_ssh_key
153
+ path = File.expand_path(configuration['public_ssh_key_path'])
154
+ errors << 'Invalid Public SSH key path' and return unless File.exist? path
155
+
156
+ key = File.read(path)
157
+ errors << 'Public SSH key is invalid' unless ::SSHKey.valid_ssh_public_key?(key)
158
+ rescue StandardError
159
+ errors << 'Invalid Public SSH key path'
160
+ end
161
+
162
+ def validate_private_ssh_key
163
+ private_ssh_key_path = configuration['private_ssh_key_path']
164
+
165
+ return unless private_ssh_key_path
166
+
167
+ path = File.expand_path(private_ssh_key_path)
168
+ errors << 'Invalid Private SSH key path' and return unless File.exist?(path)
169
+ rescue StandardError
170
+ errors << 'Invalid Private SSH key path'
171
+ end
172
+
173
+ def validate_networks(configuration_option, access_type)
174
+ networks ||= configuration[configuration_option]
175
+
176
+ if networks.nil? || networks.empty?
177
+ errors << "At least one network/IP range must be specified for #{access_type} access"
178
+ return
179
+ end
180
+
181
+ invalid_networks = networks.reject do |network|
182
+ IPAddr.new(network)
183
+ rescue StandardError
184
+ false
185
+ end
186
+
187
+ unless invalid_networks.empty?
188
+ invalid_networks.each do |network|
189
+ errors << "The #{access_type} network #{network} is an invalid range"
190
+ end
191
+ end
192
+
193
+ invalid_ranges = networks.reject do |network|
194
+ network.include? '/'
195
+ end
196
+
197
+ unless invalid_ranges.empty?
198
+ invalid_ranges.each do |_network|
199
+ errors << 'Please use the CIDR notation for the #{access_type} networks to avoid ambiguity'
200
+ end
201
+ end
202
+
203
+ return unless invalid_networks.empty?
204
+
205
+ current_ip = URI.open('http://whatismyip.akamai.com').read
206
+
207
+ current_ip_network = networks.detect do |network|
208
+ IPAddr.new(network).include?(current_ip)
209
+ rescue StandardError
210
+ false
211
+ end
212
+
213
+ unless current_ip_network
214
+ case access_type
215
+ when "SSH"
216
+ errors << "Your current IP #{current_ip} is not included into any of the #{access_type} networks you've specified, so we won't be able to SSH into the nodes "
217
+ when "API"
218
+ errors << "Your current IP #{current_ip} is not included into any of the #{access_type} networks you've specified, so we won't be able to connect to the Kubernetes API"
219
+ end
220
+ end
221
+ end
222
+
223
+
224
+ def validate_ssh_allowed_networks
225
+ return
226
+ validate_networks('ssh_allowed_networks', 'SSH')
227
+ end
228
+
229
+ def validate_api_allowed_networks
230
+ validate_networks('api_allowed_networks', 'API')
231
+ end
232
+
233
+ def validate_masters_location
234
+ return if valid_location?(configuration['location'])
235
+
236
+ errors << 'Invalid location for master nodes - valid locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland) or ash (Ashburn, Virginia, USA)'
237
+ end
238
+
239
+ def validate_k3s_version
240
+ k3s_version = configuration['k3s_version']
241
+ errors << 'Invalid k3s version' unless Hetzner::Configuration.available_releases.include? k3s_version
242
+ end
243
+
244
+ def validate_masters
245
+ masters_pool = nil
246
+
247
+ begin
248
+ masters_pool = configuration['masters']
249
+ rescue StandardError
250
+ errors << 'Invalid masters configuration'
251
+ return
252
+ end
253
+
254
+ if masters_pool.nil?
255
+ errors << 'Invalid masters configuration'
256
+ return
257
+ end
258
+
259
+ validate_instance_group masters_pool, workers: false
260
+ end
261
+
262
+ def validate_worker_node_pools
263
+ worker_node_pools = configuration['worker_node_pools'] || []
264
+
265
+ unless worker_node_pools.size.positive? || schedule_workloads_on_masters?
266
+ errors << 'Invalid node pools configuration'
267
+ return
268
+ end
269
+
270
+ return if worker_node_pools.size.zero? && schedule_workloads_on_masters?
271
+
272
+ if !worker_node_pools.is_a? Array
273
+ errors << 'Invalid node pools configuration'
274
+ elsif worker_node_pools.size.zero?
275
+ errors << 'At least one node pool is required in order to schedule workloads' unless schedule_workloads_on_masters?
276
+ elsif worker_node_pools.map { |worker_node_pool| worker_node_pool['name'] }.uniq.size != worker_node_pools.size
277
+ errors << 'Each node pool must have an unique name'
278
+ elsif server_types
279
+ worker_node_pools.each do |worker_node_pool|
280
+ validate_instance_group worker_node_pool
281
+ end
282
+ end
283
+ end
284
+
285
+ def validate_verify_host_key
286
+ return unless [true, false].include?(configuration.fetch('public_ssh_key_path', false))
287
+
288
+ errors << 'Please set the verify_host_key option to either true or false'
289
+ end
290
+
291
+ def validate_additional_packages
292
+ additional_packages = configuration['additional_packages']
293
+ errors << 'Invalid additional packages configuration - it should be an array' if additional_packages && !additional_packages.is_a?(Array)
294
+ end
295
+
296
+ def validate_post_create_commands
297
+ post_create_commands = configuration['post_create_commands']
298
+ errors << 'Invalid post create commands configuration - it should be an array' if post_create_commands && !post_create_commands.is_a?(Array)
299
+ end
300
+
301
+ def validate_kube_api_server_args
302
+ kube_api_server_args = configuration['kube_api_server_args']
303
+ return unless kube_api_server_args
304
+
305
+ errors << 'kube_api_server_args must be an array of arguments' unless kube_api_server_args.is_a? Array
306
+ end
307
+
308
+ def validate_kube_scheduler_args
309
+ kube_scheduler_args = configuration['kube_scheduler_args']
310
+ return unless kube_scheduler_args
311
+
312
+ errors << 'kube_scheduler_args must be an array of arguments' unless kube_scheduler_args.is_a? Array
313
+ end
314
+
315
+ def validate_kube_controller_manager_args
316
+ kube_controller_manager_args = configuration['kube_controller_manager_args']
317
+ return unless kube_controller_manager_args
318
+
319
+ errors << 'kube_controller_manager_args must be an array of arguments' unless kube_controller_manager_args.is_a? Array
320
+ end
321
+
322
+ def validate_kube_cloud_controller_manager_args
323
+ kube_cloud_controller_manager_args = configuration['kube_cloud_controller_manager_args']
324
+ return unless kube_cloud_controller_manager_args
325
+
326
+ errors << 'kube_cloud_controller_manager_args must be an array of arguments' unless kube_cloud_controller_manager_args.is_a? Array
327
+ end
328
+
329
+ def validate_kubelet_args
330
+ kubelet_args = configuration['kubelet_args']
331
+ return unless kubelet_args
332
+
333
+ errors << 'kubelet_args must be an array of arguments' unless kubelet_args.is_a? Array
334
+ end
335
+
336
+ def validate_kube_proxy_args
337
+ kube_proxy_args = configuration['kube_proxy_args']
338
+ return unless kube_proxy_args
339
+
340
+ errors << 'kube_proxy_args must be an array of arguments' unless kube_proxy_args.is_a? Array
341
+ end
342
+
343
+ def validate_configuration_file
344
+ config_file_path = options[:config_file]
345
+
346
+ if File.exist?(config_file_path)
347
+ begin
348
+ @configuration = YAML.load_file(options[:config_file])
349
+ unless configuration.is_a? Hash
350
+ puts 'Configuration is invalid'
351
+ exit 1
352
+ end
353
+ rescue StandardError
354
+ puts 'Please ensure that the config file is a correct YAML manifest.'
355
+ exit 1
356
+ end
357
+ else
358
+ puts 'Please specify a correct path for the config file.'
359
+ exit 1
360
+ end
361
+ end
362
+
363
+ def validate_token
364
+ errors << 'Invalid Hetzner Cloud token' unless valid_token?
365
+ end
366
+
367
+ def validate_kubeconfig_path
368
+ path = File.expand_path(configuration['kubeconfig_path'])
369
+ errors << 'kubeconfig path cannot be a directory' and return if File.directory? path
370
+
371
+ directory = File.dirname(path)
372
+ errors << "Directory #{directory} doesn't exist" unless File.exist? directory
373
+ rescue StandardError
374
+ errors << 'Invalid path for the kubeconfig'
375
+ end
376
+
377
+ def validate_kubeconfig_path_must_exist
378
+ path = File.expand_path configuration['kubeconfig_path']
379
+ errors << 'kubeconfig path is invalid' and return unless File.exist? path
380
+
381
+ errors << 'kubeconfig path cannot be a directory' if File.directory? path
382
+ rescue StandardError
383
+ errors << 'Invalid kubeconfig path'
384
+ end
385
+
386
+ def validate_cluster_name
387
+ errors << 'Cluster name is an invalid format (only lowercase letters, digits and dashes are allowed)' unless configuration['cluster_name'] =~ /\A[a-z\d-]+\z/
388
+
389
+ return if configuration['cluster_name'] =~ /\A[a-z]+.*([a-z]|\d)+\z/
390
+
391
+ errors << 'Ensure that the cluster name starts and ends with a normal letter'
392
+ end
393
+
394
+ def validate_new_k3s_version
395
+ new_k3s_version = options[:new_k3s_version]
396
+ errors << 'The new k3s version is invalid' unless Hetzner::Configuration.available_releases.include? new_k3s_version
397
+ end
398
+
399
+ def valid_token?
400
+ return @valid unless @valid.nil?
401
+
402
+ begin
403
+ token = hetzner_token
404
+ @hetzner_client = Hetzner::Client.new(token: token)
405
+ response = hetzner_client.get('/locations')
406
+ error_code = response.dig('error', 'code')
407
+ @valid = error_code != 'unauthorized'
408
+ rescue StandardError
409
+ @valid = false
410
+ end
411
+ end
412
+
413
+ def validate_instance_group(instance_group, workers: true)
414
+ instance_group_errors = []
415
+
416
+ instance_group_type = workers ? "Worker mode pool '#{instance_group['name']}'" : 'Masters pool'
417
+
418
+ instance_group_errors << "#{instance_group_type} has an invalid name" unless !workers || instance_group['name'] =~ /\A([A-Za-z0-9\-_]+)\Z/
419
+
420
+ instance_group_errors << "#{instance_group_type} is in an invalid format" unless instance_group.is_a? Hash
421
+
422
+ instance_group_errors << "#{instance_group_type} has an invalid instance type" unless !valid_token? || server_types.include?(instance_group['instance_type'])
423
+
424
+ if workers
425
+ location = instance_group.fetch('location', configuration['location'])
426
+ instance_group_errors << "#{instance_group_type} has an invalid location - valid locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland) or ash (Ashburn, Virginia, USA)" unless valid_location?(location)
427
+
428
+ in_network_zone = configuration['location'] == 'ash' ? location == 'ash' : location != 'ash'
429
+ instance_group_errors << "#{instance_group_type} must be in the same network zone as the masters. If the masters are located in Ashburn, all the node pools must be located in Ashburn too, otherwise none of the node pools should be located in Ashburn." unless in_network_zone
430
+ end
431
+
432
+ if instance_group['instance_count'].is_a? Integer
433
+ if instance_group['instance_count'] < 1
434
+ instance_group_errors << "#{instance_group_type} must have at least one node"
435
+ elsif instance_group['instance_count'] > 10
436
+ instance_group_errors << "#{instance_group_type} cannot have more than 10 nodes due to a limitation with the Hetzner placement groups. You can add more node pools if you need more nodes."
437
+ elsif !workers
438
+ instance_group_errors << 'Masters count must equal to 1 for non-HA clusters or an odd number (recommended 3) for an HA cluster' unless instance_group['instance_count'].odd?
439
+ end
440
+ else
441
+ instance_group_errors << "#{instance_group_type} has an invalid instance count"
442
+ end
443
+
444
+ errors << instance_group_errors
445
+ end
446
+
447
+ def valid_location?(location)
448
+ return if locations.empty? && !valid_token?
449
+
450
+ locations.include? location
451
+ end
452
+
453
+ def locations
454
+ return [] unless valid_token?
455
+
456
+ @locations ||= hetzner_client.get('/locations')['locations'].map { |location| location['name'] }
457
+ rescue StandardError
458
+ @errors << 'Cannot fetch locations with Hetzner API, please try again later'
459
+ []
460
+ end
461
+
462
+ def schedule_workloads_on_masters?
463
+ schedule_workloads_on_masters = configuration['schedule_workloads_on_masters']
464
+ schedule_workloads_on_masters ? !!schedule_workloads_on_masters : false
465
+ end
466
+
467
+ def server_types
468
+ return [] unless valid_token?
469
+
470
+ @server_types ||= hetzner_client.get('/server_types')['server_types'].map { |server_type| server_type['name'] }
471
+ rescue StandardError
472
+ @errors << 'Cannot fetch server types with Hetzner API, please try again later'
473
+ false
474
+ end
475
+
476
+ def validate_existing_network
477
+ return unless configuration['existing_network']
478
+
479
+ existing_network = Hetzner::Network.new(hetzner_client: hetzner_client, cluster_name: configuration['cluster_name'], existing_network: configuration['existing_network']).get
480
+
481
+ return if existing_network
482
+
483
+ @errors << "You have specified that you want to use the existing network named '#{configuration['existing_network']} but this network doesn't exist"
484
+ end
485
+ end
486
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hetzner
4
4
  module K3s
5
- VERSION = '0.5.7'
5
+ VERSION = '0.6.0.pre1'
6
6
  end
7
7
  end
data/lib/hetzner/utils.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ Net::SSH::Transport::Algorithms::ALGORITHMS.values.each { |algs| algs.reject! { |a| a =~ /^ecd(sa|h)-sha2/ } }
4
+ Net::SSH::KnownHosts::SUPPORTED_TYPE.reject! { |t| t =~ /^ecd(sa|h)-sha2/ }
5
+
6
+ require 'childprocess'
7
+
3
8
  module Utils
4
9
  CMD_FILE_PATH = '/tmp/cli.cmd'
5
10
 
@@ -19,8 +24,6 @@ module Utils
19
24
  end
20
25
 
21
26
  def run(command, kubeconfig_path:)
22
- env = ENV.to_hash.merge({ 'KUBECONFIG' => kubeconfig_path })
23
-
24
27
  write_file CMD_FILE_PATH, <<-CONTENT
25
28
  set -euo pipefail
26
29
  #{command}
@@ -29,20 +32,19 @@ module Utils
29
32
  FileUtils.chmod('+x', CMD_FILE_PATH)
30
33
 
31
34
  begin
32
- process = nil
35
+ process = ChildProcess.build('bash', '-c', CMD_FILE_PATH)
36
+ process.io.inherit!
37
+ process.environment['KUBECONFIG'] = kubeconfig_path
38
+ process.environment['HCLOUD_TOKEN'] = ENV.fetch('HCLOUD_TOKEN', '')
33
39
 
34
40
  at_exit do
35
- process&.send_signal('SIGTERM')
41
+ process.stop
36
42
  rescue Errno::ESRCH, Interrupt
37
43
  # ignore
38
44
  end
39
45
 
40
- Subprocess.check_call(['bash', '-c', CMD_FILE_PATH], env:) do |p|
41
- process = p
42
- end
43
- rescue Subprocess::NonZeroExit
44
- puts 'Command failed: non-zero exit code'
45
- exit 1
46
+ process.start
47
+ process.wait
46
48
  rescue Interrupt
47
49
  puts 'Command interrupted'
48
50
  exit 1
@@ -86,6 +88,13 @@ module Utils
86
88
  end
87
89
  end
88
90
  output.chop
91
+ # rescue StandardError => e
92
+ # p [e.class, e.message]
93
+ # retries += 1
94
+ # retry unless retries > 15 || e.message =~ /Bad file descriptor/
95
+ rescue Timeout::Error, IOError, Errno::EBADF
96
+ retries += 1
97
+ retry unless retries > 15
89
98
  rescue Net::SSH::Disconnect => e
90
99
  retries += 1
91
100
  retry unless retries > 15 || e.message =~ /Too many authentication failures/