Skip to content

Commit 1e4aa18

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 5efed5b commit 1e4aa18

2 files changed

Lines changed: 258 additions & 22 deletions

File tree

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

Lines changed: 195 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -77,60 +77,236 @@ 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."""
115+
return path.startswith(("oci://", "docker://")) or (":" in path and "/" in path and not path.startswith("/"))
116+
117+
def _resolve_flash_input(
81118
self,
82119
path: str | Dict[str, str],
83-
*,
84-
target: str | None = None,
85-
operator: Operator | Dict[str, Operator] | None = None,
86-
compression=None,
87-
):
120+
target: str | None,
121+
operator: Operator | Dict[str, Operator] | None,
122+
oci_url_from_argv: str | None,
123+
) -> tuple[Dict[str, str] | None, Dict[str, Operator] | None, str | None]:
124+
"""Resolve path/target/operator into (partitions, operators, oci_url)."""
88125
if isinstance(path, dict):
89-
partitions = path
90-
operators = operator if isinstance(operator, dict) else None
126+
return path, operator if isinstance(operator, dict) else None, oci_url_from_argv
127+
128+
if self._is_oci_path(path):
129+
oci_url = path
130+
if target is None:
131+
print("Auto-detecting partitions from OCI image annotations...")
132+
return None, None, oci_url
133+
if ":" not in target:
134+
raise ValueError(f"Target must be in format 'partition:filename' for OCI flashing, got: {target}")
135+
partition_name, filename = target.split(":", 1)
136+
partitions = {partition_name: filename}
91137
else:
92138
if target is None:
93139
raise ValueError(
94140
"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"
141+
"Usage: j storage flash --target <partition>:<file> [path]\n"
142+
"For OCI images: j storage flash [oci://image] (auto-detects partitions)\n"
143+
"For OCI with explicit mapping: j storage flash -t boot_a:boot.simg oci://image\n"
144+
"For local files: j storage flash -t boot_a:/path/to/boot.img"
97145
)
98146
partitions = {target: path}
99-
operators = {target: operator} if isinstance(operator, Operator) else None
147+
oci_url = oci_url_from_argv
148+
149+
operators = {list(partitions.keys())[0]: operator} if partitions and isinstance(operator, Operator) else None
150+
return partitions, operators, oci_url
100151

152+
def _validate_partition_mappings(self, partitions: Dict[str, str] | None) -> None:
153+
"""Validate partition mappings; raise ValueError if any path is empty."""
154+
if partitions is None:
155+
return
101156
for partition_name, file_path in partitions.items():
102157
if not file_path or not file_path.strip():
103158
raise ValueError(
104159
f"Partition '{partition_name}' has an empty file path. "
105160
f"Please provide a valid file path (e.g., -t {partition_name}:/path/to/image)"
106161
)
107162

108-
self.logger.info("Starting RideSX flash operation")
163+
def _power_off_if_available(self) -> None:
164+
"""Power off device if power child is present."""
165+
if "power" in self.children:
166+
self.power.off()
167+
self.logger.info("device powered off")
168+
else:
169+
self.logger.info("device left running")
109170

171+
def flash(
172+
self,
173+
path: str | Dict[str, str],
174+
*,
175+
target: str | None = None,
176+
operator: Operator | Dict[str, Operator] | None = None,
177+
compression=None,
178+
fls_version: str | None = None,
179+
fls_binary_url: str | None = None,
180+
):
181+
oci_url_from_argv = self._oci_url_from_argv()
182+
if oci_url_from_argv:
183+
self.logger.info(f"Detected OCI URL from command line: {oci_url_from_argv}")
184+
185+
partitions, operators, oci_url = self._resolve_flash_input(path, target, operator, oci_url_from_argv)
186+
if oci_url and partitions is None:
187+
argv_targets = self._target_mappings_from_argv()
188+
if argv_targets:
189+
self.logger.info("Using target mappings from command line arguments")
190+
partitions = argv_targets
191+
self._validate_partition_mappings(partitions)
192+
193+
self.logger.info("Starting RideSX flash operation")
110194
self.boot_to_fastboot()
111195

112-
result = self.flash_images(partitions, operators)
196+
if oci_url:
197+
if partitions is None:
198+
self.logger.info(f"Using FLS auto-detection for OCI image: {oci_url}")
199+
else:
200+
self.logger.info(f"Using FLS OCI flash with explicit mapping for image: {oci_url}")
201+
result = self.flash_oci_auto(oci_url, partitions, fls_version=fls_version, fls_binary_url=fls_binary_url)
202+
else:
203+
self.logger.info("Using traditional file-based flashing")
204+
result = self.flash_images(partitions, operators)
113205

114206
self.logger.info("flash operation completed successfully")
207+
self._power_off_if_available()
208+
return result
115209

116-
if "power" in self.children:
117-
self.power.off()
118-
self.logger.info("device powered off")
210+
def flash_oci_auto(
211+
self,
212+
oci_url: str,
213+
partitions: Dict[str, str] | None = None,
214+
fls_version: str | None = None,
215+
fls_binary_url: 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+
fls_version: Optional FLS version to download from GitHub releases
223+
fls_binary_url: Optional custom URL to download FLS binary from
224+
"""
225+
# Normalize OCI URL
226+
if not oci_url.startswith(("oci://", "docker://")):
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("Checking for fastboot devices on Exporter...")
238+
detection_result = self.call("detect_fastboot_device", 5, 2.0)
239+
240+
if detection_result["status"] != "device_found":
241+
raise RuntimeError("No fastboot devices found. Make sure device is in fastboot mode.")
242+
243+
device_id = detection_result["device_id"]
244+
self.logger.info(f"Found fastboot device: {device_id}")
245+
246+
flash_result = self.call("flash_oci_image", oci_url, partitions, fls_version, fls_binary_url)
247+
248+
return flash_result
123249

