hetzner-k3s 0.3.9 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c3b95ba8775783388acc881cbffd928c3fb00d92b6e6a5369b2bbd47f163aae
4
- data.tar.gz: 495ea16d040b3808cb069ef6b03cec04bd7e6dd8f3fe7e623e7e49a1e3dc6eb3
3
+ metadata.gz: 6ee4a4ac2c31ebff805ee20edc3658ffe64be32e50b524ee4af3646e3ffc3a3c
4
+ data.tar.gz: 8cbc33a2a696b19c8e614932d1daa7fa9beddaf9d69dd8377d909cb382e40f87
5
5
  SHA512:
6
- metadata.gz: 01a31eca33e328550f1583ff036c9002f8deb784bb76e0cb1e01df0677be9d678bfae1f619354bdb538ced6ba3a95bb5f03180429536c245e822372b452e82a7
7
- data.tar.gz: aa92fef9440c4e85afe30bbecb86091f44d0cd693c71b44b141432199f1c710488f6b7dfbf0dd59658d8a9d341414c5c75ea5d162cff12b10949f22cd0ef1cda
6
+ metadata.gz: ff2ca466abbd198b3bc76c8854113d90033fb606e9f11152ecf6d079564ee4dcbdab359a5b17229770a7dc531a9674b211d079a2204596efe3ec5b67157bf82e
7
+ data.tar.gz: a6a16c64b0ada5c4d1a740894df09a9f41ed0110b06cfd5629b1971c64836a5fd38bceebb7898432b275cb677779606ecd780b917d4095eb161987d26c0eecc0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- hetzner-k3s (0.3.9)
4
+ hetzner-k3s (0.4.0)
5
5
  bcrypt_pbkdf
6
6
  ed25519
7
7
  http
data/README.md CHANGED
@@ -54,6 +54,8 @@ cluster_name: test
54
54
  kubeconfig_path: "./kubeconfig"
55
55
  k3s_version: v1.21.3+k3s1
56
56
  ssh_key_path: "~/.ssh/id_rsa.pub"
57
+ ssh_allowed_networks:
58
+ - 0.0.0.0/0
57
59
  verify_host_key: false
58
60
  location: nbg1
59
61
  masters:
@@ -72,6 +74,8 @@ It should hopefully be self explanatory; you can run `hetzner-k3s releases` to s
72
74
 
73
75
  If you are using Docker, then set `kubeconfig_path` to `/cluster/kubeconfig` so that the kubeconfig is created in the same directory where your config file is.
74
76
 
77
+ If you don't want to specify the Hetzner token in the config file (for example if you want to use the tool with CI), then you can use the `HCLOUD_TOKEN` environment variable instead, which has predecence.
78
+
75
79
  **Important**: The tool assignes the label `cluster` to each server it creates, with the clsuter name you specify in the config file, as the value. So please ensure you don't create unrelated servers in the same project having
76
80
  the label `cluster=<cluster name>`, because otherwise they will be deleted if you delete the cluster. I recommend you create a separate Hetzner project for each cluster, see note at the end of this README for more details.
77
81
 
@@ -235,6 +239,12 @@ I recommend that you create a separate Hetzner project for each cluster, because
235
239
 
236
240
  ## changelog
237
241
 
242
+ - 0.4.0
243
+ - Ensure the masters are removed from the API load balancer before deleting the load balancer
244
+ - Ensure the servers are removed from the firewall before deleting it
245
+ - Allow using an environment variable to specify the Hetzner token
246
+ - Allow restricting SSH access to the nodes to specific networks
247
+
238
248
  - 0.3.9
239
249
  - Add command "version" to print the version of the tool in use
240
250
 
@@ -5,7 +5,9 @@ module Hetzner
5
5
  @cluster_name = cluster_name
6
6
  end
7
7
 
8
- def create
8
+ def create(ha:, networks:)
9
+ @ha = ha
10
+ @networks = networks
9
11
  puts
