nvoi 0.1.5 → 0.1.7

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 (156) 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/12-cli-db-command.md +128 -0
  15. data/.claude/todo/refactor/_target.md +79 -0
  16. data/.claude/todo/refactor-execution/00-entrypoint.md +49 -0
  17. data/.claude/todo/refactor-execution/01-objects.md +42 -0
  18. data/.claude/todo/refactor-execution/02-utils.md +41 -0
  19. data/.claude/todo/refactor-execution/03-external-cloud.md +38 -0
  20. data/.claude/todo/refactor-execution/04-external-dns.md +35 -0
  21. data/.claude/todo/refactor-execution/05-external-other.md +46 -0
  22. data/.claude/todo/refactor-execution/06-cli-deploy.md +45 -0
  23. data/.claude/todo/refactor-execution/07-cli-delete.md +43 -0
  24. data/.claude/todo/refactor-execution/08-cli-exec.md +30 -0
  25. data/.claude/todo/refactor-execution/09-cli-credentials.md +34 -0
  26. data/.claude/todo/refactor-execution/10-cli-db.md +31 -0
  27. data/.claude/todo/refactor-execution/11-cli-router.md +44 -0
  28. data/.claude/todo/refactor-execution/12-cleanup.md +120 -0
  29. data/.claude/todo/refactor-execution/_monitoring-strategy.md +126 -0
  30. data/.claude/todo/scaleway.impl.md +644 -0
  31. data/.claude/todo/scaleway.reference.md +520 -0
  32. data/.claude/todos.md +550 -0
  33. data/Gemfile +6 -0
  34. data/Gemfile.lock +46 -5
  35. data/Rakefile +1 -1
  36. data/doc/config-schema.yaml +44 -11
  37. data/examples/golang/deploy.enc +0 -0
  38. data/examples/golang/main.go +18 -0
  39. data/exe/nvoi +3 -1
  40. data/ingest +0 -0
  41. data/lib/nvoi/cli/config/command.rb +219 -0
  42. data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
  43. data/lib/nvoi/cli/credentials/show/command.rb +35 -0
  44. data/lib/nvoi/cli/db/command.rb +308 -0
  45. data/lib/nvoi/cli/delete/command.rb +75 -0
  46. data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
  47. data/lib/nvoi/cli/delete/steps/teardown_dns.rb +50 -0
  48. data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
  49. data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
  50. data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
  51. data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
  52. data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
  53. data/lib/nvoi/cli/deploy/command.rb +184 -0
  54. data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
  55. data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
  56. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +102 -0
  57. data/lib/nvoi/cli/deploy/steps/deploy_service.rb +399 -0
  58. data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
  59. data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
  60. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
  61. data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +490 -0
  62. data/lib/nvoi/cli/exec/command.rb +173 -0
  63. data/lib/nvoi/cli/logs/command.rb +66 -0
  64. data/lib/nvoi/cli/onboard/command.rb +761 -0
  65. data/lib/nvoi/cli/unlock/command.rb +72 -0
  66. data/lib/nvoi/cli.rb +339 -141
  67. data/lib/nvoi/config_api/actions/app.rb +53 -0
  68. data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
  69. data/lib/nvoi/config_api/actions/database.rb +70 -0
  70. data/lib/nvoi/config_api/actions/domain_provider.rb +40 -0
  71. data/lib/nvoi/config_api/actions/env.rb +32 -0
  72. data/lib/nvoi/config_api/actions/init.rb +67 -0
  73. data/lib/nvoi/config_api/actions/secret.rb +32 -0
  74. data/lib/nvoi/config_api/actions/server.rb +66 -0
  75. data/lib/nvoi/config_api/actions/service.rb +52 -0
  76. data/lib/nvoi/config_api/actions/volume.rb +40 -0
  77. data/lib/nvoi/config_api/base.rb +38 -0
  78. data/lib/nvoi/config_api/result.rb +26 -0
  79. data/lib/nvoi/config_api.rb +93 -0
  80. data/lib/nvoi/errors.rb +68 -50
  81. data/lib/nvoi/external/cloud/aws.rb +450 -0
  82. data/lib/nvoi/external/cloud/base.rb +99 -0
  83. data/lib/nvoi/external/cloud/factory.rb +48 -0
  84. data/lib/nvoi/external/cloud/hetzner.rb +402 -0
  85. data/lib/nvoi/external/cloud/scaleway.rb +559 -0
  86. data/lib/nvoi/external/cloud.rb +15 -0
  87. data/lib/nvoi/external/containerd.rb +86 -0
  88. data/lib/nvoi/external/database/mysql.rb +84 -0
  89. data/lib/nvoi/external/database/postgres.rb +82 -0
  90. data/lib/nvoi/external/database/provider.rb +65 -0
  91. data/lib/nvoi/external/database/sqlite.rb +72 -0
  92. data/lib/nvoi/external/database.rb +22 -0
  93. data/lib/nvoi/external/dns/cloudflare.rb +310 -0
  94. data/lib/nvoi/external/kubectl.rb +65 -0
  95. data/lib/nvoi/external/ssh.rb +106 -0
  96. data/lib/nvoi/objects/config_override.rb +60 -0
  97. data/lib/nvoi/objects/configuration.rb +483 -0
  98. data/lib/nvoi/objects/database.rb +56 -0
  99. data/lib/nvoi/objects/dns.rb +14 -0
  100. data/lib/nvoi/objects/firewall.rb +11 -0
  101. data/lib/nvoi/objects/network.rb +11 -0
  102. data/lib/nvoi/objects/server.rb +14 -0
  103. data/lib/nvoi/objects/service_spec.rb +26 -0
  104. data/lib/nvoi/objects/tunnel.rb +14 -0
  105. data/lib/nvoi/objects/volume.rb +17 -0
  106. data/lib/nvoi/utils/config_loader.rb +172 -0
  107. data/lib/nvoi/utils/constants.rb +61 -0
  108. data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
  109. data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
  110. data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
  111. data/lib/nvoi/utils/logger.rb +84 -0
  112. data/lib/nvoi/{config/naming.rb → utils/namer.rb} +37 -25
  113. data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
  114. data/lib/nvoi/utils/templates.rb +62 -0
  115. data/lib/nvoi/version.rb +1 -1
  116. data/lib/nvoi.rb +27 -55
  117. data/templates/app-ingress.yaml.erb +3 -1
  118. data/templates/error-backend.yaml.erb +134 -0
  119. metadata +121 -44
  120. data/examples/golang/deploy.yml +0 -54
  121. data/lib/nvoi/cloudflare/client.rb +0 -287
  122. data/lib/nvoi/config/config.rb +0 -248
  123. data/lib/nvoi/config/loader.rb +0 -102
  124. data/lib/nvoi/config/ssh_keys.rb +0 -82
  125. data/lib/nvoi/config/types.rb +0 -274
  126. data/lib/nvoi/constants.rb +0 -59
  127. data/lib/nvoi/credentials/editor.rb +0 -272
  128. data/lib/nvoi/deployer/cleaner.rb +0 -36
  129. data/lib/nvoi/deployer/image_builder.rb +0 -23
  130. data/lib/nvoi/deployer/infrastructure.rb +0 -126
  131. data/lib/nvoi/deployer/orchestrator.rb +0 -146
  132. data/lib/nvoi/deployer/service_deployer.rb +0 -311
  133. data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
  134. data/lib/nvoi/deployer/types.rb +0 -8
  135. data/lib/nvoi/k8s/renderer.rb +0 -44
  136. data/lib/nvoi/k8s/templates.rb +0 -29
  137. data/lib/nvoi/logger.rb +0 -72
  138. data/lib/nvoi/providers/aws.rb +0 -403
  139. data/lib/nvoi/providers/base.rb +0 -111
  140. data/lib/nvoi/providers/hetzner.rb +0 -288
  141. data/lib/nvoi/providers/hetzner_client.rb +0 -170
  142. data/lib/nvoi/remote/docker_manager.rb +0 -203
  143. data/lib/nvoi/remote/ssh_executor.rb +0 -72
  144. data/lib/nvoi/remote/volume_manager.rb +0 -103
  145. data/lib/nvoi/service/delete.rb +0 -234
  146. data/lib/nvoi/service/deploy.rb +0 -80
  147. data/lib/nvoi/service/exec.rb +0 -144
  148. data/lib/nvoi/service/provider.rb +0 -36
  149. data/lib/nvoi/steps/application_deployer.rb +0 -26
  150. data/lib/nvoi/steps/database_provisioner.rb +0 -60
  151. data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
  152. data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
  153. data/lib/nvoi/steps/server_provisioner.rb +0 -43
  154. data/lib/nvoi/steps/services_provisioner.rb +0 -29
  155. data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
  156. data/lib/nvoi/steps/volume_provisioner.rb +0 -154
