Skip to content

Commit 46b0165

Browse files
committed
fix(ssh): bootstrap-aware first-run wait + strict --ssh value classification
First run: the host gave up after a flat 20s while the container was still bootstrapping (config clone, volume chown, provider setup all run before sshd starts in start.sh) — a false 'SSH server not ready' that a retry 'solved'. Now lpb detects first run (no <state>/.initialized, the container's /home/lpb/.pi/.initialized marker), waits up to 300s with progress notes (30s warm), and on exhaustion hints at 'lpb --logs' (sshd starts automatically once the bootstrap completes) or a restart. --ssh value: argparse consumed the next arg as PUBKEY, so 'lpb --dev --ssh /path/to/project' silently used the path as an inline key (no error, no key prompt, project dir lost). Values are now classified: inline key (ssh-*/ecdsa-*/sk-* + base64) | existing .pub file (content validated, ~ expanded) | existing directory (becomes the project dir and the profile-key menu still shows) | anything else is an explicit error. An explicit positional project still wins. Docs (README, doc/lpb-cli.md, usage text) + 6 new ssh tests; 93/93 pass.
1 parent cca9c15 commit 46b0165

4 files changed

Lines changed: 255 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ CI tags images per pipeline: `:0.0.x-lpb[-dev]-cli/web` (versioned),
126126
| `lpb /path` | Pi CLI session (foreground); no path → last project or `~` |
127127
| `lpb --web /path` | VSCodium (background); `--port 8080` to change the port |
128128
| `lpb --shell /path` | Interactive bash inside the container |
129-
| `lpb --ssh [pubkey\|path] /path` | sshd server in the container for remote login (key auto-detected from `~/.ssh` when omitted) |
129+
| `lpb --ssh [pubkey\|path] [project]` | sshd server in the container for remote login (key auto-detected from `~/.ssh` when omitted) |
130130
| `lpb --ssh --ssh-password [pw]` | SSH password login (random if omitted, shown once; can combine with a key) |
131131
| `lpb --stop` / `--remove` / `--logs` | Stop / stop+remove+state cleanup / stream logs |
132132
| `lpb --update` | Self-update launcher + pull latest image for the selected pipeline |

doc/lpb-cli.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Installs (no sudo required) into `~/.local/bin`:
2828
| `lpb [/path/to/project]` | Start a **Pi CLI session** (foreground). No path → last project, or `~` if none yet |
2929
| `lpb --web [/path]` | Start **VSCodium** (background), prints the connection URL |
3030
| `lpb --shell [/path]` | Interactive bash shell inside the container |
31-
| `lpb --ssh [pubkey\|path] [/path]` | Start an sshd server in the container for remote login (key auto-detected from `~/.ssh` when omitted) |
31+
| `lpb --ssh [pubkey\|path] [project]` | Start an sshd server in the container for remote login (key auto-detected from `~/.ssh` when omitted) |
3232
| `lpb --ssh --ssh-password [pw]` | SSH password login (random if omitted, shown once; can combine with key auth) |
3333
| `lpb --stop` | Stop the container |
3434
| `lpb --remove` | Stop + remove container + state dirs |
@@ -69,7 +69,18 @@ lpb /myproject -- --thinking high # pass any pi flag
6969
profile (`~/.ssh/*.pub`). One key → used (confirmed on a TTY); several →
7070
numbered menu; none → error with a hint. Non-interactive: one key is used
7171
automatically, several → explicit selection required.
72-
- **Explicit key still wins**: `lpb --ssh <pubkey|path>` (literal key or file).
72+
- **Explicit key still wins**: `lpb --ssh <pubkey|path>` — a literal inline
73+
key or a path to a `.pub` file (tilde expanded). A value that is an
74+
existing *directory* is treated as the project dir (the key menu still
75+
appears); anything else is an error — a nonexistent path is never silently
76+
accepted as a key.
77+
- **First-run wait**: on the first boot the container bootstraps (config-repo
78+
clone, volume chown, provider setup) *before* sshd starts, which can take a
79+
few minutes. `lpb` detects first run (no `.initialized` in the state dir),
80+
waits up to 5 minutes with progress notes (30 s on warm starts), and only
81+
then reports a failure — with the last container log lines and a
82+
first-run-aware hint (`lpb --logs` shows the bootstrap finishing; sshd
83+
starts automatically once it completes).
7384
- **Password login**: `lpb --ssh --ssh-password` (random, printed once) or
7485
`lpb --ssh --ssh-password <pw>` (user-chosen). The container user's password
7586
is set at start (`chpasswd`) and `PasswordAuthentication` is enabled in the

scripts/lpb.py

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
lpb [/path/to/project] Start Pi CLI session at project (foreground)
66
lpb /path -- <pi-args...> Pass args through to pi (e.g. -p, --session)
77
lpb --shell [/path/to/project] Start interactive bash shell in container
8-
lpb --ssh [pubkey|path] [/path] Start sshd server (background) for remote login
9-
(key auto-detected from ~/.ssh when omitted)
8+
lpb --ssh [pubkey|path] [project] Start sshd server (background) for remote login
9+
(key auto-detected from ~/.ssh when omitted;
10+
a project dir in place of a key is also accepted)
1011
lpb --ssh --ssh-password [pw] SSH password login (no pw: random, shown once)
1112
lpb --web [/path/to/project] Start VSCodium at project (background)
1213
lpb --stop Stop the container
@@ -410,6 +411,35 @@ def _discover_ssh_pubkeys(ssh_dir: Path | None = None) -> list[Path]:
410411
return sorted(p for p in d.glob("*.pub") if p.is_file())
411412

412413

414+
_SSH_KEY_PREFIXES = ("ssh-rsa", "ssh-dss", "ssh-ed25519", "ecdsa-sha2-",
415+
"sk-ecdsa-", "sk-ssh-")
416+
417+
418+
def _looks_like_pubkey(value: str) -> bool:
419+
"""A public key is '<type> <base64> [comment]'; type starts with a known prefix."""
420+
parts = value.split()
421+
return len(parts) >= 2 and parts[0].startswith(_SSH_KEY_PREFIXES)
422+
423+
424+
def _classify_ssh_key_arg(value: str) -> tuple[str, object]:
425+
"""Classify the optional --ssh value:
426+
427+
("key", str) inline public key
428+
("file", Path) existing file (to read as a public key)
429+
("project", str) existing directory — a project path was passed
430+
("invalid", str) none of the above
431+
"""
432+
raw = value.strip()
433+
p = Path(raw).expanduser()
434+
if p.is_file():
435+
return "file", p
436+
if _looks_like_pubkey(raw):
437+
return "key", raw
438+
if p.is_dir():
439+
return "project", str(p)
440+
return "invalid", raw
441+
442+
413443
# ── Output helpers (stdout) ───────────────────────────────────────────────────
414444

415445
# Engine file path — resolved from __file__, NOT sys.argv[0]: the bash wrapper
@@ -858,7 +888,7 @@ def apply_overrides(project_dir: str | None = None, project_name: str | None = N
858888
" lpb [/path/to/project] Start Pi CLI session at project\n"
859889
" lpb /path -- <pi-args...> Pass flags through to pi (-p, --session, etc.)\n"
860890
" lpb --shell [/path/to/project] Interactive bash shell in container\n"
861-
" lpb --ssh [pubkey|path] Start sshd server in background (key auto-detected from ~/.ssh)\n"
891+
" lpb --ssh [pubkey|path] [project] Start sshd server in background (key auto-detected from ~/.ssh)\n"
862892
" lpb --ssh --ssh-password [pw] SSH password login (no pw: random, shown once)\n"
863893
" lpb --web [/path/to/project] Start VSCodium (background)\n"
864894
" lpb --stop Stop the container\n"
@@ -1030,9 +1060,31 @@ def parse_cli(args: list[str]) -> None:
10301060
if known.ssh is not None or known.ssh_password is not None:
10311061
cfg.ssh_mode = cfg.shell_mode = True
10321062
if known.ssh:
1033-
p = Path(known.ssh)
1034-
cfg.ssh_pubkey = p.read_text(encoding="utf-8").strip() if p.is_file() else known.ssh.strip()
1035-
elif known.ssh_password is None:
1063+
kind, value = _classify_ssh_key_arg(known.ssh)
1064+
if kind == "file":
1065+
key_text = value.read_text(encoding="utf-8").strip()
1066+
if not _looks_like_pubkey(key_text):
1067+
err(f"{value} does not contain a valid public key",
1068+
"Pass a .pub file, an inline key (lpb --ssh <key>), or run 'lpb --ssh' to pick from ~/.ssh")
1069+
raise DevstackError
1070+
cfg.ssh_pubkey = key_text
1071+
elif kind == "key":
1072+
cfg.ssh_pubkey = value
1073+
elif kind == "project":
1074+
# `lpb --ssh /path/to/project`: a directory where a key was
1075+
# expected — use it as the project dir and fall through to
1076+
# the profile-key discovery below (the selection prompt shows).
1077+
# An explicit positional project (parsed later) still wins.
1078+
if not cfg.project_dir:
1079+
cfg.project_dir = value
1080+
info(f"Project directory from --ssh argument: {value}")
1081+
known.ssh = ""
1082+
else:
1083+
err(f"'{known.ssh}' is not a valid pub key, a pub key file, or a project directory",
1084+
"Key: lpb --ssh <pubkey|path-to-pub-file> — or 'lpb --ssh' alone to pick from ~/.ssh\n"
1085+
"Project: pass it as a positional: lpb --dev --ssh <key> <project>")
1086+
raise DevstackError
1087+
if not cfg.ssh_pubkey and known.ssh == "" and known.ssh_password is None:
10361088
# No explicit key — fall back to the user's profile keys.
10371089
keys = _discover_ssh_pubkeys()
10381090
if not keys:
@@ -1942,16 +1994,19 @@ def _port_in_use(port) -> bool:
19421994
s.close()
19431995

19441996

1945-
def _wait_ssh_port(port, timeout: int = 20) -> bool:
1997+
def _wait_ssh_port(port, timeout: int = 30) -> bool:
19461998
"""Wait until the sshd port accepts TCP connections.
19471999
19482000
start.sh launches sshd asynchronously after the container starts, so
19492001
connecting right after 'run' returns is too early. The SSH banner is
19502002
verified — a bare connect cannot tell sshd apart from whatever else
19512003
holds the host port, and claiming 'ready' for a non-sshd holder would
1952-
repeat the silent-failure bug."""
2004+
repeat the silent-failure bug. Prints a progress note every 30s so a
2005+
long first-run wait is not mistaken for a hang."""
19532006
port = int(port)
1954-
deadline = time.time() + timeout
2007+
start = time.time()
2008+
deadline = start + timeout
2009+
next_note = 30
19552010
while time.time() < deadline:
19562011
try:
19572012
s = socket.create_connection(("127.0.0.1", port), timeout=2)
@@ -1967,6 +2022,9 @@ def _wait_ssh_port(port, timeout: int = 20) -> bool:
19672022
s.close()
19682023
if banner.startswith(b"SSH-"):
19692024
return True
2025+
if time.time() - start >= next_note:
2026+
info(f" ... still waiting for sshd ({int(time.time() - start)}s elapsed)")
2027+
next_note += 30
19702028
time.sleep(1)
19712029
return False
19722030

@@ -2008,15 +2066,32 @@ def _run_ssh(c: ContainerClient, project_dir: str, env_vars: list[str],
20082066
port = cfg.ssh_port
20092067
user = "lpb" # container user (uid 1000) is lpb
20102068

2069+
# First-run detection: the host state dir IS the container's /home/lpb/.pi
2070+
# mount, and start.sh creates <state>/.initialized when the bootstrap
2071+
# finishes. On the first boot the bootstrap (config-repo clone, volume
2072+
# chown, provider setup) runs BEFORE sshd starts and can take several
2073+
# minutes — a short wait here produced a false 'SSH server not ready'
2074+
# failure while the container was simply still bootstrapping.
2075+
first_run = not (Path(resolve_path(cfg.state_dir)) / ".initialized").exists()
2076+
if first_run:
2077+
info("First run — container is bootstrapping (config clone, provider setup)...")
2078+
info(" sshd starts automatically after the bootstrap; this can take a few minutes.")
2079+
wait_timeout = 300 if first_run else 30
2080+
20112081
# Wait for sshd to actually listen before claiming success — the SSH
20122082
# banner probe is the only ground truth (the host cannot see start.sh's
20132083
# log in detached mode).
2014-
if not _wait_ssh_port(port):
2084+
if not _wait_ssh_port(port, wait_timeout):
20152085
warn(f"Container started but SSH port {port} is NOT accepting the SSH protocol.")
20162086
warn(" sshd failed to start inside the container — last container log lines:")
20172087
_show_log_tail(c, 15)
2018-
err("SSH server not ready (container is still running)",
2019-
"Fix the cause above, then: lpb --stop && lpb --ssh (or --ssh-port <port>)")
2088+
if first_run:
2089+
err("SSH server not ready — the first-run bootstrap may still be running (container is still alive)",
2090+
"Watch it finish: lpb --logs (sshd starts once 'First run bootstrap complete' appears)\n"
2091+
"Start over: lpb --stop && lpb --ssh (or --ssh-port <port>)")
2092+
else:
2093+
err("SSH server not ready (container is still running)",
2094+
"Fix the cause above, then: lpb --stop && lpb --ssh (or --ssh-port <port>)")
20202095
raise DevstackError
20212096

20222097
done(f"\u2713 SSH server ready (background) — listening on port {port}")

scripts/test_lpb_ssh.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,68 @@ def test_ssh_explicit_key_path():
5454
print(" PASS\n")
5555

5656

57+
def test_ssh_explicit_key_tilde_path():
58+
print("TEST: --ssh ~/.ssh/<pub> → tilde expanded, file read")
59+
reset_mock()
60+
_clear_ssh()
61+
mod = make_module()
62+
_write_key("id_ed25519.pub")
63+
mod.parse_cli(["--ssh", "~/.ssh/id_ed25519.pub"])
64+
mod.apply_overrides()
65+
assert mod.cfg.ssh_pubkey == "ssh-ed25519 AAAAC3-id_ed25519.pub id_ed25519.pub@host"
66+
assert "~" not in mod.cfg.ssh_pubkey
67+
print(" PASS\n")
68+
69+
70+
def test_ssh_project_dir_as_value():
71+
print("TEST: --ssh <project dir> → project dir + profile key selection")
72+
reset_mock()
73+
_clear_ssh()
74+
mod = make_module()
75+
_write_key("id_ed25519.pub")
76+
proj = os.path.join(os.environ["HOME"], "myproject")
77+
os.makedirs(proj, exist_ok=True)
78+
mod.parse_cli(["--ssh", proj])
79+
mod.apply_overrides()
80+
assert mod.cfg.ssh_mode
81+
assert mod.cfg.project_dir == proj, "dir passed to --ssh must become the project dir"
82+
# non-TTY + single profile key → auto-used (the prompt shows on a TTY)
83+
assert mod.cfg.ssh_pubkey == "ssh-ed25519 AAAAC3-id_ed25519.pub id_ed25519.pub@host"
84+
shutil.rmtree(proj, ignore_errors=True)
85+
print(" PASS\n")
86+
87+
88+
def test_ssh_invalid_value_errors():
89+
print("TEST: --ssh <nonexistent path> → error (not silently a key)")
90+
reset_mock()
91+
_clear_ssh()
92+
mod = make_module()
93+
try:
94+
mod.parse_cli(["--ssh", os.path.join(os.environ["HOME"], "nowhere", "missing.pub")])
95+
raise AssertionError("expected DevstackError")
96+
except mod.DevstackError:
97+
pass
98+
print(" PASS\n")
99+
100+
101+
def test_ssh_file_without_key_content_errors():
102+
print("TEST: --ssh <file that is not a pub key> → error")
103+
reset_mock()
104+
_clear_ssh()
105+
mod = make_module()
106+
bad = os.path.join(os.environ["HOME"], "not-a-key.pub")
107+
with open(bad, "w") as f:
108+
f.write("just some text\n")
109+
try:
110+
mod.parse_cli(["--ssh", bad])
111+
raise AssertionError("expected DevstackError")
112+
except mod.DevstackError:
113+
pass
114+
finally:
115+
os.unlink(bad)
116+
print(" PASS\n")
117+
118+
57119
def test_ssh_no_key_no_profile_keys():
58120
print("TEST: --ssh with no key and empty ~/.ssh → error")
59121
reset_mock()
@@ -239,9 +301,99 @@ def test_ssh_ready_only_after_port_opens():
239301
print(" PASS\n")
240302

241303

304+
def _first_run_marker():
305+
"""Host-side first-run marker: <state dir>/.initialized (start.sh creates it
306+
when the in-container bootstrap finishes)."""
307+
return os.path.join(os.environ["HOME"], ".lpb-stack", "state", ".initialized")
308+
309+
310+
def _run_ssh_flow_timeout(wait_ok: bool, first_run: bool) -> int:
311+
"""Run the --ssh flow and return the timeout _wait_ssh_port got."""
312+
from testharness import _OutputCapture
313+
mod = make_module() # redirects HOME to the isolated test home
314+
mod.lpb_setup = _FakeSetupOk()
315+
mod._port_in_use = lambda port: False
316+
marker = _first_run_marker()
317+
try:
318+
if first_run:
319+
if os.path.exists(marker):
320+
os.unlink(marker)
321+
else:
322+
os.makedirs(os.path.dirname(marker), exist_ok=True)
323+
open(marker, "w").close()
324+
captured = {}
325+
326+
def fake_wait(port, timeout=30):
327+
captured["timeout"] = timeout
328+
return wait_ok
329+
330+
mod._wait_ssh_port = fake_wait
331+
out = _OutputCapture()
332+
out.__enter__()
333+
try:
334+
mod.parse_cli(["--ssh", "ssh-ed25519 AAAA k@h"])
335+
mod.apply_overrides()
336+
mod.cmd_run()
337+
finally:
338+
out.__exit__(None, None, None)
339+
assert captured.get("timeout") is not None, "_wait_ssh_port was not called"
340+
return captured["timeout"]
341+
finally:
342+
if os.path.exists(marker):
343+
os.unlink(marker)
344+
345+
346+
def test_ssh_first_run_waits_for_bootstrap():
347+
print("TEST: first run (no .initialized) → long sshd wait (bootstrap)")
348+
reset_mock()
349+
t_first = _run_ssh_flow_timeout(wait_ok=True, first_run=True)
350+
t_warm = _run_ssh_flow_timeout(wait_ok=True, first_run=False)
351+
assert t_first >= 120, f"first-run wait too short: {t_first}s"
352+
assert t_warm < t_first, f"warm wait ({t_warm}s) should be shorter than first-run ({t_first}s)"
353+
print(" PASS\n")
354+
355+
356+
def test_ssh_first_run_failure_hint():
357+
print("TEST: first-run wait exhausted → hint says the bootstrap may still be running")
358+
from testharness import _OutputCapture
359+
reset_mock()
360+
mod = make_module() # redirects HOME to the isolated test home
361+
mod.lpb_setup = _FakeSetupOk()
362+
mod._port_in_use = lambda port: False
363+
mod._wait_ssh_port = lambda port, timeout=30: False
364+
marker = _first_run_marker()
365+
try:
366+
if os.path.exists(marker):
367+
os.unlink(marker)
368+
out = _OutputCapture()
369+
out.__enter__()
370+
raised = None
371+
try:
372+
mod.parse_cli(["--ssh", "ssh-ed25519 AAAA k@h"])
373+
mod.apply_overrides()
374+
mod.cmd_run()
375+
except mod.DevstackError:
376+
raised = "DevstackError"
377+
finally:
378+
out.__exit__(None, None, None)
379+
out_text = "".join(out.out)
380+
assert raised == "DevstackError", "must fail when the port never opens"
381+
assert "SSH server ready" not in out_text
382+
assert "bootstrap" in out_text, "first-run failure must mention the bootstrap"
383+
assert "lpb --logs" in out_text, "first-run failure must point at the live log"
384+
finally:
385+
if os.path.exists(marker):
386+
os.unlink(marker)
387+
print(" PASS\n")
388+
389+
242390
TESTS = [
243391
test_ssh_explicit_key_literal,
244392
test_ssh_explicit_key_path,
393+
test_ssh_explicit_key_tilde_path,
394+
test_ssh_project_dir_as_value,
395+
test_ssh_invalid_value_errors,
396+
test_ssh_file_without_key_content_errors,
245397
test_ssh_no_key_no_profile_keys,
246398
test_ssh_auto_single_key,
247399
test_ssh_auto_multiple_keys_noninteractive,
@@ -254,6 +406,8 @@ def test_ssh_ready_only_after_port_opens():
254406
test_ssh_port_in_use_fails_fast,
255407
test_ssh_no_false_success_when_port_never_opens,
256408
test_ssh_ready_only_after_port_opens,
409+
test_ssh_first_run_waits_for_bootstrap,
410+
test_ssh_first_run_failure_hint,
257411
]
258412

259413

0 commit comments

Comments
 (0)