hetzner-k3s 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/hetzner/k3s/cli'
4
+ Hetzner::K3s::CLI.start
@@ -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,2 @@
1
+ module Hetzner
2
+ end
@@ -0,0 +1,2 @@
1
+ module Hetzner::Infra
2
+ end
@@ -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