hetzner-k3s 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +111 -0
- data/LICENSE.txt +21 -0
- data/README.md +221 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/cluster_config.yaml.example +17 -0
- data/exe/hetzner-k3s +4 -0
- data/hetzner-k3s.gemspec +35 -0
- data/lib/hetzner.rb +2 -0
- data/lib/hetzner/infra.rb +2 -0
- data/lib/hetzner/infra/client.rb +32 -0
- data/lib/hetzner/infra/firewall.rb +103 -0
- data/lib/hetzner/infra/load_balancer.rb +84 -0
- data/lib/hetzner/infra/network.rb +62 -0
- data/lib/hetzner/infra/server.rb +81 -0
- data/lib/hetzner/infra/ssh_key.rb +57 -0
- data/lib/hetzner/k3s/cli.rb +303 -0
- data/lib/hetzner/k3s/client_patch.rb +38 -0
- data/lib/hetzner/k3s/cluster.rb +609 -0
- data/lib/hetzner/k3s/version.rb +5 -0
- metadata +145 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "k3s"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
---
|
2
|
+
hetzner_token: blah
|
3
|
+
cluster_name: test
|
4
|
+
kubeconfig_path: "../kubeconfig"
|
5
|
+
k3s_version: v1.21.3+k3s1
|
6
|
+
ssh_key_path: "~/.ssh/id_rsa.pub"
|
7
|
+
location: nbg1
|
8
|
+
masters:
|
9
|
+
instance_type: cpx21
|
10
|
+
instance_count: 3
|
11
|
+
worker_node_pools:
|
12
|
+
- name: small
|
13
|
+
instance_type: cpx21
|
14
|
+
instance_count: 4
|
15
|
+
- name: big
|
16
|
+
instance_type: cp321
|
17
|
+
instance_count: 2
|
data/exe/hetzner-k3s
ADDED
data/hetzner-k3s.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
require_relative 'lib/hetzner/k3s/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = "hetzner-k3s"
|
5
|
+
spec.version = Hetzner::K3s::VERSION
|
6
|
+
spec.authors = ["Vito Botta"]
|
7
|
+
spec.email = ["vito@botta.me"]
|
8
|
+
|
9
|
+
spec.summary = %q{A CLI to create a Kubernetes cluster in Hetzner Cloud very quickly using k3s.}
|
10
|
+
spec.description = %q{A CLI to create a Kubernetes cluster in Hetzner Cloud very quickly using k3s.}
|
11
|
+
spec.homepage = "https://github.com/vitobotta/hetzner-k3s"
|
12
|
+
spec.license = "MIT"
|
13
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
|
14
|
+
|
15
|
+
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/vitobotta/hetzner-k3s"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/vitobotta/hetzner-k3s"
|
20
|
+
|
21
|
+
spec.add_dependency "thor"
|
22
|
+
spec.add_dependency "http"
|
23
|
+
spec.add_dependency "net-ssh"
|
24
|
+
spec.add_dependency "k8s-ruby"
|
25
|
+
spec.add_dependency "sshkey"
|
26
|
+
|
27
|
+
# Specify which files should be added to the gem when it is released.
|
28
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
29
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
30
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
31
|
+
end
|
32
|
+
spec.bindir = "exe"
|
33
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
34
|
+
spec.require_paths = ["lib"]
|
35
|
+
end
|
data/lib/hetzner.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
module Hetzner
|
2
|
+
class Client
|
3
|
+
BASE_URI = "https://api.hetzner.cloud/v1"
|
4
|
+
|
5
|
+
attr_reader :token
|
6
|
+
|
7
|
+
def initialize(token:)
|
8
|
+
@token = token
|
9
|
+
end
|
10
|
+
|
11
|
+
def get(path)
|
12
|
+
JSON.parse HTTP.headers(headers).get(BASE_URI + path).body
|
13
|
+
end
|
14
|
+
|
15
|
+
def post(path, data)
|
16
|
+
HTTP.headers(headers).post(BASE_URI + path, json: data)
|
17
|
+
end
|
18
|
+
|
19
|
+
def delete(path, id)
|
20
|
+
HTTP.headers(headers).delete(BASE_URI + path + "/" + id.to_s)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def headers
|
26
|
+
{
|
27
|
+
"Authorization": "Bearer #{@token}",
|
28
|
+
"Content-Type": "application/json"
|
29
|
+
}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module Hetzner
|
2
|
+
class Firewall
|
3
|
+
def initialize(hetzner_client:, cluster_name:)
|
4
|
+
@hetzner_client = hetzner_client
|
5
|
+
@cluster_name = cluster_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def create
|
9
|
+
puts
|
10
|
+
|
11
|
+
if firewall = find_firewall
|
12
|
+
puts "Firewall already exists, skipping."
|
13
|
+
puts
|
14
|
+
return firewall["id"]
|
15
|
+
end
|
16
|
+
|
17
|
+
puts "Creating firewall..."
|
18
|
+
|
19
|
+
response = hetzner_client.post("/firewalls", firewall_config).body
|
20
|
+
puts "...firewall created."
|
21
|
+
puts
|
22
|
+
|
23
|
+
JSON.parse(response)["firewall"]["id"]
|
24
|
+
end
|
25
|
+
|
26
|
+
def delete
|
27
|
+
if firewall = find_firewall
|
28
|
+
puts "Deleting firewall..."
|
29
|
+
hetzner_client.delete("/firewalls", firewall["id"])
|
30
|
+
puts "...firewall deleted."
|
31
|
+
else
|
32
|
+
puts "Firewall no longer exists, skipping."
|
33
|
+
end
|
34
|
+
|
35
|
+
puts
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
attr_reader :hetzner_client, :cluster_name, :firewall
|
41
|
+
|
42
|
+
def firewall_config
|
43
|
+
{
|
44
|
+
name: cluster_name,
|
45
|
+
rules: [
|
46
|
+
{
|
47
|
+
"direction": "in",
|
48
|
+
"protocol": "tcp",
|
49
|
+
"port": "22",
|
50
|
+
"source_ips": [
|
51
|
+
"0.0.0.0/0",
|
52
|
+
"::/0"
|
53
|
+
],
|
54
|
+
"destination_ips": []
|
55
|
+
},
|
56
|
+
{
|
57
|
+
"direction": "in",
|
58
|
+
"protocol": "icmp",
|
59
|
+
"port": nil,
|
60
|
+
"source_ips": [
|
61
|
+
"0.0.0.0/0",
|
62
|
+
"::/0"
|
63
|
+
],
|
64
|
+
"destination_ips": []
|
65
|
+
},
|
66
|
+
{
|
67
|
+
"direction": "in",
|
68
|
+
"protocol": "tcp",
|
69
|
+
"port": "6443",
|
70
|
+
"source_ips": [
|
71
|
+
"0.0.0.0/0",
|
72
|
+
"::/0"
|
73
|
+
],
|
74
|
+
"destination_ips": []
|
75
|
+
},
|
76
|
+
{
|
77
|
+
"direction": "in",
|
78
|
+
"protocol": "tcp",
|
79
|
+
"port": "any",
|
80
|
+
"source_ips": [
|
81
|
+
"10.0.0.0/16"
|
82
|
+
],
|
83
|
+
"destination_ips": []
|
84
|
+
},
|
85
|
+
{
|
86
|
+
"direction": "in",
|
87
|
+
"protocol": "udp",
|
88
|
+
"port": "any",
|
89
|
+
"source_ips": [
|
90
|
+
"10.0.0.0/16"
|
91
|
+
],
|
92
|
+
"destination_ips": []
|
93
|
+
}
|
94
|
+
]
|
95
|
+
}
|
96
|
+
end
|
97
|
+
|
98
|
+
def find_firewall
|
99
|
+
hetzner_client.get("/firewalls")["firewalls"].detect{ |firewall| firewall["name"] == cluster_name }
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Hetzner
|
2
|
+
class LoadBalancer
|
3
|
+
def initialize(hetzner_client:, cluster_name:)
|
4
|
+
@hetzner_client = hetzner_client
|
5
|
+
@cluster_name = cluster_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def create(location:, network_id:)
|
9
|
+
@location = location
|
10
|
+
@network_id = network_id
|
11
|
+
|
12
|
+
puts
|
13
|
+
|
14
|
+
if load_balancer = find_load_balancer
|
15
|
+
puts "API load balancer already exists, skipping."
|
16
|
+
puts
|
17
|
+
return load_balancer["id"]
|
18
|
+
end
|
19
|
+
|
20
|
+
puts "Creating API load_balancer..."
|
21
|
+
|
22
|
+
response = hetzner_client.post("/load_balancers", load_balancer_config).body
|
23
|
+
puts "...API load balancer created."
|
24
|
+
puts
|
25
|
+
|
26
|
+
JSON.parse(response)["load_balancer"]["id"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete
|
30
|
+
if load_balancer = find_load_balancer
|
31
|
+
puts "Deleting API load balancer..."
|
32
|
+
hetzner_client.delete("/load_balancers", load_balancer["id"])
|
33
|
+
puts "...API load balancer deleted."
|
34
|
+
else
|
35
|
+
puts "API load balancer no longer exists, skipping."
|
36
|
+
end
|
37
|
+
|
38
|
+
puts
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_reader :hetzner_client, :cluster_name, :load_balancer, :location, :network_id
|
44
|
+
|
45
|
+
def load_balancer_name
|
46
|
+
"#{cluster_name}-api"
|
47
|
+
end
|
48
|
+
|
49
|
+
def load_balancer_config
|
50
|
+
{
|
51
|
+
"algorithm": {
|
52
|
+
"type": "round_robin"
|
53
|
+
},
|
54
|
+
"load_balancer_type": "lb11",
|
55
|
+
"location": location,
|
56
|
+
"name": load_balancer_name,
|
57
|
+
"network": network_id,
|
58
|
+
"public_interface": true,
|
59
|
+
"services": [
|
60
|
+
{
|
61
|
+
"destination_port": 6443,
|
62
|
+
"listen_port": 6443,
|
63
|
+
"protocol": "tcp",
|
64
|
+
"proxyprotocol": false
|
65
|
+
}
|
66
|
+
],
|
67
|
+
"targets": [
|
68
|
+
{
|
69
|
+
"label_selector": {
|
70
|
+
"selector": "cluster=#{cluster_name},role=master"
|
71
|
+
},
|
72
|
+
"type": "label_selector",
|
73
|
+
"use_private_ip": true
|
74
|
+
}
|
75
|
+
]
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
def find_load_balancer
|
80
|
+
hetzner_client.get("/load_balancers")["load_balancers"].detect{ |load_balancer| load_balancer["name"] == load_balancer_name }
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Hetzner
|
2
|
+
class Network
|
3
|
+
def initialize(hetzner_client:, cluster_name:)
|
4
|
+
@hetzner_client = hetzner_client
|
5
|
+
@cluster_name = cluster_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def create
|
9
|
+
puts
|
10
|
+
|
11
|
+
if network = find_network
|
12
|
+
puts "Private network already exists, skipping."
|
13
|
+
puts
|
14
|
+
return network["id"]
|
15
|
+
end
|
16
|
+
|
17
|
+
puts "Creating private network..."
|
18
|
+
|
19
|
+
response = hetzner_client.post("/networks", network_config).body
|
20
|
+
|
21
|
+
puts "...private network created."
|
22
|
+
puts
|
23
|
+
|
24
|
+
JSON.parse(response)["network"]["id"]
|
25
|
+
end
|
26
|
+
|
27
|
+
def delete
|
28
|
+
if network = find_network
|
29
|
+
puts "Deleting network..."
|
30
|
+
hetzner_client.delete("/networks", network["id"])
|
31
|
+
puts "...network deleted."
|
32
|
+
else
|
33
|
+
puts "Network no longer exists, skipping."
|
34
|
+
end
|
35
|
+
|
36
|
+
puts
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :hetzner_client, :cluster_name
|
42
|
+
|
43
|
+
def network_config
|
44
|
+
{
|
45
|
+
name: cluster_name,
|
46
|
+
ip_range: "10.0.0.0/16",
|
47
|
+
subnets: [
|
48
|
+
{
|
49
|
+
ip_range: "10.0.0.0/16",
|
50
|
+
network_zone: "eu-central",
|
51
|
+
type: "cloud"
|
52
|
+
}
|
53
|
+
]
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
def find_network
|
58
|
+
hetzner_client.get("/networks")["networks"].detect{ |network| network["name"] == cluster_name }
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module Hetzner
|
2
|
+
class Server
|
3
|
+
def initialize(hetzner_client:, cluster_name:)
|
4
|
+
@hetzner_client = hetzner_client
|
5
|
+
@cluster_name = cluster_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def create(location:, instance_type:, instance_id:, firewall_id:, network_id:, ssh_key_id:)
|
9
|
+
puts
|
10
|
+
|
11
|
+
server_name = "#{cluster_name}-#{instance_type}-#{instance_id}"
|
12
|
+
|
13
|
+
if server = find_server(server_name)
|
14
|
+
puts "Server #{server_name} already exists, skipping."
|
15
|
+
puts
|
16
|
+
return server
|
17
|
+
end
|
18
|
+
|
19
|
+
puts "Creating server #{server_name}..."
|
20
|
+
|
21
|
+
server_config = {
|
22
|
+
name: server_name,
|
23
|
+
location: location,
|
24
|
+
image: "ubuntu-20.04",
|
25
|
+
firewalls: [
|
26
|
+
{ firewall: firewall_id }
|
27
|
+
],
|
28
|
+
networks: [
|
29
|
+
network_id
|
30
|
+
],
|
31
|
+
server_type: instance_type,
|
32
|
+
ssh_keys: [
|
33
|
+
ssh_key_id
|
34
|
+
],
|
35
|
+
user_data: user_data,
|
36
|
+
labels: {
|
37
|
+
cluster: cluster_name,
|
38
|
+
role: (server_name =~ /master/ ? "master" : "worker")
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
response = hetzner_client.post("/servers", server_config).body
|
43
|
+
|
44
|
+
puts "...server #{server_name} created."
|
45
|
+
puts
|
46
|
+
|
47
|
+
JSON.parse(response)["server"]
|
48
|
+
end
|
49
|
+
|
50
|
+
def delete(server_name:)
|
51
|
+
if server = find_server(server_name)
|
52
|
+
puts "Deleting server #{server_name}..."
|
53
|
+
hetzner_client.delete "/servers", server["id"]
|
54
|
+
puts "...server #{server_name} deleted."
|
55
|
+
else
|
56
|
+
puts "Server #{server_name} no longer exists, skipping."
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
attr_reader :hetzner_client, :cluster_name
|
63
|
+
|
64
|
+
def find_server(server_name)
|
65
|
+
hetzner_client.get("/servers")["servers"].detect{ |network| network["name"] == server_name }
|
66
|
+
end
|
67
|
+
|
68
|
+
def user_data
|
69
|
+
<<~EOS
|
70
|
+
#cloud-config
|
71
|
+
packages:
|
72
|
+
- fail2ban
|
73
|
+
runcmd:
|
74
|
+
- sed -i 's/[#]*PermitRootLogin yes/PermitRootLogin prohibit-password/g' /etc/ssh/sshd_config
|
75
|
+
- sed -i 's/[#]*PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config
|
76
|
+
- systemctl restart sshd
|
77
|
+
EOS
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|