Files
Bryn Mailer 18aa67b0e9 feat: add the e2e guest disk-performance gate (#964)
* feat: add the e2e guest disk-performance gate

Nothing measured guest disk performance in CI, which is why a quadratic
index scan shipped in viperblock 35dd9f2 and survived three subsequent
fixes to adjacent code. It is also why the two availability clauses at
the end of the ebs-performance plan's Stage 0 have never been evaluated:
a sustained random-write workload took the guest, and then the host,
down, and nothing since has demonstrated that it no longer does.

tests/e2e/diskperf/ launches an instance, attaches a dedicated blank
volume per repetition, and drives a pinned fio profile over guest SSH.
Two profiles: randwrite-4k at 4 jobs x 4 GiB, the shape the exit
criterion names, and randrw-70-30-4k, the mixed workload the headline
IOPS came from. The target is a raw device with direct=1 rather than a
file on the boot disk, so guest page cache, filesystem allocation and
journal traffic are out of the path.

Two classes of assertion, kept apart because folding them together lets
a run that left the host unreachable pass on a respectable IOPS figure.
Availability is hard pass/fail and absolute, so it gates from the first
run: the job completes inside its budget, a guest responsiveness probe
answers throughout on a second SSH channel, and the host's own sshd
answers throughout. The guest probe reads the root volume while the
workload runs on a different one, so a pass is evidence of isolation
between volumes rather than of the job merely being slow. The host probe
is a TCP connect plus an SSH banner read, which needs no credential and
still proves the kernel scheduled sshd.

Throughput and latency are banded against a committed baseline: fail
above 25% regression, warn above 10%, per-metric median across
repetitions. baseline.json is committed and embedded, with every figure
zeroed, and a zero reference records and warns rather than failing. Only
the availability class gates until numbers are captured on the bare-metal
cell; a throughput band nobody measured would be a fabricated gate.

A fresh volume per repetition is the reset. Extent count grows
monotonically, so reusing a volume makes the gate order-dependent and
drifts its own numbers upward over time, slowly normalising the
regression it exists to catch. Each job is its own guest invocation and
fio's JSON is fetched by a separate command, so a wedged job loses only
itself and reports as a budget breach rather than a parse failure.

e2e-baremetal.yml gains suites, suite-env and cell inputs so one reusable
workflow serves both the correctness cell and nightly cell 28, which
queues behind cell 20 on the shared concurrency group. Cells 25-27 are
the dedicated eks, ecs and rds cells. The per-suite Go test timeout is
resolved from e2e_suite_timeout in suite-sets.sh, where diskperf declares
its 180-minute budget beside rds's rather than in a second place.

The gate's own arithmetic runs in preflight via test-harness: band edges,
the unmeasured cases, per-stream metric selection, fio JSON parsing, and
the probe accounting including a slow-but-successful answer counting as a
breach. A gate whose comparison logic is untested is the failure mode the
plan is otherwise full of.

The extent-count ceiling named as a fourth availability assertion is not
implemented: it needs viperblock.index.entries from S1.5, which does not
exist.

* feat: let a manual bare-metal run choose its e2e suites

A dispatch run only sees inputs declared under workflow_dispatch, which had
none, so suites and suite-env resolved empty and fell back to the correctness
set. Capturing a diskperf baseline by hand was therefore unreachable without a
workflow_call caller.

* fix: build the bare-metal suites the run step will execute

The build step packs pre-built binaries into the tarball and did not see the
suite selection, so a selected suite was absent from the node unless it
happened to be one of the three the build script hardcoded.

* feat: commit the measured disk-performance baseline

Medians of three repetitions on the bare-metal cell. The throughput and
latency bands now gate instead of warning, so a 2x regression in either
job fails the suite.

randwrite-4k's write tail is two orders above the mixed job's. It is
recorded as measured rather than zeroed: an ungated metric would miss the
regression class the gate exists to catch.
2026-09-03 14:33:21 +10:00

177 lines
6.0 KiB
Go

//go:build e2e
// Package diskperf is the guest disk-performance gate. It launches an
// instance, attaches a dedicated blank data volume, drives a pinned fio
// profile inside the guest, and asserts two separable classes of property.
//
// Availability assertions are hard pass/fail with zero tolerance: the fio job
// completes inside its budget, a guest responsiveness probe on a second SSH
// channel answers throughout, and the host's own sshd answers throughout. A
// healthy system sits orders of magnitude away from these bounds and a broken
// one sits orders of magnitude past them, so they do not flap. They are the
// class that catches a volume workload taking its guest, its neighbours or its
// host down, and they are meaningful on the very first run because they are
// absolute rather than relative.
//
// Throughput and latency assertions are banded against a committed baseline:
// fail above a 25% regression, warn above 10%. Wide enough to survive
// bare-metal jitter, tight enough to catch a 2x. A baseline entry that is
// absent records and warns instead of failing, so the gate can run before
// hardware numbers exist rather than blocking on them.
//
// Three things about the workload are deliberate. The target is a raw
// unformatted device rather than a file on the boot disk, because a filesystem
// layers guest page cache, allocation and journal traffic over what is being
// measured. Each job gets its own freshly created volume, because the extent
// index grows monotonically and reusing a volume makes results order-dependent
// and drifting upward over time -- a gate that slowly normalises the
// regression it exists to catch. And the guest responsiveness probe reads the
// root volume, not the volume under test, so a passing probe is evidence of
// isolation between volumes rather than of the workload merely being slow.
//
// The suite is excluded from the default e2e run and selected only by its own
// make target, like gpu/ and storagegrowth/. Nothing under tests/e2e/harness/
// is modified by it: that path is an infra glob, and editing it forces the
// full CI matrix on every change.
package diskperf
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/mulgadc/spinifex/tests/e2e/harness"
)
var (
pkgFixOnce sync.Once
pkgFix *Fixture
pkgFixErr error
pkgSkipReason string
)
func TestMain(m *testing.M) {
code := m.Run()
if pkgFix != nil {
if pkgFix.Harness != nil {
// A leaked resource fails the run: the suite may have passed, but it
// left state on the node that the next run will trip over.
if err := pkgFix.Harness.Close(); err != nil {
fmt.Fprintf(os.Stderr, "e2e teardown: %v\n", err)
code = 1
}
}
}
os.Exit(code)
}
// Fixture carries per-process state shared across disk-performance tests.
type Fixture struct {
Env *harness.Env
AWS *harness.AWSClient
Harness *harness.Fixture
// PoolMode reports external_mode="pool". Guest SSH is the only channel for
// driving fio and for the responsiveness probe, and a nat-mode env has no
// EIP and no inbound path, so the suite skips outright without it.
PoolMode bool
}
// ArtifactDir returns the artifact directory for the currently running test.
// Derived per-call from the live t rather than memoized on the singleton, so
// it does not freeze to whichever test happened to build the fixture.
func (f *Fixture) ArtifactDir(t *testing.T) string {
t.Helper()
return harness.ArtifactDir(t, f.Env)
}
// requireDiskPerfFixture returns the package-scoped Fixture singleton, building
// it on first call. Skips the calling test when SPINIFEX_E2E is unset, when the
// mode is not "single", or when external_mode is not "pool".
func requireDiskPerfFixture(t *testing.T) *Fixture {
t.Helper()
pkgFixOnce.Do(func() {
if os.Getenv("SPINIFEX_E2E") == "" {
return
}
env := harness.LoadEnv(t)
if env.Mode != harness.ModeSingle {
pkgSkipReason = "diskperf suite requires SPINIFEX_MODE=single"
return
}
// Guard against NewAWSClient calling t.Fatal, which exits via
// runtime.Goexit and corrupts the Once state for subsequent tests.
if os.Getenv("SPINIFEX_AWS_INSECURE") != "1" {
if _, err := harness.ResolveCACert(env); err != nil {
pkgSkipReason = "no Spinifex CA cert found: " + err.Error() +
" — provision a local node first (ansible-playbook ansible/playbooks/dev-reset.yml), " +
"or target a remote cluster with SPINIFEX_AWS_INSECURE=1 (skips CA verification; see harness/aws.go)"
return
}
}
awsCli := harness.NewAWSClient(t, env)
h, err := harness.NewProcessFixture(awsCli)
if err != nil {
pkgFixErr = err
return
}
harness.EnsureDefaultSGOpen(t, awsCli)
pkgFix = &Fixture{
Env: env,
AWS: awsCli,
Harness: h,
PoolMode: detectPoolMode(env),
}
})
if pkgFixErr != nil {
t.Fatalf("diskperf fixture init: %v", pkgFixErr)
}
if pkgFix == nil {
if pkgSkipReason != "" {
t.Skip(pkgSkipReason)
}
t.Skip("SPINIFEX_E2E unset")
}
if !pkgFix.PoolMode {
t.Skip("diskperf suite requires external_mode=pool — a nat env has no EIP and cannot reach guest SSH, the only channel for driving fio")
}
return pkgFix
}
// detectPoolMode reads external_mode from spinifex.toml, defaulting to false.
// Copied from single/'s helper rather than imported: tests/e2e/harness/** is
// an infra glob this suite deliberately never touches.
func detectPoolMode(env *harness.Env) bool {
cfg := os.ExpandEnv("$HOME/spinifex/config/spinifex.toml")
if env.ConfigDir != "" {
cfg = filepath.Join(env.ConfigDir, "spinifex.toml")
}
f, err := os.Open(cfg)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
inNetwork := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") {
inNetwork = line == "[network]"
continue
}
if !inNetwork || !strings.HasPrefix(line, "external_mode") {
continue
}
if _, rhs, ok := strings.Cut(line, "="); ok {
return strings.Trim(strings.TrimSpace(rhs), "\"'") == "pool"
}
}
return false
}