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.
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