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,533 @@
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
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: 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
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
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
+ private
314
+
315
+ def zone_to_region(zone)
316
+ zone.split("-")[0..1].join("-")
317
+ end
318
+
319
+ def instance_url(path)
320
+ "#{INSTANCE_API_BASE}/zones/#{@zone}#{path}"
321
+ end
322
+
323
+ def vpc_url(path)
324
+ "#{VPC_API_BASE}/regions/#{@region}#{path}"
325
+ end
326
+
327
+ def block_url(path)
328
+ "#{BLOCK_API_BASE}/zones/#{@zone}#{path}"
329
+ end
330
+
331
+ def build_connection
332
+ Faraday.new do |f|
333
+ f.request :json
334
+ f.response :json
335
+ f.headers["X-Auth-Token"] = @secret_key
336
+ f.headers["Content-Type"] = "application/json"
337
+ end
338
+ end
339
+
340
+ def get(url)
341
+ response = @conn.get(url)
342
+ handle_response(response)
343
+ end
344
+
345
+ def post(url, payload = {})
346
+ response = @conn.post(url, payload)
347
+ handle_response(response, url, payload)
348
+ end
349
+
350
+ def patch(url, payload = {})
351
+ response = @conn.patch(url, payload)
352
+ handle_response(response)
353
+ end
354
+
355
+ def delete(url)
356
+ response = @conn.delete(url)
357
+ return nil if response.status == 204
358
+ handle_response(response)
359
+ end
360
+
361
+ def handle_response(response, url = nil, payload = nil)
362
+ case response.status
363
+ when 200..299
364
+ response.body
365
+ when 401
366
+ raise Errors::AuthenticationError, "Invalid Scaleway API token"
367
+ when 403
368
+ raise Errors::AuthenticationError, "Forbidden: check project_id and permissions"
369
+ when 404
370
+ raise Errors::NotFoundError, parse_error(response)
371
+ when 409
372
+ raise Errors::ConflictError, parse_error(response)
373
+ when 422
374
+ raise Errors::ValidationError, parse_error(response)
375
+ when 429
376
+ raise Errors::RateLimitError, "Rate limited, retry later"
377
+ else
378
+ debug = "HTTP #{response.status}: #{parse_error(response)}"
379
+ debug += "\nURL: #{url}" if url
380
+ debug += "\nPayload: #{payload.inspect}" if payload
381
+ raise Errors::ApiError, debug
382
+ end
383
+ end
384
+
385
+ def parse_error(response)
386
+ if response.body.is_a?(Hash)
387
+ response.body["message"] || response.body.inspect
388
+ else
389
+ response.body.to_s
390
+ end
391
+ end
392
+
393
+ def list_servers_api
394
+ get(instance_url("/servers"))["servers"] || []
395
+ end
396
+
397
+ def get_server_api(id)
398
+ get(instance_url("/servers/#{id}"))["server"]
399
+ end
400
+
401
+ def server_action(id, action)
402
+ post(instance_url("/servers/#{id}/action"), { action: })
403
+ end
404
+
405
+ def list_server_types
406
+ get(instance_url("/products/servers"))["servers"] || {}
407
+ end
408
+
409
+ def list_images(name: nil, arch: "x86_64")
410
+ params = ["arch=#{arch}"]
411
+ params << "name=#{name}" if name
412
+ get(instance_url("/images?#{params.join("&")}"))["images"] || []
413
+ end
414
+
415
+ def list_volumes
416
+ get(block_url("/volumes"))["volumes"] || []
417
+ end
418
+
419
+ def list_private_nics(server_id)
420
+ get(instance_url("/servers/#{server_id}/private_nics"))["private_nics"] || []
421
+ end
422
+
423
+ def create_private_nic(server_id, private_network_id)
424
+ post(instance_url("/servers/#{server_id}/private_nics"), { private_network_id: })["private_nic"]
425
+ end
426
+
427
+ def delete_private_nic(server_id, nic_id)
428
+ delete(instance_url("/servers/#{server_id}/private_nics/#{nic_id}"))
429
+ end
430
+
431
+ def set_user_data(server_id, key, content)
432
+ url = instance_url("/servers/#{server_id}/user_data/#{key}")
433
+ response = @conn.patch(url) do |req|
434
+ req.headers["Content-Type"] = "text/plain"
435
+ req.body = content
436
+ end
437
+ handle_response(response)
438
+ end
439
+
440
+ def wait_for_volume_available(volume_id, timeout: 60)
441
+ deadline = Time.now + timeout
442
+ loop do
443
+ vol = get(block_url("/volumes/#{volume_id}"))
444
+ return if vol && vol["status"] == "available"
445
+
446
+ raise Errors::VolumeError, "volume #{volume_id} did not become available" if Time.now > deadline
447
+
448
+ sleep 2
449
+ end
450
+ end
451
+
452
+ def wait_for_server_state(server_id, target_state, max_attempts)
453
+ Utils::Retry.poll(max_attempts: max_attempts, interval: 2) do
454
+ server = get_server_api(server_id)
455
+ server if server["state"] == target_state
456
+ end
457
+ end
458
+
459
+ def find_network_by_name(name)
460
+ networks = get(vpc_url("/private-networks"))["private_networks"] || []
461
+ networks.find { |n| n["name"] == name }
462
+ end
463
+
464
+ def find_security_group_by_name(name)
465
+ sgs = get(instance_url("/security_groups"))["security_groups"] || []
466
+ sgs.find { |sg| sg["name"] == name }
467
+ end
468
+
469
+ def find_server_by_name(name)
470
+ list_servers_api.find { |s| s["name"] == name }
471
+ end
472
+
473
+ def find_image(name)
474
+ image_name = case name
475
+ when "ubuntu-24.04" then "ubuntu_noble"
476
+ when "ubuntu-22.04" then "ubuntu_jammy"
477
+ when "ubuntu-20.04" then "ubuntu_focal"
478
+ when "debian-12" then "debian_bookworm"
479
+ when "debian-11" then "debian_bullseye"
480
+ else name
481
+ end
482
+
483
+ images = list_images(name: image_name)
484
+ images&.first
485
+ end
486
+
487
+ def to_network(data)
488
+ Objects::Network::Record.new(
489
+ id: data["id"],
490
+ name: data["name"],
491
+ ip_range: data.dig("subnets", 0, "subnet") || data["subnets"]&.first
492
+ )
493
+ end
494
+
495
+ def to_firewall(data)
496
+ Objects::Firewall::Record.new(
497
+ id: data["id"],
498
+ name: data["name"]
499
+ )
500
+ end
501
+
502
+ def to_server(data, fetch_private_ip: false)
503
+ # Scaleway doesn't include private_ips in the NIC response directly
504
+ # We'd need to call IPAM API which adds complexity
505
+ # Instead, private IP discovery happens via SSH in setup_k3s
506
+ Objects::Server::Record.new(
507
+ id: data["id"],
508
+ name: data["name"],
509
+ status: data["state"],
510
+ public_ipv4: data.dig("public_ip", "address"),
511
+ private_ipv4: nil
512
+ )
513
+ end
514
+
515
+ def to_volume(data)
516
+ server_id = data["references"]&.find { |r|
517
+ r["product_resource_type"] == "instance_server"
518
+ }&.dig("product_resource_id")
519
+
520
+ Objects::Volume::Record.new(
521
+ id: data["id"],
522
+ name: data["name"],
523
+ size: (data["size"] || 0) / 1_000_000_000,
524
+ location: data["zone"],
525
+ status: data["status"],
526
+ server_id:,
527
+ device_path: nil
528
+ )
529
+ end
530
+ end
531
+ end
532
+ end
533
+ 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,82 @@
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
+ tar_file = "/tmp/#{tag.tr(':', '_')}.tar"
23
+ unless system("docker", "save", tag, "-o", tar_file)
24
+ raise Errors::SshError, "docker save failed"
25
+ end
26
+
27
+ begin
28
+ remote_tar_path = "/tmp/#{tag.tr(':', '_')}.tar"
29
+ rsync_cmd = [
30
+ "rsync", "-avz",
31
+ "-e", "ssh -i #{@ssh.ssh_key} -o StrictHostKeyChecking=no",
32
+ tar_file,
33
+ "#{@ssh.user}@#{@ssh.ip}:#{remote_tar_path}"
34
+ ]
35
+
36
+ unless system(*rsync_cmd)
37
+ raise Errors::SshError, "rsync failed"
38
+ end
39
+
40
+ @ssh.execute("sudo ctr -n k8s.io images import #{remote_tar_path}")
41
+
42
+ full_image_ref = "docker.io/library/#{tag}"
43
+
44
+ begin
45
+ @ssh.execute("sudo ctr -n k8s.io images tag #{full_image_ref} #{tag}")
46
+ rescue Errors::SshCommandError => e
47
+ list_output = @ssh.execute("sudo ctr -n k8s.io images ls") rescue ""
48
+ raise Errors::SshError, "failed to tag imported image: #{e.message}\nAvailable images:\n#{list_output}"
49
+ end
50
+
51
+ @ssh.execute_ignore_errors("rm #{remote_tar_path}")
52
+ ensure
53
+ File.delete(tar_file) if File.exist?(tar_file)
54
+ end
55
+ end
56
+
57
+ def list_images(filter)
58
+ output = @ssh.execute("sudo ctr -n k8s.io images ls -q | grep '#{filter}' | sort -r")
59
+ return [] if output.empty?
60
+
61
+ output.split("\n")
62
+ rescue Errors::SshCommandError
63
+ []
64
+ end
65
+
66
+ def cleanup_old_images(prefix, keep_tags)
67
+ all_images = list_images(prefix)
68
+ return if all_images.empty?
69
+
70
+ remove_images = all_images.reject do |img|
71
+ keep_tags.any? { |tag| img.include?(tag) }
72
+ end
73
+
74
+ return if remove_images.empty?
75
+
76
+ remove_images.each do |img|
77
+ @ssh.execute_ignore_errors("sudo ctr -n k8s.io images rm #{img}")
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end