hetzner-k3s 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|