diff --git a/.gitignore b/.gitignore index bd96461..f9f754c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ *.db-wal *.sock *.tmp +/deploy/production/server.toml +/deploy/production/state/ +/deploy/production/run/ diff --git a/deploy/production/README.md b/deploy/production/README.md new file mode 100644 index 0000000..eeb33bd --- /dev/null +++ b/deploy/production/README.md @@ -0,0 +1,28 @@ +# Production-shaped RVBox Linux-server Compose deployment + +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`: + +```sh +cp ../../docs/examples/server.toml server.toml +install -d -m 0700 -o 65532 -g 65532 state run +chmod 0640 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: + +```sh +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. diff --git a/deploy/production/compose.yaml b/deploy/production/compose.yaml new file mode 100644 index 0000000..21b69fe --- /dev/null +++ b/deploy/production/compose.yaml @@ -0,0 +1,68 @@ +# Production-shaped Linux server deployment. Copy server.toml from docs/examples/ +# and supply the TLS certificate/key as read-only files. +# +# `rvbox-server` stays private to this Compose network: nginx is the only public +# listener. JSON-RPC remains disabled in server.toml by default. +name: rvbox-server + +services: + server: + image: "${RVBOX_SERVER_IMAGE:?set RVBOX_SERVER_IMAGE to a pinned rvbox-server image}" + restart: unless-stopped + command: ["--config", "/etc/rvbox/server.toml"] + read_only: true + tmpfs: + - /tmp:mode=1777,size=32m + volumes: + - type: bind + source: ./server.toml + target: /etc/rvbox/server.toml + read_only: true + - type: bind + source: ./state + target: /var/lib/rvbox-server + - type: bind + source: ./run + target: /run/rvbox + expose: + - "6899" + - "6901" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6901/readyz"] + interval: 15s + timeout: 5s + retries: 4 + start_period: 20s + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + + nginx: + image: nginx:1.27.5-alpine + restart: unless-stopped + read_only: true + depends_on: + server: + condition: service_healthy + ports: + - "${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 + volumes: + - type: bind + source: ./nginx.conf + target: /etc/nginx/conf.d/default.conf + read_only: true + - type: bind + source: ${RVBOX_TLS_CERT:?set RVBOX_TLS_CERT to the public certificate path} + target: /etc/nginx/tls/server.pem + read_only: true + - type: bind + source: ${RVBOX_TLS_KEY:?set RVBOX_TLS_KEY to the private key path} + target: /etc/nginx/tls/server-key.pem + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: ["ALL"] + cap_add: ["NET_BIND_SERVICE"] diff --git a/deploy/production/nginx.conf b/deploy/production/nginx.conf new file mode 100644 index 0000000..3cd74dc --- /dev/null +++ b/deploy/production/nginx.conf @@ -0,0 +1,42 @@ +# TLS terminator for the RVBox agent WebSocket. Do not add a JSON-RPC route: +# its deliberately unauthenticated debug adapter is loopback-only by default. +map $http_upgrade $rvbox_connection_upgrade { + default upgrade; + '' close; +} + +upstream rvbox_agent { + server server:6899; +} + +server { + listen 443 ssl; + server_name _; + + ssl_certificate /etc/nginx/tls/server.pem; + ssl_certificate_key /etc/nginx/tls/server-key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + location = /v1/agent { + proxy_pass http://rvbox_agent; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $rvbox_connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 75s; + proxy_send_timeout 15s; + proxy_buffering off; + } + + location = /livez { + proxy_pass http://server:6901/livez; + } + + location = /readyz { + proxy_pass http://server:6901/readyz; + } + + location / { + return 404; + } +} diff --git a/deploy/systemd/rvbox-server.service b/deploy/systemd/rvbox-server.service new file mode 100644 index 0000000..89a01f5 --- /dev/null +++ b/deploy/systemd/rvbox-server.service @@ -0,0 +1,34 @@ +[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/README.md b/docs/README.md index 5f64144..d26c3fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,8 +9,8 @@ Read the documents in this order: script transfer, heartbeat, and failure containment. 3. [Control plane](control-plane.md) — `rvc`, Unix-socket gRPC, JSON-RPC, and query/foreground semantics. -4. [Platform and operations](platform-and-operations.md) — Unix/Windows - contracts, recovery, storage safety, telemetry, and defaults. +4. [Platform and operations](platform-and-operations.md) — Windows contract, + recovery, storage safety, telemetry, and defaults. 5. [Configuration contract](configuration.md) — strict TOML loading, shell resolution, cross-field validation, and annotated server/client examples. 6. [Go implementation plan](implementation-plan.v1.md) — phased build order, @@ -22,6 +22,8 @@ Read the documents in this order: procedure, and known limitations. 9. [Interactive VM access](../test/rdp-access/README.md) — temporary, self-signed HTTPS browser gateway for the rare manual UAC recovery step. +10. [Linux-server operations runbook](operations-runbook.md) — deployment, + backup/restore, upgrade, and incident response. The wire authority is in [`../protos/rvbox/v1`](../protos/rvbox/v1): `common.proto` contains shared data types, `agent.proto` contains the diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index a5af7f5..bd56513 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -11,12 +11,10 @@ binaries: spool, and reconnect/reconciliation owner. - `rvc`: local CLI over the server's Unix-domain gRPC socket. -The first supported client target is Windows connecting to a Linux server; -Linux client support remains part of complete v1 but is explicitly deferred -from the current implementation effort. Implement the Linux server and native -Windows client first; do not begin Unix-like client code now. Build shared -client runtime code behind OS interfaces without prematurely implementing the -Unix supervisor. Windows v1 requires Windows 10 or newer, or Windows Server 2016 +The v1 product boundary is a Linux server and a native Windows client. A +Unix-like client is outside v1 and must not be started as part of this plan. +Build shared client runtime code behind OS interfaces without prematurely +implementing the Unix supervisor. Windows v1 requires Windows 10 or newer, or Windows Server 2016 or newer. Desktop Experience is required only for the tray and active-session command contexts; the service and Session 0 execution contexts support headless Server Core. @@ -2326,7 +2324,7 @@ version. Assert no Task Scheduler object is created or required. loss/restart, execute up to capacity at most once, preserve/replay bounded history, manage complete Job trees, and satisfy tray/elevation/autostart behavior on Windows CI, including all execution-hierarchy rows. Reaching this gate does -not automatically start the deferred Unix-client work; that requires an explicit +not automatically start future post-v1 Unix-client work; that requires an explicit later implementation decision. ## 8. End-to-end agent protocol (Phase 5) @@ -2383,7 +2381,7 @@ the visible CLI result. Include these cross-cutting cases: server demonstrates a complete background command, history/follow behavior, stdin interaction, signal, reconnect, and restart recovery through nginx WSS. This is the first supported-client milestone. It satisfies a prerequisite for -the deferred Linux supervisor work but does not automatically authorize it. +future Linux-supervisor work but does not automatically authorize it. ## 9. Server control plane and `rvc` (Phase 6) @@ -2534,14 +2532,14 @@ stack; the Unix socket has mode `0600`; JSON-RPC behavior matches gRPC unary semantics and is off unless explicitly enabled; the control integration suite can be interrupted, resumed, and reset through the shared run ID. -## 10. Deferred Linux client implementation (Phase 7; still required for full v1) +## 10. Future Unix-like client work (outside v1) -This phase is design-only in the current effort. Do not implement it now. Retain -these tasks so the later Unix-client work completes the already defined v1 -contract without weakening the shipped Windows behavior. +This work is outside the v1 product boundary. Do not implement it now. Retain +these tasks as the starting point for a later Unix-client scope without +weakening the shipped Windows behavior. -Begin this phase only after both an explicit later implementation decision and -the Windows Phase 4/5 release gates. Keep Unix code in platform-specific files/ +Begin this work only after an explicit later implementation decision and the +Windows release gates. Keep Unix code in platform-specific files/ build tags and reuse the proven runtime/store/protocol contracts without changing their wire semantics to suit Linux. @@ -2602,9 +2600,9 @@ reconnect/replay, truncation, scripts, tombstones, queue limits, `/proc` absence, profile failure, and shutdown interruption. Add explicit tests for pidfd/`clone3` availability fallbacks and deliberate `setsid` escape behavior. -**Exit criteria:** the Linux client passes the same protocol/durability suite as -Windows, plus cgroup/process-group tests, without weakening the already shipped -Windows behavior or changing the v1 wire contract. +**Future exit criteria:** the Linux client passes the same protocol/durability +suite as Windows, plus cgroup/process-group tests, without weakening the +already shipped Windows behavior or changing the established wire contract. ## 11. Reliability, observability, and operational delivery (Phase 8) @@ -2691,9 +2689,8 @@ The currently authorized merge order is deliberately vertical: 8. The Windows-applicable Phase 8 operational, packaging, stress, and release gates needed for the Linux-server/Windows-client milestone. -Stop there for the current effort. When Unix-client implementation is explicitly -started later, continue with Phase 7 (Linux supervisor/cgroup and client parity), -then the remaining Phase 8 Unix operational/release gates to complete v1. +Stop there for v1. Any Unix-client implementation is a separately authorized +post-v1 effort and does not change this milestone's completion criteria. Do not merge a later vertical slice by stubbing a durability/safety invariant. For example: foreground mode may wait on a durable background command, but must @@ -2701,12 +2698,9 @@ not bypass persistence; client output may be truncated under the documented caps, but must never block a child pipe; and a reconnection may replay work, but may never re-execute an already accepted UUID. -The current Windows-client milestone is releasable only after Phases 0–6 plus its -applicable Phase 8 packaging/security gates pass on native Windows and a clean -Linux server environment. Windows support cannot be marked optional or replaced -by cross-compilation-only checks. Stop the current implementation effort at that -milestone; Phase 7 remains deferred until explicitly started later. Full v1 is -ready only after that later Linux client also passes the common protocol/ -durability suite and Linux-specific cgroup/process tests. Every release must +The Linux-server/Windows-client v1 milestone is releasable only after Phases 0–6 +plus its applicable Phase 8 packaging/security gates pass on native Windows and +a clean Linux server environment. Windows support cannot be marked optional or +replaced by cross-compilation-only checks. Every release must conspicuously document self-reported identity, the elevated Windows execution authority, and unauthenticated optional JSON-RPC. diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md new file mode 100644 index 0000000..28f44fe --- /dev/null +++ b/docs/operations-runbook.md @@ -0,0 +1,74 @@ +# RVBox Linux-server operations runbook + +This runbook covers the v1 Linux server and native Windows clients. It does not +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. + +For Compose, follow the setup in +[`deploy/production/README.md`](../deploy/production/README.md), set a pinned +`RVBOX_SERVER_IMAGE`, `RVBOX_TLS_CERT`, and `RVBOX_TLS_KEY`, then run: + +```sh +docker compose -f deploy/production/compose.yaml config +docker compose -f deploy/production/compose.yaml up -d +docker compose -f deploy/production/compose.yaml ps +``` + +The first command must succeed before any containers start. `/livez` shows that +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 +down, then copy the entire configured `server.data_dir` tree. That tree contains +SQLite, WAL/SHM state, retained command segments, audit segments, and incidents; +backing up SQLite alone is incomplete. + +To restore, keep the server stopped, move the failed directory aside without +deleting it, restore the complete backup with ownership restricted to the +server account, and run `rvbox-server --check-config` followed by a normal +start. Keep readiness under observation. A failed recovery leaves readiness +false and records an incident; do not delete segments to force readiness. + +## Upgrade and rollback + +1. Record the running image/binary digest and `rvc stat` output. +2. Stop the server gracefully so no new dispatch is accepted. +3. Take a full data-directory backup as above. +4. Install the new image/binary without changing configuration, and run + `--check-config` before starting it. +5. Start, wait for `/readyz`, and verify `rvc stat` plus one known client + reconnect. +6. If readiness or storage recovery fails, stop, restore the prior binary/image + and full data directory, then start the known-good version. Preserve logs + and the failed copy for diagnosis. + +## Common incidents + +- **No client / stale session:** verify nginx has WebSocket `101` entries for + `/v1/agent`, server readiness is true, and the Windows service is running. + Use `rvc stat CLIENT_ID`; do not restart the client merely to clear history. +- **Spool or storage full:** `CAPACITY_EXHAUSTED` is intentional admission + protection. Inspect command retention and free space, allow terminal-age or + quota rotation to reclaim eligible data, or enlarge the owned filesystem. + Never manually remove live SQLite/WAL/segment files. +- **Output truncated:** query command status and output history for its explicit + loss/truncation markers. The command may still have completed correctly. +- **Server restart / dirty health:** wait for readiness and inspect the + incident record. Use the documented repair/acknowledgement controls only + after preserving evidence; a late client report is authoritative and is not + rewritten to match an earlier provisional state. diff --git a/docs/platform-and-operations.md b/docs/platform-and-operations.md index 606bdf9..34338f4 100644 --- a/docs/platform-and-operations.md +++ b/docs/platform-and-operations.md @@ -4,9 +4,9 @@ Daemon configuration uses strict TOML as specified in [`configuration.md`](configuration.md); the annotated examples contain every v1 knob and default. -The current implementation scope is the Linux server plus Windows client. The -Unix-like client remains part of complete v1 but is deferred and must not be -implemented yet. Its section below preserves the agreed future v1 contract; the +The v1 implementation scope is the Linux server plus Windows client. A +Unix-like client is post-v1 work and must not be implemented as part of this +milestone. Its section below is retained only as future design material; the document order does not authorize or reprioritize that work. ## Unix-like clients diff --git a/docs/testing.md b/docs/testing.md index 028b774..383462f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -99,6 +99,8 @@ respective durability or wire boundary: ```sh scripts/test-env doctor scripts/test-integration --suite sample --run-id my-sample +scripts/test-integration --list +scripts/test-integration --suite store --case '^TestStore' --run-id store-one-case scripts/test-env status --run-id my-sample scripts/test-env collect --run-id my-sample scripts/test-env reset --run-id my-sample @@ -120,6 +122,8 @@ scripts/test-env reset --run-id session-smoke scripts/test-env purge --run-id session-smoke scripts/test-e2e --scenario smoke --run-id e2e-smoke +scripts/test-e2e --list +scripts/test-e2e --scenario smoke --case '^Test' --run-id e2e-one-case scripts/test-env status --run-id e2e-smoke scripts/test-env recover --run-id e2e-smoke scripts/test-e2e --scenario smoke --run-id e2e-smoke --resume diff --git a/test/harness/harness.go b/test/harness/harness.go index ba0873b..4714d2d 100644 --- a/test/harness/harness.go +++ b/test/harness/harness.go @@ -38,6 +38,7 @@ type manifest struct { RunID string `toml:"run_id" json:"run_id"` Layer string `toml:"layer" json:"layer"` Suite string `toml:"suite" json:"suite"` + TestCase string `toml:"test_case,omitempty" json:"test_case,omitempty"` Seed int64 `toml:"seed" json:"seed"` GitCommit string `toml:"git_commit" json:"git_commit"` DirtyDiffSHA256 string `toml:"dirty_diff_sha256" json:"dirty_diff_sha256"` @@ -63,6 +64,40 @@ type harness struct { out io.Writer } +var integrationSuites = []string{"sample", "store", "server-session"} +var e2eScenarios = []string{"smoke", "script", "recovery", "all"} + +func containsChoice(choices []string, value string) bool { + for _, choice := range choices { + if choice == value { + return true + } + } + return false +} + +func printChoices(out io.Writer, heading string, choices []string) error { + if _, err := fmt.Fprintf(out, "%s:\n", heading); err != nil { + return err + } + for _, choice := range choices { + if _, err := fmt.Fprintf(out, "%s\n", choice); err != nil { + return err + } + } + return nil +} + +func validateTestCase(value string) error { + if value == "" { + return nil + } + if _, err := regexp.Compile(value); err != nil { + return fmt.Errorf("--case must be a valid Go test regexp: %w", err) + } + return nil +} + func runCLI(ctx context.Context, args []string) error { repoRoot, err := os.Getwd() if err != nil { @@ -123,13 +158,27 @@ func (h *harness) integration(ctx context.Context, args []string) error { flags := flag.NewFlagSet("integration", flag.ContinueOnError) flags.SetOutput(io.Discard) suite := flags.String("suite", "sample", "suite name") + list := flags.Bool("list", false, "list stable suites") + testCase := flags.String("case", "", "Go test name regexp for one suite") runID := flags.String("run-id", "", "run ID") resume := flags.Bool("resume", false, "resume an existing run") if err := flags.Parse(args); err != nil { return err } - if *suite != "sample" && *suite != "store" && *suite != "server-session" { - return fmt.Errorf("suite %q is not implemented yet; available: sample, store, server-session", *suite) + if *list { + if flags.NArg() != 0 || *runID != "" || *resume || *testCase != "" || *suite != "sample" { + return errors.New("--list cannot be combined with run options") + } + return printChoices(h.out, "integration suites", integrationSuites) + } + if !containsChoice(integrationSuites, *suite) { + return fmt.Errorf("suite %q is not implemented yet; available: %s", *suite, strings.Join(integrationSuites, ", ")) + } + if err := validateTestCase(*testCase); err != nil { + return err + } + if *suite == "sample" && *testCase != "" { + return errors.New("--case is only supported by executable integration suites") } var current *manifest @@ -145,6 +194,9 @@ func (h *harness) integration(ctx context.Context, args []string) error { if current.Layer != "integration" || current.Suite != *suite { return errors.New("run layer/suite does not match resume request") } + if current.TestCase != *testCase { + return errors.New("run test case does not match resume request") + } if current.Phase != "ready" && current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" && current.Phase != "failed" { return fmt.Errorf("run in phase %q is not resumable; recover or reuse it first", current.Phase) } @@ -154,6 +206,10 @@ func (h *harness) integration(ctx context.Context, args []string) error { return err } } + current.TestCase = *testCase + if err := h.writeManifest(current); err != nil { + return err + } fmt.Fprintln(h.out, current.RunID) if err := h.transition(current, "running", *suite+"-start", *suite+" integration run started"); err != nil { return err @@ -172,9 +228,9 @@ func (h *harness) integration(ctx context.Context, args []string) error { var suiteErr error switch *suite { case "store": - suiteErr = h.runStoreSuite(ctx, current) + suiteErr = h.runStoreSuite(ctx, current, *testCase) case "server-session": - suiteErr = h.runServerSessionSuite(ctx, current) + suiteErr = h.runServerSessionSuite(ctx, current, *testCase) } if suiteErr != nil { _ = h.transition(current, "failed", *suite+"-failed", suiteErr.Error()) @@ -192,14 +248,28 @@ func (h *harness) integration(ctx context.Context, args []string) error { func (h *harness) e2e(ctx context.Context, args []string) error { flags := flag.NewFlagSet("e2e", flag.ContinueOnError) flags.SetOutput(io.Discard) - scenario := flags.String("scenario", "smoke", "scenario name: smoke, script, recovery, or all") + scenario := flags.String("scenario", "smoke", "scenario name") + list := flags.Bool("list", false, "list stable scenarios") + testCase := flags.String("case", "", "Go test name regexp for one scenario") runID := flags.String("run-id", "", "run ID") resume := flags.Bool("resume", false, "resume an existing run") if err := flags.Parse(args); err != nil { return err } - if *scenario != "smoke" && *scenario != "script" && *scenario != "recovery" && *scenario != "all" { - return fmt.Errorf("scenario %q is not implemented yet; available: smoke, script, recovery, all", *scenario) + if *list { + if flags.NArg() != 0 || *runID != "" || *resume || *testCase != "" || *scenario != "smoke" { + return errors.New("--list cannot be combined with run options") + } + return printChoices(h.out, "e2e scenarios", e2eScenarios) + } + if !containsChoice(e2eScenarios, *scenario) { + return fmt.Errorf("scenario %q is not implemented yet; available: %s", *scenario, strings.Join(e2eScenarios, ", ")) + } + if err := validateTestCase(*testCase); err != nil { + return err + } + if *scenario == "all" && *testCase != "" { + return errors.New("--case requires one named e2e scenario") } var current *manifest var err error @@ -214,6 +284,9 @@ func (h *harness) e2e(ctx context.Context, args []string) error { if current.Layer != "e2e" || current.Suite != *scenario { return errors.New("run layer/scenario does not match resume request") } + if current.TestCase != *testCase { + return errors.New("run test case does not match resume request") + } if current.Phase != "ready" && current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" && current.Phase != "failed" { return fmt.Errorf("run in phase %q is not resumable; recover or reuse it first", current.Phase) } @@ -223,6 +296,10 @@ func (h *harness) e2e(ctx context.Context, args []string) error { return err } } + current.TestCase = *testCase + if err := h.writeManifest(current); err != nil { + return err + } fmt.Fprintln(h.out, current.RunID) if err := h.transition(current, "running", "e2e-start", "e2e scenario started"); err != nil { return err @@ -232,7 +309,7 @@ func (h *harness) e2e(ctx context.Context, args []string) error { scenarios = []string{"smoke", "script", "recovery"} } for _, item := range scenarios { - if err := h.runE2EScenario(ctx, current, item); err != nil { + if err := h.runE2EScenario(ctx, current, item, *testCase); err != nil { _ = h.transition(current, "failed", "e2e-"+item+"-failed", err.Error()) return err } @@ -240,18 +317,18 @@ func (h *harness) e2e(ctx context.Context, args []string) error { return h.transition(current, "completed", "e2e-complete", "e2e scenario completed") } -func (h *harness) runE2EScenario(ctx context.Context, current *manifest, scenario string) error { +func (h *harness) runE2EScenario(ctx context.Context, current *manifest, scenario, testCase string) error { if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "e2e-" + scenario, Status: "running", Detail: "scenario started"}); err != nil { return err } var err error switch scenario { case "smoke": - err = h.runGoSuite(ctx, current, "e2e-client-agent", "real client/server WebSocket and control flow", "./test/integration/clientagent") + err = h.runGoSuite(ctx, current, "e2e-client-agent", "real client/server WebSocket and control flow", "./test/integration/clientagent", testCase) case "script": - err = h.runGoSuite(ctx, current, "e2e-script-transfer", "durable script transfer and replay", "./internal/client/agent") + err = h.runGoSuite(ctx, current, "e2e-script-transfer", "durable script transfer and replay", "./internal/client/agent", testCase) case "recovery": - err = h.runStoreSuite(ctx, current) + err = h.runStoreSuite(ctx, current, testCase) default: err = fmt.Errorf("unknown e2e scenario %q", scenario) } @@ -261,7 +338,7 @@ 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 (h *harness) runStoreSuite(ctx context.Context, current *manifest) 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 { return err } @@ -270,7 +347,12 @@ func (h *harness) runStoreSuite(ctx context.Context, current *manifest) error { return err } capture := &limitedCapture{limit: maxSuiteLogSize} - command := exec.CommandContext(ctx, "go", "test", "-count=1", "-tags=integration", "-shuffle="+strconv.FormatInt(current.Seed, 10), "-timeout=2m", "./test/integration/store") + arguments := []string{"test", "-count=1", "-tags=integration", "-shuffle=" + strconv.FormatInt(current.Seed, 10), "-timeout=2m"} + if testCase != "" { + arguments = append(arguments, "-run", testCase) + } + arguments = append(arguments, "./test/integration/store") + command := exec.CommandContext(ctx, "go", arguments...) command.Stdout = capture command.Stderr = capture err := command.Run() @@ -284,11 +366,11 @@ func (h *harness) runStoreSuite(ctx context.Context, current *manifest) error { return h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "store-real-sqlite", Status: "passed", Detail: "real SQLite/WAL and filesystem cases passed"}) } -func (h *harness) runServerSessionSuite(ctx context.Context, current *manifest) error { - return h.runGoSuite(ctx, current, "server-session-real-websocket", "running real HTTP/WebSocket, protobuf, SQLite, and fencing cases", "./internal/server/session") +func (h *harness) runServerSessionSuite(ctx context.Context, current *manifest, testCase string) error { + return h.runGoSuite(ctx, current, "server-session-real-websocket", "running real HTTP/WebSocket, protobuf, SQLite, and fencing cases", "./internal/server/session", testCase) } -func (h *harness) runGoSuite(ctx context.Context, current *manifest, step, detail, packagePath string) error { +func (h *harness) runGoSuite(ctx context.Context, current *manifest, step, detail, packagePath, testCase string) error { if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: step, Status: "running", Detail: detail}); err != nil { return err } @@ -297,7 +379,12 @@ func (h *harness) runGoSuite(ctx context.Context, current *manifest, step, detai return err } capture := &limitedCapture{limit: maxSuiteLogSize} - command := exec.CommandContext(ctx, "go", "test", "-race", "-count=1", "-shuffle="+strconv.FormatInt(current.Seed, 10), "-timeout=2m", packagePath) + arguments := []string{"test", "-race", "-count=1", "-shuffle=" + strconv.FormatInt(current.Seed, 10), "-timeout=2m"} + if testCase != "" { + arguments = append(arguments, "-run", testCase) + } + arguments = append(arguments, packagePath) + command := exec.CommandContext(ctx, "go", arguments...) command.Stdout = capture command.Stderr = capture err := command.Run() diff --git a/test/harness/harness_test.go b/test/harness/harness_test.go index f27daa3..e881dbb 100644 --- a/test/harness/harness_test.go +++ b/test/harness/harness_test.go @@ -123,6 +123,32 @@ func TestE2EScenarioValidationAndRunIdentity_HP_E2E_01(t *testing.T) { } } +func TestStableSuiteAndScenarioListing_HP_CFG_01(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + h := &harness{root: t.TempDir(), now: time.Now, out: &output} + 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 { + 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 { + t.Fatalf("e2e list = %q, want %q", got, want) + } + if err := h.integration(context.Background(), []string{"--suite", "store", "--case", "["}); err == nil { + t.Fatal("invalid --case regexp succeeded") + } + if err := h.e2e(context.Background(), []string{"--scenario", "all", "--case", "Test"}); err == nil { + t.Fatal("--case with all scenarios succeeded") + } +} + func TestBoundedSuiteLogAndFailedRunRecovery_BH_STORE_03(t *testing.T) { t.Parallel()