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.
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Hetzner
4
4
  class Network
5
- def initialize(hetzner_client:, cluster_name:)
5
+ def initialize(hetzner_client:, cluster_name:, existing_network:)
6
6
  @hetzner_client = hetzner_client
7
7
  @cluster_name = cluster_name
8
+ @existing_network = existing_network
8
9
  end
9
10
 
10
11
  def create(location:)
@@ -29,9 +30,13 @@ module Hetzner
29
30
 
30
31
  def delete
31
32
  if (network = find_network)
32
- puts 'Deleting network...'
33
- hetzner_client.delete('/networks', network['id'])
34
- puts '...network deleted.'
33
+ if network['name'] == existing_network
34
+ puts 'Network existed before cluster, skipping.'
35
+ else
36
+ puts 'Deleting network...'
37
+ hetzner_client.delete('/networks', network['id'])
38
+ puts '...network deleted.'
39
+ end
35
40
  else
36
41
  puts 'Network no longer exists, skipping.'
37
42
  end
@@ -39,9 +44,18 @@ module Hetzner
39
44
  puts
40
45
  end
41
46
 
47
+ def find_network
48
+ network_name = existing_network || cluster_name
49
+ hetzner_client.get('/networks')['networks'].detect { |network| network['name'] == network_name }
50
+ end
51
+
52
+ def get
53
+ find_network
54
+ end
55
+
42
56
  private
43
57
 
44
- attr_reader :hetzner_client, :cluster_name, :location
58
+ attr_reader :hetzner_client, :cluster_name, :location, :existing_network
45
59
 
46
60
  def network_config
47
61
  {
@@ -56,9 +70,5 @@ module Hetzner
56
70
  ]
57
71
  }
58
72
  end
59
-
60
- def find_network
61
- hetzner_client.get('/networks')['networks'].detect { |network| network['name'] == cluster_name }
62
- end
63
73
  end
64
74
  end
@@ -8,13 +8,19 @@ module Hetzner
8
8
  end
9
9
 
10
10
  def create(location:, instance_type:, instance_id:, firewall_id:, network_id:, ssh_key_id:, placement_group_id:, image:, additional_packages: [], additional_post_create_commands: [])
11
+ @location = location
12
+ @instance_type = instance_type
13
+ @instance_id = instance_id
14
+ @firewall_id = firewall_id
15
+ @network_id = network_id
16
+ @ssh_key_id = ssh_key_id
17
+ @placement_group_id = placement_group_id
18
+ @image = image
11
19
  @additional_packages = additional_packages
12
20
  @additional_post_create_commands = additional_post_create_commands
13
21
 
14
22
  puts
15
23
 
16
- server_name = "#{cluster_name}-#{instance_type}-#{instance_id}"
17
-
18
24
  if (server = find_server(server_name))
19
25
  puts "Server #{server_name} already exists, skipping."
20
26
  puts
@@ -23,44 +29,15 @@ module Hetzner
23
29
 
24
30
  puts "Creating server #{server_name}..."
25
31
 
26
- server_config = {
27
- name: server_name,
28
- location:,
29
- image:,
30
- firewalls: [
31
- { firewall: firewall_id }
32
- ],
33
- networks: [
34
- network_id
35
- ],
36
- server_type: instance_type,
37
- ssh_keys: [
38
- ssh_key_id
39
- ],
40
- user_data:,
41
- labels: {
42
- cluster: cluster_name,
43
- role: (server_name =~ /master/ ? 'master' : 'worker')
44
- },
45
- placement_group: placement_group_id
46
- }
47
-
48
- response = hetzner_client.post('/servers', server_config)
49
- response_body = response.body
50
-
51
- server = JSON.parse(response_body)['server']
32
+ if (server = make_request)
33
+ puts "...server #{server_name} created."
34
+ puts
52
35
 
53
- unless server
36
+ server
37
+ else
54
38
  puts "Error creating server #{server_name}. Response details below:"
55
39
  puts
56
- p response
57
- return
58
40
  end
59
-
60
- puts "...server #{server_name} created."
61
- puts
62
-
63
- server
64
41
  end
65
42
 
66
43
  def delete(server_name:)
@@ -75,7 +52,7 @@ module Hetzner
75
52
 
76
53
  private
77
54
 
78
- attr_reader :hetzner_client, :cluster_name, :additional_packages, :additional_post_create_commands
55
+ attr_reader :hetzner_client, :cluster_name, :location, :instance_type, :instance_id, :firewall_id, :network_id, :ssh_key_id, :placement_group_id, :image, :additional_packages, :additional_post_create_commands
79
56
 
