mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
18aa67b0e9
* 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.
177 lines
6.0 KiB
Go
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
|
|
}
|