mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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
134 lines
5.6 KiB
Bash
134 lines
5.6 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"; }
|
|
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"
|
|
}
|