Files
spinifex/tests/e2e/storagegrowth/image_pull_test.go
T
2026-08-04 14:20:19 +10:00

1461 lines
73 KiB
Go

//go:build e2e
package storagegrowth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/tests/e2e/harness"
"github.com/stretchr/testify/require"
)
const (
// imagePullDevice is the guest-visible attach point requested for the
// workload volume. Distinct from npassDevice so the two workloads can
// never collide if a future change runs them against the same instance.
imagePullDevice = "/dev/sdg"
imagePullVolumeLabel = "imagepull"
// imagePullMount is the persistent mountpoint for the workload volume.
// Unlike npass's format-write-unmount cycle, this mount stays live for
// the whole pull: it doubles as the container runtime's data-root, so
// every layer byte the pull writes lands on the measured volume instead
// of the AMI's root disk.
imagePullMount = "/mnt/imagepull"
// imagePullMaxDuration bounds the whole install+pull background job.
// Generous because it only needs to cover the small (~100-500MB)
// verification image comfortably; a full-size sweep point may need this
// raised if a much larger image is ever driven through this workload.
imagePullMaxDuration = 60 * time.Minute
// imagePullDefaultImage is a public image pinned by digest, used when
// SPINIFEX_STORAGEGROWTH_IMAGE is unset. docker.io/library/node:20 as of
// writing: ~398MB compressed across many distinct layers — big enough to
// write a materially growing, non-repeating footprint (the whole point of
// this workload versus npass, see below), small enough that a routine run
// finishes in a few minutes. The full-size scenario this workload exists
// to reproduce (~13GB) is supplied by overriding the env var, not by
// raising this default.
imagePullDefaultImage = "docker.io/library/node@sha256:cacf10e99285cbbc891452e31249c1b5ec3ba225f40028fae946b75aeaf1b66a"
// Dedicated VPC CIDRs for this workload's own topology (see
// ensureImagePullInstance). Distinct range from any other suite's probe
// VPCs so a leaked resource from a failed run is easy to attribute.
imagePullVPCCIDR = "10.212.0.0/16"
imagePullWorkerSubnetCIDR = "10.212.1.0/24"
imagePullPubSubnetCIDR = "10.212.2.0/24"
// predastoreBaseDir mirrors scripts/storage-repro/probe.sh's BASE_PATH:
// the whole on-disk tree predastore stores segments under on the node.
// du -s --block-size=1 reports ALLOCATED blocks, not apparent size, so
// the sparse badger value-log holes are not billed as real bytes (see
// probe.sh's remote_allocated_bytes for the same reasoning).
predastoreBaseDir = "/var/lib/spinifex/predastore"
// imagePullTargetEnv selects which disk the pull's bytes land on. See
// imagePullTarget's doc for the two accepted values and what each means.
imagePullTargetEnv = "SPINIFEX_STORAGEGROWTH_TARGET"
// imagePullTargetVolume is the original workload: a fresh, empty,
// independently-created data volume. Default when the env var is unset.
imagePullTargetVolume = "volume"
// imagePullTargetRoot targets the instance's own root volume instead —
// see imagePullTarget's doc.
imagePullTargetRoot = "root"
// imagePullRootDevice is the root volume's guest-visible device name in
// root mode. service_impl.go's parseVolumeParams defaults to exactly this
// string when BlockDeviceMappings[0].DeviceName is unset on a
// RunInstances request, and DescribeInstances reports the same name back
// for a virtio-blk-pci root volume. This constant is used both to request
// the mapping and to re-identify it afterward.
imagePullRootDevice = "/dev/vda"
// imagePullPullCyclesEnv selects how many pull-then-free cycles the guest
// script drives — see imagePullPullCycles's doc for what a value above 1
// changes and why it is root-mode only.
imagePullPullCyclesEnv = "SPINIFEX_STORAGEGROWTH_PULL_CYCLES"
)
// imagePullGuestScript is written to the guest as /tmp/run-pull.sh and run
// under systemd-run (see launchImagePullUnit) so it survives the SSH session
// that launched it — the same reason single/datapath_test.go's HTTP server
// uses systemd-run rather than bare nohup: `nohup ... &` alone can race the
// launching session's exit and be reaped with it.
//
// It installs docker.io from the distro archive (no extra APT repo or GPG
// key needed, and the guest already has real internet egress via the
// workload VPC's IGW-routed public subnet — see ensureImagePullInstance),
// redirects storage onto the mounted volume BEFORE starting the daemons,
// then pulls the pinned image. The trap on EXIT fires on every exit path
// (success, any `exit N`, or an unhandled signal), so the exit-code marker
// file it writes is a single place the sampling loop can always eventually
// read a definitive status from.
//
// Two storage roots are redirected, not one. docker.io on current Ubuntu
// ships dockerd talking to a separate system containerd (containerd.service),
// and it is containerd — not dockerd — that extracts layers onto disk via its
// own "root" (default /var/lib/containerd), independently of dockerd's
// data-root in /etc/docker/daemon.json. A first version of this script only
// redirected dockerd's data-root and failed a live run with ENOSPC extracting
// a layer into /var/lib/containerd/tmpmounts on the AMI's small root disk,
// despite the 20GiB target volume sitting empty: data-root alone redirects
// where dockerd stores image references, not where the bytes actually land.
const imagePullGuestScript = `#!/bin/bash
set -u
trap 'echo $? > /tmp/run-pull.exitcode' EXIT
echo running > /tmp/run-pull.status
export DEBIAN_FRONTEND=noninteractive
apt-get update -y || exit 10
apt-get install -y docker.io || exit 11
systemctl stop docker.service docker.socket containerd.service 2>/dev/null || true
mkdir -p %[1]s/docker %[1]s/containerd /etc/docker /etc/containerd
containerd config default > /etc/containerd/config.toml || exit 12
sed -i 's#^root = .*#root = "%[1]s/containerd"#' /etc/containerd/config.toml || exit 13
grep -q "^root = \"%[1]s/containerd\"" /etc/containerd/config.toml || exit 14
cat > /etc/docker/daemon.json <<'JSON'
{"data-root": "%[1]s/docker"}
JSON
systemctl start containerd.service || exit 15
systemctl start docker.service || exit 16
echo pulling > /tmp/run-pull.status
docker pull %[2]s || exit 17
echo done > /tmp/run-pull.status
`
// imagePullGuestScriptRoot is the root-mode counterpart of imagePullGuestScript.
// It installs docker and pulls image the exact same way, but deliberately
// skips both redirects above: dockerd and containerd are left pointed at
// their stock locations (/var/lib/docker, /var/lib/containerd), so every byte
// the pull writes lands on the instance's own root disk rather than a mounted
// volume. That is the entire reason root mode exists — see TestImagePullGrowth's
// doc for why the root disk, not the data volume, is the surface the real
// incident filled. Same status/exitcode file protocol as
// imagePullGuestScript, so the existing polling/verification logic in
// imagePullUnitStatus reads it unchanged. Exit codes 10, 11 and 17 are kept
// aligned with imagePullGuestScript's numbering for the steps both scripts
// share; 12-16 (the redirect steps) are simply unused here.
const imagePullGuestScriptRoot = `#!/bin/bash
set -u
trap 'echo $? > /tmp/run-pull.exitcode' EXIT
echo running > /tmp/run-pull.status
export DEBIAN_FRONTEND=noninteractive
apt-get update -y || exit 10
apt-get install -y docker.io || exit 11
echo pulling > /tmp/run-pull.status
docker pull %[1]s || exit 17
echo done > /tmp/run-pull.status
`
// imagePullGuestScriptRootCycles is imagePullGuestScriptRoot's multi-cycle
// counterpart, used only when SPINIFEX_STORAGEGROWTH_PULL_CYCLES is set above
// 1 (see imagePullPullCycles). Same install steps and the same root-disk
// target (no redirect — see imagePullGuestScriptRoot's doc for why root mode
// skips it), but instead of a single `docker pull ... || exit 17`, it drives
// N pull-then-free attempts: on a disk too small for the image, an early
// cycle fills the disk and the pull fails, and the script then frees the
// guest-side space the same way containerd's own image GC would on a real
// cluster — drop the image reference and prune whatever partial/dangling
// content the failed pull left behind — before the next cycle retries.
// `docker system prune -af` is used for the free step rather than calling
// `ctr` (containerd's own CLI) directly: dockerd's containerd namespace
// ("moby" by default) and ctr's exact prune-subcommand surface are not
// something this change can verify are stable across the containerd version
// the docker.io apt package pulls in, whereas `docker system prune` is a
// documented, stable dockerd client command that — because dockerd asks
// containerd to drop the same underlying content — produces the same effect.
// The point of freeing the space at all is that the NEXT cycle's pull then
// rewrites the same freed guest block addresses: that address reuse is what
// makes an append-only backend store grow by ~a full disk each cycle while
// guest usage itself stays flat, which is the phenomenon this mode exists to
// reproduce: the retry loop a real EKS GPU worker hit when its root disk was
// too small for the image to ever fit.
//
// A per-cycle marker line is both echoed to stdout (journalctl -u
// mulga-imagepull captures it for a human reading the failure log) and
// appended to /tmp/run-pull.cycles, a stable file the Go side reads once
// after the loop settles rather than trying to tail a live unit's stdout
// (see readImagePullCycles). The current cycle number is also kept in
// /tmp/run-pull.cycle, overwritten every iteration, so the periodic sampling
// loop already running on the Go side can tag each sample with the cycle in
// flight when it was taken (see imagePullCurrentCycle).
//
// Unlike the single-pull scripts, an individual cycle's pull failure never
// collapses onto the script's own exit code: a full disk mid-cycle is the
// expected, measured phenomenon here, not a script fault, and a test that
// t.Fatalf'd on it would discard exactly the data this mode exists to
// collect. The EXIT trap's code — and therefore imagePullUnitStatus's
// failed/done result — reports only whether the N-cycle loop itself ran to
// completion; exit 10/11 (the apt-get steps) are the only failures that
// still abort the whole run, kept aligned with imagePullGuestScriptRoot's
// numbering for the steps the two scripts share.
const imagePullGuestScriptRootCycles = `#!/bin/bash
set -u
trap 'echo $? > /tmp/run-pull.exitcode' EXIT
echo running > /tmp/run-pull.status
export DEBIAN_FRONTEND=noninteractive
apt-get update -y || exit 10
apt-get install -y docker.io || exit 11
: > /tmp/run-pull.cycles
for i in $(seq 1 %[2]d); do
echo $i > /tmp/run-pull.cycle
echo "pulling (cycle $i/%[2]d)" > /tmp/run-pull.status
docker pull %[1]s
rc=$?
used=$(df --output=used -B1 / | tail -n1)
echo "CYCLE n=$i exit=$rc guest_used=$used elapsed=$SECONDS" | tee -a /tmp/run-pull.cycles
if [ "$rc" -ne 0 ]; then
docker rmi -f %[1]s >/dev/null 2>&1 || true
docker system prune -af >/dev/null 2>&1 || true
fi
done
echo done > /tmp/run-pull.status
`
// imagePullSample is one point in the time series captured throughout the
// pull, not just at the start and end. Correlating these four quantities at
// the SAME instant is the entire reason this workload measures host-side at
// all — TestNPassOverwrite measures nothing host-side, deferring entirely to
// external before/after snapshot tooling, because a before/after snapshot
// cannot show whether backend growth tracked guest writes linearly or spiked
// disproportionately partway through.
type imagePullSample struct {
ElapsedSecs float64 `json:"elapsed_secs"`
GuestUsedBytes int64 `json:"guest_used_bytes"`
HostAllocatedBytes int64 `json:"host_allocated_bytes"`
NbdkitRSSKiB int64 `json:"nbdkit_rss_kib"`
CheckpointBytes int64 `json:"checkpoint_bytes"`
// CheckpointLastModified is blocks.live.bin's S3 LastModified at this
// sample, RFC3339Nano, empty if the object does not exist yet (no drain
// has fired). SaveLiveCheckpointCtx always rewrites this one fixed key in
// place, so a change in this timestamp between two samples is direct
// evidence a drain happened in between — see imagePullWorkload's doc for
// why this is the only drain signal available to this workload.
CheckpointLastModified string `json:"checkpoint_last_modified,omitempty"`
// CheckpointProbeErr is non-empty when the on-node HeadObject probe failed
// for a reason other than the object legitimately not existing yet — an auth
// rejection or a transport error. CheckpointBytes is forced to -1 alongside
// it so a broken probe is recorded as void rather than masquerading as a
// genuine zero reading: a dead instrument must never look like "no growth".
CheckpointProbeErr string `json:"checkpoint_probe_err,omitempty"`
// Cycle is the pull-then-free cycle in flight when this sample was taken
// (see imagePullPullCycles), 1-indexed. Zero, and omitted, outside cycles
// mode: the guest script never writes /tmp/run-pull.cycle at all there
// (see imagePullGuestScriptRoot/imagePullGuestScript), so there is
// nothing to attribute the sample to beyond the run as a whole.
Cycle int `json:"cycle,omitempty"`
}
// imagePullCycle is one pull-then-free attempt's outcome in cycles mode (see
// imagePullPullCycles), parsed from one "CYCLE n=... exit=... guest_used=...
// elapsed=..." marker line imagePullGuestScriptRootCycles appends to
// /tmp/run-pull.cycles. Populated only when PullCycles > 1; empty otherwise.
type imagePullCycle struct {
Cycle int `json:"cycle"`
// ExitCode is the guest's raw docker-pull exit status for this cycle, not
// a derived classification. It cannot be told apart from any other pull
// failure (a registry timeout, a bad digest) any more than
// imagePullWorkload.PullExitCode can in single-pull mode — see that
// field's doc for why no enospc flag is reported instead of inferring one
// from docker's stderr wording. A full disk is inferred, if at all, the
// same way: GuestUsedBytes pinned near the volume's capacity across
// several cycles is evidence, scored by the measurement step reading
// this record, not by this workload fabricating a classification it
// cannot actually observe.
ExitCode int `json:"exit_code"`
// GuestUsedBytes is the guest's own df reading taken by the script
// immediately after this cycle's pull attempt settled, before the
// free-and-retry step ran — the high-water mark this cycle reached.
GuestUsedBytes int64 `json:"guest_used_bytes"`
// ElapsedSecs is bash's own $SECONDS at the point this cycle's marker was
// written, i.e. wall-clock time since the guest script itself started —
// not since the Go-side sampling loop started, which begins a moment
// earlier (see runImagePull).
ElapsedSecs float64 `json:"elapsed_secs"`
// PullCompleted is ExitCode == 0, recorded as its own field rather than
// left for a consumer to re-derive, matching
// imagePullWorkload.PullCompleted's reasoning.
PullCompleted bool `json:"pull_completed"`
}
// imagePullDrainCountNote is a fixed explanation of a signal this workload
// cannot report, carried verbatim in every record — see
// imagePullWorkload.DrainCountAvailable for why it is recorded rather than
// omitted. A const rather than an inline literal because it describes
// viperblock's telemetry, not anything a given run did, so nothing about a
// run can change it.
const imagePullDrainCountNote = "viperblock/telemetry exposes no per-drain or checkpoint-write counter: " +
"only backend-IO, WAL-op, and cache-lookup metrics are recorded, none keyed to " +
"distinguish a checkpoint write from an ordinary chunk write. A precise count needs " +
"either a viperblock-side counter (out of scope for this workload) or predastore " +
"access-log parsing for PUT .../checkpoints/blocks.live.bin, which requires debug " +
"logging predastore does not run with by default. Each sample's " +
"checkpoint_last_modified is the available mitigation: since SaveLiveCheckpointCtx " +
"always rewrites the fixed key <volume>/checkpoints/blocks.live.bin in place, a " +
"change in that timestamp between two samples is evidence a drain occurred between " +
"them, and lower-bounds the drain count at this sample interval."
// imagePullWorkload records what one invocation of the image-pull workload
// did, plus the sampled time series, for an external measurement step to
// read. Mirrors npassWorkload's contract (same result-path env var, same
// guest_used_bytes/volume_id/retained field names) with fields npassWorkload
// has no use for (passes, extent, per-pass sha) replaced by fields specific
// to a single, uncontrolled-pattern write.
//
// npass rewrites one fixed extent every pass, which pins the block map's size
// and makes the per-pass backend cost provably linear in N — useful for
// isolating the unreferenced-live leak, but structurally incapable of testing
// whether checkpoint-rewrite cost is quadratic in bytes written, because the
// block map it reserialises on every drain never grows. This workload writes
// DISTINCT, monotonically growing data instead (an image pull neither
// controls nor repeats its own byte pattern), so it is the first shape able
// to answer that question: SaveLiveCheckpointCtx reserialises the entire
// block map on every drain, so if checkpoint cost scales with block-map size,
// the samples here should show host_allocated_bytes and checkpoint_bytes
// growing super-linearly against guest_used_bytes as the pull proceeds, not
// just being offset from it by a constant multiple.
type imagePullWorkload struct {
Image string `json:"image"`
// Target is imagePullTargetVolume or imagePullTargetRoot — which disk the
// pull actually landed on. See imagePullTarget's doc for what each means;
// an external measurement step needs this to know whether VolumeID names
// a freshly-created data volume or the instance's own root volume.
Target string `json:"target"`
VolumeGiB int `json:"volume_gib"`
VolumeID string `json:"volume_id"`
// Retained reports whether the volume outlived the test process, exactly
// as npassWorkload.Retained does — a measurement taken after DeleteVolume
// purges the volume's objects sees no leak whether or not one occurred.
Retained bool `json:"retained"`
GuestUsedBytes int64 `json:"guest_used_bytes"`
Samples []imagePullSample `json:"samples"`
SampleIntervalSecs int `json:"sample_interval_secs"`
// PullDurationSecs is time to completion, or time to failure when
// PullCompleted is false.
PullDurationSecs float64 `json:"pull_duration_secs"`
// PullCompleted reports whether the pull ran to completion. False is a
// recorded outcome, not a void run: an image too large for its volume
// fills the disk, and the runtime's evict-and-re-pull churn that follows
// is the uncontrolled rewrite pattern this workload exists to reproduce,
// so the samples leading up to that failure ARE the measurement. A record
// exists at all only when the run captured real samples and a usable
// denominator — writeImagePullResult owns that boundary, and its absence
// is how a consumer learns a point measured nothing.
PullCompleted bool `json:"pull_completed"`
// PullStatus and PullExitCode are the guest script's own status marker and
// exit code as of the last poll ("pulling" + "17" for a failed pull,
// "done" + "0" for a clean one). Recorded so a consumer can tell an
// outright pull failure from a run that merely hit imagePullMaxDuration
// with the pull still making progress; the latter leaves the exit code
// empty, since the guest script's trap never fired.
//
// Neither field distinguishes a full disk from any other pull failure.
// The guest script collapses every docker pull error onto exit 17 (see
// imagePullGuestScript), so ENOSPC, a registry timeout and a bad digest
// are indistinguishable here, and no ENOSPC flag is reported rather than
// inferring one from docker's stderr wording, which is not a stable
// contract. The evidence a consumer needs is already in the record
// without it: guest_used_bytes pinned at volume_gib across the tail of
// samples is a full disk, and scoring that is the measurement step's job,
// not this workload's — the same division of labour the package doc sets
// out, and the same reasoning as DrainCountAvailable below.
PullStatus string `json:"pull_status,omitempty"`
PullExitCode string `json:"pull_exit_code,omitempty"`
// PullCycles is the configured SPINIFEX_STORAGEGROWTH_PULL_CYCLES value
// this run used — 1 for the original single-pull behaviour. Recorded
// alongside Cycles so a consumer can tell "ran once, that's the whole
// record" (PullCycles==1, Cycles empty) apart from "configured for N
// cycles but Cycles came back short" (a run that hit
// imagePullMaxDuration, or an infra fault, partway through the loop).
PullCycles int `json:"pull_cycles"`
// Cycles is cycles mode's per-cycle breakdown, one entry per pull-then-
// free attempt imagePullGuestScriptRootCycles's marker file recorded.
// Empty when PullCycles == 1: single-pull mode's guest script never
// writes /tmp/run-pull.cycles at all, so there is nothing to read.
Cycles []imagePullCycle `json:"cycles,omitempty"`
// DrainCountAvailable is always false today. Recorded explicitly, rather
// than omitted, so a measurement step reading this file learns that
// without needing this test's source: viperblock/telemetry/metrics.go
// exposes backend-IO, WAL-op, and cache-lookup counters, none keyed to
// distinguish a checkpoint write from an ordinary chunk write, so no
// drain/checkpoint-write count is reachable from outside viperblock
// itself. Per the workload's brief, no such counter was added — that
// repo is owned by another agent. A precise count needs either a
// viperblock-side counter, or predastore access-log parsing for
// `PUT .../checkpoints/blocks.live.bin`, which requires debug logging
// predastore does not run with by default. CheckpointLastModified in each
// sample is the mitigation available without either: a change between
// two samples lower-bounds the drain count at this sample interval.
DrainCountAvailable bool `json:"drain_count_available"`
DrainCountNote string `json:"drain_count_note"`
}
// TestImagePullGrowth pulls a public, digest-pinned container image onto a
// guest disk — sampling guest/host/backend state throughout — then reports
// what it did for an external measurement step to score. See
// imagePullWorkload's doc for why this shape, not npass's, is the one that
// can test whether checkpoint-rewrite cost is quadratic in bytes written.
//
// SPINIFEX_STORAGEGROWTH_TARGET selects which disk the bytes land on; see
// imagePullTarget's doc. Default imagePullTargetVolume mounts a dedicated,
// freshly-created data volume and redirects the guest runtime onto it — the
// original shape, unchanged by root mode's addition. imagePullTargetRoot
// instead lets the pull land on the instance's own root volume, which, unlike
// the freshly-created data volume, is cloned from an AMI snapshot.
//
// That clone/snapshot ancestry was root mode's original hypothesis, and it is
// refuted: measured against the same image set, the two targets amplify
// identically (live chunk bytes grow at 1.0144x guest bytes on root against
// 1.0128x on volume — 0.16% apart), so ancestry costs nothing. Root mode earns
// its place for a different reason: a real ~13GB-pull-to-~150GB-usage report on
// an EKS GPU worker filled the node's ROOT disk, and only root mode can size
// that disk small enough for the pull to run out of space, which is the
// condition the growth actually needs.
//
// Unlike npass, this instance does not use the shared default VPC: guest SSH
// and the pull itself both need real internet egress, and
// harness.InstancePublicSSHHost hard-fails on an instance with no public IP,
// so ensureImagePullInstance builds its own dedicated topology.
func TestImagePullGrowth(t *testing.T) {
fix := requireStorageGrowthFixture(t)
harness.Phase(t, "Storage Growth — Container Image Pull (checkpoint-rewrite quadratic-cost probe)")
image := imagePullImage()
sampleInterval := imagePullSampleInterval()
retain := npassRetainVolume()
target := imagePullTarget()
cycles := imagePullPullCycles()
// Cycles mode is only implemented for the root target: the churn it drives
// needs a guest script that leaves docker's storage on the root disk, and
// imagePullGuestScriptRootCycles is the only such script. Nothing about the
// leak it measures is root-specific — an append-only store grows by the
// bytes rewritten regardless of which volume takes them — so a volume-mode
// cycles script would be a valid control if one is ever written. Fail loud
// rather than silently running a single pull under a cycles-mode config.
if cycles > 1 && target != imagePullTargetRoot {
t.Fatalf("%s=%d requires %s=%s: cycles mode is implemented only by the root-target guest script",
imagePullPullCyclesEnv, cycles, imagePullTargetEnv, imagePullTargetRoot)
}
var (
instanceID string
tgt harness.SSHTarget
volID string
volGiB int
)
switch target {
case imagePullTargetRoot:
volGiB = imagePullRootGiB()
var inst *ec2.Instance
instanceID, tgt, inst = ensureImagePullInstance(t, fix, func(ami, instType, keyName, subnetID, sgID string) string {
return launchImagePullRootInstance(t, fix, ami, instType, keyName, subnetID, sgID, volGiB, retain)
})
volID = imagePullRootVolumeID(t, inst, retain)
// RunInstances created this volume implicitly, so nothing has tagged
// it: createImagePullVolume tags only the data volume it creates
// itself, and root mode bypasses harness.EnsureInstance's tagging.
// It can only be tagged once its id is known, hence here rather than
// in launchImagePullRootInstance.
tagImagePullRunResource(t, fix, volID, retain,
"the teardown sweep and the storage-repro reclaim pass both discover volumes only by this tag, and retain "+
"sets DeleteOnTermination=false so nothing else reclaims this one — untagged it leaks permanently, "+
"holding its chunks live on the backing store and skewing every later run's baseline")
default:
volGiB = imagePullVolumeGiB()
instanceID, tgt, _ = ensureImagePullInstance(t, fix, func(ami, instType, keyName, subnetID, sgID string) string {
return harness.EnsureInstance(t, fix.Harness, harness.InstanceSpec{
AMIID: ami,
InstanceType: instType,
KeyName: keyName,
SubnetID: subnetID,
SGID: sgID,
})
})
az := harness.DiscoverDefaultAZ(t, fix.Harness)
volID = createImagePullVolume(t, fix, az, volGiB, retain)
}
harness.Detail(t, "image", image, "target", target, "volume_gib", volGiB, "instance", instanceID,
"volume", volID, "retain_volume", retain, "sample_interval_secs", sampleInterval, "pull_cycles", cycles)
// The record carries everything already known about the run, and
// runImagePull fills the measured fields into it in place rather than
// returning them, so that the write below still sees whatever was captured
// when the pull fails. A failing pull is not a harness fault whose data can
// be discarded: an image that does not fit its volume is the phenomenon
// under test, and t.Fatalf unwinds via runtime.Goexit, which never reaches
// a statement following the call that failed.
w := &imagePullWorkload{
Image: image,
Target: target,
VolumeGiB: volGiB,
VolumeID: volID,
Retained: retain,
SampleIntervalSecs: sampleInterval,
PullCycles: cycles,
DrainCountAvailable: false,
DrainCountNote: imagePullDrainCountNote,
}
// defer rather than t.Cleanup: runtime.Goexit runs both, but a test's own
// defers run during its unwind, strictly before any cleanup. The record
// therefore lands while the topology is still intact, ahead of the
// terminate-and-wait cleanup ensureImagePullInstance registers — so a run
// killed during a slow teardown has already written its data.
defer writeImagePullResult(t, w)
runImagePull(t, fix, tgt, instanceID, volID, image, volGiB, sampleInterval, target, cycles, w)
harness.Detail(t, "guest_used_bytes", w.GuestUsedBytes, "samples", len(w.Samples),
"pull_duration_secs", fmt.Sprintf("%.1f", w.PullDurationSecs), "cycles_recorded", len(w.Cycles))
}
// ensureImagePullInstance launches a dedicated VPC/subnet topology for this
// workload rather than reusing ensureWorkloadInstance's shared default VPC.
//
// It builds the topology via harness.CreateWorkerEgress — the same helper
// the eks and gpu suites use for private-worker egress — even though this
// instance does not sit in the private worker subnet CreateWorkerEgress is
// designed for: it launches directly in CreateWorkerEgress's returned public
// subnet instead (after flipping MapPublicIpOnLaunch, which CreateSubnet
// defaults false), getting a real public IP and direct IGW egress — the same
// mechanism every default-VPC suite already relies on for guest SSH — without
// the extra NAT-Gateway hop CreateWorkerEgress's own private worker subnet
// would add for no benefit here. The worker subnet CreateWorkerEgress
// requires as an argument is otherwise unused by this suite; the NAT Gateway
// and EIP it provisions are therefore pure topology overhead for this
// particular instance; a leaner build could skip CreateWorkerEgress entirely
// in favour of a single MapPublicIpOnLaunch + IGW + route table, as
// tests/e2e/iam/imds_test.go's imdsAttachInternet does.
//
// The instance launch itself is factored out into launch, which receives the
// resolved AMI/instance-type/key/subnet/SG and returns the new instance ID.
// volume mode's launch calls harness.EnsureInstance unchanged; root mode's
// needs BlockDeviceMappings on the RunInstances request to size and retain
// the root volume, which harness.InstanceSpec has no field for (and this
// suite never edits tests/e2e/harness — see the package doc), so it calls
// fix.AWS.EC2.RunInstances directly instead (launchImagePullRootInstance).
func ensureImagePullInstance(t *testing.T, fix *Fixture, launch func(ami, instType, keyName, subnetID, sgID string) string) (instanceID string, tgt harness.SSHTarget, inst *ec2.Instance) {
t.Helper()
instType, arch := imagePullInstanceType(t, fix)
ami := harness.DiscoverUbuntuAMI(t, fix.Harness, arch)
keyName, keyPath := harness.EnsureKeyPair(t, fix.Harness, fix.ArtifactDir(t))
vpcID := harness.CreateVPC(t, fix.AWS, imagePullVPCCIDR)
t.Cleanup(func() { harness.DeleteVPC(t, fix.AWS, vpcID) })
workerSubnetID := harness.CreateSubnet(t, fix.AWS, vpcID, imagePullWorkerSubnetCIDR)
t.Cleanup(func() { harness.DeleteSubnet(t, fix.AWS, workerSubnetID) })
eg := harness.CreateWorkerEgress(t, fix.AWS, vpcID, workerSubnetID, imagePullPubSubnetCIDR)
t.Cleanup(func() { harness.DeleteWorkerEgress(t, fix.AWS, eg) })
// CreateSubnet defaults MapPublicIpOnLaunch to false; the workload
// instance needs a real public IP (InstancePublicSSHHost hard-fails
// without one), so it is flipped on the public subnet CreateWorkerEgress
// already routed straight to the IGW.
_, err := fix.AWS.EC2.ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{
SubnetId: aws.String(eg.PubSubnetID),
MapPublicIpOnLaunch: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
})
require.NoErrorf(t, err, "modify-subnet-attribute MapPublicIpOnLaunch on %s", eg.PubSubnetID)
sgID := imagePullDefaultSG(t, fix.AWS, vpcID)
harness.AuthorizeSSHIngress(t, fix.AWS, sgID)
instanceID = launch(ami, instType, keyName, eg.PubSubnetID, sgID)
// The launch path's own cleanup (harness.EnsureInstance, for volume mode)
// issues TerminateInstances without waiting for the terminal state, which
// races the network cleanups above: a live run hit exactly this —
// DeleteWorkerEgress tried to delete eg.PubSubnetID while the instance's
// ENI was still attached (still "shutting-down"), failing with
// DependencyViolation. t.Cleanup is LIFO, so registering this wait here —
// after launch — makes it run BEFORE any terminate cleanup launch itself
// registered and every network teardown below it, guaranteeing the ENI is
// gone first. Terminating again here is idempotent with whatever launch's
// own cleanup does later, and is the ONLY terminate cleanup for root
// mode's direct RunInstances call, which registers none of its own.
t.Cleanup(func() {
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: []*string{aws.String(instanceID)}})
harness.WaitForInstanceState(t, fix.AWS, instanceID, "terminated")
})
inst = harness.WaitForInstanceState(t, fix.AWS, instanceID, "running")
host, port := harness.InstancePublicSSHHost(t, inst)
harness.Step(t, "waiting for guest SSH on %s:%d", host, port)
if !harness.TryGuestSSHReady(host, port, "ubuntu", keyPath, 5*time.Minute) {
t.Fatalf("guest %s SSH %s:%d not ready after 5m", instanceID, host, port)
}
return instanceID, harness.SSHTarget{User: "ubuntu", Host: host, Port: port, KeyPath: keyPath}, inst
}
// imagePullDefaultSG returns the auto-created default security group for
// vpcID. Mirrors tests/e2e/iam/imds_test.go's imdsDefaultSG, which is
// unexported in another package and therefore not reachable from here.
func imagePullDefaultSG(t *testing.T, c *harness.AWSClient, vpcID string) string {
t.Helper()
out, err := c.EC2.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpcID)}},
{Name: aws.String("group-name"), Values: []*string{aws.String("default")}},
},
})
require.NoErrorf(t, err, "describe default SG for %s", vpcID)
require.Lenf(t, out.SecurityGroups, 1, "vpc %s default SG", vpcID)
return aws.StringValue(out.SecurityGroups[0].GroupId)
}
// imagePullInstanceType selects the guest for the image-pull repro. The
// synthetic npass suite uses a nano (512MB) to pack many suites onto one node,
// but a real multi-GB image pull onto 512MB OOM-thrashes containerd long before
// any disk-driven ENOSPC, and 512MB against a concurrent-write pull is exactly
// the guest-memory-pressure regime that produces the separate siv-482 lost-write
// corruption — conflating two faults in one measurement. This mode instead picks
// an instance with headroom well above the pull's working set, so the run
// measures the storage leak and not memory pressure.
//
// SPINIFEX_STORAGEGROWTH_INSTANCE_TYPE overrides the choice outright.
// Otherwise the smallest advertised type at or above SPINIFEX_STORAGEGROWTH_MIN_MEM_MIB
// (default 16384, matching the ~16GB EKS GPU worker the incident was reported on)
// is used — smallest-above-floor so it gets headroom without grabbing a type the
// node cannot seat.
func imagePullInstanceType(t *testing.T, fix *Fixture) (instanceType, arch string) {
t.Helper()
out, err := fix.AWS.EC2.DescribeInstanceTypes(&ec2.DescribeInstanceTypesInput{})
require.NoError(t, err, "DescribeInstanceTypes")
archOf := func(it *ec2.InstanceTypeInfo) string {
if it.ProcessorInfo == nil || len(it.ProcessorInfo.SupportedArchitectures) == 0 {
return ""
}
return aws.StringValue(it.ProcessorInfo.SupportedArchitectures[0])
}
if override := os.Getenv("SPINIFEX_STORAGEGROWTH_INSTANCE_TYPE"); override != "" {
for _, it := range out.InstanceTypes {
if aws.StringValue(it.InstanceType) == override {
a := archOf(it)
require.NotEmptyf(t, a, "instance type %s advertises no architecture", override)
return override, a
}
}
t.Fatalf("SPINIFEX_STORAGEGROWTH_INSTANCE_TYPE=%s not advertised by this cluster", override)
}
minMemMiB := int64(16384)
if v := os.Getenv("SPINIFEX_STORAGEGROWTH_MIN_MEM_MIB"); v != "" {
parsed, perr := strconv.ParseInt(v, 10, 64)
require.NoErrorf(t, perr, "parse SPINIFEX_STORAGEGROWTH_MIN_MEM_MIB=%q", v)
minMemMiB = parsed
}
var best *ec2.InstanceTypeInfo
var bestMem int64
for _, it := range out.InstanceTypes {
if it.MemoryInfo == nil || it.MemoryInfo.SizeInMiB == nil || archOf(it) == "" {
continue
}
mem := aws.Int64Value(it.MemoryInfo.SizeInMiB)
if mem < minMemMiB {
continue
}
if best == nil || mem < bestMem {
best, bestMem = it, mem
}
}
require.NotNilf(t, best, "no advertised instance type has >= %d MiB memory", minMemMiB)
return aws.StringValue(best.InstanceType), archOf(best)
}
// createImagePullVolume creates the volume the pull runs against, sized by
// sizeGiB (unlike npass's fixed 1 GiB extent, this workload's footprint is
// the whole point of the measurement, so its size must be large enough to
// hold a real pulled image).
//
// Mirrors createWorkloadVolume's reasoning for not using harness.EnsureVolume
// (memoized on (az, size), and its cleanup unconditionally purges every
// object under the volume's prefix — exactly the leak evidence a retained
// run needs to keep).
func createImagePullVolume(t *testing.T, fix *Fixture, az string, sizeGiB int, retain bool) string {
t.Helper()
// e2e:allow-create
out, err := fix.AWS.EC2.CreateVolume(&ec2.CreateVolumeInput{
AvailabilityZone: aws.String(az),
Size: aws.Int64(int64(sizeGiB)),
})
require.NoError(t, err, "CreateVolume in %s", az)
volID := aws.StringValue(out.VolumeId)
require.NotEmpty(t, volID, "CreateVolume returned no volume id")
harness.WaitForVolumeState(t, fix.AWS, volID, "available")
if runID := os.Getenv(harness.RunIDEnv); runID != "" {
if _, terr := fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{
Resources: []*string{aws.String(volID)},
Tags: []*ec2.Tag{{Key: aws.String(npassRunTagKey), Value: aws.String(runID)}},
}); terr != nil {
t.Logf("storagegrowth: tagging %s with %s=%s: %v", volID, npassRunTagKey, runID, terr)
}
}
if retain {
t.Logf("storagegrowth: retaining %s past process exit so the backing store can be measured with its objects still live", volID)
return volID
}
t.Cleanup(func() {
if _, derr := fix.AWS.EC2.DeleteVolume(&ec2.DeleteVolumeInput{VolumeId: aws.String(volID)}); derr != nil {
t.Logf("storagegrowth: deleting %s: %v", volID, derr)
}
})
return volID
}
// launchImagePullRootInstance launches the instance with an explicitly sized
// root volume (BlockDeviceMappings[0]) rather than going through
// harness.EnsureInstance, whose InstanceSpec has no field for
// BlockDeviceMappings — see service_impl.go's parseVolumeParams, which reads
// BlockDeviceMappings[0].Ebs.VolumeSize/DeleteOnTermination and
// BlockDeviceMappings[0].DeviceName straight off the RunInstances request.
// The AMI's default root disk is sized for booting, not for holding a
// multi-GB image pull; ENOSPC on the root disk is exactly the failure this
// mode exists to rule in or out, so undersizing it here would corrupt the
// measurement rather than merely failing the test.
//
// DeleteOnTermination is always set explicitly — never left to default — so
// root mode never depends on parseVolumeParams' current default (true)
// continuing to hold. When retain is set it is turned off: the root volume
// otherwise dies with the instance at the end of the test (see
// ensureImagePullInstance's terminate cleanup), destroying the very volume
// the post-run scan needs to read.
//
// Not memoized like harness.EnsureInstance: every call here wants its own
// freshly, explicitly sized root volume, so sharing one across differently
// configured calls would be wrong, not just wasteful.
func launchImagePullRootInstance(t *testing.T, fix *Fixture, ami, instType, keyName, subnetID, sgID string, rootGiB int, retain bool) string {
t.Helper()
// e2e:allow-create
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
ImageId: aws.String(ami),
InstanceType: aws.String(instType),
KeyName: aws.String(keyName),
SubnetId: aws.String(subnetID),
SecurityGroupIds: []*string{aws.String(sgID)},
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
BlockDeviceMappings: []*ec2.BlockDeviceMapping{
{
DeviceName: aws.String(imagePullRootDevice),
Ebs: &ec2.EbsBlockDevice{
VolumeSize: aws.Int64(int64(rootGiB)),
DeleteOnTermination: aws.Bool(!retain),
},
},
},
})
require.NoError(t, err, "RunInstances with sized root volume")
require.NotEmptyf(t, out.Instances, "RunInstances returned 0 instances")
instanceID := aws.StringValue(out.Instances[0].InstanceId)
require.NotEmpty(t, instanceID, "RunInstances returned no instance id")
// The instance carries the run tag for the same reason the root volume
// does, plus one twist that makes it load-bearing rather than a nicety
// under retain. SweepRunResources sweeps instances before volumes and
// finds each solely by tag, and DeleteVolume refuses a volume still held
// by a live instance (VolumeInUse). So an untagged instance strands even a
// correctly tagged root volume: the sweep never terminates the holder, and
// the volume delete then fails behind it. Both tags are required for the
// sweep to reclaim a retained root volume, so both are fatal under retain.
tagImagePullRunResource(t, fix, instanceID, retain,
"the teardown sweep finds instances only by this tag and must terminate this one before it can delete the "+
"root volume attached to it (DeleteVolume refuses a volume held by a live instance), so an untagged "+
"instance leaves even a correctly tagged root volume unreclaimable behind it")
return instanceID
}
// tagImagePullRunResource stamps the run tag on a resource root mode created
// outside the harness's own Ensure* helpers, which is the only thing that
// makes it reclaimable: harness.SweepRunResources discovers every resource
// class solely by DescribeX filtered on tag:e2e:run, and the storage-repro
// reclaim pass matches the same tag. Untagged, neither can ever see it.
//
// whyFatal names what specifically breaks when the tag does not land, and is
// only used under retain, where the failure is fatal. That asymmetry is the
// point: without retain, DeleteOnTermination=true and the terminate cleanup in
// ensureImagePullInstance reclaim everything on both the pass and the t.Fatal
// path, so the tag is only a backstop against a process that dies without
// running cleanups at all — worth logging, not worth failing a measurement
// over. With retain, the tag is the *sole* reclamation path, and an unreclaimed
// volume is not a disk-space nuisance but a measurement fault: its chunks stay
// live on the backing store, so every later run starts from a dirtier baseline
// than the one before, and this harness exists to measure a ratio against an
// identical baseline. A run that cannot guarantee its own cleanup must fail
// loudly rather than silently poison the next run's denominator.
//
// The key is npassRunTagKey, this package's restatement of the harness's
// unexported runTagKey — the harness exports no tagging helper at all
// (tagRunResources is likewise unexported), and this suite never edits
// tests/e2e/harness (see the package doc in main_test.go).
func tagImagePullRunResource(t *testing.T, fix *Fixture, resourceID string, retain bool, whyFatal string) {
t.Helper()
// No run id means no sweep is coming, so there is no tag for one to match
// on and nothing to fail about — reclaiming a retained resource is then
// the operator's own business, as createImagePullVolume's retain log says.
runID := os.Getenv(harness.RunIDEnv)
if runID == "" {
return
}
_, err := fix.AWS.EC2.CreateTags(&ec2.CreateTagsInput{
Resources: []*string{aws.String(resourceID)},
Tags: []*ec2.Tag{{Key: aws.String(npassRunTagKey), Value: aws.String(runID)}},
})
if retain {
require.NoErrorf(t, err, "tagging %s with %s=%s, which retain makes mandatory: %s",
resourceID, npassRunTagKey, runID, whyFatal)
return
}
if err != nil {
t.Logf("storagegrowth: tagging %s with %s=%s: %v", resourceID, npassRunTagKey, runID, err)
}
}
// imagePullRootVolumeID resolves root mode's VolumeID from inst's own
// BlockDeviceMappings — the same DescribeInstances-sourced *ec2.Instance
// ensureImagePullInstance already waited for at "running" — rather than the
// RunInstances response, because that is what item 6 of this mode's contract
// requires: an external tool scans the reported volume by ID afterward, so a
// placeholder or guessed ID would make it silently scan nothing.
//
// Every failure path here is fatal, not logged-and-continued: this is a
// measurement harness, and a root-mode run that cannot prove which volume it
// wrote to, or that the volume will survive to be scanned, must not produce a
// result record for something else to trust.
func imagePullRootVolumeID(t *testing.T, inst *ec2.Instance, retain bool) string {
t.Helper()
instanceID := aws.StringValue(inst.InstanceId)
require.NotEmptyf(t, inst.BlockDeviceMappings, "instance %s: DescribeInstances reported no BlockDeviceMappings — root mode cannot identify which volume the pull landed on", instanceID)
for _, bdm := range inst.BlockDeviceMappings {
if aws.StringValue(bdm.DeviceName) != imagePullRootDevice {
continue
}
require.NotNilf(t, bdm.Ebs, "instance %s: root device %s BlockDeviceMapping has no Ebs entry", instanceID, imagePullRootDevice)
volID := aws.StringValue(bdm.Ebs.VolumeId)
require.NotEmptyf(t, volID, "instance %s: root device %s Ebs entry has no VolumeId", instanceID, imagePullRootDevice)
if retain {
require.Falsef(t, aws.BoolValue(bdm.Ebs.DeleteOnTermination),
"instance %s: retain requested but root volume %s still reports DeleteOnTermination=true — it would be destroyed at instance termination before the post-run scan can read it",
instanceID, volID)
}
return volID
}
t.Fatalf("instance %s: no BlockDeviceMapping for root device %s among %d mapping(s) — root volume id is not discoverable", instanceID, imagePullRootDevice, len(inst.BlockDeviceMappings))
return ""
}
// runImagePull, in volume mode (target == imagePullTargetVolume), attaches
// volID, formats and mounts it persistently, redirects the guest's container
// runtime onto that mount, pulls image in the background, and samples
// guest/host/backend state on an interval until the pull completes. In root
// mode (target == imagePullTargetRoot) volID already names the instance's own
// root volume (see imagePullRootVolumeID): there is nothing to attach, format,
// or redirect, and the guest samples its own root filesystem instead of the
// mount.
//
// The measured fields land in w as they are captured, rather than in a record
// returned at the end, so a pull that fails partway still leaves its samples
// with the caller — see TestImagePullGrowth for why that data must survive.
//
// cycles > 1 (root mode only — see TestImagePullGrowth's guard) drives
// imagePullGuestScriptRootCycles instead of the single-pull scripts; the
// sampling loop below tags each sample with the cycle in flight
// (imagePullCurrentCycle), and once the guest script's own loop settles,
// readImagePullCycles fills w.Cycles/w.PullCompleted from its marker file.
func runImagePull(t *testing.T, fix *Fixture, tgt harness.SSHTarget, instanceID, volID, image string, volGiB, sampleIntervalSecs int, target string, cycles int, w *imagePullWorkload) {
t.Helper()
// dfPath is where the guest-side denominator is measured. Volume mode
// samples the mounted, redirected volume; root mode has no mount to
// sample and instead reads the instance's own root filesystem directly.
dfPath := imagePullMount
if target == imagePullTargetRoot {
dfPath = "/"
harness.Step(t, "pulling %s directly onto instance %s's root volume %s (no attach, no redirect)", image, instanceID, volID)
} else {
harness.Step(t, "attaching %s (%dGiB) and pulling %s onto it", volID, volGiB, image)
before := harness.GuestDiskSet(t, tgt)
harness.AttachVolumeWait(t, fix.AWS, volID, instanceID, imagePullDevice)
dev := harness.WaitForNewGuestDisk(t, tgt, before, 90*time.Second)
defer harness.DetachVolumeWait(t, fix.AWS, volID)
mkfsScript := fmt.Sprintf(
"sudo mkfs.ext4 -F -L %s /dev/%s && sudo mkdir -p %s && sudo mount /dev/%s %s",
imagePullVolumeLabel, dev, imagePullMount, dev, imagePullMount)
out, err := harness.GuestExec(tgt, mkfsScript)
require.NoErrorf(t, err, "mkfs+mount /dev/%s: %s", dev, strings.TrimSpace(out))
defer func() {
// Stop the daemons first: both hold files open under the mount (it is
// their redirected storage root), so an unmount attempted while either
// is still running fails "target is busy" — observed on a live run
// after a failed pull left docker/containerd running.
_, _ = harness.GuestExec(tgt, "sudo systemctl stop docker.service docker.socket containerd.service 2>/dev/null")
if uout, uerr := harness.GuestExec(tgt, fmt.Sprintf("sudo umount %s", imagePullMount)); uerr != nil {
t.Logf("storagegrowth: unmount %s: %v: %s", imagePullMount, uerr, strings.TrimSpace(uout))
}
}()
}
launchImagePullUnit(t, tgt, image, target, cycles)
ssh := harness.NewPeerSSH()
w.Samples = make([]imagePullSample, 0, 64)
start := time.Now()
interval := time.Duration(sampleIntervalSecs) * time.Second
// pullErr holds why the pull did not complete, deferring the failure until
// after the final sample below. Failing inside the loop instead would skip
// that sample and lose the settled post-failure state, which on a disk that
// filled up is the most informative point in the series.
var pullErr string
for {
s := captureImagePullSample(t, tgt, ssh, fix.Env.WANHost, volID, start, dfPath)
if cycles > 1 {
s.Cycle = imagePullCurrentCycle(t, tgt)
}
w.Samples = append(w.Samples, s)
harness.Detail(t, "sample_elapsed_s", fmt.Sprintf("%.1f", s.ElapsedSecs),
"guest_used", s.GuestUsedBytes, "host_allocated", s.HostAllocatedBytes,
"nbdkit_rss_kib", s.NbdkitRSSKiB, "checkpoint_bytes", s.CheckpointBytes, "cycle", s.Cycle)
status, exitCode, done, failed := imagePullUnitStatus(t, tgt)
w.PullStatus, w.PullExitCode = status, exitCode
if failed {
pullErr = fmt.Sprintf("image pull unit failed (status=%s exitcode=%s)", status, exitCode)
break
}
if done {
// In cycles mode, "done" only means imagePullGuestScriptRootCycles's
// own N-cycle loop ran to completion — an individual cycle's pull
// failure never reaches this exit code (see that script's doc), so
// it says nothing about whether the FINAL pull attempt actually
// succeeded. readImagePullCycles below sets PullCompleted from the
// last recorded cycle instead, once its marker file can be read.
if cycles <= 1 {
w.PullCompleted = true
}
break
}
if time.Since(start) > imagePullMaxDuration {
pullErr = fmt.Sprintf("image pull did not complete within %s (status=%s)", imagePullMaxDuration, status)
break
}
time.Sleep(interval)
}
// One final sample right after the pull settles, so the last data point
// reflects settled state rather than whatever the last periodic tick
// happened to catch mid-write. Taken on the failure path too: after a disk
// fills, this is the reading that pins guest_used_bytes at the volume's
// capacity, which is what tells a consumer the pull ran out of room.
finalSample := captureImagePullSample(t, tgt, ssh, fix.Env.WANHost, volID, start, dfPath)
if cycles > 1 {
finalSample.Cycle = imagePullCurrentCycle(t, tgt)
}
w.Samples = append(w.Samples, finalSample)
w.PullDurationSecs = time.Since(start).Seconds()
w.GuestUsedBytes = w.Samples[len(w.Samples)-1].GuestUsedBytes
// Cycles mode's per-cycle breakdown is read once here, after the guest
// script's loop has settled (or the run gave up waiting on it above) —
// not polled every tick like the status/exitcode marker files, since
// /tmp/run-pull.cycles is only complete once the loop that appends to it
// has stopped running.
if cycles > 1 {
readImagePullCycles(t, tgt, w)
}
// A cycle-mode pull failure never reaches pullErr (see the "done" branch
// above): imagePullGuestScriptRootCycles's own script-level exit code only
// reflects an infra fault (apt-get) or the run exceeding
// imagePullMaxDuration, both of which remain fatal here exactly as in
// single-pull mode. An individual cycle ENOSPCing is not a harness fault —
// it is the measurement — so it never sets pullErr in the first place.
if pullErr != "" {
logOut, _ := harness.GuestExec(tgt, "journalctl -u mulga-imagepull --no-pager 2>/dev/null | tail -n 100")
t.Fatalf("%s:\n%s", pullErr, logOut)
}
require.Positivef(t, w.GuestUsedBytes, "guest reported 0 used bytes on %s after pulling %s — the denominator every downstream ratio divides by is unusable", dfPath, image)
// verifyImagePullLandedOnVolume asserts the mounted volume's own runtime
// state directories exist, which only applies to volume mode — root mode
// never mounts anything at imagePullMount for it to inspect.
if target != imagePullTargetRoot {
verifyImagePullLandedOnVolume(t, tgt)
}
}
// launchImagePullUnit writes the target-appropriate guest script to the guest
// as /tmp/run-pull.sh and starts it as a detached systemd transient unit.
// Volume mode uses imagePullGuestScript (redirects onto the mount); root mode
// uses imagePullGuestScriptRoot (no redirect, writes land on the root disk
// directly), or imagePullGuestScriptRootCycles instead when cycles > 1 (see
// TestImagePullGrowth's guard: cycles mode is root-only). The script is
// base64-encoded rather than heredoc'd directly into the SSH command:
// imagePullGuestScript contains its own heredoc (for /etc/docker/daemon.json)
// and shell metacharacters, and base64 avoids having to reason about two
// layers of shell quoting (the local ssh command string and the remote shell
// it runs) nesting correctly.
func launchImagePullUnit(t *testing.T, tgt harness.SSHTarget, image, target string, cycles int) {
t.Helper()
script := fmt.Sprintf(imagePullGuestScript, imagePullMount, image)
switch {
case target == imagePullTargetRoot && cycles > 1:
script = fmt.Sprintf(imagePullGuestScriptRootCycles, image, cycles)
case target == imagePullTargetRoot:
script = fmt.Sprintf(imagePullGuestScriptRoot, image)
}
encoded := base64.StdEncoding.EncodeToString([]byte(script))
writeCmd := fmt.Sprintf(
"echo %s | base64 -d | sudo tee /tmp/run-pull.sh >/dev/null && sudo chmod +x /tmp/run-pull.sh",
encoded)
out, err := harness.GuestExec(tgt, writeCmd)
require.NoErrorf(t, err, "write run-pull.sh: %s", strings.TrimSpace(out))
launchCmd := "sudo rm -f /tmp/run-pull.status /tmp/run-pull.exitcode; " +
`sudo systemd-run --unit=mulga-imagepull --description="storagegrowth image pull" -- /bin/bash /tmp/run-pull.sh`
out, err = harness.GuestExec(tgt, launchCmd)
require.NoErrorf(t, err, "launch run-pull.sh via systemd-run: %s", strings.TrimSpace(out))
}
// imagePullUnitStatus reports the background pull's progress by reading the
// exit-code marker file the guest script's EXIT trap always writes, rather
// than querying systemd unit state directly: the trap fires on every exit
// path, so the marker is a single place that is always eventually populated,
// whereas ActiveState/SubState carry transient values that would need their
// own state machine to interpret correctly here.
//
// exitCode is the marker's raw contents, empty until the trap has fired. It is
// returned rather than folded into failed because the code itself is recorded
// in the result — see imagePullWorkload.PullExitCode for what it can and
// cannot tell a consumer apart.
func imagePullUnitStatus(t *testing.T, tgt harness.SSHTarget) (status, exitCode string, done, failed bool) {
t.Helper()
out, err := harness.GuestExec(tgt, "cat /tmp/run-pull.status 2>/dev/null || echo unknown")
status = strings.TrimSpace(out)
if err != nil {
// A transient SSH hiccup mid-pull is not itself a failure signal;
// the next tick tries again.
return status, "", false, false
}
rc, rcErr := harness.GuestExec(tgt, "cat /tmp/run-pull.exitcode 2>/dev/null || true")
rc = strings.TrimSpace(rc)
if rcErr == nil && rc != "" {
return status, rc, true, rc != "0"
}
return status, "", false, false
}
// imagePullCurrentCycle reads the guest's current-cycle marker
// (/tmp/run-pull.cycle, overwritten once per iteration by
// imagePullGuestScriptRootCycles), so the periodic sampling loop in
// runImagePull can tag each sample with the cycle in flight when it was
// taken. Returns 0 (the zero value, and therefore omitted — see
// imagePullSample.Cycle) if the file does not exist yet or the read fails;
// missing a cycle tag on one sample does not make that sample unusable, so
// this is silent rather than logged or fatal — every tick already logs
// enough via harness.Detail's own "cycle" field for a human to notice a
// pattern of zeros if the marker file is never appearing at all.
func imagePullCurrentCycle(t *testing.T, tgt harness.SSHTarget) int {
t.Helper()
out, err := harness.GuestExec(tgt, "cat /tmp/run-pull.cycle 2>/dev/null || true")
if err != nil {
return 0
}
n, perr := strconv.Atoi(strings.TrimSpace(out))
if perr != nil {
return 0
}
return n
}
// imagePullCycleMarkerRE matches one "CYCLE n=<i> exit=<code>
// guest_used=<bytes> elapsed=<secs>" line imagePullGuestScriptRootCycles
// appends to /tmp/run-pull.cycles once per pull-then-free attempt.
var imagePullCycleMarkerRE = regexp.MustCompile(`^CYCLE n=(\d+) exit=(\d+) guest_used=(\d+) elapsed=(\d+)`)
// readImagePullCycles reads the guest's cycle-marker file after
// imagePullGuestScriptRootCycles's N-cycle loop settles, and fills
// w.Cycles and w.PullCompleted from it. A missing or unparsable file is
// logged, not fatal: cycles mode's void gate is the same one every mode
// uses (writeImagePullResult's no-samples-or-non-positive-guest-used-bytes
// check), and losing the per-cycle breakdown does not make the samples
// already captured in w.Samples unusable.
func readImagePullCycles(t *testing.T, tgt harness.SSHTarget, w *imagePullWorkload) {
t.Helper()
out, err := harness.GuestExec(tgt, "cat /tmp/run-pull.cycles 2>/dev/null || true")
if err != nil {
t.Logf("storagegrowth: read /tmp/run-pull.cycles: %v", err)
return
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := imagePullCycleMarkerRE.FindStringSubmatch(line)
if m == nil {
t.Logf("storagegrowth: unparsable cycle marker line %q", line)
continue
}
n, _ := strconv.Atoi(m[1])
exitCode, _ := strconv.Atoi(m[2])
used, _ := strconv.ParseInt(m[3], 10, 64)
elapsed, _ := strconv.ParseFloat(m[4], 64)
w.Cycles = append(w.Cycles, imagePullCycle{
Cycle: n,
ExitCode: exitCode,
GuestUsedBytes: used,
ElapsedSecs: elapsed,
PullCompleted: exitCode == 0,
})
}
// PullCompleted mirrors single-pull mode's meaning (did the last pull
// attempt actually succeed), not "did the script finish its loop" — see
// the "done" branch in runImagePull's sampling loop for why those are
// different questions in cycles mode.
if len(w.Cycles) > 0 {
w.PullCompleted = w.Cycles[len(w.Cycles)-1].PullCompleted
}
}
// captureImagePullSample takes one guest/host/backend reading. dfPath is
// where the guest-side denominator is sampled from — imagePullMount in
// volume mode, "/" in root mode (see runImagePull). Every failure here is
// logged, not fatal: a single bad reading mid-pull (a busy SSH session, a du
// that raced a write) must not abort a run that would otherwise complete, and
// a sparse sample series is still useful — only an entirely empty one is not,
// which the caller's final require.Positivef on the guest side guards
// against.
func captureImagePullSample(t *testing.T, tgt harness.SSHTarget, ssh *harness.PeerSSH, wanHost, volID string, start time.Time, dfPath string) imagePullSample {
t.Helper()
s := imagePullSample{ElapsedSecs: time.Since(start).Seconds()}
if out, err := harness.GuestExec(tgt, fmt.Sprintf("df --output=used -B1 %s", dfPath)); err != nil {
t.Logf("storagegrowth: sample guest df on %s: %v: %s", dfPath, err, strings.TrimSpace(out))
} else if v, perr := parseLastIntField(out); perr != nil {
t.Logf("storagegrowth: parse guest df output %q: %v", out, perr)
} else {
s.GuestUsedBytes = v
}
if v, err := hostPredastoreAllocatedBytes(ssh, wanHost); err != nil {
t.Logf("storagegrowth: host du via PeerSSH on %s: %v", wanHost, err)
} else {
s.HostAllocatedBytes = v
}
rssCtx, rssCancel := context.WithTimeout(context.Background(), 15*time.Second)
rssOut, rssErr := ssh.Run(rssCtx, wanHost, `ps -eo rss,comm | awk '$2 ~ /nbdkit/ {s+=$1} END {printf "%d", s+0}'`)
rssCancel()
if rssErr != nil {
t.Logf("storagegrowth: nbdkit RSS via PeerSSH on %s: %v", wanHost, rssErr)
} else if v, perr := strconv.ParseInt(strings.TrimSpace(string(rssOut)), 10, 64); perr != nil {
t.Logf("storagegrowth: parse nbdkit RSS output %q: %v", rssOut, perr)
} else {
s.NbdkitRSSKiB = v
}
// HeadObject the live-checkpoint key on the node itself. The gateway identity
// the operator box signs with has no S3 read grant on this bucket (it answers
// 403), so the only credential that works is predastore's own, which lives on
// the node in predastore.toml. Reading it there over PeerSSH keeps the secret
// on the node: it is used inline to invoke the AWS CLI, unset immediately, and
// never crosses back to the operator box.
key := fmt.Sprintf("%s/checkpoints/blocks.live.bin", volID)
region := imagePullGetenv("SPINIFEX_AWS_REGION", "ap-southeast-2")
headCtx, headCancel := context.WithTimeout(context.Background(), 20*time.Second)
headOut, headErr := ssh.Run(headCtx, wanHost, fmt.Sprintf(imagePullCheckpointHeadScript, region, key))
headCancel()
if headErr != nil {
// A transport failure is a broken probe, not an absent checkpoint: void
// the reading so it can never be read back as a genuine zero.
s.CheckpointBytes = -1
s.CheckpointProbeErr = fmt.Sprintf("head via PeerSSH: %v", headErr)
t.Logf("storagegrowth: checkpoint head via PeerSSH on %s: %v", wanHost, headErr)
return s
}
line := strings.TrimSpace(string(headOut))
switch {
case strings.HasPrefix(line, "OK\t"):
// OK <content-length> <last-modified>, tab-separated by the probe script.
parts := strings.Split(line, "\t")
if v, perr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64); perr == nil {
s.CheckpointBytes = v
} else {
s.CheckpointBytes = -1
s.CheckpointProbeErr = fmt.Sprintf("parse checkpoint length %q: %v", parts[1], perr)
}
if len(parts) >= 3 {
if tm, terr := time.Parse(time.RFC3339, strings.TrimSpace(parts[2])); terr == nil {
s.CheckpointLastModified = tm.UTC().Format(time.RFC3339Nano)
}
}
case line == "NOTFOUND":
// Expected before the first drain has fired: the checkpoint object does
// not exist yet, so CheckpointBytes legitimately stays 0.
default:
// The probe classified an auth or other failure (a 403 the on-node creds
// should have prevented). Void the reading rather than emit a false 0.
s.CheckpointBytes = -1
s.CheckpointProbeErr = strings.TrimPrefix(strings.TrimPrefix(line, "ERR"), "\t")
t.Logf("storagegrowth: checkpoint head on %s: %s", wanHost, line)
}
return s
}
// verifyImagePullLandedOnVolume asserts the data-root redirect actually took
// effect: the mounted volume must contain the runtime's own state
// directories, not just an empty filesystem next to a daemon that silently
// fell back to /var/lib/docker on the root disk.
func verifyImagePullLandedOnVolume(t *testing.T, tgt harness.SSHTarget) {
t.Helper()
out, err := harness.GuestExec(tgt, fmt.Sprintf("sudo ls -A %s", imagePullMount))
require.NoErrorf(t, err, "list %s: %s", imagePullMount, strings.TrimSpace(out))
entries := strings.Fields(out)
require.NotEmptyf(t, entries, "%s is empty after the pull — the container runtime's data-root redirect did not take effect", imagePullMount)
harness.Detail(t, "data_root_entries", strings.Join(entries, ","))
}
// imagePullCheckpointHeadScript HEADs the live-checkpoint object on the node
// using predastore's own [[auth]] credentials from predastore.toml, because the
// gateway identity the operator box holds cannot read this bucket. It emits one
// classified line on stdout and always exits 0, so the classification — not an
// SSH exit status — drives the caller: "OK\t<len>\t<lastmod>" on success,
// "NOTFOUND" when the object does not exist yet, or "ERR\t<detail>" on an auth
// or transport failure. predastore.toml is root-owned, so the read goes
// through passwordless sudo (the node's SSH user has it, same as the du/ps
// samples above). The credentials are read into shell locals, passed inline to
// a single aws invocation, then unset — never echoed, never leaving the node.
// Format args: 1=region, 2=object key.
const imagePullCheckpointHeadScript = `set -u
cfg=/etc/spinifex/predastore/predastore.toml
ak=$(sudo -n awk '/^\[\[auth\]\]/{f=1} f && /^access_key_id/{gsub(/^[^"]*"|"[^"]*$/,""); print; exit}' "$cfg")
sk=$(sudo -n awk '/^\[\[auth\]\]/{f=1} f && /^secret_access_key/{gsub(/^[^"]*"|"[^"]*$/,""); print; exit}' "$cfg")
if [ -z "$ak" ] || [ -z "$sk" ]; then printf 'ERR\tno predastore credentials in %%s\n' "$cfg"; exit 0; fi
out=$(AWS_ACCESS_KEY_ID="$ak" AWS_SECRET_ACCESS_KEY="$sk" AWS_CA_BUNDLE=/etc/spinifex/ca.pem \
aws --endpoint-url https://127.0.0.1:8443 --region '%[1]s' s3api head-object \
--bucket predastore --key '%[2]s' --query '[ContentLength,LastModified]' --output text 2>&1)
rc=$?
unset ak sk
if [ "$rc" -eq 0 ]; then printf 'OK\t%%s\n' "$out";
elif printf '%%s' "$out" | grep -qiE '404|not found'; then printf 'NOTFOUND\n';
else printf 'ERR\t%%s\n' "$(printf '%%s' "$out" | tr '\n' ' ')"; fi
`
// imagePullGetenv returns the named environment variable, or def when unset.
// A trivial local copy of harness's unexported getenv (env.go), which this
// suite does not import from because tests/e2e/harness is deliberately never
// edited by this suite (see the package doc in main_test.go).
func imagePullGetenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// parseLastIntField extracts the last whitespace-separated integer field from
// out, tolerating a leading header line (df prints one; du does not, but the
// same parse works for both).
func parseLastIntField(out string) (int64, error) {
fields := strings.Fields(out)
if len(fields) == 0 {
return 0, fmt.Errorf("no output")
}
return strconv.ParseInt(fields[len(fields)-1], 10, 64)
}
// hostPredastoreAllocatedBytes reads predastoreBaseDir's total allocated
// bytes on the node at wanHost, over PeerSSH. This is the one measurement
// that actually works against a live deployment: it needs no S3 credential
// at all, only SSH access to the node, unlike a per-volume S3 listing of the
// backend bucket (see assertNPassBackendByteDelta in storage_growth_test.go
// for why that path is unusable here).
//
// The number returned is NODE-WIDE, not attributable to any single volume --
// du has no concept of which volume a chunk object belongs to, it only sees
// bytes on disk. Callers that need a per-workload signal must take two
// readings and use the delta, and must be able to argue nothing else was
// writing to the node in between.
func hostPredastoreAllocatedBytes(ssh *harness.PeerSSH, wanHost string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := ssh.Run(ctx, wanHost, fmt.Sprintf("sudo du -s --block-size=1 %s 2>/dev/null | cut -f1", predastoreBaseDir))
if err != nil {
return 0, fmt.Errorf("du %s via PeerSSH: %w", predastoreBaseDir, err)
}
v, perr := parseLastIntField(string(out))
if perr != nil {
return 0, fmt.Errorf("parse du output %q: %w", out, perr)
}
return v, nil
}
// writeImagePullResult persists the workload record at the path named by
// npassResultPathEnv (SPINIFEX_STORAGEGROWTH_RESULT_PATH) — the same env var
// npass uses, per the shared result contract. See writeNPassResult for why an
// unset path only logs, and why a set path treats every failure as fatal.
//
// This is the single arbiter of which runs produce a result at all, and it is
// deliberately not the same question as whether the test passed. A run whose
// pull failed but which captured real samples is VALID and is written: an
// image too large for its volume is the phenomenon under test, and discarding
// its data would throw away the sweep's most interesting points. A run that
// captured nothing — the guest never came up, SSH never worked, every df
// reading failed — is VOID, and writes no file, because a consumer distinguishes
// the two by the file's presence alone.
//
// w is a pointer so a deferred call to this function observes the record as it
// finally stood, not a copy frozen when the defer was registered.
func writeImagePullResult(t *testing.T, w *imagePullWorkload) {
t.Helper()
data, err := json.MarshalIndent(w, "", " ")
require.NoError(t, err, "marshal image-pull workload record")
path := os.Getenv(npassResultPathEnv)
if path == "" {
harness.Detail(t, "workload_record", string(data))
return
}
// The void gate. No samples means nothing was ever measured, and a
// non-positive denominator is the same unusable number the run's own
// require.Positivef rejects — every downstream ratio divides by it. Writing
// either would hand the measurement step a plausible, wrong point instead of
// the absent one that correctly voids it. t.Errorf, not require: this runs
// deferred, typically while the test is already unwinding from the failure
// that caused the void, and the test is red either way.
if len(w.Samples) == 0 || w.GuestUsedBytes <= 0 {
t.Errorf("storagegrowth: refusing to write %s from a run that measured nothing "+
"(%d samples, guest_used_bytes=%d) — the denominator every downstream ratio divides by is "+
"unusable, and an absent result is how the measurement step learns this point is void",
path, len(w.Samples), w.GuestUsedBytes)
return
}
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755), "result dir for %s", path)
require.NoError(t, os.WriteFile(path, data, 0o644), "write %s", path)
harness.Detail(t, "workload_result", path, "pull_completed", w.PullCompleted)
}
// imagePullImage returns the image to pull, overridable via
// SPINIFEX_STORAGEGROWTH_IMAGE. Must be pinned by digest: a floating tag
// would let the image drift between runs, confounding any comparison across
// sweep points that is supposed to hold the workload constant.
func imagePullImage() string {
if v := os.Getenv("SPINIFEX_STORAGEGROWTH_IMAGE"); v != "" {
return v
}
return imagePullDefaultImage
}
// imagePullVolumeGiB returns the workload volume's size, overridable via
// SPINIFEX_STORAGEGROWTH_VOLUME_GIB. Unlike npass's fixed 1 GiB extent, this
// must be large enough to actually hold a pulled image plus its extracted
// layers.
func imagePullVolumeGiB() int {
return envPositiveIntOr("SPINIFEX_STORAGEGROWTH_VOLUME_GIB", 20)
}
// imagePullSampleInterval returns the sampling interval in seconds,
// overridable via SPINIFEX_STORAGEGROWTH_SAMPLE_SECS.
func imagePullSampleInterval() int {
return envPositiveIntOr("SPINIFEX_STORAGEGROWTH_SAMPLE_SECS", 15)
}
// imagePullTarget returns which disk the pull's bytes land on, overridable
// via SPINIFEX_STORAGEGROWTH_TARGET. imagePullTargetVolume (default) is the
// original workload: a fresh, empty, independently-created data volume.
// imagePullTargetRoot instead targets the instance's own root volume, which is
// cloned from an AMI snapshot. That ancestry was the original reason for the
// mode and is refuted (the two targets amplify within 0.16% of each other);
// root mode is retained because only it can undersize the disk the pull writes
// to. See TestImagePullGrowth's doc.
//
// An unrecognized value is a configuration error and panics rather than
// silently falling back to volume mode, matching envPositiveIntOr/envBool's
// reasoning: a mistyped target would otherwise silently measure the wrong
// mode and attribute the result to the one that was intended.
func imagePullTarget() string {
v := os.Getenv(imagePullTargetEnv)
if v == "" {
return imagePullTargetVolume
}
switch v {
case imagePullTargetVolume, imagePullTargetRoot:
return v
default:
panic(fmt.Sprintf("%s=%q is not one of %q, %q", imagePullTargetEnv, v, imagePullTargetVolume, imagePullTargetRoot))
}
}
// imagePullRootGiB returns the root volume's size in root mode, overridable
// via SPINIFEX_STORAGEGROWTH_ROOT_GIB. The AMI's default root disk is sized
// for booting, not for holding a multi-GB image pull, so this must default
// well above imagePullVolumeGiB's default: undersizing it produces ENOSPC on
// the root disk, which is exactly the failure root mode exists to rule in or
// out, not a harness bug to work around by shrinking the pull.
func imagePullRootGiB() int {
return envPositiveIntOr("SPINIFEX_STORAGEGROWTH_ROOT_GIB", 60)
}
// imagePullPullCycles returns how many pull-then-free cycles the guest
// script drives, overridable via SPINIFEX_STORAGEGROWTH_PULL_CYCLES. Default
// 1 is today's single-pull behaviour, fully unchanged (see
// imagePullGuestScriptRoot/imagePullGuestScript — neither is touched by this
// mode). A value above 1 switches root mode onto
// imagePullGuestScriptRootCycles instead, looping the pull-then-free sequence
// that reproduces containerd's own evict-and-retry churn on a disk too small
// for the image — see TestImagePullGrowth's doc for why that pattern, not a
// single failed pull, is the shape a real EKS GPU worker node hit. Rejected
// outside root mode by TestImagePullGrowth itself, not here: this function
// only parses the env var, it does not have target in scope to validate
// against.
func imagePullPullCycles() int {
return envPositiveIntOr(imagePullPullCyclesEnv, 1)
}