hetzner-k3s 0.5.5 → 0.5.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hetzner
4
+ class Configuration
5
+ GITHUB_DELIM_LINKS = ','.freeze
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
+ while !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 Hetzner API, please try again later'
72
+ else
73
+ puts 'Cannot fetch the releases with Hetzner API, please try again later'
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, meta_part = match[1], match[2]
108
+ next if !url_part || !meta_part
109
+ return url_part if meta_part == "next"
110
+ end
111
+ end
112
+
113
+ nil
114
+ end
115
+
116
+ def self.assign_url_part(meta_part, url_part)
117
+ case meta_part
118
+ when "next"
119
+ url_part
120
+ end
121
+ end
122
+
123
+ def validate_create
124
+ validate_public_ssh_key
125
+ validate_private_ssh_key
126
+ validate_ssh_allowed_networks
127
+ validate_masters_location
128
+ validate_k3s_version
129
+ validate_masters
130
+ validate_worker_node_pools
131
+ validate_verify_host_key
132
+ validate_additional_packages
133
+ validate_post_create_commands
134
+ validate_kube_api_server_args
135
+ validate_kube_scheduler_args
136
+ validate_kube_controller_manager_args
137
+ validate_kube_cloud_controller_manager_args
138
+ validate_kubelet_args
139
+ validate_kube_proxy_args
140
+ end
141
+
142
+ def validate_upgrade
143
+ validate_kubeconfig_path_must_exist
144
+ validate_new_k3s_version
145
+ end
146
+
147
+ def validate_public_ssh_key
148
+ path = File.expand_path(configuration['public_ssh_key_path'])
149
+ errors << 'Invalid Public SSH key path' and return unless File.exist? path
150
+
151
+ key = File.read(path)
152
+ errors << 'Public SSH key is invalid' unless ::SSHKey.valid_ssh_public_key?(key)
153
+ rescue StandardError
154
+ errors << 'Invalid Public SSH key path'
155
+ end
156
+
157
+ def validate_private_ssh_key
158
+ private_ssh_key_path = configuration['private_ssh_key_path']
159
+
160
+ return unless private_ssh_key_path
161
+
162
+ path = File.expand_path(private_ssh_key_path)
163
+ errors << 'Invalid Private SSH key path' and return unless File.exist?(path)
164
+ rescue StandardError
165
+ errors << 'Invalid Private SSH key path'
166
+ end
167
+
168
+ def validate_ssh_allowed_networks
169
+ networks ||= configuration['ssh_allowed_networks']
170
+
171
+ if networks.nil? || networks.empty?
172
+ errors << 'At least one network/IP range must be specified for SSH access'
173
+ return
174
+ end
175
+
176
+ invalid_networks = networks.reject do |network|
177
+ IPAddr.new(network)
178
+ rescue StandardError
179
+ false
180
+ end
181
+
182
+ unless invalid_networks.empty?
183
+ invalid_networks.each do |network|
184
+ errors << "The network #{network} is an invalid range"
185
+ end
186
+ end
187
+
188
+ invalid_ranges = networks.reject do |network|
189
+ network.include? '/'
190
+ end
191
+
192
+ unless invalid_ranges.empty?
193
+ invalid_ranges.each do |_network|
194
+ errors << 'Please use the CIDR notation for the networks to avoid ambiguity'
195
+ end
196
+ end
197
+
198
+ return unless invalid_networks.empty?
199
+
200
+ current_ip = URI.open('http://whatismyip.akamai.com').read
201
+
202
+ current_ip_networks = networks.detect do |network|
203
+ IPAddr.new(network).include?(current_ip)
204
+ rescue StandardError
205
+ false
206
+ end
207
+
208
+ errors << "Your current IP #{current_ip} is not included into any of the networks you've specified, so we won't be able to SSH into the nodes" unless current_ip_networks
209
+ end
210
+
211
+ def validate_masters_location
212
+ return if valid_location?(configuration['location'])
213
+
214
+ errors << 'Invalid location for master nodes - valid locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland) or ash (Ashburn, Virginia, USA)'
215
+ end
216
+
217
+ def validate_k3s_version
218
+ k3s_version = configuration['k3s_version']
219
+ errors << 'Invalid k3s version' unless Hetzner::Configuration.available_releases.include? k3s_version
220
+ end
221
+
222
+ def validate_masters
223
+ masters_pool = nil
224
+
225
+ begin
226
+ masters_pool = configuration['masters']
227
+ rescue StandardError
228
+ errors << 'Invalid masters configuration'
229
+ return
230
+ end
231
+
232
+ if masters_pool.nil?
233
+ errors << 'Invalid masters configuration'
234
+ return
235
+ end
236
+
237
+ validate_instance_group masters_pool, workers: false
238
+ end
239
+
240
+ def validate_worker_node_pools
241
+ worker_node_pools = configuration['worker_node_pools'] || []
242
+
243
+ unless worker_node_pools.size.positive? || schedule_workloads_on_masters?
244
+ errors << 'Invalid node pools configuration'
245
+ return
246
+ end
247
+
248
+ return if worker_node_pools.size.zero? && schedule_workloads_on_masters?
249
+
250
+ if !worker_node_pools.is_a? Array
251
+ errors << 'Invalid node pools configuration'
252
+ elsif worker_node_pools.size.zero?
253
+ errors << 'At least one node pool is required in order to schedule workloads' unless schedule_workloads_on_masters?
254
+ elsif worker_node_pools.map { |worker_node_pool| worker_node_pool['name'] }.uniq.size != worker_node_pools.size
255
+ errors << 'Each node pool must have an unique name'
256
+ elsif server_types
257
+ worker_node_pools.each do |worker_node_pool|
258
+ validate_instance_group worker_node_pool
259
+ end
260
+ end
261
+ end
262
+
263
+ def validate_verify_host_key
264
+ return unless [true, false].include?(configuration.fetch('public_ssh_key_path', false))
265
+
266
+ errors << 'Please set the verify_host_key option to either true or false'
267
+ end
268
+
269
+ def validate_additional_packages
270
+ additional_packages = configuration['additional_packages']
271
+ errors << 'Invalid additional packages configuration - it should be an array' if additional_packages && !additional_packages.is_a?(Array)
272
+ end
273
+
274
+ def validate_post_create_commands
275
+ post_create_commands = configuration['post_create_commands']
276
+ errors << 'Invalid post create commands configuration - it should be an array' if post_create_commands && !post_create_commands.is_a?(Array)
277
+ end
278
+
279
+ def validate_kube_api_server_args
280
+ kube_api_server_args = configuration['kube_api_server_args']
281
+ return unless kube_api_server_args
282
+
283
+ errors << 'kube_api_server_args must be an array of arguments' unless kube_api_server_args.is_a? Array
284
+ end
285
+
286
+ def validate_kube_scheduler_args
287
+ kube_scheduler_args = configuration['kube_scheduler_args']
288
+ return unless kube_scheduler_args
289
+
290
+ errors << 'kube_scheduler_args must be an array of arguments' unless kube_scheduler_args.is_a? Array
291
+ end
292
+
293
+ def validate_kube_controller_manager_args
294
+ kube_controller_manager_args = configuration['kube_controller_manager_args']
295
+ return unless kube_controller_manager_args
296
+
297
+ errors << 'kube_controller_manager_args must be an array of arguments' unless kube_controller_manager_args.is_a? Array
298
+ end
299
+
300
+ def validate_kube_cloud_controller_manager_args
301
+ kube_cloud_controller_manager_args = configuration['kube_cloud_controller_manager_args']
302
+ return unless kube_cloud_controller_manager_args
303
+
304
+ errors << 'kube_cloud_controller_manager_args must be an array of arguments' unless kube_cloud_controller_manager_args.is_a? Array
305
+ end
306
+
307
+ def validate_kubelet_args
308
+ kubelet_args = configuration['kubelet_args']
309
+ return unless kubelet_args
310
+
311
+ errors << 'kubelet_args must be an array of arguments' unless kubelet_args.is_a? Array
312
+ end
313
+
314
+ def validate_kube_proxy_args
315
+ kube_proxy_args = configuration['kube_proxy_args']
316
+ return unless kube_proxy_args
317
+
318
+ errors << 'kube_proxy_args must be an array of arguments' unless kube_proxy_args.is_a? Array
319
+ end
320
+
321
+ def validate_configuration_file
322
+ config_file_path = options[:config_file]
323
+
324
+ if File.exist?(config_file_path)
325
+ begin
326
+ @configuration = YAML.load_file(options[:config_file])
327
+ unless configuration.is_a? Hash
328
+ puts 'Configuration is invalid'
329
+ exit 1
330
+ end
331
+ rescue StandardError
332
+ puts 'Please ensure that the config file is a correct YAML manifest.'
333
+ exit 1
334
+ end
335
+ else
336
+ puts 'Please specify a correct path for the config file.'
337
+ exit 1
338
+ end
339
+ end
340
+
341
+ def validate_token
342
+ errors << 'Invalid Hetzner Cloud token' unless valid_token?
343
+ end
344
+
345
+ def validate_kubeconfig_path
346
+ path = File.expand_path(configuration['kubeconfig_path'])
347
+ errors << 'kubeconfig path cannot be a directory' and return if File.directory? path
348
+
349
+ directory = File.dirname(path)
350
+ errors << "Directory #{directory} doesn't exist" unless File.exist? directory
351
+ rescue StandardError
352
+ errors << 'Invalid path for the kubeconfig'
353
+ end
354
+
355
+ def validate_kubeconfig_path_must_exist
356
+ path = File.expand_path configuration['kubeconfig_path']
357
+ errors << 'kubeconfig path is invalid' and return unless File.exist? path
358
+
359
+ errors << 'kubeconfig path cannot be a directory' if File.directory? path
360
+ rescue StandardError
361
+ errors << 'Invalid kubeconfig path'
362
+ end
363
+
364
+ def validate_cluster_name
365
+ errors << 'Cluster name is an invalid format (only lowercase letters, digits and dashes are allowed)' unless configuration['cluster_name'] =~ /\A[a-z\d-]+\z/
366
+
367
+ return if configuration['cluster_name'] =~ /\A[a-z]+.*([a-z]|\d)+\z/
368
+
369
+ errors << 'Ensure that the cluster name starts and ends with a normal letter'
370
+ end
371
+
372
+ def validate_new_k3s_version
373
+ new_k3s_version = options[:new_k3s_version]
374
+ errors << 'The new k3s version is invalid' unless Hetzner::Configuration.available_releases.include? new_k3s_version
375
+ end
376
+
377
+ def valid_token?
378
+ return @valid unless @valid.nil?
379
+
380
+ begin
381
+ token = hetzner_token
382
+ @hetzner_client = Hetzner::Client.new(token:)
383
+ response = hetzner_client.get('/locations')
384
+ error_code = response.dig('error', 'code')
385
+ @valid = error_code != 'unauthorized'
386
+ rescue StandardError
387
+ @valid = false
388
+ end
389
+ end
390
+
391
+ def validate_instance_group(instance_group, workers: true)
392
+ instance_group_errors = []
393
+
394
+ instance_group_type = workers ? "Worker mode pool '#{instance_group['name']}'" : 'Masters pool'
395
+
396
+ instance_group_errors << "#{instance_group_type} has an invalid name" unless !workers || instance_group['name'] =~ /\A([A-Za-z0-9\-_]+)\Z/
397
+
398
+ instance_group_errors << "#{instance_group_type} is in an invalid format" unless instance_group.is_a? Hash
399
+
400
+ instance_group_errors << "#{instance_group_type} has an invalid instance type" unless !valid_token? || server_types.include?(instance_group['instance_type'])
401
+
402
+ if workers
403
+ location = instance_group.fetch('location', configuration['location'])
404
+ 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)
405
+
406
+ in_network_zone = configuration['location'] == 'ash' ? location == 'ash' : location != 'ash'
407
+ 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
408
+ end
409
+
410
+ if instance_group['instance_count'].is_a? Integer
411
+ if instance_group['instance_count'] < 1
412
+ instance_group_errors << "#{instance_group_type} must have at least one node"
413
+ elsif instance_group['instance_count'] > 10
414
+ 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."
415
+ elsif !workers
416
+ 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?
417
+ end
418
+ else
419
+ instance_group_errors << "#{instance_group_type} has an invalid instance count"
420
+ end
421
+
422
+ errors << instance_group_errors
423
+ end
424
+
425
+ def valid_location?(location)
426
+ return if locations.empty? && !valid_token?
427
+
428
+ locations.include? location
429
+ end
430
+
431
+ def locations
432
+ return [] unless valid_token?
433
+
434
+ @locations ||= hetzner_client.get('/locations')['locations'].map { |location| location['name'] }
435
+ rescue StandardError
436
+ @errors << 'Cannot fetch locations with Hetzner API, please try again later'
437
+ []
438
+ end
439
+
440
+ def schedule_workloads_on_masters?
441
+ schedule_workloads_on_masters = configuration['schedule_workloads_on_masters']
442
+ schedule_workloads_on_masters ? !!schedule_workloads_on_masters : false
443
+ end
444
+
445
+ def server_types
446
+ return [] unless valid_token?
447
+
448
+ @server_types ||= hetzner_client.get('/server_types')['server_types'].map { |server_type| server_type['name'] }
449
+ rescue StandardError
450
+ @errors << 'Cannot fetch server types with Hetzner API, please try again later'
451
+ false
452
+ end
453
+ end
454
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hetzner
4
4
  module K3s
5
- VERSION = '0.5.5'
5
+ VERSION = '0.5.8'
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.5
4
+ version: 0.5.8
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-22 00:00:00.000000000 Z
11
+ date: 2022-08-11 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