10
12
 
11
13
  if firewall = find_firewall
@@ -16,16 +18,21 @@ module Hetzner
16
18
 
17
19
  puts "Creating firewall..."
18
20
 
19
- response = hetzner_client.post("/firewalls", firewall_config).body
21
+ response = hetzner_client.post("/firewalls", create_firewall_config).body
20
22
  puts "...firewall created."
21
23
  puts
22
24
 
23
25
  JSON.parse(response)["firewall"]["id"]
24
26
  end
25
27
 
26
- def delete
28
+ def delete(servers)
27
29
  if firewall = find_firewall
28
30
  puts "Deleting firewall..."
31
+
32
+ servers.each do |server|
33
+ hetzner_client.post("/firewalls/#{firewall["id"]}/actions/remove_from_resources", remove_targets_config(server["id"]))
34
+ end
35
+
29
36
  hetzner_client.delete("/firewalls", firewall["id"])
30
37
  puts "...firewall deleted."
31
38
  else
@@ -37,64 +44,79 @@ module Hetzner
37
44
 
38
45
  private
39
46
 
40
- attr_reader :hetzner_client, :cluster_name, :firewall
47
+ attr_reader :hetzner_client, :cluster_name, :firewall, :ha, :networks
48
+
49
+ def create_firewall_config
50
+ rules = [
51
+ {
52
+ "description": "Allow port 22 (SSH)",
53
+ "direction": "in",
54
+ "protocol": "tcp",
55
+ "port": "22",
56
+ "source_ips": networks,
57
+ "destination_ips": []
58
+ },
59
+ {
60
+ "description": "Allow ICMP (ping)",
61
+ "direction": "in",
62
+ "protocol": "icmp",
63
+ "port": nil,
64
+ "source_ips": [
65
+ "0.0.0.0/0",
66
+ "::/0"
67
+ ],
68
+ "destination_ips": []
69
+ },
70
+ {
71
+ "description": "Allow all TCP traffic between nodes on the private network",
72
+ "direction": "in",
73
+ "protocol": "tcp",
74
+ "port": "any",
75
+ "source_ips": [
76
+ "10.0.0.0/16"
77
+ ],
78
+ "destination_ips": []
79
+ },
80
+ {
81
+ "description": "Allow all UDP traffic between nodes on the private network",
82
+ "direction": "in",
83
+ "protocol": "udp",
84
+ "port": "any",
85
+ "source_ips": [
86
+ "10.0.0.0/16"
87
+ ],
88
+ "destination_ips": []
89
+ }
90
+ ]
91
+
92
+ unless ha
93
+ rules << {
94
+ "description": "Allow port 6443 (Kubernetes API server)",
95
+ "direction": "in",
96
+ "protocol": "tcp",
97
+ "port": "6443",
98
+ "source_ips": [
99
+ "0.0.0.0/0",
100
+ "::/0"
101
+ ],
102
+ "destination_ips": []
103
+ }
104
+ end
41
105
 
