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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/todo/refactor/00-overview.md +171 -0
  3. data/.claude/todo/refactor/01-objects.md +96 -0
  4. data/.claude/todo/refactor/02-utils.md +143 -0
  5. data/.claude/todo/refactor/03-external-cloud.md +164 -0
  6. data/.claude/todo/refactor/04-external-dns.md +104 -0
  7. data/.claude/todo/refactor/05-external.md +133 -0
  8. data/.claude/todo/refactor/06-cli.md +123 -0
  9. data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
  10. data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
  11. data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
  12. data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
  13. data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
  14. data/.claude/todo/refactor/_target.md +79 -0
  15. data/.claude/todo/scaleway.impl.md +644 -0
  16. data/.claude/todo/scaleway.reference.md +520 -0
  17. data/Gemfile +1 -0
  18. data/Gemfile.lock +12 -2
  19. data/doc/config-schema.yaml +44 -11
  20. data/examples/golang/deploy.enc +0 -0
  21. data/examples/golang/main.go +18 -0
  22. data/exe/nvoi +3 -1
  23. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  24. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  25. data/lib/nvoi/cli/db/command.rb +308 -0
  26. data/lib/nvoi/cli/delete/command.rb +75 -0
  27. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  28. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
  29. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  30. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  31. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  32. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  33. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  34. data/lib/nvoi/cli/deploy/command.rb +184 -0
  35. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  36. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  37. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
  38. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
  39. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  40. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  41. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  42. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
  43. data/lib/nvoi/cli/exec/command.rb +173 -0
  44. data/lib/nvoi/cli.rb +83 -142
  45. data/lib/nvoi/config_api/actions/app.rb +53 -0
  46. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  47. data/lib/nvoi/config_api/actions/database.rb +70 -0
  48. data/lib/nvoi/config_api/actions/env.rb +32 -0
  49. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  50. data/lib/nvoi/config_api/actions/server.rb +66 -0
  51. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  52. data/lib/nvoi/config_api/base.rb +44 -0
  53. data/lib/nvoi/config_api/result.rb +26 -0
  54. data/lib/nvoi/config_api.rb +70 -0
  55. data/lib/nvoi/errors.rb +68 -50
  56. data/lib/nvoi/external/cloud/aws.rb +425 -0
  57. data/lib/nvoi/external/cloud/base.rb +99 -0
  58. data/lib/nvoi/external/cloud/factory.rb +48 -0
  59. data/lib/nvoi/external/cloud/hetzner.rb +376 -0
  60. data/lib/nvoi/external/cloud/scaleway.rb +533 -0
  61. data/lib/nvoi/external/cloud.rb +15 -0
  62. data/lib/nvoi/external/containerd.rb +82 -0
  63. data/lib/nvoi/external/database/mysql.rb +84 -0
  64. data/lib/nvoi/external/database/postgres.rb +82 -0
  65. data/lib/nvoi/external/database/provider.rb +65 -0
  66. data/lib/nvoi/external/database/sqlite.rb +72 -0
  67. data/lib/nvoi/external/database.rb +22 -0
  68. data/lib/nvoi/external/dns/cloudflare.rb +292 -0
  69. data/lib/nvoi/external/kubectl.rb +65 -0
  70. data/lib/nvoi/external/ssh.rb +106 -0
  71. data/lib/nvoi/objects/config_override.rb +60 -0
  72. data/lib/nvoi/objects/configuration.rb +463 -0
  73. data/lib/nvoi/objects/database.rb +56 -0
  74. data/lib/nvoi/objects/dns.rb +14 -0
  75. data/lib/nvoi/objects/firewall.rb +11 -0
  76. data/lib/nvoi/objects/network.rb +11 -0
  77. data/lib/nvoi/objects/server.rb +14 -0
  78. data/lib/nvoi/objects/service_spec.rb +26 -0
  79. data/lib/nvoi/objects/tunnel.rb +14 -0
  80. data/lib/nvoi/objects/volume.rb +17 -0
  81. data/lib/nvoi/utils/config_loader.rb +172 -0
  82. data/lib/nvoi/utils/constants.rb +61 -0
  83. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  84. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  85. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  86. data/lib/nvoi/utils/logger.rb +84 -0
  87. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
  88. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  89. data/lib/nvoi/utils/templates.rb +62 -0
  90. data/lib/nvoi/version.rb +1 -1
  91. data/lib/nvoi.rb +10 -54
  92. data/templates/error-backend.yaml.erb +134 -0
  93. metadata +97 -44
  94. data/examples/golang/deploy.yml +0 -54
  95. data/lib/nvoi/cloudflare/client.rb +0 -287
  96. data/lib/nvoi/config/config.rb +0 -248
  97. data/lib/nvoi/config/loader.rb +0 -102
  98. data/lib/nvoi/config/ssh_keys.rb +0 -82
  99. data/lib/nvoi/config/types.rb +0 -274
  100. data/lib/nvoi/constants.rb +0 -59
  101. data/lib/nvoi/credentials/editor.rb +0 -272
  102. data/lib/nvoi/deployer/cleaner.rb +0 -36
  103. data/lib/nvoi/deployer/image_builder.rb +0 -23
  104. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  105. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  106. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  107. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  108. data/lib/nvoi/deployer/types.rb +0 -8
  109. data/lib/nvoi/k8s/renderer.rb +0 -44
  110. data/lib/nvoi/k8s/templates.rb +0 -29
  111. data/lib/nvoi/logger.rb +0 -72
  112. data/lib/nvoi/providers/aws.rb +0 -403
  113. data/lib/nvoi/providers/base.rb +0 -111
  114. data/lib/nvoi/providers/hetzner.rb +0 -288
  115. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  116. data/lib/nvoi/remote/docker_manager.rb +0 -203
  117. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  118. data/lib/nvoi/remote/volume_manager.rb +0 -103
  119. data/lib/nvoi/service/delete.rb +0 -234
  120. data/lib/nvoi/service/deploy.rb +0 -80
  121. data/lib/nvoi/service/exec.rb +0 -144
  122. data/lib/nvoi/service/provider.rb +0 -36
  123. data/lib/nvoi/steps/application_deployer.rb +0 -26
  124. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  125. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  126. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  127. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  128. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  129. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  130. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,376 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Nvoi
