mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 15:34:02 +01:00
vmbox: add a serial transport so shutdown tests do not depend on sshd
The Arch cloud image leaves sshd enabled but never started -- no start line, no failure line, dependencies satisfied. Four hypotheses (NTP, cloud-init clean, a socket-disable, a blocking start) were each disproven by measurement, so rather than keep paying a ~10 min rebuild per guess this adds the transport the tool should arguably have used all along. For the case vmbox exists for, serial beats ssh on the merits: it keeps printing straight through the poweroff that kills an ssh connection, and it works when sshd does not. `vm run` now picks ssh when it answers and the serial console otherwise, and recovers the guest exit code either way. Guests get a second serial port (ttyS1) on a unix socket with a root autologin, kept separate from ttyS0 so driving a shell never interleaves with the append-only log the verdict is read from. serial_exec.py waits for an explicit per-command end marker rather than a fixed sleep. That is not a detail: an earlier probe used a 4s window, the next command was typed while the previous one still ran, and the tty echoed that input -- which reads exactly like output in a transcript and produced a confident but wrong conclusion. It also strips ANSI CSI and systemd's OSC 3008 shell-integration blocks, verified against a real captured boot log. Also: a vmbox-sshd-kick unit that forces sshd up after multi-user.target as belt and braces, and the smoke-test sandbox renamed to a name that actually passes validate_vm_name -- "__smoke" was rejected, so the gate silently never ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
This commit is contained in:
parent
a2d672f7fd
commit
0b1f86ae2c
1
vmbox/--help/sockets.target.wants/sshd-unix-local.socket
Symbolic link
1
vmbox/--help/sockets.target.wants/sshd-unix-local.socket
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../sshd-unix-local.socket
|
||||||
11
vmbox/--help/sshd-unix-local.socket
Normal file
11
vmbox/--help/sshd-unix-local.socket
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated by systemd-ssh-generator
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=OpenSSH Server Socket (systemd-ssh-generator, AF_UNIX Local)
|
||||||
|
Documentation=man:systemd-ssh-generator(8)
|
||||||
|
|
||||||
|
[Socket]
|
||||||
|
ListenStream=/run/ssh-unix-local/socket
|
||||||
|
Accept=yes
|
||||||
|
PollLimitIntervalSec=30s
|
||||||
|
PollLimitBurst=50
|
||||||
1
vmbox/--help/sshd-unix-local@.service
Symbolic link
1
vmbox/--help/sshd-unix-local@.service
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/usr/lib/systemd/system/sshd@.service
|
||||||
@ -30,6 +30,8 @@ if [[ -f /etc/default/grub ]]; then
|
|||||||
grub-mkconfig -o /boot/grub/grub.cfg 2>/dev/null || true
|
grub-mkconfig -o /boot/grub/grub.cfg 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
systemctl enable serial-getty@ttyS0.service
|
systemctl enable serial-getty@ttyS0.service
|
||||||
|
# ttyS1 carries the interactive console vmbox drives programmatically.
|
||||||
|
systemctl enable serial-getty@ttyS1.service
|
||||||
|
|
||||||
# --- ssh ------------------------------------------------------------------
|
# --- ssh ------------------------------------------------------------------
|
||||||
# `systemctl enable sshd` alone is NOT enough on this image: systemd's
|
# `systemctl enable sshd` alone is NOT enough on this image: systemd's
|
||||||
@ -37,8 +39,13 @@ systemctl enable serial-getty@ttyS0.service
|
|||||||
# no-op and leave nothing listening on TCP:22. The guest then boots perfectly
|
# no-op and leave nothing listening on TCP:22. The guest then boots perfectly
|
||||||
# and every ssh gets "Connection reset by peer". Force the real service on and
|
# and every ssh gets "Connection reset by peer". Force the real service on and
|
||||||
# verify the symlink exists, failing the build loudly if it does not.
|
# verify the symlink exists, failing the build loudly if it does not.
|
||||||
|
# Do NOT `systemctl disable sshd.socket` here. On Arch sshd.socket and
|
||||||
|
# sshd.service are alternatives, and disabling the socket after enabling the
|
||||||
|
# service also drops the service's multi-user.target.wants symlink -- the
|
||||||
|
# build then reports "enabled" while the sealed image has no sshd at boot.
|
||||||
|
# Evidence: the build log shows "Created symlink" for every other unit but
|
||||||
|
# never for sshd.service, and the guest boot shows only the AF_UNIX socket.
|
||||||
systemctl enable sshd.service
|
systemctl enable sshd.service
|
||||||
systemctl disable sshd.socket 2>/dev/null || true
|
|
||||||
if [[ ! -e /etc/systemd/system/multi-user.target.wants/sshd.service ]]; then
|
if [[ ! -e /etc/systemd/system/multi-user.target.wants/sshd.service ]]; then
|
||||||
echo "provision: FAILED to enable sshd.service -- guest would be unreachable" >&2
|
echo "provision: FAILED to enable sshd.service -- guest would be unreachable" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@ -50,6 +57,23 @@ ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1 || {
|
|||||||
echo "provision: FAILED to generate sshd host keys" >&2
|
echo "provision: FAILED to generate sshd host keys" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
# Belt and braces. On this image sshd.service is enabled and its dependencies
|
||||||
|
# are satisfied, yet it produces no start line at boot -- neither started nor
|
||||||
|
# failed. Rather than keep guessing at the mechanism, force it explicitly once
|
||||||
|
# multi-user is reached. Harmless if sshd already started.
|
||||||
|
cat > /etc/systemd/system/vmbox-sshd-kick.service <<'KICK'
|
||||||
|
[Unit]
|
||||||
|
Description=vmbox: ensure sshd is actually running
|
||||||
|
After=multi-user.target network.target
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/systemctl start --no-block sshd.service
|
||||||
|
RemainAfterExit=yes
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
KICK
|
||||||
|
systemctl enable vmbox-sshd-kick.service
|
||||||
|
|
||||||
echo "provision: sshd.service enabled with $(ls /etc/ssh/ssh_host_*_key | wc -l) host keys"
|
echo "provision: sshd.service enabled with $(ls /etc/ssh/ssh_host_*_key | wc -l) host keys"
|
||||||
|
|
||||||
# --- systemd --user session ----------------------------------------------
|
# --- systemd --user session ----------------------------------------------
|
||||||
@ -62,12 +86,14 @@ loginctl enable-linger "$GUEST_USER" 2>/dev/null || true
|
|||||||
# Without autologin here an unreachable guest can only be diagnosed by
|
# Without autologin here an unreachable guest can only be diagnosed by
|
||||||
# inferring from absent log lines, which is exactly how a single sshd fault
|
# inferring from absent log lines, which is exactly how a single sshd fault
|
||||||
# turned into several blind image rebuilds.
|
# turned into several blind image rebuilds.
|
||||||
install -d -m 755 /etc/systemd/system/serial-getty@ttyS0.service.d
|
for tty in ttyS0 ttyS1; do
|
||||||
cat > /etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf <<SUNIT
|
install -d -m 755 "/etc/systemd/system/serial-getty@${tty}.service.d"
|
||||||
|
cat > "/etc/systemd/system/serial-getty@${tty}.service.d/autologin.conf" <<SUNIT
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=
|
ExecStart=
|
||||||
ExecStart=-/sbin/agetty --autologin root --keep-baud 115200,57600,38400,9600 %I \$TERM
|
ExecStart=-/sbin/agetty --autologin root --keep-baud 115200,57600,38400,9600 %I \$TERM
|
||||||
SUNIT
|
SUNIT
|
||||||
|
done
|
||||||
|
|
||||||
# --- host repo share ------------------------------------------------------
|
# --- host repo share ------------------------------------------------------
|
||||||
# Mounted read-only; the guest clones out of it rather than working in it.
|
# Mounted read-only; the guest clones out of it rather than working in it.
|
||||||
|
|||||||
@ -67,6 +67,10 @@ vm_qmp_sock() { printf '%s/%s/qmp.sock' "$VMBOX_VMS_DIR" "$1"; }
|
|||||||
# Separate control channel: the recorder occupies the main QMP socket, and a
|
# Separate control channel: the recorder occupies the main QMP socket, and a
|
||||||
# QMP socket accepts a single client at a time.
|
# QMP socket accepts a single client at a time.
|
||||||
vm_qmp_ctl() { printf '%s/%s/qmp.sock.ctl' "$VMBOX_VMS_DIR" "$1"; }
|
vm_qmp_ctl() { printf '%s/%s/qmp.sock.ctl' "$VMBOX_VMS_DIR" "$1"; }
|
||||||
|
# Bidirectional serial console. Unlike ssh this survives the poweroff it is
|
||||||
|
# reporting on, and it works when sshd does not -- so it is the transport of
|
||||||
|
# record for shutdown tests, not a fallback.
|
||||||
|
vm_console() { printf '%s/%s/console.sock' "$VMBOX_VMS_DIR" "$1"; }
|
||||||
vm_events() { printf '%s/%s/events.jsonl' "$VMBOX_VMS_DIR" "$1"; }
|
vm_events() { printf '%s/%s/events.jsonl' "$VMBOX_VMS_DIR" "$1"; }
|
||||||
vm_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; }
|
vm_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; }
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,10 @@ launch_vm() {
|
|||||||
-device virtio-vga
|
-device virtio-vga
|
||||||
-chardev file,id=ser0,path="$serial",append=on
|
-chardev file,id=ser0,path="$serial",append=on
|
||||||
-serial chardev:ser0
|
-serial chardev:ser0
|
||||||
|
# ttyS1: interactive console on a unix socket. Separate from ttyS0 so
|
||||||
|
# driving a shell never interleaves with the log the verdict reads.
|
||||||
|
-chardev "socket,id=ser1,path=$(vm_console "$name"),server=on,wait=off"
|
||||||
|
-serial chardev:ser1
|
||||||
-qmp "unix:${qmp},server=on,wait=off"
|
-qmp "unix:${qmp},server=on,wait=off"
|
||||||
# A QMP socket serves ONE client, and the recorder holds that one for
|
# A QMP socket serves ONE client, and the recorder holds that one for
|
||||||
# the VM's whole life. Screenshots and status queries therefore need
|
# the VM's whole life. Screenshots and status queries therefore need
|
||||||
|
|||||||
@ -17,6 +17,10 @@ source "$(dirname "${BASH_SOURCE[0]}")/ssh.sh"
|
|||||||
source "$(dirname "${BASH_SOURCE[0]}")/verdict.sh"
|
source "$(dirname "${BASH_SOURCE[0]}")/verdict.sh"
|
||||||
|
|
||||||
readonly RC_PATH="/var/tmp/vmbox.rc"
|
readonly RC_PATH="/var/tmp/vmbox.rc"
|
||||||
|
# Transport for guest commands. Serial is the default for shutdown work: it
|
||||||
|
# keeps printing through the poweroff that kills ssh, and it still works when
|
||||||
|
# sshd does not. ssh stays available as the faster path for bulk work.
|
||||||
|
readonly VMBOX_TRANSPORT="${VMBOX_TRANSPORT:-auto}"
|
||||||
readonly VMBOX_RUN_TIMEOUT="${VMBOX_RUN_TIMEOUT:-600}"
|
readonly VMBOX_RUN_TIMEOUT="${VMBOX_RUN_TIMEOUT:-600}"
|
||||||
|
|
||||||
run_in_vm() {
|
run_in_vm() {
|
||||||
@ -54,9 +58,33 @@ run_in_vm() {
|
|||||||
_run_exec() {
|
_run_exec() {
|
||||||
local name="$1"; shift
|
local name="$1"; shift
|
||||||
local cmd="$*"
|
local cmd="$*"
|
||||||
vm_ssh_exec "$name" \
|
case "$(_run_transport "$name")" in
|
||||||
"sh -c '{ $cmd; } ; echo \$? | sudo tee $RC_PATH >/dev/null' </dev/null" \
|
serial) _run_exec_serial "$name" "$cmd" ;;
|
||||||
</dev/null
|
*) vm_ssh_exec "$name" \
|
||||||
|
"sh -c '{ $cmd; } ; echo \$? | sudo tee $RC_PATH >/dev/null' </dev/null" \
|
||||||
|
</dev/null ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefer ssh when it answers (faster, cleaner), else drive the serial console.
|
||||||
|
_run_transport() {
|
||||||
|
local name="$1"
|
||||||
|
case "$VMBOX_TRANSPORT" in
|
||||||
|
ssh|serial) printf '%s' "$VMBOX_TRANSPORT"; return ;;
|
||||||
|
esac
|
||||||
|
if vm_ssh_exec "$name" -o BatchMode=yes -o ConnectTimeout=4 true 2>/dev/null; then
|
||||||
|
printf 'ssh'
|
||||||
|
else
|
||||||
|
printf 'serial'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_run_exec_serial() {
|
||||||
|
local name="$1" cmd="$2" console
|
||||||
|
console="$(vm_console "$name")"
|
||||||
|
[[ -S "$console" ]] || die "no serial console socket for '$name'"
|
||||||
|
python3 "$VMBOX_LIB_DIR/serial_exec.py" "$console" \
|
||||||
|
"{ $cmd ; } ; echo \$? > $RC_PATH"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Give a guest that is shutting down time to finish writing its serial log.
|
# Give a guest that is shutting down time to finish writing its serial log.
|
||||||
@ -109,11 +137,22 @@ _run_finish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Boot the sandbox again just far enough to read the recorded exit code.
|
# Boot the sandbox again just far enough to read the recorded exit code.
|
||||||
|
# The overlay survives the poweroff, so the value written before the machine
|
||||||
|
# stopped is still there.
|
||||||
_run_recover_rc() {
|
_run_recover_rc() {
|
||||||
local name="$1" rc=""
|
local name="$1" rc="" console
|
||||||
launch_vm "$name" >/dev/null 2>&1 || return 0
|
launch_vm "$name" >/dev/null 2>&1 || return 0
|
||||||
if vm_wait_ssh "$name" 120; then
|
|
||||||
|
if vm_wait_ssh "$name" 90; then
|
||||||
rc="$(vm_ssh_exec "$name" "cat $RC_PATH 2>/dev/null" 2>/dev/null | tr -dc '0-9')"
|
rc="$(vm_ssh_exec "$name" "cat $RC_PATH 2>/dev/null" 2>/dev/null | tr -dc '0-9')"
|
||||||
|
else
|
||||||
|
# ssh may be broken or simply slower than the console; the serial
|
||||||
|
# shell can read the file just as well.
|
||||||
|
console="$(vm_console "$name")"
|
||||||
|
if [[ -S "$console" ]]; then
|
||||||
|
rc="$(python3 "$VMBOX_LIB_DIR/serial_exec.py" "$console" \
|
||||||
|
"cat $RC_PATH 2>/dev/null" 2>/dev/null | grep -oE '^[0-9]+' | head -1)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
printf '%s' "$rc"
|
printf '%s' "$rc"
|
||||||
}
|
}
|
||||||
|
|||||||
104
vmbox/lib/serial_exec.py
Executable file
104
vmbox/lib/serial_exec.py
Executable file
@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run a command in a vmbox guest over its serial console root shell.
|
||||||
|
|
||||||
|
This exists because ssh is not the right channel for the thing vmbox is for.
|
||||||
|
A shutdown script kills the connection carrying its own result, and the serial
|
||||||
|
console keeps printing right through the poweroff -- so for the shutdown case
|
||||||
|
serial is not a fallback, it is the better transport. It also works when sshd
|
||||||
|
is broken, which makes an unreachable guest debuggable instead of opaque.
|
||||||
|
|
||||||
|
Every command is bracketed by explicit markers and waited on until its END
|
||||||
|
marker appears. Never use a fixed sleep: if the window closes early the next
|
||||||
|
command is typed while the previous one still runs, the tty echoes that input,
|
||||||
|
and the echoed text is indistinguishable from output in the transcript.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
serial_exec.py <console.sock> <command> -> runs, prints output
|
||||||
|
serial_exec.py <console.sock> --wait-prompt -> just wait for a shell
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
PROMPT = "[root@vmbox"
|
||||||
|
# Strip ANSI CSI sequences and the OSC 3008 shell-integration blocks that
|
||||||
|
# systemd's login emits, both of which otherwise corrupt marker matching.
|
||||||
|
_ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
|
||||||
|
_OSC = re.compile(r"\x1b?\]3008;[^\x07\\]*(?:\x07|\\)?")
|
||||||
|
|
||||||
|
|
||||||
|
def clean(raw: bytes) -> str:
|
||||||
|
text = raw.decode("utf-8", "replace")
|
||||||
|
text = _ANSI.sub("", text)
|
||||||
|
text = _OSC.sub("", text)
|
||||||
|
return text.replace("\r", "")
|
||||||
|
|
||||||
|
|
||||||
|
class Console:
|
||||||
|
"""A line-oriented client for the guest's serial console socket."""
|
||||||
|
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
self.sock.connect(path)
|
||||||
|
self.sock.settimeout(0.4)
|
||||||
|
self.buf = b""
|
||||||
|
|
||||||
|
def read_until(self, needle: str, timeout: float) -> bool:
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
try:
|
||||||
|
chunk = self.sock.recv(4096)
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not chunk:
|
||||||
|
return False
|
||||||
|
self.buf += chunk
|
||||||
|
if needle in clean(self.buf):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def wait_prompt(self, timeout: float = 180) -> bool:
|
||||||
|
# Nudge with a newline: if the shell is already up we get a fresh
|
||||||
|
# prompt immediately instead of waiting for unrelated output.
|
||||||
|
self.sock.sendall(b"\n")
|
||||||
|
return self.read_until(PROMPT, timeout)
|
||||||
|
|
||||||
|
def run(self, cmd: str, timeout: float = 300) -> str:
|
||||||
|
token = uuid.uuid4().hex[:8]
|
||||||
|
start, done = f"<<<S{token}>>>", f"<<<E{token}>>>"
|
||||||
|
self.buf = b""
|
||||||
|
# `; echo RC=$?` keeps the guest's exit status, which the ssh path
|
||||||
|
# cannot report once the machine stops.
|
||||||
|
self.sock.sendall(f"echo {start}; {cmd}; echo RC=$?; echo {done}\n".encode())
|
||||||
|
if not self.read_until(done, timeout):
|
||||||
|
return f"### TIMEOUT: no end marker after {timeout}s ###"
|
||||||
|
text = clean(self.buf)
|
||||||
|
body = text.split(start, 1)[-1].split(done, 1)[0]
|
||||||
|
# Drop the echoed command line itself.
|
||||||
|
lines = [ln for ln in body.splitlines() if start not in ln and done not in ln]
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
console = Console(sys.argv[1])
|
||||||
|
if sys.argv[2] == "--wait-prompt":
|
||||||
|
return 0 if console.wait_prompt() else 1
|
||||||
|
if not console.wait_prompt():
|
||||||
|
print("serial_exec: no root prompt on the console", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(console.run(" ".join(sys.argv[2:])))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -21,7 +21,7 @@ base_smoke_test() {
|
|||||||
source "$VMBOX_LIB_DIR/launch.sh"
|
source "$VMBOX_LIB_DIR/launch.sh"
|
||||||
source "$VMBOX_LIB_DIR/ssh.sh"
|
source "$VMBOX_LIB_DIR/ssh.sh"
|
||||||
|
|
||||||
local smoke="__smoke"
|
local smoke="vmbox-smoke"
|
||||||
vm_exists "$smoke" && overlay_rm "$smoke" >/dev/null 2>&1
|
vm_exists "$smoke" && overlay_rm "$smoke" >/dev/null 2>&1
|
||||||
overlay_new "$smoke" >/dev/null
|
overlay_new "$smoke" >/dev/null
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ base_smoke_test() {
|
|||||||
if (( failed == 0 )); then
|
if (( failed == 0 )); then
|
||||||
overlay_rm "$smoke" >/dev/null 2>&1 || true
|
overlay_rm "$smoke" >/dev/null 2>&1 || true
|
||||||
else
|
else
|
||||||
warn "keeping sandbox '__smoke' so its logs survive; remove it with: vm rm __smoke"
|
warn "keeping sandbox 'vmbox-smoke' so its logs survive; remove it with: vm rm vmbox-smoke"
|
||||||
die "refusing to ship an unreachable base image"
|
die "refusing to ship an unreachable base image"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user