Skip to content

Commit 7d25e87

Browse files
committed
ridesx: support OCI flashing
by leveraging FLS, we introduce the option to do `j storage flash oci://...` for ridesx targets. Currently, this will extract the OCI archive to the exporter host and proceed to flash with fastboot CLI. This also includes the option to specify which files to use by using -t/--target in combination with oci:// For example: ``` j storage flash -t boot_a:boot_a.simg system_a:system_a.simg oci://quay.io/bzlotnik/ridesx:latest ``` Otherwise it would rely on annotations to figure out the partitions from caib annotations: "automotive.sdv.cloud.redhat.com/partition" Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com>
1 parent 09ea7f1 commit 7d25e87

2 files changed

Lines changed: 264 additions & 21 deletions

File tree

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

Lines changed: 204 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -77,60 +77,246 @@ def flash_images(self, partitions: Dict[str, str], operators: Optional[Dict[str,
7777

7878
return flash_result
7979

80-
def flash(
80+
def _oci_url_from_argv(self) -> str | None:
81+
"""Return first OCI-like URL from sys.argv, or None."""
82+
import sys
83+
84+
for arg in sys.argv[1:]:
85+
if self._is_oci_path(arg):
86+
return arg
87+
return None
88+
89+
def _target_mappings_from_argv(self) -> Dict[str, str] | None:
90+
"""Return target mappings from sys.argv, if present."""
91+
import sys
92+
93+
mapping: Dict[str, str] = {}
94+
args = sys.argv[1:]
95+
i = 0
96+
while i < len(args):
97+
arg = args[i]
98+
spec = None
99+
if arg in ("-t", "--target"):
100+
if i + 1 < len(args):
101+
spec = args[i + 1]
102+
i += 1
103+
elif arg.startswith("--target="):
104+
spec = arg.split("=", 1)[1]
105+
106+
if spec and ":" in spec:
107+
name, img = spec.split(":", 1)
108+
mapping[name] = img
109+
i += 1
110+
111+
return mapping or None
112+
113+
def _is_oci_path(self, path: str) -> bool:
114+
"""Return True if path looks like an OCI image reference (oci:// or bare registry URL)."""
115+
if path.startswith("oci://"):
116+
return True
117+
# Reject other schemes (docker://, http://, etc.)
118+
if "://" in path:
119+
return False
120+
# Bare registry URL: has colon (for tag) and slash (for path), not absolute path
121+
return ":" in path and "/" in path and not path.startswith("/")
122+
123+
def _resolve_flash_input(
81124
self,
82125
path: str | Dict[str, str],
83-
*,
84-
target: str | None = None,
85-
operator: Operator | Dict[str, Operator] | None = None,
86-
compression=None,
87-
):
126+
target: str | None,
127+
operator: Operator | Dict[str, Operator] | None,
128+
oci_url_from_argv: str | None,
129+
) -> tuple[Dict[str, str] | None, Dict[str, Operator] | None, str | None]:
130+
"""Resolve path/target/operator into (partitions, operators, oci_url)."""
88131
if isinstance(path, dict):
89-
partitions = path
90132
operators = operator if isinstance(operator, dict) else None
133+
oci_url = oci_url_from_argv if oci_url_from_argv is not None else None
134+
return path, operators, oci_url
135+
136+
if self._is_oci_path(path):
137+
oci_url = path
138+
if target is None:
139+
self.logger.info("Auto-detecting partitions from OCI image annotations...")
140+
return None, None, oci_url
141+
if ":" not in target:
142+
raise ValueError(f"Target must be in format 'partition:filename' for OCI flashing, got: {target}")
143+
partition_name, filename = target.split(":", 1)
144+
partitions = {partition_name: filename}
91145
else:
92146
if target is None:
93147
raise ValueError(
94148
"This driver requires a target partition.\n"
95-
"Usage: j storage flash --target <partition>:<file>\n"
96-
"Example: j storage flash -t boot_a:aboot.img -t system_a:rootfs.simg -t system_b:qm_var.simg"
149+
"Usage: j storage flash --target <partition>:<file> [path]\n"
150+
"For OCI images: j storage flash [oci://image] (auto-detects partitions)\n"
151+
"For OCI with explicit mapping: j storage flash -t boot_a:boot.simg oci://image\n"
152+
"For local files: j storage flash -t boot_a:/path/to/boot.img"
97153
)
98154
partitions = {target: path}
99-
operators = {target: operator} if isinstance(operator, Operator) else None
155+
oci_url = oci_url_from_argv
156+
157+
operators = {list(partitions.keys())[0]: operator} if partitions and isinstance(operator, Operator) else None
158+
return partitions, operators, oci_url
100159

160+
def _validate_partition_mappings(self, partitions: Dict[str, str] | None) -> None:
161+
"""Validate partition mappings; raise ValueError if any path is empty."""
162+
if partitions is None:
163+
return
101164
for partition_name, file_path in partitions.items():
102165
if not file_path or not file_path.strip():
103166
raise ValueError(
104167
f"Partition '{partition_name}' has an empty file path. "
105168
f"Please provide a valid file path (e.g., -t {partition_name}:/path/to/image)"
106169
)
107170

108-
self.logger.info("Starting RideSX flash operation")
171+
def _power_off_if_available(self) -> None:
172+
"""Power off device if power child is present."""
173+
if "power" in self.children:
174+
self.power.off()
175+
self.logger.info("device powered off")
176+
else:
177+
self.logger.info("device left running")
109178

179+
def flash(
180+
self,
181+
path: str | Dict[str, str],
182+
*,
183+
target: str | None = None,
184+
operator: Operator | Dict[str, Operator] | None = None,
185+
compression=None,
186+
fls_version: str | None = None,
187+
fls_binary_url: str | None = None,
188+
):
189+
oci_url_from_argv = self._oci_url_from_argv()
190+
if oci_url_from_argv:
191+
self.logger.info(f"Detected OCI URL from command line: {oci_url_from_argv}")
192+
193+
partitions, operators, oci_url = self._resolve_flash_input(path, target, operator, oci_url_from_argv)
194+
if oci_url and partitions is None:
195+
argv_targets = self._target_mappings_from_argv()
196+
if argv_targets:
197+
self.logger.info("Using target mappings from command line arguments")
198+
partitions = argv_targets
199+
self._validate_partition_mappings(partitions)
200+
201+
self.logger.info("Starting RideSX flash operation")
110202
self.boot_to_fastboot()
111203

112-
result = self.flash_images(partitions, operators)
204+
if oci_url:
205+
if partitions is None:
206+
self.logger.info(f"Using FLS auto-detection for OCI image: {oci_url}")
207+
else:
208+
self.logger.info(f"Using FLS OCI flash with explicit mapping for image: {oci_url}")
209+
result = self.flash_oci_auto(oci_url, partitions, fls_version=fls_version, fls_binary_url=fls_binary_url)
210+
else:
211+
self.logger.info("Using traditional file-based flashing")
212+
result = self.flash_images(partitions, operators)
113213

114214
self.logger.info("flash operation completed successfully")
215+
self._power_off_if_available()
216+
return result
115217

116-
if "power" in self.children:
117-
self.power.off()
118-
self.logger.info("device powered off")
218+
def flash_oci_auto(
219+
self,
220+
oci_url: str,
221+
partitions: Dict[str, str] | None = None,
222+
fls_version: str | None = None,
223+
fls_binary_url: str | None = None,
224+
):
225+
"""Flash OCI image using auto-detection or explicit partition mapping
226+
227+
Args:
228+
oci_url: OCI image reference (e.g., "oci://registry.com/image:latest")
229+
partitions: Optional mapping of partition -> filename inside OCI image
230+
fls_version: Optional FLS version to download from GitHub releases
231+
fls_binary_url: Optional custom URL to download FLS binary from
232+
"""
233+
# Normalize OCI URL
234+
if not oci_url.startswith("oci://"):
235+
if "://" in oci_url:
236+
raise ValueError(f"Only oci:// URLs are supported, got: {oci_url}")
237+
if ":" in oci_url and "/" in oci_url:
238+
oci_url = f"oci://{oci_url}"
239+
else:
240+
raise ValueError(f"Invalid OCI URL format: {oci_url}")
241+
242+
if partitions:
243+
self.logger.info(f"Flashing OCI image with explicit mapping: {list(partitions.keys())}")
119244
else:
120-
self.logger.info("device left running")
245+
self.logger.info(f"Auto-detecting partitions for OCI image: {oci_url}")
121246

122-
return result
247+
self.logger.info("Checking for fastboot devices on Exporter...")
248+
detection_result = self.call("detect_fastboot_device", 5, 2.0)
249+
250+
if detection_result["status"] != "device_found":
251+
raise RuntimeError("No fastboot devices found. Make sure device is in fastboot mode.")
252+
253+
device_id = detection_result["device_id"]
254+
self.logger.info(f"Found fastboot device: {device_id}")
255+
256+
flash_result = self.call("flash_oci_image", oci_url, partitions, fls_version, fls_binary_url)
257+
258+
return flash_result
123259

124260
def cli(self):
261+
import click
262+
125263
generic_cli = FlasherClient.cli(self)
126264

127265
@driver_click_group(self)
128266
def base():
129267
"""RideSX storage operations"""
130268
pass
131269

270+
# Add all generic commands except 'flash' (we override it)
132271
for name, cmd in generic_cli.commands.items():
133-
base.add_command(cmd, name=name)
272+
if name != "flash":
273+
base.add_command(cmd, name=name)
274+
275+
@base.command()
276+
@click.argument("file")
277+
@click.option(
278+
"-t",
279+
"--target",
280+
"target_specs",
281+
multiple=True,
282+
help="Partition mapping as partition:filename (e.g., boot_a:boot.img)",
283+
)
284+
@click.option(
285+
"--fls-version",
286+
type=str,
287+
default=None,
288+
help="FLS version to download from GitHub releases (e.g., 0.1.9)",
289+
)
290+
@click.option(
291+
"--fls-binary-url",
292+
type=str,
293+
default=None,
294+
help="Custom URL to download FLS binary from (overrides --fls-version)",
295+
)
296+
def flash(file, target_specs, fls_version, fls_binary_url):
297+
"""Flash image to device
298+
299+
For OCI images with auto-detection:
300+
j storage flash oci://registry.com/image:tag
301+
302+
For OCI images with explicit partition mapping:
303+
j storage flash -t boot_a:boot.img oci://registry.com/image:tag
304+
305+
For local files:
306+
j storage flash -t boot_a:/path/to/boot.img .
307+
"""
308+
if target_specs:
309+
mapping: dict[str, str] = {}
310+
for spec in target_specs:
311+
if ":" not in spec:
312+
raise click.ClickException(f"Invalid target spec '{spec}'. Expected format: partition:filename")
313+
name, img = spec.split(":", 1)
314+
mapping[name] = img
315+
316+
self.flash(mapping, fls_version=fls_version, fls_binary_url=fls_binary_url)
317+
return
318+
319+
self.flash(file, target=None, fls_version=fls_version, fls_binary_url=fls_binary_url)
134320

135321
@base.command()
136322
def boot_to_fastboot():

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

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
from jumpstarter_driver_opendal.driver import Opendal
99

1010
from jumpstarter.common.exceptions import ConfigurationError
11+
from jumpstarter.common.fls import get_fls_binary
1112
from jumpstarter.driver import Driver, export
1213

1314

1415
@dataclass(kw_only=True)
1516
class RideSXDriver(Driver):
1617
"""RideSX Driver"""
17-
decompression_timeout: int = field(default=15 * 60) # 15 minutes
18-
flash_timeout: int = field(default=30 * 60) # 30 minutes
19-
continue_timeout: int = field(default=20 * 60) # 20 minutes
18+
19+
decompression_timeout: int = field(default=15 * 60) # 15 minutes
20+
flash_timeout: int = field(default=30 * 60) # 30 minutes
21+
continue_timeout: int = field(default=20 * 60) # 20 minutes
2022
storage_dir: str = field(default="/var/lib/jumpstarter/ridesx")
2123

2224
def __post_init__(self):
@@ -197,6 +199,61 @@ def flash_with_fastboot(self, device_id: str, partitions: Dict[str, str]):
197199
self.logger.warning(f"stdout: {e.stdout}")
198200
self.logger.warning(f"stderr: {e.stderr}")
199201

202+
@export
203+
def flash_oci_image(
204+
self,
205+
oci_url: str,
206+
partitions: Dict[str, str] | None = None,
207+
fls_version: str | None = None,
208+
fls_binary_url: str | None = None,
209+
):
210+
"""Flash OCI image using FLS fastboot CLI
211+
212+
Args:
213+
oci_url: OCI image reference (e.g., "quay.io/bzlotnik/ridesx-image:latest")
214+
partitions: Optional mapping of partition -> filename inside OCI image
215+
fls_version: Optional FLS version to download from GitHub releases
216+
fls_binary_url: Optional custom URL to download FLS binary from
217+
"""
218+
if not oci_url.startswith("oci://"):
219+
raise ValueError(f"OCI URL must start with oci://, got: {oci_url}")
220+
221+
fls_binary = get_fls_binary(fls_version, fls_binary_url)
222+
fls_cmd = [fls_binary, "fastboot", oci_url]
223+
224+
if partitions:
225+
for partition_name, filename in sorted(partitions.items()):
226+
fls_cmd.extend(["-t", f"{partition_name}:{filename}"])
227+
228+
# Align fastboot timeout with driver timeout
229+
fls_cmd.extend(["--timeout", str(self.flash_timeout)])
230+
231+
self.logger.info(f"Running FLS fastboot: {' '.join(fls_cmd)}")
232+
233+
try:
234+
result = subprocess.run(fls_cmd, capture_output=True, text=True, check=True, timeout=self.flash_timeout)
235+
236+
self.logger.info("FLS fastboot auto-detection completed successfully")
237+
self.logger.debug(f"FLS stdout: {result.stdout}")
238+
if result.stderr:
239+
self.logger.debug(f"FLS stderr: {result.stderr}")
240+
241+
return {"status": "success", "output": result.stdout}
242+
243+
except subprocess.CalledProcessError as e:
244+
self.logger.error(f"FLS fastboot auto-detection failed - return code: {e.returncode}")
245+
self.logger.error(f"stdout: {e.stdout}")
246+
self.logger.error(f"stderr: {e.stderr}")
247+
raise RuntimeError(f"FLS fastboot auto-detection failed: {e}") from e
248+
249+
except subprocess.TimeoutExpired:
250+
self.logger.error("FLS fastboot auto-detection timed out")
251+
raise RuntimeError("FLS fastboot auto-detection timeout") from None
252+
253+
except FileNotFoundError:
254+
self.logger.error("FLS command not found - ensure FLS is installed and in PATH")
255+
raise RuntimeError("FLS command not found") from None
256+
200257
@export
201258
async def boot_to_fastboot(self):
202259
"""Boot device to fastboot mode"""

0 commit comments

Comments
 (0)