Files
rvbox/scripts/windows/test-host
T

490 lines
21 KiB
Bash
Executable File

#!/bin/sh
# Native Windows VM controller for the Helium VirtualBox smoke fixture.
#
# This intentionally runs on the Linux controller. VBoxManage and the
# password file stay on the Linux VirtualBox host, reached only over SSH. The
# VM's GUI-subsystem rvbox.exe is never started directly by Guest Control:
# Guest Control runs console-safe management programs, while SCM runs the real
# service process.
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
usage() {
cat <<'EOF'
usage: scripts/windows/test-host ACTION [--run-id ID] [--bundle DIRECTORY] [--endpoint HOST:PORT]
Actions:
status read-only VM/snapshot identity and state check
prepare restore the declared baseline, boot headless, and verify Guest Additions
stage copy a bundle containing rvbox.exe and client.toml into the guest test root
install install and start RVBox from the staged bundle through a fixture-only full-admin principal
run start the already-installed RVBox SCM service from the staged bundle
collect copy bounded guest artifacts to the local test-run directory
stop stop RVBox through SCM and request a graceful guest shutdown
reset stop the guest if necessary, restore the declared baseline, and leave it off
recover read-only fixture/run-state check for a stopped-resumable run
Optional environment:
The documented Helium fixture identity and password-file path are defaults.
RVBOX_TEST_VBOX_HOST, RVBOX_TEST_VBOX_VM, RVBOX_TEST_VBOX_VM_UUID,
RVBOX_TEST_VBOX_SNAPSHOT, RVBOX_TEST_VBOX_SNAPSHOT_UUID,
RVBOX_TEST_GUEST_USER, RVBOX_TEST_GUEST_PASSWORD_FILE (overrides)
RVBOX_TEST_PROVISIONER_USER, RVBOX_TEST_PROVISIONER_PASSWORD_FILE
(default Administrator and the documented fixture password file)
RVBOX_TEST_HOST_STAGE_ROOT (default /home/cabbage/.local/state/rvbox-test-runs)
RVBOX_TEST_RUN_ROOT (default .test-runs/windows-vm)
EOF
}
fail() { printf '%s\n' "test-host: $*" >&2; exit 2; }
require_env() {
eval "value=\${$1-}"
[ -n "$value" ] || fail "$1 is required"
}
safe_word() {
case $2 in
''|*[!A-Za-z0-9._:/@+=,-]*) fail "$1 contains unsupported characters" ;;
esac
}
safe_id() {
case $1 in
[a-z0-9]* ) ;;
* ) fail "run ID must start with lowercase alphanumeric" ;;
esac
case $1 in
*[!a-z0-9-]*|????????????????????????????????????????????????????????????????*)
fail "run ID must match [a-z0-9][a-z0-9-]{0,63}"
;;
esac
}
action=${1-}
[ -n "$action" ] || { usage >&2; exit 2; }
case $action in --help|-h) usage; exit 0 ;; esac
shift
run_id=
bundle=
endpoint=
while [ "$#" -gt 0 ]; do
case $1 in
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
--bundle) [ "$#" -ge 2 ] || fail "--bundle needs a value"; bundle=$2; shift 2 ;;
--endpoint) [ "$#" -ge 2 ] || fail "--endpoint needs a value"; endpoint=$2; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) fail "unknown argument $1" ;;
esac
done
case $action in status|prepare|stage|install|run|collect|stop|reset|recover) ;; *) usage >&2; fail "unknown action $action" ;; esac
if [ "$action" != status ]; then
[ -n "$run_id" ] || fail "$action requires --run-id"
safe_id "$run_id"
fi
if [ "$action" = stage ]; then
[ -d "$bundle" ] || fail "stage requires an existing --bundle directory"
[ -f "$bundle/rvbox.exe" ] || fail "bundle must contain rvbox.exe"
[ -f "$bundle/client.toml" ] || fail "bundle must contain client.toml"
fi
if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; fi
# The Helium smoke fixture is the only supported native lane today. Keep its
# non-secret identity and host-local password-file *path* here so a developer
# can run the controller without retyping fixture metadata. Operators may
# override any value for another recorded fixture. The password itself is
# never read by this script and is never stored in the repository.
: "${RVBOX_TEST_VBOX_HOST:=helium-remote}"
: "${RVBOX_TEST_VBOX_VM:=rvbox-win10-test}"
: "${RVBOX_TEST_VBOX_VM_UUID:=6cdc114f-71e5-4167-a394-e922e14e6f5c}"
: "${RVBOX_TEST_VBOX_SNAPSHOT:=baseline-clean-administrator}"
: "${RVBOX_TEST_VBOX_SNAPSHOT_UUID:=ba5ce5f1-77e3-44b0-8d91-534becce27ff}"
: "${RVBOX_TEST_GUEST_USER:=rvboxtest}"
: "${RVBOX_TEST_GUEST_PASSWORD_FILE:=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password}"
: "${RVBOX_TEST_PROVISIONER_USER:=Administrator}"
: "${RVBOX_TEST_PROVISIONER_PASSWORD_FILE:=$RVBOX_TEST_GUEST_PASSWORD_FILE}"
provisioner_user=$RVBOX_TEST_PROVISIONER_USER
provisioner_password_file=$RVBOX_TEST_PROVISIONER_PASSWORD_FILE
for name in RVBOX_TEST_VBOX_HOST RVBOX_TEST_VBOX_VM RVBOX_TEST_VBOX_VM_UUID \
RVBOX_TEST_VBOX_SNAPSHOT RVBOX_TEST_VBOX_SNAPSHOT_UUID \
RVBOX_TEST_GUEST_USER RVBOX_TEST_GUEST_PASSWORD_FILE; do
require_env "$name"
done
safe_word RVBOX_TEST_VBOX_HOST "$RVBOX_TEST_VBOX_HOST"
safe_word RVBOX_TEST_VBOX_VM "$RVBOX_TEST_VBOX_VM"
safe_word RVBOX_TEST_VBOX_VM_UUID "$RVBOX_TEST_VBOX_VM_UUID"
safe_word RVBOX_TEST_VBOX_SNAPSHOT "$RVBOX_TEST_VBOX_SNAPSHOT"
safe_word RVBOX_TEST_VBOX_SNAPSHOT_UUID "$RVBOX_TEST_VBOX_SNAPSHOT_UUID"
safe_word RVBOX_TEST_GUEST_USER "$RVBOX_TEST_GUEST_USER"
safe_word RVBOX_TEST_GUEST_PASSWORD_FILE "$RVBOX_TEST_GUEST_PASSWORD_FILE"
if [ -n "$provisioner_user" ]; then safe_word RVBOX_TEST_PROVISIONER_USER "$provisioner_user"; fi
if [ -n "$provisioner_password_file" ]; then safe_word RVBOX_TEST_PROVISIONER_PASSWORD_FILE "$provisioner_password_file"; fi
host_stage_root=${RVBOX_TEST_HOST_STAGE_ROOT:-/home/cabbage/.local/state/rvbox-test-runs}
run_root=${RVBOX_TEST_RUN_ROOT:-$repo_root/.test-runs/windows-vm}
safe_word RVBOX_TEST_HOST_STAGE_ROOT "$host_stage_root"
remote_run_id=${run_id:-fixture-status}
host_stage=$host_stage_root/$remote_run_id
guest_root="C:\\ProgramData\\RVBox\\test-runs\\$remote_run_id"
remote() {
# All values below are constrained words before becoming remote shell
# arguments. Password contents are never transmitted or printed; only the
# approved host-local password-file path is passed to VBoxManage.
remote_endpoint=${endpoint:--}
remote_script=/home/cabbage/.local/state/rvbox-test-controller/$remote_run_id.sh
ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" \
"install -d -m 700 /home/cabbage/.local/state/rvbox-test-controller && cat > '$remote_script' && chmod 700 '$remote_script'" <<'REMOTE'
set -eu
trap 'rm -f "$0"' EXIT
action=$1
vm=$2
expected_vm_uuid=$3
snapshot=$4
expected_snapshot_uuid=$5
guest_user=$6
password_file=$7
host_stage=$8
guest_root=$9
shift 9
endpoint=$1
provisioner_user=$2
provisioner_password_file=$3
[ "$endpoint" = - ] && endpoint=
fail() { printf '%s\n' "remote test-host: $*" >&2; exit 2; }
lease_root=$(dirname "$host_stage")/.rvbox-windows-vm-lease
lease_owner=$lease_root/run-id
run_id=$(basename "$host_stage")
acquire_lease() {
install -d -m 700 "$(dirname "$lease_root")"
if mkdir "$lease_root" 2>/dev/null; then
umask 077
printf '%s\n' "$run_id" >"$lease_owner"
return 0
fi
[ -f "$lease_owner" ] || fail "Windows VM lease is malformed: $lease_root"
owner=$(cat "$lease_owner")
[ "$owner" = "$run_id" ] || fail "Windows VM is leased by run $owner"
}
require_lease() {
[ -f "$lease_owner" ] || fail "Windows VM lease is missing"
owner=$(cat "$lease_owner")
[ "$owner" = "$run_id" ] || fail "Windows VM is leased by run $owner"
}
release_lease() {
require_lease
rm "$lease_owner"
rmdir "$lease_root"
}
step() {
install -d -m 700 "$host_stage"
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >>"$host_stage/controller.steps"
}
vm_field() {
VBoxManage showvminfo "$vm" --machinereadable | sed -n "s/^$1=\"\([^\"]*\)\"/\1/p" | head -n 1
}
assert_identity() {
actual_vm_uuid=$(vm_field UUID)
[ "$actual_vm_uuid" = "$expected_vm_uuid" ] || fail "VM UUID mismatch"
actual_snapshot_uuid=$(VBoxManage snapshot "$vm" list --machinereadable | sed -n 's/^CurrentSnapshotUUID="\([^"]*\)"/\1/p')
[ "$actual_snapshot_uuid" = "$expected_snapshot_uuid" ] || fail "current snapshot UUID mismatch"
}
state() { vm_field VMState; }
guest_run() {
# This VirtualBox build has no --wait-exit. It can return 33 after a
# successful guest process, so each caller must emit RVBOX_GUEST_OK only
# after its own assertion succeeds. Never treat the VBoxManage exit code
# alone as a guest-command result.
output=$(VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
run "$@" </dev/null 2>&1) || true
printf '%s\n' "$output"
printf '%s\n' "$output" | tr -d '\r' | grep -qx 'RVBOX_GUEST_OK'
}
provisioner_run() {
# The clean baseline deliberately has no RVBox service. Guest Control's
# normal test account has a filtered UAC token, so only the fixture-only
# full-token administrator may perform the first machine-wide install.
[ -n "$provisioner_user" ] || fail "install requires RVBOX_TEST_PROVISIONER_USER"
[ -n "$provisioner_password_file" ] || fail "install requires RVBOX_TEST_PROVISIONER_PASSWORD_FILE"
output=$(VBoxManage guestcontrol "$vm" --username "$provisioner_user" --passwordfile "$provisioner_password_file" \
run "$@" </dev/null 2>&1) || true
printf '%s\n' "$output"
printf '%s\n' "$output" | tr -d '\r' | grep -qx 'RVBOX_GUEST_OK'
}
assert_provisioner_elevated() {
# This fixture is en-US. Check the mandatory label before allowing any
# machine-wide mutation, so an accidentally filtered automation account
# fails closed instead of silently weakening the test contract.
provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c 'whoami /groups | findstr /c:"High Mandatory Level" >NUL && echo RVBOX_GUEST_OK' >/dev/null || \
fail "fixture provisioner is not a full high-integrity administrator"
}
assert_clean_guest() {
# A missing service is the authoritative clean-baseline condition. The
# test service name is unique, so do not delete or alter any other service.
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c 'sc.exe query RVBoxClient >NUL 2>&1 & if errorlevel 1060 (echo RVBOX_GUEST_OK) else exit /b 1' >/dev/null || \
fail "reset baseline is not clean: RVBoxClient is already installed"
}
assert_staged_guest() {
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "if exist \"$guest_root\\rvbox.exe\" if exist \"$guest_root\\client.toml\" echo RVBOX_GUEST_OK" >/dev/null || \
fail "staged guest bundle is missing rvbox.exe or client.toml"
}
assert_provisioner_absent() {
# A second logged-on Administrator could become an additional WTS active
# candidate and invalidate ACTIVE_* selection tests. Do not guess which
# account the supervisor would choose: fail before dispatch and reset.
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "query user | findstr /i /c:\"$provisioner_user\" >NUL & if errorlevel 1 echo RVBOX_GUEST_OK" >/dev/null || \
fail "fixture provisioner remains logged on; reset before active-session tests"
}
wait_guest_additions() {
attempt=0
while [ "$attempt" -lt 60 ]; do
properties=$(VBoxManage guestproperty enumerate "$vm" 2>/dev/null || true)
if printf '%s\n' "$properties" | grep -q '/VirtualBox/GuestAdd/Version' && \
printf '%s\n' "$properties" | grep -q '/VirtualBox/GuestInfo/OS/Release'; then
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
fail "Guest Additions did not publish version and Windows OS-release properties"
}
wait_service() {
wanted=$1
attempt=0
while [ "$attempt" -lt 30 ]; do
if guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "sc.exe query RVBoxClient | findstr /c:\"$wanted\" >NUL && echo RVBOX_GUEST_OK" >/dev/null 2>&1; then
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
fail "RVBoxClient did not reach $wanted"
}
case "$action" in
prepare-stage)
assert_identity
require_lease
[ "$(state)" = running ] || fail "stage requires a running prepared VM"
install -d -m 700 "$host_stage"
;;
status)
assert_identity
printf 'vm=%s uuid=%s snapshot=%s state=%s\n' "$vm" "$expected_vm_uuid" "$snapshot" "$(state)"
;;
prepare)
assert_identity
acquire_lease
step prepare-lease-acquired
[ "$(state)" = poweroff ] || fail "prepare requires a powered-off VM; use stop or reset first"
VBoxManage snapshot "$vm" restore "$snapshot" >/dev/null
step prepare-snapshot-restored
assert_identity
VBoxManage startvm "$vm" --type headless >/dev/null
step prepare-vm-started
wait_guest_additions
step prepare-guest-additions-ready
assert_clean_guest
step prepare-clean-baseline-verified
;;
probe-identity)
assert_identity
require_lease
[ "$(state)" = running ] || fail "identity probe requires a running prepared VM"
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c 'whoami /groups & query user & echo RVBOX_GUEST_OK' >/dev/null
;;
stage)
assert_identity
require_lease
[ "$(state)" = running ] || fail "stage requires a running prepared VM"
[ -f "$host_stage/rvbox.exe" ] && [ -f "$host_stage/client.toml" ] || fail "host bundle is incomplete"
;;
stage-create-root)
assert_identity
require_lease
[ "$(state)" = running ] || fail "stage requires a running prepared VM"
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "if not exist \"$guest_root\" mkdir \"$guest_root\" & echo RVBOX_GUEST_OK" >/dev/null
;;
stage-copy-exe)
assert_identity
require_lease
VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
copyto "$host_stage/rvbox.exe" "$guest_root\\rvbox.exe" </dev/null
;;
stage-copy-config)
assert_identity
require_lease
VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
copyto "$host_stage/client.toml" "$guest_root\\client.toml" </dev/null
;;
stage-copy-ca)
assert_identity
require_lease
[ -f "$host_stage/ca.pem" ] || fail "host bundle has no ca.pem"
VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
copyto "$host_stage/ca.pem" "$guest_root\\ca.pem" </dev/null
;;
install)
assert_identity
require_lease
[ "$(state)" = running ] || fail "install requires a running prepared VM"
assert_clean_guest
assert_staged_guest
assert_provisioner_elevated
# Guest Control cannot reliably wait for GUI-subsystem rvbox.exe. It
# may report a non-zero wrapper result after the process has started,
# therefore SCM state is the completion proof for this exact install.
VBoxManage guestcontrol "$vm" --username "$provisioner_user" --passwordfile "$provisioner_password_file" \
run --exe "$guest_root\\rvbox.exe" --unquoted-args -- \
--install-service --config "$guest_root\\client.toml" </dev/null >/dev/null 2>&1 || true
wait_service RUNNING
assert_provisioner_absent
step install-scm-service-running
printf 'service=RVBoxClient state=RUNNING install=clean-baseline\n'
;;
run)
assert_identity
require_lease
[ "$(state)" = running ] || fail "run requires a running prepared VM"
assert_staged_guest
assert_provisioner_elevated
if [ -n "$endpoint" ]; then
endpoint_host=${endpoint%:*}
endpoint_port=${endpoint##*:}
guest_run --exe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --wait-stdout --wait-stderr --unquoted-args -- \
-NoProfile -NonInteractive -Command "if (-not (Test-NetConnection -ComputerName '$endpoint_host' -Port $endpoint_port -InformationLevel Quiet)) { exit 1 }; Write-Output RVBOX_GUEST_OK" >/dev/null
fi
image="\\\"$guest_root\\rvbox.exe\\\" --service --config \\\"$guest_root\\client.toml\\\""
provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "sc.exe query RVBoxClient >NUL 2>&1 && echo RVBOX_GUEST_OK" >/dev/null || \
fail "RVBoxClient is not installed; run install from the clean baseline first"
provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "sc.exe config RVBoxClient binPath= \"$image\" start= demand >NUL 2>&1 && echo RVBOX_GUEST_OK" >/dev/null || \
fail "fixture provisioner could not change RVBoxClient configuration"
provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c '(sc.exe start RVBoxClient >NUL 2>&1 || sc.exe query RVBoxClient | findstr /c:"RUNNING" >NUL) && echo RVBOX_GUEST_OK' >/dev/null || \
fail "fixture provisioner could not start RVBoxClient"
wait_service RUNNING
printf 'service=RVBoxClient state=RUNNING\n'
;;
collect)
assert_identity
require_lease
install -d -m 700 "$host_stage/artifacts"
if [ "$(state)" = running ]; then
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c "sc.exe queryex RVBoxClient > \"$guest_root\\service-status.txt\" 2>&1 & echo RVBOX_GUEST_OK" >/dev/null || true
VBoxManage guestcontrol "$vm" --username "$guest_user" --passwordfile "$password_file" \
copyfrom "$guest_root" "$host_stage/artifacts" --recursive </dev/null >/dev/null 2>&1 || true
fi
printf 'collected host_stage=%s/artifacts\n' "$host_stage"
;;
stop)
assert_identity
require_lease
if [ "$(state)" = running ]; then
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
/d /s /c 'sc.exe stop RVBoxClient >NUL 2>&1 || exit /b 0 & echo RVBOX_GUEST_OK' >/dev/null || true
VBoxManage controlvm "$vm" acpipowerbutton >/dev/null
attempt=0
while [ "$attempt" -lt 60 ]; do
[ "$(state)" = poweroff ] && break
attempt=$((attempt + 1))
sleep 1
done
[ "$(state)" = poweroff ] || fail "guest did not power off after ACPI request"
fi
printf 'stopped vm=%s\n' "$vm"
;;
reset)
assert_identity
require_lease
if [ "$(state)" = running ]; then
VBoxManage controlvm "$vm" acpipowerbutton >/dev/null
attempt=0
while [ "$attempt" -lt 60 ]; do
[ "$(state)" = poweroff ] && break
attempt=$((attempt + 1))
sleep 1
done
[ "$(state)" = poweroff ] || fail "guest did not power off before reset"
fi
VBoxManage snapshot "$vm" restore "$snapshot" >/dev/null
assert_identity
release_lease
printf 'reset vm=%s snapshot=%s\n' "$vm" "$snapshot"
;;
recover)
assert_identity
if [ -f "$lease_owner" ]; then
printf 'recoverable vm=%s state=%s stage=%s lease_owner=%s\n' "$vm" "$(state)" "$host_stage" "$(cat "$lease_owner")"
else
printf 'recoverable vm=%s state=%s stage=%s lease_owner=none\n' "$vm" "$(state)" "$host_stage"
fi
;;
esac
REMOTE
ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" sh "$remote_script" \
"$1" "$RVBOX_TEST_VBOX_VM" "$RVBOX_TEST_VBOX_VM_UUID" \
"$RVBOX_TEST_VBOX_SNAPSHOT" "$RVBOX_TEST_VBOX_SNAPSHOT_UUID" \
"$RVBOX_TEST_GUEST_USER" "$RVBOX_TEST_GUEST_PASSWORD_FILE" \
"$host_stage" "$guest_root" "$remote_endpoint" \
"$provisioner_user" "$provisioner_password_file" </dev/null
}
case $action in
prepare)
remote prepare
printf 'prepared vm=%s stage=%s\n' "$RVBOX_TEST_VBOX_VM" "$host_stage"
;;
stage)
remote prepare-stage
scp -q "$bundle/rvbox.exe" "$bundle/client.toml" "$RVBOX_TEST_VBOX_HOST:$host_stage/"
if [ -f "$bundle/ca.pem" ]; then scp -q "$bundle/ca.pem" "$RVBOX_TEST_VBOX_HOST:$host_stage/"; fi
remote stage
remote stage-create-root
remote stage-copy-exe
remote stage-copy-config
if [ -f "$bundle/ca.pem" ]; then remote stage-copy-ca; fi
printf 'staged guest_root=%s\n' "$guest_root"
;;
collect)
remote collect
local_artifacts=$run_root/$run_id/artifacts
mkdir -p "$local_artifacts"
scp -q -r "$RVBOX_TEST_VBOX_HOST:$host_stage/artifacts/." "$local_artifacts/" 2>/dev/null || true
printf 'artifacts=%s\n' "$local_artifacts"
;;
*) remote "$action" ;;
esac