1212from urllib .parse import urlparse
1313
1414import click
15+ import pexpect
1516import requests
1617from jumpstarter_driver_composite .client import CompositeClient
1718from jumpstarter_driver_opendal .client import FlasherClient , OpendalClient , operator_for_path
@@ -96,6 +97,8 @@ def flash( # noqa: C901
9697 headers : dict [str , str ] | None = None ,
9798 bearer_token : str | None = None ,
9899 retries : int = 3 ,
100+ method : str = "fls" ,
101+ fls_version : str = "" ,
99102 ):
100103 if bearer_token :
101104 bearer_token = self ._validate_bearer_token (bearer_token )
@@ -169,7 +172,7 @@ def flash( # noqa: C901
169172 self ._perform_flash_operation (
170173 partition , path , image_url , should_download_to_httpd ,
171174 storage_thread , error_queue , cacert_file , insecure_tls ,
172- headers , bearer_token
175+ headers , bearer_token , method , fls_version
173176 )
174177 self .logger .info (f"Flash operation succeeded on attempt { attempt + 1 } " )
175178 break
@@ -287,6 +290,8 @@ def _perform_flash_operation(
287290 insecure_tls : bool ,
288291 headers : dict [str , str ] | None ,
289292 bearer_token : str | None ,
293+ method : str ,
294+ fls_version : str ,
290295 ):
291296 """Perform the actual flash operation with console setup.
292297
@@ -314,8 +319,15 @@ def _perform_flash_operation(
314319 console .expect (manifest .spec .login .prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
315320
316321 # make sure that the device is connected to the network and has an IP address
317- console .sendline ("udhcpc" )
318- console .expect (manifest .spec .login .prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
322+ try :
323+ console .sendline ("udhcpc" )
324+ console .expect (manifest .spec .login .prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
325+ except pexpect .TIMEOUT as e :
326+ self .logger .error (f"Timeout waiting for udhcpc to complete: { e } " )
327+ raise FlashRetryableError ("Timeout waiting for udhcpc to complete" ) from e
328+ except Exception as e :
329+ self .logger .error (f"Error running udhcpc: { e } " )
330+ raise FlashRetryableError (f"Error running udhcpc: { e } " ) from e
319331
320332 stored_cacert = None
321333 if should_download_to_httpd :
@@ -325,17 +337,32 @@ def _perform_flash_operation(
325337
326338 header_args = self ._prepare_headers (headers , bearer_token )
327339
328- # Perform the actual flash operation
329- self ._flash_with_progress (
330- console ,
331- manifest ,
332- path ,
333- image_url ,
334- target_device ,
335- insecure_tls ,
336- stored_cacert ,
337- header_args ,
338- )
340+
341+ if method == "fls" :
342+ self ._flash_with_fls (
343+ console ,
344+ manifest ,
345+ path ,
346+ image_url ,
347+ target_device ,
348+ insecure_tls ,
349+ stored_cacert ,
350+ header_args ,
351+ fls_version ,
352+ )
353+ elif method == "shell" :
354+ self ._flash_with_progress (
355+ console ,
356+ manifest ,
357+ path ,
358+ image_url ,
359+ target_device ,
360+ insecure_tls ,
361+ stored_cacert ,
362+ header_args ,
363+ )
364+ else :
365+ raise ArgumentError (f"Invalid method: { method } " )
339366
340367 console .sendline ("reboot" )
341368 time .sleep (2 )
@@ -382,7 +409,7 @@ def _setup_flasher_ssl(self, console, manifest, cacert_file: str | None) -> str
382409
383410 return None
384411
385- def _curl_tls_args (self , insecure_tls : bool , stored_cacert : str | None ) -> str :
412+ def _cmdline_tls_args (self , insecure_tls : bool , stored_cacert : str | None ) -> str :
386413 """Generate TLS arguments for curl command.
387414
388415 Args:
@@ -418,6 +445,109 @@ def _sq(s: str) -> str:
418445
419446 return " " .join (parts )
420447
448+ def _flash_with_fls (
449+ self ,
450+ console ,
451+ manifest ,
452+ path ,
453+ image_url ,
454+ target_path ,
455+ insecure_tls ,
456+ stored_cacert ,
457+ header_args : str ,
458+ fls_version : str ,
459+ ):
460+ """Flash image to target device with progress monitoring.
461+
462+ Args:
463+ console: Console object for device interaction
464+ manifest: Flasher manifest containing target definitions
465+ path: Path to the source image
466+ image_url: URL to download the image from
467+ target_path: Target device path to flash to
468+ insecure_tls: Whether to use insecure TLS
469+ stored_cacert: Path to the stored CA certificate in the DUT flasher
470+ header_args: Header arguments for curl command
471+ fls_version: Version of FLS to use
472+ """
473+
474+ # Calculate decompress and tls arguments for curl
475+ prompt = manifest .spec .login .prompt
476+ tls_args = self ._cmdline_tls_args (insecure_tls , stored_cacert )
477+
478+ if fls_version != "" :
479+ self .logger .info (f"Downloading FLS version { fls_version } from GitHub releases" )
480+ # Download fls binary to the target device (until it is available on the target device)
481+ fls_url = (
482+ f"https://github.com/jumpstarter-dev/fls/releases/download/{ fls_version } /"
483+ f"fls-aarch64-linux"
484+ )
485+ console .sendline (f"curl -L { fls_url } -o /sbin/fls" )
486+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
487+ console .sendline ("echo $?" )
488+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
489+
490+ exit_code = int (console .before .decode (errors = "ignore" ).strip ().splitlines ()[- 1 ])
491+
492+ if exit_code != 0 :
493+ raise FlashRetryableError (f"Failed to download FLS from { fls_url } , exit code: { exit_code } " )
494+ console .sendline ("chmod +x /sbin/fls" )
495+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
496+
497+ # Flash the image
498+ flash_cmd = f'fls from-url -i 1.0 -n { tls_args } { header_args } --o-direct "{ image_url } " { target_path } '
499+ console .sendline (flash_cmd )
500+
501+ # Start monitoring the flash operation
502+ self ._monitor_fls_progress (console , prompt )
503+
504+ self .logger .info ("Flushing buffers" )
505+ console .sendline ("sync" )
506+ console .expect (prompt , timeout = EXPECT_TIMEOUT_SYNC )
507+
508+ def _monitor_fls_progress (self , console , prompt ):
509+ """Monitor FLS flash progress by printing console output as it arrives."""
510+ last_printed_length = 0
511+ while True :
512+ try :
513+ # Try to expect the prompt with a short timeout to read output incrementally
514+ console .expect ([prompt , pexpect .TIMEOUT ], timeout = 1 )
515+
516+ # Get the output that was read - this contains all output since last match
517+ # We need to track what we've already printed to avoid duplicates
518+ current_output = console .before .decode (errors = "ignore" )
519+
520+ # Only process new output that we haven't seen before
521+ if len (current_output ) > last_printed_length :
522+ new_output = current_output [last_printed_length :]
523+ if new_output :
524+ print (new_output , end = '' , flush = True )
525+ last_printed_length = len (current_output )
526+
527+ # Check if we matched the prompt (index 0 means prompt matched)
528+ if console .match_index == 0 :
529+ # Prompt was matched, flash operation is complete
530+ break
531+ # If match_index is 1, it means TIMEOUT was matched, so we continue the loop
532+
533+ if 'panicked at' in current_output :
534+ raise FlashRetryableError (f"FLS panicked: { current_output } " )
535+
536+ except pexpect .EOF as err :
537+ # End of file - connection closed
538+ self .logger .error ("Console connection closed unexpectedly" )
539+ raise FlashRetryableError ("Console connection closed during flash operation" ) from err
540+ except Exception as err :
541+ self .logger .error (f"Error monitoring FLS progress: { err } " )
542+ raise FlashRetryableError (f"Error monitoring FLS progress: { err } " ) from err
543+
544+ # check the fls exit code
545+ console .sendline ("echo $?" )
546+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
547+ exit_code = int (console .before .decode (errors = "ignore" ).strip ().splitlines ()[- 1 ])
548+ if exit_code != 0 :
549+ raise FlashRetryableError (f"FLS flash operation failed, exit code: { exit_code } " )
550+
421551 def _flash_with_progress (
422552 self ,
423553 console ,
@@ -444,7 +574,7 @@ def _flash_with_progress(
444574 # Calculate decompress and tls arguments for curl
445575 prompt = manifest .spec .login .prompt
446576 decompress_cmd = _get_decompression_command (path )
447- tls_args = self ._curl_tls_args (insecure_tls , stored_cacert )
577+ tls_args = self ._cmdline_tls_args (insecure_tls , stored_cacert )
448578
449579 # Check if the image URL is accessible using curl and the TLS arguments
450580 self ._check_url_access (console , prompt , image_url , tls_args , header_args )
@@ -1016,6 +1146,18 @@ def base():
10161146 default = 3 ,
10171147 help = "Number of retry attempts for flash operation (default: 3)" ,
10181148 )
1149+ @click .option (
1150+ "--method" ,
1151+ type = click .Choice (["fls" , "shell" ]),
1152+ default = "fls" ,
1153+ help = "Method to use for flash operation (default: fls)" ,
1154+ )
1155+ @click .option (
1156+ "--fls-version" ,
1157+ type = str ,
1158+ default = "0.1.5" , # TODO(majopela): set default to "" once fls is included in our images
1159+ help = "Download an specific fls version from the github releases" ,
1160+ )
10191161 @debug_console_option
10201162 def flash (
10211163 file ,
@@ -1030,6 +1172,8 @@ def flash(
10301172 header ,
10311173 bearer ,
10321174 retries ,
1175+ method ,
1176+ fls_version ,
10331177 ):
10341178 """Flash image to DUT from file"""
10351179 if os_image_checksum_file and os .path .exists (os_image_checksum_file ):
@@ -1051,6 +1195,8 @@ def flash(
10511195 headers = headers ,
10521196 bearer_token = bearer ,
10531197 retries = retries ,
1198+ method = method ,
1199+ fls_version = fls_version ,
10541200 )
10551201
10561202 @base .command ()
0 commit comments