Files
Ben Duncan 828b5df4aa fix: pause a guest on a backend I/O error instead of handing it EIO (#977)
* fix: fail DRAIN when a volume seal failed

shutdownAndUnmount logged the volume unmount error and returned nothing, so
StopAll returned nil and the DRAIN phase ACKed success with vms_stopped set.
The coordinator then advanced to STORAGE and stopped viperblock while the seal
was still in flight, tearing the VBState envelope and leaving the guest unable
to mount its root volume on the next boot. The deploy reported success.

shutdownAndUnmount now returns ErrVolumeSealFailed when the unmount did not
seal; QMP, PID-file, fw_cfg and telemetry cleanup stay best-effort. stopCleanup
and stopOne carry it without aborting the rest of the teardown, since QEMU is
already down by then, and StopAll aggregates the per-VM failures so
handleShutdownDrain ACKs with Error set and never reports vms_stopped.

volumeMounterAdapter.Unmount was the second swallow site: it logged each failed
seal and returned nil unconditionally. It now attempts every volume and returns
the aggregate, which is what the VolumeMounter contract already described.

Terminate still tolerates a failed seal — the volumes are deleted next, so an
unsealed block map loses nothing a caller can ask for.

This does not fix the seal itself. It converts silent data loss into a refused
upgrade that an operator can act on.

* test: add the storagefault e2e suite for backend-outage guest survival

Freezes predastore with SIGSTOP to hold connections open while answering
nothing, which is the fault a timeout-free path cannot distinguish from a
slow peer. On one node that is a total outage; on three it freezes every
node but the guest's own, breaking the RS(2,1) shard floor.

The suite writes and flushes a verifiable region before the fault and
churns a separate unverified region during it, so an interrupted write is
never mistaken for lost data. The root-filesystem load runs from the
user's home rather than /tmp, which is tmpfs on this image and would move
no bytes on the root device.

* test: verify the root device is under write pressure, and size the outage

The previous guard matched /^ *vda / against /proc/diskstats, where the
device name is the third field, and read $7 which is ms_reading rather
than sectors_written. It printed empty and asserted nothing, so a green
run could not distinguish a surviving guest from an idle disk. It now
samples sectors_written twice and fails unless it advances.

The outage is sized from viperblock's absorption rather than guessed:
a workload must fill DefaultMaxPendingBytes before backpressure engages
at all, so a short freeze measures the retry budget rather than the fault.

* test: add nbdkit fault injection to the storage-fault e2e suite

The predastore freeze takes the whole storage cluster away from every
guest on it, which is too broad to run beside anything else. Removing a
single volume's NBD server is the same fault at one-volume scope: one
host, one guest, nothing cluster-wide stopped.

Both signals are here because they sit on opposite sides of the defect.
SIGSTOP holds the socket open and answers nothing, so the guest blocks
and no error reaches ext4. SIGKILL closes it, and QEMU's nbd driver at
the default reconnect-delay of 0 fails requests at once, which under
werror=report aborts the journal.

Signalling is guarded: a pid is matched on comm, our plugin and the
volume argument together, and re-confirmed immediately before the
signal, because these hosts run nbdkit for other volumes and pids are
reused.

* fix: pause a guest on a backend I/O error instead of handing it EIO

Every virtio-blk device was created with werror=report,rerror=report, so a
backend outage was delivered to the guest as EIO. ext4 aborts its journal on
that and remounts read-only, and the write that failed is simply gone. Under
werror=stop QEMU pauses the VM and holds the failed request, so a backend that
comes back resumes the guest with nothing lost.

The boot drive, the cold-boot data volume and the hot-plugged data volume all
move to stop. Alongside it the NBD transport gets reconnect-delay=30, which
pauses requests rather than failing them while a storage process restarts on
the same socket; on the legacy -drive form it has to be file.reconnect-delay,
because the bare name reaches the raw format driver, which has no such option.

The QMP heartbeat now recognises run state io-error and issues cont. After ten
attempts the instance is reported impaired and the cadence drops to one attempt
per ten heartbeats, but it never stops: QEMU holds the request for as long as
the guest exists, so a backend that returns an hour later can still be resumed.

The storagefault suite gains the fault this was written for — kill the volume's
own nbdkit, hold, then put it back on the same socket — and it now verifies a
sha256 region across the outage rather than trusting the guest to be reachable.
Three harness defects went with it: the socket-liveness poll ran unprivileged
against a 0770 directory and could never succeed, the relaunched socket was
left 0755 where QEMU needs group write, and the two variants shared one guest
so a fault in the first was diagnosed in the second.

* test: cover the io-error resume policy

* test: stop two parallel tests racing on the terminate backoff variable

* fix: report a guest paused on a backend I/O error as impaired

A guest held by werror=stop kept reporting ok on both status checks. QMP stays
responsive while the guest is paused -- cont is being sent and answered -- so
the QMP failure gate that DescribeInstanceStatus keys on never trips, and the
one state a customer most needs to see looked healthy.

Instance status now reports impaired/failed while the resume budget is
non-zero, timestamped from IOErrorSince so impairedSince is when the guest
paused rather than when it was escalated. System status reports impaired too:
a backend I/O error is an infrastructure fault, not a guest one, and that
distinction is what the two checks exist to draw. The counter is cleared by
the first poll that sees the guest running, so both clear on recovery.

* test: reap the relaunched nbdkit when the fault test ends

relaunchNbdkit detaches the server with setsid so it outlives the SSH session
that started it, and viperblockd never learned it exists. Nothing reaped it, so
every run left an nbdkit holding a socket and a cache for a volume that was
later detached or deleted -- four were found running on dev-prod against two
detached and two deleted volumes.

The PID is resolved from the socket path rather than the volume id, so a
legitimate viperblockd server for the same volume is never the one killed.

* test: prove the backend is serving before a fault is called cleared

Thawing the process a fault stopped is not the same as restoring the service.
The outage test treated SIGCONT as the end of the fault and went straight to
judging the guest, so it passed while the cluster it ran on stayed unable to
serve a single object for the next 35 minutes, and the next test would have
inherited that.

thawPredastore now puts and gets an object through every gate, with a budget,
and fails the test if the backend does not come back.

* fix: do not hand back a mount entry whose export is gone

A volume whose unmount failed kept its entry in MountedVolumes while its nbdkit
was gone. Every later ebs.mount then matched that entry and returned its socket
path, so vmMgr waited for a socket nothing would ever create and the instance
could not be started again — on dev-prod a guest stopped against a frozen
backend was left permanently stopped, four start attempts failing identically
on a socket the node had already removed.

Drop the entry when there is positive evidence the export is dead: the socket
is absent, or the recorded pid is not running. Neither can be true of a live
export, so the double-writer hazard the guard exists to prevent is unaffected.

Also make the durability test's pattern helpers report failures. The read dd sat
in a pipeline, so its exit status was discarded and the harness hashed nothing
and returned the empty-string sha256 as a checksum.

* test: report the guest console when a restart never answers SSH

A restart that reaches running but never comes back is indistinguishable from
a dead root filesystem without the serial console, and the log is gone once
the fixture terminates the instance.

* fix: converge guest port datapaths concurrently, not one deadline after another

Each guest port that misses its SB binding burns the full convergence deadline,
and applyEIPs spent them in series. Three ports belonging to long-stopped
instances therefore held a newly launched guest's EIP dark for the sum of them
before its own probe started.

AddEIP stays serial since it mutates NB. Only the waits fan out, bounded, and
sb-cluster-state-reset is now serialised with a minimum interval so concurrent
probes cannot each fire a cluster-wide resync.

* fix: refuse a stale cross-node start after the owning node is gone

The volume lease already refused a cross-node mount, but it is a liveness
claim: 45s TTL, renewed every 15s. A node that dies holding un-uploaded
writes stops renewing, and 45 seconds later nothing refuses a start elsewhere
that loads a stale backend checkpoint.

A failed seal now writes a durable marker naming the node holding the only
current copy, cleared on a seal that reaches the backend and on delete. The
lease covers a live holder, the marker a dead one.

The refusal also reaches the caller instead of being flattened to
ServerInternal, which told an operator to retry something that cannot succeed
while the holder is down.

* fix: keep the daemon's message when a start is refused

Rebuilding the error from the code alone rendered the generic ErrorLookup
text, so a refusal naming the node holding the volume reached the caller as
boilerplate about attaching volumes.

* fix(ec2): terminate detaches every DeleteOnTermination=false volume

TerminateStoppedInstance skipped the detach for a non-Boot DoT=false volume
on the assumption that Stop's Unmount had already cleared its attachment.
Unmount only clears it when the seal succeeded, so a volume whose seal failed
stayed in-use pointing at an instance that terminate then deleted, leaving it
unreachable with no owner to release it.

The running-instance path already detaches every DoT=false volume; the stopped
path now matches it, and tolerates an already-gone volume the same way.

DeleteVolume also keeps its provider error on the way out. The terminate reaper
retries the delete every two minutes and logged a bare ServerInternal, which
made a blob node being down indistinguishable from a transient fault.

* fix(ec2): DetachVolume --force releases a volume whose instance is gone

The ordinary detach routes to ec2.cmd.{instanceID} for the guest unplug, so a
volume left attached by a part-way teardown could never be detached: the
instance no longer runs anywhere and nothing answers. That also made it
undeletable, since DeleteVolume refuses an attached volume.

ForceDetachVolume already existed for this deadlock but was reachable only from
account teardown. Force now falls back to it when the instance has no responder,
which is the one case where clearing the attachment cannot strand a live guest.

* fix(viperblock): unsealed writes steer placement instead of pinning a volume

The dirty marker refused any mount from a node other than the one holding
unsealed writes. Instance start already forwards to the node that last ran the
instance and falls back after a 30s window, so that refusal turned a working
fallback into a volume that could run nowhere once its owner was gone. It
traded a rare data-loss risk for a permanent availability failure.

The marker is now a record and a warning, not a gate. A takeover logs the
previous holder and why it could not seal, then adopts the marker so the old
node is no longer treated as the better source. Live exclusion is unchanged and
still enforced by the lease.

It is also written on open rather than on seal failure. A node killed mid-write
never reaches its seal, so the old trigger left no trace in exactly the case the
marker exists to describe.

Adds 'spx admin volumes unsealed' to see which volumes would cost something if
started elsewhere.

* fix(viperblock): fence a volume whose lease moved instead of logging it

The lease was checked once, at mount, and never again. A node that lost it
kept its export up and kept writing into a volume another node owned, so
both copies advanced and the backend settled it by arrival order.

Losing a renewal now decides: another holder means tear the export down,
no holder means the entry merely aged out, so reclaim it and leave the
guest running. Fencing never seals — this node's copy is the stale one —
and leaves the dirty marker for the winner to take over.

This is not an epoch fence. It narrows the window from the 45s TTL to one
renewal interval at no write-path cost; the backend still cannot refuse a
stale writer.

* fix(network): advertise EIPs so a recycled address is reachable

The gateway switch port only asked ovn-northd to publish the router's NAT
addresses in centralised and routed modes, so on a distributed cluster nothing
ever announced an elastic IP. OVN still answers ARP for it, which is why a
first-time allocation works and hid this — but no gratuitous ARP is sent when
the address moves to a new ENI, so every host on the segment keeps resolving it
to the previous holder's MAC until its cache ages out. Reusing an address from
the pool then lands on a dead MAC and the instance is unreachable from outside.

Distributed mode needs the option as much as the others: northd scopes each
entry with is_chassis_resident(<vif port>), so the announcement leaves from the
chassis running the instance rather than the gateway, which is what distributed
NAT wants.

Converge on the already-attached path too. AttachIGW returns early once the
gateway port exists and nothing else rewrites it, so without that an existing
VPC would stay silent for its whole life.

Confirmed on dev-prod: setting the option moved three EIPs from unadvertised to
carrying their correct external_mac, each scoped to its own ENI's port.

* fix(viperblock): make the volume fence and its dirty marker ownership-safe

Four corrections to the lease-loss fence, all found in review.

Drop the reacquire path. Losing a lease and finding no successor was treated as
a lapsed entry worth reclaiming, but leases.acquire returns the cached entry —
already flagged lost, with a dead renewal goroutine — so the "reclaimed" lease
renewed nothing and the node went on writing a volume it could not prove it
owned. The two errors are not symmetric: a false fence costs one guest a
restart, a false reclaim leaves two nodes writing one volume. Fence in both
cases, and report which happened through the outcome attribute.

Surrender a lease this node cannot confirm. Renewal only noticed a loss when
the server rejected an update, so a node partitioned from NATS kept its export
up while the TTL expired and another node took the volume. Track the last
confirmed renewal and give the volume up once that ages past the point where
the server may admit another owner. Bound the renewal call too, so a hung
JetStream request cannot hold the goroutine past that point.

Do not act on a kill that did not take. Both the fence and ebs.unmount removed
the mount entry, released the lease and announced the result before knowing
nbdkit had exited — the fence then reported a writer stopped that was still
running, and the unmount sealed into a directory a live process was writing.
Wait for the exit, and on failure leave the volume mounted and say so. An
already-exited process now counts as success, which is what the caller asked.

Give the dirty marker a generation. It recorded an owner and nothing to order
two nodes' claims, so a node returning after a takeover could clear or overwrite
the winner's marker and point placement back at the stale copy. Stamp the lease
generation, refuse a write from an older one, and only clear a marker this node
still owns. A marker that cannot be written now fails the open rather than
leaving writes nothing records.

* test(viperblock): cover the stall surrender and the unmount kill guard

The two ownership cases the fence work left untested.

A holder partitioned from NATS while the winner keeps it never gets an update
rejected, so revision checking cannot see it — it has to give the volume up on
its own clock. Pins that validity sits below the server TTL with room for a
check in between, that a lapsed holder reports the loss as a stall rather than
as a takeover, and that releasing a surrendered lease leaves the entry for
whoever holds it now.

The unmount side of the kill guard: sealing under a live nbdkit writes a state
torn between two writers, so a kill that did not take has to fail the unmount
and leave the entry mounted for a retry.

* fix(e2e): keep the guest private key for as long as the key pair

EnsureKeyPair computed the PEM path from the caller's directory but wrote
the file inside the ensureOnce closure, which runs only on the first call.
Callers passed a per-test artifact dir, and ArtifactDir removes that dir
when the test passes — so the first test's success deleted the only copy
of the private key while the key pair stayed registered, and every later
call returned a path nothing had ever been written to.

Guests after the first were then launched against a public key whose
private half no longer existed, so the harness could never authenticate
and gave up after the full five-minute budget. The guests were healthy
and reachable on tcp/22 throughout, which is why this read as a storage
or network fault for so long.

The directory parameter was the defect, so it is gone: the PEM now lives
in a directory the fixture owns and removes with the key pair, and no
caller can supply one that is shorter-lived.

Also reorder the killed-nbdkit teardown. The reaper registered its own
cleanup after the detach registered one, so LIFO killed the block server
before the platform detached the disk and DetachVolume then failed with
"Node ... is in use". The detach cleanup now reaps once it is done.

* fix: stop leaking an nbdkit export when recovery cannot adopt it

Restart recovery adopts a guest's boot volume, then routinely fails the
-efi volume with "claim lease: context deadline exceeded" -- JetStream is
still catching up at daemon start and the KV claim runs on the client's
5s default. Recovery gave up on the first error and never retried, so the
export stayed live with no registry entry. At terminate the unmount found
nothing to seal, returned NotFound and killed nothing, and the process
outlived its guest holding the volume and socket open. EFI is always the
second volume adopted, which is why it was always the one that leaked.

Two changes, because either alone leaves a hole:

- rebuildMountedVolume retries the build (4 attempts, 3s apart) so a
  JetStream that is merely slow no longer orphans an export for the life
  of the process.
- unmountVolume reaps a corroborated export for the volume it was asked
  to unmount when the registry has no entry for it, so an export orphaned
  by any means is still cleaned up. The reap goes through
  reapOrphanedNbdkit, which declines anything a live process references:
  at unmount the guest's blockdev may not be torn down yet, and killing
  the server underneath it strands QEMU.

* test: hold the backend-serving probe and assert predastore readiness

The backend-outage test declared the backend restored on one successful
round trip through each gate. A gate can answer the request that arrives
just after a thaw and then lose its blob peers under the load that
follows, which is a backend that never recovered reported as one that did
-- and that is what happened: the probe reported all three gates serving
minutes before the guest was still taking BLOCK_IO_ERROR on every resume.

The round trip has to hold across three rounds now, and the count restarts
rather than decays when one fails, so a gate that serves and then stops is
the failure the hold is looking for rather than noise it averages out.

It also checks /readyz on each host and reports any failed check by name.
The round trip only proves this workstation can write through a gate; it
says nothing about which blob peers that gate can reach, and a gate one
node short of the write floor serves a small object and fails a guest. A
host that serves no admin listener is logged and skipped, since the port
is optional configuration.

* fix: scope predastore's admin port to the cluster plane in the host policy

predastore's admin listener binds the host's cluster bind_addr and serves
/healthz and /readyz unauthenticated. Readiness now names which blob peers
a gate cannot reach, which is exactly the kind of topology detail 4432 is
peer-scoped for, so 8660 joins it in the peer-scoped rule rather than the
public one.

* fix: keep resuming a paused guest at heartbeat rate after a long outage

Past ten attempts the cont dropped to one per ten heartbeats, so a guest
whose backend had come back could sit paused for another five minutes
before anything retried it. Measured on dev-prod: the backend was proved
serving through all three gates while the guest was still waiting on
attempt 60, five minutes after attempt 50.

The slowdown was there to avoid paying a failed I/O every thirty seconds
on a backend still down. That is nearly free, and five minutes of downtime
after recovery is not, so only the log line is throttled now.

* test: fail the run when a key pair name resolves to more than one pair

A create that lands twice leaves two key pairs with one name and different
material. RunInstances resolves the name to one of them while the harness
holds the private key of the other, and the only symptom is every guest in
the run refusing the key at the SSH gate — five minutes of waiting per
guest, four guests, and nothing anywhere naming the cause. Observed on
dev-prod: two pairs created a millisecond apart, both named
e2e-key-30de10ba.

One DescribeKeyPairs after the create says so outright instead.

* test: tolerate an already-released volume in the detach teardown

Teardown runs DetachVolumeWait after a terminate that releases the volume
itself, so the detach can arrive at a volume already on its way to
available. That is the outcome it wanted, and treating the IncorrectState
refusal as fatal failed a backend-outage run whose assertions had all
passed — the guest came back, every acknowledged write verified, and the
test still went red on the way out.

VolumeInUse is still fatal: that one means the volume is attached
somewhere it should not be.

* test: carry the merged suites onto the reworked key-pair and account-scoped seams

The diskperf suite arrived on dev calling EnsureKeyPair with the artifact dir
this branch removed, and DeleteVolume's metadata lookup is account-scoped now,
so the provider-failure test has to seed and delete under the same account.

* refactor: make the fence outcome a type in one const block

Four separate exported string constants with a paragraph of comment each,
passed to RecordVolumeFence as a bare string. Group them, give them a
FenceOutcome type, and take that type at both call sites so an arbitrary
value can no longer reach the metric attribute.

* refactor: move viperblockd's wire contract into a leaf vbwire package

The daemon imported viperblockd for exactly two symbols -- the fenced
volume subject and its event -- which put the storage service back on the
daemon's dependency graph to name something it only listens for.

vbwire holds the contract instead: the subject and event, the dirty
bucket name and record shape, and the read-only listing an operator tool
needs. It imports nothing from spinifex, so both the daemon and the admin
CLI drop their edge to viperblockd entirely. cmd/service.go keeps its
one, which is genuine: it constructs the service.

* docs: drop a specific host name from the start-refusal comment

Named a real dev-prod host in a comment and in test fixtures. Say what
the name is instead.

* ci: give storagefault a dedicated nightly cell

The suite is eligible for both topologies and runs on the PR gate, but no
nightly cell ran it: the permutation cells run a deliberately narrow
subset and none of the dedicated suite cells named it.

Cell 29, single-node, following eks/ecs/rds. It cannot join a permutation
cell -- it SIGSTOPs a cluster-wide service, so every suite sharing that
environment would report the outage as its own failure.

* ci: run storagefault nightly on three nodes, not one

The previous commit put its dedicated cell on a single-node runner. That was
the wrong topology and the wrong justification: single-node is RS(1,0), where
the shard floor cannot be broken and a cross-node takeover after a failed seal
does not exist, so the two assertions worth having a dedicated cell for were
the two it could not make. Cell 29 is now real-multi on ci-multi.

It also corrects the claim that the PR gate already covers the multi-node case.
The suite is eligible for both topologies, but e2e.yml's default suite list
never names it, so it has never run there. A full pass is ~63 minutes on
dev-prod bare metal, almost all of it deliberate waiting -- one test holds
predastore SIGSTOPped for seven minutes -- which is too long to add to a gate
that currently finishes well inside its budget. Nightly is where it belongs.

Consequences: a per-cell timeout_minutes, defaulting to the previous 50, since
90 minutes of suite cannot run in a 50-minute job; the permutation steps gated
on matrix.suite == '' so the dedicated cells do not also run them; and a Run
Multi Suite E2E step that builds NODES_CSV from the tofu inventory and asserts
the suite against E2E_SUITES_MULTI before running it.

* fix: stop a startup KV race leaving vpcd serving keyless guests

vpcd opens five KV buckets while NATS is still coming up, each attempt
carrying a ~5s JetStream API deadline, and applied three different policies to
the same failure: spinifex-vpc-enis was fatal, while instance-state,
security-groups, subnets and vpcs each degraded silently for the lifetime of
the process. A five-second blip during a cold start therefore decided how a
node served metadata until someone restarted it.

On dev-prod all three nodes hit this after a deploy. Two were saved by
accident: their ENI bucket also timed out, so they exited 1 and systemd
restarted them cleanly. The third's ENI bucket opened in time, so it never
crashed and ran for hours with no instance record loader. With the local state
file unreadable as well, every instance-backed IMDS leaf 404'd, cloud-init's
crawl died on ami-id and fell back to DataSourceNone, and every guest launched
on that node booted with no authorized_keys.

kvstore.OpenWithRetry retries for a bounded window before any policy applies,
logs the cause per attempt and the wait on a late success, wraps the last error
with the bucket name, and returns at once on a cancelled context. All five
opens use it. Failing to open the instance record space is now fatal, matching
the ENI bucket: the dependency whose loss stops guests logging in should not be
treated more leniently than the one whose loss stops ENI resolution.

Also fixes the reason the fallback was load bearing at all. The local state
file was written 0600 and vpcd reads it as a different user in the shared
spinifex group, so the local-first path had never once worked and its
mtime+size cache had never fired. It is 0640 now, set with an explicit Chmod so
an inherited umask cannot undo it.

Closes mulga-gtpzz, mulga-dhqxr. Still open: mulga-mm2oc, IMDS serving an
unresolvable instance as "no key pair" with nothing logged.

* fix: scope the ime-* firewall accept to IMDS and VPC DNS only

The per-tap OVS demux delivers any guest packet addressed to
169.254.169.254 or .253 onto an ime-* host port regardless of L4 port,
and iifname "ime-*" accept then admitted all of it. Every wildcard-bound
host service — sshd, the AWS gateway, northstar, the console — answered
guest packets on those addresses; only asymmetric reply routing kept the
handshake from completing, and a guest could still drive northstar to
recurse unthrottled. Scope the accept to IMDS (.254:80) and VPC DNS
(.253:53), letting everything else fall through to policy drop.

* docs: document the instance-to-host plane and how to protect it without the ISO

The metadata/VPC-DNS link-local plane (169.254.169.254/.253) is served by
host services and is invisible to a NIC-level or cloud firewall, since the
traffic is intra-host on the OVS bridge. Explain that the ISO ships the
scoping rule armed, that the binary installer ships the firewall off, and
that a non-ISO install must enable it with setup.sh --firewall=on or
firewall_enabled=true. Include verification from an instance and the host.

* fix: withhold the IMDS meta-data listing when the instance does not resolve

Every omission from the meta-data root is a positive claim about the instance —
public-keys/ absent means "launched without a key pair", and cloud-init believes
it, writes no authorized_keys and boots the guest keyless. So resolving the
instance was best-effort for a listing whose whole meaning depends on it: a
backend error or a miss shortened the listing instead of failing it, and nothing
anywhere recorded that the answer was a guess.

Withhold the listing with a 500 instead. cloud-init retries a 5xx and cannot
retry a 200 that is merely wrong, so this converts a permanent silent failure
into a transient loud one. servePublicKeys already had this policy for the leaf;
the root is what contradicted it.

A profile lookup that fails still serves the listing minus iam/, and now logs.
The key pair is the field a guest cannot recover from losing, while a missing
role surfaces through the credentials leaf, which reports the miss itself.

Also stops the storagefault cross-node test failing on its own access path. It
tolerates the pattern changing across a takeover, but readPattern needs sudo,
and a volume opened from the backend checkpoint may predate cloud-init writing
its sudoers drop-in — indistinguishable from an unreadable device at the dd call
site. It now checks sudo first, and reads cloud-init's recorded datasource to
tell a rolled-back root filesystem from IMDS never having answered, failing
loudly on the latter rather than accepting it as storage loss.

---------

Co-authored-by: brynmailer-mdc <bdm@mulgadc.com>
2026-09-03 19:45:33 -05: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)
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)
}