80
57
  def find_server(server_name)
81
58
  hetzner_client.get('/servers?sort=created:desc')['servers'].detect { |network| network['name'] == server_name }
@@ -113,5 +90,40 @@ module Hetzner
113
90
  #{post_create_commands}
114
91
  YAML
115
92
  end
93
+
94
+ def server_name
95
+ @server_name ||= "#{cluster_name}-#{instance_type}-#{instance_id}"
96
+ end
97
+
98
+ def server_config
99
+ @server_config ||= {
100
+ name: server_name,
101
+ location: location,
102
+ image: image,
103
+ firewalls: [
104
+ { firewall: firewall_id }
105
+ ],
106
+ networks: [
107
+ network_id
108
+ ],
109
+ server_type: instance_type,
110
+ ssh_keys: [
111
+ ssh_key_id
112
+ ],
113
+ user_data: user_data,
114
+ labels: {
115
+ cluster: cluster_name,
116
+ role: (server_name =~ /master/ ? 'master' : 'worker')
117
+ },
118
+ placement_group: placement_group_id
119
+ }
120
+ end
121
+
122
+ def make_request
123
+ response = hetzner_client.post('/servers', server_config)
124
+ response_body = response.body
125
+
126
+ JSON.parse(response_body)['server']
127
+ end
116
128
  end
117
129
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'thor'
4
- require 'http'
4
+ require 'openssl'
5
+ require 'httparty'
5
6
  require 'sshkey'
6
7
  require 'ipaddr'
7
8
  require 'open-uri'
8
9
  require 'yaml'
9
10
 
10
11
  require_relative 'cluster'
12
+ require_relative 'configuration'
11
13
  require_relative 'version'
12
14
 
13
15
  module Hetzner
@@ -17,13 +19,6 @@ module Hetzner
17
19
  true
18
20
  end
19
21
 
20
- def initialize(*args)
21
- @errors = []
22
- @used_server_types = []
23
-
24
- super
25
- end
26
-
27
22
  desc 'version', 'Print the version'
28
23
  def version
29
24
  puts Hetzner::K3s::VERSION
@@ -32,15 +27,15 @@ module Hetzner
32
27
  desc 'create-cluster', 'Create a k3s cluster in Hetzner Cloud'
33
28
  option :config_file, required: true
34
29
  def create_cluster
35
- validate_configuration :create
36
- Cluster.new(hetzner_client:, hetzner_token:).create configuration:
30
+ configuration.validate action: :create
31
+ Cluster.new(configuration: configuration).create
37
32
  end
38
33
 
39
34
  desc 'delete-cluster', 'Delete an existing k3s cluster in Hetzner Cloud'
40
35
  option :config_file, required: true
41
36
  def delete_cluster
42
- validate_configuration :delete
43
- Cluster.new(hetzner_client:, hetzner_token:).delete configuration:
37
+ configuration.validate action: :delete
38
+ Cluster.new(configuration: configuration).delete
44
39
  end
45
40
 
46
41
  desc 'upgrade-cluster', 'Upgrade an existing k3s cluster in Hetzner Cloud to a new version'
@@ -48,399 +43,27 @@ module Hetzner
48
43
  option :new_k3s_version, required: true
49
44
  option :force, default: 'false'
50
45
  def upgrade_cluster
51
- validate_configuration :upgrade
52
-
53
- Cluster.new(hetzner_client:, hetzner_token:)
54
- .upgrade(configuration:, new_k3s_version: options[:new_k3s_version], config_file: options[:config_file])
46
+ configuration.validate action: :upgrade
47
+ Cluster.new(configuration: configuration).upgrade(new_k3s_version: options[:new_k3s_version], config_file: options[:config_file])
55
48
  end
56
49
 
57
50
  desc 'releases', 'List available k3s releases'
58
51
  def releases
59
- available_releases.each do |release|
52
+ Hetzner::Configuration.available_releases.each do |release|
60
53
  puts release
61
54
  end
62
55
  end
63
56
 
64
57
  private
65
58
 
