test: add reproducible Windows VM bundle harness

This commit is contained in:
2026-09-09 06:44:54 +00:00
parent 2abf0bf19b
commit a9aec8d9a8
7 changed files with 685 additions and 55 deletions
+6
View File
@@ -4,6 +4,7 @@ COMPOSE := docker compose -f deploy/compose.yaml
GO_PACKAGES := ./...
.PHONY: generate fmt lint test test-race test-coverage test-integration test-e2e test-status build verify \
build-windows-test-bundle \
_toolchain-generate _toolchain-fmt _toolchain-lint _toolchain-test _toolchain-test-race \
_toolchain-test-coverage _toolchain-build _toolchain-verify
@@ -45,6 +46,11 @@ test-status:
@test -n "$(RUN_ID)" || (echo "RUN_ID is required" >&2; exit 2)
./scripts/test-env status --run-id "$(RUN_ID)"
build-windows-test-bundle:
@test -n "$(RUN_ID)" || (echo "RUN_ID is required" >&2; exit 2)
@test -n "$(WINDOWS_TEST_CONFIG)" || (echo "WINDOWS_TEST_CONFIG is required" >&2; exit 2)
./scripts/windows/build-test-bundle --run-id "$(RUN_ID)" --config "$(WINDOWS_TEST_CONFIG)"
_toolchain-generate:
buf generate
+46 -19
View File
@@ -190,7 +190,9 @@ scripts/test-unit [--package PATTERN] [--run REGEXP] [--race]
scripts/test-integration [--suite NAME|all] [--run-id ID] [--resume]
scripts/test-e2e [--scenario NAME|all] [--run-id ID] [--resume]
scripts/test-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge|gc ...
scripts/windows/test-host.ps1 Prepare|Status|Run|Collect|Stop|Reset
scripts/build build|verify (pinned toolchain; host-safe)
scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE]
scripts/windows/test-host status|prepare|stage|run|collect|stop|reset|recover
```
Scripts are thin, reviewed orchestration wrappers. `scripts/test-unit` invokes
@@ -199,6 +201,13 @@ commands call a shared Go harness under `test/harness` so manifest parsing,
timeouts, process control, reporting, and cleanup logic are not reimplemented in
shell and PowerShell. The harness itself is built in the toolchain container;
no Go/Buf/protoc/SQLite SDK or package manager is installed on the host.
`scripts/build` is the corresponding host-safe wrapper around the established
containerized `make build`/`make verify` targets. A native run uses
`scripts/windows/build-test-bundle` to make one non-secret bundle below its
exact `.test-runs/<run-id>` directory, with source-commit and SHA-256 manifest;
it refuses replacement rather than overwriting an earlier bundle. This is not a
release publisher: signing, version resources, and public checksum publication
remain Phase 8 gates.
The first integration/E2E invocation creates a filesystem-safe random run ID,
or validates an explicitly supplied one, and records
@@ -257,12 +266,14 @@ copies prior mutable test state.
The Linux controller exposes bounded fault controls for frame drop/delay,
listener interruption, process termination at named durability checkpoints,
filesystem quota/error simulation, and monotonic/wall-clock advancement in the
deterministic peer. Native Windows `test-host.ps1` validates administrator state,
OS build, UAC/policy fixture, active sessions, service identity, and test-root
ownership before running. It uses a test-specific ProgramData root and an
exclusive host lease; production-name SCM/Run-key installation tests run only in
a resettable VM snapshot. Passwords and VM access credentials come from the CI
secret store or interactive prompt, never arguments persisted in the manifest.
deterministic peer. Native Windows `test-host` validates administrator state, OS
build, UAC/policy fixture, active sessions, service identity, VM/snapshot UUIDs,
and test-root ownership before running. It is a POSIX controller which uses SSH
to run `VBoxManage` on the Linux fixture host; it uses a test-specific
ProgramData root and an exclusive remote host lease. Production-name SCM/Run-key
installation tests run only in a resettable VM snapshot. Passwords and VM access
credentials come from the CI secret store or interactive prompt, never arguments
persisted in the manifest.
The E2E controller owns the canonical run manifest on Linux and passes the same
run ID plus a one-run configuration bundle to the preconfigured Windows runner.
@@ -761,7 +772,7 @@ supported Windows 10 or Server 2016 baseline; it need not be the everyday runner
A physical Windows machine is optional. It is useful for an additional real
display/audio/DDC command smoke, but RVBox only guarantees correct token/session/
process execution—not success of arbitrary vendor hardware APIs—so physical
hardware is not a release blocker. `test-host.ps1 Prepare` must inventory the
hardware is not a release blocker. `test-host prepare` must inventory the
host against this checklist and refuse destructive suites unless the machine is
explicitly marked disposable/resettable and the clean snapshot identity is
recorded.
@@ -787,7 +798,7 @@ mirror; update both documents when the fixture is reprovisioned.
| VM name / UUID | `rvbox-win10-test` / `6cdc114f-71e5-4167-a394-e922e14e6f5c` |
| VM group / config | `/RVBox/Tests`; `/home/cabbage/VirtualBox VMs/RVBox/Tests/rvbox-win10-test/rvbox-win10-test.vbox` |
| Guest OS | Windows 10 Pro 22H2, build `19045.2006`, en-US, BIOS boot |
| Guest Additions | `7.2.16r174877`; Guest Control readiness requires `GuestAdditionsRunLevel=3` |
| Guest Additions | `7.2.16r174877`; readiness requires published Guest Additions version and Windows OS-release properties (this build does not publish a RunLevel property) |
| Resources | 2 vCPU, 4096 MiB RAM, 64 MiB VRAM, `VBoxSVGA`, 3D acceleration disabled, 40 GiB dynamically allocated VDI |
| Disk / source media | `/home/cabbage/VMs/rvbox-win10-test.vdi`; source ISO `/media/Data2/Downloaded/Win10_22H2_English_x64.iso` (Windows image index 6) |
| Devices | Audio (`none`), playback/capture, USB (OHCI/EHCI/xHCI), clipboard/file transfer, drag-and-drop, and shared folders disabled; Intel 82540EM NIC, cable connected |
@@ -802,10 +813,23 @@ The guest password, SSH key, and any host account secret are test secrets. Keep
them in the operator/CI secret store or a mode-600 password file outside the
repository; never put them in this plan, a command-line argument, a run
manifest, or collected logs. `VBoxManage guestcontrol` supports
`--passwordfile`; prefer that option over an inline password. Every operator or
agent must set `RVBOX_TEST_GUEST_PASSWORD_FILE` to the absolute path of that
host-side file before a native run. The account name and VM metadata above are
not credentials.
`--passwordfile`; prefer that option over an inline password. The documented
fixture's host-local password-file path is a controller default and may be
overridden with `RVBOX_TEST_GUEST_PASSWORD_FILE`; the password value is never a
default or repository value. The account name and VM metadata above are not
credentials.
Guest Control uses this split-token administrator's medium-integrity token; its
Administrators SID is deny-only. Do not bypass UAC to create the service during
automation. A one-time interactive elevated fixture bootstrap must install the
test `RVBoxClient` service in the reset baseline and grant `rvboxtest` only
query/change-config/start/stop service access plus write access to its dedicated
test subtree. Each native run then changes that bootstrapped service's explicit
image/configuration and starts it through SCM. This is fixture administration,
not a second product worker/elevation broker or a Task Scheduler dependency.
Keep the exact service SDDL and test-subtree ACL with the fixture record before
taking its replacement baseline snapshot. The production installer/UAC flow is
tested separately through an interactive elevated lane.
For this provisioned lane, the approved host-only credential-file location is
`/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must
@@ -853,15 +877,18 @@ ssh "$RVBOX_TEST_VBOX_HOST" \
ssh "$RVBOX_TEST_VBOX_HOST" \
'VBoxManage startvm "rvbox-win10-test" --type headless'
# Poll these properties before a test; GuestAdditionsRunLevel=3 is required
# for Guest Control, and LoggedInUsers/NoLoggedInUsers selects the session case.
# Poll these properties before a test. This fixture requires Guest Additions
# version plus Windows OS-release properties; LoggedInUsers/NoLoggedInUsers
# selects the session case.
ssh "$RVBOX_TEST_VBOX_HOST" \
'VBoxManage guestproperty enumerate "rvbox-win10-test"'
```
The harness must wait for the VM to report `running`, then poll Guest
Properties until Guest Additions is ready and the requested login fixture is
observed. NAT address `10.0.2.15` was observed during provisioning but is DHCP
Properties until the Guest Additions version and Windows OS-release properties
are present and the requested login fixture is observed. Do not require a
`GuestAdditionsRunLevel` value: VirtualBox Guest Additions `7.2.16r174877` on
this fixture does not publish it. NAT address `10.0.2.15` was observed during provisioning but is DHCP
state, not an identity or a stable endpoint; use Guest Control for management
and discover any test networking separately. A failed readiness poll is a
stopped-resumable run, not permission to start a second VM with the same name.
@@ -964,7 +991,7 @@ scripts/
test-integration # resumable component-suite entry point
test-e2e # resumable production-shaped scenario entry point
test-env # doctor/recover/reuse/cleanup by exact run ID
windows/test-host.ps1 # native Windows host lifecycle adapter
windows/test-host # POSIX controller for the native Windows VM lifecycle
test/
coverage.toml # requirement-to-case inventory with stable IDs
harness/ # shared manifest, journal, orchestration, and reporting
@@ -2196,7 +2223,7 @@ The native `windows-supervisor` and `windows-service-tray` integration suites us
real Windows subprocesses and APIs for both shells, CWD/environment overlays,
concurrent output without newlines, stdin ordering/close, Job tree termination,
script materialization/cleanup, service shutdown interruption, and every token/
session context. They run through `scripts/windows/test-host.ps1` under the
session context. They run through `scripts/windows/test-host` under the
exclusive host lease and journal every machine-wide mutation for reset.
Add a table-driven crash suite for every acceptance, script-upload, launch,
+55 -19
View File
@@ -24,7 +24,7 @@ observation.
| Snapshot folder | `/home/cabbage/VirtualBox VMs/RVBox/Tests/rvbox-win10-test/Snapshots` |
| Guest OS | Windows 10 Pro 22H2, build `19045.2006`, en-US, BIOS boot |
| Guest account | Local `rvboxtest`; split-token local administrator; console session 1 was observed during provisioning |
| Guest Additions | `7.2.16r174877`; Guest Control readiness requires `GuestAdditionsRunLevel=3` |
| Guest Additions | `7.2.16r174877`; readiness requires published Guest Additions version and Windows OS-release properties (this build does not publish a RunLevel property) |
| Last observed state | `poweroff`; current snapshot `baseline-disk-first` |
The Guest Control credential is test-only. The account name is safe to record,
@@ -108,35 +108,71 @@ the stale unregistered `win10_dev` configuration (its disk is missing).
## Harness contract
The checked-in PowerShell adapter is [`scripts/windows/test-host.ps1`](../scripts/windows/test-host.ps1).
Its actions are `Prepare`, `Status`, `Run`, `Collect`, `Stop`, and `Reset`.
The adapter takes identity and credentials only from the host environment:
The canonical adapter is the POSIX controller script
[`scripts/windows/test-host`](../scripts/windows/test-host). It runs from the
Linux controller and invokes `VBoxManage` only through SSH on Helium; the
fixture host is Arch Linux and does not provide PowerShell. Its actions are
`status`, `prepare`, `stage`, `run`, `collect`, `stop`, `reset`, and `recover`.
The legacy [`test-host.ps1`](../scripts/windows/test-host.ps1) is retained only
as a reference for a future Windows-hosted fixture and is not the Helium lane.
```powershell
$env:RVBOX_WINDOWS_VM = 'rvbox-win10-test'
$env:RVBOX_WINDOWS_BASELINE_SNAPSHOT = 'baseline-disk-first'
$env:RVBOX_WINDOWS_GUEST_USER = 'rvboxtest'
$env:RVBOX_WINDOWS_GUEST_PASSWORD_FILE = 'C:\secure\rvbox-win10-test.password'
```
The implementation plan also uses the equivalent `RVBOX_TEST_*` names for
controllers that run the lifecycle over SSH:
The adapter takes identity and credentials only from its environment:
```sh
export RVBOX_TEST_VBOX_HOST=helium-remote
export RVBOX_TEST_VBOX_VM=rvbox-win10-test
export RVBOX_TEST_VBOX_VM_UUID=6cdc114f-71e5-4167-a394-e922e14e6f5c
export RVBOX_TEST_VBOX_SNAPSHOT=baseline-disk-first
export RVBOX_TEST_VBOX_SNAPSHOT_UUID=9430a9a4-754a-4b22-beaa-8dfd90043f5b
export RVBOX_TEST_GUEST_USER=rvboxtest
export RVBOX_TEST_GUEST_PASSWORD_FILE=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password
```
Those values are the controller defaults for this one documented fixture, so a
normal Helium run does not need to export them. They remain overrideable for a
separately recorded fixture. The default contains only the host-local password
*file path*, never the password value.
The provisioned lane's backend is SSH plus `VBoxManage` plus Guest Control; it
does not require WinRM, OpenSSH inside Windows, or a stable guest IP. Guest
commands must pass explicit paths and argument vectors. With this VirtualBox
build, `--wait-stdout` and `--wait-stderr` are supported but `--wait-exit` is
not; use `closeprocess` when a process wait cannot complete. For `cmd.exe`
payloads, include `--unquoted-args` so Windows backslashes and the single `/c`
payload are preserved.
does not require WinRM, OpenSSH inside Windows, or a stable guest IP. The
adapter first validates the VM and snapshot UUIDs, acquires its exclusive lease
on Helium, and writes a non-secret step report to the run directory. Guest
commands use exact console-safe executable paths and argument vectors. With
this VirtualBox build, `--wait-stdout` and `--wait-stderr` are supported but
`--wait-exit` is not; use `closeprocess` when a process wait cannot complete.
For `cmd.exe` payloads, include `--unquoted-args` so Windows backslashes and
the single `/c` payload are preserved.
`stage` accepts one versioned non-secret test bundle and copies it first to an
exact host staging directory, then to
`C:\\ProgramData\\RVBox\\test-runs\\<run-id>`. `run` never directly executes the
GUI-subsystem `rvbox.exe` through Guest Control. It uses `sc.exe` and other
console-safe management tools to start/query/stop the installed RVBox service,
then checks the service's real health endpoint, named-pipe response, durable
state, and agent-server results. Before a WSS scenario it performs a bounded
guest-to-nginx connectivity and CA-trust probe. The resulting service and
artifact paths are recorded in the run report and reclaimed by the snapshot
reset rather than broad guest deletion.
### Required one-time service bootstrap
Guest Control launches `rvboxtest` with its filtered, medium-integrity UAC
token: the Administrators SID is deny-only. The harness must not bypass UAC to
create services. Before native service tests can run, an operator must use a
trusted interactive elevated session to install one test-only `RVBoxClient`
service in the baseline and grant only `rvboxtest` the service rights required
by the harness: query status/configuration, change its image/configuration,
start, and stop. The test account also needs write access only to the dedicated
`C:\\ProgramData\\RVBox\\test-runs` subtree; SYSTEM retains ownership of normal
RVBox state and logs. Record the resulting service SDDL and subtree ACL in this
document before taking a new reset snapshot.
Each run then stages an exact bundle, changes the bootstrapped service image to
that run's explicit `--service --config` command line, and starts it through
SCM. The one-time bootstrap is fixture administration, not a second RVBox
process, runtime elevation broker, or Task Scheduler mechanism. Native tests
for the production installer/UAC flow remain a separately interactive test;
they cannot be automated through this filtered Guest Control token.
## Scope and known limitations
+70 -17
View File
@@ -15,6 +15,30 @@ Run focused unit tests with:
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
```
Build all supported binaries without installing `make` or Go on the host:
```sh
scripts/build build
```
For a native Windows run, create the immutable per-run test bundle with the
pinned toolchain. Supply a test-specific config whose endpoint and CA path are
valid for that run; the command refuses to replace an existing bundle.
```sh
scripts/windows/build-test-bundle \
--run-id windows-smoke \
--config .test-runs/windows-smoke/client.toml \
--ca .test-runs/windows-smoke/ca.pem
scripts/windows/test-host prepare --run-id windows-smoke
scripts/windows/test-host stage --run-id windows-smoke \
--bundle .test-runs/windows-smoke/windows-bundle
```
The bundle manifest records the source commit and SHA-256 of every bundled
file. It is a test artifact, not a signed release package; release signing,
version resources, and publication are Phase 8 gates.
The integration harness provides the Phase 0 `sample` suite, the incremental
Phase 2 `store` suite, and the incremental Phase 3 `server-session` suite.
The resumable E2E harness adds `smoke`, `script`, `recovery`, and `all`
@@ -97,20 +121,49 @@ than treating it as a stable endpoint. VRDE is enabled at
`192.168.50.162:3389` for diagnostics, while native Windows RDP is disabled in
the baseline.
The exact headless VirtualBox/Guest Control adapter is
`scripts/windows/test-host.ps1`; it takes the VM name, baseline snapshot, and
guest identity/password-file only from host environment variables, acquires an
exclusive lease, and never writes secrets to the repository. Set
`RVBOX_WINDOWS_GUEST_PASSWORD_FILE` to a mode-600 file outside the repository;
the adapter passes it with VirtualBox `--passwordfile` and never accepts an
inline password. Use `Prepare`, `Run`,
`Collect`, `Stop`, and `Reset` in that order for a native run. The VM is the
minimum smoke lane, so deferred native multi-session/ambiguous-session, Server
Core, and older-build entries remain explicitly blocked until their own
fixtures exist. A previous Windows 10 fixture probe found that VirtualBox Guest
Control 7.2.16 rejects the GUI-subsystem `rvbox.exe` as a directly runnable
guest executable; wrapping it through `cmd.exe` exited the RVBox process but
left the Guest Control wrapper waiting. This remains an adapter completion-path
limitation: native runtime coverage needs a service-driven or
console-compatible guest runner. Wine or a protocol stub is not treated as
equivalent coverage.
The canonical headless VirtualBox/Guest Control adapter is
`scripts/windows/test-host`. It is a POSIX controller script because the
fixture's VirtualBox host is Arch Linux and has no PowerShell runtime. The
controller connects to Helium over SSH; `VBoxManage` and the host-only password
file never need to exist on the Linux development controller. It takes the VM
identity, baseline snapshot, guest identity, and password-file only from host
environment variables, acquires an exclusive remote lease, and never writes
secrets to the repository, run manifest, or command line. Set
`RVBOX_TEST_GUEST_PASSWORD_FILE` to the mode-600 host-side file; the adapter
passes it only as VirtualBox `--passwordfile`.
The provisioned fixture's non-secret identity values, including the host-local
password-file path, are safe defaults in that script and may be overridden for
another documented fixture; the password itself is never embedded.
The native lifecycle is `status`, `prepare`, `stage`, `run`, `collect`,
`stop`, and `reset`. `prepare` verifies the VM and snapshot UUIDs, restores the
baseline, starts headless, waits for Guest Additions, and records the selected
login fixture. `stage` copies a versioned non-secret test bundle through a
run-specific host directory to a run-specific guest directory. `run` starts
the real RVBox SCM service and observes it through SCM, the local health/tray
pipe, durable artifacts, and the test agent endpoint; it must not invoke the
GUI-subsystem `rvbox.exe` directly through Guest Control. `collect` obtains
only bounded/redacted artifacts, and `reset` restores the exact baseline and
leaves the VM powered off.
This service-driven protocol is required because VirtualBox Guest Control
7.2.16 does not reliably complete a direct GUI-subsystem `rvbox.exe` run;
wrapping it in `cmd.exe` can leave the Guest Control wrapper waiting. Guest
Control is therefore limited to console-safe setup tools (`sc.exe`, `whoami`,
`query`, bounded file operations) and artifact collection. A native E2E run
also performs a bounded guest-to-nginx HTTPS/TCP readiness probe before it
starts the service. Wine and protocol stubs are not equivalent Windows
coverage.
The fixture needs a one-time operator-approved service bootstrap before the
SCM smoke lane can run: Guest Control supplies the split-token administrator's
medium UAC token, so it cannot safely install services. The bootstrap installs
the test service in the reset baseline and grants the test account only the SCM
query/change-config/start/stop rights plus access to its dedicated test subtree.
Each run then reconfigures and starts that one service. This does not add a
product broker or use Task Scheduler; it is fixture setup. See
[testing-vm.md](testing-vm.md) for the exact boundary.
The VM is the minimum smoke lane, so native multi-session/ambiguous-session,
Server Core, and older-build entries remain explicitly blocked until their own
fixtures exist. Their pure selector tests remain mandatory.
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# Host-safe entry point for the pinned RVBox build toolchain.
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
target=${1:-build}
shift || true
case $target in
build|verify) ;;
*)
printf '%s\n' 'usage: scripts/build [build|verify]' >&2
exit 2
;;
esac
[ "$#" -eq 0 ] || {
printf '%s\n' 'scripts/build does not accept additional arguments' >&2
exit 2
}
cd "$repo_root"
exec docker compose -f deploy/compose.yaml run --rm toolchain make "$target"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/sh
# Build a deterministic, non-secret Windows test bundle for one harness run.
# It deliberately does not sign or publish artifacts; those are release gates.
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
usage() {
cat <<'EOF'
usage: scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] [--no-build]
Builds bin/rvbox.exe in the pinned Docker toolchain, then creates exactly:
.test-runs/ID/windows-bundle/{rvbox.exe,client.toml[,ca.pem],manifest.sha256}
The bundle is for the resettable native-test fixture only. The config must use
the target guest paths and the declared test nginx endpoint. Existing bundles
are refused rather than overwritten.
EOF
}
fail() { printf '%s\n' "build-test-bundle: $*" >&2; exit 2; }
run_id=
config=
ca=
build=yes
while [ "$#" -gt 0 ]; do
case $1 in
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
--config) [ "$#" -ge 2 ] || fail "--config needs a file"; config=$2; shift 2 ;;
--ca) [ "$#" -ge 2 ] || fail "--ca needs a PEM file"; ca=$2; shift 2 ;;
--no-build) build=no; shift ;;
--help|-h) usage; exit 0 ;;
*) fail "unknown argument $1" ;;
esac
done
case $run_id in
[a-z0-9]* ) ;;
* ) fail "run ID must start with lowercase alphanumeric" ;;
esac
case $run_id in
''|*[!a-z0-9-]*|????????????????????????????????????????????????????????????????*)
fail "run ID must match [a-z0-9][a-z0-9-]{0,63}"
;;
esac
[ -f "$config" ] && [ ! -L "$config" ] || fail "--config must be a regular non-symlink file"
if [ -n "$ca" ]; then [ -f "$ca" ] && [ ! -L "$ca" ] || fail "--ca must be a regular non-symlink file"; fi
if [ "$build" = yes ]; then "$repo_root/scripts/build" build; fi
binary=$repo_root/bin/rvbox.exe
[ -f "$binary" ] && [ ! -L "$binary" ] || fail "expected regular Windows binary at bin/rvbox.exe"
bundle=$repo_root/.test-runs/$run_id/windows-bundle
mkdir -p "$repo_root/.test-runs/$run_id"
if ! mkdir "$bundle"; then
fail "refusing to overwrite existing bundle $bundle"
fi
trap 'rmdir "$bundle" 2>/dev/null || true' INT TERM HUP
install -m 700 "$binary" "$bundle/rvbox.exe"
install -m 600 "$config" "$bundle/client.toml"
if [ -n "$ca" ]; then install -m 600 "$ca" "$bundle/ca.pem"; fi
commit=$(git -C "$repo_root" rev-parse HEAD)
{
printf 'run_id=%s\n' "$run_id"
printf 'git_commit=%s\n' "$commit"
(cd "$bundle" && sha256sum rvbox.exe client.toml ${ca:+ca.pem})
} >"$bundle/manifest.sha256"
chmod 600 "$bundle/manifest.sha256"
trap - INT TERM HUP
printf 'bundle=%s\n' "$bundle"
+412
View File
@@ -0,0 +1,412 @@
#!/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
run create/update and start the 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_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|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-disk-first}"
: "${RVBOX_TEST_VBOX_SNAPSHOT_UUID:=9430a9a4-754a-4b22-beaa-8dfd90043f5b}"
: "${RVBOX_TEST_GUEST_USER:=rvboxtest}"
: "${RVBOX_TEST_GUEST_PASSWORD_FILE:=/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password}"
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"
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
[ "$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'
}
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
step prepare-bootstrap-complete
;;
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
;;
run)
assert_identity
require_lease
[ "$(state)" = running ] || fail "run requires a running prepared VM"
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\\\""
guest_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 fixture-bootstrapped or the test token lacks SCM query access"
guest_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 service does not grant the test token SERVICE_CHANGE_CONFIG"
guest_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 service did not accept SCM start"
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" </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