test: add production compose smoke harness
This commit is contained in:
@@ -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
|
||||
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
|
||||
cp ../../docs/examples/server.toml server.toml
|
||||
chmod 0640 server.toml
|
||||
cp server.toml.example server.toml
|
||||
chmod 0644 server.toml
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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;
|
||||
`server-run` contains only the ephemeral local control socket. Do not publish,
|
||||
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
|
||||
```
|
||||
|
||||
@@ -10,10 +10,10 @@ services:
|
||||
image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}"
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "-ec"]
|
||||
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
|
||||
# Compose does not turn a scalar `command` into one shell script argument.
|
||||
# Preserve this whole program as $0 for /bin/sh -c rather than passing
|
||||
# `mkdir` followed by its words as separate shell positional arguments.
|
||||
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
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=8m
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
- /tmp:mode=1777,size=32m
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./server.toml
|
||||
source: ${RVBOX_SERVER_CONFIG:-./server.toml}
|
||||
target: /etc/rvbox/server.toml
|
||||
read_only: true
|
||||
- type: volume
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
server:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${RVBOX_HTTPS_PORT:-443}:443"
|
||||
- "${RVBOX_HTTPS_BIND:-0.0.0.0}:${RVBOX_HTTPS_PORT:-443}:443"
|
||||
tmpfs:
|
||||
- /var/cache/nginx:uid=101,gid=101,mode=0755,size=16m
|
||||
- /var/run:uid=101,gid=101,mode=0755,size=4m
|
||||
@@ -87,7 +87,12 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
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:
|
||||
server-data:
|
||||
|
||||
@@ -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"
|
||||
@@ -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-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge|gc ...
|
||||
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/test-host status|prepare|stage|install|run|collect|stop|reset|recover
|
||||
```
|
||||
|
||||
@@ -6,7 +6,8 @@ describe a Unix-like client, which is outside v1.
|
||||
## Deploy and verify
|
||||
|
||||
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.
|
||||
|
||||
For Compose, follow the setup in
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`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
|
||||
|
||||
Executable
+137
@@ -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
|
||||
@@ -599,6 +599,12 @@ layer = "unit"
|
||||
status = "implemented"
|
||||
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]]
|
||||
id = "HP-STORE-01"
|
||||
layer = "integration"
|
||||
|
||||
@@ -54,3 +54,43 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user