Skip to content

Commit 7cd0ffa

Browse files
committed
ridesx: add tests for fls integration
Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent 93528f3 commit 7cd0ffa

2 files changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import tempfile
2+
from unittest.mock import patch
3+
4+
import pytest
5+
from jumpstarter_driver_pyserial.driver import PySerial
6+
7+
from .driver import RideSXDriver
8+
from jumpstarter.common.utils import serve
9+
10+
11+
@pytest.fixture(scope="session")
12+
def temp_storage_dir():
13+
with tempfile.TemporaryDirectory() as temp_dir:
14+
yield temp_dir
15+
16+
17+
@pytest.fixture(scope="session")
18+
def ridesx_driver(temp_storage_dir):
19+
yield RideSXDriver(
20+
storage_dir=temp_storage_dir,
21+
children={
22+
"serial": PySerial(url="loop://"),
23+
},
24+
)
25+
26+
27+
@pytest.fixture
28+
def ridesx_client(ridesx_driver):
29+
"""Create a client instance for testing client-side methods"""
30+
with serve(ridesx_driver) as client:
31+
yield client
32+
33+
34+
# OCI Path Detection Tests
35+
36+
37+
@pytest.mark.parametrize(
38+
"path,expected",
39+
[
40+
# OCI paths (should return True)
41+
("oci://quay.io/myimage:latest", True),
42+
("quay.io/myorg/myimage:latest", True),
43+
("ghcr.io/owner/repo:tag", True),
44+
("registry.redhat.io/rhel:9", True),
45+
# Non-OCI paths (should return False)
46+
("docker://registry.example.com/image:v1", False), # only oci:// scheme accepted
47+
("/path/to/file.img", False),
48+
("relative/path/file.img", False),
49+
("boot.img", False),
50+
],
51+
)
52+
def test_is_oci_path(ridesx_client, path, expected):
53+
assert ridesx_client._is_oci_path(path) is expected
54+
55+
56+
# OCI URL from argv Tests
57+
58+
59+
@pytest.mark.parametrize(
60+
"argv,expected",
61+
[
62+
(["cmd", "oci://quay.io/image:tag"], "oci://quay.io/image:tag"),
63+
(["cmd", "quay.io/org/image:v1"], "quay.io/org/image:v1"),
64+
(["cmd", "ghcr.io/owner/repo:latest"], "ghcr.io/owner/repo:latest"),
65+
(["cmd", "docker.io/library/alpine:3.18"], "docker.io/library/alpine:3.18"),
66+
(["cmd", "/path/to/file.img", "-t", "boot:file"], None),
67+
(["cmd", "-t", "boot:file", "oci://first:tag", "oci://second:tag"], "oci://first:tag"),
68+
],
69+
)
70+
def test_oci_url_from_argv(ridesx_client, argv, expected):
71+
with patch("sys.argv", argv):
72+
result = ridesx_client._oci_url_from_argv()
73+
assert result == expected
74+
75+
76+
# Target Mappings from argv Tests
77+
78+
79+
@pytest.mark.parametrize(
80+
"argv,expected",
81+
[
82+
(["cmd", "-t", "boot_a:boot.img"], {"boot_a": "boot.img"}),
83+
(["cmd", "--target", "system_a:rootfs.simg"], {"system_a": "rootfs.simg"}),
84+
(["cmd", "--target=boot_a:boot.img"], {"boot_a": "boot.img"}),
85+
(
86+
["cmd", "-t", "boot_a:boot.img", "-t", "system_a:rootfs.simg"],
87+
{"boot_a": "boot.img", "system_a": "rootfs.simg"},
88+
),
89+
(["cmd", "oci://image:tag"], None),
90+
(
91+
["cmd", "-t", "boot_a:boot.img", "oci://image:tag", "--other", "value"],
92+
{"boot_a": "boot.img"},
93+
),
94+
],
95+
)
96+
def test_target_mappings_from_argv(ridesx_client, argv, expected):
97+
with patch("sys.argv", argv):
98+
result = ridesx_client._target_mappings_from_argv()
99+
assert result == expected
100+
101+
102+
# Resolve Flash Input Tests
103+
104+
105+
def test_resolve_flash_input_dict_path(ridesx_client):
106+
"""Test dict path input returns partitions directly"""
107+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
108+
{"boot": "/path/boot.img"}, None, None, None
109+
)
110+
assert partitions == {"boot": "/path/boot.img"}
111+
assert operators is None
112+
assert oci_url is None
113+
114+
# With OCI from argv
115+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
116+
{"boot": "boot.img"}, None, None, "oci://image:tag"
117+
)
118+
assert partitions == {"boot": "boot.img"}
119+
assert oci_url == "oci://image:tag"
120+
121+
122+
def test_resolve_flash_input_oci_path(ridesx_client):
123+
"""Test OCI path input with and without target"""
124+
# Without target - auto-detect mode
125+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
126+
"oci://quay.io/image:tag", None, None, None
127+
)
128+
assert partitions is None
129+
assert operators is None
130+
assert oci_url == "oci://quay.io/image:tag"
131+
132+
# With target - explicit mapping
133+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
134+
"oci://quay.io/image:tag", "boot_a:boot.img", None, None
135+
)
136+
assert partitions == {"boot_a": "boot.img"}
137+
assert oci_url == "oci://quay.io/image:tag"
138+
139+
140+
def test_resolve_flash_input_oci_path_invalid_target(ridesx_client):
141+
"""Test OCI path with invalid target format raises error"""
142+
with pytest.raises(ValueError, match="Target must be in format"):
143+
ridesx_client._resolve_flash_input("oci://quay.io/image:tag", "boot_a", None, None)
144+
145+
146+
def test_resolve_flash_input_local_path(ridesx_client):
147+
"""Test local path input requires target"""
148+
# Without target - should raise
149+
with pytest.raises(ValueError, match="This driver requires a target partition"):
150+
ridesx_client._resolve_flash_input("/path/to/boot.img", None, None, None)
151+
152+
# With target
153+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
154+
"/path/to/boot.img", "boot_a", None, None
155+
)
156+
assert partitions == {"boot_a": "/path/to/boot.img"}
157+
assert oci_url is None
158+
159+
# With target and OCI from argv
160+
partitions, operators, oci_url = ridesx_client._resolve_flash_input(
161+
"/path/to/boot.img", "boot_a", None, "oci://image:tag"
162+
)
163+
assert partitions == {"boot_a": "/path/to/boot.img"}
164+
assert oci_url == "oci://image:tag"
165+
166+
167+
# Validate Partition Mappings Tests
168+
169+
170+
def test_validate_partition_mappings(ridesx_client):
171+
"""Test partition mapping validation"""
172+
# None is valid (auto-detect mode)
173+
ridesx_client._validate_partition_mappings(None)
174+
175+
# Valid mapping
176+
ridesx_client._validate_partition_mappings({"boot": "/path/to/boot.img"})
177+
178+
# Empty path raises
179+
with pytest.raises(ValueError, match="has an empty file path"):
180+
ridesx_client._validate_partition_mappings({"boot": ""})
181+
182+
# Whitespace-only path raises
183+
with pytest.raises(ValueError, match="has an empty file path"):
184+
ridesx_client._validate_partition_mappings({"boot": " "})
185+
186+
187+
# Flash OCI Auto Tests
188+
189+
190+
def test_flash_oci_auto_normalizes_bare_registry(ridesx_client):
191+
"""Test that bare registry URLs are normalized with oci:// prefix"""
192+
with patch.object(ridesx_client, "call") as mock_call:
193+
mock_call.side_effect = [
194+
{"status": "device_found", "device_id": "ABC123"},
195+
{"status": "success"},
196+
]
197+
198+
result = ridesx_client.flash_oci_auto("quay.io/org/image:tag")
199+
200+
assert result == {"status": "success"}
201+
flash_call = mock_call.call_args_list[1]
202+
assert flash_call[0][1].startswith("oci://")
203+
204+
205+
def test_flash_oci_auto_error_cases(ridesx_client):
206+
"""Test flash_oci_auto error handling"""
207+
# Invalid URL
208+
with pytest.raises(ValueError, match="Invalid OCI URL format"):
209+
ridesx_client.flash_oci_auto("invalid-url")
210+
211+
# No device found
212+
with patch.object(ridesx_client, "call") as mock_call:
213+
mock_call.return_value = {"status": "no_device_found", "device_id": None}
214+
215+
with pytest.raises(RuntimeError, match="No fastboot devices found"):
216+
ridesx_client.flash_oci_auto("oci://image:tag")