@@ -0,0 +1,559 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Nvoi
7
+ module External
8
+ module Cloud
9
+ # Scaleway provider implements the compute provider interface for Scaleway Cloud
10
+ class Scaleway < Base
11
+ INSTANCE_API_BASE = "https://api.scaleway.com/instance/v1"
12
+ VPC_API_BASE = "https://api.scaleway.com/vpc/v2"
13
+ BLOCK_API_BASE = "https://api.scaleway.com/block/v1alpha1"
14
+
15
+ VALID_ZONES = %w[
16
+ fr-par-1 fr-par-2 fr-par-3
17
+ nl-ams-1 nl-ams-2 nl-ams-3
18
+ pl-waw-1 pl-waw-2 pl-waw-3
19
+ ].freeze
20
+
21
+ def initialize(secret_key, project_id, zone: "fr-par-1")
22
+ @secret_key = secret_key
23
+ @project_id = project_id
24
+ @zone = zone
25
+ @region = zone_to_region(zone)
26
+ @conn = build_connection
27
+ end
28
+
29
+ attr_reader :zone, :region, :project_id
30
+
31
+ # Network operations
32
+
33
+ def find_or_create_network(name)
34
+ network = find_network_by_name(name)
35
+ return to_network(network) if network
36
+
37
+ network = post(vpc_url("/private-networks"), {
38
+ name:,
39
+ project_id: @project_id
40
+ })
41
+
42
+ to_network(network)
43
+ end
44
+
45
+ def get_network_by_name(name)
46
+ network = find_network_by_name(name)
47
+ raise Errors::NetworkError, "network not found: #{name}" unless network
48
+
49
+ to_network(network)
50
+ end
51
+
52
+ def delete_network(id)
53
+ # First detach all servers from this network
54
+ list_servers_api.each do |server|
55
+ nics = list_private_nics(server["id"])
56
+ nics.each do |nic|
57
+ next unless nic["private_network_id"] == id
58
+
59
+ delete_private_nic(server["id"], nic["id"])
60
+ rescue StandardError
61
+ # Ignore cleanup errors
62
+ end
63
+ end
64
+
65
+ delete(vpc_url("/private-networks/#{id}"))
66
+ end
67
+
68
+ # Firewall operations (Security Groups)
69
+
70
+ def find_or_create_firewall(name)
71
+ sg = find_security_group_by_name(name)
72
+ return to_firewall(sg) if sg
73
+
74
+ sg = post(instance_url("/security_groups"), {
75
+ name:,
76
+ project: @project_id,
77
+ stateful: true,
78
+ inbound_default_policy: "drop",
79
+ outbound_default_policy: "accept"
80
+ })["security_group"]
81
+
82
+ # Add SSH rule
83
+ post(instance_url("/security_groups/#{sg["id"]}/rules"), {
84
+ protocol: "TCP",
85
+ direction: "inbound",
86
+ action: "accept",
87
+ ip_range: "0.0.0.0/0",
88
+ dest_port_from: 22,
89
+ dest_port_to: 22
90
+ })
91
+
92
+ to_firewall(sg)
93
+ end
94
+
95
+ def get_firewall_by_name(name)
96
+ sg = find_security_group_by_name(name)
97
+ raise Errors::FirewallError, "security group not found: #{name}" unless sg
98
+
99
+ to_firewall(sg)
100
+ end
101
+
102
+ def delete_firewall(id)
103
+ delete(instance_url("/security_groups/#{id}"))
104
+ end
105
+
106
+ # Server operations
107
+
108
+ def find_server(name)
109
+ server = find_server_by_name(name)
110
+ return nil unless server
111
+
112
+ to_server(server, fetch_private_ip: true)
113
+ end
114
+
115
+ def find_server_by_id(id)
116
+ server = get(instance_url("/servers/#{id}"))["server"]
117
+ return nil unless server
118
+
119
+ to_server(server, fetch_private_ip: true)
120
+ rescue Errors::NotFoundError
121
+ nil
122
+ end
123
+
124
+ def list_servers
125
+ list_servers_api.map { |s| to_server(s) }
126
+ end
127
+
128
+ def create_server(opts)
129
+ # Validate server type
130
+ server_types = list_server_types_api
131
+ unless server_types.key?(opts.type)
132
+ raise Errors::ValidationError, "invalid server type: #{opts.type}"
133
+ end
134
+
135
+ # Resolve image
136
+ image = find_image(opts.image)
137
+ raise Errors::ValidationError, "invalid image: #{opts.image}" unless image
138
+
139
+ create_opts = {
140
+ name: opts.name,
141
+ commercial_type: opts.type,
142
+ image: image["id"],
143
+ project: @project_id,
144
+ boot_type: "local",
145
+ tags: []
146
+ }
147
+
148
+ # Add security group if provided
149
+ if opts.firewall_id && !opts.firewall_id.empty?
150
+ create_opts[:security_group] = opts.firewall_id
151
+ end
152
+
153
+ server = post(instance_url("/servers"), create_opts)["server"]
154
+
155
+ # Set cloud-init user data if provided
156
+ if opts.user_data && !opts.user_data.empty?
157
+ set_user_data(server["id"], "cloud-init", opts.user_data)
158
+ end
159
+
160
+ # Power on the server
161
+ server_action(server["id"], "poweron")
162
+
163
+ # Attach to private network if provided
164
+ if opts.network_id && !opts.network_id.empty?
165
+ wait_for_server_state(server["id"], "running", 30)
166
+ create_private_nic(server["id"], opts.network_id)
167
+ end
168
+
169
+ to_server(get_server_api(server["id"]))
170
+ end
171
+
172
+ def wait_for_server(server_id, max_attempts)
173
+ server = Utils::Retry.poll(max_attempts:, interval: Utils::Constants::SERVER_READY_INTERVAL) do
174
+ s = get_server_api(server_id)
175
+ to_server(s) if s["state"] == "running" && s.dig("public_ip", "address")
176
+ end
177
+
178
+ raise Errors::ServerCreationError, "server did not become running after #{max_attempts} attempts" unless server
179
+
180
+ server
181
+ end
182
+
183
+ def delete_server(id)
184
+ # Delete private NICs first
185
+ nics = list_private_nics(id)
186
+ nics.each do |nic|
187
+ delete_private_nic(id, nic["id"])
188
+ rescue StandardError
189
+ # Ignore cleanup errors
190
+ end
191
+
192
+ # Terminate server (this also stops and deletes)
193
+ server_action(id, "terminate")
194
+ rescue StandardError => e
195
+ # If terminate fails, try poweroff then delete
196
+ begin
197
+ server_action(id, "poweroff")
198
+ sleep(5)
199
+ delete(instance_url("/servers/#{id}"))
200
+ rescue StandardError
201
+ raise e
202
+ end
203
+ end
204
+
205
+ # Volume operations
206
+
207
+ def create_volume(opts)
208
+ server = get_server_api(opts.server_id)
209
+ raise Errors::VolumeError, "server not found: #{opts.server_id}" unless server
210
+
211
+ volume = post(block_url("/volumes"), {
212
+ name: opts.name,
213
+ perf_iops: 5000,
214
+ from_empty: { size: opts.size * 1_000_000_000 },
215
+ project_id: @project_id
216
+ })
217
+
218
+ to_volume(volume)
219
+ end
220
+
221
+ def get_volume(id)
222
+ volume = get(block_url("/volumes/#{id}"))
223
+ return nil unless volume
224
+
225
+ to_volume(volume)
226
+ rescue Errors::NotFoundError
227
+ nil
228
+ end
229
+
230
+ def get_volume_by_name(name)
231
+ volume = list_volumes.find { |v| v["name"] == name }
232
+ return nil unless volume
233
+
234
+ to_volume(volume)
235
+ end
236
+
237
+ def delete_volume(id)
238
+ delete(block_url("/volumes/#{id}"))
239
+ end
240
+
241
+ def attach_volume(volume_id, server_id)
242
+ server = get_server_api(server_id)
243
+ raise Errors::VolumeError, "server not found: #{server_id}" unless server
244
+
245
+ wait_for_volume_available(volume_id)
246
+
247
+ current_volumes = server["volumes"] || {}
248
+ next_index = current_volumes.keys.map(&:to_i).max.to_i + 1
249
+
250
+ new_volumes = current_volumes.dup
251
+ new_volumes[next_index.to_s] = { id: volume_id, volume_type: "sbs_volume" }
252
+
253
+ patch(instance_url("/servers/#{server_id}"), { volumes: new_volumes })
254
+ end
255
+
256
+ def detach_volume(volume_id)
257
+ list_servers_api.each do |server|
258
+ volumes = server["volumes"] || {}
259
+ volumes.each do |idx, vol|
260
+ next unless vol["id"] == volume_id
261
+
262
+ new_volumes = volumes.reject { |k, _| k == idx }
263
+ patch(instance_url("/servers/#{server["id"]}"), { volumes: new_volumes })
264
+ return
265
+ end
266
+ end
267
+ end
268
+
269
+ def wait_for_device_path(volume_id, ssh)
270
+ # Scaleway doesn't provide device_path in API
271
+ # Find device by volume ID in /dev/disk/by-id/
272
+ Utils::Retry.poll(max_attempts: 30, interval: 2) do
273
+ output = ssh.execute("ls /dev/disk/by-id/ 2>/dev/null | grep -i '#{volume_id}' || true").strip
274
+ next nil if output.empty?
275
+
276
+ device_name = output.lines.first.strip
277
+ "/dev/disk/by-id/#{device_name}"
278
+ end
279
+ end
280
+
281
+ # Validation operations
282
+
283
+ def validate_instance_type(instance_type)
284
+ server_types = list_server_types_api
285
+ unless server_types.key?(instance_type)
286
+ raise Errors::ValidationError, "invalid scaleway server type: #{instance_type}"
287
+ end
288
+
289
+ true
290
+ end
291
+
292
+ def validate_region(region)
293
+ unless VALID_ZONES.include?(region)
294
+ raise Errors::ValidationError, "invalid scaleway zone: #{region}. Valid: #{VALID_ZONES.join(", ")}"
295
+ end
296
+
297
+ true
298
+ end
299
+
300
+ def validate_credentials
301
+ list_server_types_api
302
+ true
303
+ rescue Errors::AuthenticationError => e
304
+ raise Errors::ValidationError, "scaleway credentials invalid: #{e.message}"
305
+ end
306
+
307
+ # Server IP lookup for exec/db commands
308
+ def server_ip(server_name)
309
+ server = find_server(server_name)
310
+ server&.public_ipv4
311
+ end
312
+
313
+ # List available server types for onboarding
314
+ def list_server_types
315
+ list_server_types_api.map do |name, info|
316
+ {
317
+ name:,
318
+ cores: info.dig("ncpus"),
319
+ ram: info.dig("ram"),
320
+ hourly_price: info.dig("hourly_price")
321
+ }
322
+ end
323
+ end
324
+
325
+ # List available zones for onboarding
326
+ def list_zones
327
+ VALID_ZONES.map do |z|
328
+ parts = z.split("-")
329
+ city = case parts[0..1].join("-")
330
+ when "fr-par" then "Paris"
331
+ when "nl-ams" then "Amsterdam"
332
+ when "pl-waw" then "Warsaw"
333
+ else parts[0..1].join("-")
334
+ end
335
+ { name: z, city: }
336
+ end
337
+ end
338
+
339
+ private
340
+
341
+ def zone_to_region(zone)
342
+ zone.split("-")[0..1].join("-")
343
+ end
344
+
345
+ def instance_url(path)
346
+ "#{INSTANCE_API_BASE}/zones/#{@zone}#{path}"
347
+ end
348
+
349
+ def vpc_url(path)
350
+ "#{VPC_API_BASE}/regions/#{@region}#{path}"
351
+ end
352
+
353
+ def block_url(path)
354
+ "#{BLOCK_API_BASE}/zones/#{@zone}#{path}"
355
+ end
356
+
357
+ def build_connection
358
+ Faraday.new do |f|
359
+ f.request :json
360
+ f.response :json
361
+ f.headers["X-Auth-Token"] = @secret_key
362
+ f.headers["Content-Type"] = "application/json"
363
+ end
364
+ end
365
+
366
+ def get(url)
367
+ response = @conn.get(url)
368
+ handle_response(response)
369
+ end
370
+
371
+ def post(url, payload = {})
372
+ response = @conn.post(url, payload)
373
+ handle_response(response, url, payload)
374
+ end
375
+
376
+ def patch(url, payload = {})
377
+ response = @conn.patch(url, payload)
378
+ handle_response(response)
379
+ end
380
+
381
+ def delete(url)
382
+ response = @conn.delete(url)
383
+ return nil if response.status == 204
384
+ handle_response(response)
385
+ end
386
+
387
+ def handle_response(response, url = nil, payload = nil)
388
+ case response.status
389
+ when 200..299
390
+ response.body
391
+ when 401
392
+ raise Errors::AuthenticationError, "Invalid Scaleway API token"
393
+ when 403
394
+ raise Errors::AuthenticationError, "Forbidden: check project_id and permissions"
395
+ when 404
396
+ raise Errors::NotFoundError, parse_error(response)
397
+ when 409
398
+ raise Errors::ConflictError, parse_error(response)
399
+ when 422
400
+ raise Errors::ValidationError, parse_error(response)
401
+ when 429
402
+ raise Errors::RateLimitError, "Rate limited, retry later"
403
+ else
404
+ debug = "HTTP #{response.status}: #{parse_error(response)}"
405
+ debug += "\nURL: #{url}" if url
406
+ debug += "\nPayload: #{payload.inspect}" if payload
407
+ raise Errors::ApiError, debug
408
+ end
409
+ end
410
+
411
+ def parse_error(response)
412
+ if response.body.is_a?(Hash)
413
+ response.body["message"] || response.body.inspect
414
+ else
415
+ response.body.to_s
416
+ end
417
+ end
418
+
419
+ def list_servers_api
420
+ get(instance_url("/servers"))["servers"] || []
421
+ end
422
+
423
+ def get_server_api(id)
424
+ get(instance_url("/servers/#{id}"))["server"]
425
+ end
426
+
427
+ def server_action(id, action)
428
+ post(instance_url("/servers/#{id}/action"), { action: })
429
+ end
430
+
431
+ def list_server_types_api
432
+ get(instance_url("/products/servers"))["servers"] || {}
433
+ end
434
+
435
+ def list_images(name: nil, arch: "x86_64")
436
+ params = ["arch=#{arch}"]
437
+ params << "name=#{name}" if name
438
+ get(instance_url("/images?#{params.join("&")}"))["images"] || []
439
+ end
440
+
441
+ def list_volumes
442
+ get(block_url("/volumes"))["volumes"] || []
443
+ end
444
+
445
+ def list_private_nics(server_id)
446
+ get(instance_url("/servers/#{server_id}/private_nics"))["private_nics"] || []
447
+ end
448
+
449
+ def create_private_nic(server_id, private_network_id)
450
+ post(instance_url("/servers/#{server_id}/private_nics"), { private_network_id: })["private_nic"]
451
+ end
452
+
453
+ def delete_private_nic(server_id, nic_id)
454
+ delete(instance_url("/servers/#{server_id}/private_nics/#{nic_id}"))
455
+ end
456
+
457
+ def set_user_data(server_id, key, content)
458
+ url = instance_url("/servers/#{server_id}/user_data/#{key}")
459
+ response = @conn.patch(url) do |req|
460
+ req.headers["Content-Type"] = "text/plain"
461
+ req.body = content
462
+ end
463
+ handle_response(response)
464
+ end
465
+
466
+ def wait_for_volume_available(volume_id, timeout: 60)
467
+ deadline = Time.now + timeout
468
+ loop do
469
+ vol = get(block_url("/volumes/#{volume_id}"))
470
+ return if vol && vol["status"] == "available"
471
+
472
+ raise Errors::VolumeError, "volume #{volume_id} did not become available" if Time.now > deadline
473
+
474
+ sleep 2
475
+ end
476
+ end
477
+
478
+ def wait_for_server_state(server_id, target_state, max_attempts)
479
+ Utils::Retry.poll(max_attempts:, interval: 2) do
480
+ server = get_server_api(server_id)
481
+ server if server["state"] == target_state
482
+ end
483
+ end
484
+
485
+ def find_network_by_name(name)
486
+ networks = get(vpc_url("/private-networks"))["private_networks"] || []
487
+ networks.find { |n| n["name"] == name }
488
+ end
489
+
490
+ def find_security_group_by_name(name)
491
+ sgs = get(instance_url("/security_groups"))["security_groups"] || []
492
+ sgs.find { |sg| sg["name"] == name }
493
+ end
494
+
495
+ def find_server_by_name(name)
496
+ list_servers_api.find { |s| s["name"] == name }
497
+ end
498
+
499
+ def find_image(name)
500
+ image_name = case name
501
+ when "ubuntu-24.04" then "ubuntu_noble"
502
+ when "ubuntu-22.04" then "ubuntu_jammy"
503
+ when "ubuntu-20.04" then "ubuntu_focal"
504
+ when "debian-12" then "debian_bookworm"
505
+ when "debian-11" then "debian_bullseye"
506
+ else name
507
+ end
508
+
509
+ images = list_images(name: image_name)
510
+ images&.first
511
+ end
512
+
513
+ def to_network(data)
514
+ Objects::Network::Record.new(
515
+ id: data["id"],
516
+ name: data["name"],
517
+ ip_range: data.dig("subnets", 0, "subnet") || data["subnets"]&.first
518
+ )
519
+ end
520
+
521
+ def to_firewall(data)
522
+ Objects::Firewall::Record.new(
523
+ id: data["id"],
524
+ name: data["name"]
525
+ )
526
+ end
527
+
528
+ def to_server(data, fetch_private_ip: false)
529
+ # Scaleway doesn't include private_ips in the NIC response directly
530
+ # We'd need to call IPAM API which adds complexity
531
+ # Instead, private IP discovery happens via SSH in setup_k3s
532
+ Objects::Server::Record.new(
533
+ id: data["id"],
534
+ name: data["name"],
535
+ status: data["state"],
536
+ public_ipv4: data.dig("public_ip", "address"),
537
+ private_ipv4: nil
538
+ )
539
+ end
540
+
541
+ def to_volume(data)
542
+ server_id = data["references"]&.find { |r|
543
+ r["product_resource_type"] == "instance_server"
544
+ }&.dig("product_resource_id")
545
+
546
+ Objects::Volume::Record.new(
547
+ id: data["id"],
548
+ name: data["name"],
549
+ size: (data["size"] || 0) / 1_000_000_000,
550
+ location: data["zone"],
551
+ status: data["status"],
552
+ server_id:,
553
+ device_path: nil
554
+ )
555
+ end
556
+ end
557
+ end
558
+ end
559
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ module Cloud
6
+ def self.for(config)
7
+ Factory.for(config)
8
+ end
9
+
10
+ def self.validate(config, provider)
11
+ Factory.validate(config, provider)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nvoi
4
+ module External
5
+ # Containerd manages container operations on remote servers via containerd/ctr
6
+ class Containerd
7
+ attr_reader :ssh
8
+
9
+ def initialize(ssh)
10
+ @ssh = ssh
11
+ end
12
+
13
+ # Build image locally, save to tar, rsync to remote, load with containerd
14
+ def build_and_deploy_image(path, tag, cache_from: nil)
15
+ cache_args = cache_from ? "--cache-from #{cache_from}" : ""
16
+ local_build_cmd = "cd #{path} && DOCKER_BUILDKIT=1 docker build --platform linux/amd64 #{cache_args} --build-arg BUILDKIT_INLINE_CACHE=1 -t #{tag} ."
17
+
18
+ unless system("bash", "-c", local_build_cmd)
19
+ raise Errors::SshError, "local build failed"
20
+ end
21
+
22
+ # Tag as :latest for next build's cache
23
+ system("docker", "tag", tag, cache_from) if cache_from
24
+
25
+ tar_file = "/tmp/#{tag.tr(':', '_')}.tar"
26
+ unless system("docker", "save", tag, "-o", tar_file)
27
+ raise Errors::SshError, "docker save failed"
28
+ end
29
+
30
+ begin
31
+ remote_tar_path = "/tmp/#{tag.tr(':', '_')}.tar"
32
+ rsync_cmd = [
33
+ "rsync", "-avz",
34
+ "-e", "ssh -i #{@ssh.ssh_key} -o StrictHostKeyChecking=no",
35
+ tar_file,
36
+ "#{@ssh.user}@#{@ssh.ip}:#{remote_tar_path}"
37
+ ]
38
+
39
+ unless system(*rsync_cmd)
40
+ raise Errors::SshError, "rsync failed"
41
+ end
42
+
43
+ Nvoi.logger.info "Importing image into containerd..."
44
+ @ssh.execute("sudo ctr -n k8s.io images import #{remote_tar_path}")
45
+
46
+ full_image_ref = "docker.io/library/#{tag}"
47
+
48
+ begin
49
+ @ssh.execute("sudo ctr -n k8s.io images tag #{full_image_ref} #{tag}")
50
+ rescue Errors::SshCommandError => e
51
+ list_output = @ssh.execute("sudo ctr -n k8s.io images ls") rescue ""
52
+ raise Errors::SshError, "failed to tag imported image: #{e.message}\nAvailable images:\n#{list_output}"
53
+ end
54
+
55
+ @ssh.execute_ignore_errors("rm #{remote_tar_path}")
56
+ ensure
57
+ File.delete(tar_file) if File.exist?(tar_file)
58
+ end
59
+ end
60
+
61
+ def list_images(filter)
62
+ output = @ssh.execute("sudo ctr -n k8s.io images ls -q | grep '#{filter}' | sort -r")
63
+ return [] if output.empty?
64
+
65
+ output.split("\n")
66
+ rescue Errors::SshCommandError
67
+ []
68
+ end
69
+
70
+ def cleanup_old_images(prefix, keep_tags)
71
+ all_images = list_images(prefix)
72
+ return if all_images.empty?
73
+
74
+ remove_images = all_images.reject do |img|
75
+ keep_tags.any? { |tag| img.include?(tag) }
76
+ end
77
+
78
+ return if remove_images.empty?
79
+
80
+ remove_images.each do |img|
81
+ @ssh.execute_ignore_errors("sudo ctr -n k8s.io images rm #{img}")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end