Files
spinifex/docs/testing/e2e-coverage.md
T
Ben Duncan 021f439bab feat: recover instances automatically from a node that stopped heartbeating (#1111)
* feat: recover instances automatically from a node that stopped heartbeating

A node that dies hard takes its guests with it, and nothing brings them back
until an operator brings that node back. Every daemon now runs the same small
reconciler: it reads the instance records, selects desired-running instances
whose owner is stale, and relaunches them here.

The claim is a CAS on Status.LastNode and that is the whole of the
coordination. Several survivors race one instance, exactly one revision wins,
and the losers find a record naming a live owner and stop considering it. A
claimant that dies mid-launch leaves the record naming a node that is itself
stale, which is the same candidate the next pass derives again, so there is no
lease to expire and nothing to reconcile by hand.

Ownership is not permission to write. The volume leases still decide whether the
guest can move, and VolumeMounter.Mount runs before startQEMU, so an attempt
that loses the volume race has touched no tap, no OVS port and no OVN binding.

A storage failure is not a host failure and wants the opposite response. A guest
paused by werror=stop publishes Health.IOErrorSince, and a record carrying it is
refused: the volumes are the same objects in the same store from anywhere, so
relaunching moves the pause rather than ending it. A node whose own object store
is not answering stands down entirely, which separates a store broken here from
a store broken everywhere without either having to be diagnosed. A launch that
does not complete backs this node off that instance, doubling to a cap, because
nothing on the mount path can yet say which of the two it hit.

Recovery is off by default and inert below three nodes. It also waits until it
has seen its peers live, or five minutes, so the first node back from a
coordinated shutdown does not recover guests from nodes that are still booting.

* test: prove instance recovery moves a host failure and refuses a storage failure

The e2e half of automatic instance recovery, asserted from both sides because a reconciler that cannot tell the two failures apart passes one and fails the other. InstanceAutoRecovery takes a node down and requires the guest back on exactly one survivor, identified by the qemu process rather than by what the API reports. InstanceRecoveryRefusesStorageFault stops predastore cluster-wide first, so the guest is paused by werror=stop when its node goes away, and requires that nothing moves.

Also fixes the settle window, which re-evaluated every pass and so held a daemon back for its first five minutes over exactly the failure the reconciler exists to handle. It now latches once every peer has been seen live.

harness.InstanceHostingNodes replaces the single-node lookup so a caller can assert there is exactly one host rather than assume it, and skip a node it has deliberately taken down.

* fix: make automatic instance recovery a cluster-wide setting

Recovery was read from the per-node Config, so it had to be written three times on a three-node cluster, once inside each [nodes.<name>] table. A top-level [recovery] table — the obvious thing to write, and what the e2e suite writes — was parsed into nothing and the daemon reported recovery disabled on every node with the setting plainly in the file.

Cluster-wide is also the honest shape. A node with recovery off is not opting out of anything: recovery is what survivors do to a dead node's instances, so the setting belongs where there is one of it.

* test: drive real I/O into the storage-fault case, and freeze the store rather than stop it

Measured on a three-node cluster: predastore stopped cluster-wide for twelve minutes against idle guests paused none of them. Viperblock serves what it has cached and an idle guest issues nothing that reaches the object store, so the test asserted that nothing moved in a run where nothing was ever faulted.

The victim now launches with cloud-init user-data that writes to its root volume continuously. User-data rather than SSH because a routed-NAT cell gives guests no public address, so a test that had to log in could not run on half of them.

The fault is SIGSTOP rather than a unit stop, for the reason the storagefault suite already gives: a frozen process holds its connections open and answers nothing. Under that fault and that load the first BLOCK_IO_ERROR arrived 4m47s later, so the pause budget is now ten minutes rather than three.

* test: give instance recovery its own suite and its own nightly cell

Both tests take a node away and one of them freezes predastore cluster-wide, so anything sharing the environment reports that outage as its own failure — the same argument storagefault already makes for being its own package. They are also slow by nature: a dead owner has to be seen stale twice, a guest has to boot, and the storage case waits for a pause measured at 4m47s and then holds four minutes to prove nothing moved. Roughly twenty minutes on top of a permutation cell budgeted at thirty-five.

Cell 31 is three-node and static, matching storagefault's, with a 75-minute job budget over a 60-minute suite timeout. The suite is eligible on multi-node environments and deliberately not in the nightly permutation set.

* fix: give a guest long enough to open a volume the object store is slow to serve

QEMU opens every drive before its monitor answers a greeting, so the QMP
greeting deadline is really a deadline on opening the guest's volumes. At 30s
it was shorter than a cold viperblock open costs on a cluster that has lost a
node, where every object read is served degraded: measured at 35s with one node
of three away. The launch killed the guest five seconds short of ready and then
did it again on every retry, which is a recovery that can never complete.

A network-backed drive now gets its own deadline for the same reason VFIO
already had one — synchronous work before the monitor answers — and the
instancerecovery suite is what found it.

Also make the suite's predastore thaw best effort per node. It runs after a
node has been taken away, where there is no process to signal, and a node it
cannot reach must not stop it restoring the rest.

* docs: name the two newest cells in the dispatch input

The cells input is the operator's index of what a number means, and it stopped
at 29 while 30 and 31 were added.

* test: prove the guest is writing before freezing the store in the recovery suite

The storage-fault test froze the object store as soon as RunInstances reported
running, which is when QEMU has launched rather than when the guest has booted.
On the nightly cluster the freeze landed while the guest was still in UEFI, so
the only drive it could reach was the EFI varstore — and pflash has no
werror=stop to pause on, so the guest never paused and the test reported a
product failure it had caused itself.

The load driver now prints to the serial console after each pass it has written
and synced, and the test waits for that before freezing. The console is read
through GetConsoleOutput, so this still needs no login and still runs on cells
whose guests have no public address.

* feat: recover instances automatically with nothing to configure

The switch is gone. An instance whose host has failed is down whether or
not recovery is enabled, so the only outcome a disabled reconciler can
produce is leaving it down — which is the state recovery exists to end,
not a safer one to fall back to.

Against that, the switch cost a cluster that can be configured into
never recovering anything, with no symptom until a node dies. This
feature already hit that once: a top-level table parsed into nothing and
every node reported recovery off with the setting plainly in the file.

What makes recovery inert is now only facts about the cluster — no
cluster config, no JetStream, or fewer than three nodes — and each says
in the log which one applied. The e2e no longer writes config or
restarts daemons: it waits for every node to report the reconciler
active on a cluster nothing configured, so reintroducing a switch fails
there as well as in the unit tests.

* feat: stop retrying a recovery that is never going to work

Every failed attempt used to go to the same exponential backoff and stay
there. An instance nothing could launch was retried every fifteen
minutes forever, still observed-running on a node that is gone, with no
account of it anywhere the customer or an operator would look.

A failure is now classified and counted. Two classes are read from what
the launch actually reports: the node it was taken from still holds a
volume lease, which clears on that lease's clock and is retried on a
faster one, and this node has no room, which another survivor may. There
is no terminal class, because nothing on this path can prove one — the
launch collapses everything else into ServerInternal, so a verdict of
"never going to work" would be a guess, and the guess that abandons a
recoverable instance is the expensive one.

The budget bounds the unknown case instead. Every class counts against
it, since a class that did not would be the old unbounded retry under a
narrower condition. Exhausting it leaves the instance where AWS leaves
one whose host failed and could not be brought back: stopped, carrying a
StateReason that names the cause and the last error, one StartInstances
from running again. Writing the desired state down is also what makes
one node's decision cluster-wide, because selection reads desired state
— the same property the claim already has.

A record another node has since claimed is never touched, so a node
exhausting its attempts cannot stop a guest that is now running
elsewhere.

* fix: give a guest the time for every cost it pays before its monitor answers

A GPU instance booting from a network-backed root volume pays both
costs: QEMU opens every drive across the object store and then pins the
whole of guest RAM, one after the other, all before the monitor answers.
The deadline took whichever was larger, so such a guest was given less
time than it needs.

That the two floors are both 180s is why nothing has been seen yet — the
VFIO branch has always returned at least the network-drive deadline. It
is the same defect either way, and the symptom when it lands is a GPU
instance SIGKILLed on every attempt while its plain sibling launches,
which reads as a GPU fault rather than as a deadline.

The costs are now added, the plain QEMU startup is counted once rather
than replaced, and the cap covers both floors together so it cannot clip
a guest before either cost has been waited out.

* test: assert the AWS-visible arc of a host failure end to end

A customer cannot see which host an instance is on, and AWS never tells
them. What they can see is DescribeInstanceStatus, so that is where a
recovery has to be provable, and the suite never looked at it.

Three frames are now required, in order: running with system-status ok
before anything is broken, system-status impaired while the owning node
is gone, and ok again once a survivor is running it. The middle frame is
the one worth having — the window opens when the owner's heartbeat goes
stale, which is strictly before any survivor may claim, and closes when
the relaunch registers, so it is bounded rather than lucky. Reporting ok
throughout would be the more comfortable lie.

The availability zone is compared across the first and last frames as
well. A recovery that crossed one would break every placement promise
made to the customer, and nothing else in the suite would have noticed.

* test: prove a half-healthy node fences itself and is never a second writer

The two existing recovery cases take a node away by stopping its services,
which is the failure this feature handles least interestingly: a node that is
gone has no opinion about who owns its volumes. The one that matters is the node
that is still up, still running QEMU, still answering its own local clients, and
simply cannot reach consensus. That is the state in which two nodes can each
believe they own a volume, and the only thing between that and a corrupted disk
is that the partitioned node gives up first, on a clock it can evaluate without
reaching anything.

So this drops NATS on 4222 and 4248 between one node and its peers and nothing
else, then asserts the ordering the safety argument rests on rather than the
outcome it produces. A watch samples every node every three seconds for the
whole run, including the heal, and fails on any single sample naming two hosts.
Two guests writing one volume through one object store is data loss already
under way, not a race that resolves, and no point-in-time assertion can see one
that appeared between its own samples.

The injector takes its own nft table rather than touching inet spinifex_filter,
because a test that flushed the node's real firewall would leave it either
unfirewalled or misfirewalled with no sign of which, and a separate table at
priority -300 heals in one atomic delete. A transient systemd timer removes it
independently, so a cancelled workflow does not leave a node cut off from its
cluster until somebody notices. It proves SSH still works and proves a peer port
is genuinely unreachable before anything is asserted, since a rule set that
installed cleanly and dropped nothing would read as the system tolerating a
partition.

The suite budget goes to 90m and cell 31 to 105m to fit the third case.

* fix: move an instance's boot state, volumes, logical port and address with it

A record-driven launch assumes the node reading the record is the node that wrote
it. On a daemon restart that holds. On a recovery onto a survivor it does not, and
five things follow from it — one that stops a moved instance booting at all, and
four that stop it running anywhere.

A load balancer never comes back from a host failure. Its boot configuration is a
set of files under the runtime directory and the record holds their paths, so the
survivor restores paths that exist only on the node that died. The hook that
rebuilds them was reachable from Restore alone, and the cross-node path goes
straight to vmMgr.Run — so QEMU is handed a netcfg, an agent env and a CA cert
that are not there. The gateway URL baked into that env names the creating node's
management bridge as well, which the rebuild corrects because it rebuilds from
whichever node is running it. Firing the hook on both paths fixes both, and a
rebuild that fails now refuses the launch: a guest that boots without its network
configuration is worse than one that does not boot, because the records would say
it recovered.

The export outlives the guest and holds the volume lease. A returning node is
healthy, so it renews that lease indefinitely and the new owner never acquires:
the guest is stopped here and refused there, recovery spends its attempt budget on
a volume that was never going to be free, and gives up on an instance that was
never unrecoverable. So the export is given up too, over a new node-addressed
request that tears it down exactly as the fence does. Deliberately not an unmount
— an unmount seals, this node's block map is the stale one, and sealing publishes
it over the winner's. Unlike the fence it deletes the lease key rather than
leaving it to expire, because the lease is still this node's to hand back.

The tap outlives the guest, and the tap is what claims the instance's OVN logical
port. The binding names one chassis, so two nodes offering the same iface-id do
not split the traffic — they contend, and the address answers from whichever won
last. It also blocks the layer above: the external IP's host ingress is pruned on
the evidence that the owning guest sits on another chassis, and that stays false
while this node is still claiming the port.

The management address outlives the guest as a cluster-wide reservation, and the
release was unconditional. br-mgmt is one flat L2 segment, so the node an instance
left was freeing an address still in use and offering it to the next instance
anywhere. Only the node an entry names may release it now, with the takeover
recorded on arrival so the node actually running the instance can still give the
address back. An entry written before the node field existed names nobody and
stays releasable, because there is no other holder to collide with.

The fence path already cleaned up the tap, so the release fix repairs that path
too.

* test: prove a load balancer keeps its name, its address and its traffic when its host dies

Takes away the node running an internet-facing ALB and asserts the four things a
customer of one actually holds: the name keeps resolving, the address behind it
does not move, requests through the name are answered again without anybody being
called, and the backends are healthy again from the recovered load balancer's
point of view.

The backends are placed around the load balancer rather than beside it. A load
balancer lands wherever a node answers its queue group, so its node is discovered
and the backends are relaunched until both are elsewhere; one on the node about to
be taken away would be recovered too, and the run could not then tell an ALB that
came back serving from one that came back with nothing to serve.

DNS is asked of a node's own northstar over the wire, and the HTTP probes resolve
through the same server, so the claim under test is what this cluster answers for
a name rather than what the machine running the tests was configured to forward
to. A watch samples every surviving node every five seconds for the whole run,
because an answer that went missing or pointed elsewhere for one reconcile pass is
the outage, and no single sample reaches it.

The returning node then has to give up what it no longer owns: no second copy of
the guest, and no tap still claiming the load balancer's logical port, since OVN
binds a port to one chassis and a second claimant contends for it rather than
splitting the traffic. The last assertion is that the name still serves with that
node back in the cluster.

* docs: name the new cell in the nightly's cell list

* test: stop the disk-load guest from filling the console it is judged by

The load generator announced every write, once a second, for as long as the
test ran. The console is a ring buffer this suite reads back, so an hour of
that pushes out everything explaining a failure, including the kernel's own
account of the I/O error the test exists to produce. Announce twenty times,
then keep writing quietly.

* docs: say what the OCI guide has been proven on, and what a host failure does

The guide read as a support statement for OCI rather than as an account of one
tested path, promised seven steps and then gave eight, and said nothing about
what happens to a guest or its public address when the node under it fails.
State the evidence, state that the nightly guards the datapath and not the
cloud, and give the failure behaviour as a table with the reason codes a
customer will actually see.

Also say plainly what the volume lease is: one running guest and a bounded,
logged window, not a write barrier the object store enforces. Someone sizing a
workload against this should read that before they read the table.

* docs: say why the mount classifier refuses to retry a lease another node holds

It reads as an omission — a lease held elsewhere is the most transient refusal
there is. It is not. This classifier drives the restore path, where the holder
is a node that is running the guest, so retrying would relaunch against a live
owner. Recovery wants the opposite and reads the refusal itself.

* feat: claim a recovered guest's public address in line, and verify it arrived

A recovered instance was reported running before anything moved its public
address off the node that died, so for up to fifteen seconds the records said
recovered and the address answered nothing. The recovery path now runs the
affinity pass itself, which is idempotent and is the same pass the ticker runs.

Two things the pass could not do before. It abandoned every remaining address
on the first API failure, though a throttle or a denial is per-object and the
guests behind the others are just as dark. And it trusted the move's own
response, which is OCI agreeing to the request rather than OCI having done it,
so an accepted move that never landed read as success. It now re-reads what OCI
delivers here and reports the difference.

An address that will not arrive makes the guest a system-status failure with the
time it went dark, the same shape AWS uses for a host-level fault. The guest
passes every check it can be asked about itself, which is exactly why reporting
it healthy was the worst available answer.

* feat: give a recovery attempt one trace across all three of its phases

The volumes opening, QEMU starting and its monitor answering already trace
themselves, but nothing tied them to the attempt that asked for them: three
orphan traces per recovery, no way to ask which phase a slow one spent its time
in, and nothing naming the node the guest came from. A span over the attempt
makes them its children, and the reconciler's own log lines carry its trace ID
because the handler stamps it from the context.

A failed attempt records the fault and the attempt count on the span, so the
budget running out is readable as a sequence rather than as twelve unrelated
warnings.

* fix: finish a fence once its writer finally exits

A kill that did not take effect was logged, counted and dropped. The export
stayed registered and the lease stayed held, which is correct while the writer
lives and wrong forever after it dies: a lease a healthy node keeps renewing
never expires, so the volume could not start anywhere.

The failure is now re-reported every 30 s until it resolves, and the teardown
completes as soon as the writer is confirmed gone. Only a delivered kill is
waited on — a refused one says nothing about what became of the writer.

* test: stop judging a detached stop or terminate on a transient state

Both tests read the instance state immediately after the ack, but the transition
runs on its own goroutine, so the read races it in both directions: too early
sees running, too late sees the final state. The race gate caught the first of
those on a loaded runner.

* fix: replicate the volume lease and dirty buckets across the cluster

Both were created with the default single replica, so both lived on one node.
That node holds the answer to "who may write this volume" and the marker every
mount reads, and losing it takes volume leasing and mounting down cluster-wide.
A partitioned node was fenced correctly and then no survivor could relaunch its
guest: every attempt failed on "read dirty mark: no responders available", for
ten minutes, until the budget ran out.

The replica count is now the cluster's node count, as the gateway's own buckets
already do. Existing buckets are raised in place rather than requiring a wipe.

The e2e diagnostics that should have said this are fixed too. The fence and the
surrender are logged by the storage service, not the daemon, so the assertions
read the wrong journal; and Eventuallyf evaluates its message at the call site,
so a ten-minute wait was explained by a log that stopped before it began.
2026-09-27 15:56:09 -05:00

16 KiB
Raw Blame History

What each E2E suite proves

This index exists to answer "does anything already test that?" before someone reasons it out from the code. Comments describe intent when written and can go stale; these suites run against a live cluster and are the current answer on behaviour.

Suites live in tests/e2e/<name>/ and build to tests/e2e/_bin/<name>.test. Every file carries //go:build e2e, so a default build skips them. The sets that decide what runs where are in .github/scripts/suite-sets.sh, and the nightly permutation matrix is .github/e2e-cells.json.

Set Suites
E2E_SUITES_SINGLE single iam cert eks ecs storagegrowth partialblock rds quota storagefault
E2E_SUITES_MULTI multinode lb cert quota lbrecovery instancerecovery storagefault
E2E_SUITES_NIGHTLY_SINGLE single cert iam
E2E_SUITES_NIGHTLY_MULTI multinode cert lb

The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. eks, ecs, rds, storagefault, instancerecovery and lbrecovery each get a dedicated cell instead.

The suites

single — the broad single-node EC2/EBS/VPC surface

The largest suite, 25 top-level tests. Notable entry points:

  • TestVPCEgressPaths — the authority on subnet egress semantics. One VPC with a public and a private subnet, sharing two guests across four stages: PublicSubnetEgress (IGW egress works), RouteBeforeSubnet (a regression guard for an IGW route installed on the main route table before the subnet exists), the NAT Gateway rounds, and EIPFlip (associating an Elastic IP onto a running instance).
    • NATBaseline hard-fails if a private-subnet guest reaches the internet with no NAT Gateway. NATGatewayUp then proves egress appears and NATGatewayDown that it disappears. SPINIFEX_VPCEGRESS_NAT_ROUNDS repeats the up/down cycle for a soak run.
    • This is AWS parity for private subnets. It ran in pool mode only until 2026-09-26: the fixture gated it on external_mode = "pool", on the stale premise that nat clusters have no EIPs. They do when a public pool is configured, so the gate is now "can this cluster allocate public addresses" and the nat-single cell runs this test beside natuplink, which never creates a private subnet.
  • TestSGPolicyDatapath, TestSGReachabilityPolicy — security groups as an actual datapath, not just API state.
  • TestWANEgressSMTPBlock — egress port blocking.
  • TestInstanceMetadata — IMDS from inside a guest.
  • TestStopStart, TestEarlyRebootLiveness, TestGuestChurnDurability, TestSnapshotLifecycle, TestSnapshotBackedLaunch, TestCreateImage, TestVolumeLifecycle, TestSpotInstanceLifecycle, TestLaunchTemplateBoot.
  • TestNegativeErrorPaths — the 8a–8h AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, and so on).

