utils/vmbox/lib/common.sh
Krzysztof kuhy Rudnicki 0b1f86ae2c 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
2026-08-22 20:58:22 +02:00

138 lines
5.9 KiB
Bash

#!/bin/bash
# ============================================================================
# vmbox: shared paths, logging and validation.
# Sourced by every other lib/ file and by bin/vm. Defines no side effects.
# ============================================================================
# Guard against double-sourcing: each lib sources common.sh unconditionally.
# shellcheck disable=SC2034 # every path/const below is consumed by the
# sibling lib/*.sh files that source this one, which shellcheck cannot see.
[[ -n "${VMBOX_COMMON_SOURCED:-}" ]] && return 0
readonly VMBOX_COMMON_SOURCED=1
# Repo root is the parent of lib/, resolved through symlinks so the CLI works
# when bin/vm is symlinked onto PATH by install.sh.
VMBOX_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
readonly VMBOX_LIB_DIR
# Go up one real directory rather than stripping a "/lib" suffix: the suffix
# form silently yields the wrong root when sourced via a relative path.
VMBOX_ROOT="$(cd "$VMBOX_LIB_DIR/.." && pwd -P)"
readonly VMBOX_ROOT
readonly VMBOX_GUEST_DIR="$VMBOX_ROOT/guest"
# State lives OUTSIDE the repo: these are multi-GB disk images and live
# sockets, and the repo has a pre-commit hook that blocks binaries.
readonly VMBOX_HOME="${VMBOX_HOME:-$HOME/.local/share/vmbox}"
readonly VMBOX_BASE_DIR="$VMBOX_HOME/base"
readonly VMBOX_VMS_DIR="$VMBOX_HOME/vms"
readonly VMBOX_BASE_IMG="$VMBOX_BASE_DIR/base.qcow2"
readonly VMBOX_BASE_SHA="$VMBOX_BASE_DIR/base.qcow2.sha256"
readonly VMBOX_SSH_KEY="$VMBOX_BASE_DIR/id_ed25519"
# Base SSH port. Each VM gets VMBOX_SSH_PORT_BASE + its index.
readonly VMBOX_SSH_PORT_BASE="${VMBOX_SSH_PORT_BASE:-2300}"
# Shared multicast segment so sandboxes can reach each other (no root, no bridge).
readonly VMBOX_MCAST="${VMBOX_MCAST:-230.0.0.1:12345}"
# Guest static subnet on the second NIC (the mcast segment has no DHCP).
readonly VMBOX_GUEST_SUBNET="${VMBOX_GUEST_SUBNET:-10.77.0}"
readonly VMBOX_GUEST_USER="${VMBOX_GUEST_USER:-arch}"
# Colours only when stdout is a terminal, so logs stay clean when piped.
if [[ -t 1 ]]; then
readonly C_RED=$'\033[0;31m' C_GRN=$'\033[0;32m' C_YLW=$'\033[0;33m'
readonly C_BLU=$'\033[0;34m' C_OFF=$'\033[0m'
else
readonly C_RED='' C_GRN='' C_YLW='' C_BLU='' C_OFF=''
fi
log() { printf '%s==>%s %s\n' "$C_BLU" "$C_OFF" "$*"; }
ok() { printf '%s ok %s %s\n' "$C_GRN" "$C_OFF" "$*"; }
warn() { printf '%s warn%s %s\n' "$C_YLW" "$C_OFF" "$*" >&2; }
die() { printf '%serror%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
# VM names become directory names and qemu -name values; keep them boring.
validate_vm_name() {
local name="${1:-}"
[[ -n "$name" ]] || die "vm name is required"
[[ "$name" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$ ]] ||
die "invalid vm name '$name' (allowed: alphanumeric, _ and -, max 32 chars)"
printf '%s' "$name"
}
vm_dir() { printf '%s/%s' "$VMBOX_VMS_DIR" "$1"; }
vm_overlay() { printf '%s/%s/overlay.qcow2' "$VMBOX_VMS_DIR" "$1"; }
vm_meta() { printf '%s/%s/meta' "$VMBOX_VMS_DIR" "$1"; }
vm_qmp_sock() { printf '%s/%s/qmp.sock' "$VMBOX_VMS_DIR" "$1"; }
# Separate control channel: the recorder occupies the main QMP socket, and a
# QMP socket accepts a single client at a time.
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_pidfile() { printf '%s/%s/vm.pid' "$VMBOX_VMS_DIR" "$1"; }
# Serial logs are per-boot: a relaunch must never overwrite the log a verdict
# is about to be read from. See vm_next_serial().
vm_serial() { printf '%s/%s/serial.%s.log' "$VMBOX_VMS_DIR" "$1" "$2"; }
vm_exists() { [[ -d "$(vm_dir "$1")" ]]; }
require_vm() {
vm_exists "$1" || die "no such vm: '$1' (create it with: vm new $1)"
}
# meta is a flat key=value file; values must not contain newlines.
meta_get() {
local name="$1" key="$2" line
line="$(grep -m1 "^${key}=" "$(vm_meta "$name")" 2>/dev/null)" || return 1
printf '%s' "${line#*=}"
}
meta_set() {
local name="$1" key="$2" value="$3" meta
meta="$(vm_meta "$name")"
[[ "$value" == *$'\n'* ]] && die "meta value for '$key' must be single-line"
touch "$meta"
# Rewrite in place: delete any existing key, then append the new value.
grep -v "^${key}=" "$meta" > "$meta.tmp" 2>/dev/null || true
printf '%s=%s\n' "$key" "$value" >> "$meta.tmp"
mv "$meta.tmp" "$meta"
}
# Highest existing serial.N.log + 1, so each boot writes its own file.
vm_next_serial() {
local name="$1" n=0 f
# -e guards the unmatched-glob case; without it an empty dir yields either
# a literal "serial.*.log" (bash) or a hard error (zsh).
for f in "$(vm_dir "$name")"/serial.*.log; do
[[ -e "$f" ]] || continue
local base="${f##*/serial.}"
base="${base%.log}"
[[ "$base" =~ ^[0-9]+$ ]] && (( base >= n )) && n=$(( base + 1 ))
done
printf '%d' "$n"
}
# A VM is running if its pidfile names a live qemu process. Stale pidfiles are
# normal: a guest that powers itself off leaves one behind.
vm_is_running() {
local pid
pid="$(cat "$(vm_pidfile "$1")" 2>/dev/null)" || return 1
[[ -n "$pid" ]] || return 1
[[ -r "/proc/$pid/cmdline" ]] || return 1
grep -qa vmbox-"$1" "/proc/$pid/cmdline" 2>/dev/null
}
require_host_deps() {
local missing=()
command -v qemu-system-x86_64 >/dev/null || missing+=(qemu-base)
command -v qemu-img >/dev/null || missing+=(qemu-img)
command -v xorrisofs >/dev/null || missing+=(libisoburn)
command -v python3 >/dev/null || missing+=(python)
(( ${#missing[@]} == 0 )) ||
die "missing host dependencies: ${missing[*]} -- run: $VMBOX_ROOT/install.sh"
}