nvoi 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -5
  3. data/Gemfile.lock +17 -8
  4. data/Rakefile +1 -1
  5. data/lib/nvoi/cli/config/command.rb +46 -41
  6. data/lib/nvoi/cli/credentials/edit/command.rb +20 -20
  7. data/lib/nvoi/cli/credentials/show/command.rb +1 -1
  8. data/lib/nvoi/cli/db/command.rb +10 -10
  9. data/lib/nvoi/cli/delete/command.rb +2 -2
  10. data/lib/nvoi/cli/deploy/command.rb +2 -2
  11. data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
  12. data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
  13. data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
  14. data/lib/nvoi/cli/exec/command.rb +3 -3
  15. data/lib/nvoi/cli/logs/command.rb +2 -2
  16. data/lib/nvoi/cli/onboard/command.rb +176 -622
  17. data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
  18. data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
  19. data/lib/nvoi/cli/onboard/steps/compute.rb +139 -0
  20. data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
  21. data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
  22. data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
  23. data/lib/nvoi/cli/onboard/ui.rb +84 -0
  24. data/lib/nvoi/cli/unlock/command.rb +2 -2
  25. data/lib/nvoi/cli.rb +0 -32
  26. data/lib/nvoi/configuration/app_service.rb +54 -0
  27. data/lib/nvoi/configuration/application.rb +44 -0
  28. data/lib/nvoi/configuration/builder.rb +417 -0
  29. data/lib/nvoi/configuration/database.rb +56 -0
  30. data/lib/nvoi/configuration/deploy.rb +15 -0
  31. data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
  32. data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
  33. data/lib/nvoi/configuration/providers.rb +78 -0
  34. data/lib/nvoi/configuration/result.rb +43 -0
  35. data/lib/nvoi/configuration/root.rb +234 -0
  36. data/lib/nvoi/configuration/server.rb +39 -0
  37. data/lib/nvoi/configuration/service.rb +62 -0
  38. data/lib/nvoi/external/cloud/aws.rb +12 -12
  39. data/lib/nvoi/external/cloud/hetzner.rb +7 -7
  40. data/lib/nvoi/external/cloud/scaleway.rb +7 -7
  41. data/lib/nvoi/external/cloud/types.rb +42 -0
  42. data/lib/nvoi/external/database/mysql.rb +1 -1
  43. data/lib/nvoi/external/database/postgres.rb +1 -1
  44. data/lib/nvoi/external/database/provider.rb +1 -1
  45. data/lib/nvoi/external/database/sqlite.rb +1 -1
  46. data/lib/nvoi/external/database/types.rb +55 -0
  47. data/lib/nvoi/external/dns/cloudflare.rb +6 -6
  48. data/lib/nvoi/external/dns/types.rb +24 -0
  49. data/lib/nvoi/utils/config_loader.rb +12 -12
  50. data/lib/nvoi/utils/credential_store.rb +4 -4
  51. data/lib/nvoi/utils/env_resolver.rb +3 -3
  52. data/lib/nvoi/utils/namer.rb +2 -2
  53. data/lib/nvoi/utils/presence.rb +23 -0
  54. data/lib/nvoi/version.rb +1 -1
  55. data/lib/nvoi.rb +2 -17
  56. metadata +95 -58
  57. data/.claude/todo/refactor/00-overview.md +0 -171
  58. data/.claude/todo/refactor/01-objects.md +0 -96
  59. data/.claude/todo/refactor/02-utils.md +0 -143
  60. data/.claude/todo/refactor/03-external-cloud.md +0 -164
  61. data/.claude/todo/refactor/04-external-dns.md +0 -104
  62. data/.claude/todo/refactor/05-external.md +0 -133
  63. data/.claude/todo/refactor/06-cli.md +0 -123
  64. data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
  65. data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
  66. data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
  67. data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
  68. data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
  69. data/.claude/todo/refactor/12-cli-db-command.md +0 -128
  70. data/.claude/todo/refactor/_target.md +0 -79
  71. data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
  72. data/.claude/todo/refactor-execution/01-objects.md +0 -42
  73. data/.claude/todo/refactor-execution/02-utils.md +0 -41
  74. data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
  75. data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
  76. data/.claude/todo/refactor-execution/05-external-other.md +0 -46
  77. data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
  78. data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
  79. data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
  80. data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
  81. data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
  82. data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
  83. data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
  84. data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
  85. data/.claude/todo/scaleway.impl.md +0 -644
  86. data/.claude/todo/scaleway.reference.md +0 -520
  87. data/.claude/todos/buckets.md +0 -41
  88. data/.claude/todos.md +0 -550
  89. data/ingest +0 -0
  90. data/lib/nvoi/config_api/actions/app.rb +0 -53
  91. data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
  92. data/lib/nvoi/config_api/actions/database.rb +0 -70
  93. data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
  94. data/lib/nvoi/config_api/actions/env.rb +0 -32
  95. data/lib/nvoi/config_api/actions/init.rb +0 -67
  96. data/lib/nvoi/config_api/actions/secret.rb +0 -32
  97. data/lib/nvoi/config_api/actions/server.rb +0 -66
  98. data/lib/nvoi/config_api/actions/service.rb +0 -52
  99. data/lib/nvoi/config_api/actions/volume.rb +0 -40
  100. data/lib/nvoi/config_api/base.rb +0 -38
  101. data/lib/nvoi/config_api/result.rb +0 -26
  102. data/lib/nvoi/config_api.rb +0 -93
  103. data/lib/nvoi/objects/configuration.rb +0 -483
  104. data/lib/nvoi/objects/database.rb +0 -56
  105. data/lib/nvoi/objects/dns.rb +0 -14
  106. data/lib/nvoi/objects/firewall.rb +0 -11
  107. data/lib/nvoi/objects/network.rb +0 -11
  108. data/lib/nvoi/objects/server.rb +0 -14
  109. data/lib/nvoi/objects/tunnel.rb +0 -14
  110. data/lib/nvoi/objects/volume.rb +0 -17