66
- attr_reader :configuration, :hetzner_client, :k3s_version
67
- attr_accessor :errors, :used_server_types
68
-
69
- def validate_configuration(action)
70
- validate_configuration_file
71
- validate_token
72
- validate_cluster_name
73
- validate_kubeconfig_path
74
-
75
- case action
76
- when :create
77
- validate_create
78
- when :delete
79
- validate_kubeconfig_path_must_exist
80
- when :upgrade
81
- validate_upgrade
82
- end
83
-
84
- errors.flatten!
85
-
86
- return if errors.empty?
87
-
88
- puts 'Some information in the configuration file requires your attention:'
89
-
90
- errors.each do |error|
91
- puts " - #{error}"
92
- end
93
-
94
- exit 1
95
- end
96
-
97
- def valid_token?
98
- return @valid unless @valid.nil?
99
-
100
- begin
101
- token = hetzner_token
102
- @hetzner_client = Hetzner::Client.new(token:)
103
- response = hetzner_client.get('/locations')
104
- error_code = response.dig('error', 'code')
105
- @valid = error_code&.size != 0
106
- rescue StandardError
107
- @valid = false
108
- end
109
- end
110
-
111
- def validate_token
112
- errors << 'Invalid Hetzner Cloud token' unless valid_token?
113
- end
114
-
115
- def validate_cluster_name
116
- errors << 'Cluster name is an invalid format (only lowercase letters, digits and dashes are allowed)' unless configuration['cluster_name'] =~ /\A[a-z\d-]+\z/
117
-
118
- return if configuration['cluster_name'] =~ /\A[a-z]+.*\z/
119
-
120
- errors << 'Ensure that the cluster name starts with a normal letter'
121
- end
122
-
123
- def validate_kubeconfig_path
124
- path = File.expand_path(configuration['kubeconfig_path'])
125
- errors << 'kubeconfig path cannot be a directory' and return if File.directory? path
126
-
127
- directory = File.dirname(path)
128
- errors << "Directory #{directory} doesn't exist" unless File.exist? directory
129
- rescue StandardError
130
- errors << 'Invalid path for the kubeconfig'
131
- end
132
-
133
- def validate_public_ssh_key
134
- path = File.expand_path(configuration['public_ssh_key_path'])
135
- errors << 'Invalid Public SSH key path' and return unless File.exist? path
136
-
137
- key = File.read(path)
138
- errors << 'Public SSH key is invalid' unless ::SSHKey.valid_ssh_public_key?(key)
139
- rescue StandardError
140
- errors << 'Invalid Public SSH key path'
141
- end
142
-
143
- def validate_private_ssh_key
144
- private_ssh_key_path = configuration['private_ssh_key_path']
145
-
146
- return unless private_ssh_key_path
147
-
148
- path = File.expand_path(private_ssh_key_path)
149
- errors << 'Invalid Private SSH key path' and return unless File.exist?(path)
150
- rescue StandardError
151
- errors << 'Invalid Private SSH key path'
152
- end
153
-
154
- def validate_kubeconfig_path_must_exist
155
- path = File.expand_path configuration['kubeconfig_path']
156
- errors << 'kubeconfig path is invalid' and return unless File.exist? path
157
-
158
- errors << 'kubeconfig path cannot be a directory' if File.directory? path
159
- rescue StandardError
160
- errors << 'Invalid kubeconfig path'
161
- end
162
-
163
- def server_types
164
- return [] unless valid_token?
165
-
166
- @server_types ||= hetzner_client.get('/server_types')['server_types'].map { |server_type| server_type['name'] }
167
- rescue StandardError
168
- @errors << 'Cannot fetch server types with Hetzner API, please try again later'
169
- false
170
- end
171
-
172
- def locations
173
- return [] unless valid_token?
174
-
175
- @locations ||= hetzner_client.get('/locations')['locations'].map { |location| location['name'] }
176
- rescue StandardError
177
- @errors << 'Cannot fetch locations with Hetzner API, please try again later'
178
- []
179
- end
180
-
181
- def valid_location?(location)
182
- return if locations.empty? && !valid_token?
183
-
184
- locations.include? location
185
- end
186
-
187
- def validate_masters_location
188
- return if valid_location?(configuration['location'])
189
-
190
- errors << 'Invalid location for master nodes - valid locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland) or ash (Ashburn, Virginia, USA)'
191
- end
192
-
193
- def available_releases
194
- @available_releases ||= begin
195
- response = HTTP.get('https://api.github.com/repos/k3s-io/k3s/tags?per_page=999').body
196
- JSON.parse(response).map { |hash| hash['name'] }
197
- end
198
- rescue StandardError
199
- errors << 'Cannot fetch the releases with Hetzner API, please try again later'
200
- end
201
-
202
- def validate_k3s_version
203
- k3s_version = configuration['k3s_version']
204
- errors << 'Invalid k3s version' unless available_releases.include? k3s_version
205
- end
206
-
207
- def validate_new_k3s_version
208
- new_k3s_version = options[:new_k3s_version]
209
- errors << 'The new k3s version is invalid' unless available_releases.include? new_k3s_version
210
- end
59
+ attr_reader :hetzner_token, :hetzner_client
211
60
 
