test: add resumable isolated test harness
This commit is contained in:
@@ -42,7 +42,8 @@ test-e2e:
|
||||
./scripts/test-e2e
|
||||
|
||||
test-status:
|
||||
./scripts/test-env status
|
||||
@test -n "$(RUN_ID)" || (echo "RUN_ID is required" >&2; exit 2)
|
||||
./scripts/test-env status --run-id "$(RUN_ID)"
|
||||
|
||||
_toolchain-generate:
|
||||
buf generate
|
||||
@@ -53,6 +54,7 @@ _toolchain-fmt:
|
||||
|
||||
_toolchain-lint:
|
||||
./scripts/check-buf-lint
|
||||
go run ./test/harness coverage
|
||||
go vet $(GO_PACKAGES)
|
||||
|
||||
_toolchain-test:
|
||||
@@ -73,4 +75,3 @@ _toolchain-build:
|
||||
|
||||
_toolchain-verify: _toolchain-fmt _toolchain-lint _toolchain-test _toolchain-build
|
||||
git diff --exit-code -- gen/go
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ Read the documents in this order:
|
||||
resolution, cross-field validation, and annotated server/client examples.
|
||||
6. [Go implementation plan](implementation-plan.v1.md) — phased build order,
|
||||
package boundaries, storage/session/client details, tests, and release gates.
|
||||
7. [Implementation testing](testing.md) — container entry points, run
|
||||
lifecycle, exact cleanup, and coverage inventory.
|
||||
|
||||
The wire authority is in [`../protos/rvbox/v1`](../protos/rvbox/v1):
|
||||
`common.proto` contains shared data types, `agent.proto` contains the
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# RVBox implementation test workflow
|
||||
|
||||
All Go, protobuf, and harness work runs in the pinned toolchain container. No
|
||||
host Go installation is used.
|
||||
|
||||
Run the normal pre-commit gate with:
|
||||
|
||||
```sh
|
||||
make verify
|
||||
```
|
||||
|
||||
Run focused unit tests with:
|
||||
|
||||
```sh
|
||||
scripts/test-unit --package ./internal/domain --run UUIDv7 --race
|
||||
```
|
||||
|
||||
The integration harness currently provides the Phase 0 `sample` suite. It
|
||||
proves run isolation and the durable lifecycle without starting an RVBox
|
||||
service that has not been implemented yet:
|
||||
|
||||
```sh
|
||||
scripts/test-env doctor
|
||||
scripts/test-integration --suite sample --run-id my-sample
|
||||
scripts/test-env status --run-id my-sample
|
||||
scripts/test-env collect --run-id my-sample
|
||||
scripts/test-env reset --run-id my-sample
|
||||
scripts/test-env reuse --run-id my-sample
|
||||
scripts/test-integration --suite sample --run-id my-sample --resume
|
||||
scripts/test-env reset --run-id my-sample
|
||||
scripts/test-env purge --run-id my-sample
|
||||
```
|
||||
|
||||
Each run owns only `.test-runs/<run-id>` and resources explicitly recorded in
|
||||
that run's versioned manifest. The journal is append-only and fsynced. `purge`
|
||||
validates the run ID and manifest identity, refuses symlink targets or manifests
|
||||
that still list runtime resources, and then removes only that exact run. Purged
|
||||
artifacts are not recoverable. Dependency cache volumes are never part of run
|
||||
cleanup.
|
||||
|
||||
`test/coverage.toml` is the incremental requirement-to-test inventory. The
|
||||
`make verify` lint stage checks unique stable IDs and verifies every implemented
|
||||
test reference against source. Native Windows integration/E2E entries remain
|
||||
explicitly blocked until the resettable Windows host is available; Wine or a
|
||||
protocol stub is not treated as equivalent coverage.
|
||||
|
||||
+12
-15
@@ -1,20 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
command=${1:-}
|
||||
case "$command" in
|
||||
doctor)
|
||||
docker version >/dev/null
|
||||
docker compose version >/dev/null
|
||||
echo "Docker and Docker Compose are available."
|
||||
;;
|
||||
status)
|
||||
echo "No test run has been created; the shared harness is not implemented yet."
|
||||
;;
|
||||
*)
|
||||
echo "usage: scripts/test-env doctor|status" >&2
|
||||
echo "recover/reuse/collect/reset/purge arrive with the Phase 0 harness implementation" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
if [ -z "$command" ]; then
|
||||
echo "usage: scripts/test-env doctor|coverage|status|logs|collect|recover|reuse|stop|reset|purge [--run-id ID]" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
|
||||
docker version >/dev/null
|
||||
docker compose version >/dev/null
|
||||
|
||||
cd "$repo_root"
|
||||
exec docker compose -f deploy/compose.yaml run --rm toolchain \
|
||||
go run ./test/harness "$command" "$@"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "integration harness is not implemented yet; use scripts/test-unit for the current Phase 0/1 scope" >&2
|
||||
exit 2
|
||||
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
toolchain_image=$(docker compose -f "$repo_root/deploy/compose.yaml" images -q toolchain 2>/dev/null || true)
|
||||
if [ -z "$toolchain_image" ]; then
|
||||
toolchain_image=$(docker image inspect rvbox-dev-toolchain --format '{{.Id}}' 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
cd "$repo_root"
|
||||
exec docker compose -f deploy/compose.yaml run --rm \
|
||||
-e "RVBOX_TOOLCHAIN_IMAGE=$toolchain_image" \
|
||||
toolchain go run ./test/harness integration "$@"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
version = 1
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-CFG-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = [
|
||||
"internal/config/config_test.go:TestAnnotatedExamples_HP_CFG_01",
|
||||
"internal/config/config_test.go:TestStrictTOMLRejections_HP_CFG_01",
|
||||
"internal/config/config_test.go:TestExplicitFlagPrecedence_HP_CFG_01",
|
||||
"test/harness/harness_test.go:TestSampleRunLifecycle_HP_CFG_01",
|
||||
]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-IDEM-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/id_test.go:TestUUIDv7CanonicalValidation_HP_IDEM_01"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-IDEM-02"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/idempotency_test.go:TestImmutableRequestReplay_HP_IDEM_02"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-IDEM-10"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = [
|
||||
"internal/domain/id_test.go:TestUUIDv7GeneratorMonotonicAcrossClockRollback_HP_IDEM_10",
|
||||
"internal/domain/id_test.go:TestUUIDv7GeneratorConcurrentOrderingAndUniqueness_HP_IDEM_10",
|
||||
]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-CMD-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = [
|
||||
"internal/domain/lifecycle_test.go:TestLifecycleTransitionMatrix_HP_CMD_01",
|
||||
"internal/domain/lifecycle_test.go:TestTerminalPredicateExhaustive_HP_CMD_01",
|
||||
"internal/domain/revision_test.go:TestRevisionComparisonAndAdvance_HP_CMD_01",
|
||||
]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-LAUNCH-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/lifecycle_test.go:TestLaunchBarrier_HP_LAUNCH_01"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-PROTO-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/sequence_test.go:TestOutputTruncationRanges_HP_PROTO_01"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-PROTO-09"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = [
|
||||
"internal/domain/errors_test.go:TestDomainErrorMapping_HP_PROTO_09",
|
||||
"internal/domain/errors_test.go:TestDomainErrorBoundsAndCause_HP_PROTO_09",
|
||||
]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-CLIENT-07"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/sequence_test.go:TestEventSequenceAndDuplicateEquivalence_HP_CLIENT_07"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-WINCTX-01"
|
||||
layer = "unit"
|
||||
status = "implemented"
|
||||
tests = ["internal/domain/errors_test.go:TestWindowsPrelaunchErrorCodes_HP_WINCTX_01"]
|
||||
|
||||
[[requirements]]
|
||||
id = "HP-WINCTX-02"
|
||||
layer = "integration"
|
||||
status = "blocked_native_windows"
|
||||
tests = []
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
manifestVersion = 1
|
||||
repositoryID = "rvbox"
|
||||
)
|
||||
|
||||
var runIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
type manifest struct {
|
||||
Version uint32 `toml:"version" json:"version"`
|
||||
RepositoryID string `toml:"repository_id" json:"repository_id"`
|
||||
RunID string `toml:"run_id" json:"run_id"`
|
||||
Layer string `toml:"layer" json:"layer"`
|
||||
Suite string `toml:"suite" json:"suite"`
|
||||
Seed int64 `toml:"seed" json:"seed"`
|
||||
GitCommit string `toml:"git_commit" json:"git_commit"`
|
||||
DirtyDiffSHA256 string `toml:"dirty_diff_sha256" json:"dirty_diff_sha256"`
|
||||
ToolchainImage string `toml:"toolchain_image" json:"toolchain_image"`
|
||||
ComposeProject string `toml:"compose_project" json:"compose_project"`
|
||||
Phase string `toml:"phase" json:"phase"`
|
||||
CreatedAt time.Time `toml:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `toml:"updated_at" json:"updated_at"`
|
||||
OwnedResources []string `toml:"owned_resources" json:"owned_resources"`
|
||||
}
|
||||
|
||||
type journalEntry struct {
|
||||
At time.Time `json:"at"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func runCLI(ctx context.Context, args []string) error {
|
||||
repoRoot, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := &harness{root: filepath.Join(repoRoot, ".test-runs"), now: func() time.Time { return time.Now().UTC() }, out: os.Stdout}
|
||||
if len(args) == 0 {
|
||||
return usageError()
|
||||
}
|
||||
if args[0] != "doctor" {
|
||||
unlock, err := acquireLock(filepath.Join(repoRoot, ".test-harness.lock"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
}
|
||||
switch args[0] {
|
||||
case "doctor":
|
||||
return h.doctor(repoRoot)
|
||||
case "coverage":
|
||||
return validateCoverageInventory(filepath.Join(repoRoot, "test", "coverage.toml"))
|
||||
case "integration":
|
||||
return h.integration(ctx, args[1:])
|
||||
case "status", "logs", "collect", "recover", "reuse", "stop", "reset", "purge":
|
||||
return h.environmentCommand(args[0], args[1:])
|
||||
default:
|
||||
return usageError()
|
||||
}
|
||||
}
|
||||
|
||||
func usageError() error {
|
||||
return errors.New("usage: harness doctor|coverage|integration|status|logs|collect|recover|reuse|stop|reset|purge")
|
||||
}
|
||||
|
||||
func (h *harness) doctor(repoRoot string) error {
|
||||
for _, file := range []string{"go.mod", "deploy/compose.yaml", "docs/implementation-plan.v1.md"} {
|
||||
if _, err := os.Stat(filepath.Join(repoRoot, file)); err != nil {
|
||||
return fmt.Errorf("repository check %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(h.root, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
probe, err := os.CreateTemp(h.root, ".doctor-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("test run root is not writable: %w", err)
|
||||
}
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
_ = os.Remove(name)
|
||||
fmt.Fprintln(h.out, "RVBox test harness is ready; native Windows availability remains a separate host gate.")
|
||||
return nil
|
||||
}
|
||||
|
||||
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")
|
||||
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" {
|
||||
return fmt.Errorf("suite %q is not implemented yet; available: sample", *suite)
|
||||
}
|
||||
|
||||
var current *manifest
|
||||
var err error
|
||||
if *resume {
|
||||
if *runID == "" {
|
||||
return errors.New("--resume requires --run-id")
|
||||
}
|
||||
current, err = h.load(*runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Layer != "integration" || current.Suite != *suite {
|
||||
return errors.New("run layer/suite does not match resume request")
|
||||
}
|
||||
if current.Phase != "ready" && current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" {
|
||||
return fmt.Errorf("run in phase %q is not resumable; recover or reuse it first", current.Phase)
|
||||
}
|
||||
} else {
|
||||
current, err = h.create(*runID, "integration", *suite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(h.out, current.RunID)
|
||||
if err := h.transition(current, "running", "sample-start", "sample integration run started"); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = h.transition(current, "interrupted", "interrupt", ctx.Err().Error())
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "invariant", Status: "passed", Detail: "manifest ownership and journal durability verified"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.transition(current, "completed", "sample-complete", "sample integration run completed")
|
||||
}
|
||||
|
||||
func (h *harness) environmentCommand(command string, args []string) error {
|
||||
flags := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
runID := flags.String("run-id", "", "run ID")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *runID == "" {
|
||||
return fmt.Errorf("%s requires --run-id", command)
|
||||
}
|
||||
current, err := h.load(*runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch command {
|
||||
case "status":
|
||||
encoded, _ := json.MarshalIndent(current, "", " ")
|
||||
fmt.Fprintln(h.out, string(encoded))
|
||||
return nil
|
||||
case "logs":
|
||||
data, err := os.ReadFile(h.journalPath(current.RunID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = h.out.Write(data)
|
||||
return err
|
||||
case "collect":
|
||||
return h.collect(current)
|
||||
case "recover":
|
||||
if current.Phase != "interrupted" && current.Phase != "stopped" && current.Phase != "running" {
|
||||
return fmt.Errorf("run in phase %q does not need recovery", current.Phase)
|
||||
}
|
||||
return h.transition(current, "ready", "recover", "run recovered and ready to resume")
|
||||
case "reuse":
|
||||
if current.Phase != "reset" && current.Phase != "completed" && current.Phase != "ready" {
|
||||
return fmt.Errorf("run in phase %q cannot be reused", current.Phase)
|
||||
}
|
||||
return h.transition(current, "ready", "reuse", "run retained for reuse")
|
||||
case "stop":
|
||||
return h.transition(current, "stopped", "stop", "owned runtime resources stopped")
|
||||
case "reset":
|
||||
return h.transition(current, "reset", "reset", "owned runtime state reset; manifest and journal retained")
|
||||
case "purge":
|
||||
return h.purge(current)
|
||||
default:
|
||||
return usageError()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *harness) create(requestedID, layer, suite string) (*manifest, error) {
|
||||
if requestedID == "" {
|
||||
id, err := domain.NewUUIDv7()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestedID = id.String()
|
||||
}
|
||||
if err := validateRunID(requestedID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runDir := h.runDir(requestedID)
|
||||
if err := os.MkdirAll(h.root, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Mkdir(runDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create run %s: %w", requestedID, err)
|
||||
}
|
||||
seedValue, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commit := commandOutput("git", "rev-parse", "HEAD")
|
||||
diffHash := repositoryDirtyHash()
|
||||
now := h.now()
|
||||
current := &manifest{
|
||||
Version: manifestVersion, RepositoryID: repositoryID, RunID: requestedID,
|
||||
Layer: layer, Suite: suite, Seed: seedValue.Int64(), GitCommit: strings.TrimSpace(commit),
|
||||
DirtyDiffSHA256: hex.EncodeToString(diffHash[:]), ToolchainImage: os.Getenv("RVBOX_TOOLCHAIN_IMAGE"),
|
||||
ComposeProject: "rvbox-test-" + requestedID, Phase: "created", CreatedAt: now, UpdatedAt: now,
|
||||
OwnedResources: []string{},
|
||||
}
|
||||
if err := h.writeManifest(current); err != nil {
|
||||
_ = os.Remove(runDir)
|
||||
return nil, err
|
||||
}
|
||||
if err := h.appendJournal(requestedID, journalEntry{At: now, Step: "create", Status: "passed", Data: map[string]any{"seed": current.Seed}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (h *harness) transition(current *manifest, phase, step, detail string) error {
|
||||
current.Phase = phase
|
||||
current.UpdatedAt = h.now()
|
||||
if err := h.writeManifest(current); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.appendJournal(current.RunID, journalEntry{At: current.UpdatedAt, Step: step, Status: phase, Detail: detail})
|
||||
}
|
||||
|
||||
func (h *harness) load(runID string) (*manifest, error) {
|
||||
if err := validateRunID(runID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(h.manifestPath(runID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var current manifest
|
||||
decoder := toml.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(¤t); err != nil {
|
||||
return nil, fmt.Errorf("decode run manifest: %w", err)
|
||||
}
|
||||
if current.Version != manifestVersion || current.RepositoryID != repositoryID || current.RunID != runID {
|
||||
return nil, errors.New("run manifest identity/version mismatch")
|
||||
}
|
||||
return ¤t, nil
|
||||
}
|
||||
|
||||
func (h *harness) writeManifest(current *manifest) error {
|
||||
data, err := toml.Marshal(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return atomicWrite(h.manifestPath(current.RunID), data, 0o600)
|
||||
}
|
||||
|
||||
func (h *harness) appendJournal(runID string, entry journalEntry) error {
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(h.journalPath(runID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := file.Write(append(data, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func (h *harness) collect(current *manifest) error {
|
||||
reportDir := filepath.Join(h.runDir(current.RunID), "artifacts")
|
||||
if err := os.MkdirAll(reportDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
report := map[string]any{"run_id": current.RunID, "layer": current.Layer, "suite": current.Suite, "phase": current.Phase, "collected_at": h.now(), "payloads_included": false}
|
||||
data, _ := json.MarshalIndent(report, "", " ")
|
||||
path := filepath.Join(reportDir, "report.json")
|
||||
if err := atomicWrite(path, append(data, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.appendJournal(current.RunID, journalEntry{At: h.now(), Step: "collect", Status: "passed", Detail: "bounded redacted report written"}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(h.out, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *harness) purge(current *manifest) error {
|
||||
runDir := h.runDir(current.RunID)
|
||||
info, err := os.Lstat(runDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("refusing to purge a symlink or non-directory run path")
|
||||
}
|
||||
if len(current.OwnedResources) != 0 {
|
||||
return errors.New("refusing to purge while manifest still lists owned runtime resources; reset first")
|
||||
}
|
||||
if err := os.RemoveAll(runDir); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(h.out, "purged %s (not recoverable)\n", runDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *harness) runDir(runID string) string { return filepath.Join(h.root, runID) }
|
||||
func (h *harness) manifestPath(runID string) string {
|
||||
return filepath.Join(h.runDir(runID), "run.toml")
|
||||
}
|
||||
func (h *harness) journalPath(runID string) string {
|
||||
return filepath.Join(h.runDir(runID), "journal.jsonl")
|
||||
}
|
||||
|
||||
func validateRunID(runID string) error {
|
||||
if !runIDPattern.MatchString(runID) || runID == "." || runID == ".." {
|
||||
return fmt.Errorf("invalid filesystem-safe run ID %q", runID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
directory := filepath.Dir(path)
|
||||
temporary, err := os.CreateTemp(directory, ".write-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err := temporary.Chmod(mode); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
|
||||
func commandOutput(name string, args ...string) string {
|
||||
output, err := exec.Command(name, args...).Output()
|
||||
if err != nil {
|
||||
return "unavailable"
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func repositoryDirtyHash() [sha256.Size]byte {
|
||||
hash := sha256.New()
|
||||
tracked := commandBytes("git", "diff", "--binary", "HEAD", "--", ".")
|
||||
_, _ = hash.Write(tracked)
|
||||
untracked := commandBytes("git", "ls-files", "--others", "--exclude-standard", "-z")
|
||||
for _, name := range bytes.Split(untracked, []byte{0}) {
|
||||
if len(name) == 0 {
|
||||
continue
|
||||
}
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write(name)
|
||||
if data, err := os.ReadFile(string(name)); err == nil {
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write(data)
|
||||
}
|
||||
}
|
||||
var result [sha256.Size]byte
|
||||
copy(result[:], hash.Sum(nil))
|
||||
return result
|
||||
}
|
||||
|
||||
func commandBytes(name string, args ...string) []byte {
|
||||
output, err := exec.Command(name, args...).Output()
|
||||
if err != nil {
|
||||
return []byte("unavailable")
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
type coverageInventory struct {
|
||||
Version uint32 `toml:"version"`
|
||||
Requirements []coverageRequirement `toml:"requirements"`
|
||||
}
|
||||
|
||||
type coverageRequirement struct {
|
||||
ID string `toml:"id"`
|
||||
Layer string `toml:"layer"`
|
||||
Status string `toml:"status"`
|
||||
Tests []string `toml:"tests"`
|
||||
}
|
||||
|
||||
var coverageIDPattern = regexp.MustCompile(`^(HP|ERR|RACE|CRASH|SEC|BOUND|REC)-[A-Z0-9]+-[0-9]{2}$`)
|
||||
|
||||
func validateCoverageInventory(path string) error {
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = absolutePath
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var inventory coverageInventory
|
||||
decoder := toml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&inventory); err != nil {
|
||||
return err
|
||||
}
|
||||
if inventory.Version != 1 || len(inventory.Requirements) == 0 {
|
||||
return errors.New("coverage inventory needs version 1 and at least one requirement")
|
||||
}
|
||||
seen := make(map[string]bool, len(inventory.Requirements))
|
||||
repositoryRoot := filepath.Dir(filepath.Dir(path))
|
||||
for _, requirement := range inventory.Requirements {
|
||||
if !coverageIDPattern.MatchString(requirement.ID) || seen[requirement.ID] {
|
||||
return fmt.Errorf("invalid or duplicate coverage ID %q", requirement.ID)
|
||||
}
|
||||
seen[requirement.ID] = true
|
||||
if requirement.Layer != "unit" && requirement.Layer != "integration" && requirement.Layer != "e2e" {
|
||||
return fmt.Errorf("coverage %s has invalid layer %q", requirement.ID, requirement.Layer)
|
||||
}
|
||||
if requirement.Status != "implemented" && requirement.Status != "planned" && requirement.Status != "blocked_native_windows" {
|
||||
return fmt.Errorf("coverage %s has invalid status %q", requirement.ID, requirement.Status)
|
||||
}
|
||||
if requirement.Status == "implemented" && len(requirement.Tests) == 0 {
|
||||
return fmt.Errorf("implemented coverage %s has no tests", requirement.ID)
|
||||
}
|
||||
for _, reference := range requirement.Tests {
|
||||
parts := strings.Split(reference, ":")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || filepath.IsAbs(parts[0]) || strings.Contains(parts[0], "..") {
|
||||
return fmt.Errorf("coverage %s has invalid test reference %q", requirement.ID, reference)
|
||||
}
|
||||
source, err := os.ReadFile(filepath.Join(repositoryRoot, filepath.FromSlash(parts[0])))
|
||||
if err != nil {
|
||||
return fmt.Errorf("coverage %s test reference: %w", requirement.ID, err)
|
||||
}
|
||||
if !bytes.Contains(source, []byte("func "+parts[1]+"(")) {
|
||||
return fmt.Errorf("coverage %s test function %q not found", requirement.ID, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSampleRunLifecycle_HP_CFG_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
var output bytes.Buffer
|
||||
h := &harness{root: root, now: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() }, out: &output}
|
||||
current, err := h.create("sample-run", "integration", "sample")
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if current.Phase != "created" || current.ComposeProject != "rvbox-test-sample-run" {
|
||||
t.Fatalf("manifest = %+v", current)
|
||||
}
|
||||
if err := h.transition(current, "interrupted", "interrupt", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := h.load("sample-run")
|
||||
if err != nil || loaded.Phase != "interrupted" {
|
||||
t.Fatalf("load = (%+v, %v)", loaded, err)
|
||||
}
|
||||
if err := h.collect(loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "sample-run", "artifacts", "report.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.transition(loaded, "reset", "reset", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.purge(loaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "sample-run")); !os.IsNotExist(err) {
|
||||
t.Fatalf("purged run still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIsolationAndManifestIdentity_HP_CFG_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &harness{root: t.TempDir(), now: time.Now, out: &bytes.Buffer{}}
|
||||
first, err := h.create("first-run", "integration", "sample")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := h.create("second-run", "integration", "sample")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.purge(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.load(second.RunID); err != nil {
|
||||
t.Fatalf("unrelated run was affected: %v", err)
|
||||
}
|
||||
|
||||
manifestPath := h.manifestPath(second.RunID)
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = bytes.Replace(data, []byte(`repository_id = 'rvbox'`), []byte(`repository_id = 'other'`), 1)
|
||||
data = bytes.Replace(data, []byte(`repository_id = "rvbox"`), []byte(`repository_id = "other"`), 1)
|
||||
if err := os.WriteFile(manifestPath, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.load(second.RunID); err == nil {
|
||||
t.Fatal("foreign manifest accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIDTraversalAndSymlinkPurgeRejected_HP_CFG_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range []string{"", "../escape", "/absolute", "UPPER", strings.Repeat("a", 65)} {
|
||||
if err := validateRunID(value); err == nil {
|
||||
t.Errorf("validateRunID(%q) succeeded", value)
|
||||
}
|
||||
}
|
||||
root := t.TempDir()
|
||||
h := &harness{root: root, now: time.Now, out: &bytes.Buffer{}}
|
||||
target := t.TempDir()
|
||||
if err := os.Symlink(target, filepath.Join(root, "linked-run")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.purge(&manifest{RunID: "linked-run"}); err == nil {
|
||||
t.Fatal("symlink purge succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationResumeValidation_HP_CFG_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &harness{root: t.TempDir(), now: time.Now, out: &bytes.Buffer{}}
|
||||
if err := h.integration(context.Background(), []string{"--resume", "--suite", "sample"}); err == nil {
|
||||
t.Fatal("resume without run ID succeeded")
|
||||
}
|
||||
if err := h.integration(context.Background(), []string{"--suite", "unknown"}); err == nil {
|
||||
t.Fatal("unknown suite succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageInventoryReferencesExistingTests_HP_CFG_01(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateCoverageInventory(filepath.Join("..", "coverage.toml")); err != nil {
|
||||
t.Fatalf("validateCoverageInventory(): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func acquireLock(path string) (func(), error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("another test harness process holds %s: %w", path, err)
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
|
||||
_ = file.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func acquireLock(path string) (func(), error) {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("another test harness process may hold %s: %w", path, err)
|
||||
}
|
||||
return func() {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(path)
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
if err := runCLI(ctx, os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "test harness:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user