42
- def firewall_config
43
106
  {
44
107
  name: cluster_name,
45
- rules: [
46
- {
47
- "description": "Allow port 22 (SSH)",
48
- "direction": "in",
49
- "protocol": "tcp",
50
- "port": "22",
51
- "source_ips": [
52
- "0.0.0.0/0",
53
- "::/0"
54
- ],
55
- "destination_ips": []
56
- },
57
- {
58
- "description": "Allow ICMP (ping)",
59
- "direction": "in",
60
- "protocol": "icmp",
61
- "port": nil,
62
- "source_ips": [
63
- "0.0.0.0/0",
64
- "::/0"
65
- ],
66
- "destination_ips": []
67
- },
68
- {
69
- "description": "Allow port 6443 (Kubernetes API server)",
70
- "direction": "in",
71
- "protocol": "tcp",
72
- "port": "6443",
73
- "source_ips": [
74
- "0.0.0.0/0",
75
- "::/0"
76
- ],
77
- "destination_ips": []
78
- },
79
- {
80
- "description": "Allow all TCP traffic between nodes on the private network",
81
- "direction": "in",
82
- "protocol": "tcp",
83
- "port": "any",
84
- "source_ips": [
85
- "10.0.0.0/16"
86
- ],
87
- "destination_ips": []
88
- },
108
+ rules: rules
109
+ }
110
+ end
111
+
112
+ def remove_targets_config(server_id)
113
+ {
114
+ "remove_from": [
89
115
  {
90
- "description": "Allow all UDP traffic between nodes on the private network",
91
- "direction": "in",
92
- "protocol": "udp",
93
- "port": "any",
94
- "source_ips": [
95
- "10.0.0.0/16"
96
- ],
97
- "destination_ips": []
116
+ "server": {
117
+ "id": server_id
118
+ },
119
+ "type": "server"
98
120
  }
99
121
  ]
100
122
  }
@@ -19,7 +19,7 @@ module Hetzner
19
19
 
20
20
  puts "Creating API load_balancer..."
21
21
 
22
- response = hetzner_client.post("/load_balancers", load_balancer_config).body
22
+ response = hetzner_client.post("/load_balancers", create_load_balancer_config).body
23
23
  puts "...API load balancer created."
24
24
  puts
25
25
 
@@ -29,6 +29,9 @@ module Hetzner
29
29
  def delete(ha:)
30
30
  if load_balancer = find_load_balancer
31
31
  puts "Deleting API load balancer..." unless ha
32
+
33
+ hetzner_client.post("/load_balancers/#{load_balancer["id"]}/actions/remove_target", remove_targets_config)
34
+
32
35
  hetzner_client.delete("/load_balancers", load_balancer["id"])
33
36
  puts "...API load balancer deleted." unless ha
34
37
  elsif ha
@@ -46,7 +49,7 @@ module Hetzner
46
49
  "#{cluster_name}-api"
47
50
  end
48
51
 
49
- def load_balancer_config
52
+ def create_load_balancer_config
50
53
  {
51
54
  "algorithm": {
52
55
  "type": "round_robin"
@@ -76,6 +79,15 @@ module Hetzner
76
79
  }
77
80
  end
78
81
 
82
+ def remove_targets_config
83
+ {
84
+ "label_selector": {
85
+ "selector": "cluster=#{cluster_name},role=master"
86
+ },
87
+ "type": "label_selector"
88
+ }
89
+ end
90
+
79
91
  def find_load_balancer
80
92
  hetzner_client.get("/load_balancers")["load_balancers"].detect{ |load_balancer| load_balancer["name"] == load_balancer_name }
81
93
  end
@@ -1,6 +1,8 @@
1
1
  require "thor"
2
2
  require "http"
3
3
  require "sshkey"
4
+ require 'ipaddr'
5
+ require 'open-uri'
4
6
 
5
7
  require_relative "cluster"
6
8
  require_relative "version"
@@ -23,7 +25,7 @@ module Hetzner
23
25
  def create_cluster
24
26
  validate_config_file :create
25
27
 
26
- Cluster.new(hetzner_client: hetzner_client).create configuration: configuration
28
+ Cluster.new(hetzner_client: hetzner_client, hetzner_token: find_hetzner_token).create configuration: configuration
27
29
  end
28
30
 
29
31
  desc "delete-cluster", "Delete an existing k3s cluster in Hetzner Cloud"
@@ -31,7 +33,7 @@ module Hetzner
31
33
 
32
34
  def delete_cluster
33
35
  validate_config_file :delete
34
- Cluster.new(hetzner_client: hetzner_client).delete configuration: configuration
36
+ Cluster.new(hetzner_client: hetzner_client, hetzner_token: find_hetzner_token).delete configuration: configuration
35
37
  end
36
38
 
37
39
  desc "upgrade-cluster", "Upgrade an existing k3s cluster in Hetzner Cloud to a new version"
@@ -41,7 +43,7 @@ module Hetzner
41
43
 
42
44
  def upgrade_cluster
43
45
  validate_config_file :upgrade
44
- Cluster.new(hetzner_client: hetzner_client).upgrade configuration: configuration, new_k3s_version: options[:new_k3s_version], config_file: options[:config_file]
46
+ Cluster.new(hetzner_client: hetzner_client, hetzner_token: find_hetzner_token).upgrade configuration: configuration, new_k3s_version: options[:new_k3s_version], config_file: options[:config_file]
45
47
  end
46
48
 
47
49
  desc "releases", "List available k3s releases"
@@ -82,6 +84,7 @@ module Hetzner
82
84
  case action
83
85
  when :create
84
86
  validate_ssh_key
87
+ validate_ssh_allowed_networks
85
88
  validate_location
86
89
  validate_k3s_version
87
90
  validate_masters
@@ -107,12 +110,26 @@ module Hetzner
107
110
  end
108
111
  end
109
112
 
113
+ def valid_token?
114
+ return @valid unless @valid.nil?
115
+
116
+ begin
117
+ token = find_hetzner_token
118
+ @hetzner_client = Hetzner::Client.new(token: token)
119
+ response = hetzner_client.get("/locations")
120
+ error_code = response.dig("error", "code")
121
+ @valid = if error_code and error_code.size > 0
122
+ false
123
+ else
124
+ true
125
+ end
126
+ rescue
127
+ @valid = false
128
+ end
129
+ end
130
+
110
131
  def validate_token
111
- token = configuration.dig("hetzner_token")
112
- @hetzner_client = Hetzner::Client.new(token: token)
113
- hetzner_client.get("/locations")
114
- rescue
115
- errors << "Invalid Hetzner Cloid token"
132
+ errors << "Invalid Hetzner Cloud token" unless valid_token?
116
133
  end
117
134
 
118
135
  def validate_cluster_name
@@ -149,6 +166,7 @@ module Hetzner
149
166
  end
150
167
 
151
168
  def server_types
169
+ return [] unless valid_token?
152
170
  @server_types ||= hetzner_client.get("/server_types")["server_types"].map{ |server_type| server_type["name"] }
153
171
  rescue
154
172
  @errors << "Cannot fetch server types with Hetzner API, please try again later"
@@ -156,13 +174,15 @@ module Hetzner
156
174
  end
157
175
 
158
176
  def locations
177
+ return [] unless valid_token?
159
178
  @locations ||= hetzner_client.get("/locations")["locations"].map{ |location| location["name"] }
160
179
  rescue
161
180
  @errors << "Cannot fetch locations with Hetzner API, please try again later"
162
- false
181
+ []
163
182
  end
164
183
 
165
184
  def validate_location
185
+ return if locations.empty? && !valid_token?
166
186
  errors << "Invalid location - available locations: nbg1 (Nuremberg, Germany), fsn1 (Falkenstein, Germany), hel1 (Helsinki, Finland)" unless locations.include? configuration.dig("location")
167
187
  end
168
188
 
@@ -271,7 +291,7 @@ module Hetzner
271
291
  instance_group_errors << "#{instance_group_type} is in an invalid format"
272
292
  end
273
293
 
274
- unless server_types.include?(instance_group["instance_type"])
294
+ unless !valid_token? or server_types.include?(instance_group["instance_type"])
275
295
  instance_group_errors << "#{instance_group_type} has an invalid instance type"
276
296
  end
277
297
 
@@ -296,16 +316,62 @@ module Hetzner
296
316
  config_hash = YAML.load_file(File.expand_path(configuration["kubeconfig_path"]))
297
317
  config_hash['current-context'] = configuration["cluster_name"]
298
318
  @kubernetes_client = K8s::Client.config(K8s::Config.new(config_hash))
299
- rescue
300
319
  errors << "Cannot connect to the Kubernetes cluster"
301
320
  false
302
321
  end
303
322
 
304
-
305
323
  def validate_verify_host_key
306
324
  return unless [true, false].include?(configuration.fetch("ssh_key_path", false))
307
325
  errors << "Please set the verify_host_key option to either true or false"
308
326
  end
327
+
328
+ def find_hetzner_token
329
+ @token = ENV["HCLOUD_TOKEN"]
330
+ return @token if @token
331
+ @token = configuration.dig("hetzner_token")
332
+ end
333
+
334
+ def validate_ssh_allowed_networks
335
+ networks ||= configuration.dig("ssh_allowed_networks")
336
+
337
+ if networks.nil? or networks.empty?
338
+ errors << "At least one network/IP range must be specified for SSH access"
339
+ return
340
+ end
341
+
342
+ invalid_networks = networks.reject do |network|
343
+ IPAddr.new(network) rescue false
344
+ end
345
+
346
+ unless invalid_networks.empty?
347
+ invalid_networks.each do |network|
348
+ errors << "The network #{network} is an invalid range"
349
+ end
350
+ end
351
+
352
+ invalid_ranges = networks.reject do |network|
353
+ network.include? "/"
354
+ end
355
+
356
+ unless invalid_ranges.empty?
357
+ invalid_ranges.each do |network|
358
+ errors << "Please use the CIDR notation for the networks to avoid ambiguity"
359
+ end
360
+ end
361
+
362
+ return unless invalid_networks.empty?
363
+
364
+ current_ip = URI.open('http://whatismyip.akamai.com').read
365
+
366
+ current_ip_networks = networks.detect do |network|
367
+ IPAddr.new(network).include?(current_ip) rescue false
368
+ end
369
+
370
+ unless current_ip_networks
371
+ 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"
372
+ end
373
+ end
374
+
309
375
  end
310
376
  end
311
377
  end
@@ -16,12 +16,12 @@ require_relative "../k3s/client_patch"
16
16
 
17
17
 
18
18
  class Cluster
19
- def initialize(hetzner_client:)
19
+ def initialize(hetzner_client:, hetzner_token:)
20
20
  @hetzner_client = hetzner_client
21
+ @hetzner_token = hetzner_token
21
22
  end
22
23
 
23
24
  def create(configuration:)
24
- @hetzner_token = configuration.dig("hetzner_token")
25
25
  @cluster_name = configuration.dig("cluster_name")
26
26
  @kubeconfig_path = File.expand_path(configuration.dig("kubeconfig_path"))
27
27
  @ssh_key_path = File.expand_path(configuration.dig("ssh_key_path"))
@@ -31,6 +31,7 @@ class Cluster
31
31
  @location = configuration.dig("location")
32
32
  @verify_host_key = configuration.fetch("verify_host_key", false)
33
33
  @servers = []
34
+ @networks = configuration.dig("ssh_allowed_networks")
34
35
 
35
36
  create_resources
36
37
 
@@ -69,7 +70,7 @@ class Cluster
69
70
  :masters_config, :worker_node_pools,
70
71
  :location, :ssh_key_path, :kubernetes_client,
71
72
  :hetzner_token, :tls_sans, :new_k3s_version, :configuration,
72
- :config_file, :verify_host_key
73
+ :config_file, :verify_host_key, :networks
73
74
 
74
75
 
75
76
  def latest_k3s_version
@@ -78,10 +79,13 @@ class Cluster
78
79
  end
79
80
 
80
81
  def create_resources
82
+ master_instance_type = masters_config["instance_type"]
83
+ masters_count = masters_config["instance_count"]
84
+
81
85
  firewall_id = Hetzner::Firewall.new(
82
86
  hetzner_client: hetzner_client,
83
87
  cluster_name: cluster_name
84
- ).create
88
+ ).create(ha: (masters_count > 1), networks: networks)
85
89
 
86
90
  network_id = Hetzner::Network.new(
87
91
  hetzner_client: hetzner_client,
@@ -95,9 +99,6 @@ class Cluster
95
99
 
96
100
  server_configs = []
97
101
 
98
- master_instance_type = masters_config["instance_type"]
99
- masters_count = masters_config["instance_count"]
100
-
101
102
  masters_count.times do |i|
102
103
  server_configs << {
103
104
  location: location,
@@ -150,42 +151,15 @@ class Cluster
150
151
  end
151
152
 
152
153
  def delete_resources
153
- # Deleting nodes defined according to Kubernetes first
154
- begin
155
- Timeout::timeout(5) do
156
- servers = kubernetes_client.api("v1").resource("nodes").list
157
-
158
- threads = servers.map do |node|
159
- Thread.new do
160
- Hetzner::Server.new(hetzner_client: hetzner_client, cluster_name: cluster_name).delete(server_name: node.metadata[:name])
161
- end
162
- end
163
-
164
- threads.each(&:join) unless threads.empty?
165
- end
166
- rescue Timeout::Error, Excon::Error::Socket
167
- puts "Unable to fetch nodes from Kubernetes API. Is the cluster online?"
168
- end
169
-
170
- # Deleting nodes defined in the config file just in case there are leftovers i.e. nodes that
171
- # were not part of the cluster for some reason
172
-
173
- threads = all_servers.map do |server|
174
- Thread.new do
175
- Hetzner::Server.new(hetzner_client: hetzner_client, cluster_name: cluster_name).delete(server_name: server["name"])
176
- end
177
- end
178
-
179
- threads.each(&:join) unless threads.empty?
180
-
181
- puts
182
-
183
- sleep 5 # give time for the servers to actually be deleted
154
+ Hetzner::LoadBalancer.new(
155
+ hetzner_client: hetzner_client,
156
+ cluster_name: cluster_name
157
+ ).delete(ha: (masters.size > 1))
184
158
 
185
159
  Hetzner::Firewall.new(
186
160
  hetzner_client: hetzner_client,
187
161
  cluster_name: cluster_name
188
- ).delete
162
+ ).delete(all_servers)
189
163
 
190
164
  Hetzner::Network.new(
191
165
  hetzner_client: hetzner_client,
@@ -197,11 +171,13 @@ class Cluster
197
171
  cluster_name: cluster_name
198
172
  ).delete(ssh_key_path: ssh_key_path)
199
173
 
200
- Hetzner::LoadBalancer.new(
201
- hetzner_client: hetzner_client,
202
- cluster_name: cluster_name
203
- ).delete(ha: (masters.size > 1))
174
+ threads = all_servers.map do |server|
175
+ Thread.new do
176
+ Hetzner::Server.new(hetzner_client: hetzner_client, cluster_name: cluster_name).delete(server_name: server["name"])
177
+ end
178
+ end
204
179
 
180
+ threads.each(&:join) unless threads.empty?
205
181
  end
206
182
 
207
183
  def upgrade_cluster
@@ -249,6 +225,7 @@ class Cluster
249
225
  --kube-scheduler-arg="bind-address=0.0.0.0" \
250
226
  --node-taint CriticalAddonsOnly=true:NoExecute \
251
227
  --kubelet-arg="cloud-provider=external" \
228
+ --advertise-address=$(hostname -I | awk '{print $2}') \
252
229
  --node-ip=$(hostname -I | awk '{print $2}') \
253
230
  --node-external-ip=$(hostname -I | awk '{print $1}') \
254
231
  --flannel-iface=#{flannel_interface} \
@@ -1,5 +1,5 @@
1
1
  module Hetzner
2
2
  module K3s
3
- VERSION = "0.3.9"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  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.3.9
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vito Botta
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-08-20 00:00:00.000000000 Z
11
+ date: 2021-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor