hetzner-k3s 0.5.6 → 0.5.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,484 @@
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
96
+
97
+ attr_reader :configuration, :errors, :options
98
+
99
+ def self.fetch_releases(url)
100
+ response = HTTP.get(url)
101
+ [response, JSON.parse(response.body).map { |hash| hash['name'] }]
102
+ end
103
+
104
+ def self.extract_next_github_page_url(link_header)
105
+ link_header.split(GITHUB_DELIM_LINKS).each do |link|
106
+ GITHUB_LINK_REGEX.match(link.strip) do |match|
107
+ url_part = match[1]
108
+ meta_part = match[2]
109
+ next if !url_part || !meta_part
110
+ return url_part if meta_part == 'next'
111
+ end
112
+ end
113
+
114
+ nil
115
+ end
116
+
117
+ def self.assign_url_part(meta_part, url_part)
118
+ case meta_part
119
+ when 'next'
120
+ url_part
121
+ end
122
+ end
123
+
124
+ def validate_create
125
+ validate_public_ssh_key
126
+ validate_private_ssh_key
127
+ validate_ssh_allowed_networks
128
+ validate_api_allowed_networks
129
+ validate_masters_location
130
+ validate_k3s_version
131
+ validate_masters
132
+ validate_worker_node_pools
133
+ validate_verify_host_key
134
+ validate_additional_packages
135
+ validate_post_create_commands
136
+ validate_kube_api_server_args
137
+ validate_kube_scheduler_args
138
+ validate_kube_controller_manager_args
139
+ validate_kube_cloud_controller_manager_args
140
+ validate_kubelet_args
141
+ validate_kube_proxy_args
142
+ validate_existing_network
143
+ end
144
+
145
+ def validate_upgrade
146
+ validate_kubeconfig_path_must_exist
147
+ validate_new_k3s_version
148
+ end
149
+
150
+ def validate_public_ssh_key
151
+ path = File.expand_path(configuration['public_ssh_key_path'])
152
+ errors << 'Invalid Public SSH key path' and return unless File.exist? path
153
+
154
+ key = File.read(path)
155
+ errors << 'Public SSH key is invalid' unless ::SSHKey.valid_ssh_public_key?(key)
156
+ rescue StandardError
157
+ errors << 'Invalid Public SSH key path'
158
+ end
159
+
160
+ def validate_private_ssh_key
161
+ private_ssh_key_path = configuration['private_ssh_key_path']
162
+
163
+ return unless private_ssh_key_path
164
+
165
+ path = File.expand_path(private_ssh_key_path)
166
+ errors << 'Invalid Private SSH key path' and return unless File.exist?(path)
167
+ rescue StandardError
168
+ errors << 'Invalid Private SSH key path'
169
+ end
170
+
171
+ def validate_networks(configuration_option, access_type)
172
+ networks ||= configuration[configuration_option]
173
+
174
+ if networks.nil? || networks.empty?
175
+ errors << "At least one network/IP range must be specified for #{access_type} access"
176
+ return
177
+ end
178
+
179
+ invalid_networks = networks.reject do |network|
180
+ IPAddr.new(network)
181
+ rescue StandardError
182
+ false
183
+ end
184
+
185
+ unless invalid_networks.empty?
186
+ invalid_networks.each do |network|
187
+ errors << "The #{access_type} network #{network} is an invalid range"
188
+ end
189
+ end
190
+
191
+ invalid_ranges = networks.reject do |network|
192
+ network.include? '/'
193
+ end
194
+
195
+ unless invalid_ranges.empty?
196
+ invalid_ranges.each do |_network|
197
+ errors << 'Please use the CIDR notation for the #{access_type} networks to avoid ambiguity'
198
+ end
199
+ end
200
+
201
+ return unless invalid_networks.empty?
202
+
203
+ current_ip = URI.open('http://whatismyip.akamai.com').read
204
+
205
+ current_ip_network = networks.detect do |network|
206
+ IPAddr.new(network).include?(current_ip)
207
+ rescue StandardError
208
+ false
209
+ end
210
+
211
+ unless current_ip_network
212
+ case access_type
213
+ when "SSH"
214
+ 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 "
215
+ when "API"
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 connect to the Kubernetes API"
217
+ end
218
+ end
219
+ end
220
+
221
+
222
+ def validate_ssh_allowed_networks
223
+ return
224
+ validate_networks('ssh_allowed_networks', 'SSH')
225
+ end
226
+
227
+ def validate_api_allowed_networks
228
+ validate_networks('api_allowed_networks', 'API')
229
+ end
230
+
231
+ def validate_masters_location
232
+ return if valid_location?(configuration['location'])
233
+
234
+ errors << 'Invalid location for master nodes - valid locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland) or ash (Ashburn, Virginia, USA)'
235
+ end
236
+
237
+ def validate_k3s_version
238
+ k3s_version = configuration['k3s_version']
239
+ errors << 'Invalid k3s version' unless Hetzner::Configuration.available_releases.include? k3s_version
240
+ end
241
+
242
+ def validate_masters
243
+ masters_pool = nil
244
+
245
+ begin
246
+ masters_pool = configuration['masters']
247
+ rescue StandardError
248
+ errors << 'Invalid masters configuration'
249
+ return
250
+ end
251
+
252
+ if masters_pool.nil?
253
+ errors << 'Invalid masters configuration'
254
+ return
255
+ end
256
+
257
+ validate_instance_group masters_pool, workers: false
258
+ end
259
+
260
+ def validate_worker_node_pools
261
+ worker_node_pools = configuration['worker_node_pools'] || []
262
+
263
+ unless worker_node_pools.size.positive? || schedule_workloads_on_masters?
264
+ errors << 'Invalid node pools configuration'
265
+ return
266
+ end
267
+
268
+ return if worker_node_pools.size.zero? && schedule_workloads_on_masters?
269
+
270
+ if !worker_node_pools.is_a? Array
271
+ errors << 'Invalid node pools configuration'
272
+ elsif worker_node_pools.size.zero?
273
+ errors << 'At least one node pool is required in order to schedule workloads' unless schedule_workloads_on_masters?
274
+ elsif worker_node_pools.map { |worker_node_pool| worker_node_pool['name'] }.uniq.size != worker_node_pools.size
275
+ errors << 'Each node pool must have an unique name'
276
+ elsif server_types
277
+ worker_node_pools.each do |worker_node_pool|
278
+ validate_instance_group worker_node_pool
279
+ end
280
+ end
281
+ end
282
+
283
+ def validate_verify_host_key
284
+ return unless [true, false].include?(configuration.fetch('public_ssh_key_path', false))
285
+
286
+ errors << 'Please set the verify_host_key option to either true or false'
287
+ end
288
+
289
+ def validate_additional_packages
290
+ additional_packages = configuration['additional_packages']
291
+ errors << 'Invalid additional packages configuration - it should be an array' if additional_packages && !additional_packages.is_a?(Array)
292
+ end
293
+
294
+ def validate_post_create_commands
295
+ post_create_commands = configuration['post_create_commands']
296
+ errors << 'Invalid post create commands configuration - it should be an array' if post_create_commands && !post_create_commands.is_a?(Array)
297
+ end
298
+
299
+ def validate_kube_api_server_args
300
+ kube_api_server_args = configuration['kube_api_server_args']
301
+ return unless kube_api_server_args
302
+
303
+ errors << 'kube_api_server_args must be an array of arguments' unless kube_api_server_args.is_a? Array
304
+ end
305
+
306
+ def validate_kube_scheduler_args
307
+ kube_scheduler_args = configuration['kube_scheduler_args']
308
+ return unless kube_scheduler_args
309
+
310
+ errors << 'kube_scheduler_args must be an array of arguments' unless kube_scheduler_args.is_a? Array
311
+ end
312
+
313
+ def validate_kube_controller_manager_args
314
+ kube_controller_manager_args = configuration['kube_controller_manager_args']
315
+ return unless kube_controller_manager_args
316
+
317
+ errors << 'kube_controller_manager_args must be an array of arguments' unless kube_controller_manager_args.is_a? Array
318
+ end
319
+
320
+ def validate_kube_cloud_controller_manager_args
321
+ kube_cloud_controller_manager_args = configuration['kube_cloud_controller_manager_args']
322
+ return unless kube_cloud_controller_manager_args
323
+
324
+ errors << 'kube_cloud_controller_manager_args must be an array of arguments' unless kube_cloud_controller_manager_args.is_a? Array
325
+ end
326
+
327
+ def validate_kubelet_args
328
+ kubelet_args = configuration['kubelet_args']
329
+ return unless kubelet_args
330
+
331
+ errors << 'kubelet_args must be an array of arguments' unless kubelet_args.is_a? Array
332
+ end
333
+
334
+ def validate_kube_proxy_args
335
+ kube_proxy_args = configuration['kube_proxy_args']
336
+ return unless kube_proxy_args
337
+
338
+ errors << 'kube_proxy_args must be an array of arguments' unless kube_proxy_args.is_a? Array
339
+ end
340
+
341
+ def validate_configuration_file
342
+ config_file_path = options[:config_file]
343
+
344
+ if File.exist?(config_file_path)
345
+ begin
346
+ @configuration = YAML.load_file(options[:config_file])
347
+ unless configuration.is_a? Hash
348
+ puts 'Configuration is invalid'
349
+ exit 1
350
+ end
351
+ rescue StandardError
352
+ puts 'Please ensure that the config file is a correct YAML manifest.'
353
+ exit 1
354
+ end
355
+ else
356
+ puts 'Please specify a correct path for the config file.'
357
+ exit 1
358
+ end
359
+ end
360
+
361
+ def validate_token
362
+ errors << 'Invalid Hetzner Cloud token' unless valid_token?
363
+ end
364
+
365
+ def validate_kubeconfig_path
366
+ path = File.expand_path(configuration['kubeconfig_path'])
367
+ errors << 'kubeconfig path cannot be a directory' and return if File.directory? path
368
+
369
+ directory = File.dirname(path)
370
+ errors << "Directory #{directory} doesn't exist" unless File.exist? directory
371
+ rescue StandardError
372
+ errors << 'Invalid path for the kubeconfig'
373
+ end
374
+
375
+ def validate_kubeconfig_path_must_exist
376
+ path = File.expand_path configuration['kubeconfig_path']
377
+ errors << 'kubeconfig path is invalid' and return unless File.exist? path
378
+
379
+ errors << 'kubeconfig path cannot be a directory' if File.directory? path
380
+ rescue StandardError
381
+ errors << 'Invalid kubeconfig path'
382
+ end
383
+
384
+ def validate_cluster_name
385
+ errors << 'Cluster name is an invalid format (only lowercase letters, digits and dashes are allowed)' unless configuration['cluster_name'] =~ /\A[a-z\d-]+\z/
386
+
387
+ return if configuration['cluster_name'] =~ /\A[a-z]+.*([a-z]|\d)+\z/
388
+
389
+ errors << 'Ensure that the cluster name starts and ends with a normal letter'
390
+ end
391
+
392
+ def validate_new_k3s_version
393
+ new_k3s_version = options[:new_k3s_version]
394
+ errors << 'The new k3s version is invalid' unless Hetzner::Configuration.available_releases.include? new_k3s_version
395
+ end
396
+
397
+ def valid_token?
398
+ return @valid unless @valid.nil?
399
+
400
+ begin
401
+ token = hetzner_token
402
+ @hetzner_client = Hetzner::Client.new(token:)
403
+ response = hetzner_client.get('/locations')
404
+ error_code = response.dig('error', 'code')
405
+ @valid = error_code != 'unauthorized'
406
+ rescue StandardError
407
+ @valid = false
408
+ end
409
+ end
410
+
411
+ def validate_instance_group(instance_group, workers: true)
412
+ instance_group_errors = []
413
+
414
+ instance_group_type = workers ? "Worker mode pool '#{instance_group['name']}'" : 'Masters pool'
415
+
416
+ instance_group_errors << "#{instance_group_type} has an invalid name" unless !workers || instance_group['name'] =~ /\A([A-Za-z0-9\-_]+)\Z/
417
+
418
+ instance_group_errors << "#{instance_group_type} is in an invalid format" unless instance_group.is_a? Hash
419
+
420
+ instance_group_errors << "#{instance_group_type} has an invalid instance type" unless !valid_token? || server_types.include?(instance_group['instance_type'])
421
+
422
+ if workers
423
+ location = instance_group.fetch('location', configuration['location'])
424
+ 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)
425
+
426
+ in_network_zone = configuration['location'] == 'ash' ? location == 'ash' : location != 'ash'
427
+ 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
428
+ end
429
+
430
+ if instance_group['instance_count'].is_a? Integer
431
+ if instance_group['instance_count'] < 1
432
+ instance_group_errors << "#{instance_group_type} must have at least one node"
433
+ elsif instance_group['instance_count'] > 10
434
+ 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."
435
+ elsif !workers
436
+ 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?
437
+ end
438
+ else
439
+ instance_group_errors << "#{instance_group_type} has an invalid instance count"
440
+ end
441
+
442
+ errors << instance_group_errors
443
+ end
444
+
445
+ def valid_location?(location)
446
+ return if locations.empty? && !valid_token?
447
+
448
+ locations.include? location
449
+ end
450
+
451
+ def locations
452
+ return [] unless valid_token?
453
+
454
+ @locations ||= hetzner_client.get('/locations')['locations'].map { |location| location['name'] }
455
+ rescue StandardError
456
+ @errors << 'Cannot fetch locations with Hetzner API, please try again later'
457
+ []
458
+ end
459
+
460
+ def schedule_workloads_on_masters?
461
+ schedule_workloads_on_masters = configuration['schedule_workloads_on_masters']
462
+ schedule_workloads_on_masters ? !!schedule_workloads_on_masters : false
463
+ end
464
+
465
+ def server_types
466
+ return [] unless valid_token?
467
+
468
+ @server_types ||= hetzner_client.get('/server_types')['server_types'].map { |server_type| server_type['name'] }
469
+ rescue StandardError
470
+ @errors << 'Cannot fetch server types with Hetzner API, please try again later'
471
+ false
472
+ end
473
+
474
+ def validate_existing_network
475
+ if configuration["existing_network"]
476
+ existing_network = Hetzner::Network.new(hetzner_client:, cluster_name: configuration["cluster_name"], existing_network: configuration["existing_network"]).get
477
+
478
+ unless existing_network
479
+ @errors << "You have specified that you want to use the existing network named '#{configuration["existing_network"]} but this network doesn't exist"
480
+ end
481
+ end
482
+ end
483
+ end
484
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hetzner
4
4
  module K3s