@@ -1,644 +0,0 @@
1
- # Scaleway Provider Implementation Guide
2
-
3
- ## Overview
4
-
5
- This document provides implementation details for creating a Scaleway provider that conforms to the existing `Nvoi::Providers::Base` interface.
6
-
7
- ## Files to Create
8
-
9
- ```
10
- lib/nvoi/providers/
11
- ├── scaleway.rb # Main provider class
12
- └── scaleway_client.rb # HTTP client for Scaleway API
13
- ```
14
-
15
- ---
16
-
17
- ## 1. ScalewayClient (`lib/nvoi/providers/scaleway_client.rb`)
18
-
19
- ### Class Structure
20
-
21
- ```ruby
22
- # frozen_string_literal: true
23
-
24
- require "faraday"
25
- require "json"
26
-
27
- module Nvoi
28
- module Providers
29
- class ScalewayClient
30
- INSTANCE_API_BASE = "https://api.scaleway.com/instance/v1"
31
- VPC_API_BASE = "https://api.scaleway.com/vpc/v2"
32
- BLOCK_API_BASE = "https://api.scaleway.com/block/v1alpha1"
33
-
34
- def initialize(secret_key, project_id, zone: "fr-par-1")
35
- @secret_key = secret_key
36
- @project_id = project_id
37
- @zone = zone
38
- @region = zone_to_region(zone)
39
- @conn = build_connection
40
- end
41
-
42
- # ... methods below
43
- end
44
- end
45
- end
46
- ```
47
-
48
- ### Required Methods
49
-
50
- #### HTTP Helpers
51
-
52
- ```ruby
53
- def get(url)
54
- def post(url, payload = {})
55
- def patch(url, payload = {})
56
- def delete(url)
57
- def handle_response(response)
58
- ```
59
-
60
- #### Server Methods
61
-
62
- ```ruby
63
- def list_servers
64
- # GET /instance/v1/zones/{zone}/servers
65
- # Returns: array of server hashes
66
-
67
- def get_server(id)
68
- # GET /instance/v1/zones/{zone}/servers/{id}
69
- # Returns: server hash
70
-
71
- def create_server(payload)
72
- # POST /instance/v1/zones/{zone}/servers
73
- # Payload: { name:, commercial_type:, image:, project:, security_group:, tags: }
74
- # Returns: server hash
75
-
76
- def delete_server(id)
77
- # DELETE /instance/v1/zones/{zone}/servers/{id}
78
-
79
- def server_action(id, action)
80
- # POST /instance/v1/zones/{zone}/servers/{id}/action
81
- # Payload: { action: "poweron" | "poweroff" | "terminate" }
82
- ```
83
-
84
- #### Server Type / Image Methods
85
-
86
- ```ruby
87
- def list_server_types
88
- # GET /instance/v1/zones/{zone}/products/servers
89
- # Returns: hash of server_type_name => details
90
-
91
- def list_images(name: nil, arch: "x86_64")
92
- # GET /instance/v1/zones/{zone}/images?name={name}&arch={arch}
93
- # Returns: array of image hashes
94
- ```
95
-
96
- #### Security Group Methods
97
-
98
- ```ruby
99
- def list_security_groups
100
- # GET /instance/v1/zones/{zone}/security_groups
101
- # Returns: array of security_group hashes
102
-
103
- def get_security_group(id)
104
- # GET /instance/v1/zones/{zone}/security_groups/{id}
105
-
106
- def create_security_group(payload)
107
- # POST /instance/v1/zones/{zone}/security_groups
108
- # Payload: { name:, project:, stateful:, inbound_default_policy:, outbound_default_policy: }
109
-
110
- def delete_security_group(id)
111
- # DELETE /instance/v1/zones/{zone}/security_groups/{id}
112
-
113
- def create_security_group_rule(security_group_id, payload)
114
- # POST /instance/v1/zones/{zone}/security_groups/{id}/rules
115
- # Payload: { protocol:, direction:, action:, ip_range:, dest_port_from:, dest_port_to: }
116
- ```
117
-
118
- #### Private Network Methods (Regional - VPC API)
119
-
120
- ```ruby
121
- def list_private_networks
122
- # GET /vpc/v2/regions/{region}/private-networks
123
- # Returns: array of private_network hashes
124
-
125
- def get_private_network(id)
126
- # GET /vpc/v2/regions/{region}/private-networks/{id}
127
-
128
- def create_private_network(payload)
129
- # POST /vpc/v2/regions/{region}/private-networks
130
- # Payload: { name:, project_id:, subnets: ["10.0.1.0/24"] }
131
-
132
- def delete_private_network(id)
133
- # DELETE /vpc/v2/regions/{region}/private-networks/{id}
134
- ```
135
-
136
- #### Private NIC Methods (Zoned - Instance API)
137
-
138
- ```ruby
139
- def list_private_nics(server_id)
140
- # GET /instance/v1/zones/{zone}/servers/{server_id}/private_nics
141
-
142
- def create_private_nic(server_id, private_network_id)
143
- # POST /instance/v1/zones/{zone}/servers/{server_id}/private_nics
144
- # Payload: { private_network_id: }
145
-
146
- def delete_private_nic(server_id, nic_id)
147
- # DELETE /instance/v1/zones/{zone}/servers/{server_id}/private_nics/{nic_id}
148
- ```
149
-
150
- #### Volume Methods (Zoned - Block API)
151
-
152
- ```ruby
153
- def list_volumes
154
- # GET /block/v1alpha1/zones/{zone}/volumes
155
-
156
- def get_volume(id)
157
- # GET /block/v1alpha1/zones/{zone}/volumes/{id}
158
-
159
- def create_volume(payload)
160
- # POST /block/v1alpha1/zones/{zone}/volumes
161
- # Payload: { name:, perf_iops:, from_empty: { size: }, project_id: }
162
-
163
- def delete_volume(id)
164
- # DELETE /block/v1alpha1/zones/{zone}/volumes/{id}
165
-
166
- def update_server_volumes(server_id, volumes_hash)
167
- # PATCH /instance/v1/zones/{zone}/servers/{server_id}
168
- # Payload: { volumes: { "0": { id: }, "1": { id: } } }
169
- ```
170
-
171
- #### Helper Methods
172
-
173
- ```ruby
174
- private
175
-
176
- def zone_to_region(zone)
177
- # "fr-par-1" => "fr-par"
178
- zone.split("-")[0..1].join("-")
179
- end
180
-
181
- def instance_url(path)
182
- "#{INSTANCE_API_BASE}/zones/#{@zone}#{path}"
183
- end
184
-
185
- def vpc_url(path)
186
- "#{VPC_API_BASE}/regions/#{@region}#{path}"
187
- end
188
-
189
- def block_url(path)
190
- "#{BLOCK_API_BASE}/zones/#{@zone}#{path}"
191
- end
192
-
193
- def build_connection
194
- Faraday.new do |f|
195
- f.request :json
196
- f.response :json
197
- f.headers["X-Auth-Token"] = @secret_key
198
- f.headers["Content-Type"] = "application/json"
199
- end
200
- end
201
- ```
202
-
203
- ---
204
-
205
- ## 2. Scaleway Provider (`lib/nvoi/providers/scaleway.rb`)
206
-
207
- ### Class Structure
208
-
209
- ```ruby
210
- # frozen_string_literal: true
211
-
212
- require_relative "scaleway_client"
213
-
214
- module Nvoi
215
- module Providers
216
- class Scaleway < Base
217
- def initialize(secret_key, project_id, zone: "fr-par-1")
218
- @client = ScalewayClient.new(secret_key, project_id, zone: zone)
219
- @project_id = project_id
220
- @zone = zone
221
- end
222
-
223
- # ... interface methods below
224
- end
225
- end
226
- end
227
- ```
228
-
229
- ### Interface Method Implementations
230
-
231
- #### Network Operations
232
-
233
- ```ruby
234
- def find_or_create_network(name)
235
- # 1. List private networks, find by name
236
- # 2. If found, return to_network(network)
237
- # 3. If not found, create with:
238
- # - name: name
239
- # - project_id: @project_id
240
- # - subnets: [Constants::SUBNET_CIDR]
241
- # 4. Return to_network(created_network)
242
- end
243
-
244
- def get_network_by_name(name)
245
- # 1. List private networks, find by name
246
- # 2. Raise NetworkError if not found
247
- # 3. Return to_network(network)
248
- end
249
-
250
- def delete_network(id)
251
- # 1. First detach all resources (list servers, check private_nics)
252
- # 2. Delete private network
253
- # NOTE: Network must have no attached resources before deletion
254
- end
255
- ```
256
-
257
- #### Firewall Operations
258
-
259
- ```ruby
260
- def find_or_create_firewall(name)
261
- # 1. List security groups, find by name
262
- # 2. If found, return to_firewall(sg)
263
- # 3. If not found, create security group:
264
- # - name: name
265
- # - project: @project_id
266
- # - stateful: true
267
- # - inbound_default_policy: "drop"
268
- # - outbound_default_policy: "accept"
269
- # 4. Add SSH rule (port 22, TCP, inbound, accept, 0.0.0.0/0)
270
- # 5. Return to_firewall(created_sg)
271
- end
272
-
273
- def get_firewall_by_name(name)
274
- # 1. List security groups, find by name
275
- # 2. Raise FirewallError if not found
276
- # 3. Return to_firewall(sg)
277
- end
278
-
279
- def delete_firewall(id)
280
- # DELETE security group
281
- # NOTE: Cannot delete if servers are using it
282
- end
283
- ```
284
-
285
- #### Server Operations
286
-
287
- ```ruby
288
- def find_server(name)
289
- # 1. List servers, find by name
290
- # 2. Return nil if not found
291
- # 3. Return to_server(server)
292
- end
293
-
294
- def list_servers
295
- # 1. List all servers
296
- # 2. Map to to_server(s)
297
- end
298
-
299
- def create_server(opts)
300
- # 1. Resolve image name to UUID if needed
301
- # 2. Validate commercial_type exists
302
- # 3. Create server with:
303
- # - name: opts.name
304
- # - commercial_type: opts.type
305
- # - image: resolved_image_uuid
306
- # - project: @project_id
307
- # - security_group: opts.firewall_id (if provided)
308
- # - tags: []
309
- # 4. If opts.user_data provided, set via cloud-init (see note below)
310
- # 5. Power on the server (action: poweron)
311
- # 6. If opts.network_id provided, create private NIC after server is running
312
- # 7. Return to_server(server)
313
-
314
- # NOTE: user_data/cloud-init may need to be passed differently
315
- # Check if Scaleway supports user_data in create payload
316
- end
317
-
318
- def wait_for_server(server_id, max_attempts)
319
- # 1. Loop max_attempts times
320
- # 2. Get server, check state == "running"
321
- # 3. Sleep Constants::SERVER_READY_INTERVAL between checks
322
- # 4. Raise ServerCreationError if timeout
323
- # 5. Return to_server(server)
324
- end
325
-
326
- def delete_server(id)
327
- # 1. Get server to check current state
328
- # 2. List private NICs, delete each one
329
- # 3. Remove from security group (or just delete server)
330
- # 4. Terminate server (action: terminate)
331
- # NOTE: Scaleway may require server to be stopped first
332
- end
333
- ```
334
-
335
- #### Volume Operations
336
-
337
- ```ruby
338
- def create_volume(opts)
339
- # 1. Get server to find zone
340
- # 2. Create volume via Block API:
341
- # - name: opts.name
342
- # - perf_iops: 5000 (or configurable)
343
- # - from_empty: { size: opts.size * 1_000_000_000 } # GB to bytes
344
- # - project_id: @project_id
345
- # 3. Return to_volume(volume)
346
- end
347
-
348
- def get_volume(id)
349
- # 1. Get volume from Block API
350
- # 2. Return nil if not found
351
- # 3. Return to_volume(volume)
352
- end
353
-
354
- def get_volume_by_name(name)
355
- # 1. List volumes, find by name
356
- # 2. Return nil if not found
357
- # 3. Return to_volume(volume)
358
- end
359
-
360
- def delete_volume(id)
361
- # 1. Ensure volume is detached (status != "in_use")
362
- # 2. Delete via Block API
363
- end
364
-
365
- def attach_volume(volume_id, server_id)
366
- # 1. Get server to get current volumes
367
- # 2. Build new volumes hash including existing + new volume
368
- # 3. PATCH server with updated volumes
369
- # NOTE: Must include ALL volumes in the update, including root
370
- end
371
-
372
- def detach_volume(volume_id)
373
- # 1. Find which server has this volume
374
- # 2. Get server's current volumes
375
- # 3. Remove this volume from the hash
376
- # 4. PATCH server with updated volumes
377
- end
378
- ```
379
-
380
- #### Validation Operations
381
-
382
- ```ruby
383
- def validate_instance_type(instance_type)
384
- # 1. List server types
385
- # 2. Check if instance_type exists in the list
386
- # 3. Raise ValidationError if not found
387
- # 4. Return true
388
- end
389
-
390
- def validate_region(region)
391
- # 1. Check if region/zone is valid
392
- # Valid zones: fr-par-1, fr-par-2, fr-par-3, nl-ams-1, nl-ams-2, nl-ams-3, pl-waw-1, pl-waw-2, pl-waw-3
393
- # 2. Raise ValidationError if invalid
394
- # 3. Return true
395
- end
396
-
397
- def validate_credentials
398
- # 1. Try to list server types (simple API call)
399
- # 2. If AuthenticationError, raise ValidationError
400
- # 3. Return true
401
- end
402
- ```
403
-
404
- #### Private Converters
405
-
406
- ```ruby
407
- private
408
-
409
- def find_network_by_name(name)
410
- @client.list_private_networks.find { |n| n["name"] == name }
411
- end
412
-
413
- def find_security_group_by_name(name)
414
- @client.list_security_groups.find { |sg| sg["name"] == name }
415
- end
416
-
417
- def find_server_by_name(name)
418
- @client.list_servers.find { |s| s["name"] == name }
419
- end
420
-
421
- def find_image(name)
422
- # Map common names to Scaleway equivalents
423
- image_name = case name
424
- when "ubuntu-24.04" then "ubuntu_noble"
425
- when "ubuntu-22.04" then "ubuntu_jammy"
426
- when "ubuntu-20.04" then "ubuntu_focal"
427
- when "debian-12" then "debian_bookworm"
428
- else name
429
- end
430
-
431
- images = @client.list_images(name: image_name)
432
- images&.first
433
- end
434
-
435
- def to_network(data)
436
- Network.new(
437
- id: data["id"],
438
- name: data["name"],
439
- ip_range: data.dig("subnets", 0, "subnet") || data["subnets"]&.first
440
- )
441
- end
442
-
443
- def to_firewall(data)
444
- Firewall.new(
445
- id: data["id"],
446
- name: data["name"]
447
- )
448
- end
449
-
450
- def to_server(data)
451
- Server.new(
452
- id: data["id"],
453
- name: data["name"],
454
- status: data["state"], # NOTE: Scaleway uses "state", not "status"
455
- public_ipv4: data.dig("public_ip", "address")
456
- )
457
- end
458
-
459
- def to_volume(data)
460
- # Find server_id from references if attached
461
- server_id = data["references"]&.find { |r| r["product_resource_type"] == "instance_server" }&.dig("product_resource_id")
462
-
463
- Volume.new(
464
- id: data["id"],
465
- name: data["name"],
466
- size: data["size"] / 1_000_000_000, # bytes to GB
467
- location: data["zone"],
468
- status: data["status"],
469
- server_id: server_id,
470
- device_path: nil # Scaleway doesn't provide device path in API
471
- )
472
- end
473
- ```
474
-
475
- ---
476
-
477
- ## 3. Key Differences from Hetzner Implementation
478
-
479
- | Aspect | Hetzner | Scaleway |
480
- |--------|---------|----------|
481
- | Auth header | `Authorization: Bearer` | `X-Auth-Token` |
482
- | Server status field | `status` | `state` |
483
- | Public IP path | `public_net.ipv4.ip` | `public_ip.address` |
484
- | Server type param | `server_type` | `commercial_type` |
485
- | Image format | name string | UUID (resolve from name) |
486
- | Network attachment | At server creation | Via Private NIC (separate call) |
487
- | Firewall concept | Separate Firewall resource | Security Group |
488
- | Firewall rules | Defined at creation | Added via separate endpoint |
489
- | Volume API | Instance API | Block API (separate) |
490
- | Volume size | Integer GB | Bytes |
491
- | Volume attach | `attach` action | PATCH server volumes |
492
- | Location concept | `location` (datacenter) | `zone` (availability zone) |
493
- | Network scope | Network zone | Region |
494
-
495
- ---
496
-
497
- ## 4. Configuration Requirements
498
-
499
- Add to config loading (likely in `config/loader.rb` or similar):
500
-
501
- ```ruby
502
- # Scaleway credentials
503
- scaleway_secret_key: ENV["SCALEWAY_SECRET_KEY"]
504
- scaleway_project_id: ENV["SCALEWAY_PROJECT_ID"]
505
- scaleway_zone: ENV["SCALEWAY_ZONE"] || "fr-par-1"
506
- ```
507
-
508
- ---
509
-
510
- ## 5. Provider Registration
511
-
512
- Update `lib/nvoi/service/provider.rb` to include Scaleway:
513
-
514
- ```ruby
515
- def self.for(config)
516
- case config.provider
517
- when "hetzner"
518
- Providers::Hetzner.new(config.hetzner_token)
519
- when "scaleway"
520
- Providers::Scaleway.new(
521
- config.scaleway_secret_key,
522
- config.scaleway_project_id,
523
- zone: config.scaleway_zone
524
- )
525
- when "aws"
526
- Providers::AWS.new(...)
527
- else
528
- raise ConfigError, "unknown provider: #{config.provider}"
529
- end
530
- end
531
- ```
532
-
533
- ---
534
-
535
- ## 6. Testing Checklist
536
-
537
- Create test file: `test/nvoi/providers/scaleway_test.rb`
538
-
539
- Test cases to implement:
540
- - [ ] `test_find_or_create_network_creates_when_missing`
541
- - [ ] `test_find_or_create_network_returns_existing`
542
- - [ ] `test_get_network_by_name_raises_when_missing`
543
- - [ ] `test_delete_network`
544
- - [ ] `test_find_or_create_firewall_creates_with_ssh_rule`
545
- - [ ] `test_find_or_create_firewall_returns_existing`
546
- - [ ] `test_delete_firewall`
547
- - [ ] `test_find_server`
548
- - [ ] `test_list_servers`
549
- - [ ] `test_create_server`
550
- - [ ] `test_create_server_with_network`
551
- - [ ] `test_create_server_with_firewall`
552
- - [ ] `test_wait_for_server_success`
553
- - [ ] `test_wait_for_server_timeout`
554
- - [ ] `test_delete_server_cleans_up_nics`
555
- - [ ] `test_create_volume`
556
- - [ ] `test_attach_volume`
557
- - [ ] `test_detach_volume`
558
- - [ ] `test_delete_volume`
559
- - [ ] `test_validate_instance_type_valid`
560
- - [ ] `test_validate_instance_type_invalid`
561
- - [ ] `test_validate_region_valid`
562
- - [ ] `test_validate_region_invalid`
563
- - [ ] `test_validate_credentials_success`
564
- - [ ] `test_validate_credentials_failure`
565
-
566
- ---
567
-
568
- ## 7. Error Handling
569
-
570
- Map Scaleway errors to existing Nvoi errors:
571
-
572
- ```ruby
573
- def handle_response(response)
574
- case response.status
575
- when 200..299
576
- response.body
577
- when 401
578
- raise AuthenticationError, "Invalid Scaleway API token"
579
- when 403
580
- raise AuthenticationError, "Forbidden: check project_id and permissions"
581
- when 404
582
- raise NotFoundError, parse_error(response)
583
- when 409
584
- raise ConflictError, parse_error(response)
585
- when 422
586
- raise ValidationError, parse_error(response)
587
- when 429
588
- raise RateLimitError, "Rate limited, retry later"
589
- else
590
- raise APIError, parse_error(response)
591
- end
592
- end
593
-
594
- def parse_error(response)
595
- if response.body.is_a?(Hash)
596
- response.body["message"] || response.body.to_s
597
- else
598
- "HTTP #{response.status}: #{response.body}"
599
- end
600
- end
601
- ```
602
-
603
- ---
604
-
605
- ## 8. Cloud-Init / User Data
606
-
607
- Scaleway supports cloud-init via the `user_data` field. Research needed on exact format:
608
-
609
- ```ruby
610
- # Option 1: In create payload (if supported)
611
- {
612
- "name": "server",
613
- "commercial_type": "DEV1-S",
614
- "image": "uuid",
615
- "user_data": {
616
- "cloud-init": "base64_encoded_cloud_config"
617
- }
618
- }
619
-
620
- # Option 2: Separate endpoint after creation
621
- # PUT /instance/v1/zones/{zone}/servers/{id}/user_data/cloud-init
622
- ```
623
-
624
- ---
625
-
626
- ## 9. Implementation Order
627
-
628
- 1. **ScalewayClient** - HTTP client with all API methods
629
- 2. **Scaleway Provider** - Core interface implementation
630
- 3. **Image name resolution** - Map ubuntu-24.04 to Scaleway image UUIDs
631
- 4. **Tests** - Unit tests with mocked responses
632
- 5. **Integration** - Register in provider factory
633
- 6. **Config** - Add Scaleway config options
634
- 7. **Documentation** - Update README with Scaleway setup
635
-
636
- ---
637
-
638
- ## 10. Open Questions
639
-
640
- 1. **User data format**: Verify exact cloud-init payload format
641
- 2. **Default VPC**: Should we use default VPC or create custom?
642
- 3. **Volume device path**: Scaleway doesn't expose this - may need OS-level detection
643
- 4. **IP allocation**: Auto-assign public IP or require explicit request?
644
- 5. **Boot type**: Use `local` or `bootscript`?