From 51fa88d05bd41219e551acb7452f051f6eb123ab Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 11 Sep 2026 05:55:42 +0000 Subject: [PATCH] test: expand server protocol scenario coverage --- .gitignore | 2 -- deploy/production/README.md | 13 +++++------ deploy/production/compose.yaml | 34 +++++++++++++++++++++++++---- deploy/systemd/rvbox-server.service | 34 ----------------------------- docs/implementation-plan.v1.md | 6 +++-- docs/operations-runbook.md | 13 +++-------- docs/testing.md | 6 ++++- test/harness/harness.go | 27 ++++++++++++++++++++--- test/harness/harness_test.go | 4 ++-- 9 files changed, 74 insertions(+), 65 deletions(-) delete mode 100644 deploy/systemd/rvbox-server.service diff --git a/.gitignore b/.gitignore index f9f754c..6f609f3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,3 @@ *.sock *.tmp /deploy/production/server.toml -/deploy/production/state/ -/deploy/production/run/ diff --git a/deploy/production/README.md b/deploy/production/README.md index eeb33bd..a068da4 100644 --- a/deploy/production/README.md +++ b/deploy/production/README.md @@ -4,12 +4,10 @@ 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 and -prepare writable state directories for the runtime image UID/GID `65532`: +Before the first start, create `server.toml` from the authoritative example: ```sh cp ../../docs/examples/server.toml server.toml -install -d -m 0700 -o 65532 -g 65532 state run chmod 0640 server.toml ``` @@ -22,7 +20,8 @@ docker compose -f compose.yaml config docker compose -f compose.yaml up -d ``` -Only `state/` and `run/` are persistent/owned deployment data. Back up the -whole `state/` directory while the server is stopped; `run/` contains only the -ephemeral local control socket. Do not publish, proxy, or enable JSON-RPC except -for intentional loopback debugging. +The stack's `init` container creates the two named volumes with the runtime +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. diff --git a/deploy/production/compose.yaml b/deploy/production/compose.yaml index 21b69fe..4e94a6e 100644 --- a/deploy/production/compose.yaml +++ b/deploy/production/compose.yaml @@ -6,6 +6,25 @@ name: rvbox-server services: + init: + 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 + read_only: true + tmpfs: + - /tmp:mode=1777,size=8m + volumes: + - server-data:/var/lib/rvbox-server + - server-run:/run/rvbox + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + cap_add: ["CHOWN", "FOWNER"] + server: image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}" restart: unless-stopped @@ -18,11 +37,11 @@ services: source: ./server.toml target: /etc/rvbox/server.toml read_only: true - - type: bind - source: ./state + - type: volume + source: server-data target: /var/lib/rvbox-server - - type: bind - source: ./run + - type: volume + source: server-run target: /run/rvbox expose: - "6899" @@ -36,6 +55,9 @@ services: security_opt: - no-new-privileges:true cap_drop: ["ALL"] + depends_on: + init: + condition: service_completed_successfully nginx: image: nginx:1.27.5-alpine @@ -66,3 +88,7 @@ services: - no-new-privileges:true cap_drop: ["ALL"] cap_add: ["NET_BIND_SERVICE"] + +volumes: + server-data: + server-run: diff --git a/deploy/systemd/rvbox-server.service b/deploy/systemd/rvbox-server.service deleted file mode 100644 index 89a01f5..0000000 --- a/deploy/systemd/rvbox-server.service +++ /dev/null @@ -1,34 +0,0 @@ -[Unit] -Description=RVBox server -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=rvbox -Group=rvbox -ExecStartPre=/usr/local/bin/rvbox-server --check-config --config /etc/rvbox/server.toml -ExecStart=/usr/local/bin/rvbox-server --config /etc/rvbox/server.toml -Restart=on-failure -RestartSec=5s -TimeoutStopSec=35s -WorkingDirectory=/var/lib/rvbox-server -StateDirectory=rvbox-server -RuntimeDirectory=rvbox -RuntimeDirectoryMode=0750 -UMask=0077 -LimitNOFILE=65536 -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ProtectKernelTunables=yes -ProtectKernelModules=yes -ProtectControlGroups=yes -RestrictSUIDSGID=yes -LockPersonality=yes -MemoryDenyWriteExecute=yes -ReadWritePaths=/var/lib/rvbox-server /run/rvbox - -[Install] -WantedBy=multi-user.target diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index bd56513..8206e75 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -2627,8 +2627,10 @@ label. Use structured logs and audit records for those identifiers instead. Provide: - nginx configuration showing WebSocket upgrade proxying and TLS termination; -- systemd units for server/client with private state directories, restart - policy, working directory, file descriptor limits, and least privilege; +- a self-contained production Docker Compose stack for the Linux server with + nginx TLS termination, private named state/control volumes, restart policy, + file descriptor limits, and least privilege; the v1 server is not installed + or managed as a host systemd service; - example server/client configuration files with every default and an explicit JSON-RPC exposure warning; - Windows `rvbox.exe` GUI/icon/version resources, SCM service installation, diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md index 28f44fe..df165c0 100644 --- a/docs/operations-runbook.md +++ b/docs/operations-runbook.md @@ -5,10 +5,9 @@ describe a Unix-like client, which is outside v1. ## Deploy and verify -Use either the checked-in Compose deployment or the systemd unit, never both -for the same server data directory. Start from the annotated -[`server.toml`](examples/server.toml) and retain `json_rpc.enabled = false` -unless performing loopback-only debugging. +Use the checked-in Compose deployment as the sole Linux-server runtime. Start +from the annotated [`server.toml`](examples/server.toml) and retain +`json_rpc.enabled = false` unless performing loopback-only debugging. For Compose, follow the setup in [`deploy/production/README.md`](../deploy/production/README.md), set a pinned @@ -25,12 +24,6 @@ the process is up; `/readyz` becomes successful only after durable recovery. The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port 6900 or add a proxy route for JSON-RPC. -For systemd, create the `rvbox` service account, install the binary and -`deploy/systemd/rvbox-server.service`, place a root:`rvbox` owned `0640` -`/etc/rvbox/server.toml`, then run `systemctl daemon-reload` and -`systemctl enable --now rvbox-server`. The unit performs `--check-config` -before every start. - ## Backup and restore Stop dispatch before copying data: stop the server gracefully, confirm it is diff --git a/docs/testing.md b/docs/testing.md index 383462f..b17a359 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -88,7 +88,11 @@ the exact local run root after the local stack is down and the VM snapshot has been restored. The integration harness provides the Phase 0 `sample` suite, the incremental -Phase 2 `store` suite, and the incremental Phase 3 `server-session` suite. +Phase 2 `store` suite, Phase 3 `server-session`, and `control` and +`client-agent` protocol suites. The deterministic E2E lane provides `smoke`, +`interactive`, `idempotency`, `reconnect`, `retention`, +`expiry-and-incidents`, `script`, and `recovery`. Native Windows execution +contexts remain the separately leased `scripts/windows/native-test` lane. The resumable E2E harness adds `smoke`, `script`, `recovery`, and `all` scenarios. Each run writes its manifest and run ID before starting work. The storage suite uses a real temporary SQLite database in WAL mode and a real diff --git a/test/harness/harness.go b/test/harness/harness.go index 4714d2d..5beb735 100644 --- a/test/harness/harness.go +++ b/test/harness/harness.go @@ -64,8 +64,8 @@ type harness struct { out io.Writer } -var integrationSuites = []string{"sample", "store", "server-session"} -var e2eScenarios = []string{"smoke", "script", "recovery", "all"} +var integrationSuites = []string{"sample", "store", "server-session", "control", "client-agent"} +var e2eScenarios = []string{"smoke", "interactive", "idempotency", "reconnect", "retention", "expiry-and-incidents", "script", "recovery", "all"} func containsChoice(choices []string, value string) bool { for _, choice := range choices { @@ -231,6 +231,10 @@ func (h *harness) integration(ctx context.Context, args []string) error { suiteErr = h.runStoreSuite(ctx, current, *testCase) case "server-session": suiteErr = h.runServerSessionSuite(ctx, current, *testCase) + case "control": + suiteErr = h.runGoSuite(ctx, current, "control-real-grpc", "running gRPC control and JSON-RPC compatibility cases", "./internal/server/control", *testCase) + case "client-agent": + suiteErr = h.runGoSuite(ctx, current, "client-agent-real-websocket", "running real client/server WebSocket control-flow cases", "./test/integration/clientagent", *testCase) } if suiteErr != nil { _ = h.transition(current, "failed", *suite+"-failed", suiteErr.Error()) @@ -306,7 +310,7 @@ func (h *harness) e2e(ctx context.Context, args []string) error { } scenarios := []string{*scenario} if *scenario == "all" { - scenarios = []string{"smoke", "script", "recovery"} + scenarios = []string{"smoke", "interactive", "idempotency", "reconnect", "retention", "expiry-and-incidents", "script", "recovery"} } for _, item := range scenarios { if err := h.runE2EScenario(ctx, current, item, *testCase); err != nil { @@ -325,6 +329,16 @@ func (h *harness) runE2EScenario(ctx context.Context, current *manifest, scenari switch scenario { case "smoke": err = h.runGoSuite(ctx, current, "e2e-client-agent", "real client/server WebSocket and control flow", "./test/integration/clientagent", testCase) + case "interactive": + err = h.runGoSuite(ctx, current, "e2e-stdin-protocol", "durable stdin append, replay, close, and acknowledgement flow", "./test/integration/clientagent", chooseTestCase(testCase, "^TestControlStdinIntentReplaysAndAcknowledges_HP_DISPATCH_08$")) + case "idempotency": + err = h.runGoSuite(ctx, current, "e2e-request-idempotency", "request UUID replay and immutable request-hash conflict handling", "./internal/server/control", chooseTestCase(testCase, "^TestRunCommandRequestIDIdempotencyAndValidation_BH_CONTROL_02$")) + case "reconnect": + err = h.runGoSuite(ctx, current, "e2e-reconnect-reconciliation", "WebSocket reconciliation, fenced dispatch, and session wake handling", "./test/integration/clientagent", chooseTestCase(testCase, "^(TestWebSocketHelloWelcome_HP_SES_06|TestWebSocketDispatchAfterReconciliation_HP_DISPATCH_03|TestControlQueueWakesReconciledSession_HP_DISPATCH_06)$")) + case "retention": + err = h.runStoreSuite(ctx, current, chooseTestCase(testCase, "^(TestTerminalAgeRetentionEvictsWholeCommand_HP_STORE_08|TestTombstoneFIFOIsCappedInEvictionTransaction_HP_STORE_09|TestRetentionCrashStagesRollForward_CRASH_STORE_04)$")) + case "expiry-and-incidents": + err = h.runStoreSuite(ctx, current, chooseTestCase(testCase, "^(TestIncidentDirtyResolutionIdempotencyAndRecurrence_HP_STORE_11|TestIrreparableIncidentRequiresExplicitAcknowledgement_BH_STORE_09|TestFilesystemFloorAndCounterMismatch_BH_STORE_08)$")) case "script": err = h.runGoSuite(ctx, current, "e2e-script-transfer", "durable script transfer and replay", "./internal/client/agent", testCase) case "recovery": @@ -338,6 +352,13 @@ func (h *harness) runE2EScenario(ctx context.Context, current *manifest, scenari return h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "e2e-" + scenario, Status: "passed", Detail: "scenario passed"}) } +func chooseTestCase(override, defaultCase string) string { + if override != "" { + return override + } + return defaultCase +} + func (h *harness) runStoreSuite(ctx context.Context, current *manifest, testCase string) error { if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "store-real-sqlite", Status: "running", Detail: "running real SQLite/WAL and filesystem cases"}); err != nil { return err diff --git a/test/harness/harness_test.go b/test/harness/harness_test.go index e881dbb..d47a70b 100644 --- a/test/harness/harness_test.go +++ b/test/harness/harness_test.go @@ -131,14 +131,14 @@ func TestStableSuiteAndScenarioListing_HP_CFG_01(t *testing.T) { if err := h.integration(context.Background(), []string{"--list"}); err != nil { t.Fatalf("list integration suites: %v", err) } - if got, want := output.String(), "integration suites:\nsample\nstore\nserver-session\n"; got != want { + if got, want := output.String(), "integration suites:\nsample\nstore\nserver-session\ncontrol\nclient-agent\n"; got != want { t.Fatalf("integration list = %q, want %q", got, want) } output.Reset() if err := h.e2e(context.Background(), []string{"--list"}); err != nil { t.Fatalf("list e2e scenarios: %v", err) } - if got, want := output.String(), "e2e scenarios:\nsmoke\nscript\nrecovery\nall\n"; got != want { + if got, want := output.String(), "e2e scenarios:\nsmoke\ninteractive\nidempotency\nreconnect\nretention\nexpiry-and-incidents\nscript\nrecovery\nall\n"; got != want { t.Fatalf("e2e list = %q, want %q", got, want) } if err := h.integration(context.Background(), []string{"--suite", "store", "--case", "["}); err == nil {