utils/vmbox/lib/ssh.sh
Krzysztof kuhy Rudnicki cb3c985ee6 vmbox: 3-4x faster runs, a read-only host package cache, and vm scp
`vm run` took 41s cold / 30s warm to run `true`. Both were vmbox waiting on
itself. Now 12s / 0s, with all five shutdown verdicts re-tested unchanged.

- _run_settle waited up to 30s for the kernel's "reboot: Power down" marker
  UNCONDITIONALLY, including on the majority of runs that leave the guest up,
  where that marker is never going to appear. It now settles only when the
  guest has actually stopped, or when ssh returned 255 (what a successful
  poweroff looks like from the host).

- The ssh readiness polls used a 5s ConnectTimeout. QEMU's user-mode
  networking ACCEPTS the forwarded connection while the guest still has
  nothing on :22, so each probe blocked for the full timeout instead of
  failing fast: a guest reachable at ~10s was reported up at ~45s. Fixed in
  both the sandbox and build paths, via an env override -- ssh honours the
  FIRST value for an option, so a second -o could not have worked.

Making runs fast exposed a race: sshd now answers BEFORE tty1's autologin has
started Xorg, so the X environment could be missing on the first run after a
boot. `VMBOX_X_WAIT=<seconds>` waits for the session; it defaults to 0 so an
ordinary run never pays for a graphical session it is not using.

Build: the host's /var/cache/pacman/pkg is offered to the build guest
read-only (`readonly=on` AND an `ro` mount, because it is the real host cache)
as an extra CacheDir after the guest's own writable one, so a package the host
lacks still downloads rather than failing the build. This removes the 396 MiB
the build used to fetch. Host cache verified byte-for-byte identical across
four builds: same file count, same size, same mtime.

Build time itself moved only 103.8s -> 100.3s. Per-phase timings (also added
here) show why: 47s of it is the build guest's cloud-init first boot, and that
is NOT datasource probing -- `cloud-init analyze blame` totals 2.27s. Left
unexplained rather than guessed at; SESSION_RESULTS.md records the evidence.

Also: `vm scp`, which lib/ssh.sh could always do but the CLI never exposed,
and provision.sh split at the 250-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
2026-08-22 23:17:21 +02:00

106 lines
4.2 KiB
Bash

#!/bin/bash
# ============================================================================
# vmbox: ssh access to a sandbox.
#
# Guest host keys are deliberately NOT verified and NOT recorded: a sandbox is
# reset constantly, so a real known_hosts entry would collide on every reset.
# Isolation comes from the VM boundary, not from ssh key continuity.
# ============================================================================
# shellcheck source=common.sh
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
vm_ssh_port() {
local port
port="$(meta_get "$1" ssh_port)" || die "sandbox '$1' has no ssh port in meta"
printf '%s' "$port"
}
_ssh_base_args() {
local port="$1"
printf '%s\0' \
-i "$VMBOX_SSH_KEY" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o GlobalKnownHostsFile=/dev/null \
-o LogLevel=ERROR \
-o ConnectTimeout="${VMBOX_SSH_CONNECT_TIMEOUT:-5}" \
-o ServerAliveInterval=5 \
-p "$port"
}
# Run a command in the guest. Returns the guest's exit code, EXCEPT when the
# connection dies -- ssh reports 255 for a dropped link, a killed sshd and a
# successful poweroff alike, which is exactly why the verdict logic never
# trusts this return value on its own.
vm_ssh_exec() {
local name="$1"; shift
local port; port="$(vm_ssh_port "$name")"
local -a args=()
mapfile -d '' -t args < <(_ssh_base_args "$port")
ssh "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
}
vm_ssh_interactive() {
local name
name="$(validate_vm_name "${1:-}")"; shift || true
require_vm "$name"
if ! vm_is_running "$name"; then
log "Sandbox '$name' is not running; starting it"
source "$VMBOX_LIB_DIR/launch.sh"
launch_vm "$name"
vm_wait_ssh "$name" 120 || die "sandbox '$name' did not become reachable"
fi
local port; port="$(vm_ssh_port "$name")"
local -a args=()
mapfile -d '' -t args < <(_ssh_base_args "$port")
# -t forces a pty so interactive tools (and sudo prompts) behave.
ssh -t "${args[@]}" "${VMBOX_GUEST_USER}@127.0.0.1" "$@"
}
vm_scp_to() {
local name="$1" src="$2" dest="$3" port
port="$(vm_ssh_port "$name")"
scp -i "$VMBOX_SSH_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -P "$port" -r "$src" "${VMBOX_GUEST_USER}@127.0.0.1:$dest"
}
# Poll until the guest answers. ssh is its own port prober, so this needs no
# nc/socat (neither is installed on this host).
vm_wait_ssh() {
local name="$1" timeout="${2:-120}" deadline start now
# Deadline is WALL CLOCK, not a count of sleeps. Each failed probe also
# burns up to ConnectTimeout seconds inside ssh itself, so counting only
# the sleeps under-measures badly: a guest that never boots (no kernel,
# stuck in GRUB) made a nominal 150s wait run for well over 10 minutes,
# which reads as a hung tool rather than an unreachable sandbox.
printf -v start '%(%s)T' -1
deadline=$(( start + timeout ))
while :; do
printf -v now '%(%s)T' -1
(( now < deadline )) || return 1
# A 1s connect timeout (not the 5s default) is what makes this loop
# track the guest instead of lagging ~35s behind it. QEMU's user-mode
# networking ACCEPTS the forwarded TCP connection even while the guest
# has nothing listening on :22, so a probe against a booting guest does
# not fail fast -- it blocks for the whole ConnectTimeout. Measured:
# sshd is reachable at ~10s, but a 5s-timeout poll reported the guest
# up at ~45s, and that inflated figure was blamed on cloud-init.
#
# It goes through the env var because ssh honours the FIRST value given
# for an option, so a second -o appended here would be ignored -- and
# anything after the hostname is the remote command, not an option.
if vm_is_running "$name" &&
VMBOX_SSH_CONNECT_TIMEOUT=1 vm_ssh_exec "$name" -o BatchMode=yes true 2>/dev/null; then
return 0
fi
# A qemu that has exited means the guest will never answer.
if ! vm_is_running "$name"; then
(( now - start > 3 )) && return 1
fi
sleep 1
done
}