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.
- checksums.yaml +4 -4
- data/.claude/todo/refactor/00-overview.md +171 -0
- data/.claude/todo/refactor/01-objects.md +96 -0
- data/.claude/todo/refactor/02-utils.md +143 -0
- data/.claude/todo/refactor/03-external-cloud.md +164 -0
- data/.claude/todo/refactor/04-external-dns.md +104 -0
- data/.claude/todo/refactor/05-external.md +133 -0
- data/.claude/todo/refactor/06-cli.md +123 -0
- data/.claude/todo/refactor/07-cli-deploy-command.md +177 -0
- data/.claude/todo/refactor/08-cli-deploy-steps.md +201 -0
- data/.claude/todo/refactor/09-cli-delete-command.md +169 -0
- data/.claude/todo/refactor/10-cli-exec-command.md +157 -0
- data/.claude/todo/refactor/11-cli-credentials-command.md +190 -0
- data/.claude/todo/refactor/_target.md +79 -0
- data/.claude/todo/scaleway.impl.md +644 -0
- data/.claude/todo/scaleway.reference.md +520 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +12 -2
- data/doc/config-schema.yaml +44 -11
- data/examples/golang/deploy.enc +0 -0
- data/examples/golang/main.go +18 -0
- data/exe/nvoi +3 -1
- data/lib/nvoi/cli/credentials/edit/command.rb +384 -0
- data/lib/nvoi/cli/credentials/show/command.rb +35 -0
- data/lib/nvoi/cli/db/command.rb +308 -0
- data/lib/nvoi/cli/delete/command.rb +75 -0
- data/lib/nvoi/cli/delete/steps/detach_volumes.rb +98 -0
- data/lib/nvoi/cli/delete/steps/teardown_dns.rb +49 -0
- data/lib/nvoi/cli/delete/steps/teardown_firewall.rb +46 -0
- data/lib/nvoi/cli/delete/steps/teardown_network.rb +30 -0
- data/lib/nvoi/cli/delete/steps/teardown_server.rb +50 -0
- data/lib/nvoi/cli/delete/steps/teardown_tunnel.rb +44 -0
- data/lib/nvoi/cli/delete/steps/teardown_volume.rb +61 -0
- data/lib/nvoi/cli/deploy/command.rb +184 -0
- data/lib/nvoi/cli/deploy/steps/build_image.rb +27 -0
- data/lib/nvoi/cli/deploy/steps/cleanup_images.rb +42 -0
- data/lib/nvoi/cli/deploy/steps/configure_tunnel.rb +100 -0
- data/lib/nvoi/cli/deploy/steps/deploy_service.rb +396 -0
- data/lib/nvoi/cli/deploy/steps/provision_network.rb +44 -0
- data/lib/nvoi/cli/deploy/steps/provision_server.rb +143 -0
- data/lib/nvoi/cli/deploy/steps/provision_volume.rb +171 -0
- data/lib/nvoi/cli/deploy/steps/setup_k3s.rb +481 -0
- data/lib/nvoi/cli/exec/command.rb +173 -0
- data/lib/nvoi/cli.rb +83 -142
- data/lib/nvoi/config_api/actions/app.rb +53 -0
- data/lib/nvoi/config_api/actions/compute_provider.rb +55 -0
- data/lib/nvoi/config_api/actions/database.rb +70 -0
- data/lib/nvoi/config_api/actions/env.rb +32 -0
- data/lib/nvoi/config_api/actions/secret.rb +32 -0
- data/lib/nvoi/config_api/actions/server.rb +66 -0
- data/lib/nvoi/config_api/actions/volume.rb +40 -0
- data/lib/nvoi/config_api/base.rb +44 -0
- data/lib/nvoi/config_api/result.rb +26 -0
- data/lib/nvoi/config_api.rb +70 -0
- data/lib/nvoi/errors.rb +68 -50
- data/lib/nvoi/external/cloud/aws.rb +425 -0
- data/lib/nvoi/external/cloud/base.rb +99 -0
- data/lib/nvoi/external/cloud/factory.rb +48 -0
- data/lib/nvoi/external/cloud/hetzner.rb +376 -0
- data/lib/nvoi/external/cloud/scaleway.rb +533 -0
- data/lib/nvoi/external/cloud.rb +15 -0
- data/lib/nvoi/external/containerd.rb +82 -0
- data/lib/nvoi/external/database/mysql.rb +84 -0
- data/lib/nvoi/external/database/postgres.rb +82 -0
- data/lib/nvoi/external/database/provider.rb +65 -0
- data/lib/nvoi/external/database/sqlite.rb +72 -0
- data/lib/nvoi/external/database.rb +22 -0
- data/lib/nvoi/external/dns/cloudflare.rb +292 -0
- data/lib/nvoi/external/kubectl.rb +65 -0
- data/lib/nvoi/external/ssh.rb +106 -0
- data/lib/nvoi/objects/config_override.rb +60 -0
- data/lib/nvoi/objects/configuration.rb +463 -0
- data/lib/nvoi/objects/database.rb +56 -0
- data/lib/nvoi/objects/dns.rb +14 -0
- data/lib/nvoi/objects/firewall.rb +11 -0
- data/lib/nvoi/objects/network.rb +11 -0
- data/lib/nvoi/objects/server.rb +14 -0
- data/lib/nvoi/objects/service_spec.rb +26 -0
- data/lib/nvoi/objects/tunnel.rb +14 -0
- data/lib/nvoi/objects/volume.rb +17 -0
- data/lib/nvoi/utils/config_loader.rb +172 -0
- data/lib/nvoi/utils/constants.rb +61 -0
- data/lib/nvoi/{credentials/manager.rb → utils/credential_store.rb} +16 -16
- data/lib/nvoi/{credentials → utils}/crypto.rb +8 -5
- data/lib/nvoi/{config → utils}/env_resolver.rb +10 -2
- data/lib/nvoi/utils/logger.rb +84 -0
- data/lib/nvoi/{config/naming.rb → utils/namer.rb} +28 -25
- data/lib/nvoi/{deployer → utils}/retry.rb +23 -3
- data/lib/nvoi/utils/templates.rb +62 -0
- data/lib/nvoi/version.rb +1 -1
- data/lib/nvoi.rb +10 -54
- data/templates/error-backend.yaml.erb +134 -0
- metadata +97 -44
- data/examples/golang/deploy.yml +0 -54
- data/lib/nvoi/cloudflare/client.rb +0 -287
- data/lib/nvoi/config/config.rb +0 -248
- data/lib/nvoi/config/loader.rb +0 -102
- data/lib/nvoi/config/ssh_keys.rb +0 -82
- data/lib/nvoi/config/types.rb +0 -274
- data/lib/nvoi/constants.rb +0 -59
- data/lib/nvoi/credentials/editor.rb +0 -272
- data/lib/nvoi/deployer/cleaner.rb +0 -36
- data/lib/nvoi/deployer/image_builder.rb +0 -23
- data/lib/nvoi/deployer/infrastructure.rb +0 -126
- data/lib/nvoi/deployer/orchestrator.rb +0 -146
- data/lib/nvoi/deployer/service_deployer.rb +0 -311
- data/lib/nvoi/deployer/tunnel_manager.rb +0 -57
- data/lib/nvoi/deployer/types.rb +0 -8
- data/lib/nvoi/k8s/renderer.rb +0 -44
- data/lib/nvoi/k8s/templates.rb +0 -29
- data/lib/nvoi/logger.rb +0 -72
- data/lib/nvoi/providers/aws.rb +0 -403
- data/lib/nvoi/providers/base.rb +0 -111
- data/lib/nvoi/providers/hetzner.rb +0 -288
- data/lib/nvoi/providers/hetzner_client.rb +0 -170
- data/lib/nvoi/remote/docker_manager.rb +0 -203
- data/lib/nvoi/remote/ssh_executor.rb +0 -72
- data/lib/nvoi/remote/volume_manager.rb +0 -103
- data/lib/nvoi/service/delete.rb +0 -234
- data/lib/nvoi/service/deploy.rb +0 -80
- data/lib/nvoi/service/exec.rb +0 -144
- data/lib/nvoi/service/provider.rb +0 -36
- data/lib/nvoi/steps/application_deployer.rb +0 -26
- data/lib/nvoi/steps/database_provisioner.rb +0 -60
- data/lib/nvoi/steps/k3s_cluster_setup.rb +0 -105
- data/lib/nvoi/steps/k3s_provisioner.rb +0 -351
- data/lib/nvoi/steps/server_provisioner.rb +0 -43
- data/lib/nvoi/steps/services_provisioner.rb +0 -29
- data/lib/nvoi/steps/tunnel_configurator.rb +0 -66
- 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,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
|