test: expand server protocol scenario coverage
This commit is contained in:
@@ -9,5 +9,3 @@
|
||||
*.sock
|
||||
*.tmp
|
||||
/deploy/production/server.toml
|
||||
/deploy/production/state/
|
||||
/deploy/production/run/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
+24
-3
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user