Class: Nvoi::External::Cloud::Scaleway

Inherits:
Base
  • Object
show all
Defined in:
lib/nvoi/external/cloud/scaleway.rb

Overview

Scaleway provider implements the compute provider interface for Scaleway Cloud

Constant Summary collapse

INSTANCE_API_BASE =
"https://api.scaleway.com/instance/v1"
VPC_API_BASE =
"https://api.scaleway.com/vpc/v2"
BLOCK_API_BASE =
"https://api.scaleway.com/block/v1alpha1"
VALID_ZONES =
%w[
  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
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(secret_key, project_id, zone: "fr-par-1") ⇒ Scaleway

Returns a new instance of Scaleway.



21
22
23
24
25
26
27
# File 'lib/nvoi/external/cloud/scaleway.rb', line 21

def initialize(secret_key, project_id, zone: "fr-par-1")
  @secret_key = secret_key
  @project_id = project_id
  @zone = zone
  @region = zone_to_region(zone)
  @conn = build_connection
end

Instance Attribute Details

#project_idObject (readonly)

Returns the value of attribute project_id.



29
30
31
# File 'lib/nvoi/external/cloud/scaleway.rb', line 29

def project_id
  @project_id
end

#regionObject (readonly)

Returns the value of attribute region.



29
30
31
# File 'lib/nvoi/external/cloud/scaleway.rb', line 29

def region
  @region
end

#zoneObject (readonly)

Returns the value of attribute zone.



29
30
31
# File 'lib/nvoi/external/cloud/scaleway.rb', line 29

def zone
  @zone
end

Instance Method Details

#attach_volume(volume_id, server_id) ⇒ Object



241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/nvoi/external/cloud/scaleway.rb', line 241

def attach_volume(volume_id, server_id)
  server = get_server_api(server_id)
  raise Errors::VolumeError, "server not found: #{server_id}" unless server

  wait_for_volume_available(volume_id)

  current_volumes = server["volumes"] || {}
  next_index = current_volumes.keys.map(&:to_i).max.to_i + 1

  new_volumes = current_volumes.dup
  new_volumes[next_index.to_s] = { id: volume_id, volume_type: "sbs_volume" }

  patch(instance_url("/servers/#{server_id}"), { volumes: new_volumes })
end

#create_server(opts) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/nvoi/external/cloud/scaleway.rb', line 128

def create_server(opts)
  # Validate server type
  server_types = list_server_types_api
  unless server_types.key?(opts.type)
    raise Errors::ValidationError, "invalid server type: #{opts.type}"
  end

  # Resolve image
  image = find_image(opts.image)
  raise Errors::ValidationError, "invalid image: #{opts.image}" unless image

  create_opts = {
    name: opts.name,
    commercial_type: opts.type,
    image: image["id"],
    project: @project_id,
    boot_type: "local",
    tags: []
  }

  # Add security group if provided
  unless opts.firewall_id.blank?
    create_opts[:security_group] = opts.firewall_id
  end

  server = post(instance_url("/servers"), create_opts)["server"]

  # Set cloud-init user data if provided
  unless opts.user_data.blank?
    set_user_data(server["id"], "cloud-init", opts.user_data)
  end

  # Power on the server
  server_action(server["id"], "poweron")

  # Attach to private network if provided
  unless opts.network_id.blank?
    wait_for_server_state(server["id"], "running", 30)
    create_private_nic(server["id"], opts.network_id)
  end

  to_server(get_server_api(server["id"]))
end

#create_volume(opts) ⇒ Object

Volume operations



207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/nvoi/external/cloud/scaleway.rb', line 207

def create_volume(opts)
  server = get_server_api(opts.server_id)
  raise Errors::VolumeError, "server not found: #{opts.server_id}" unless server

  volume = post(block_url("/volumes"), {
    name: opts.name,
    perf_iops: 5000,
    from_empty: { size: opts.size * 1_000_000_000 },
    project_id: @project_id
  })

  to_volume(volume)
end

#delete_firewall(id) ⇒ Object



102
103
104
# File 'lib/nvoi/external/cloud/scaleway.rb', line 102

def delete_firewall(id)
  delete(instance_url("/security_groups/#{id}"))
end

#delete_network(id) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/nvoi/external/cloud/scaleway.rb', line 52

def delete_network(id)
  # First detach all servers from this network
  list_servers_api.each do |server|
    nics = list_private_nics(server["id"])
    nics.each do |nic|
      next unless nic["private_network_id"] == id

      delete_private_nic(server["id"], nic["id"])
    rescue StandardError
      # Ignore cleanup errors
    end
  end

  delete(vpc_url("/private-networks/#{id}"))
end

#delete_server(id) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/nvoi/external/cloud/scaleway.rb', line 183

def delete_server(id)
  # Delete private NICs first
  nics = list_private_nics(id)
  nics.each do |nic|
    delete_private_nic(id, nic["id"])
  rescue StandardError
    # Ignore cleanup errors
  end

  # Terminate server (this also stops and deletes)
  server_action(id, "terminate")
rescue StandardError => e
  # If terminate fails, try poweroff then delete
  begin
    server_action(id, "poweroff")
    sleep(5)
    delete(instance_url("/servers/#{id}"))
  rescue StandardError
    raise e
  end
end

#delete_volume(id) ⇒ Object



237
238
239
# File 'lib/nvoi/external/cloud/scaleway.rb', line 237

def delete_volume(id)
  delete(block_url("/volumes/#{id}"))
end

#detach_volume(volume_id) ⇒ Object



256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/nvoi/external/cloud/scaleway.rb', line 256

def detach_volume(volume_id)
  list_servers_api.each do |server|
    volumes = server["volumes"] || {}
    volumes.each do |idx, vol|
      next unless vol["id"] == volume_id

      new_volumes = volumes.reject { |k, _| k == idx }
      patch(instance_url("/servers/#{server["id"]}"), { volumes: new_volumes })
      return
    end
  end
end

#find_or_create_firewall(name) ⇒ Object

Firewall operations (Security Groups)



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/nvoi/external/cloud/scaleway.rb', line 70

def find_or_create_firewall(name)
  sg = find_security_group_by_name(name)
  return to_firewall(sg) if sg

  sg = post(instance_url("/security_groups"), {
    name:,
    project: @project_id,
    stateful: true,
    inbound_default_policy: "drop",
    outbound_default_policy: "accept"
  })["security_group"]

  # Add SSH rule
  post(instance_url("/security_groups/#{sg["id"]}/rules"), {
    protocol: "TCP",
    direction: "inbound",
    action: "accept",
    ip_range: "0.0.0.0/0",
    dest_port_from: 22,
    dest_port_to: 22
  })

  to_firewall(sg)
end

#find_or_create_network(name) ⇒ Object

Network operations



33
34
35
36
37
38
39
40
41
42
43
# File 'lib/nvoi/external/cloud/scaleway.rb', line 33

def find_or_create_network(name)
  network = find_network_by_name(name)
  return to_network(network) if network

  network = post(vpc_url("/private-networks"), {
    name:,
    project_id: @project_id
  })

  to_network(network)
end

#find_server(name) ⇒ Object

Server operations



108
109
110
111
112
113
# File 'lib/nvoi/external/cloud/scaleway.rb', line 108

def find_server(name)
  server = find_server_by_name(name)
  return nil unless server

  to_server(server, fetch_private_ip: true)
end

#find_server_by_id(id) ⇒ Object



115
116
117
118
119
120
121
122
# File 'lib/nvoi/external/cloud/scaleway.rb', line 115

def find_server_by_id(id)
  server = get(instance_url("/servers/#{id}"))["server"]
  return nil unless server

  to_server(server, fetch_private_ip: true)
rescue Errors::NotFoundError
  nil
end

#get_firewall_by_name(name) ⇒ Object



95
96
97
98
99
100
# File 'lib/nvoi/external/cloud/scaleway.rb', line 95

def get_firewall_by_name(name)
  sg = find_security_group_by_name(name)
  raise Errors::FirewallError, "security group not found: #{name}" unless sg

  to_firewall(sg)
end

#get_network_by_name(name) ⇒ Object



45
46
47
48
49
50
# File 'lib/nvoi/external/cloud/scaleway.rb', line 45

def get_network_by_name(name)
  network = find_network_by_name(name)
  raise Errors::NetworkError, "network not found: #{name}" unless network

  to_network(network)
end

#get_volume(id) ⇒ Object



221
222
223
224
225
226
227
228
# File 'lib/nvoi/external/cloud/scaleway.rb', line 221

def get_volume(id)
  volume = get(block_url("/volumes/#{id}"))
  return nil unless volume

  to_volume(volume)
rescue Errors::NotFoundError
  nil
end

#get_volume_by_name(name) ⇒ Object



230
231
232
233
234
235
# File 'lib/nvoi/external/cloud/scaleway.rb', line 230

def get_volume_by_name(name)
  volume = list_volumes.find { |v| v["name"] == name }
  return nil unless volume

  to_volume(volume)
end

#list_server_typesObject

List available server types for onboarding



314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/nvoi/external/cloud/scaleway.rb', line 314

def list_server_types
  list_server_types_api.map do |name, info|
    arch = info.dig("arch") || "x86_64"
    {
      name:,
      cores: info.dig("ncpus"),
      ram: info.dig("ram"),
      hourly_price: info.dig("hourly_price"),
      architecture: arch.include?("arm") ? "arm64" : "x86"
    }
  end
end

#list_serversObject



124
125
126
# File 'lib/nvoi/external/cloud/scaleway.rb', line 124

def list_servers
  list_servers_api.map { |s| to_server(s) }
end

#list_zonesObject

List available zones for onboarding



328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/nvoi/external/cloud/scaleway.rb', line 328

def list_zones
  VALID_ZONES.map do |z|
    parts = z.split("-")
    city = case parts[0..1].join("-")
    when "fr-par" then "Paris"
    when "nl-ams" then "Amsterdam"
    when "pl-waw" then "Warsaw"
    else parts[0..1].join("-")
    end
    { name: z, city: }
  end
end

#server_ip(server_name) ⇒ Object

Server IP lookup for exec/db commands



308
309
310
311
# File 'lib/nvoi/external/cloud/scaleway.rb', line 308

def server_ip(server_name)
  server = find_server(server_name)
  server&.public_ipv4
end

#validate_credentialsObject



300
301
302
303
304
305
# File 'lib/nvoi/external/cloud/scaleway.rb', line 300

def validate_credentials
  list_server_types_api
  true
rescue Errors::AuthenticationError => e
  raise Errors::ValidationError, "scaleway credentials invalid: #{e.message}"
end

#validate_instance_type(instance_type) ⇒ Object

Validation operations



283
284
285
286
287
288
289
290
# File 'lib/nvoi/external/cloud/scaleway.rb', line 283

def validate_instance_type(instance_type)
  server_types = list_server_types_api
  unless server_types.key?(instance_type)
    raise Errors::ValidationError, "invalid scaleway server type: #{instance_type}"
  end

  true
end

#validate_region(region) ⇒ Object



292
293
294
295
296
297
298
# File 'lib/nvoi/external/cloud/scaleway.rb', line 292

def validate_region(region)
  unless VALID_ZONES.include?(region)
    raise Errors::ValidationError, "invalid scaleway zone: #{region}. Valid: #{VALID_ZONES.join(", ")}"
  end

  true
end

#wait_for_device_path(volume_id, ssh) ⇒ Object



269
270
271
272
273
274
275
276
277
278
279
# File 'lib/nvoi/external/cloud/scaleway.rb', line 269

def wait_for_device_path(volume_id, ssh)
  # Scaleway doesn't provide device_path in API
  # Find device by volume ID in /dev/disk/by-id/
  Utils::Retry.poll(max_attempts: 30, interval: 2) do
    output = ssh.execute("ls /dev/disk/by-id/ 2>/dev/null | grep -i '#{volume_id}' || true").strip
    next nil if output.empty?

    device_name = output.lines.first.strip
    "/dev/disk/by-id/#{device_name}"
  end
end

#wait_for_server(server_id, max_attempts) ⇒ Object



172
173
174
175
176
177
178
179
180
181
# File 'lib/nvoi/external/cloud/scaleway.rb', line 172

def wait_for_server(server_id, max_attempts)
  server = Utils::Retry.poll(max_attempts:, interval: Utils::Constants::SERVER_READY_INTERVAL) do
    s = get_server_api(server_id)
    to_server(s) if s["state"] == "running" && s.dig("public_ip", "address")
  end

  raise Errors::ServerCreationError, "server did not become running after #{max_attempts} attempts" unless server

  server
end