Skip to content

Commit 44e0d03

Browse files
committed
ridesx: add tests for fls integration
Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent 0422bf8 commit 44e0d03

2 files changed

Lines changed: 358 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+
("docker://registry.example.com/image:v1", True),
43+
("quay.io/myorg/myimage:latest", True),
44+
("ghcr.io/owner/repo:tag", True),
45+
("registry.redhat.io/rhel:9", True),
46+
# Non-OCI paths (should return False)
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: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,145 @@ def test_power_rescue(ridesx_power_driver):
452452
with pytest.raises(NotImplementedError, match="Rescue mode not available"):
453453
client.call("rescue")
454454

455+
456+
# FLS GitHub URL Tests
457+
458+
459+
@pytest.mark.parametrize(
460+
"arch,version,expected_binary",
461+
[
462+
("aarch64", "0.1.9", "fls-aarch64-linux"),
463+
("arm64", "0.1.9", "fls-aarch64-linux"),
464+
("x86_64", "0.2.0", "fls-x86_64-linux"),
465+
("amd64", "0.2.0", "fls-x86_64-linux"),
466+
("unknown", "0.1.9", "fls-aarch64-linux"), # defaults to aarch64
467+
],
468+
)
469+
def test_get_fls_github_url(ridesx_driver, arch, version, expected_binary):
470+
with patch("platform.machine", return_value=arch):
471+
url = ridesx_driver._get_fls_github_url(version)
472+
assert url == f"https://github.com/jumpstarter-dev/fls/releases/download/{version}/{expected_binary}"
473+
474+
475+
# FLS Binary Resolution Tests
476+
477+
478+
def test_get_fls_binary_with_custom_url(temp_storage_dir, ridesx_driver):
479+
binary_path = Path(temp_storage_dir) / "custom-fls"
480+
481+
with patch.object(ridesx_driver, "_download_fls", return_value=str(binary_path)) as mock_download:
482+
result = ridesx_driver._get_fls_binary(fls_binary_url="https://example.com/fls")
483+
484+
mock_download.assert_called_once_with("https://example.com/fls")
485+
assert result == str(binary_path)
486+
487+
488+
def test_get_fls_binary_with_version(temp_storage_dir, ridesx_driver):
489+
binary_path = Path(temp_storage_dir) / "fls-0.1.9"
490+
491+
with patch.object(ridesx_driver, "_download_fls", return_value=str(binary_path)) as mock_download:
492+
with patch.object(ridesx_driver, "_get_fls_github_url", return_value="https://github.com/...") as mock_url:
493+
result = ridesx_driver._get_fls_binary(fls_version="0.1.9")
494+
495+
mock_url.assert_called_once_with("0.1.9")
496+
mock_download.assert_called_once()
497+
assert result == str(binary_path)
498+
499+
500+
def test_get_fls_binary_falls_back_to_path(ridesx_driver):
501+
# No version/url configured, should fall back to system PATH
502+
result = ridesx_driver._get_fls_binary()
503+
assert result == "fls"
504+
505+
506+
# Flash OCI Image Tests
507+
508+
509+
def test_flash_oci_image_success(temp_storage_dir, ridesx_driver):
510+
with serve(ridesx_driver) as client:
511+
with patch.object(ridesx_driver, "_get_fls_binary", return_value="/usr/local/bin/fls"):
512+
with patch("subprocess.run") as mock_subprocess:
513+
mock_result = MagicMock()
514+
mock_result.stdout = "Flashing complete"
515+
mock_result.stderr = ""
516+
mock_result.returncode = 0
517+
mock_subprocess.return_value = mock_result
518+
519+
result = client.call("flash_oci_image", "oci://quay.io/image:tag", None, None, None)
520+
521+
assert result["status"] == "success"
522+
mock_subprocess.assert_called_once()
523+
call_args = mock_subprocess.call_args[0][0]
524+
assert call_args[0] == "/usr/local/bin/fls"
525+
assert call_args[1] == "fastboot"
526+
assert call_args[2] == "quay.io/image:tag" # oci:// prefix stripped
527+
528+
529+
def test_flash_oci_image_with_partitions(temp_storage_dir, ridesx_driver):
530+
with serve(ridesx_driver) as client:
531+
with patch.object(ridesx_driver, "_get_fls_binary", return_value="fls"):
532+
with patch("subprocess.run") as mock_subprocess:
533+
mock_result = MagicMock()
534+
mock_result.stdout = "Flashing complete"
535+
mock_result.stderr = ""
536+
mock_result.returncode = 0
537+
mock_subprocess.return_value = mock_result
538+
539+
partitions = {"boot_a": "boot.img", "system_a": "rootfs.simg"}
540+
result = client.call("flash_oci_image", "oci://image:tag", partitions, None, None)
541+
542+
assert result["status"] == "success"
543+
call_args = mock_subprocess.call_args[0][0]
544+
# Check that -t flags are present for partitions
545+
assert "-t" in call_args
546+
assert "boot_a:boot.img" in call_args
547+
assert "system_a:rootfs.simg" in call_args
548+
549+
550+
def test_flash_oci_image_error_cases(temp_storage_dir, ridesx_driver):
551+
"""Test flash_oci_image error handling for various failure modes"""
552+
from jumpstarter.client.core import DriverError
553+
554+
with serve(ridesx_driver) as client:
555+
with patch.object(ridesx_driver, "_get_fls_binary", return_value="fls"):
556+
with patch("subprocess.run") as mock_subprocess:
557+
# CalledProcessError
558+
error = subprocess.CalledProcessError(1, "fls")
559+
error.stdout = ""
560+
error.stderr = "Flash failed"
561+
mock_subprocess.side_effect = error
562+
563+
with pytest.raises(DriverError, match="FLS fastboot auto-detection failed"):
564+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
565+
566+
# TimeoutExpired
567+
mock_subprocess.side_effect = subprocess.TimeoutExpired("fls", 1800)
568+
569+
with pytest.raises(DriverError, match="FLS fastboot auto-detection timeout"):
570+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
571+
572+
# FileNotFoundError
573+
mock_subprocess.side_effect = FileNotFoundError("fls not found")
574+
575+
with pytest.raises(DriverError, match="FLS command not found"):
576+
client.call("flash_oci_image", "oci://image:tag", None, None, None)
577+
578+
579+
def test_flash_oci_image_normalizes_url(temp_storage_dir, ridesx_driver):
580+
"""Test that OCI URLs are normalized correctly"""
581+
with serve(ridesx_driver) as client:
582+
with patch.object(ridesx_driver, "_get_fls_binary", return_value="fls"):
583+
with patch("subprocess.run") as mock_subprocess:
584+
mock_result = MagicMock()
585+
mock_result.stdout = "Done"
586+
mock_result.stderr = ""
587+
mock_result.returncode = 0
588+
mock_subprocess.return_value = mock_result
589+
590+
# Test that oci:// prefix is stripped
591+
client.call("flash_oci_image", "oci://quay.io/org/image:v1", None, None, None)
592+
593+
call_args = mock_subprocess.call_args[0][0]
594+
# The URL passed to fls should not have oci:// prefix
595+
assert "quay.io/org/image:v1" in call_args
596+
assert "oci://quay.io/org/image:v1" not in call_args

0 commit comments

Comments
 (0)