nvoi 0.1.7 → 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.
- checksums.yaml +4 -4
- data/Gemfile +1 -5
- data/Gemfile.lock +17 -8
- data/Rakefile +1 -1
- data/lib/nvoi/cli/config/command.rb +46 -41
- data/lib/nvoi/cli/credentials/edit/command.rb +20 -20
- data/lib/nvoi/cli/credentials/show/command.rb +1 -1
- data/lib/nvoi/cli/db/command.rb +10 -10
- data/lib/nvoi/cli/delete/command.rb +2 -2
- data/lib/nvoi/cli/deploy/command.rb +29 -13
- data/lib/nvoi/cli/deploy/steps/build_image.rb +48 -6
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +2 -2
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +3 -13
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +1 -1
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +1 -1
- data/lib/nvoi/cli/exec/command.rb +3 -3
- data/lib/nvoi/cli/logs/command.rb +2 -2
- data/lib/nvoi/cli/onboard/command.rb +176 -622
- data/lib/nvoi/cli/onboard/steps/app.rb +108 -0
- data/lib/nvoi/cli/onboard/steps/app_name.rb +26 -0
- data/lib/nvoi/cli/onboard/steps/compute.rb +139 -0
- data/lib/nvoi/cli/onboard/steps/database.rb +97 -0
- data/lib/nvoi/cli/onboard/steps/domain.rb +48 -0
- data/lib/nvoi/cli/onboard/steps/env.rb +67 -0
- data/lib/nvoi/cli/onboard/ui.rb +84 -0
- data/lib/nvoi/cli/unlock/command.rb +2 -2
- data/lib/nvoi/cli.rb +0 -32
- data/lib/nvoi/configuration/app_service.rb +54 -0
- data/lib/nvoi/configuration/application.rb +44 -0
- data/lib/nvoi/configuration/builder.rb +417 -0
- data/lib/nvoi/configuration/database.rb +56 -0
- data/lib/nvoi/configuration/deploy.rb +15 -0
- data/lib/nvoi/{objects/service_spec.rb → configuration/deployment.rb} +4 -3
- data/lib/nvoi/{objects/config_override.rb → configuration/override.rb} +4 -4
- data/lib/nvoi/configuration/providers.rb +78 -0
- data/lib/nvoi/configuration/result.rb +43 -0
- data/lib/nvoi/configuration/root.rb +234 -0
- data/lib/nvoi/configuration/server.rb +39 -0
- data/lib/nvoi/configuration/service.rb +62 -0
- data/lib/nvoi/external/cloud/aws.rb +12 -12
- data/lib/nvoi/external/cloud/hetzner.rb +7 -7
- data/lib/nvoi/external/cloud/scaleway.rb +7 -7
- data/lib/nvoi/external/cloud/types.rb +42 -0
- data/lib/nvoi/external/containerd.rb +1 -48
- data/lib/nvoi/external/database/mysql.rb +1 -1
- data/lib/nvoi/external/database/postgres.rb +1 -1
- data/lib/nvoi/external/database/provider.rb +1 -1
- data/lib/nvoi/external/database/sqlite.rb +1 -1
- data/lib/nvoi/external/database/types.rb +55 -0
- data/lib/nvoi/external/dns/cloudflare.rb +6 -6
- data/lib/nvoi/external/dns/types.rb +24 -0
- data/lib/nvoi/external/ssh.rb +0 -12
- data/lib/nvoi/external/ssh_tunnel.rb +100 -0
- data/lib/nvoi/utils/config_loader.rb +12 -12
- data/lib/nvoi/utils/credential_store.rb +4 -4
- data/lib/nvoi/utils/env_resolver.rb +3 -3
- data/lib/nvoi/utils/namer.rb +2 -2
- data/lib/nvoi/utils/presence.rb +23 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +2 -17
- metadata +96 -57
- data/.claude/todo/refactor/00-overview.md +0 -171
- data/.claude/todo/refactor/01-objects.md +0 -96
- data/.claude/todo/refactor/02-utils.md +0 -143
- data/.claude/todo/refactor/03-external-cloud.md +0 -164
- data/.claude/todo/refactor/04-external-dns.md +0 -104
- data/.claude/todo/refactor/05-external.md +0 -133
- data/.claude/todo/refactor/06-cli.md +0 -123
- data/.claude/todo/refactor/07-cli-deploy-command.md +0 -177
- data/.claude/todo/refactor/08-cli-deploy-steps.md +0 -201
- data/.claude/todo/refactor/09-cli-delete-command.md +0 -169
- data/.claude/todo/refactor/10-cli-exec-command.md +0 -157
- data/.claude/todo/refactor/11-cli-credentials-command.md +0 -190
- data/.claude/todo/refactor/12-cli-db-command.md +0 -128
- data/.claude/todo/refactor/_target.md +0 -79
- data/.claude/todo/refactor-execution/00-entrypoint.md +0 -49
- data/.claude/todo/refactor-execution/01-objects.md +0 -42
- data/.claude/todo/refactor-execution/02-utils.md +0 -41
- data/.claude/todo/refactor-execution/03-external-cloud.md +0 -38
- data/.claude/todo/refactor-execution/04-external-dns.md +0 -35
- data/.claude/todo/refactor-execution/05-external-other.md +0 -46
- data/.claude/todo/refactor-execution/06-cli-deploy.md +0 -45
- data/.claude/todo/refactor-execution/07-cli-delete.md +0 -43
- data/.claude/todo/refactor-execution/08-cli-exec.md +0 -30
- data/.claude/todo/refactor-execution/09-cli-credentials.md +0 -34
- data/.claude/todo/refactor-execution/10-cli-db.md +0 -31
- data/.claude/todo/refactor-execution/11-cli-router.md +0 -44
- data/.claude/todo/refactor-execution/12-cleanup.md +0 -120
- data/.claude/todo/refactor-execution/_monitoring-strategy.md +0 -126
- data/.claude/todo/scaleway.impl.md +0 -644
- data/.claude/todo/scaleway.reference.md +0 -520
- data/.claude/todos.md +0 -550
- data/ingest +0 -0
- data/lib/nvoi/config_api/actions/app.rb +0 -53
- data/lib/nvoi/config_api/actions/compute_provider.rb +0 -55
- data/lib/nvoi/config_api/actions/database.rb +0 -70
- data/lib/nvoi/config_api/actions/domain_provider.rb +0 -40
- data/lib/nvoi/config_api/actions/env.rb +0 -32
- data/lib/nvoi/config_api/actions/init.rb +0 -67
- data/lib/nvoi/config_api/actions/secret.rb +0 -32
- data/lib/nvoi/config_api/actions/server.rb +0 -66
- data/lib/nvoi/config_api/actions/service.rb +0 -52
- data/lib/nvoi/config_api/actions/volume.rb +0 -40
- data/lib/nvoi/config_api/base.rb +0 -38
- data/lib/nvoi/config_api/result.rb +0 -26
- data/lib/nvoi/config_api.rb +0 -93
- data/lib/nvoi/objects/configuration.rb +0 -483
- data/lib/nvoi/objects/database.rb +0 -56
- data/lib/nvoi/objects/dns.rb +0 -14
- data/lib/nvoi/objects/firewall.rb +0 -11
- data/lib/nvoi/objects/network.rb +0 -11
- data/lib/nvoi/objects/server.rb +0 -14
- data/lib/nvoi/objects/tunnel.rb +0 -14
- 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`?
|