nvoi 0.1.5 → 0.1.6
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 +4 -4
- data/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +12 -2
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli.rb +83 -142
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +44 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +70 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +425 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +376 -0
- data/lib/nvoi/external/cloud/scaleway.rb +533 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +82 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +292 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +463 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +10 -54
- data/templates/error-backend.yaml.erb +134 -0
- metadata +97 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- data/lib/nvoi/steps/volume_provisioner.rb +0 -154
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "hetzner_client"
|
|
4
|
-
|
|
5
|
-
module Nvoi
|
|
6
|
-
module Providers
|
|
7
|
-
# Hetzner provider implements the compute provider interface for Hetzner Cloud
|
|
8
|
-
class Hetzner < Base
|
|
9
|
-
def initialize(token)
|
|
10
|
-
@client = HetznerClient.new(token)
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Network operations
|
|
14
|
-
|
|
15
|
-
def find_or_create_network(name)
|
|
16
|
-
network = find_network_by_name(name)
|
|
17
|
-
return to_network(network) if network
|
|
18
|
-
|
|
19
|
-
network = @client.create_network(
|
|
20
|
-
name:,
|
|
21
|
-
ip_range: Constants::NETWORK_CIDR,
|
|
22
|
-
subnets: [{
|
|
23
|
-
type: "cloud",
|
|
24
|
-
ip_range: Constants::SUBNET_CIDR,
|
|
25
|
-
network_zone: "eu-central"
|
|
26
|
-
}]
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
to_network(network)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def get_network_by_name(name)
|
|
33
|
-
network = find_network_by_name(name)
|
|
34
|
-
raise NetworkError, "network not found: #{name}" unless network
|
|
35
|
-
|
|
36
|
-
to_network(network)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def delete_network(id)
|
|
40
|
-
@client.delete_network(id.to_i)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Firewall operations
|
|
44
|
-
|
|
45
|
-
def find_or_create_firewall(name)
|
|
46
|
-
firewall = find_firewall_by_name(name)
|
|
47
|
-
return to_firewall(firewall) if firewall
|
|
48
|
-
|
|
49
|
-
firewall = @client.create_firewall(
|
|
50
|
-
name:,
|
|
51
|
-
rules: [{
|
|
52
|
-
direction: "in",
|
|
53
|
-
protocol: "tcp",
|
|
54
|
-
port: "22",
|
|
55
|
-
source_ips: ["0.0.0.0/0", "::/0"]
|
|
56
|
-
}]
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
to_firewall(firewall)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def get_firewall_by_name(name)
|
|
63
|
-
firewall = find_firewall_by_name(name)
|
|
64
|
-
raise FirewallError, "firewall not found: #{name}" unless firewall
|
|
65
|
-
|
|
66
|
-
to_firewall(firewall)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def delete_firewall(id)
|
|
70
|
-
@client.delete_firewall(id.to_i)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Server operations
|
|
74
|
-
|
|
75
|
-
def find_server(name)
|
|
76
|
-
server = find_server_by_name(name)
|
|
77
|
-
return nil unless server
|
|
78
|
-
|
|
79
|
-
to_server(server)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def list_servers
|
|
83
|
-
@client.list_servers.map { |s| to_server(s) }
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def create_server(opts)
|
|
87
|
-
# Resolve IDs
|
|
88
|
-
server_type = find_server_type(opts.type)
|
|
89
|
-
raise ValidationError, "invalid server type: #{opts.type}" unless server_type
|
|
90
|
-
|
|
91
|
-
image = find_image(opts.image)
|
|
92
|
-
raise ValidationError, "invalid image: #{opts.image}" unless image
|
|
93
|
-
|
|
94
|
-
location = find_location(opts.location)
|
|
95
|
-
raise ValidationError, "invalid location: #{opts.location}" unless location
|
|
96
|
-
|
|
97
|
-
create_opts = {
|
|
98
|
-
name: opts.name,
|
|
99
|
-
server_type: server_type["name"],
|
|
100
|
-
image: image["name"],
|
|
101
|
-
location: location["name"],
|
|
102
|
-
user_data: opts.user_data,
|
|
103
|
-
start_after_create: true
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
# Add network if provided
|
|
107
|
-
if opts.network_id && !opts.network_id.empty?
|
|
108
|
-
create_opts[:networks] = [opts.network_id.to_i]
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# Add firewall if provided
|
|
112
|
-
if opts.firewall_id && !opts.firewall_id.empty?
|
|
113
|
-
create_opts[:firewalls] = [{ firewall: opts.firewall_id.to_i }]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
server = @client.create_server(create_opts)
|
|
117
|
-
to_server(server)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def wait_for_server(server_id, max_attempts)
|
|
121
|
-
max_attempts.times do
|
|
122
|
-
server = @client.get_server(server_id.to_i)
|
|
123
|
-
|
|
124
|
-
if server["status"] == "running"
|
|
125
|
-
return to_server(server)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
sleep(Constants::SERVER_READY_INTERVAL)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
raise ServerCreationError, "server did not become running after #{max_attempts} attempts"
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def delete_server(id)
|
|
135
|
-
server = @client.get_server(id.to_i)
|
|
136
|
-
|
|
137
|
-
# Remove from firewalls
|
|
138
|
-
@client.list_firewalls.each do |fw|
|
|
139
|
-
fw["applied_to"]&.each do |applied|
|
|
140
|
-
next unless applied["type"] == "server" && applied.dig("server", "id") == id.to_i
|
|
141
|
-
|
|
142
|
-
@client.remove_firewall_from_server(fw["id"], id.to_i)
|
|
143
|
-
rescue StandardError
|
|
144
|
-
# Ignore cleanup errors
|
|
145
|
-
end
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Detach from networks
|
|
149
|
-
server["private_net"]&.each do |pn|
|
|
150
|
-
@client.detach_server_from_network(id.to_i, pn["network"])
|
|
151
|
-
rescue StandardError
|
|
152
|
-
# Ignore cleanup errors
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
@client.delete_server(id.to_i)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# Volume operations
|
|
159
|
-
|
|
160
|
-
def create_volume(opts)
|
|
161
|
-
server = @client.get_server(opts.server_id.to_i)
|
|
162
|
-
raise VolumeError, "server not found: #{opts.server_id}" unless server
|
|
163
|
-
|
|
164
|
-
volume = @client.create_volume(
|
|
165
|
-
name: opts.name,
|
|
166
|
-
size: opts.size,
|
|
167
|
-
location: server.dig("datacenter", "location", "name"),
|
|
168
|
-
format: "xfs"
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
to_volume(volume)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def get_volume(id)
|
|
175
|
-
volume = @client.get_volume(id.to_i)
|
|
176
|
-
return nil unless volume
|
|
177
|
-
|
|
178
|
-
to_volume(volume)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def get_volume_by_name(name)
|
|
182
|
-
volume = @client.list_volumes.find { |v| v["name"] == name }
|
|
183
|
-
return nil unless volume
|
|
184
|
-
|
|
185
|
-
to_volume(volume)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
def delete_volume(id)
|
|
189
|
-
@client.delete_volume(id.to_i)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def attach_volume(volume_id, server_id)
|
|
193
|
-
@client.attach_volume(volume_id.to_i, server_id.to_i)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def detach_volume(volume_id)
|
|
197
|
-
@client.detach_volume(volume_id.to_i)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Validation operations
|
|
201
|
-
|
|
202
|
-
def validate_instance_type(instance_type)
|
|
203
|
-
server_type = find_server_type(instance_type)
|
|
204
|
-
raise ValidationError, "invalid hetzner server type: #{instance_type}" unless server_type
|
|
205
|
-
|
|
206
|
-
true
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def validate_region(region)
|
|
210
|
-
location = find_location(region)
|
|
211
|
-
raise ValidationError, "invalid hetzner location: #{region}" unless location
|
|
212
|
-
|
|
213
|
-
true
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def validate_credentials
|
|
217
|
-
@client.list_server_types
|
|
218
|
-
true
|
|
219
|
-
rescue AuthenticationError => e
|
|
220
|
-
raise ValidationError, "hetzner credentials invalid: #{e.message}"
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
private
|
|
224
|
-
|
|
225
|
-
def find_network_by_name(name)
|
|
226
|
-
@client.list_networks.find { |n| n["name"] == name }
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def find_firewall_by_name(name)
|
|
230
|
-
@client.list_firewalls.find { |f| f["name"] == name }
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def find_server_by_name(name)
|
|
234
|
-
@client.list_servers.find { |s| s["name"] == name }
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def find_server_type(name)
|
|
238
|
-
@client.list_server_types.find { |t| t["name"] == name }
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def find_image(name)
|
|
242
|
-
# Images endpoint requires filtering
|
|
243
|
-
response = @client.get("/images?name=#{name}")
|
|
244
|
-
response["images"]&.first
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def find_location(name)
|
|
248
|
-
@client.list_locations.find { |l| l["name"] == name }
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
def to_network(data)
|
|
252
|
-
Network.new(
|
|
253
|
-
id: data["id"].to_s,
|
|
254
|
-
name: data["name"],
|
|
255
|
-
ip_range: data["ip_range"]
|
|
256
|
-
)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def to_firewall(data)
|
|
260
|
-
Firewall.new(
|
|
261
|
-
id: data["id"].to_s,
|
|
262
|
-
name: data["name"]
|
|
263
|
-
)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def to_server(data)
|
|
267
|
-
Server.new(
|
|
268
|
-
id: data["id"].to_s,
|
|
269
|
-
name: data["name"],
|
|
270
|
-
status: data["status"],
|
|
271
|
-
public_ipv4: data.dig("public_net", "ipv4", "ip")
|
|
272
|
-
)
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def to_volume(data)
|
|
276
|
-
Volume.new(
|
|
277
|
-
id: data["id"].to_s,
|
|
278
|
-
name: data["name"],
|
|
279
|
-
size: data["size"],
|
|
280
|
-
location: data.dig("location", "name"),
|
|
281
|
-
status: data["status"],
|
|
282
|
-
server_id: data["server"]&.to_s,
|
|
283
|
-
device_path: data["linux_device"]
|
|
284
|
-
)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
end
|
|
288
|
-
end
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "faraday"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Nvoi
|
|
7
|
-
module Providers
|
|
8
|
-
# Raw HTTP client for Hetzner Cloud API
|
|
9
|
-
class HetznerClient
|
|
10
|
-
BASE_URL = "https://api.hetzner.cloud/v1"
|
|
11
|
-
|
|
12
|
-
def initialize(token)
|
|
13
|
-
@token = token
|
|
14
|
-
@conn = Faraday.new do |f|
|
|
15
|
-
f.request :json
|
|
16
|
-
f.response :json
|
|
17
|
-
f.headers["Authorization"] = "Bearer #{token}"
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def get(path)
|
|
22
|
-
response = @conn.get("#{BASE_URL}#{path}")
|
|
23
|
-
handle_response(response)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def post(path, payload = {})
|
|
27
|
-
response = @conn.post("#{BASE_URL}#{path}", payload)
|
|
28
|
-
handle_response(response)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def delete(path)
|
|
32
|
-
response = @conn.delete("#{BASE_URL}#{path}")
|
|
33
|
-
return nil if response.status == 204
|
|
34
|
-
handle_response(response)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Server types
|
|
38
|
-
def list_server_types
|
|
39
|
-
get("/server_types")["server_types"]
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Locations
|
|
43
|
-
def list_locations
|
|
44
|
-
get("/locations")["locations"]
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Servers
|
|
48
|
-
def list_servers
|
|
49
|
-
get("/servers")["servers"]
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def get_server(id)
|
|
53
|
-
get("/servers/#{id}")["server"]
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def create_server(payload)
|
|
57
|
-
post("/servers", payload)["server"]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def delete_server(id)
|
|
61
|
-
delete("/servers/#{id}")
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Networks
|
|
65
|
-
def list_networks
|
|
66
|
-
get("/networks")["networks"]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def create_network(payload)
|
|
70
|
-
post("/networks", payload)["network"]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def delete_network(id)
|
|
74
|
-
delete("/networks/#{id}")
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Firewalls
|
|
78
|
-
def list_firewalls
|
|
79
|
-
get("/firewalls")["firewalls"]
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def create_firewall(payload)
|
|
83
|
-
post("/firewalls", payload)["firewall"]
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def delete_firewall(id)
|
|
87
|
-
delete("/firewalls/#{id}")
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def apply_firewall_to_server(firewall_id, server_id)
|
|
91
|
-
payload = {
|
|
92
|
-
apply_to: [{
|
|
93
|
-
type: "server",
|
|
94
|
-
server: { id: server_id }
|
|
95
|
-
}]
|
|
96
|
-
}
|
|
97
|
-
post("/firewalls/#{firewall_id}/actions/apply_to_resources", payload)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def remove_firewall_from_server(firewall_id, server_id)
|
|
101
|
-
payload = {
|
|
102
|
-
remove_from: [{
|
|
103
|
-
type: "server",
|
|
104
|
-
server: { id: server_id }
|
|
105
|
-
}]
|
|
106
|
-
}
|
|
107
|
-
post("/firewalls/#{firewall_id}/actions/remove_from_resources", payload)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Volumes
|
|
111
|
-
def list_volumes
|
|
112
|
-
get("/volumes")["volumes"]
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def get_volume(id)
|
|
116
|
-
get("/volumes/#{id}")["volume"]
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def create_volume(payload)
|
|
120
|
-
post("/volumes", payload)["volume"]
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def delete_volume(id)
|
|
124
|
-
delete("/volumes/#{id}")
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def attach_volume(volume_id, server_id)
|
|
128
|
-
post("/volumes/#{volume_id}/actions/attach", { server: server_id })
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def detach_volume(volume_id)
|
|
132
|
-
post("/volumes/#{volume_id}/actions/detach", {})
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# Server network attachment
|
|
136
|
-
def attach_server_to_network(server_id, network_id)
|
|
137
|
-
post("/servers/#{server_id}/actions/attach_to_network", { network: network_id })
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def detach_server_from_network(server_id, network_id)
|
|
141
|
-
post("/servers/#{server_id}/actions/detach_from_network", { network: network_id })
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
private
|
|
145
|
-
|
|
146
|
-
def handle_response(response)
|
|
147
|
-
case response.status
|
|
148
|
-
when 200..299
|
|
149
|
-
response.body
|
|
150
|
-
when 401
|
|
151
|
-
raise AuthenticationError, "Invalid Hetzner API token"
|
|
152
|
-
when 404
|
|
153
|
-
raise NotFoundError, parse_error(response)
|
|
154
|
-
when 422
|
|
155
|
-
raise ValidationError, parse_error(response)
|
|
156
|
-
else
|
|
157
|
-
raise APIError, parse_error(response)
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def parse_error(response)
|
|
162
|
-
if response.body.is_a?(Hash) && response.body["error"]
|
|
163
|
-
response.body["error"]["message"]
|
|
164
|
-
else
|
|
165
|
-
"HTTP #{response.status}: #{response.body}"
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Nvoi
|
|
4
|
-
module Remote
|
|
5
|
-
# ContainerRunOptions contains options for running a container
|
|
6
|
-
ContainerRunOptions = Struct.new(:name, :image, :network, :volumes, :environment, :command, keyword_init: true)
|
|
7
|
-
|
|
8
|
-
# WaitForHealthOptions contains options for health checking
|
|
9
|
-
WaitForHealthOptions = Struct.new(:container, :utility_container, :health_check_path, :port,
|
|
10
|
-
:max_attempts, :interval, :logger, keyword_init: true)
|
|
11
|
-
|
|
12
|
-
# DockerManager manages Docker operations on remote servers
|
|
13
|
-
class DockerManager
|
|
14
|
-
attr_reader :ssh
|
|
15
|
-
|
|
16
|
-
def initialize(ssh)
|
|
17
|
-
@ssh = ssh
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Create a Docker network
|
|
21
|
-
def create_network(name)
|
|
22
|
-
@ssh.execute("docker network create #{name} 2>/dev/null || true")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Build image locally, save to tar, rsync to remote, load on remote
|
|
26
|
-
def build_image(path, tag, cache_from = nil)
|
|
27
|
-
# 1. Build locally
|
|
28
|
-
cache_args = cache_from ? "--cache-from #{cache_from}" : ""
|
|
29
|
-
local_build_cmd = "cd #{path} && DOCKER_BUILDKIT=1 docker build --platform linux/amd64 #{cache_args} --build-arg BUILDKIT_INLINE_CACHE=1 -t #{tag} ."
|
|
30
|
-
|
|
31
|
-
unless system("bash", "-c", local_build_cmd)
|
|
32
|
-
raise SSHError, "local build failed"
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# 2. Save to tar
|
|
36
|
-
tar_file = "/tmp/#{tag.tr(':', '_')}.tar"
|
|
37
|
-
unless system("docker", "save", tag, "-o", tar_file)
|
|
38
|
-
raise SSHError, "docker save failed"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
begin
|
|
42
|
-
# 3. Rsync to remote
|
|
43
|
-
remote_tar_path = "/tmp/#{tag.tr(':', '_')}.tar"
|
|
44
|
-
rsync_cmd = ["rsync", "-avz",
|
|
45
|
-
"-e", "ssh -i #{@ssh.ssh_key} -o StrictHostKeyChecking=no",
|
|
46
|
-
tar_file,
|
|
47
|
-
"#{@ssh.user}@#{@ssh.ip}:#{remote_tar_path}"]
|
|
48
|
-
|
|
49
|
-
unless system(*rsync_cmd)
|
|
50
|
-
raise SSHError, "rsync failed"
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# 4. Load on remote with ctr (containerd)
|
|
54
|
-
output = @ssh.execute("sudo ctr -n k8s.io images import #{remote_tar_path}")
|
|
55
|
-
|
|
56
|
-
# Docker saves images with docker.io/library/ prefix
|
|
57
|
-
full_image_ref = "docker.io/library/#{tag}"
|
|
58
|
-
|
|
59
|
-
# Tag the imported image with the simple name
|
|
60
|
-
begin
|
|
61
|
-
@ssh.execute("sudo ctr -n k8s.io images tag #{full_image_ref} #{tag}")
|
|
62
|
-
rescue SSHCommandError => e
|
|
63
|
-
list_output = @ssh.execute("sudo ctr -n k8s.io images ls") rescue ""
|
|
64
|
-
raise SSHError, "failed to tag imported image: #{e.message}\nAvailable images:\n#{list_output}"
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Cleanup tar file
|
|
68
|
-
@ssh.execute_quiet("rm #{remote_tar_path}")
|
|
69
|
-
ensure
|
|
70
|
-
File.delete(tar_file) if File.exist?(tar_file)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Run a container
|
|
75
|
-
def run_container(opts)
|
|
76
|
-
cmd = "docker run -d --name #{opts.name} --network #{opts.network}"
|
|
77
|
-
|
|
78
|
-
opts.volumes&.each do |vol|
|
|
79
|
-
cmd += " -v #{vol}"
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
opts.environment&.each do |k, v|
|
|
83
|
-
# Escape single quotes in value
|
|
84
|
-
escaped_v = v.to_s.gsub("'", "'\\''")
|
|
85
|
-
cmd += " -e #{k}='#{escaped_v}'"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
cmd += " --restart unless-stopped #{opts.image}"
|
|
89
|
-
cmd += " #{opts.command}" if opts.command && !opts.command.empty?
|
|
90
|
-
|
|
91
|
-
@ssh.execute(cmd)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Execute a command inside a running container
|
|
95
|
-
def exec(container, command)
|
|
96
|
-
@ssh.execute("docker exec #{container} #{command}")
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Wait for a container to be healthy
|
|
100
|
-
def wait_for_health(opts)
|
|
101
|
-
interval = opts.interval || 3
|
|
102
|
-
|
|
103
|
-
opts.max_attempts.times do |i|
|
|
104
|
-
# Check container status
|
|
105
|
-
begin
|
|
106
|
-
status = @ssh.execute(
|
|
107
|
-
"docker inspect --format='{{.State.Status}}' #{opts.container} 2>/dev/null || echo 'none'"
|
|
108
|
-
)
|
|
109
|
-
rescue SSHCommandError
|
|
110
|
-
opts.logger&.info("[%d/%d] Failed to get container status", i + 1, opts.max_attempts)
|
|
111
|
-
sleep(interval)
|
|
112
|
-
next
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
# Get last log line
|
|
116
|
-
log_line = @ssh.execute("docker logs --tail 1 #{opts.container} 2>&1") rescue ""
|
|
117
|
-
|
|
118
|
-
opts.logger&.info("[%d/%d] Status: %s | %s", i + 1, opts.max_attempts, status, log_line.strip)
|
|
119
|
-
|
|
120
|
-
if status == "running"
|
|
121
|
-
# Check if app responds using utility container
|
|
122
|
-
health_cmd = "docker exec #{opts.utility_container} curl -s -o /dev/null -w '%{http_code}' -m 2 http://#{opts.container}:#{opts.port}#{opts.health_check_path} 2>&1 || echo '000'"
|
|
123
|
-
|
|
124
|
-
http_code = @ssh.execute(health_cmd) rescue "000"
|
|
125
|
-
if http_code.include?("200")
|
|
126
|
-
return true
|
|
127
|
-
else
|
|
128
|
-
code = http_code.length >= 3 ? http_code[-3..] : "000"
|
|
129
|
-
opts.logger&.info(" App not ready yet (HTTP %s)", code)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
sleep(interval)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
false
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Get container status
|
|
140
|
-
def container_status(name)
|
|
141
|
-
@ssh.execute("docker inspect --format='{{.State.Status}}' #{name} 2>/dev/null || echo 'none'")
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Get container logs
|
|
145
|
-
def container_logs(name, lines)
|
|
146
|
-
@ssh.execute("docker logs --tail #{lines} #{name} 2>&1")
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# Stop a container
|
|
150
|
-
def stop_container(name)
|
|
151
|
-
@ssh.execute("docker stop #{name} 2>/dev/null || true")
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
# Remove a container
|
|
155
|
-
def remove_container(name)
|
|
156
|
-
@ssh.execute("docker rm #{name} 2>/dev/null || true")
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# List containers matching a filter
|
|
160
|
-
def list_containers(filter)
|
|
161
|
-
output = @ssh.execute("docker ps -a --filter '#{filter}' --format '{{.Names}}' --no-trunc | sort -r")
|
|
162
|
-
return [] if output.empty?
|
|
163
|
-
|
|
164
|
-
output.split("\n")
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# List images matching a filter
|
|
168
|
-
def list_images(filter)
|
|
169
|
-
output = @ssh.execute("docker images --filter '#{filter}' --format '{{.Tag}}' | sort -r")
|
|
170
|
-
return [] if output.empty?
|
|
171
|
-
|
|
172
|
-
output.split("\n")
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Cleanup old Docker images
|
|
176
|
-
def cleanup_old_images(prefix, keep_tags)
|
|
177
|
-
all_tags = list_images("reference=#{prefix}:*")
|
|
178
|
-
|
|
179
|
-
remove_tags = all_tags.reject { |tag| keep_tags.include?(tag) }
|
|
180
|
-
return if remove_tags.empty?
|
|
181
|
-
|
|
182
|
-
images = remove_tags.map { |tag| "#{prefix}:#{tag}" }
|
|
183
|
-
@ssh.execute("docker rmi #{images.join(' ')} 2>/dev/null || true")
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Check if a container is running
|
|
187
|
-
def container_running?(name)
|
|
188
|
-
output = @ssh.execute("docker ps -q -f name=^#{name}$ -f status=running 2>/dev/null")
|
|
189
|
-
!output.empty?
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Setup a Cloudflare tunnel sidecar
|
|
193
|
-
def setup_cloudflared(network, token, name)
|
|
194
|
-
create_network(network)
|
|
195
|
-
|
|
196
|
-
cmd = "docker run -d --name #{name} --network #{network} --restart always " \
|
|
197
|
-
"cloudflare/cloudflared:latest tunnel run --token #{token}"
|
|
198
|
-
|
|
199
|
-
@ssh.execute(cmd)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
end
|