Runs on a node, shelling out to ip/iptables/ovn-nbctl locally. Skips unless spinifex.toml says external_mode = "nat". Nine sequential phases: host wiring (transit veth, ip_forward, NAT egress rules), config, the EIP surface (enabled or disabled depending on whether a public pool exists), default-subnet public IP mapping, OVN gateway and SNAT on the transit net, instance egress proven via the serial console, host-to-guest Tier 1 ingress, a second VPC getting a unique transit gateway IP, and the EIP ingress lifecycle.

Phase 9 has two shapes. On one node it is the EIP lifecycle: allocate, associate, host delivery plumbing, a TCP handshake to the address, a vpcd restart that must replay the plumbing, and disassociate tearing it down. On a cluster it is the distributed lane instead — a guest on every node, each public IP plumbed by the node running its guest and by no other, every transit veth carrying the one cluster-wide MAC, each dnat_and_snat row carrying external_mac and logical_port, inbound proven by a handshake and outbound by each guest's own console. That lane is the regression guard for the two defects that made routed NAT work on one node only, and neither is visible to a single-node run.

Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.

Two nightly cells run it: nat-single (cell 19) and nat-multi (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.

multinode — behaviour that only exists on more than one node

VPCSetup, SpansMultipleNodes, SpreadPlacement, EveryRunningInstanceReported, BastionSSH, and its own NAT Gateway lane: PreNATIsolation, NATGatewayInternet, NATCleanupOrdering.

instancerecovery — a guest whose node goes away (multi-node)

Asserted from both sides, and the pair is the point. InstanceAutoRecovery takes a node down and requires the guest to come back on exactly one survivor, identified by the qemu process rather than by what the API says, then brings the node back and requires it to drop the local copy instead of relaunching it. InstanceRecoveryRefusesStorageFault freezes predastore cluster-wide first, so the guest is paused by werror=stop when its node goes away, and requires that nothing moves — a store that refuses every node cannot be fixed by moving a guest, and the relaunch would cost it the request QEMU is holding. A reconciler that cannot tell a host failure from a storage failure passes the first and fails the second. Neither turns recovery on, because there is no setting for it: both wait for every node to report the reconciler active on a cluster nothing configured, so a build that reintroduced a switch fails here.

InstancePartitionRecovery is the fault this feature exists for, and the only one of the three that produces it rather than approximating it. It drops NATS on 4222 and 4248 between one node and its peers and nothing else, so that node keeps its services, keeps its QEMU and keeps answering its own local clients while it cannot reach consensus — half healthy, which is what a real network fault looks like and what a service stop cannot imitate. It then requires that the node gives up the volume on a clock it can evaluate without reaching anything, that a survivor takes the guest over, and that the rejoining node forgets its copy rather than relaunching it. The assertion it exists for is the one no single sample can reach: a watch samples every node every three seconds for the whole run and fails on any sample naming two hosts, because two guests writing one volume through one object store is data loss already under way rather than a race that resolves. The evidence for why the guest stopped is the surrender and the fence in the node's own journal — a guest killed for any other reason satisfies the process count and means the opposite.

The partition gets its own nft table and a systemd deadman timer, for reasons worth keeping. The node's real firewall is inet spinifex_filter, so a test that flushed or edited it would leave the node unfirewalled with no sign of which rules it lost; a separate table at priority -300 says exactly "these packets, dropped" and heals in one atomic delete. systemd-run --on-active removes it independently, so a cancelled workflow or a panicking test does not leave a node cut off from its cluster until somebody notices. It also proves SSH still works and proves a peer port is actually unreachable before asserting anything — a rule set that installed cleanly and dropped nothing would otherwise read as the system tolerating a partition.

The storage-fault victim launches with cloud-init user-data that writes continuously, and that is load bearing. An idle guest issues no I/O that reaches the object store, so a version of this test without a workload left three guests running happily through twelve minutes of dead predastore and proved nothing. Driving the load from user-data rather than over SSH is what lets it run on a routed-NAT cell, where guests have no public address. The pause itself takes minutes — 4m47s when measured — so the budget is generous on purpose.

Its own suite and its own nightly cell, for storagefault's reasons. It freezes a cluster-wide service and removes a node, so anything sharing the environment reports that outage as its own failure; and it is slow by nature, which is why it is not in a permutation cell budgeted at thirty-five minutes. Both behaviours were first proven by hand against a real hypervisor power-off on the three-node OCI cluster.

lbrecovery — a load balancer whose node goes away (multi-node, needs three)

The customer-visible half of host failure, asserted from outside the cluster rather than from the records. ALBSurvivesItsHostFailing stands up an internet-facing ALB with two HTTP backends, discovers which node the load balancer's guest landed on, and takes that node away. It then requires the four things a customer of a load balancer actually holds: the name keeps resolving, the address behind it does not move, requests through the name are answered again within a bounded time, and the backends — which never moved — are healthy again from the recovered load balancer's point of view.

The backends are placed around the load balancer, not beside it. A load balancer is launched over a queue group and lands wherever a node answers, so its node is discovered and the backends are then launched and relaunched until both are somewhere else. A backend on the node about to be taken away would be recovered too, and the run could not then tell an ALB that came back serving from one that came back with nothing to serve.

DNS is queried against a node's own northstar, not through the runner's resolver. Go's resolver dials port 53 on a named node — which is open on every node deliberately, because northstar serves public names — so the assertion is about what this cluster answers rather than about what the machine running the tests was configured to forward to. The same resolver drives the HTTP probes, so traffic goes through the load balancer's name end to end and not through an address resolved once at the start. dig is deliberately not used: it is proven present in the guest image and nowhere else.

The assertion no single sample can reach is the name. A watch queries every surviving node every five seconds for the whole run, and fails on any sample where the record is missing, where a node refuses the query, or where it answers an address that is not the load balancer's. An answer naming another address is the worst of the three, because the traffic goes there; an empty answer outlives the fix for as long as anything cached it. An address that moves is asserted separately and before any traffic, since a passing probe against a renumbered address would hide it.

Then the node comes back, and has to give up what it no longer owns. The returning node must not relaunch its local copy, and must no longer carry the tap that claims the load balancer's logical port — OVN binds a port to one chassis, so a second claimant does not split the traffic, it contends for it and the address answers from whichever won last. That same stale tap is also what keeps the external address's host ingress from being pruned, since the prune waits on evidence the guest sits elsewhere. The last assertion is that the name still serves traffic with the node back in the cluster, which is the end-user form of the same question.

DescribeLoadBalancers is required to keep reporting active throughout, and that is AWS parity rather than an oversight. AWS never marks a load balancer degraded because a host under it failed: the state describes the customer's configuration, not our hardware. The honest signals about the outage are target health and the traffic itself, which is why both are asserted and the state is not trusted as evidence of anything.

Its own suite and its own cell, for instancerecovery's reasons plus one. It takes a node away, so anything sharing the environment reports that outage as its own failure; it needs its own VPC, public subnet and a backend placement no shared fixture describes; and a failure here has to read as "the load balancer did not survive its host" rather than as one more red subtest in a suite about load balancer features.

iam — everything IAM and STS

Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, aws:SourceIp seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.

cert — the cluster CA and TLS identity

CA installed and downloadable, fingerprint matches config, hostnames present in DNS SANs, OpenSSL verification, curl without a CA flag, and a shared issuer across nodes.

lb — load balancers (multi-node)

Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, ModifyListener, and NLB UDP.

eks, ecs, ecr — container services

eks: cluster create/delete, access entries, addon delivery, GetToken, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. ecs: clusters, container instances, the three network modes (awsvpc, bridge, host), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. ecr: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.

rds — managed databases

The create/describe/connect path against a live cluster: a DB VM that actually boots, TLS enforced with plaintext refused, clients connecting from the same and another subnet, class changes replacing the VM while keeping overrides, deferred parameter-group attachment windows, and delete-while-creating leaving nothing behind.

Storage and durability

  • storagefault — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. The claim is about corruption, not availability. SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
  • partialblock — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
  • storagegrowth — volume growth and image pull. Requires external_mode=pool; skips on a nat env, which has no EIP and therefore no guest SSH.
  • reboot — a slow guest waited for and keeping what it wrote, and a wedged guest reset once its budget expires. Proves a real reboot by requiring a new boot ID.
  • diskperf — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires external_mode=pool for guest SSH.

Other

  • quota — every enforced dimension refuses with the same quotaExceeded code; a launch is charged for every instance it asks for; one instance wider than the cap is refused.
  • gpu — device visible in the guest, driver access, launch.
  • bedrock — ListFoundationModels/GetFoundationModel against a running cluster. Wire-format parity is unit-tested, not here.
  • ddil — Denied, Disrupted, Intermittent, Limited network scenarios.
  • teardown — sweeps leftovers.
  • harness — shared primitives, not a suite. harness/vpc_egress.go carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on.
  • manifest-check, manifest-lint — manifest validation, not cluster behaviour.

Keeping this honest

Add a row when a suite is added, and correct one when a suite's claim changes. An index that has drifted is worse than none, because it will be trusted. If a suite's package comment and this file disagree, check the test bodies — both are prose and either can be stale.