Skip to content

Commit 1bc077b

Browse files
committed
flashers: chunk long credentials over serial
serial access cannot handle very long strings we need to pass, for example, jwt token from openshift. instead chunk into smaller pieces and write incrementally also remove the cmdline --oci-username and --oci-password, and leave only env vars Signed-off-by: Benny Zlotnik <bzlotnik@redhat.com> Assisted-by: claude-opus-4.6
1 parent e17f6ba commit 1bc077b

3 files changed

Lines changed: 146 additions & 47 deletions

File tree

python/packages/jumpstarter-driver-flashers/README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,6 @@ Options:
150150
--insecure-tls Skip TLS certificate verification
151151
--header TEXT Custom HTTP header in 'Key: Value' format
152152
--bearer TEXT Bearer token for HTTP authentication
153-
--oci-username TEXT OCI registry username (or OCI_USERNAME environment variable)
154-
--oci-password TEXT OCI registry password (or OCI_PASSWORD environment variable)
155153
--retries INTEGER Number of retry attempts for flash operation
156154
(default: 3)
157155
--method [fls|shell] Method to use for flash operation (default:

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

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import hashlib
23
import json
34
import os
@@ -129,14 +130,13 @@ def flash( # noqa: C901
129130
oci_username: str | None = None,
130131
oci_password: str | None = None,
131132
):
133+
"""Flash image to DUT"""
132134
if bearer_token:
133135
bearer_token = self._validate_bearer_token(bearer_token)
134136

135137
if headers:
136138
headers = self._validate_header_dict(headers)
137-
oci_username, oci_password = self._validate_oci_credentials(oci_username, oci_password)
138-
139-
"""Flash image to DUT"""
139+
oci_username, oci_password = self._resolve_oci_credentials(path, oci_username, oci_password)
140140
should_download_to_httpd = True
141141
image_url = ""
142142
original_http_url = None
@@ -653,7 +653,7 @@ def _flash_with_fls(
653653

654654
# Flash the image
655655
creds_file = None
656-
with self._redaction_scope([oci_username, oci_password]):
656+
with self._redaction_scope([oci_password]):
657657
if str(path).startswith("oci://") and oci_username:
658658
creds_file = self._setup_fls_oci_credential_file(console, prompt, oci_username, oci_password or "")
659659

@@ -664,12 +664,8 @@ def _flash_with_fls(
664664
)
665665
console.sendline(flash_cmd)
666666

667-
try:
668-
# Start monitoring the flash operation
669-
self._monitor_fls_progress(console, prompt)
670-
finally:
671-
if creds_file:
672-
self._cleanup_fls_oci_credential_file(console, prompt, creds_file)
667+
# Start monitoring the flash operation
668+
self._monitor_fls_progress(console, prompt)
673669

674670
self.logger.info("Flushing buffers")
675671
console.sendline("sync")
@@ -1304,12 +1300,24 @@ def _validate_oci_credentials(
13041300

13051301
if bool(username) != bool(password):
13061302
raise click.ClickException(
1307-
"OCI authentication requires both --oci-username and --oci-password "
1308-
"(or OCI_USERNAME and OCI_PASSWORD environment variables)"
1303+
"OCI authentication requires both OCI_USERNAME and OCI_PASSWORD "
1304+
"environment variables (or both oci_username and oci_password arguments)"
13091305
)
13101306

13111307
return username, password
13121308

1309+
def _resolve_oci_credentials(
1310+
self, path: PathBuf, username: str | None, password: str | None
1311+
) -> tuple[str | None, str | None]:
1312+
if username is None and password is None and path.startswith("oci://"):
1313+
username = os.environ.get("OCI_USERNAME")
1314+
password = os.environ.get("OCI_PASSWORD")
1315+
1316+
if username or password:
1317+
self.logger.info("Using OCI registry credentials from environment variables")
1318+
1319+
return self._validate_oci_credentials(username, password)
1320+
13131321
def _fls_oci_auth_env(self, path: PathBuf, creds_file: str | None) -> str:
13141322
if not str(path).startswith("oci://") or not creds_file:
13151323
return ""
@@ -1345,20 +1353,35 @@ def _temporarily_disable_console_debug_stream(self, console):
13451353
def _setup_fls_oci_credential_file(
13461354
self, console, prompt: str, oci_username: str, oci_password: str, creds_file: str = "/tmp/fls_creds"
13471355
) -> str:
1356+
# Write credential file using base64-encoded chunks to avoid serial
1357+
# console line buffer overflow with long tokens (e.g. 1400+ char JWTs).
1358+
creds_content = (
1359+
f"FLS_REGISTRY_USERNAME={shlex.quote(oci_username)}\n"
1360+
f"FLS_REGISTRY_PASSWORD={shlex.quote(oci_password)}\n"
1361+
)
1362+
encoded = base64.b64encode(creds_content.encode()).decode()
1363+
1364+
chunk_size = 512
13481365
with self._temporarily_disable_console_debug_stream(console):
1349-
console.sendline(f"umask 077 && cat > {shlex.quote(creds_file)} <<'EOF_FLS_CREDS'")
1350-
console.sendline(f"FLS_REGISTRY_USERNAME={shlex.quote(oci_username)}")
1351-
console.sendline(f"FLS_REGISTRY_PASSWORD={shlex.quote(oci_password)}")
1352-
console.sendline("EOF_FLS_CREDS")
1366+
console.sendline(f"true > {shlex.quote(creds_file)}")
13531367
console.expect(prompt, timeout=EXPECT_TIMEOUT_DEFAULT)
1354-
console.sendline(f"chmod 600 {shlex.quote(creds_file)}")
1368+
1369+
# Write base64 data in chunks to a temp file
1370+
b64_file = f"{creds_file}.b64"
1371+
console.sendline(f"true > {shlex.quote(b64_file)}")
13551372
console.expect(prompt, timeout=EXPECT_TIMEOUT_DEFAULT)
1356-
return creds_file
13571373

1358-
def _cleanup_fls_oci_credential_file(self, console, prompt: str, creds_file: str):
1359-
with self._temporarily_disable_console_debug_stream(console):
1360-
console.sendline(f"rm -f {shlex.quote(creds_file)}")
1374+
for i in range(0, len(encoded), chunk_size):
1375+
chunk = encoded[i : i + chunk_size]
1376+
console.sendline(f"printf '%s' {shlex.quote(chunk)} >> {shlex.quote(b64_file)}")
1377+
console.expect(prompt, timeout=EXPECT_TIMEOUT_DEFAULT)
1378+
1379+
# Decode into the actual creds file
1380+
console.sendline(f"base64 -d {shlex.quote(b64_file)} > {shlex.quote(creds_file)}")
13611381
console.expect(prompt, timeout=EXPECT_TIMEOUT_DEFAULT)
1382+
console.sendline(f"chmod 600 {shlex.quote(creds_file)}")
1383+
console.expect(prompt, timeout=EXPECT_TIMEOUT_DEFAULT)
1384+
return creds_file
13621385

13631386
def _resolve_flash_parameters(
13641387
self, file: str | None, partitions: tuple[str, ...] | None, block_device: str | None
@@ -1425,6 +1448,7 @@ def _resolve_flash_parameters(
14251448

14261449
return flash_ops
14271450

1451+
14281452
def cli(self):
14291453
@driver_click_group(self)
14301454
def base():
@@ -1465,18 +1489,6 @@ def base():
14651489
type=str,
14661490
help="Bearer token for HTTP authentication",
14671491
)
1468-
@click.option(
1469-
"--oci-username",
1470-
type=str,
1471-
envvar="OCI_USERNAME",
1472-
help="OCI registry username (or OCI_USERNAME environment variable)",
1473-
)
1474-
@click.option(
1475-
"--oci-password",
1476-
type=str,
1477-
envvar="OCI_PASSWORD",
1478-
help="OCI registry password (or OCI_PASSWORD environment variable)",
1479-
)
14801492
@click.option(
14811493
"--retries",
14821494
type=int,
@@ -1514,8 +1526,6 @@ def flash(
15141526
insecure_tls,
15151527
header,
15161528
bearer,
1517-
oci_username,
1518-
oci_password,
15191529
retries,
15201530
method,
15211531
fls_version,
@@ -1576,8 +1586,6 @@ def flash(
15761586
insecure_tls=insecure_tls,
15771587
headers=headers_dict,
15781588
bearer_token=bearer,
1579-
oci_username=oci_username,
1580-
oci_password=oci_password,
15811589
retries=retries,
15821590
method=method,
15831591
fls_version=fls_version,

python/packages/jumpstarter-driver-flashers/jumpstarter_driver_flashers/client_test.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ def test_validate_oci_credentials_accepts_pair_and_strips_whitespace():
6565
assert password == "mypassword"
6666

6767

68+
def test_resolve_oci_credentials_reads_env_for_oci_path(monkeypatch):
69+
"""Test OCI credentials are read from environment for OCI paths."""
70+
client = MockFlasherClient()
71+
monkeypatch.setenv("OCI_USERNAME", "env-user")
72+
monkeypatch.setenv("OCI_PASSWORD", "env-pass")
73+
74+
username, password = client._resolve_oci_credentials("oci://quay.io/org/image:tag", None, None)
75+
assert username == "env-user"
76+
assert password == "env-pass"
77+
78+
79+
def test_resolve_oci_credentials_ignores_env_for_non_oci_path(monkeypatch):
80+
"""Test OCI credential env vars are ignored for non-OCI image paths."""
81+
client = MockFlasherClient()
82+
monkeypatch.setenv("OCI_USERNAME", "env-user")
83+
monkeypatch.setenv("OCI_PASSWORD", "env-pass")
84+
85+
username, password = client._resolve_oci_credentials("https://example.com/image.raw.xz", None, None)
86+
assert username is None
87+
assert password is None
88+
89+
90+
def test_resolve_oci_credentials_rejects_partial_env_for_oci_path(monkeypatch):
91+
"""Test partial OCI env credentials are rejected for OCI paths."""
92+
client = MockFlasherClient()
93+
monkeypatch.setenv("OCI_USERNAME", "env-user")
94+
monkeypatch.delenv("OCI_PASSWORD", raising=False)
95+
96+
with pytest.raises(click.ClickException, match="OCI authentication requires both"):
97+
client._resolve_oci_credentials("oci://quay.io/org/image:tag", None, None)
98+
99+
68100
def test_fls_oci_auth_env_sources_credentials_file():
69101
"""Test OCI auth shell snippet sources the on-target credentials file"""
70102
client = MockFlasherClient()
@@ -101,8 +133,8 @@ def test_redact_sensitive_values_masks_username_and_password():
101133
assert result == "user=*** pass=***"
102134

103135

104-
def test_setup_and_cleanup_fls_oci_credential_file():
105-
"""Test secure credentials file setup and cleanup commands."""
136+
def test_setup_fls_oci_credential_file():
137+
"""Test secure credentials file setup commands."""
106138
client = MockFlasherClient()
107139

108140
class MockConsole:
@@ -120,14 +152,75 @@ def expect(self, prompt, timeout=None):
120152
console = MockConsole()
121153
creds_path = client._setup_fls_oci_credential_file(console, "#", "myuser", "my'password")
122154
assert creds_path == "/tmp/fls_creds"
123-
assert "cat > /tmp/fls_creds <<'EOF_FLS_CREDS'" in console.sent_lines[0]
124-
assert "FLS_REGISTRY_USERNAME=myuser" in console.sent_lines[1]
125-
assert "FLS_REGISTRY_PASSWORD='my'\"'\"'password'" in console.sent_lines[2]
126-
assert "chmod 600 /tmp/fls_creds" in console.sent_lines[4]
155+
156+
# Verify chunked base64 approach: creates file, writes b64 chunks, decodes, cleans up
157+
assert "true > /tmp/fls_creds" in console.sent_lines[0]
158+
assert "true > /tmp/fls_creds.b64" in console.sent_lines[1]
159+
160+
# Find the base64 chunk lines (printf commands)
161+
b64_lines = [line for line in console.sent_lines if "printf" in line and ".b64" in line]
162+
assert len(b64_lines) >= 1
163+
164+
# Verify decode step
165+
assert any("base64 -d" in line for line in console.sent_lines)
166+
assert any("chmod 600 /tmp/fls_creds" in line for line in console.sent_lines)
167+
168+
# Verify the decoded content is correct
169+
import base64
170+
171+
b64_data = ""
172+
for line in b64_lines:
173+
# Extract the base64 chunk from: printf '%s' <chunk> >> /tmp/fls_creds.b64
174+
parts = shlex.split(line)
175+
b64_data += parts[2] # the chunk argument
176+
decoded = base64.b64decode(b64_data).decode()
177+
assert "FLS_REGISTRY_USERNAME=myuser" in decoded
178+
assert "FLS_REGISTRY_PASSWORD='my'\"'\"'password'" in decoded
179+
127180
assert console.logfile_read is not None
128181

129-
client._cleanup_fls_oci_credential_file(console, "#", "/tmp/fls_creds")
130-
assert "rm -f /tmp/fls_creds" in console.sent_lines[-1]
182+
183+
def test_setup_fls_oci_credential_file_chunks_long_tokens():
184+
"""Test that long JWT tokens are split into multiple base64 chunks."""
185+
client = MockFlasherClient()
186+
187+
class MockConsole:
188+
def __init__(self):
189+
self.logfile_read = object()
190+
self.sent_lines = []
191+
self.expect_calls = []
192+
193+
def sendline(self, line):
194+
self.sent_lines.append(line)
195+
196+
def expect(self, prompt, timeout=None):
197+
self.expect_calls.append((prompt, timeout))
198+
199+
console = MockConsole()
200+
# Simulate a 1400-char JWT token (similar to real Kubernetes service account tokens)
201+
long_token = "eyJ" + "a" * 1397
202+
203+
creds_path = client._setup_fls_oci_credential_file(console, "#", "serviceaccount", long_token)
204+
assert creds_path == "/tmp/fls_creds"
205+
206+
# With a 1400+ char token, the base64 encoding should produce multiple chunks
207+
b64_lines = [line for line in console.sent_lines if "printf" in line and ".b64" in line]
208+
assert len(b64_lines) > 1, f"Expected multiple chunks for long token, got {len(b64_lines)}"
209+
210+
# Each printf line should be well under serial buffer limits
211+
for line in b64_lines:
212+
assert len(line) < 600, f"Chunk line too long ({len(line)} chars): {line[:80]}..."
213+
214+
# Verify roundtrip: reassemble and decode
215+
import base64
216+
217+
b64_data = ""
218+
for line in b64_lines:
219+
parts = shlex.split(line)
220+
b64_data += parts[2]
221+
decoded = base64.b64decode(b64_data).decode()
222+
assert f"FLS_REGISTRY_PASSWORD={long_token}" in decoded
223+
assert "FLS_REGISTRY_USERNAME=serviceaccount" in decoded
131224

132225

133226
def test_flash_http_url_with_oci_credentials_still_uses_direct_http_path():

0 commit comments

Comments
 (0)