feat: add Linux server operational delivery assets
This commit is contained in:
@@ -8,3 +8,6 @@
|
||||
*.db-wal
|
||||
*.sock
|
||||
*.tmp
|
||||
/deploy/production/server.toml
|
||||
/deploy/production/state/
|
||||
/deploy/production/run/
|
||||
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+105
-18
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user