test: add production compose smoke harness

This commit is contained in:
2026-09-11 06:28:11 +00:00
parent 110a329652
commit 248c41c7e1
9 changed files with 259 additions and 12 deletions
+28 -4
View File
@@ -4,16 +4,19 @@ This directory is intentionally separate from the development/toolchain Compose
files. It starts only the Linux server and nginx TLS terminator; Windows clients files. It starts only the Linux server and nginx TLS terminator; Windows clients
connect through nginx at `/v1/agent`. connect through nginx at `/v1/agent`.
Before the first start, create `server.toml` from the authoritative example: Before the first start, create `server.toml` from the Compose-specific example:
```sh ```sh
cp ../../docs/examples/server.toml server.toml cp server.toml.example server.toml
chmod 0640 server.toml chmod 0644 server.toml
``` ```
Set `RVBOX_SERVER_IMAGE` to an immutable image reference, plus absolute paths Set `RVBOX_SERVER_IMAGE` to an immutable image reference, plus absolute paths
for `RVBOX_TLS_CERT` and `RVBOX_TLS_KEY`. The TLS key must be readable by Docker for `RVBOX_TLS_CERT` and `RVBOX_TLS_KEY`. The TLS key must be readable by Docker
but should remain inaccessible to ordinary host users. Validate before start: but should remain inaccessible to ordinary host users. `server.toml` can be
kept outside this directory by setting `RVBOX_SERVER_CONFIG` to its absolute
path; this is useful for a controlled smoke run without changing deployment
files. Validate before start:
```sh ```sh
docker compose -f compose.yaml config docker compose -f compose.yaml config
@@ -25,3 +28,24 @@ ownership required by the non-root server. `server-data` is the sole persistent
data volume and must be backed up as a whole while the server is stopped; data volume and must be backed up as a whole while the server is stopped;
`server-run` contains only the ephemeral local control socket. Do not publish, `server-run` contains only the ephemeral local control socket. Do not publish,
proxy, or enable JSON-RPC except for intentional loopback debugging. proxy, or enable JSON-RPC except for intentional loopback debugging.
`server.toml` contains configuration rather than credentials and must be
world-readable on the host (`0644`): a bind mount preserves host file ownership,
while the server intentionally runs as the fixed unprivileged container UID
`65532`. Keep TLS private keys outside `server.toml` and restrict the key file
separately.
The provided Compose-specific example binds the private agent and observability
listeners to `0.0.0.0` *inside the Compose network*. This is required for nginx
to proxy them. It does not publish those ports to the host.
Set `RVBOX_HTTPS_BIND` when a deployment must bind a particular host interface;
it defaults to `0.0.0.0`. The repeatable test-only smoke lane binds only
loopback, uses material beneath `.test-runs`, and can be run after building a
local runtime image:
```sh
docker build -f deploy/Dockerfile.runtime -t rvbox-server:test .
scripts/test-production-compose run --run-id production-smoke
scripts/test-production-compose clean --run-id production-smoke --purge --yes
```
+12 -7
View File
@@ -10,10 +10,10 @@ services:
image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}" image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}"
user: "0:0" user: "0:0"
entrypoint: ["/bin/sh", "-ec"] entrypoint: ["/bin/sh", "-ec"]
command: >- # Compose does not turn a scalar `command` into one shell script argument.
mkdir -p /var/lib/rvbox-server /run/rvbox && # Preserve this whole program as $0 for /bin/sh -c rather than passing
chown 65532:65532 /var/lib/rvbox-server /run/rvbox && # `mkdir` followed by its words as separate shell positional arguments.
chmod 0700 /var/lib/rvbox-server /run/rvbox command: ["mkdir -p /var/lib/rvbox-server /run/rvbox && chown 65532:65532 /var/lib/rvbox-server /run/rvbox && chmod 0700 /var/lib/rvbox-server /run/rvbox"]
read_only: true read_only: true
tmpfs: tmpfs:
- /tmp:mode=1777,size=8m - /tmp:mode=1777,size=8m
@@ -34,7 +34,7 @@ services:
- /tmp:mode=1777,size=32m - /tmp:mode=1777,size=32m
volumes: volumes:
- type: bind - type: bind
source: ./server.toml source: ${RVBOX_SERVER_CONFIG:-./server.toml}
target: /etc/rvbox/server.toml target: /etc/rvbox/server.toml
read_only: true read_only: true
- type: volume - type: volume
@@ -67,7 +67,7 @@ services:
server: server:
condition: service_healthy condition: service_healthy
ports: ports:
- "${RVBOX_HTTPS_PORT:-443}:443" - "${RVBOX_HTTPS_BIND:-0.0.0.0}:${RVBOX_HTTPS_PORT:-443}:443"
tmpfs: tmpfs:
- /var/cache/nginx:uid=101,gid=101,mode=0755,size=16m - /var/cache/nginx:uid=101,gid=101,mode=0755,size=16m
- /var/run:uid=101,gid=101,mode=0755,size=4m - /var/run:uid=101,gid=101,mode=0755,size=4m
@@ -87,7 +87,12 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: ["ALL"] cap_drop: ["ALL"]
cap_add: ["NET_BIND_SERVICE"] # The nginx master creates worker-owned temporary directories beneath its
# explicitly mounted tmpfs paths, then drops workers to UID/GID 101. These
# are the exact bootstrap capabilities required for that lifecycle,
# including Docker user-namespace-remapping hosts; it retains no network,
# process, mount, or broad administration capability.
cap_add: ["NET_BIND_SERVICE", "DAC_OVERRIDE", "CHOWN", "SETUID", "SETGID"]
volumes: volumes:
server-data: server-data:
+21
View File
@@ -0,0 +1,21 @@
# RVBox Linux-server Docker Compose configuration.
# Copy this file to server.toml before starting the production stack.
#
# The server is private to the Compose network. These two listeners deliberately
# bind all container interfaces so nginx can reach them; Compose publishes only
# nginx's TLS port to the host.
[server]
data_dir = "/var/lib/rvbox-server"
agent_listen = "0.0.0.0:6899"
agent_path = "/v1/agent"
control_socket = "/run/rvbox/server.sock"
[json_rpc]
# The unauthenticated compatibility adapter is intentionally disabled in the
# production stack. Use a separately scoped loopback debugging setup if needed.
enabled = false
[observability]
# nginx exposes only /livez and /readyz, not the metrics listener itself.
listen = "0.0.0.0:6901"
+1
View File
@@ -189,6 +189,7 @@ scripts/test-integration [--suite NAME|all] [--run-id ID] [--resume]
scripts/test-e2e [--scenario 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/test-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge|gc ...
scripts/build build|verify|doctor|recover|clean (pinned toolchain; host-safe) scripts/build build|verify|doctor|recover|clean (pinned toolchain; host-safe)
scripts/test-production-compose run|clean --run-id ID (loopback production-stack smoke)
scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE] scripts/windows/build-test-bundle --run-id ID --config FILE [--ca FILE]
scripts/windows/test-host status|prepare|stage|install|run|collect|stop|reset|recover scripts/windows/test-host status|prepare|stage|install|run|collect|stop|reset|recover
``` ```
+2 -1
View File
@@ -6,7 +6,8 @@ describe a Unix-like client, which is outside v1.
## Deploy and verify ## Deploy and verify
Use the checked-in Compose deployment as the sole Linux-server runtime. Start Use the checked-in Compose deployment as the sole Linux-server runtime. Start
from the annotated [`server.toml`](examples/server.toml) and retain from its Compose-specific
[`server.toml.example`](../deploy/production/server.toml.example) and retain
`json_rpc.enabled = false` unless performing loopback-only debugging. `json_rpc.enabled = false` unless performing loopback-only debugging.
For Compose, follow the setup in For Compose, follow the setup in
+12
View File
@@ -193,6 +193,18 @@ Interactive browser access is a deliberately temporary recovery path only. See
self-signed HTTPS Guacamole lifecycle; it must be started only after the native self-signed HTTPS Guacamole lifecycle; it must be started only after the native
fixture controller has prepared and leased the VM, and stopped before reset. fixture controller has prepared and leased the VM, and stopped before reset.
The Linux production Compose asset has a separate, loopback-only smoke lane. It
uses a disposable self-signed key only under the ignored `.test-runs` tree,
starts the real non-root server and nginx services, probes TLS liveness and
readiness, and invokes `rvc` through the private socket. It leaves compact logs
and output for inspection, while removing its exact Compose project by default:
```sh
docker build -f deploy/Dockerfile.runtime -t rvbox-server:test .
scripts/test-production-compose run --run-id production-smoke
scripts/test-production-compose clean --run-id production-smoke --purge --yes
```
The canonical headless VirtualBox/Guest Control adapter is The canonical headless VirtualBox/Guest Control adapter is
`scripts/windows/test-host`. It is a POSIX controller script because the `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 fixture's VirtualBox host is Arch Linux and has no PowerShell runtime. The
+137
View File
@@ -0,0 +1,137 @@
#!/bin/sh
# Disposable production-Compose smoke test. All generated data stays beneath
# the ignored .test-runs/<run-id>/production-compose directory.
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
compose_file=$repo_root/deploy/production/compose.yaml
usage() {
cat <<'EOF'
usage: scripts/test-production-compose run|clean --run-id ID [options]
run options:
--port PORT loopback HTTPS port (default: 18443)
--keep retain the exact Compose project for inspection
clean options:
--purge --yes also remove only .test-runs/<run-id>/production-compose
The run requires a locally available RVBox runtime image. By default it uses
rvbox-server:test; set RVBOX_PRODUCTION_SMOKE_IMAGE to override it. It creates a
test-only self-signed localhost certificate, verifies TLS /livez and /readyz,
and invokes rvc through the server's private control socket. It never writes to
/tmp or to deployment configuration files.
EOF
}
fail() { printf '%s\n' "test-production-compose: $*" >&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|clean|--help|-h) ;; *) usage >&2; fail "unknown action $action" ;; esac
[ "$action" != --help ] && [ "$action" != -h ] || { usage; exit 0; }
run_id=
port=${RVBOX_PRODUCTION_SMOKE_PORT:-18443}
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"
image=${RVBOX_PRODUCTION_SMOKE_IMAGE:-rvbox-server:test}
project=rvbox-production-smoke-$run_id
run_root=$repo_root/.test-runs/$run_id/production-compose
config=$run_root/server.toml
cert=$run_root/server.pem
key=$run_root/server-key.pem
compose() {
RVBOX_SERVER_IMAGE=$image RVBOX_SERVER_CONFIG=$config RVBOX_TLS_CERT=$cert \
RVBOX_TLS_KEY=$key RVBOX_HTTPS_BIND=127.0.0.1 RVBOX_HTTPS_PORT=$port \
docker compose -p "$project" -f "$compose_file" "$@"
}
collect() {
[ -d "$run_root" ] || return 0
compose logs --no-color --tail=500 >"$run_root/compose.log" 2>&1 || true
}
down() {
compose down --volumes --remove-orphans || true
}
clean() {
collect
down
if [ "$purge" = yes ]; then
[ -L "$run_root" ] && fail "refusing symlink run root $run_root"
rm -rf "$run_root"
# Remove the per-run parent only when production-compose was its only
# child; another harness lane may legitimately share the same run ID.
rmdir "$repo_root/.test-runs/$run_id" 2>/dev/null || true
fi
}
case $action in
clean)
clean
printf 'cleaned run_id=%s\n' "$run_id"
;;
run)
docker version >/dev/null 2>&1 || fail "Docker is unavailable"
docker compose version >/dev/null 2>&1 || fail "Docker Compose is unavailable"
command -v openssl >/dev/null 2>&1 || fail "openssl is required for the test-only certificate"
command -v curl >/dev/null 2>&1 || fail "curl is required for TLS probes"
docker image inspect "$image" >/dev/null 2>&1 || fail "runtime image $image is absent; build or set RVBOX_PRODUCTION_SMOKE_IMAGE"
[ ! -e "$run_root" ] || fail "refusing to overwrite existing run root $run_root; use clean --purge --yes"
umask 077
mkdir -p "$run_root"
cp "$repo_root/deploy/production/server.toml.example" "$config"
# Docker user-namespace remapping may prevent a container from reading
# a host file with mode 0600. This is a disposable test-only key under
# .test-runs, never a production certificate.
chmod 0644 "$config"
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 -subj /CN=localhost \
-addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' -keyout "$key" -out "$cert" >/dev/null 2>&1
chmod 0644 "$key" "$cert"
trap 'status=$?; collect; if [ "$keep" = no ]; then down; fi; exit "$status"' EXIT INT TERM
compose up -d
attempt=0
while [ "$attempt" -lt 40 ]; do
if curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/livez" >/dev/null && \
curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/readyz" >/dev/null; then
break
fi
attempt=$((attempt + 1))
sleep 1
done
curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/livez" >/dev/null
curl --fail --silent --show-error --max-time 3 --cacert "$cert" "https://127.0.0.1:$port/readyz" >/dev/null
compose exec -T server /usr/local/bin/rvc --socket /run/rvbox/server.sock stat >"$run_root/rvc-stat.txt"
printf 'production Compose smoke passed: run_id=%s artifacts=%s\n' "$run_id" "$run_root"
;;
esac
+6
View File
@@ -599,6 +599,12 @@ layer = "unit"
status = "implemented" status = "implemented"
tests = ["test/windowsnative/native_fixture_test.go:TestNativeFixtureAssets_HP_HARNESS_20"] tests = ["test/windowsnative/native_fixture_test.go:TestNativeFixtureAssets_HP_HARNESS_20"]
[[requirements]]
id = "HP-OPS-01"
layer = "unit"
status = "implemented"
tests = ["test/windowsnative/native_fixture_test.go:TestProductionComposeAssets_HP_OPS_01"]
[[requirements]] [[requirements]]
id = "HP-STORE-01" id = "HP-STORE-01"
layer = "integration" layer = "integration"
+40
View File
@@ -54,3 +54,43 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
t.Fatal("fixture-only context faults are not separated from release builds") t.Fatal("fixture-only context faults are not separated from release builds")
} }
} }
// TestProductionComposeAssets_HP_OPS_01 guards the deployment properties that
// a syntax-only Compose check cannot prove: private server reachability through
// nginx, non-root state initialization, and a repository-local smoke cleanup.
func TestProductionComposeAssets_HP_OPS_01(t *testing.T) {
t.Parallel()
root := filepath.Clean(filepath.Join("..", ".."))
read := func(relative string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, relative))
if err != nil {
t.Fatalf("read %s: %v", relative, err)
}
return string(data)
}
compose := read("deploy/production/compose.yaml")
for _, required := range []string{
"RVBOX_SERVER_CONFIG:-./server.toml",
"RVBOX_HTTPS_BIND:-0.0.0.0",
"condition: service_completed_successfully",
"condition: service_healthy",
"NET_BIND_SERVICE", "DAC_OVERRIDE", "CHOWN", "SETUID", "SETGID",
} {
if !strings.Contains(compose, required) {
t.Fatalf("production Compose is missing %q", required)
}
}
config := read("deploy/production/server.toml.example")
for _, required := range []string{"agent_listen = \"0.0.0.0:6899\"", "listen = \"0.0.0.0:6901\"", "enabled = false"} {
if !strings.Contains(config, required) {
t.Fatalf("production server example is missing %q", required)
}
}
runner := read("scripts/test-production-compose")
for _, required := range []string{".test-runs/$run_id/production-compose", "RVBOX_HTTPS_BIND=127.0.0.1", "curl --fail", "compose exec -T server", "clean --purge --yes"} {
if !strings.Contains(runner, required) {
t.Fatalf("production smoke runner is missing %q", required)
}
}
}