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