@@ -38,6 +38,7 @@ class FlashRetryableError(FlashError):
3838class FlashNonRetryableError (FlashError ):
3939 """Exception for non-retryable flash errors (configuration, file system, etc.)."""
4040
41+
4142debug_console_option = click .option ("--console-debug" , is_flag = True , help = "Enable console debug mode" )
4243
4344EXPECT_TIMEOUT_DEFAULT = 60
@@ -100,6 +101,7 @@ def flash( # noqa: C901
100101 retries : int = 3 ,
101102 method : str = "fls" ,
102103 fls_version : str = "" ,
104+ fls_binary_url : str | None = None ,
103105 ):
104106 if bearer_token :
105107 bearer_token = self ._validate_bearer_token (bearer_token )
@@ -112,8 +114,11 @@ def flash( # noqa: C901
112114 image_url = ""
113115 original_http_url = None
114116 operator_scheme = None
115- # initrmafs cannot handle https yet, fallback to using the exporter's http server
116- if path .startswith (("http://" , "https://" )) and not force_exporter_http :
117+ if path .startswith ("oci://" ):
118+ # OCI URLs are always passed directly to fls
119+ image_url = path
120+ should_download_to_httpd = False
121+ elif path .startswith (("http://" , "https://" )) and not force_exporter_http :
117122 # the flasher image can handle the http(s) from a remote directly, unless target is isolated
118123 image_url = path
119124 should_download_to_httpd = False
@@ -171,9 +176,19 @@ def flash( # noqa: C901
171176 for attempt in range (retries + 1 ): # +1 for initial attempt
172177 try :
173178 self ._perform_flash_operation (
174- partition , path , image_url , should_download_to_httpd ,
175- storage_thread , error_queue , cacert_file , insecure_tls ,
176- headers , bearer_token , method , fls_version
179+ partition ,
180+ path ,
181+ image_url ,
182+ should_download_to_httpd ,
183+ storage_thread ,
184+ error_queue ,
185+ cacert_file ,
186+ insecure_tls ,
187+ headers ,
188+ bearer_token ,
189+ method ,
190+ fls_version ,
191+ fls_binary_url ,
177192 )
178193 self .logger .info (f"Flash operation succeeded on attempt { attempt + 1 } " )
179194 break
@@ -193,15 +208,14 @@ def flash( # noqa: C901
193208 )
194209 self .logger .info (f"Retrying flash operation (attempt { attempt + 2 } /{ retries + 1 } )" )
195210 # Wait a bit before retrying
196- time .sleep (2 ** attempt ) # Exponential backoff
211+ time .sleep (2 ** attempt ) # Exponential backoff
197212 continue
198213 else :
199214 self .logger .error (f"Flash operation failed after { retries + 1 } attempts" )
200215 raise FlashError (
201216 f"Flash operation failed after { retries + 1 } attempts. Last error: { categorized_error } "
202217 ) from e
203218
204-
205219 total_time = time .time () - start_time
206220 # total time in minutes:seconds
207221 minutes , seconds = divmod (total_time , 60 )
@@ -261,7 +275,7 @@ def _find_exception_in_chain(self, exception: Exception, target_type: type) -> E
261275 The found exception instance if found, None otherwise
262276 """
263277 # Check if this is an ExceptionGroup and look through its exceptions
264- if hasattr (exception , ' exceptions' ):
278+ if hasattr (exception , " exceptions" ):
265279 for sub_exc in exception .exceptions :
266280 result = self ._find_exception_in_chain (sub_exc , target_type )
267281 if result is not None :
@@ -272,17 +286,17 @@ def _find_exception_in_chain(self, exception: Exception, target_type: type) -> E
272286 return exception
273287
274288 # Check the cause chain
275- current = getattr (exception , ' __cause__' , None )
289+ current = getattr (exception , " __cause__" , None )
276290 while current is not None :
277291 if isinstance (current , target_type ):
278292 return current
279293 # Also check if the cause is an ExceptionGroup
280- if hasattr (current , ' exceptions' ):
294+ if hasattr (current , " exceptions" ):
281295 for sub_exc in current .exceptions :
282296 result = self ._find_exception_in_chain (sub_exc , target_type )
283297 if result is not None :
284298 return result
285- current = getattr (current , ' __cause__' , None )
299+ current = getattr (current , " __cause__" , None )
286300 return None
287301
288302 def _perform_flash_operation (
@@ -299,6 +313,7 @@ def _perform_flash_operation(
299313 bearer_token : str | None ,
300314 method : str ,
301315 fls_version : str ,
316+ fls_binary_url : str | None ,
302317 ):
303318 """Perform the actual flash operation with console setup.
304319
@@ -351,7 +366,6 @@ def _perform_flash_operation(
351366
352367 header_args = self ._prepare_headers (headers , bearer_token )
353368
354-
355369 if method == "fls" :
356370 self ._flash_with_fls (
357371 console ,
@@ -363,6 +377,7 @@ def _perform_flash_operation(
363377 stored_cacert ,
364378 header_args ,
365379 fls_version ,
380+ fls_binary_url ,
366381 )
367382 elif method == "shell" :
368383 self ._flash_with_progress (
@@ -453,6 +468,34 @@ def _sq(s: str) -> str:
453468
454469 return " " .join (parts )
455470
471+ def _download_fls_binary (self , console , prompt : str , download_url : str , error_message_prefix : str ):
472+ """Download FLS binary to the target device.
473+
474+ Args:
475+ console: Console object for device interaction
476+ prompt: Login prompt for console interaction
477+ download_url: URL to download the FLS binary from
478+ error_message_prefix: Prefix for error message if download fails
479+
480+ Raises:
481+ FlashRetryableError: If download fails or binary cannot be made executable
482+ """
483+ console .sendline (f"curl -L { download_url } -o /sbin/fls" )
484+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
485+ console .sendline ("echo $?" )
486+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
487+
488+ try :
489+ lines = console .before .decode (errors = "ignore" ).strip ().splitlines ()
490+ exit_code = int (lines [- 1 ]) if lines else - 1
491+ except (IndexError , ValueError ) as e :
492+ raise FlashRetryableError (f"{ error_message_prefix } , failed to parse exit code" ) from e
493+
494+ if exit_code != 0 :
495+ raise FlashRetryableError (f"{ error_message_prefix } , exit code: { exit_code } " )
496+ console .sendline ("chmod +x /sbin/fls" )
497+ console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
498+
456499 def _flash_with_fls (
457500 self ,
458501 console ,
@@ -464,6 +507,7 @@ def _flash_with_fls(
464507 stored_cacert ,
465508 header_args : str ,
466509 fls_version : str ,
510+ fls_binary_url : str | None ,
467511 ):
468512 """Flash image to target device with progress monitoring.
469513
@@ -477,30 +521,21 @@ def _flash_with_fls(
477521 stored_cacert: Path to the stored CA certificate in the DUT flasher
478522 header_args: Header arguments for curl command
479523 fls_version: Version of FLS to use
524+ fls_binary_url: Custom URL to download FLS binary from (overrides fls_version)
480525 """
481526
482527 # Calculate decompress and tls arguments for curl
483528 prompt = manifest .spec .login .prompt
484529 tls_args = self ._cmdline_tls_args (insecure_tls , stored_cacert )
485530
486- if fls_version != "" :
531+ if fls_binary_url :
532+ self .logger .info (f"Downloading FLS binary from custom URL: { fls_binary_url } " )
533+ self ._download_fls_binary (console , prompt , fls_binary_url , f"Failed to download FLS from { fls_binary_url } " )
534+ elif fls_version != "" :
487535 self .logger .info (f"Downloading FLS version { fls_version } from GitHub releases" )
488536 # Download fls binary to the target device (until it is available on the target device)
489- fls_url = (
490- f"https://github.com/jumpstarter-dev/fls/releases/download/{ fls_version } /"
491- f"fls-aarch64-linux"
492- )
493- console .sendline (f"curl -L { fls_url } -o /sbin/fls" )
494- console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
495- console .sendline ("echo $?" )
496- console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
497-
498- exit_code = int (console .before .decode (errors = "ignore" ).strip ().splitlines ()[- 1 ])
499-
500- if exit_code != 0 :
501- raise FlashRetryableError (f"Failed to download FLS from { fls_url } , exit code: { exit_code } " )
502- console .sendline ("chmod +x /sbin/fls" )
503- console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
537+ fls_url = f"https://github.com/jumpstarter-dev/fls/releases/download/{ fls_version } /fls-aarch64-linux"
538+ self ._download_fls_binary (console , prompt , fls_url , f"Failed to download FLS from { fls_url } " )
504539
505540 # Flash the image
506541 flash_cmd = f'fls from-url -i 1.0 -n { tls_args } { header_args } --o-direct "{ image_url } " { target_path } '
@@ -529,7 +564,7 @@ def _monitor_fls_progress(self, console, prompt):
529564 if len (current_output ) > last_printed_length :
530565 new_output = current_output [last_printed_length :]
531566 if new_output :
532- print (new_output , end = '' , flush = True )
567+ print (new_output , end = "" , flush = True )
533568 last_printed_length = len (current_output )
534569
535570 # Check if we matched the prompt (index 0 means prompt matched)
@@ -538,7 +573,7 @@ def _monitor_fls_progress(self, console, prompt):
538573 break
539574 # If match_index is 1, it means TIMEOUT was matched, so we continue the loop
540575
541- if ' panicked at' in current_output :
576+ if " panicked at" in current_output :
542577 raise FlashRetryableError (f"FLS panicked: { current_output } " )
543578
544579 except pexpect .EOF as err :
@@ -592,8 +627,8 @@ def _flash_with_progress(
592627 flash_cmd = (
593628 f'( set -o pipefail; curl -fsSL { tls_args } { header_args } "{ image_url } " | '
594629 f"{ decompress_cmd } "
595- f' dd of={ target_path } bs=64k iflag=fullblock oflag=direct ' +
596- '&& echo "F""LASH_COMPLETE" || echo "F""LASH_FAILED" ) &'
630+ f" dd of={ target_path } bs=64k iflag=fullblock oflag=direct "
631+ + '&& echo "F""LASH_COMPLETE" || echo "F""LASH_FAILED" ) &'
597632 )
598633 console .sendline (flash_cmd )
599634 console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT * 2 )
@@ -611,7 +646,7 @@ def _monitor_flash_progress(self, console, prompt):
611646 console .sendline ("pidof dd" )
612647 console .expect (prompt , timeout = EXPECT_TIMEOUT_DEFAULT )
613648 pidof_output = console .before .decode (errors = "ignore" )
614- accumulated_output = pidof_output # just in case we get the FLASH_COMPLETE or FLASH_FAILED markers soon
649+ accumulated_output = pidof_output # just in case we get the FLASH_COMPLETE or FLASH_FAILED markers soon
615650
616651 # Extract the actual process ID from the output, handling potential error messages
617652 lines = pidof_output .splitlines ()
@@ -691,8 +726,8 @@ def _update_accumulated_output(self, accumulated_output, data):
691726 """Update accumulated output with new data, keeping only last 64KB."""
692727 accumulated_output += data
693728 # Keep only the last 64KB to prevent memory growth
694- if len (accumulated_output ) > 64 * 1024 :
695- accumulated_output = accumulated_output [- 64 * 1024 :]
729+ if len (accumulated_output ) > 64 * 1024 :
730+ accumulated_output = accumulated_output [- 64 * 1024 :]
696731 return accumulated_output
697732
698733 def _update_progress_stats (self , data , last_pos , last_time ):
@@ -925,7 +960,16 @@ def dump(
925960
926961 def _filename (self , path : PathBuf ) -> str :
927962 """Extract filename from url or path"""
928- if path .startswith (("http://" , "https://" )):
963+ if path .startswith ("oci://" ):
964+ oci_path = path [6 :] # Remove "oci://" prefix
965+ if ":" in oci_path :
966+ repository , tag = oci_path .rsplit (":" , 1 )
967+ repo_name = repository .split ("/" )[- 1 ] if "/" in repository else repository
968+ return f"{ repo_name } -{ tag } "
969+ else :
970+ repo_name = oci_path .split ("/" )[- 1 ] if "/" in oci_path else oci_path
971+ return repo_name
972+ elif path .startswith (("http://" , "https://" )):
929973 return urlparse (path ).path .split ("/" )[- 1 ]
930974 else :
931975 return Path (path ).name
@@ -1163,9 +1207,14 @@ def base():
11631207 @click .option (
11641208 "--fls-version" ,
11651209 type = str ,
1166- default = "0.1.9" , # TODO(majopela): set default to "" once fls is included in our images
1210+ default = "0.1.9" , # TODO(majopela): set default to "" once fls is included in our images
11671211 help = "Download an specific fls version from the github releases" ,
11681212 )
1213+ @click .option (
1214+ "--fls-binary-url" ,
1215+ type = str ,
1216+ help = "Custom URL to download FLS binary from (overrides --fls-version)" ,
1217+ )
11691218 @debug_console_option
11701219 def flash (
11711220 file ,
@@ -1182,6 +1231,7 @@ def flash(
11821231 retries ,
11831232 method ,
11841233 fls_version ,
1234+ fls_binary_url ,
11851235 ):
11861236 """Flash image to DUT from file"""
11871237 if os_image_checksum_file and os .path .exists (os_image_checksum_file ):
@@ -1205,6 +1255,7 @@ def flash(
12051255 retries = retries ,
12061256 method = method ,
12071257 fls_version = fls_version ,
1258+ fls_binary_url = fls_binary_url ,
12081259 )
12091260
12101261 @base .command ()
0 commit comments