Skip to content

Commit 6e91508

Browse files
(PTFE-3027) Add support for block storage
1 parent 689956d commit 6e91508

2 files changed

Lines changed: 164 additions & 9 deletions

File tree

runner_manager/backend/scaleway.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pydantic import Field
99
from redis_om import NotFoundError
1010
from scaleway import Client
11+
from scaleway.block.v1alpha1 import BlockV1Alpha1API
1112
from scaleway.instance.v1 import (
1213
Image,
1314
Server,
@@ -56,6 +57,26 @@ def client(self) -> InstanceUtilsV1API:
5657
)
5758
return InstanceUtilsV1API(scw_client)
5859

60+
@property
61+
def block_client(self) -> BlockV1Alpha1API:
62+
"""Returns a Scaleway Block Storage API client."""
63+
access_key = self.config.access_key or os.getenv("SCW_ACCESS_KEY")
64+
secret_key = self.config.secret_key or os.getenv("SCW_SECRET_KEY")
65+
66+
if not access_key or not secret_key:
67+
raise ValueError(
68+
"Scaleway credentials not found. Set SCW_ACCESS_KEY and SCW_SECRET_KEY."
69+
)
70+
71+
scw_client = Client(
72+
access_key=access_key,
73+
secret_key=secret_key,
74+
default_project_id=self.config.project_id,
75+
default_zone=self.config.zone,
76+
default_region=self.config.region,
77+
)
78+
return BlockV1Alpha1API(scw_client)
79+
5980
def sanitize_tags(self, tags: List[str]) -> List[str]:
6081
"""Sanitize tags to comply with Scaleway requirements.
6182
@@ -438,20 +459,41 @@ def delete(self, runner: Runner) -> int:
438459

439460
# Delete associated volumes
440461
# 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
462+
# - l_ssd (local storage): Usually auto-deleted with the server, use Instance API
463+
# - sbs_volume (block storage): Persists after server deletion, must be deleted manually using Block API
444464
for volume_id in volume_ids:
445465
try:
446-
self.client.delete_volume(
447-
zone=self.config.zone,
448-
volume_id=volume_id,
449-
)
450-
log.info(f"Volume {volume_id} deleted successfully")
466+
# First, try to delete using Block Storage API (for sbs_volume)
467+
try:
468+
self.block_client.delete_volume(
469+
zone=self.config.zone,
470+
volume_id=volume_id,
471+
)
472+
log.info(
473+
f"Block storage volume {volume_id} deleted successfully"
474+
)
475+
except Exception as block_error:
476+
block_error_msg = str(block_error)
477+
# If volume not found in Block API, try Instance API (for l_ssd volumes)
478+
if (
479+
"404" in block_error_msg
480+
or "not_found" in block_error_msg.lower()
481+
):
482+
log.debug(
483+
f"Volume {volume_id} not found in Block API, trying Instance API"
484+
)
485+
self.client.delete_volume(
486+
zone=self.config.zone,
487+
volume_id=volume_id,
488+
)
489+
log.info(
490+
f"Instance volume {volume_id} deleted successfully"
491+
)
492+
else:
493+
raise block_error
451494
except Exception as vol_error:
452495
error_msg = str(vol_error)
453496
# Volume may already be deleted automatically (especially l_ssd volumes)
454-
# or might not be found if searching in wrong scope
455497
if "404" in error_msg or "not_found" in error_msg.lower():
456498
log.info(
457499
f"Volume {volume_id} not found - may have been auto-deleted with server or already cleaned up"

tests/unit/backend/test_scaleway.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,119 @@ def test_delete_not_found(scaleway_runner, fake_scaleway_group, caplog):
549549
assert result == 1
550550

551551

552+
def test_delete_with_block_storage_volume(
553+
scaleway_runner, fake_scaleway_group, caplog, monkeypatch
554+
):
555+
"""Test instance deletion with block storage (sbs_volume) using Block API."""
556+
backend = fake_scaleway_group.backend
557+
scaleway_runner.instance_id = "test-server-id"
558+
scaleway_runner.save()
559+
560+
# Mock server with block storage volume
561+
mock_volume = MagicMock()
562+
mock_volume.id = "test-block-volume-id"
563+
mock_server = MagicMock()
564+
mock_server.id = "test-server-id"
565+
mock_server.state = ServerState.RUNNING
566+
mock_server.volumes = {"0": mock_volume}
567+
568+
# Mock Instance API client
569+
mock_client = MagicMock()
570+
mock_client.get_server.return_value = MagicMock(server=mock_server)
571+
mock_client.delete_server.return_value = None
572+
mock_client.delete_volume.return_value = None
573+
mock_client.server_action.return_value = None
574+
575+
# Mock Block Storage API client
576+
mock_block_client = MagicMock()
577+
mock_block_client.delete_volume.return_value = None
578+
579+
# Patch both clients
580+
monkeypatch.setattr(ScalewayBackend, "client", property(lambda self: mock_client))
581+
monkeypatch.setattr(
582+
ScalewayBackend, "block_client", property(lambda self: mock_block_client)
583+
)
584+
585+
# Mock wait_for_server_state
586+
def mock_wait(self, server_id, target_state, timeout=300):
587+
return mock_server
588+
589+
monkeypatch.setattr(ScalewayBackend, "wait_for_server_state", mock_wait)
590+
591+
result = backend.delete(scaleway_runner)
592+
593+
# Verify Block Storage API was called first
594+
mock_block_client.delete_volume.assert_called_once_with(
595+
zone=backend.config.zone,
596+
volume_id="test-block-volume-id",
597+
)
598+
# Verify Instance API delete_volume was NOT called
599+
mock_client.delete_volume.assert_not_called()
600+
# Verify successful deletion was logged
601+
assert (
602+
"Block storage volume test-block-volume-id deleted successfully" in caplog.text
603+
)
604+
assert result == 1
605+
606+
607+
def test_delete_with_volume_fallback_to_instance_api(
608+
scaleway_runner, fake_scaleway_group, caplog, monkeypatch
609+
):
610+
"""Test volume deletion fallback from Block API to Instance API for l_ssd volumes."""
611+
backend = fake_scaleway_group.backend
612+
scaleway_runner.instance_id = "test-server-id"
613+
scaleway_runner.save()
614+
615+
# Mock server with l_ssd volume
616+
mock_volume = MagicMock()
617+
mock_volume.id = "test-lssd-volume-id"
618+
mock_server = MagicMock()
619+
mock_server.id = "test-server-id"
620+
mock_server.state = ServerState.RUNNING
621+
mock_server.volumes = {"0": mock_volume}
622+
623+
# Mock Instance API client
624+
mock_client = MagicMock()
625+
mock_client.get_server.return_value = MagicMock(server=mock_server)
626+
mock_client.delete_server.return_value = None
627+
mock_client.delete_volume.return_value = None
628+
mock_client.server_action.return_value = None
629+
630+
# Mock Block Storage API client to return 404 (volume not found in Block API)
631+
mock_block_client = MagicMock()
632+
mock_block_client.delete_volume.side_effect = Exception(
633+
"404 not_found: Volume not found in Block API"
634+
)
635+
636+
# Patch both clients
637+
monkeypatch.setattr(ScalewayBackend, "client", property(lambda self: mock_client))
638+
monkeypatch.setattr(
639+
ScalewayBackend, "block_client", property(lambda self: mock_block_client)
640+
)
641+
642+
# Mock wait_for_server_state
643+
def mock_wait(self, server_id, target_state, timeout=300):
644+
return mock_server
645+
646+
monkeypatch.setattr(ScalewayBackend, "wait_for_server_state", mock_wait)
647+
648+
result = backend.delete(scaleway_runner)
649+
650+
# Verify Block Storage API was called first
651+
mock_block_client.delete_volume.assert_called_once_with(
652+
zone=backend.config.zone,
653+
volume_id="test-lssd-volume-id",
654+
)
655+
# Verify Instance API was called as fallback
656+
mock_client.delete_volume.assert_called_once_with(
657+
zone=backend.config.zone,
658+
volume_id="test-lssd-volume-id",
659+
)
660+
# Verify successful deletion through Instance API was logged
661+
assert "Instance volume test-lssd-volume-id deleted successfully" in caplog.text
662+
assert result == 1
663+
664+
552665
def test_list_with_auto_create(fake_scaleway_group, monkeypatch):
553666
"""Test list() creates runners for servers not in database."""
554667
backend = fake_scaleway_group.backend

0 commit comments

Comments
 (0)