Skip to content

Commit 39b0356

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 7839e50 commit 39b0356

2 files changed

Lines changed: 322 additions & 22 deletions

File tree

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

Lines changed: 236 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33
from typing import Dict, Optional
44

5+
import click
56
from jumpstarter_driver_composite.client import CompositeClient
67
from jumpstarter_driver_opendal.client import FlasherClient, operator_for_path
78
from jumpstarter_driver_power.client import PowerClient
@@ -68,7 +69,7 @@ def flash_images(self, partitions: Dict[str, str], operators: Optional[Dict[str,
6869
detection_result = self.call("detect_fastboot_device", 5, 2.0)
6970

7071
if detection_result["status"] != "device_found":
71-
raise RuntimeError("No fastboot devices found. Make sure device is in fastboot mode.")
72+
raise click.ClickException("No fastboot devices found. Make sure device is in fastboot mode.")
7273

7374
device_id = detection_result["device_id"]
7475
self.logger.info(f"found fastboot device: {device_id}")
@@ -77,6 +78,31 @@ def flash_images(self, partitions: Dict[str, str], operators: Optional[Dict[str,
7778

7879
return flash_result
7980

81+
def _is_oci_path(self, path: str) -> bool:
82+
"""Return True if path looks like an OCI image reference."""
83+
return path.startswith(("oci://", "docker://")) or (
84+
":" in path and "/" in path and not path.startswith("/") and not path.startswith(("http://", "https://"))
85+
)
86+
87+
def _validate_partition_mappings(self, partitions: Dict[str, str] | None) -> None:
88+
"""Validate partition mappings; raise ValueError if any path is empty."""
89+
if partitions is None:
90+
return
91+
for partition_name, file_path in partitions.items():
92+
if not file_path or not file_path.strip():
93+
raise ValueError(
94+
f"Partition '{partition_name}' has an empty file path. "
95+
f"Please provide a valid file path (e.g., -t {partition_name}:/path/to/image)"
96+
)
97+
98+
def _power_off_if_available(self) -> None:
99+
"""Power off device if power child is present."""
100+
if "power" in self.children:
101+
self.power.off()
102+
self.logger.info("device powered off")
103+
else:
104+
self.logger.info("device left running")
105+
80106
def flash(
81107
self,
82108
path: str | Dict[str, str],
@@ -85,41 +111,183 @@ def flash(
85111
operator: Operator | Dict[str, Operator] | None = None,
86112
compression=None,
87113
):
114+
"""Flash image to DUT - supports both OCI and traditional paths.
115+
116+
Args:
117+
path: File path, URL, or OCI image reference (or dict of partition->path mappings)
118+
target: Target partition (for single file mode)
119+
operator: Optional operator for file access (usually auto-detected)
120+
compression: Compression type
121+
"""
122+
# Auto-detect flash mode based on path type
88123
if isinstance(path, dict):
89-
partitions = path
90-
operators = operator if isinstance(operator, dict) else None
124+
# Dictionary mode: {partition: file_path, ...}
125+
operators_dict = operator if isinstance(operator, dict) else None
126+
return self.flash_local(path, operators_dict)
127+
128+
elif isinstance(path, str) and (path.startswith("oci://") or self._is_oci_path(path)):
129+
# OCI mode: auto-detect partitions or use target as partition->filename mapping
130+
if target and ":" in target:
131+
# Target is "partition:filename" format for OCI explicit mapping
132+
partition_name, filename = target.split(":", 1)
133+
partitions = {partition_name: filename}
134+
return self.flash_with_targets(path, partitions)
135+
else:
136+
# OCI auto-detection mode
137+
return self.flash_oci_auto(path, None)
138+
91139
else:
140+
# Traditional single file mode
92141
if target is None:
93142
raise ValueError(
94-
"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"
143+
"This driver requires a target partition for non-OCI paths.\n"
144+
"Usage: client.flash('/path/to/file.img', target='boot_a')\n"
145+
"For OCI: client.flash('oci://registry.com/image:tag')\n"
146+
"For dict: client.flash({'boot_a': '/path/to/file.img'})"
97147
)
148+
149+
# Use operator if provided, otherwise auto-detect
150+
if operator is not None:
151+
operators = {target: operator} if isinstance(operator, Operator) else operator
152+
else:
153+
operators = None
154+
98155
partitions = {target: path}
99-
operators = {target: operator} if isinstance(operator, Operator) else None
156+
return self.flash_local(partitions, operators)
100157

101-
for partition_name, file_path in partitions.items():
102-
if not file_path or not file_path.strip():
103-
raise ValueError(
104-
f"Partition '{partition_name}' has an empty file path. "
105-
f"Please provide a valid file path (e.g., -t {partition_name}:/path/to/image)"
106-
)
158+
def flash_with_targets(
159+
self,
160+
oci_url: str,
161+
partitions: Dict[str, str],
162+
):
163+
"""Flash OCI image with explicit partition mappings.
164+
165+
Args:
166+
oci_url: OCI image URL (must start with oci://)
167+
partitions: Mapping of partition name -> filename in OCI image
168+
169+
Raises:
170+
ValueError: If partitions is empty or None
171+
"""
172+
if not partitions:
173+
raise ValueError(
174+
"flash_with_targets requires a non-empty mapping of partition name -> filename. "
175+
"Use flash() for auto-detection mode."
176+
)
177+
self._validate_partition_mappings(partitions)
107178

108179
self.logger.info("Starting RideSX flash operation")
180+
self.boot_to_fastboot()
181+
182+
self.logger.info(f"Using FLS OCI flash with explicit mapping for image: {oci_url}")
183+
result = self.flash_oci_auto(oci_url, partitions)
184+
185+
self.logger.info("flash operation completed successfully")
186+
self._power_off_if_available()
187+
return result
109188

189+
def flash_local(
190+
self,
191+
partitions: Dict[str, str],
192+
operators: Dict[str, Operator] | None = None,
193+
):
194+
"""Flash local files or URLs to partitions.
195+
196+
Args:
197+
partitions: Mapping of partition name -> file path or URL
198+
operators: Optional mapping of partition name -> operator
199+
"""
200+
self._validate_partition_mappings(partitions)
201+
202+
self.logger.info("Starting RideSX flash operation")
110203
self.boot_to_fastboot()
111204

205+
self.logger.info(f"Flashing local files: {list(partitions.keys())}")
112206
result = self.flash_images(partitions, operators)
113207

114208
self.logger.info("flash operation completed successfully")
209+
self._power_off_if_available()
210+
return result
115211

116-
if "power" in self.children:
117-
self.power.off()
118-
self.logger.info("device powered off")
212+
def flash_oci_auto(
213+
self,
214+
oci_url: str,
215+
partitions: Dict[str, str] | None = None,
216+
):
217+
"""Flash OCI image using auto-detection or explicit partition mapping
218+
219+
Args:
220+
oci_url: OCI image reference (e.g., "oci://registry.com/image:latest")
221+
partitions: Optional mapping of partition -> filename inside OCI image
222+
"""
223+
# Normalize OCI URL
224+
if not oci_url.startswith("oci://"):
225+
if "://" in oci_url:
226+
raise ValueError(f"Only oci:// URLs are supported, got: {oci_url}")
227+
if ":" in oci_url and "/" in oci_url:
228+
oci_url = f"oci://{oci_url}"
229+
else:
230+
raise ValueError(f"Invalid OCI URL format: {oci_url}")
231+
232+
if partitions:
233+
self.logger.info(f"Flashing OCI image with explicit mapping: {list(partitions.keys())}")
119234
else:
120-
self.logger.info("device left running")
235+
self.logger.info(f"Auto-detecting partitions for OCI image: {oci_url}")
121236

122-
return result
237+
self.logger.info("Starting RideSX flash operation")
238+
self.boot_to_fastboot()
239+
240+
self.logger.info("Checking for fastboot devices on Exporter...")
241+
detection_result = self.call("detect_fastboot_device", 5, 2.0)
242+
243+
if detection_result["status"] != "device_found":
244+
raise click.ClickException("No fastboot devices found. Make sure device is in fastboot mode.")
245+
246+
device_id = detection_result["device_id"]
247+
self.logger.info(f"Found fastboot device: {device_id}")
248+
249+
flash_result = self.call("flash_oci_image", oci_url, partitions)
250+
251+
# Display FLS output to user
252+
if flash_result.get("status") == "success" and flash_result.get("output"):
253+
self.logger.info("FLS fastboot completed successfully")
254+
# Log the detailed output for user visibility
255+
for line in flash_result["output"].strip().split("\n"):
256+
if line.strip():
257+
self.logger.info(f"FLS: {line.strip()}")
258+
259+
return flash_result
260+
261+
def _parse_target_specs(self, target_specs: tuple[str, ...]) -> dict[str, str]:
262+
"""Parse -t target specs into a partition->path mapping."""
263+
mapping: dict[str, str] = {}
264+
for spec in target_specs:
265+
if ":" not in spec:
266+
raise click.ClickException(f"Invalid target spec '{spec}'. Expected format: partition:path")
267+
name, path = spec.split(":", 1)
268+
mapping[name] = path
269+
return mapping
270+
271+
def _parse_and_validate_targets(self, target_specs: tuple[str, ...]):
272+
"""Parse and validate target specifications, returning (mapping, single_target)."""
273+
mapping = {}
274+
single_target = None
275+
276+
for spec in target_specs:
277+
if ":" in spec:
278+
# Multi-partition format: partition:path
279+
partition, file_path = spec.split(":", 1)
280+
mapping[partition] = file_path
281+
else:
282+
# Single partition format: just partition name
283+
if single_target is not None:
284+
raise click.ClickException("Cannot mix single-partition and multi-partition target specs")
285+
single_target = spec
286+
287+
if mapping and single_target:
288+
raise click.ClickException("Cannot mix single-partition and multi-partition target specs")
289+
290+
return mapping, single_target
123291

124292
def cli(self):
125293
generic_cli = FlasherClient.cli(self)
@@ -129,8 +297,57 @@ def base():
129297
"""RideSX storage operations"""
130298
pass
131299

300+
# Add all generic commands except 'flash' (we override it)
132301
for name, cmd in generic_cli.commands.items():
133-
base.add_command(cmd, name=name)
302+
if name != "flash":
303+
base.add_command(cmd, name=name)
304+
305+
@base.command()
306+
@click.argument("path", required=False)
307+
@click.option(
308+
"-t",
309+
"--target",
310+
"target_specs",
311+
multiple=True,
312+
help="Target spec as partition:path for multi-partition or just partition for single file",
313+
)
314+
def flash(path, target_specs):
315+
"""Flash image to device.
316+
317+
\b
318+
Examples:
319+
# OCI auto-detection
320+
j storage flash oci://registry.com/image:tag
321+
322+
# OCI with explicit partition->filename mapping
323+
j storage flash -t boot_a:boot.img oci://registry.com/image:tag
324+
325+
# Single file to partition
326+
j storage flash /local/boot.img --target boot_a
327+
328+
# Multiple files
329+
j storage flash -t boot_a:/local/boot.img -t system_a:/local/system.img
330+
331+
# HTTP URLs
332+
j storage flash -t boot_a:https://example.com/boot.img
333+
"""
334+
# Parse target specifications
335+
if target_specs:
336+
mapping, single_target = self._parse_and_validate_targets(target_specs)
337+
338+
if mapping:
339+
# Multi-partition mode: use mapping as dict
340+
self.flash(mapping)
341+
else:
342+
# Single partition mode: use path with target
343+
if not path:
344+
raise click.ClickException("Path argument required when using single-partition target")
345+
self.flash(path, target=single_target)
346+
elif path:
347+
# Path only - should be OCI for auto-detection
348+
self.flash(path)
349+
else:
350+
raise click.ClickException("Provide a path or use -t to specify partition mappings")
134351

135352
@base.command()
136353
def boot_to_fastboot():

0 commit comments

Comments
 (0)