Skip to content

Commit 3e76c25

Browse files
(PTFE-3027) Fix volume creation and deletion for scaleway backend
1 parent c53a485 commit 3e76c25

2 files changed

Lines changed: 215 additions & 8 deletions

File tree

runner_manager/backend/scaleway.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# pyright: reportOptionalMemberAccess=false, reportArgumentType=false, reportReturnType=false, reportMissingTypeStubs=false
1+
# pyright: reportOptionalMemberAccess=false, reportArgumentType=false, reportReturnType=false, reportMissingTypeStubs=false, reportAttributeAccessIssue=false
22
import logging
33
import os
44
import re
@@ -7,17 +7,16 @@
77

88
from pydantic import Field
99
from redis_om import NotFoundError
10-
from scaleway import Client # type: ignore[import-untyped]
11-
from scaleway.instance.v1 import ( # type: ignore[import-untyped]
10+
from scaleway import Client
11+
from scaleway.instance.v1 import (
1212
Image,
1313
Server,
1414
ServerAction,
1515
ServerState,
16+
VolumeServerTemplate,
1617
)
17-
from scaleway.instance.v1.custom_api import (
18-
InstanceUtilsV1API, # type: ignore[import-untyped]
19-
)
20-
from scaleway.marketplace.v2 import MarketplaceV2API # type: ignore[import-untyped]
18+
from scaleway.instance.v1.custom_api import InstanceUtilsV1API
19+
from scaleway.marketplace.v2 import MarketplaceV2API
2120

2221
from runner_manager.backend.base import BaseBackend
2322
from runner_manager.models.backend import (
@@ -264,6 +263,52 @@ def create(self, runner: Runner) -> Runner:
264263
use_gateway = bool(self.instance_config.public_gateway_id)
265264
dynamic_ip_required = self.instance_config.enable_public_ip and not use_gateway
266265

266+
# Prepare volumes configuration
267+
# Create explicit boot volume with specified size to avoid default 10GB
268+
# Volume types:
269+
# - l_ssd: Local SSD storage (fast, can create raw volumes)
270+
# - sbs_volume: Block Storage (cannot create raw, must use base_snapshot from image)
271+
volumes_config = None
272+
273+
if not self.instance_config.volumes:
274+
volume_size_bytes = self.instance_config.volume_size_gb * 1000000000
275+
276+
if self.instance_config.volume_type == "l_ssd":
277+
volumes_config = {
278+
"0": VolumeServerTemplate(
279+
name=f"{runner.name}-boot",
280+
size=volume_size_bytes,
281+
volume_type="l_ssd",
282+
)
283+
}
284+
log.info(
285+
f"Creating l_ssd boot volume: {self.instance_config.volume_size_gb}GB"
286+
)
287+
else:
288+
img = self.get_image(self.instance_config.image)
289+
if img.root_volume and img.root_volume.id:
290+
volumes_config = {
291+
"0": VolumeServerTemplate(
292+
name=f"{runner.name}-boot",
293+
size=volume_size_bytes,
294+
volume_type="sbs_volume",
295+
base_snapshot=img.root_volume.id,
296+
boot=True,
297+
)
298+
}
299+
log.info(
300+
f"Creating sbs_volume boot volume: {self.instance_config.volume_size_gb}GB "
301+
f"from snapshot {img.root_volume.id}"
302+
)
303+
else:
304+
log.warning(
305+
f"Image {img.id} has no root_volume, using default volume from image"
306+
)
307+
volumes_config = None
308+
else:
309+
# Use user-provided volumes configuration
310+
volumes_config = self.instance_config.volumes
311+
267312
# Create server using _create_server
268313
# Note: In SDK 2.10.3, 'protected' is a required parameter
269314
response = self.client._create_server(
@@ -278,6 +323,7 @@ def create(self, runner: Runner) -> Runner:
278323
project=self.config.project_id,
279324
organization=self.config.organization_id,
280325
security_group=security_group,
326+
volumes=volumes_config,
281327
)
282328

283329
server = response.server
@@ -391,6 +437,10 @@ def delete(self, runner: Runner) -> int:
391437
log.info(f"Server {runner.instance_id} deleted successfully")
392438

393439
# Delete associated volumes
440+
# Note: The behavior differs by volume type:
441+
# - l_ssd (local storage): Usually auto-deleted with the server
442+
# - sbs_volume (block storage): Persists after server deletion, must be deleted manually
443+
# The Instance API manages both types, but sbs volumes need explicit cleanup
394444
for volume_id in volume_ids:
395445
try:
396446
self.client.delete_volume(
@@ -399,7 +449,15 @@ def delete(self, runner: Runner) -> int:
399449
)
400450
log.info(f"Volume {volume_id} deleted successfully")
401451
except Exception as vol_error:
402-
log.warning(f"Failed to delete volume {volume_id}: {vol_error}")
452+
error_msg = str(vol_error)
453+
# Volume may already be deleted automatically (especially l_ssd volumes)
454+
# or might not be found if searching in wrong scope
455+
if "404" in error_msg or "not_found" in error_msg.lower():
456+
log.info(
457+
f"Volume {volume_id} not found - may have been auto-deleted with server or already cleaned up"
458+
)
459+
else:
460+
log.warning(f"Failed to delete volume {volume_id}: {vol_error}")
403461

404462
except Exception as e:
405463
if "404" in str(e) or "not found" in str(e).lower():

tests/unit/backend/test_scaleway.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,152 @@ def test_list(scaleway_runner, scaleway_group):
606606
scaleway_group.backend.delete(runner)
607607
with pytest.raises(NotFoundError):
608608
scaleway_group.backend.get(runner.instance_id)
609+
610+
611+
def test_create_with_default_sbs_volume(scaleway_runner, fake_scaleway_group, monkeypatch):
612+
"""Test instance creation with default sbs_volume configuration."""
613+
# Mock image with root_volume
614+
mock_image = MagicMock()
615+
mock_image.id = "test-image-id"
616+
mock_root_volume = MagicMock()
617+
mock_root_volume.id = "snapshot-id"
618+
mock_image.root_volume = mock_root_volume
619+
620+
# Patch get_image at class level
621+
def mock_get_image(self, image_name):
622+
return mock_image
623+
624+
monkeypatch.setattr(ScalewayBackend, "get_image", mock_get_image)
625+
626+
backend = fake_scaleway_group.backend
627+
runner = backend.create(scaleway_runner)
628+
629+
# Verify volumes parameter was passed to _create_server
630+
mock_client = backend.client
631+
create_call = mock_client._create_server.call_args
632+
volumes = create_call.kwargs.get("volumes")
633+
634+
assert volumes is not None
635+
assert "0" in volumes
636+
assert volumes["0"].volume_type == "sbs_volume"
637+
assert volumes["0"].size == 20_000_000_000 # 20GB default
638+
assert volumes["0"].base_snapshot == "snapshot-id"
639+
640+
641+
def test_create_with_l_ssd_volume(scaleway_runner, fake_scaleway_group, monkeypatch):
642+
"""Test instance creation with l_ssd volume type."""
643+
# Mock image
644+
mock_image = MagicMock()
645+
mock_image.id = "test-image-id"
646+
647+
# Patch get_image at class level
648+
def mock_get_image(self, image_name):
649+
return mock_image
650+
651+
monkeypatch.setattr(ScalewayBackend, "get_image", mock_get_image)
652+
653+
# Configure l_ssd
654+
fake_scaleway_group.backend.instance_config.volume_type = "l_ssd"
655+
fake_scaleway_group.backend.instance_config.volume_size_gb = 80
656+
657+
backend = fake_scaleway_group.backend
658+
runner = backend.create(scaleway_runner)
659+
660+
# Verify volumes parameter
661+
mock_client = backend.client
662+
create_call = mock_client._create_server.call_args
663+
volumes = create_call.kwargs.get("volumes")
664+
665+
assert volumes is not None
666+
assert "0" in volumes
667+
assert volumes["0"].volume_type == "l_ssd"
668+
assert volumes["0"].size == 80_000_000_000 # 80GB
669+
# For l_ssd, no base_snapshot should be set
670+
assert not hasattr(volumes["0"], "base_snapshot") or volumes["0"].base_snapshot is None
671+
672+
673+
def test_create_with_custom_volume_size(scaleway_runner, fake_scaleway_group, monkeypatch):
674+
"""Test instance creation with custom volume size."""
675+
mock_image = MagicMock()
676+
mock_image.id = "test-image-id"
677+
mock_root_volume = MagicMock()
678+
mock_root_volume.id = "snapshot-id"
679+
mock_image.root_volume = mock_root_volume
680+
681+
# Patch get_image at class level
682+
def mock_get_image(self, image_name):
683+
return mock_image
684+
685+
monkeypatch.setattr(ScalewayBackend, "get_image", mock_get_image)
686+
687+
# Set custom size
688+
fake_scaleway_group.backend.instance_config.volume_size_gb = 100
689+
backend = fake_scaleway_group.backend
690+
691+
runner = backend.create(scaleway_runner)
692+
693+
mock_client = backend.client
694+
create_call = mock_client._create_server.call_args
695+
volumes = create_call.kwargs.get("volumes")
696+
697+
assert volumes["0"].size == 100_000_000_000 # 100GB
698+
699+
700+
def test_create_with_no_root_volume_fallback(scaleway_runner, fake_scaleway_group, monkeypatch, caplog):
701+
"""Test fallback when image has no root_volume."""
702+
mock_image = MagicMock()
703+
mock_image.id = "test-image-id"
704+
mock_image.root_volume = None # No root volume
705+
706+
# Patch get_image at class level
707+
def mock_get_image(self, image_name):
708+
return mock_image
709+
710+
monkeypatch.setattr(ScalewayBackend, "get_image", mock_get_image)
711+
712+
backend = fake_scaleway_group.backend
713+
runner = backend.create(scaleway_runner)
714+
715+
# Verify warning was logged
716+
assert "has no root_volume, using default volume from image" in caplog.text
717+
718+
# Verify volumes=None was passed
719+
mock_client = backend.client
720+
create_call = mock_client._create_server.call_args
721+
volumes = create_call.kwargs.get("volumes")
722+
assert volumes is None
723+
724+
725+
def test_create_with_user_provided_volumes(scaleway_runner, fake_scaleway_group, monkeypatch):
726+
"""Test instance creation with user-provided volumes configuration."""
727+
from scaleway.instance.v1 import VolumeServerTemplate
728+
729+
# Mock image
730+
mock_image = MagicMock()
731+
mock_image.id = "test-image-id"
732+
733+
# Patch get_image at class level
734+
def mock_get_image(self, image_name):
735+
return mock_image
736+
737+
monkeypatch.setattr(ScalewayBackend, "get_image", mock_get_image)
738+
739+
# Set user-provided volumes
740+
custom_volumes = {
741+
"0": VolumeServerTemplate(
742+
volume_type="sbs_volume",
743+
size=50_000_000_000,
744+
base_snapshot="custom-snapshot-id",
745+
)
746+
}
747+
fake_scaleway_group.backend.instance_config.volumes = custom_volumes
748+
749+
backend = fake_scaleway_group.backend
750+
runner = backend.create(scaleway_runner)
751+
752+
# Verify user-provided volumes were used
753+
mock_client = backend.client
754+
create_call = mock_client._create_server.call_args
755+
volumes = create_call.kwargs.get("volumes")
756+
757+
assert volumes == custom_volumes

0 commit comments

Comments
 (0)