22from pathlib import Path
33from typing import Dict , Optional
44
5+ import click
56from jumpstarter_driver_composite .client import CompositeClient
67from jumpstarter_driver_opendal .client import FlasherClient , operator_for_path
78from 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