212
- def validate_masters
213
- masters_pool = nil
214
-
215
- begin
216
- masters_pool = configuration['masters']
217
- rescue StandardError
218
- errors << 'Invalid masters configuration'
219
- return
220
- end
221
-
222
- if masters_pool.nil?
223
- errors << 'Invalid masters configuration'
224
- return
61
+ def configuration
62
+ @configuration ||= begin
63
+ config = ::Hetzner::Configuration.new(options: options)
64
+ @hetzner_token = config.hetzner_token
65
+ config
225
66
  end
226
-
227
- validate_instance_group masters_pool, workers: false
228
- end
229
-
230
- def validate_worker_node_pools
231
- worker_node_pools = configuration['worker_node_pools'] || []
232
-
233
- unless worker_node_pools.size.positive? || schedule_workloads_on_masters?
234
- errors << 'Invalid node pools configuration'
235
- return
236
- end
237
-
238
- return if worker_node_pools.size.zero? && schedule_workloads_on_masters?
239
-
240
- if !worker_node_pools.is_a? Array
241
- errors << 'Invalid node pools configuration'
242
- elsif worker_node_pools.size.zero?
243
- errors << 'At least one node pool is required in order to schedule workloads' unless schedule_workloads_on_masters?
244
- elsif worker_node_pools.map { |worker_node_pool| worker_node_pool['name'] }.uniq.size != worker_node_pools.size
245
- errors << 'Each node pool must have an unique name'
246
- elsif server_types
247
- worker_node_pools.each do |worker_node_pool|
248
- validate_instance_group worker_node_pool
249
- end
250
- end
251
- end
252
-
253
- def schedule_workloads_on_masters?
254
- schedule_workloads_on_masters = configuration['schedule_workloads_on_masters']
255
- schedule_workloads_on_masters ? !!schedule_workloads_on_masters : false
256
- end
257
-
258
- def validate_instance_group(instance_group, workers: true)
259
- instance_group_errors = []
260
-
261
- instance_group_type = workers ? "Worker mode pool '#{instance_group['name']}'" : 'Masters pool'
262
-
263
- instance_group_errors << "#{instance_group_type} has an invalid name" unless !workers || instance_group['name'] =~ /\A([A-Za-z0-9\-_]+)\Z/
264
-
265
- instance_group_errors << "#{instance_group_type} is in an invalid format" unless instance_group.is_a? Hash
266
-
267
- instance_group_errors << "#{instance_group_type} has an invalid instance type" unless !valid_token? || server_types.include?(instance_group['instance_type'])
268
-
269
- if workers
270
- location = instance_group.fetch('location', configuration['location'])
271
- 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)
272
-
273
- in_network_zone = configuration['location'] == 'ash' ? location == 'ash' : location != 'ash'
274
- 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
275
- end
276
-
277
- if instance_group['instance_count'].is_a? Integer
278
- if instance_group['instance_count'] < 1
279
- instance_group_errors << "#{instance_group_type} must have at least one node"
280
- elsif instance_group['instance_count'] > 10
281
- 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."
282
- elsif !workers
283
- 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?
284
- end
285
- else
286
- instance_group_errors << "#{instance_group_type} has an invalid instance count"
287
- end
288
-
289
- used_server_types << instance_group['instance_type']
290
-
291
- errors << instance_group_errors
292
- end
293
-
294
- def validate_verify_host_key
295
- return unless [true, false].include?(configuration.fetch('public_ssh_key_path', false))
296
-
297
- errors << 'Please set the verify_host_key option to either true or false'
298
- end
299
-
300
- def hetzner_token
301
- @token = ENV.fetch('HCLOUD_TOKEN', nil)
302
- return @token unless @token.nil?
303
-
304
- @token = configuration['hetzner_token']
305
- end
306
-
307
- def validate_ssh_allowed_networks
308
- networks ||= configuration['ssh_allowed_networks']
309
-
310
- if networks.nil? || networks.empty?
311
- errors << 'At least one network/IP range must be specified for SSH access'
312
- return
313
- end
314
-
315
- invalid_networks = networks.reject do |network|
316
- IPAddr.new(network)
317
- rescue StandardError
318
- false
319
- end
320
-
321
- unless invalid_networks.empty?
322
- invalid_networks.each do |network|
323
- errors << "The network #{network} is an invalid range"
324
- end
325
- end
326
-
327
- invalid_ranges = networks.reject do |network|
328
- network.include? '/'
329
- end
330
-
331
- unless invalid_ranges.empty?
332
- invalid_ranges.each do |_network|
333
- errors << 'Please use the CIDR notation for the networks to avoid ambiguity'
334
- end
335
- end
336
-
337
- return unless invalid_networks.empty?
338
-
339
- current_ip = URI.open('http://whatismyip.akamai.com').read
340
-
341
- current_ip_networks = networks.detect do |network|
342
- IPAddr.new(network).include?(current_ip)
343
- rescue StandardError
344
- false
345
- end
346
-
347
- 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
348
- end
349
-
350
- def validate_additional_packages
351
- additional_packages = configuration['additional_packages']
352
- errors << 'Invalid additional packages configuration - it should be an array' if additional_packages && !additional_packages.is_a?(Array)
353
- end
354
-
355
- def validate_post_create_commands
356
- post_create_commands = configuration['post_create_commands']
357
- errors << 'Invalid post create commands configuration - it should be an array' if post_create_commands && !post_create_commands.is_a?(Array)
358
- end
359
-
360
- def validate_create
361
- validate_public_ssh_key
362
- validate_private_ssh_key
363
- validate_ssh_allowed_networks
364
- validate_masters_location
365
- validate_k3s_version
366
- validate_masters
367
- validate_worker_node_pools
368
- validate_verify_host_key
369
- validate_additional_packages
370
- validate_post_create_commands
371
- validate_kube_api_server_args
372
- validate_kube_scheduler_args
373
- validate_kube_controller_manager_args
374
- validate_kube_cloud_controller_manager_args
375
- validate_kubelet_args
376
- validate_kube_proxy_args
377
- end
378
-
379
- def validate_upgrade
380
- validate_kubeconfig_path_must_exist
381
- validate_new_k3s_version
382
- end
383
-
384
- def validate_configuration_file
385
- config_file_path = options[:config_file]
386
-
387
- if File.exist?(config_file_path)
388
- begin
389
- @configuration = YAML.load_file(options[:config_file])
390
- unless configuration.is_a? Hash
391
- puts 'Configuration is invalid'
392
- exit 1
393
- end
394
- rescue StandardError
395
- puts 'Please ensure that the config file is a correct YAML manifest.'
396
- exit 1
397
- end
398
- else
399
- puts 'Please specify a correct path for the config file.'
400
- exit 1
401
- end
402
- end
403
-
404
- def validate_kube_api_server_args
405
- kube_api_server_args = configuration['kube_api_server_args']
406
- return unless kube_api_server_args
407
-
408
- errors << 'kube_api_server_args must be an array of arguments' unless kube_api_server_args.is_a? Array
409
- end
410
-
411
- def validate_kube_scheduler_args
412
- kube_scheduler_args = configuration['kube_scheduler_args']
413
- return unless kube_scheduler_args
414
-
415
- errors << 'kube_scheduler_args must be an array of arguments' unless kube_scheduler_args.is_a? Array
416
- end
417
-
418
- def validate_kube_controller_manager_args
419
- kube_controller_manager_args = configuration['kube_controller_manager_args']
420
- return unless kube_controller_manager_args
421
-
422
- errors << 'kube_controller_manager_args must be an array of arguments' unless kube_controller_manager_args.is_a? Array
423
- end
424
-
425
- def validate_kube_cloud_controller_manager_args
426
- kube_cloud_controller_manager_args = configuration['kube_cloud_controller_manager_args']
427
- return unless kube_cloud_controller_manager_args
428
-
429
- errors << 'kube_cloud_controller_manager_args must be an array of arguments' unless kube_cloud_controller_manager_args.is_a? Array
430
- end
431
-
432
- def validate_kubelet_args
433
- kubelet_args = configuration['kubelet_args']
434
- return unless kubelet_args
435
-
436
- errors << 'kubelet_args must be an array of arguments' unless kubelet_args.is_a? Array
437
- end
438
-
439
- def validate_kube_proxy_args
440
- kube_proxy_args = configuration['kube_proxy_args']
441
- return unless kube_proxy_args
442
-
443
- errors << 'kube_proxy_args must be an array of arguments' unless kube_proxy_args.is_a? Array
444
67
  end
445
68
  end
446
69
  end