Skip to content

Commit 86e2b09

Browse files
(PTFE-2972) Add search from marketplace image by name
1 parent 31e090c commit 86e2b09

File tree

2 files changed

+210
-23
lines changed

2 files changed

+210
-23
lines changed

runner_manager/backend/scaleway.py

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from scaleway.instance.v1.custom_api import (
1818
InstanceUtilsV1API, # type: ignore[import-untyped]
1919
)
20+
from scaleway.marketplace.v2 import MarketplaceV2API # type: ignore[import-untyped]
2021

2122
from runner_manager.backend.base import BaseBackend
2223
from runner_manager.models.backend import (
@@ -74,25 +75,122 @@ def sanitize_tags(self, tags: List[str]) -> List[str]:
7475
return sanitized
7576

7677
def get_image(self, image_name: str) -> Image:
77-
"""Get image by name or ID."""
78+
"""Get image by name or ID.
79+
80+
Supports three lookup methods in order:
81+
1. Direct UUID lookup (for explicit image IDs)
82+
2. User's custom images by name (e.g., Packer-built images)
83+
3. Scaleway Marketplace images by label
84+
85+
Args:
86+
image_name: Image UUID, custom image name, or marketplace label
87+
88+
Returns:
89+
Image object from Scaleway Instance API
90+
91+
Raises:
92+
ValueError: If image is not found
93+
"""
94+
import re
95+
96+
# Check if it's a UUID format
97+
uuid_pattern = re.compile(
98+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
99+
re.IGNORECASE,
100+
)
101+
is_uuid = bool(uuid_pattern.match(image_name))
102+
103+
# 1. Try direct UUID lookup
104+
if is_uuid:
105+
try:
106+
return self.client.get_image(
107+
zone=self.config.zone,
108+
image_id=image_name,
109+
).image
110+
except Exception as e:
111+
log.debug(f"Image ID lookup failed: {e}")
112+
raise ValueError(
113+
f"Image with ID '{image_name}' not found in zone {self.config.zone}"
114+
)
115+
116+
# 2. Try to find in user's custom images by name (e.g., Packer images)
78117
try:
79-
# Try to get by ID first
80-
return self.client.get_image(
81-
zone=self.config.zone,
82-
image_id=image_name,
83-
).image
84-
except Exception:
85-
# Otherwise, list images and find by name
86118
images = self.client.list_images(
87119
zone=self.config.zone,
88120
name=image_name,
89121
).images
90122
if images:
123+
log.info(f"Found user image '{image_name}': {images[0].id}")
91124
return images[0]
92-
raise ValueError(
93-
f"Image '{image_name}' not found in zone {self.config.zone}"
125+
except Exception as e:
126+
log.debug(f"User images lookup failed: {e}")
127+
128+
# 3. Try Scaleway Marketplace
129+
try:
130+
# Create marketplace client
131+
scw_client = Client(
132+
access_key=self.config.access_key or os.getenv("SCW_ACCESS_KEY"),
133+
secret_key=self.config.secret_key or os.getenv("SCW_SECRET_KEY"),
134+
default_project_id=self.config.project_id,
135+
default_zone=self.config.zone,
136+
default_region=self.config.region,
137+
)
138+
marketplace_client = MarketplaceV2API(scw_client)
139+
140+
# List all marketplace images with pagination
141+
all_images = []
142+
page = 1
143+
page_size = 100
144+
145+
while True:
146+
images_result = marketplace_client.list_images(
147+
include_eol=True,
148+
page=page,
149+
page_size=page_size,
150+
)
151+
all_images.extend(images_result.images)
152+
153+
if len(images_result.images) < page_size:
154+
break
155+
page += 1
156+
157+
# Find image by label
158+
marketplace_image = None
159+
for img in all_images:
160+
if img.label == image_name:
161+
marketplace_image = img
162+
break
163+
164+
if not marketplace_image:
165+
raise ValueError(f"Image label '{image_name}' not found in marketplace")
166+
167+
log.info(f"Found marketplace image '{image_name}': {marketplace_image.id}")
168+
169+
# Get the local version for the current zone
170+
local_images = marketplace_client.list_local_images(
171+
image_id=marketplace_image.id,
172+
zone=self.config.zone,
94173
)
95174

175+
if local_images.local_images:
176+
for local_img in local_images.local_images:
177+
if local_img.zone == self.config.zone:
178+
log.info(f"Resolved to local image ID: {local_img.id}")
179+
return self.client.get_image(
180+
zone=self.config.zone,
181+
image_id=local_img.id,
182+
).image
183+
184+
except Exception as marketplace_error:
185+
log.debug(
186+
f"Marketplace lookup failed for '{image_name}': {marketplace_error}"
187+
)
188+
189+
raise ValueError(
190+
f"Image '{image_name}' not found in zone {self.config.zone}. "
191+
f"Tried: UUID lookup, user images, and marketplace."
192+
)
193+
96194
def wait_for_server_state(
97195
self,
98196
server_id: str,

tests/unit/backend/test_scaleway.py

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,115 @@ def test_backend_name(fake_scaleway_group):
124124

125125

126126
def test_get_image(fake_scaleway_group):
127-
"""Test getting image by name."""
127+
"""Test getting image by name from user images."""
128128
backend = fake_scaleway_group.backend
129129
image = backend.get_image("ubuntu_jammy")
130130

131131
assert image.id == "test-image-id"
132132
assert image.name == "ubuntu_jammy"
133133

134134

135+
def test_get_image_by_uuid(fake_scaleway_group):
136+
"""Test getting image by UUID."""
137+
backend = fake_scaleway_group.backend
138+
139+
# UUID format should trigger direct get_image call
140+
uuid = "ec31d73d-ca36-4536-adf4-0feb76d30379"
141+
image = backend.get_image(uuid)
142+
143+
assert image.id == "test-image-id"
144+
145+
146+
def test_get_image_marketplace(fake_scaleway_group, monkeypatch):
147+
"""Test getting image from Scaleway Marketplace."""
148+
# Mock marketplace image
149+
mock_marketplace_img = MagicMock()
150+
mock_marketplace_img.id = "marketplace-img-id"
151+
mock_marketplace_img.label = "ubuntu_noble"
152+
153+
# Mock local image
154+
mock_local_img = MagicMock()
155+
mock_local_img.id = "local-img-uuid"
156+
mock_local_img.zone = "fr-par-1"
157+
158+
# Mock marketplace API
159+
mock_marketplace_api = MagicMock()
160+
mock_marketplace_api.list_images.return_value = MagicMock(
161+
images=[mock_marketplace_img]
162+
)
163+
mock_marketplace_api.list_local_images.return_value = MagicMock(
164+
local_images=[mock_local_img]
165+
)
166+
167+
# Mock MarketplaceV2API where it's imported in the backend module
168+
monkeypatch.setattr(
169+
"runner_manager.backend.scaleway.MarketplaceV2API",
170+
lambda client: mock_marketplace_api,
171+
)
172+
173+
# Mock list_images to return empty (force marketplace lookup)
174+
backend = fake_scaleway_group.backend
175+
mock_client = backend.client
176+
mock_client.list_images.return_value = MagicMock(images=[])
177+
178+
# Get image from marketplace
179+
image = backend.get_image("ubuntu_noble")
180+
181+
# Verify it called marketplace API
182+
assert mock_marketplace_api.list_images.called
183+
assert mock_marketplace_api.list_local_images.called
184+
assert image.id == "test-image-id"
185+
186+
187+
def test_get_image_not_found(fake_scaleway_group, monkeypatch):
188+
"""Test error when image is not found anywhere."""
189+
backend = fake_scaleway_group.backend
190+
191+
# Mock all lookups to fail
192+
mock_client = backend.client
193+
mock_client.list_images.return_value = MagicMock(images=[])
194+
195+
# Mock marketplace to also fail
196+
mock_marketplace_api = MagicMock()
197+
mock_marketplace_api.list_images.return_value = MagicMock(images=[])
198+
199+
monkeypatch.setattr(
200+
"runner_manager.backend.scaleway.MarketplaceV2API",
201+
lambda client: mock_marketplace_api,
202+
)
203+
204+
with pytest.raises(ValueError, match="not found in zone"):
205+
backend.get_image("non-existent-image")
206+
207+
208+
def test_get_image_user_priority(fake_scaleway_group, monkeypatch):
209+
"""Test that user images take priority over marketplace images."""
210+
backend = fake_scaleway_group.backend
211+
212+
# Mock user image found
213+
mock_user_image = MagicMock()
214+
mock_user_image.id = "user-custom-image-id"
215+
mock_user_image.name = "my-custom-ubuntu"
216+
217+
mock_client = backend.client
218+
mock_client.list_images.return_value = MagicMock(images=[mock_user_image])
219+
220+
# Mock marketplace (should not be called)
221+
mock_marketplace_api = MagicMock()
222+
monkeypatch.setattr(
223+
"runner_manager.backend.scaleway.MarketplaceV2API",
224+
lambda client: mock_marketplace_api,
225+
)
226+
227+
image = backend.get_image("my-custom-ubuntu")
228+
229+
# Verify user image was returned
230+
assert image.id == "user-custom-image-id"
231+
232+
# Verify marketplace was NOT called (user image found first)
233+
assert not mock_marketplace_api.list_images.called
234+
235+
135236
def test_create_instance_mock(scaleway_runner, fake_scaleway_group):
136237
"""Test instance creation with mocked client."""
137238
backend = fake_scaleway_group.backend
@@ -438,18 +539,6 @@ def test_get_image_by_id(fake_scaleway_group):
438539
assert image.id == "test-image-id"
439540

440541

441-
def test_get_image_not_found(fake_scaleway_group, monkeypatch):
442-
"""Test get_image when image is not found."""
443-
backend = fake_scaleway_group.backend
444-
445-
mock_client = backend.client
446-
mock_client.get_image.side_effect = Exception("Image not found")
447-
mock_client.list_images.return_value = MagicMock(images=[])
448-
449-
with pytest.raises(ValueError, match="not found in zone"):
450-
backend.get_image("non-existent-image")
451-
452-
453542
# Real API tests (skipped if credentials not available)
454543

455544

0 commit comments

Comments
 (0)