feat: add native Windows hierarchy test harness
This commit is contained in:
@@ -7,7 +7,7 @@ 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]
|
||||
usage: scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] [--no-build] [--native-fixture]
|
||||
|
||||
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}
|
||||
@@ -15,6 +15,10 @@ Builds bin/rvbox.exe in the pinned Docker toolchain, then creates exactly:
|
||||
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.
|
||||
|
||||
--native-fixture compiles the Windows executable with the rvbox_native_test
|
||||
tag. That non-release binary alone accepts --test-fail-contexts, used only to
|
||||
prove pre-launch elevation fallback order in the disposable VM.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -24,12 +28,14 @@ run_id=
|
||||
config=
|
||||
ca=
|
||||
build=yes
|
||||
native_fixture=no
|
||||
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 ;;
|
||||
--native-fixture) native_fixture=yes; shift ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) fail "unknown argument $1" ;;
|
||||
esac
|
||||
@@ -48,6 +54,15 @@ esac
|
||||
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
|
||||
if [ "$native_fixture" = yes ]; then
|
||||
# Keep the server/rvc artifacts produced by scripts/build, but replace only
|
||||
# the disposable test client with its explicitly tagged fixture build.
|
||||
# No host Go toolchain is used. Strip symbol/DWARF tables because this
|
||||
# disposable binary is transferred to the remote VM fixture; this does not
|
||||
# change executable behavior or the tagged test boundary.
|
||||
docker compose -f "$repo_root/deploy/compose.yaml" run --rm toolchain \
|
||||
env GOOS=windows GOARCH=amd64 go build -trimpath -tags rvbox_native_test -ldflags '-H=windowsgui -s -w' -o bin/rvbox.exe ./cmd/rvbox
|
||||
fi
|
||||
binary=$repo_root/bin/rvbox.exe
|
||||
[ -f "$binary" ] && [ ! -L "$binary" ] || fail "expected regular Windows binary at bin/rvbox.exe"
|
||||
|
||||
@@ -66,6 +81,7 @@ commit=$(git -C "$repo_root" rev-parse HEAD)
|
||||
{
|
||||
printf 'run_id=%s\n' "$run_id"
|
||||
printf 'git_commit=%s\n' "$commit"
|
||||
printf 'native_fixture=%s\n' "$native_fixture"
|
||||
(cd "$bundle" && sha256sum rvbox.exe client.toml ${ca:+ca.pem})
|
||||
} >"$bundle/manifest.sha256"
|
||||
chmod 600 "$bundle/manifest.sha256"
|
||||
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/bin/sh
|
||||
# Production-shaped Linux server/nginx -> native Windows client test lane.
|
||||
set -eu
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/windows/native-test run|recover|clean --run-id ID [options]
|
||||
|
||||
run options:
|
||||
--port PORT Current-controller TLS port (default RVBOX_NATIVE_PORT or 16899)
|
||||
--keep retain the stack/VM lease for inspection
|
||||
|
||||
recover reports the exact VM lease and Compose resources.
|
||||
clean stops only the matching stack and resets the matching VM run.
|
||||
--purge --yes also delete only the matching local and Helium run files
|
||||
|
||||
run uses a separately tagged disposable fixture binary to prove every Windows
|
||||
execution context through SCM, nginx WSS, the Linux server, and rvc. Release
|
||||
binaries reject the fixture-only pre-launch failure switch.
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() { printf '%s\n' "native-test: $*" >&2; exit 2; }
|
||||
|
||||
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; }
|
||||
shift
|
||||
case $action in run|recover|clean|--help|-h) ;; *) usage >&2; fail "unknown action $action" ;; esac
|
||||
[ "$action" != --help ] && [ "$action" != -h ] || { usage; exit 0; }
|
||||
|
||||
run_id=
|
||||
port=${RVBOX_NATIVE_PORT:-16899}
|
||||
keep=no
|
||||
purge=no
|
||||
yes=no
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case $1 in
|
||||
--run-id) [ "$#" -ge 2 ] || fail "--run-id needs a value"; run_id=$2; shift 2 ;;
|
||||
--port) [ "$#" -ge 2 ] || fail "--port needs a value"; port=$2; shift 2 ;;
|
||||
--keep) keep=yes; shift ;;
|
||||
--purge) purge=yes; shift ;;
|
||||
--yes) yes=yes; shift ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) fail "unknown argument $1" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$run_id" ] || fail "$action requires --run-id"
|
||||
safe_id "$run_id"
|
||||
case $port in *[!0-9]*|'') fail "--port must be an integer" ;; esac
|
||||
[ "$port" -ge 1024 ] && [ "$port" -le 65535 ] || fail "--port must be 1024..65535"
|
||||
[ "$purge" = no ] || [ "$action" = clean ] || fail "--purge is only valid with clean"
|
||||
[ "$yes" = no ] || [ "$action" = clean ] || fail "--yes is only valid with clean"
|
||||
[ "$purge" = no ] || [ "$yes" = yes ] || fail "--purge requires --yes"
|
||||
|
||||
: "${RVBOX_TEST_VBOX_HOST:=helium-remote}"
|
||||
case $RVBOX_TEST_VBOX_HOST in ''|*[!A-Za-z0-9._:@-]*) fail "RVBOX_TEST_VBOX_HOST contains unsupported characters" ;; esac
|
||||
endpoint_host=${RVBOX_NATIVE_ENDPOINT_HOST:-x1.xcel.me}
|
||||
case $endpoint_host in ''|*[!A-Za-z0-9.-]*) fail "RVBOX_NATIVE_ENDPOINT_HOST contains unsupported characters" ;; esac
|
||||
project=rvbox-native-$run_id
|
||||
run_root=$repo_root/.test-runs/$run_id
|
||||
fixture_dir=$run_root/native-windows
|
||||
client_id=native-$run_id
|
||||
client_config=$fixture_dir/client.toml
|
||||
server_config=$fixture_dir/server.toml
|
||||
vm_prepared=no
|
||||
|
||||
compose() {
|
||||
RVBOX_NATIVE_RUN_ID="$run_id" RVBOX_NATIVE_PORT="$port" \
|
||||
RVBOX_NATIVE_BIND=0.0.0.0 RVBOX_NATIVE_UID="$(id -u)" RVBOX_NATIVE_GID="$(id -g)" \
|
||||
RVBOX_NATIVE_RUNTIME_DIR="$fixture_dir" RVBOX_NATIVE_ENDPOINT_HOST="$endpoint_host" \
|
||||
docker compose -p "$project" -f "$repo_root/test/linux-server/compose.yaml" "$@"
|
||||
}
|
||||
|
||||
rvc() {
|
||||
compose exec -T server /opt/rvbox/rvc --socket /run/rvbox/server.sock "$@"
|
||||
}
|
||||
|
||||
prepare_files() {
|
||||
umask 077
|
||||
mkdir -p "$fixture_dir"
|
||||
[ ! -e "$server_config" ] || fail "refusing to overwrite existing $server_config"
|
||||
[ ! -e "$client_config" ] || fail "refusing to overwrite existing $client_config"
|
||||
printf '%s\n' '[server]' 'data_dir = "/state/data"' 'agent_listen = "0.0.0.0:6899"' 'control_socket = "/run/rvbox/server.sock"' '' '[observability]' 'listen = "0.0.0.0:6901"' >"$server_config"
|
||||
printf '%s\n' \
|
||||
'[client]' \
|
||||
"server_url = \"wss://$endpoint_host:$port/v1/agent\"" \
|
||||
'state_dir = "C:\\ProgramData\\RVBox\\test-state"' \
|
||||
"client_id = \"$client_id\"" \
|
||||
'daemon_cwd = "C:\\ProgramData\\RVBox\\test-work"' \
|
||||
'' '[tls]' \
|
||||
'# Empty intentionally exercises v1 matching-host self-signed TLS.' \
|
||||
'ca_file = ""' \
|
||||
"server_name = \"$endpoint_host\"" \
|
||||
'' '[observability]' \
|
||||
'listen = "127.0.0.1:6902"' \
|
||||
'log_file = "C:\\ProgramData\\RVBox\\test-logs\\rvbox.log"' >"$client_config"
|
||||
chmod 600 "$server_config" "$client_config"
|
||||
}
|
||||
|
||||
stage_stack() {
|
||||
# scripts/build intentionally execs its Docker command. Keep that process
|
||||
# replacement inside a subshell so this lifecycle controller continues.
|
||||
("$repo_root/scripts/build" build)
|
||||
install -d -m 700 "$fixture_dir/pki" "$fixture_dir/state/data" "$fixture_dir/state/control"
|
||||
compose --profile tools run --rm certgen
|
||||
compose up -d server nginx
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
if rvc stat >/dev/null 2>&1; then return 0; fi
|
||||
attempt=$((attempt + 1)); sleep 1
|
||||
done
|
||||
compose logs --tail=200
|
||||
fail "server control socket did not become ready"
|
||||
}
|
||||
|
||||
wait_client() {
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 45 ]; do
|
||||
state=$(rvc stat "$client_id" 2>/dev/null || true)
|
||||
if printf '%s\n' "$state" | grep -q "client $client_id connected=true"; then return 0; fi
|
||||
attempt=$((attempt + 1)); sleep 1
|
||||
done
|
||||
compose logs --tail=200
|
||||
fail "native Windows client did not connect through nginx WSS"
|
||||
}
|
||||
|
||||
assert_context() {
|
||||
label=$1
|
||||
elevated=$2
|
||||
want=$3
|
||||
if [ "$elevated" = yes ]; then
|
||||
issued=$(rvc run --background --shell cmd --elevated "$client_id" "echo RVBOX_NATIVE_$label" 2>&1) || fail "$label admission failed: $issued"
|
||||
else
|
||||
issued=$(rvc run --background --shell cmd "$client_id" "echo RVBOX_NATIVE_$label" 2>&1) || fail "$label admission failed: $issued"
|
||||
fi
|
||||
issue=$(printf '%s\n' "$issued" | awk 'NR == 1 { print $1 }')
|
||||
case $issue in ????????-????-7???-????-????????????) ;; *) fail "$label returned invalid issue UUID: $issued" ;; esac
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 45 ]; do
|
||||
result=$(rvc stat "$client_id" "$issue" 2>/dev/null || true)
|
||||
if printf '%s\n' "$result" | grep -q 'lifecycle=COMMAND_SUCCEEDED'; then
|
||||
printf '%s\n' "$result" | grep -q "windows_effective_context=$want" || fail "$label effective context mismatch: $result"
|
||||
printf '%s\n' "$result" >"$fixture_dir/$label.stat"
|
||||
printf 'passed %s issue=%s context=%s\n' "$label" "$issue" "$want"
|
||||
return 0
|
||||
fi
|
||||
case $result in *'lifecycle=COMMAND_FAILED'*|*'lifecycle=COMMAND_REJECTED'*|*'lifecycle=COMMAND_TERMINATED'*) fail "$label did not succeed: $result" ;; esac
|
||||
attempt=$((attempt + 1)); sleep 1
|
||||
done
|
||||
fail "$label did not reach terminal success"
|
||||
}
|
||||
|
||||
collect() {
|
||||
if [ "$vm_prepared" = yes ]; then
|
||||
"$repo_root/scripts/windows/test-host" collect --run-id "$run_id" || true
|
||||
fi
|
||||
if [ -d "$fixture_dir" ]; then
|
||||
compose logs --no-color --tail=500 >"$fixture_dir/server-proxy.log" 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
clean() {
|
||||
compose down --volumes --remove-orphans || true
|
||||
# A reset is the isolation boundary for the next run. Do not conceal a
|
||||
# failed shutdown/snapshot restore behind a successful-looking `clean`:
|
||||
# callers must repair or explicitly inspect the retained VM lease first.
|
||||
"$repo_root/scripts/windows/test-host" reset --run-id "$run_id"
|
||||
if [ "$purge" = yes ]; then
|
||||
[ -L "$run_root" ] && fail "refusing symlink run root $run_root"
|
||||
rm -rf "$run_root"
|
||||
fi
|
||||
}
|
||||
|
||||
case $action in
|
||||
recover)
|
||||
"$repo_root/scripts/windows/test-host" recover --run-id "$run_id"
|
||||
compose ps
|
||||
printf 'run_root=%s\n' "$run_root"
|
||||
;;
|
||||
clean)
|
||||
collect
|
||||
clean
|
||||
printf 'cleaned run_id=%s\n' "$run_id"
|
||||
;;
|
||||
run)
|
||||
trap 'status=$?; if [ "$status" -ne 0 ]; then collect; fi' EXIT
|
||||
[ ! -e "$run_root" ] || fail "refusing to reuse existing run root $run_root; inspect with recover or remove with clean --purge --yes"
|
||||
prepare_files
|
||||
stage_stack
|
||||
"$repo_root/scripts/windows/build-test-bundle" --native-fixture --run-id "$run_id" --config "$client_config"
|
||||
"$repo_root/scripts/windows/test-host" prepare --run-id "$run_id"
|
||||
vm_prepared=yes
|
||||
"$repo_root/scripts/windows/test-host" stage --run-id "$run_id" --bundle "$run_root/windows-bundle"
|
||||
"$repo_root/scripts/windows/test-host" install --run-id "$run_id"
|
||||
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --endpoint "$endpoint_host:$port"
|
||||
wait_client
|
||||
assert_context active-user no active-user
|
||||
assert_context active-user-elevated yes active-user-elevated
|
||||
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --fail-contexts ACTIVE_USER_ELEVATED
|
||||
wait_client
|
||||
assert_context active-system yes active-system
|
||||
"$repo_root/scripts/windows/test-host" run --run-id "$run_id" --fail-contexts ACTIVE_USER_ELEVATED,ACTIVE_SYSTEM
|
||||
wait_client
|
||||
assert_context local-system-active-fallback yes local-system
|
||||
"$repo_root/scripts/windows/test-host" run --run-id "$run_id"
|
||||
wait_client
|
||||
"$repo_root/scripts/windows/test-host" logoff --run-id "$run_id"
|
||||
assert_context local-service no local-service
|
||||
assert_context local-system-no-user yes local-system
|
||||
collect
|
||||
if [ "$keep" = no ]; then clean; fi
|
||||
printf 'native Windows hierarchy run passed: %s\n' "$run_id"
|
||||
;;
|
||||
esac
|
||||
+214
-20
@@ -12,7 +12,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/windows/test-host ACTION [--run-id ID] [--bundle DIRECTORY] [--endpoint HOST:PORT]
|
||||
usage: scripts/windows/test-host ACTION [--run-id ID] [--bundle DIRECTORY] [--endpoint HOST:PORT] [--fail-contexts LIST]
|
||||
|
||||
Actions:
|
||||
status read-only VM/snapshot identity and state check
|
||||
@@ -20,7 +20,9 @@ Actions:
|
||||
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
|
||||
logoff log off the sole active fixture user; use only after service installation
|
||||
collect copy bounded guest artifacts to the local test-run directory
|
||||
inspect read-only RVBox SCM state and bounded client log from a prepared run
|
||||
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
|
||||
@@ -34,6 +36,9 @@ Optional environment:
|
||||
(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)
|
||||
RVBOX_TEST_ACCEL_HTTP_URL, RVBOX_TEST_ACCEL_HTTP_AUTH,
|
||||
RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR, RVBOX_TEST_ACCEL_SOCKS5
|
||||
(documented accelerated HTTP stage route; empty URL disables it)
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -70,17 +75,19 @@ shift
|
||||
run_id=
|
||||
bundle=
|
||||
endpoint=
|
||||
fail_contexts=
|
||||
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 ;;
|
||||
--fail-contexts) [ "$#" -ge 2 ] || fail "--fail-contexts needs a value"; fail_contexts=$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
|
||||
case $action in status|prepare|stage|install|run|logoff|collect|inspect|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"
|
||||
@@ -91,6 +98,7 @@ if [ "$action" = stage ]; then
|
||||
[ -f "$bundle/client.toml" ] || fail "bundle must contain client.toml"
|
||||
fi
|
||||
if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; fi
|
||||
if [ -n "$fail_contexts" ]; then safe_word fail_contexts "$fail_contexts"; 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
|
||||
@@ -106,8 +114,13 @@ if [ -n "$endpoint" ]; then safe_word endpoint "$endpoint"; fi
|
||||
: "${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}"
|
||||
: "${RVBOX_TEST_ACCEL_HTTP_URL:=http://x1.xcel.me:9124}"
|
||||
: "${RVBOX_TEST_ACCEL_HTTP_AUTH:=x1:x1}"
|
||||
: "${RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR:=/home/ubuntu/Downloads}"
|
||||
: "${RVBOX_TEST_ACCEL_SOCKS5:=socks5h://127.0.0.1:1085}"
|
||||
provisioner_user=$RVBOX_TEST_PROVISIONER_USER
|
||||
provisioner_password_file=$RVBOX_TEST_PROVISIONER_PASSWORD_FILE
|
||||
accelerated_artifact=
|
||||
|
||||
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 \
|
||||
@@ -124,24 +137,49 @@ 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
|
||||
if [ -n "$RVBOX_TEST_ACCEL_HTTP_URL" ]; then
|
||||
safe_word RVBOX_TEST_ACCEL_HTTP_URL "$RVBOX_TEST_ACCEL_HTTP_URL"
|
||||
safe_word RVBOX_TEST_ACCEL_HTTP_AUTH "$RVBOX_TEST_ACCEL_HTTP_AUTH"
|
||||
safe_word RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR "$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR"
|
||||
safe_word RVBOX_TEST_ACCEL_SOCKS5 "$RVBOX_TEST_ACCEL_SOCKS5"
|
||||
case $RVBOX_TEST_ACCEL_HTTP_URL in http://*|https://*) ;; *) fail "RVBOX_TEST_ACCEL_HTTP_URL must use http(s)" ;; esac
|
||||
case $RVBOX_TEST_ACCEL_SOCKS5 in socks5://*|socks5h://*) ;; *) fail "RVBOX_TEST_ACCEL_SOCKS5 must use socks5" ;; esac
|
||||
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"
|
||||
# This value crosses a remote POSIX shell before Guest Control. Windows accepts
|
||||
# forward slashes, which avoids backslash loss while SSH reconstructs argv.
|
||||
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.
|
||||
# arguments. Password contents are never transmitted or printed; only the
|
||||
# approved host-local password-file path is passed to VBoxManage. Execute
|
||||
# the helper through this one SSH connection: the former upload-then-run
|
||||
# scheme could race with a stale controller that removed the shared helper
|
||||
# filename between those two connections.
|
||||
remote_action=$1
|
||||
retry_limit=1
|
||||
# These operations are either read-only or converge on the same staged
|
||||
# artifact/service configuration. A lost SSH response is therefore safe to
|
||||
# retry. Lifecycle transitions remain single-attempt: their caller must
|
||||
# inspect/recover rather than risk a duplicate reset, shutdown, or logoff.
|
||||
case $remote_action in
|
||||
status|recover|prepare-stage|stage|stage-create-root|stage-copy-exe|stage-copy-config|stage-copy-ca|collect|inspect|install|run)
|
||||
retry_limit=4
|
||||
;;
|
||||
esac
|
||||
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'
|
||||
remote_fail_contexts=${fail_contexts:--}
|
||||
retry_attempt=1
|
||||
while [ "$retry_attempt" -le "$retry_limit" ]; do
|
||||
if ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 "$RVBOX_TEST_VBOX_HOST" \
|
||||
"sh -s -- '$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' '$remote_fail_contexts'" <<'REMOTE'
|
||||
set -eu
|
||||
trap 'rm -f "$0"' EXIT
|
||||
|
||||
action=$1
|
||||
vm=$2
|
||||
@@ -156,7 +194,9 @@ shift 9
|
||||
endpoint=$1
|
||||
provisioner_user=$2
|
||||
provisioner_password_file=$3
|
||||
fail_contexts=$4
|
||||
[ "$endpoint" = - ] && endpoint=
|
||||
[ "$fail_contexts" = - ] && fail_contexts=
|
||||
|
||||
fail() { printf '%s\n' "remote test-host: $*" >&2; exit 2; }
|
||||
|
||||
@@ -289,6 +329,19 @@ wait_service() {
|
||||
fail "RVBoxClient did not reach $wanted"
|
||||
}
|
||||
|
||||
wait_service_stopped() {
|
||||
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:"STOPPED" >NUL && echo RVBOX_GUEST_OK' >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
fail "RVBoxClient did not reach STOPPED"
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
prepare-stage)
|
||||
assert_identity
|
||||
@@ -332,8 +385,8 @@ case "$action" in
|
||||
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
|
||||
guest_run --exe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||
-NoProfile -NonInteractive -Command "New-Item -ItemType Directory -Force -Path '$guest_root','C:/ProgramData/RVBox/test-work','C:/ProgramData/RVBox/test-logs' | Out-Null; Write-Output RVBOX_GUEST_OK" >/dev/null
|
||||
;;
|
||||
stage-copy-exe)
|
||||
assert_identity
|
||||
@@ -384,7 +437,14 @@ case "$action" in
|
||||
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\\\""
|
||||
# Reconfigure from a stopped service so a controlled fixture fault cannot
|
||||
# leak across hierarchy rows. The release build rejects this argument;
|
||||
# only the separately tagged disposable test binary accepts it.
|
||||
provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||
/d /s /c '(sc.exe stop RVBoxClient >NUL 2>&1 || sc.exe query RVBoxClient | findstr /c:"STOPPED" >NUL) && echo RVBOX_GUEST_OK' >/dev/null || true
|
||||
wait_service_stopped
|
||||
image="\\\"$guest_root\\rvbox.exe\\\" --service --config \\\"$guest_root\\client.toml\\\""
|
||||
if [ -n "$fail_contexts" ]; then image="$image --test-fail-contexts $fail_contexts"; fi
|
||||
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"
|
||||
@@ -397,6 +457,32 @@ case "$action" in
|
||||
wait_service RUNNING
|
||||
printf 'service=RVBoxClient state=RUNNING\n'
|
||||
;;
|
||||
logoff)
|
||||
assert_identity
|
||||
require_lease
|
||||
[ "$(state)" = running ] || fail "logoff requires a running prepared VM"
|
||||
# The clean fixture has exactly one active console account. Logging it off
|
||||
# leaves the LocalSystem service alive and makes the no-user hierarchy
|
||||
# rows observable without introducing a second session candidate. Do not
|
||||
# run `logoff` through that account's Guest Control channel: Windows ends
|
||||
# the channel before VBoxManage can return its completion sentinel. The
|
||||
# fixture-only full-token provisioner is non-interactive (and separately
|
||||
# asserted absent from WTS candidates), so it can prove the transition.
|
||||
provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||
/d /s /c "logoff 1 & echo RVBOX_GUEST_OK" >/dev/null
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 30 ]; do
|
||||
if provisioner_run --exe 'C:\\Windows\\System32\\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||
/d /s /c "query user | findstr /i /c:\"$guest_user\" >NUL & if errorlevel 1 echo RVBOX_GUEST_OK" >/dev/null 2>&1; then
|
||||
step logoff-no-active-user
|
||||
printf 'session=none\n'
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ "$attempt" -lt 30 ] || fail "fixture user did not log off"
|
||||
;;
|
||||
collect)
|
||||
assert_identity
|
||||
require_lease
|
||||
@@ -409,6 +495,13 @@ case "$action" in
|
||||
fi
|
||||
printf 'collected host_stage=%s/artifacts\n' "$host_stage"
|
||||
;;
|
||||
inspect)
|
||||
assert_identity
|
||||
require_lease
|
||||
[ "$(state)" = running ] || fail "inspect requires a running prepared VM"
|
||||
guest_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \
|
||||
/d /s /c "sc.exe queryex RVBoxClient & sc.exe qc RVBoxClient & reg.exe query \"HKLM\\SYSTEM\\CurrentControlSet\\Services\\RVBoxClient\" /v ImagePath & reg.exe query \"HKLM\\SYSTEM\\CurrentControlSet\\Services\\RVBoxClient\" /v ObjectName & dir \"C:/ProgramData/RVBox\" & icacls \"C:/ProgramData/RVBox\" & certutil -hashfile \"$guest_root\\rvbox.exe\" SHA256 & \"$guest_root\\rvbox.exe\" --check-config --config \"$guest_root\\client.toml\" > \"$guest_root\\check-config.txt\" 2>&1 & type \"$guest_root\\check-config.txt\" & \"$guest_root\\rvbox.exe\" --service --config \"$guest_root\\client.toml\" > \"$guest_root\\direct-service-probe.txt\" 2>&1 & type \"$guest_root\\direct-service-probe.txt\" & if exist \"C:/ProgramData/RVBox/service-startup.log\" type \"C:/ProgramData/RVBox/service-startup.log\" & wevtutil qe System /q:\"*[System[(EventID=7000 or EventID=7009 or EventID=7031 or EventID=7034)]]\" /c:3 /rd:true /f:text & if exist \"C:/ProgramData/RVBox/test-logs/rvbox.log\" type \"C:/ProgramData/RVBox/test-logs/rvbox.log\" & echo RVBOX_GUEST_OK"
|
||||
;;
|
||||
stop)
|
||||
assert_identity
|
||||
require_lease
|
||||
@@ -428,6 +521,16 @@ case "$action" in
|
||||
;;
|
||||
reset)
|
||||
assert_identity
|
||||
# A controller may be interrupted after it has brought down its
|
||||
# Compose stack but before it removes local run files. When the VM is
|
||||
# already powered off at the declared baseline and no lease exists,
|
||||
# reset is therefore a safe no-op. It lets `native-test clean` repair
|
||||
# that abandoned local run without pretending it owns a live VM.
|
||||
if [ ! -f "$lease_owner" ]; then
|
||||
[ "$(state)" = poweroff ] || fail "reset requires the run lease while the VM is not powered off"
|
||||
printf 'reset vm=%s snapshot=%s already-clean\n' "$vm" "$snapshot"
|
||||
exit 0
|
||||
fi
|
||||
require_lease
|
||||
if [ "$(state)" = running ]; then
|
||||
VBoxManage controlvm "$vm" acpipowerbutton >/dev/null
|
||||
@@ -454,12 +557,74 @@ case "$action" in
|
||||
;;
|
||||
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
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
retry_attempt=$((retry_attempt + 1))
|
||||
if [ "$retry_attempt" -le "$retry_limit" ]; then
|
||||
printf 'remote action=%s interrupted; retry %s/%s\n' "$remote_action" "$retry_attempt" "$retry_limit" >&2
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
fail "remote action $remote_action exhausted $retry_limit SSH attempts"
|
||||
}
|
||||
|
||||
# accelerated_stage uses the documented controller HTTP endpoint only for a
|
||||
# compressed disposable test executable. The endpoint remains authenticated;
|
||||
# the artifact name contains the run ID and payload digest and is deleted after
|
||||
# successful guest staging. A failed HTTP attempt leaves no host executable
|
||||
# change and fails the stage; the executable is never sent over the fragile
|
||||
# SSH route.
|
||||
accelerated_stage() {
|
||||
[ -d "$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR" ] || {
|
||||
printf 'stage: accelerated publish directory unavailable\n' >&2
|
||||
return 1
|
||||
}
|
||||
stage_http_dir=$(mktemp -d "${TMPDIR:-/tmp}/rvbox-http-stage.XXXXXX")
|
||||
stage_http_xz=$stage_http_dir/rvbox.exe.xz
|
||||
xz -T0 -3 -c "$bundle/rvbox.exe" >"$stage_http_xz"
|
||||
stage_http_hash=$(sha256sum "$stage_http_xz" | awk '{print $1}')
|
||||
stage_http_name="rvbox-$run_id-$stage_http_hash.xz"
|
||||
stage_http_published=$RVBOX_TEST_ACCEL_HTTP_PUBLISH_DIR/$stage_http_name
|
||||
if [ -e "$stage_http_published" ]; then
|
||||
[ ! -L "$stage_http_published" ] || fail "refusing symlink accelerated artifact $stage_http_published"
|
||||
existing_hash=$(sha256sum "$stage_http_published" | awk '{print $1}')
|
||||
[ "$existing_hash" = "$stage_http_hash" ] || fail "accelerated artifact name collision: $stage_http_published"
|
||||
else
|
||||
# The payload contains no configuration or credential. It is readable
|
||||
# only to the authenticated HTTP service so nginx can serve it.
|
||||
install -m 644 "$stage_http_xz" "$stage_http_published"
|
||||
fi
|
||||
rm -rf "$stage_http_dir"
|
||||
stage_http_url=$RVBOX_TEST_ACCEL_HTTP_URL/$stage_http_name
|
||||
printf 'stage: accelerated HTTP transfer\n'
|
||||
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=10 -o ServerAliveCountMax=2 "$RVBOX_TEST_VBOX_HOST" \
|
||||
"set -eu; stage='$host_stage'; target=\$stage/rvbox.exe.xz.http; curl --proxy '$RVBOX_TEST_ACCEL_SOCKS5' --anyauth -u '$RVBOX_TEST_ACCEL_HTTP_AUTH' --continue-at - --retry 4 --retry-all-errors --retry-delay 2 --connect-timeout 15 --max-time 120 --fail --silent --show-error --output \$target '$stage_http_url'; expected='$stage_http_hash'; actual=\$(sha256sum \$target | awk '{print \$1}'); test \"\$actual\" = \"\$expected\"; xz -dc \$target >\$stage/rvbox.exe.new; chmod 700 \$stage/rvbox.exe.new; mv \$stage/rvbox.exe.new \$stage/rvbox.exe; rm -f \$target"; then
|
||||
printf 'stage: accelerated HTTP transfer failed\n' >&2
|
||||
rm -f "$stage_http_published"
|
||||
return 1
|
||||
fi
|
||||
accelerated_artifact=$stage_http_published
|
||||
return 0
|
||||
}
|
||||
|
||||
# Only small non-secret configuration files use the SSH control route. The
|
||||
# executable itself always uses accelerated_stage above.
|
||||
copy_stage_file() {
|
||||
source_file=$1
|
||||
target_name=$2
|
||||
copy_attempt=1
|
||||
while [ "$copy_attempt" -le 4 ]; do
|
||||
if scp -q "$source_file" "$RVBOX_TEST_VBOX_HOST:$host_stage/$target_name"; then
|
||||
return 0
|
||||
fi
|
||||
copy_attempt=$((copy_attempt + 1))
|
||||
if [ "$copy_attempt" -le 4 ]; then
|
||||
printf 'stage: small-file SSH copy retry %s/4 (%s)\n' "$copy_attempt" "$target_name" >&2
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
fail "could not copy staged $target_name after 4 SSH attempts"
|
||||
}
|
||||
|
||||
case $action in
|
||||
@@ -468,14 +633,43 @@ case $action in
|
||||
printf 'prepared vm=%s stage=%s\n' "$RVBOX_TEST_VBOX_VM" "$host_stage"
|
||||
;;
|
||||
stage)
|
||||
printf 'stage: verify prepared VM and lease\n'
|
||||
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
|
||||
[ -f "$bundle/rvbox.exe" ] && [ ! -L "$bundle/rvbox.exe" ] || fail "rvbox.exe must be a regular non-symlink file"
|
||||
[ -f "$bundle/client.toml" ] && [ ! -L "$bundle/client.toml" ] || fail "client.toml must be a regular non-symlink file"
|
||||
if [ -f "$bundle/ca.pem" ]; then
|
||||
[ ! -L "$bundle/ca.pem" ] || fail "ca.pem must not be a symlink"
|
||||
local_hashes=$(cd "$bundle" && sha256sum rvbox.exe client.toml ca.pem)
|
||||
else
|
||||
local_hashes=$(cd "$bundle" && sha256sum rvbox.exe client.toml)
|
||||
fi
|
||||
copy_stage_file "$bundle/client.toml" client.toml
|
||||
if [ -f "$bundle/ca.pem" ]; then copy_stage_file "$bundle/ca.pem" ca.pem; fi
|
||||
local_exe_hash=$(sha256sum "$bundle/rvbox.exe" | awk '{print $1}')
|
||||
remote_exe_hash=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "sha256sum '$host_stage/rvbox.exe' 2>/dev/null | awk '{print \$1}'" 2>/dev/null || true)
|
||||
if [ "$local_exe_hash" = "$remote_exe_hash" ]; then
|
||||
printf 'stage: matching accelerated executable already present\n'
|
||||
else
|
||||
accelerated_stage || fail "accelerated executable transfer failed"
|
||||
trap 'if [ -n "$accelerated_artifact" ]; then rm -f "$accelerated_artifact"; fi' EXIT HUP INT TERM
|
||||
fi
|
||||
if [ -f "$bundle/ca.pem" ]; then
|
||||
remote_hashes=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "cd '$host_stage' && sha256sum rvbox.exe client.toml ca.pem" 2>/dev/null || true)
|
||||
else
|
||||
remote_hashes=$(ssh -o BatchMode=yes "$RVBOX_TEST_VBOX_HOST" "cd '$host_stage' && sha256sum rvbox.exe client.toml" 2>/dev/null || true)
|
||||
fi
|
||||
[ "$local_hashes" = "$remote_hashes" ] || fail "bundle transfer did not reach the expected SHA-256 manifest"
|
||||
printf 'verified transfer_sha256 run_id=%s\n%s\n' "$run_id" "$local_hashes"
|
||||
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
|
||||
if [ -n "$accelerated_artifact" ]; then
|
||||
rm -f "$accelerated_artifact"
|
||||
accelerated_artifact=
|
||||
trap - EXIT HUP INT TERM
|
||||
fi
|
||||
printf 'staged guest_root=%s\n' "$guest_root"
|
||||
;;
|
||||
collect)
|
||||
|
||||
Reference in New Issue
Block a user