#!/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 (required by install; a fixture-only full-token administrator) 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}" : "${RVBOX_TEST_VBOX_SNAPSHOT_UUID:=5e79176a-3e56-4c5d-bb61-a405a6dcdd59}" : "${RVBOX_TEST_GUEST_USER:=rvboxtest}" : "${RVBOX_TEST_GUEST_PASSWORD_FILE:=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password}" 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 "$@" &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 "$@" &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 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 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 || true printf 'artifacts=%s\n' "$local_artifacts" ;; *) remote "$action" ;; esac