124250
def cli(self):
251+
import click
252+
125253
generic_cli = FlasherClient.cli(self)
126254

127255
@driver_click_group(self)
128256
def base():
129257
"""RideSX storage operations"""
130258
pass
131259

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

135311
@base.command()
136312
def boot_to_fastboot():

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

Lines changed: 63 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,64 @@ 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+
# Normalize OCI URL - remove oci:// prefix for FLS
219+
if oci_url.startswith("oci://"):
220+
fls_url = oci_url[6:] # Remove 'oci://' prefix
221+
else:
222+
fls_url = oci_url
223+
224+
fls_binary = get_fls_binary(fls_version, fls_binary_url)
225+
fls_cmd = [fls_binary, "fastboot", fls_url]
226+
227+
if partitions:
228+
for partition_name, filename in sorted(partitions.items()):
229+
fls_cmd.extend(["-t", f"{partition_name}:{filename}"])
230+
231+
# Align fastboot timeout with driver timeout
232+
fls_cmd.extend(["--timeout", str(self.flash_timeout)])
233+
234+
self.logger.info(f"Running FLS fastboot: {' '.join(fls_cmd)}")
235+
236+
try:
237+
result = subprocess.run(fls_cmd, capture_output=True, text=True, check=True, timeout=self.flash_timeout)
238+
239+
self.logger.info("FLS fastboot auto-detection completed successfully")
240+
self.logger.debug(f"FLS stdout: {result.stdout}")
241+
if result.stderr:
242+
self.logger.debug(f"FLS stderr: {result.stderr}")
243+
244+
return {"status": "success", "output": result.stdout}
245+
246+
except subprocess.CalledProcessError as e:
247+
self.logger.error(f"FLS fastboot auto-detection failed - return code: {e.returncode}")
248+
self.logger.error(f"stdout: {e.stdout}")
249+
self.logger.error(f"stderr: {e.stderr}")
250+
raise RuntimeError(f"FLS fastboot auto-detection failed: {e}") from e
251+
252+
except subprocess.TimeoutExpired:
253+
self.logger.error("FLS fastboot auto-detection timed out")
254+
raise RuntimeError("FLS fastboot auto-detection timeout") from None
255+
256+
except FileNotFoundError:
257+
self.logger.error("FLS command not found - ensure FLS is installed and in PATH")
258+
raise RuntimeError("FLS command not found") from None
259+
200260
@export
201261
async def boot_to_fastboot(self):
202262
"""Boot device to fastboot mode"""

0 commit comments

Comments
 (0)