Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit cea4061

Browse files
authored
Merge pull request #795 from bennyz/fls-custom
flashers: support flashing from OCI registries
2 parents bf08fd9 + ecf0ad9 commit cea4061

1 file changed

Lines changed: 88 additions & 37 deletions

File tree

  • packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers

packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client.py

Lines changed: 88 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class FlashRetryableError(FlashError):
3838
class FlashNonRetryableError(FlashError):
3939
"""Exception for non-retryable flash errors (configuration, file system, etc.)."""
4040

41+
4142
debug_console_option = click.option("--console-debug", is_flag=True, help="Enable console debug mode")
4243

4344
EXPECT_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

Comments
 (0)