python/packages/jumpstarter-driver-ridesx/jumpstarter_driver_ridesx/driver_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,100 @@ def test_power_rescue(ridesx_power_driver):
452452
with pytest.raises(NotImplementedError, match="Rescue mode not available"):
453453
client.call("rescue")
454454

455+
456+
# Flash OCI Image Tests
457+
# Note: FLS download utilities are tested in jumpstarter.common.fls_test
458+
459+
460+
def test_flash_oci_image_success(temp_storage_dir, ridesx_driver):
461+
with serve(ridesx_driver) as client:
462+
with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="/usr/local/bin/fls"):
463+
with patch("subprocess.run") as mock_subprocess:
464+
mock_result = MagicMock()
465+
mock_result.stdout = "Flashing complete"
466+
mock_result.stderr = ""
467+
mock_result.returncode = 0
468+
mock_subprocess.return_value = mock_result
469+
470+
result = client.call("flash_oci_image", "oci://quay.io/image:tag", None, None, None)
471+
472+
assert result["status"] == "success"
473+
mock_subprocess.assert_called_once()
474+
call_args = mock_subprocess.call_args[0][0]
475+
assert call_args[0] == "/usr/local/bin/fls"
476+
assert call_args[1] == "fastboot"
477+
assert call_args[2] == "quay.io/image:tag" # oci:// prefix stripped
478+
479+
480+
def test_flash_oci_image_with_partitions(temp_storage_dir, ridesx_driver):
481+
with serve(ridesx_driver) as client:
482+
with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"):
483+
with patch("subprocess.run") as mock_subprocess:
484+
mock_result = MagicMock()
485+
mock_result.stdout = "Flashing complete"
486+
mock_result.stderr = ""
487+
mock_result.returncode = 0
488+
mock_subprocess.return_value = mock_result
489+
490+
partitions = {"boot_a": "boot.img", "system_a": "rootfs.simg"}
491+
result = client.call("flash_oci_image", "oci://image:tag", partitions, None, None)
492+
493+
assert result["status"] == "success"
494+
call_args = mock_subprocess.call_args[0][0]
495+
# Check that -t flags are present for partitions
496+
assert "-t" in call_args
497+
assert "boot_a:boot.img" in call_args
498+
assert "system_a:rootfs.simg" in call_args
499+
500+
501+
def test_flash_oci_image_error_cases(temp_storage_dir, ridesx_driver):
502+
"""Test flash_oci_image error handling for various failure modes"""
503+
from jumpstarter.client.core import DriverError
504+
505+
with serve(ridesx_driver) as client:
506+
# Reject non-oci:// schemes
507+
with pytest.raises(DriverError, match="Only oci:// URLs are supported"):
508+
client.call("flash_oci_image", "docker://image:tag", None, None, None)
509+
510+
with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"):
511+
with patch("subprocess.run") as mock_subprocess:
512+
# CalledProcessError
513+
error = subprocess.CalledProcessError(1, "fls")
514+
error.stdout = ""
515+
error.stderr = "Flash failed"
516+
mock_subprocess.side_effect = error
517+
518+
with pytest.raises(DriverError, match="FLS fastboot auto-detection failed"):
519+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
520+
521+
# TimeoutExpired
522+
mock_subprocess.side_effect = subprocess.TimeoutExpired("fls", 1800)
523+
524+
with pytest.raises(DriverError, match="FLS fastboot auto-detection timeout"):
525+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
526+
527+
# FileNotFoundError
528+
mock_subprocess.side_effect = FileNotFoundError("fls not found")
529+
530+
with pytest.raises(DriverError, match="FLS command not found"):
531+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
532+
533+
534+
def test_flash_oci_image_normalizes_url(temp_storage_dir, ridesx_driver):
535+
"""Test that OCI URLs are normalized correctly"""
536+
with serve(ridesx_driver) as client:
537+
with patch("jumpstarter_driver_ridesx.driver.get_fls_binary", return_value="fls"):
538+
with patch("subprocess.run") as mock_subprocess:
539+
mock_result = MagicMock()
540+
mock_result.stdout = "Done"
541+
mock_result.stderr = ""
542+
mock_result.returncode = 0
543+
mock_subprocess.return_value = mock_result
544+
545+
# Test that oci:// prefix is stripped
546+
client.call("flash_oci_image", "oci://quay.io/org/image:v1", None, None, None)
547+
548+
call_args = mock_subprocess.call_args[0][0]
549+
# The URL passed to fls should not have oci:// prefix
550+
assert "quay.io/org/image:v1" in call_args
551+
assert "oci://quay.io/org/image:v1" not in call_args

0 commit comments

Comments
 (0)