7
+ module External
8
+ module Cloud
9
+ # Hetzner provider implements the compute provider interface for Hetzner Cloud
10
+ class Hetzner < Base
11
+ BASE_URL = "https://api.hetzner.cloud/v1"
12
+
13
+ def initialize(token)
14
+ @token = token
15
+ @conn = Faraday.new do |f|
16
+ f.request :json
17
+ f.response :json
18
+ f.headers["Authorization"] = "Bearer #{token}"
19
+ end
20
+ end
21
+
22
+ # Network operations
23
+
24
+ def find_or_create_network(name)
25
+ network = find_network_by_name(name)
26
+ return to_network(network) if network
27
+
28
+ network = create_network_api(
29
+ name:,
30
+ ip_range: Utils::Constants::NETWORK_CIDR,
31
+ subnets: [{
32
+ type: "cloud",
33
+ ip_range: Utils::Constants::SUBNET_CIDR,
34
+ network_zone: "eu-central"
35
+ }]
36
+ )
37
+
38
+ to_network(network)
39
+ end
40
+
41
+ def get_network_by_name(name)
42
+ network = find_network_by_name(name)
43
+ raise Errors::NetworkError, "network not found: #{name}" unless network
44
+
45
+ to_network(network)
46
+ end
47
+
48
+ def delete_network(id)
49
+ delete("/networks/#{id.to_i}")
50
+ end
51
+
52
+ # Firewall operations
53
+
54
+ def find_or_create_firewall(name)
55
+ firewall = find_firewall_by_name(name)
56
+ return to_firewall(firewall) if firewall
57
+
58
+ firewall = create_firewall_api(
59
+ name:,
60
+ rules: [{
61
+ direction: "in",
62
+ protocol: "tcp",
63
+ port: "22",
64
+ source_ips: ["0.0.0.0/0", "::/0"]
65
+ }]
66
+ )
67
+
68
+ to_firewall(firewall)
69
+ end
70
+
71
+ def get_firewall_by_name(name)
72
+ firewall = find_firewall_by_name(name)
73
+ raise Errors::FirewallError, "firewall not found: #{name}" unless firewall
74
+
75
+ to_firewall(firewall)
76
+ end
77
+
78
+ def delete_firewall(id)
79
+ delete("/firewalls/#{id.to_i}")
80
+ end
81
+
82
+ # Server operations
83
+
84
+ def find_server(name)
85
+ server = find_server_by_name(name)
86
+ return nil unless server
87
+
88
+ to_server(server)
89
+ end
90
+
91
+ def find_server_by_id(id)
92
+ server = get("/servers/#{id.to_i}")["server"]
93
+ return nil unless server
94
+
95
+ to_server(server)
96
+ rescue Errors::NotFoundError
97
+ nil
98
+ end
99
+
100
+ def list_servers
101
+ get("/servers")["servers"].map { |s| to_server(s) }
102
+ end
103
+
104
+ def create_server(opts)
105
+ # Resolve IDs
106
+ server_type = find_server_type(opts.type)
107
+ raise Errors::ValidationError, "invalid server type: #{opts.type}" unless server_type
108
+
109
+ image = find_image(opts.image)
110
+ raise Errors::ValidationError, "invalid image: #{opts.image}" unless image
111
+
112
+ location = find_location(opts.location)
113
+ raise Errors::ValidationError, "invalid location: #{opts.location}" unless location
114
+
115
+ create_opts = {
116
+ name: opts.name,
117
+ server_type: server_type["name"],
118
+ image: image["name"],
119
+ location: location["name"],
120
+ user_data: opts.user_data,
121
+ start_after_create: true
122
+ }
123
+
124
+ # Add network if provided
125
+ if opts.network_id && !opts.network_id.empty?
126
+ create_opts[:networks] = [opts.network_id.to_i]
127
+ end
128
+
129
+ # Add firewall if provided
130
+ if opts.firewall_id && !opts.firewall_id.empty?
131
+ create_opts[:firewalls] = [{ firewall: opts.firewall_id.to_i }]
132
+ end
133
+
134
+ server = post("/servers", create_opts)["server"]
135
+ to_server(server)
136
+ end
137
+
138
+ def wait_for_server(server_id, max_attempts)
139
+ server = Utils::Retry.poll(max_attempts: max_attempts, interval: Utils::Constants::SERVER_READY_INTERVAL) do
140
+ s = get("/servers/#{server_id.to_i}")["server"]
141
+ to_server(s) if s["status"] == "running"
142
+ end
143
+
144
+ raise Errors::ServerCreationError, "server did not become running after #{max_attempts} attempts" unless server
145
+
146
+ server
147
+ end
148
+
149
+ def delete_server(id)
150
+ server = get("/servers/#{id.to_i}")["server"]
151
+
152
+ # Remove from firewalls
153
+ get("/firewalls")["firewalls"].each do |fw|
154
+ fw["applied_to"]&.each do |applied|
155
+ next unless applied["type"] == "server" && applied.dig("server", "id") == id.to_i
156
+
157
+ remove_firewall_from_server(fw["id"], id.to_i)
158
+ rescue StandardError
159
+ # Ignore cleanup errors
160
+ end
161
+ end
162
+
163
+ # Detach from networks
164
+ server["private_net"]&.each do |pn|
165
+ detach_server_from_network(id.to_i, pn["network"])
166
+ rescue StandardError
167
+ # Ignore cleanup errors
168
+ end
169
+
170
+ delete("/servers/#{id.to_i}")
171
+ end
172
+
173
+ # Volume operations
174
+
175
+ def create_volume(opts)
176
+ server = get("/servers/#{opts.server_id.to_i}")["server"]
177
+ raise Errors::VolumeError, "server not found: #{opts.server_id}" unless server
178
+
179
+ volume = post("/volumes", {
180
+ name: opts.name,
181
+ size: opts.size,
182
+ location: server.dig("datacenter", "location", "name"),
183
+ format: "xfs"
184
+ })["volume"]
185
+
186
+ to_volume(volume)
187
+ end
188
+
189
+ def get_volume(id)
190
+ volume = get("/volumes/#{id.to_i}")["volume"]
191
+ return nil unless volume
192
+
193
+ to_volume(volume)
194
+ end
195
+
196
+ def get_volume_by_name(name)
197
+ volume = get("/volumes")["volumes"].find { |v| v["name"] == name }
198
+ return nil unless volume
199
+
200
+ to_volume(volume)
201
+ end
202
+
203
+ def delete_volume(id)
204
+ delete("/volumes/#{id.to_i}")
205
+ end
206
+
207
+ def attach_volume(volume_id, server_id)
208
+ post("/volumes/#{volume_id.to_i}/actions/attach", { server: server_id.to_i })
209
+ end
210
+
211
+ def detach_volume(volume_id)
212
+ post("/volumes/#{volume_id.to_i}/actions/detach", {})
213
+ end
214
+
215
+ def wait_for_device_path(volume_id, _ssh)
216
+ # Hetzner provides device_path in API response
217
+ Utils::Retry.poll(max_attempts: 30, interval: 2) do
218
+ volume = get("/volumes/#{volume_id.to_i}")["volume"]
219
+ volume["linux_device"] if volume && volume["linux_device"] && !volume["linux_device"].empty?
220
+ end
221
+ end
222
+
223
+ # Validation operations
224
+
225
+ def validate_instance_type(instance_type)
226
+ server_type = find_server_type(instance_type)
227
+ raise Errors::ValidationError, "invalid hetzner server type: #{instance_type}" unless server_type
228
+
229
+ true
230
+ end
231
+
232
+ def validate_region(region)
233
+ location = find_location(region)
234
+ raise Errors::ValidationError, "invalid hetzner location: #{region}" unless location
235
+
236
+ true
237
+ end
238
+
239
+ def validate_credentials
240
+ get("/server_types")
241
+ true
242
+ rescue Errors::AuthenticationError => e
243
+ raise Errors::ValidationError, "hetzner credentials invalid: #{e.message}"
244
+ end
245
+
246
+ private
247
+
248
+ def get(path)
249
+ response = @conn.get("#{BASE_URL}#{path}")
250
+ handle_response(response)
251
+ end
252
+
253
+ def post(path, payload = {})
254
+ response = @conn.post("#{BASE_URL}#{path}", payload)
255
+ handle_response(response)
256
+ end
257
+
258
+ def delete(path)
259
+ response = @conn.delete("#{BASE_URL}#{path}")
260
+ return nil if response.status == 204
261
+ handle_response(response)
262
+ end
263
+
264
+ def handle_response(response)
265
+ case response.status
266
+ when 200..299
267
+ response.body
268
+ when 401
269
+ raise Errors::AuthenticationError, "Invalid Hetzner API token"
270
+ when 404
271
+ raise Errors::NotFoundError, parse_error(response)
272
+ when 422
273
+ raise Errors::ValidationError, parse_error(response)
274
+ else
275
+ raise Errors::ApiError, parse_error(response)
276
+ end
277
+ end
278
+
279
+ def parse_error(response)
280
+ if response.body.is_a?(Hash) && response.body["error"]
281
+ response.body["error"]["message"]
282
+ else
283
+ "HTTP #{response.status}: #{response.body}"
284
+ end
285
+ end
286
+
287
+ def find_network_by_name(name)
288
+ get("/networks")["networks"].find { |n| n["name"] == name }
289
+ end
290
+
291
+ def find_firewall_by_name(name)
292
+ get("/firewalls")["firewalls"].find { |f| f["name"] == name }
293
+ end
294
+
295
+ def find_server_by_name(name)
296
+ get("/servers")["servers"].find { |s| s["name"] == name }
297
+ end
298
+
299
+ def find_server_type(name)
300
+ get("/server_types")["server_types"].find { |t| t["name"] == name }
301
+ end
302
+
303
+ def find_image(name)
304
+ response = get("/images?name=#{name}")
305
+ response["images"]&.first
306
+ end
307
+
308
+ def find_location(name)
309
+ get("/locations")["locations"].find { |l| l["name"] == name }
310
+ end
311
+
312
+ def create_network_api(payload)
313
+ post("/networks", payload)["network"]
314
+ end
315
+
316
+ def create_firewall_api(payload)
317
+ post("/firewalls", payload)["firewall"]
318
+ end
319
+
320
+ def remove_firewall_from_server(firewall_id, server_id)
321
+ payload = {
322
+ remove_from: [{
323
+ type: "server",
324
+ server: { id: server_id }
325
+ }]
326
+ }
327
+ post("/firewalls/#{firewall_id}/actions/remove_from_resources", payload)
328
+ end
329
+
330
+ def detach_server_from_network(server_id, network_id)
331
+ post("/servers/#{server_id}/actions/detach_from_network", { network: network_id })
332
+ end
333
+
334
+ def to_network(data)
335
+ Objects::Network::Record.new(
336
+ id: data["id"].to_s,
337
+ name: data["name"],
338
+ ip_range: data["ip_range"]
339
+ )
340
+ end
341
+
342
+ def to_firewall(data)
343
+ Objects::Firewall::Record.new(
344
+ id: data["id"].to_s,
345
+ name: data["name"]
346
+ )
347
+ end
348
+
349
+ def to_server(data)
350
+ # Get private IP from private_net array
351
+ private_ip = data["private_net"]&.first&.dig("ip")
352
+
353
+ Objects::Server::Record.new(
354
+ id: data["id"].to_s,
355
+ name: data["name"],
356
+ status: data["status"],
357
+ public_ipv4: data.dig("public_net", "ipv4", "ip"),
358
+ private_ipv4: private_ip
359
+ )
360
+ end
361
+
362
+ def to_volume(data)
363
+ Objects::Volume::Record.new(
364
+ id: data["id"].to_s,
365
+ name: data["name"],
366
+ size: data["size"],
367
+ location: data.dig("location", "name"),
368
+ status: data["status"],
369
+ server_id: data["server"]&.to_s,
370
+ device_path: data["linux_device"]
371
+ )
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end