5
- VERSION = '0.5.6'
5
+ VERSION = '0.5.9'
6
6
  end
7
7
  end
data/lib/hetzner/utils.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Utils
2
4
  CMD_FILE_PATH = '/tmp/cli.cmd'
3
5
 
@@ -32,6 +34,7 @@ module Utils
32
34
  at_exit do
33
35
  process&.send_signal('SIGTERM')
34
36
  rescue Errno::ESRCH, Interrupt
37
+ # ignore
35
38
  end
36
39
 
37
40
  Subprocess.check_call(['bash', '-c', CMD_FILE_PATH], env:) do |p|
@@ -55,13 +58,13 @@ module Utils
55
58
  puts "Waiting for server #{server_name} to be up..."
56
59
 
57
60
  loop do
58
- result = ssh(server, 'echo UP')
59
- break if result == 'UP'
61
+ result = ssh(server, 'cat /etc/ready')
62
+ break if result == 'true'
60
63
  end
61
64
 
62
65
  puts "...server #{server_name} is now up."
63
66
  end
64
- rescue Errno::ENETUNREACH, Errno::EHOSTUNREACH, Timeout::Error, IOError
67
+ rescue Errno::ENETUNREACH, Errno::EHOSTUNREACH, Timeout::Error, IOError, Errno::ECONNRESET
65
68
  retries += 1
