utils/vmbox/lib/ssh.sh
Krzysztof kuhy Rudnicki a2d672f7fd vmbox: disposable Arch VM sandbox for destructive system tests
Much of testsAndMisc/linux_configuration cannot be tested on the host: 15
files use `chattr +i` (so a bad test is not simply revertable), the shutdown
installers write units that power the machine off, and boot_recovery installs
pacman hooks. The repo's own CI says as much -- its Ubuntu job runs only the
side-effect-free tests, noting the rest "drive pacman, /etc, systemd or adb".

vmbox runs them in a throwaway Arch guest and verifies the result from the
HOST, which is the part that is not obvious: a successful shutdown kills the
connection carrying the report, and ssh returns 255 for a clean poweroff, a
crash and a dropped link alike. So the verdict comes from two artefacts that
outlive the VM -- a QMP event log written by a recorder attached at launch
(attach later and qemu has already exited), and the serial console log.

Five outcomes are distinguished: clean poweroff (SHUTDOWN guest:true plus a
serial tail reaching "reboot: Power down"), dirty stop, guest-reset (rebooted
instead of powering off), GUEST_PANICKED, and hung. A POWERDOWN event proves
nothing -- it means the request was delivered, not that anything acted on it.

Reset is a qcow2 backing-file trick: the golden image is built once and sealed
(chmod 444 plus a sha256 sidecar verified on every launch, since booting the
base would silently invalidate every overlay), and each sandbox is a thin
overlay, so reset is rm + recreate. No root anywhere -- user-mode networking
with per-VM forwarded ssh ports, and a socket multicast segment for VM-to-VM.

The guest clock is pinned and re-applied on every launch, because the shutdown
installers gate on a 21:00-05:00 window; NTP is masked in the image or it
silently undoes the pin. Repos are cloned from a read-only share scoped to an
explicit directory, never $HOME, and credentials are sentinel values.

`vm build` ends by booting a throwaway sandbox off the sealed image and
refusing to ship if ssh does not answer. Several distinct faults produced an
identical "boots fine, connection reset" symptom; checks inside the build VM
only prove state at seal time, so the gate has to be an end-to-end boot.

Tests: 8 bats cases over the verdict table and name/meta/serial handling;
shellcheck and ruff clean; every file within 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 20:47:56 +02:00

84 lines
2.9 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=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}" waited=0
while (( waited < timeout )); do
if vm_is_running "$name" && 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.
vm_is_running "$name" || { (( waited > 3 )) && return 1; }
sleep 2; waited=$(( waited + 2 ))
done
return 1
}