test: expand server protocol scenario coverage

This commit is contained in:
2026-09-11 05:55:42 +00:00
parent c709b27e5e
commit 51fa88d05b
9 changed files with 74 additions and 65 deletions
-2
View File
@@ -9,5 +9,3 @@
*.sock *.sock
*.tmp *.tmp
/deploy/production/server.toml /deploy/production/server.toml
/deploy/production/state/
/deploy/production/run/
+6 -7
View File
@@ -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 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 and Before the first start, create `server.toml` from the authoritative example:
prepare writable state directories for the runtime image UID/GID `65532`:
```sh ```sh
cp ../../docs/examples/server.toml server.toml cp ../../docs/examples/server.toml server.toml
install -d -m 0700 -o 65532 -g 65532 state run
chmod 0640 server.toml chmod 0640 server.toml
``` ```
@@ -22,7 +20,8 @@ docker compose -f compose.yaml config
docker compose -f compose.yaml up -d docker compose -f compose.yaml up -d
``` ```
Only `state/` and `run/` are persistent/owned deployment data. Back up the The stack's `init` container creates the two named volumes with the runtime
whole `state/` directory while the server is stopped; `run/` contains only the ownership required by the non-root server. `server-data` is the sole persistent
ephemeral local control socket. Do not publish, proxy, or enable JSON-RPC except data volume and must be backed up as a whole while the server is stopped;
for intentional loopback debugging. `server-run` contains only the ephemeral local control socket. Do not publish,
proxy, or enable JSON-RPC except for intentional loopback debugging.
+30 -4
View File
@@ -6,6 +6,25 @@
name: rvbox-server name: rvbox-server
services: 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: server:
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}"
restart: unless-stopped restart: unless-stopped
@@ -18,11 +37,11 @@ services:
source: ./server.toml source: ./server.toml
target: /etc/rvbox/server.toml target: /etc/rvbox/server.toml
read_only: true read_only: true
- type: bind - type: volume
source: ./state source: server-data
target: /var/lib/rvbox-server target: /var/lib/rvbox-server
- type: bind - type: volume
source: ./run source: server-run
target: /run/rvbox target: /run/rvbox
expose: expose:
- "6899" - "6899"
@@ -36,6 +55,9 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: ["ALL"] cap_drop: ["ALL"]
depends_on:
init:
condition: service_completed_successfully
nginx: nginx:
image: nginx:1.27.5-alpine image: nginx:1.27.5-alpine
@@ -66,3 +88,7 @@ services:
- no-new-privileges:true - no-new-privileges:true
cap_drop: ["ALL"] cap_drop: ["ALL"]
cap_add: ["NET_BIND_SERVICE"] cap_add: ["NET_BIND_SERVICE"]
volumes:
server-data:
server-run:
-34
View File
@@ -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
+4 -2
View File
@@ -2627,8 +2627,10 @@ label. Use structured logs and audit records for those identifiers instead.
Provide: Provide:
- nginx configuration showing WebSocket upgrade proxying and TLS termination; - nginx configuration showing WebSocket upgrade proxying and TLS termination;
- systemd units for server/client with private state directories, restart - a self-contained production Docker Compose stack for the Linux server with
policy, working directory, file descriptor limits, and least privilege; 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 - example server/client configuration files with every default and an explicit
JSON-RPC exposure warning; JSON-RPC exposure warning;
- Windows `rvbox.exe` GUI/icon/version resources, SCM service installation, - Windows `rvbox.exe` GUI/icon/version resources, SCM service installation,
+3 -10
View File
@@ -5,10 +5,9 @@ describe a Unix-like client, which is outside v1.
## Deploy and verify ## Deploy and verify
Use either the checked-in Compose deployment or the systemd unit, never both Use the checked-in Compose deployment as the sole Linux-server runtime. Start
for the same server data directory. Start from the annotated from the annotated [`server.toml`](examples/server.toml) and retain
[`server.toml`](examples/server.toml) and retain `json_rpc.enabled = false` `json_rpc.enabled = false` unless performing loopback-only debugging.
unless performing loopback-only debugging.
For Compose, follow the setup in For Compose, follow the setup in
[`deploy/production/README.md`](../deploy/production/README.md), set a pinned [`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 The public endpoint accepts only `wss://HOST/v1/agent`. Do not publish port
6900 or add a proxy route for JSON-RPC. 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 ## Backup and restore
Stop dispatch before copying data: stop the server gracefully, confirm it is Stop dispatch before copying data: stop the server gracefully, confirm it is
+5 -1
View File
@@ -88,7 +88,11 @@ the exact local run root after the local stack is down and the VM snapshot has
been restored. been restored.
The integration harness provides the Phase 0 `sample` suite, the incremental 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` The resumable E2E harness adds `smoke`, `script`, `recovery`, and `all`
scenarios. Each run writes its manifest and run ID before starting work. 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 The storage suite uses a real temporary SQLite database in WAL mode and a real
+24 -3
View File
@@ -64,8 +64,8 @@ type harness struct {
out io.Writer out io.Writer
} }
var integrationSuites = []string{"sample", "store", "server-session"} var integrationSuites = []string{"sample", "store", "server-session", "control", "client-agent"}
var e2eScenarios = []string{"smoke", "script", "recovery", "all"} var e2eScenarios = []string{"smoke", "interactive", "idempotency", "reconnect", "retention", "expiry-and-incidents", "script", "recovery", "all"}
func containsChoice(choices []string, value string) bool { func containsChoice(choices []string, value string) bool {
for _, choice := range choices { for _, choice := range choices {
@@ -231,6 +231,10 @@ func (h *harness) integration(ctx context.Context, args []string) error {
suiteErr = h.runStoreSuite(ctx, current, *testCase) suiteErr = h.runStoreSuite(ctx, current, *testCase)
case "server-session": case "server-session":
suiteErr = h.runServerSessionSuite(ctx, current, *testCase) 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 { if suiteErr != nil {
_ = h.transition(current, "failed", *suite+"-failed", suiteErr.Error()) _ = 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} scenarios := []string{*scenario}
if *scenario == "all" { if *scenario == "all" {
scenarios = []string{"smoke", "script", "recovery"} scenarios = []string{"smoke", "interactive", "idempotency", "reconnect", "retention", "expiry-and-incidents", "script", "recovery"}
} }
for _, item := range scenarios { for _, item := range scenarios {
if err := h.runE2EScenario(ctx, current, item, *testCase); err != nil { 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 { switch scenario {
case "smoke": case "smoke":
err = h.runGoSuite(ctx, current, "e2e-client-agent", "real client/server WebSocket and control flow", "./test/integration/clientagent", testCase) 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": case "script":
err = h.runGoSuite(ctx, current, "e2e-script-transfer", "durable script transfer and replay", "./internal/client/agent", testCase) err = h.runGoSuite(ctx, current, "e2e-script-transfer", "durable script transfer and replay", "./internal/client/agent", testCase)
case "recovery": 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"}) 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 { 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 { 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 return err
+2 -2
View File
@@ -131,14 +131,14 @@ func TestStableSuiteAndScenarioListing_HP_CFG_01(t *testing.T) {
if err := h.integration(context.Background(), []string{"--list"}); err != nil { if err := h.integration(context.Background(), []string{"--list"}); err != nil {
t.Fatalf("list integration suites: %v", err) 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) t.Fatalf("integration list = %q, want %q", got, want)
} }
output.Reset() output.Reset()
if err := h.e2e(context.Background(), []string{"--list"}); err != nil { if err := h.e2e(context.Background(), []string{"--list"}); err != nil {
t.Fatalf("list e2e scenarios: %v", err) 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) t.Fatalf("e2e list = %q, want %q", got, want)
} }
if err := h.integration(context.Background(), []string{"--suite", "store", "--case", "["}); err == nil { if err := h.integration(context.Background(), []string{"--suite", "store", "--case", "["}); err == nil {