66
69
  retry if retries <= 15
67
70
  end
@@ -78,7 +81,7 @@ module Utils
78
81
 
79
82
  Net::SSH.start(public_ip, 'root', params) do |session|
80
83
  session.exec!(command) do |_channel, _stream, data|
81
- output << data
84
+ output = "#{output}#{data}"
82
85
  puts data if print_output
83
86
  end
84
87
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hetzner-k3s
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.6
4
+ version: 0.5.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vito Botta
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-02-25 00:00:00.000000000 Z
11
+ date: 2022-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bcrypt_pbkdf
@@ -161,6 +161,7 @@ files:
161
161
  - lib/hetzner/infra/ssh_key.rb
162
162
  - lib/hetzner/k3s/cli.rb
163
163
  - lib/hetzner/k3s/cluster.rb
164
+ - lib/hetzner/k3s/configuration.rb
164
165
  - lib/hetzner/k3s/version.rb
165
166
  - lib/hetzner/utils.rb
166
167
  homepage: https://github.com/vitobotta/hetzner-k3s
@@ -179,14 +180,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
179
180
  requirements:
180
181
  - - ">="
181
182
  - !ruby/object:Gem::Version
182
- version: 3.1.0
183
+ version: 3.1.2
183
184
  required_rubygems_version: !ruby/object:Gem::Requirement
184
185
  requirements:
185
186
  - - ">="
186
187
  - !ruby/object:Gem::Version
187
188
  version: '0'
188
189
  requirements: []
189
- rubygems_version: 3.3.3
190
+ rubygems_version: 3.3.7
190
191
  signing_key:
191
192
  specification_version: 4
192
193
  summary: A CLI to create a Kubernetes cluster in Hetzner Cloud very quickly using