Commit Graph

14 Commits

Author SHA1 Message Date
Ben Duncan 8ab96b29a1 fix: replicate every JetStream KV bucket across the cluster (#1117)
* fix: replicate every JetStream KV bucket across the cluster

A KV bucket created without a replica count gets one replica, and one
replica lives on one JetStream-chosen server recorded in no config.
Losing, restarting or partitioning that server makes the bucket return
"no responders available" from every node at once, so a single-node event
becomes a cluster-wide outage. The reconciler leader-lease buckets were
the worst case: acquiring a lease is a write, so a lease bucket without a
quorum cannot be acquired by anyone, and every reconciler sharing it stops
cluster-wide at exactly the moment a node has failed.

The cluster's node count is now declared once, in config.LoadConfig, and
read by the one helper every bucket creation goes through. Every process
that creates a bucket loads the cluster config first, so no service can
forget to pass it and no signature has to carry it. An undeclared size is
an error at creation rather than a silent fall back to one replica.

Existing buckets are raised rather than left behind: a service opening one
raises it, the daemon sweeps them all after formation, and
"spx admin kv replicas --repair" does it on demand. Raising reads the live
stream config and changes only the replica count, and never lowers one.

Removes the ~30 replicas/clusterSize parameters that only forwarded a
number, and JetStreamManager.UpdateReplicas, which set len(nodes) with no
clamp and would have failed outright on a cluster of six or more.

Proved by a repo-wide invariant test pinning bucket creation to the one
helper, a whole-cluster sweep in every multi-node nightly cell, and a new
kvquorum suite (cell 33) that takes away the node leading the most buckets
and requires the survivors to keep answering for all of them.

* chore: name cell 33 in the nightly dispatch input

* fix: a KV bucket that cannot be raised must still open

The first version of the replica fix treated a raise it could not place as a
hard error, and on a real three-node cluster that turned the fix into the
outage it prevents. A node whose config named a fourth server wanted four
replicas, NATS answered "no suitable peers for placement" for three, and the
daemon retried for its full five-minute budget without finishing bootstrap.
The bucket was present, quorate and serving on three nodes throughout. A
config naming a node before that node serves is the ordinary middle of
growing a cluster, so that is a state to tolerate rather than refuse.

The line is now drawn between the two cases instead of around both. Creating
a bucket below the cluster's replica count still fails, because that is the
defect and a fallback there reintroduces it where it is hardest to notice.
Failing to raise an existing bucket warns and hands the bucket back, and the
raise is retried on every open besides the daemon sweep and --repair. This
matters most in kvlease, which runs on every reconcile tick: refusing there
would stop every reconciler sharing a lease bucket that was working.

getOrCreateBucket now attaches before it creates. A create asking for more
replicas than the cluster can place is refused before the server looks at the
name, so with create-first "this bucket already exists" was an inference from
which error came back, and the wrong one comes back on a single-node server.

RaiseAllBucketReplicas attempts every bucket and joins the failures, and
--repair keeps going too. Stopping at the first would leave the rest of a
cluster short because one bucket could not be placed.

Also adds nats.ServerName/NodeFromServerName beside the template that renders
server_name, with a test tying them together. The kvquorum suite had mapped
stream peers to nodes by comparing them to node names, which they are not:
they are the node name behind a "spinifex-nats-" prefix, so nothing matched
and the node-loss test could not choose a victim.

* test: wait for the right NATS peer count after a node rejoins

/routez reports a node's routes to its peers, not its own membership, so a
fully formed three-node cluster is two peers per node. The kvquorum suite
waited for three and spent its whole five-minute rejoin budget on a cluster
that had already reformed. Every other caller passes len(Nodes)-1; the
helper's doc comment now says so.

The assertion the test exists for had already passed: node1 was leading 18 of
47 buckets when it was taken away, and node2 and node3 each still answered for
all 47.

* fix: replicate KV buckets across the largest odd count a cluster can place

A bucket was replicated across min(nodes, 5) servers, which stores a fourth
full copy on a four-node cluster and still survives exactly one loss — the
same tolerance R3 gives for three quarters of the write amplification. A Raft
majority needs an odd count, so the rule becomes the largest odd count the
cluster can place, capped at five: 1, 3, 3, 5, 5. It is one expression with two
entry points, so no caller can hold a stale copy of the policy and keep passing
after the policy changes.

Three retry loops could not tell a configuration fault from a cluster that had
yet to form. An undeclared cluster size never becomes declared by waiting, so
each of them spent its whole budget on one and then reported quorum as the
cause. They now stop on a permanent error and say what is actually wrong. This
is what the integration suite was hitting: it builds its gateway by hand and
never loads a cluster config, so the size was never declared and the IAM retry
sat in its sleep until the test timed out. Both ends are fixed.

The audit reported configuration alone. A bucket at three replicas with one
peer offline and one lagging is one failure from unavailable and read as ok.
BucketReport now carries the leader and a count of the peers JetStream itself
calls current and reachable, quorum is measured against that count, and
spx admin kv replicas exits 0, 1 or 2 so a deployment gate can tell a problem
--repair fixes from one needing a human.

Declaration is write-once: daemon.Start refuses a conflicting redeclaration
because it is about to create buckets, and it declares before JetStream init
rather than after it. config.LoadConfig warns instead, because it also reads
config files that are not this process's own cluster.

spx admin init --nodes 2 is refused — no replica count on two nodes both
survives a loss and accepts writes — and single-node replication is asserted in
the single e2e suite, so it runs in every nightly host permutation.

* fix: withhold the vLLM serving image until its artifact is published

The catalog offers ubuntu-26.04-vllm-serving-x86_64, and the qcow2 it names
404s on iso.mulgadc.com, so spx admin images import would fail for every
operator who tried it. Its GPU siblings are published; this one never was.

The entry moves to WithheldImages rather than being deleted, because the entry
is the definition — the URL, the checksum and the two tags resolveServingAMI
filters on. Publishing the artifact is then a move back, not a retype.
Bedrock already handles the image not being registered: resolveServingAMI
returns ErrServingAMINotFound, which is the honest answer while there is
nothing to launch.

The bedrock tag contract is still pinned, now against the map the definition
lives in, and it additionally asserts the entry is not offered for import. The
e2e reachability walk covers AvailableImages alone, so a withheld entry is out
of it by construction.

* fix: drop the cluster size from the call sites dev added

Two constructors on dev still pass a replica count that no longer exists as a
parameter: the cluster size is a declared process fact, so EKSServiceDeps has
no ClusterSize field and NewIAMServiceImpl takes three arguments. The merge is
textually clean and breaks the build, which is the shape of collision a
signature removal always has.

* docs: explain what an operator sees when a cluster server fails

The replication section said how many copies a bucket has and stopped there,
which leaves the question an operator actually has during an incident: one
server is gone, what still works. It now covers the majority side carrying on,
an isolated server refusing changes on purpose and why that is the behaviour
you want, guests continuing to run while anything that has to be recorded does
not, the risk of reading state from a server that is cut off, and a returning
server catching up by itself.

The surrounding prose reads from the operator's side rather than the
implementation's: what you see and what to do, not which consensus protocol is
underneath.
2026-09-28 12:34:55 -05:00
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
Ben Duncan ea3e01bb20 fix: reboot a guest by asking it, not by pulling its reset line (#1028)
* fix: reboot a guest by asking it, not by pulling its reset line

RebootInstances was QMP system_reset alone. The guest kernel is never told,
so it does not sync, does not unmount and loses every dirty page - which is
how a reboot issued seconds after a write loses that write, and how a guest
rebooted during cloud-init can fail to come back at all.

Guests now launch with -action shutdown=pause, so a clean guest poweroff
leaves QEMU paused rather than gone. Reboot presses the power button, waits
for the guest to reach that state, then resets and resumes the same process:
no volume, tap or state churn, and no window where the guest is missing. A
guest that will not go quietly is hard-reset, which is what AWS documents.

The button is pressed again every 10s while the guest ignores it. One signal
is not a shutdown request on a guest whose acpid has not started, which is
the same boot race that produced the failure.

Two consequences handled here: a guest that powers itself off now leaves
QEMU paused, so the heartbeat quits it; and a guest launched before the flag
exits on powerdown rather than pausing, so it is reset without being asked.

* fix: answer a reboot when it is accepted, not when the guest is back

Asking a guest to shut down takes as long as the guest takes. RebootInstances
gives the daemon five seconds to reply, so a reboot that waited for the guest
reported a reboot that had in fact succeeded as InvalidInstanceID.NotFound.

Manager.Reboot now admits the reboot — the instance exists and is running — and
runs the powerdown and reset in the background, which is the contract EC2
documents. The two admission failures are still reported; nothing after them
can be, so a reset that fails is logged and left to the heartbeat.

The gateway also stops calling a NATS timeout NotFound. Only the absence of a
subscriber says anything about where the instance is, which is how the sibling
start and terminate paths already read it.

* test: prove the churn reboot by its boot ID, not by how fast it happened

The subtest waited ten seconds and then read /proc/uptime. A reboot is
asynchronous, so the guest answering at that point may be the one that has
not shut down yet, and its uptime is whatever it was before — which the test
reads as "reboot may not have occurred".

Waiting for the boot ID to change is positive proof the guest rebooted and
does not depend on how long the guest takes to go down. The uptime assertion
stays, now measured against a boot that is known to be new.

* test: prove the graceful reboot against real guests and a real engine

Two tests the reboot design asked for and the unit tests cannot give.

Cell 18 launches two guests that differ only in how they answer the power
button. One installs a shutdown hook writing an fsynced line a second, so the
file that survives measures how much of its shutdown it was allowed to run —
a guest reset instead of asked leaves none of it. The other ignores the power
key entirely and must be reset once its budget expires, with the daemon saying
so. Each variant asserts the evidence only it can produce, and the slow one
asserts the absence of the line the wedged one requires.

Cell 27 loads a PostgreSQL instance past its shared buffers with pgbench and
reboots it dirty. The probe is an UNLOGGED table: PostgreSQL writes no WAL for
one, so a clean shutdown checkpoints it and crash recovery truncates it
unconditionally. Rows committed normally survive a hard reset too, so the
obvious assertion would pass against the bug.

The rds suite and cell 27 both gain budget for the load phase.

* fix: stop the process-group test misreading a reaped child as alive

The test polled until the child was gone, then checked once more. A task
being reaped shows state X for an instant, which processRunning counted as
running, so that second look failed about 1 run in 50 under -race.

* fix: wait for the recovery retry loop before removing a test's data dir

restoreInstances starts the retry loop untracked, and it rewrites the local
state file when it ends. A write landing during t.TempDir cleanup failed
TestRestoreInstances_* with RemoveAll: directory not empty.
2026-09-14 15:39:27 -05:00
Ben Duncan 18853cef31 fix: close the four defects keeping the e2e nightly red (#1007)
* fix: stop the e2e analyser inventing failures and let a detach outlast its seal

go-junit-report emits <error message="No test result found"> for a === RUN with
no matching result line, and attaches the test's own captured log as the body.
The analyser counted those as failures, so the baremetal cell reported 25
failures against a junit file recording 1, with log output reading as
assertions. Separate the marker from a real error in both the parse and the
roll-up, count the unresolved cases and say so in the summary.

The gateway gave a detach 30s while the daemon budgets 180s for the ebs.unmount
seal and 180s more for the QMP unplug chain, so any detach whose seal replayed a
WAL was reported as ServerInternal while it went on to succeed.

Also switch four nightly job gates from always() to !cancelled(): always()
overrides cancellation, so a cancelled run kept starting jobs.

* fix: serialise the e2e workflows on one group and retry a dropped libvirt

e2e.yml had no concurrency block at all, so every push to dev could provision
alongside every other push and alongside the nightly. All four workflows that
build on these hypervisors now share e2e-hypervisors, queued rather than
cancelled. A per-environment group is not expressible: GitHub picks the group
before it picks a runner, and the environment is a property of the runner.

tofu apply also failed outright on a transient libvirt connect, which fails a
45-minute job in 90 seconds and reports it as a test failure. retry_run retries
only when that attempt's own output names a connection failure, so a real
failure still fails once.

Also collect the unfiltered journal from the matrix cells and the node journal
from cell-21: the unit filter drops the kernel and OOM lines that explain why
one node behaved unlike its siblings, and a passing run's journal is what makes
a failing one readable.

* fix: build the baremetal junit from the unfiltered suite log

run-baremetal-e2e.sh streams the full suite output to suite-output.log and a
grep-filtered summary to its stdout, which the workflow tees into
test-baremetal.log. That filter anchors --- PASS at column 0 and Go indents
every subtest result by four spaces, so the junit was generated from a log with
no subtest results in it: go-junit-report saw each === RUN with no outcome and
emitted <error message="No test result found">.

17 of cell-28's 39 testcases and 93 of cell-20's 120 were that. Regenerating
cell-28 from suite-output.log gives 39 tests, 0 failures, 0 errors.

* fix: converge released EIPs instead of trusting a fire-and-forget teardown

A disassociate leaves the ENI running, so the orphan sweep's owning-port test
never fires for it. The teardown then rests entirely on vpc.delete-nat, which is
core NATS and is lost whenever vpcd is restarting — and the intent KV watch is
UpdatesOnly, so the same window hides the write that would have driven a repair
pass. The dnat_and_snat row and its host /32 route to a released public address
survived until something else happened to touch them, which for a live ENI was
nothing at all.

Give the sweep the second test it was missing: AddEIP is the only writer of
dnat_and_snat, so the floating IPs intent asks for are the complete set of
addresses that may hold one, and a row for any other address is stale however
alive its port. Ports and external IPs travel together in LiveEIPs, both unioned
from the start-of-pass snapshot and the fresh re-read, so a mid-pass launch is
still spared. The host unbind now runs even when the row had already gone, which
is the state a crash between the OVN delete and the unbind leaves.

Run that sweep on the startup pass too. Startup is the pass that follows the
blind window, so skipping it deferred repair of exactly the changes that were
missed. The port-group and ENI-port sweeps stay gated there for the in-flight
create they were written for; the EIP sweep re-reads intent immediately before
deciding and abandons the whole sweep if that read fails, which is the same
protection by a stronger route.

* fix: keep a release verify off the hypervisors an e2e run is using

release.yml is the fifth workflow that provisions on the ci-single pool and had
no concurrency block at all, so a tag push could land a tofu apply on the same
libvirt host a nightly cell was mid-provision on. The group goes on the verify
job rather than the workflow: the build jobs touch no hypervisor and should not
queue behind a nightly.

* fix: stop cell-27 handing a 50-minute suite a 50-minute job

The rds suite is budgeted 50m in suite-sets.sh and cell-27 took the matrix
default of 50 minutes for the whole job, so provisioning, install, the suite and
teardown had to fit inside the suite's allowance alone. It never could: the last
measured run spent 6 minutes reaching the suite and was killed 44 minutes into
it, mid-TestAutomatedBackups. A job timeout reports "The operation was canceled"
and nothing else, which is why this cell has no verdict in any nightly rather
than a failure anyone could read.

cell-27 gets 75 minutes, and the select job now refuses to start a run whose job
budget does not clear its suite budget plus a setup allowance. The inner timeout
firing first is the property that matters: go test says where it stopped and
what failed, and the job timeout says nothing at all. cell-29 already had the
same fix applied by hand; nothing stopped the next suite from repeating it.

* fix: converge host EIP plumbing against intent, not against the NAT row

The released-EIP sweep landed and did not fire. Its premise was that a stranded
dnat_and_snat row is what a stranded host route can be found by, and on the path
that actually strands one there is no row left. AddEIP scrubs the predecessor
row sharing the private IP before installing the new one, so when reconcile
re-added the instance's auto-assigned address it silently deleted the released
EIP's row — and applyEIPs runs ahead of the sweep, which then walked a table
with nothing stale in it while the /32 route and its proxy-ARP stayed up.

Give the sweep the host's own inventory. ListEIPIngress reads the EIPs back from
the FORWARD accepts EnsureEIPIngress stamps with its own comment, so a binding is
found by what installed it rather than by a row some other path is free to
delete. Anything plumbed that intent no longer asks for is torn down, which also
covers a crash between the OVN delete and the unbind. A failed inventory fails
the pass: an empty list and an unreadable one are indistinguishable, and reading
the second as the first is a sweep that reports success having done nothing.

Close the window inside the pass too. The predecessor scrub now reads the row it
is about to drop and releases that address's host state, so the strand does not
exist between applyEIPs and the sweep rather than merely being repaired a step
later.

* fix: retry the release verify's libvirt calls like every other job does

The verify job drives the same libvirt provider on the same hypervisors as the
e2e cells, where a dropped connection under concurrent load is the steady state
and is retried. Here it was a hard failure that reads as a broken release. It
was skipped earlier only because the job checks out mulga and not spinifex, so
the shared helpers were not on disk — a sparse checkout of .github/scripts costs
nothing and makes apply and destroy behave like their e2e counterparts.

* fix: do not report a rebooting DB instance available on the pre-reboot agent's health

RegisterDBInstance stamped a fresh LastSeen while leaving Agent.EngineHealth at
the value the previous agent process reported. After a reboot the new agent
registers before it has probed anything, so the record read as a healthy engine
with a beat newer than the transition, and the reconciler moved the instance to
available while PostgreSQL was still starting up. A client that connects the
moment the status turns green then gets "the database system is starting up".

A register now discards the previous process's verdict and records when this
agent process started. The restart reconciler requires that registration to be
newer than the transition, which also closes the window where a beat lands
between the engine being stopped and the VM going down.

* test: record the credential kubectl sent when an EKS call comes back Unauthorized

The IRSA pod subtest's first kubectl is the gateway-CA configmap, which lands
before the pod dumps are registered, so a 401 there left no evidence at all. A
verbose read-only probe reports whether an Authorization header was sent, which
separates a token the apiserver rejected from one the exec plugin never made.

* fix: report a starting engine promptly, and budget CI jobs from the suite

The agent waited a full heartbeat interval before its first beat, so a restart
could not be observed as finished for up to 30 seconds after the engine was
already serving. Beat at startup instead, and report every 5 seconds until the
engine has answered once — the control plane is actively waiting on that beat,
and the steady-state cadence it hands back is for a database already serving.

The rds suite is the one most sensitive to what else the hypervisor is doing:
its DB VMs serialise behind a 4 GiB semaphore, so its wall clock is set by how
long it waits for the budget. The same pass ran 45m beside four nightly cells
and overran 50m beside nine, panicking with two tests still running. Raise its
budget to 60m.

That budget was then larger than the jobs meant to contain it. A job cancelled
on timeout reports only "The operation was canceled" and takes the artifacts
with it, so the inner `go test` timeout must always fire first — and it could
not for rds on e2e.yml, nor for storagefault or diskperf, which have asked for
90m and 180m inside a 60m job since they were added. Both workflows now resolve
their job budget from suite-sets.sh rather than carrying a literal: e2e.yml
from the largest requested suite, e2e-suites.yml per matrix leg, so a leg is
bounded by its own suite and not by the longest one beside it. The default push
set resolves to exactly the timeouts both workflows had before.

* fix: check a permutation cell's job budget against the suites it runs

The guard only saw cells that name a suite, so the nine that run a topology's
nightly subset were never checked against it — the same class of mistake the
guard exists to catch, in the majority of the cells. Bound each by the longest
suite in its subset: max() and not sum(), because whichever suite hangs must
panic on its own timeout first, and a sum would demand a budget the set has
never come close to using. Every cell clears it today.

* fix: report why a PostgreSQL engine would not start, and stop failing a stop that landed

Three defects, all found by the first rds pass that ever ran deep enough to
reach them.

A wait that saw an instance reach `failed` called t.Fatalf from inside the
polling condition, and testify runs that condition on its own goroutine. The
Goexit killed the tick and left the collector empty, which reads as the
condition being satisfied — so the wait returned successfully on an instance it
had just declared broken, logged "reached status available" about one that was
failed, and let the test carry on against it. Six conditions across four suites
call Fatal or require the same way. EventuallyErr now runs its condition on the
test's goroutine, which makes all six behave as written.

A DB VM stop has two drivers: the API call and the reconciler resuming one whose
caller it assumes has died. When they overlap the loser is told the VM is not in
a state to be stopped — which is the state the stop was heading for — and the
customer's StopDBInstance returned InvalidDBInstanceState for a stop that had
just completed. Both paths now treat a refused command the way they already
treat an unanswered one: it says nothing about the VM, so the fleet view
settles it.

PostgreSQL had no equivalent of the engine log tail MariaDB has carried since it
was added, so a postmaster that refuses a setting produced "engine did not
respond on /run/postgresql:5432" and nothing else — on a guest with no SSH,
whose console records only that pg_ctl could not start the server. The probe now
quotes the tail of the log the image already directs the postmaster to, and the
reboot and modify paths carry the agent's reason into the failure the way the
create path always has. The reason reaches the customer through StatusInfos.

The rds suite's budget goes to 90m and cell-27's job to 100m. Its wall clock is
what the DB-VM queue costs rather than what the tests do — single tests logged
11m, 22m and 26m waiting for budget — and the pass above still had three of
seventeen tests running at 60m.

* fix: keep a DB VM recoverable when its agent cannot start, and flush the volume before a snapshot

An rds-agent that failed to construct exited, so a transient IMDS credential
resolution took the guest down for good: rds-datadir and rds-init cascade on the
missing handoff, the engine never starts, and the control plane sees only silence
until it declares the instance failed ten minutes later. Construction is retried
like every other boot step instead.

OpenRC skips start_post when the start itself fails, which is the one case with
no diagnostic at all — the console carried nothing but "died". The log mirror
moves out of the handoff wait so a failed start uses it too.

A snapshot quiesced the engine and nothing else. The engine repairs its own files
from the WAL, but postgresql.conf sits beside them and it does not: a restore
came up on a torn tail and the postmaster stopped on a syntax error at the
unterminated include hook. The quiesce now flushes the whole data mount, and the
hook is renamed into place rather than appended, so a boot that finds a half
written one replaces it instead of appending after its open quote.

* fix: flush the config cloud-init wrote before a reset can take it

A DB instance rebooted seconds after it went available came back with no
RDS_GATEWAY_URL, so rds-agent could not reach the control plane and the engine
never started. cloud-init writes agent.env once, per-instance, and never again;
a reboot is a QMP system_reset with no guest shutdown, so the file was still
dirty page cache when the machine reset under it and the next boot read nothing.
The handoff wait now syncs, which is the point at which the agent has proved it
read that file.

* fix: stop dropping an EIP teardown published while vpcd is restarting

DisassociateAddress publishes vpc.delete-nat fire-and-forget. On run
34046316024 vpcd disconnected from NATS at 03:51:26.73 and its replacement
subscribed at 03:51:27.09; the publish landed at 03:51:26.92, in the gap, and
went nowhere. ReleaseAddress then returned the address to the pool two minutes
later with its /32 host route still delivering to the guest that had held it.

vpc.delete-nat now uses request-reply like vpc.add-nat, and retries only on
no-responders — which returns immediately, so the cost is paid solely when
nothing is listening. A teardown that still fails is logged as an error naming
the address rather than lost silently. Neither is fatal to the caller: the
reconciler's orphan sweep remains the backstop.

vpc.add-nat on the EIP path stays fire-and-forget. The shared helper blocks it
on the flows barrier for up to 45s, and associating an address should not
inherit that.

Two reconcile passes in that run had the address absent from intent and did not
sweep its host route, and the journal cannot say why: the sweep logged only when
it acted, so a route it kept was indistinguishable from one it never saw. It now
logs the bindings it found, kept and wanted.

* ci: render one run-level table for the e2e nightly

Reading a nightly meant opening nineteen jobs, and the Actions UI does not
separate a cell that failed from one cancelled out from under it — a
distinction every investigation of this suite has needed, and one the run
34046316024 analysis had to reach for the jobs API to get.

A final job, needs: every cell and if: always(), renders cell, topology,
result, duration, runner and per-cell test counts into the step summary, with
run-level totals, the three slowest cells and the failing test names.

Result, duration and runner come from the jobs API so a cell that died before
writing junit still gets a row. Counts come from a new junit-only artifact per
cell rather than the full cell artifact, so the roll-up does not pull nineteen
journals it has no use for.

Verified against run 34046316024: 14 success, 2 cancelled, 1 failure, with the
two cancellations shown as cancelled rather than folded into failure.

* fix: assert EC2's actual EIP semantics, and probe EKS auth on an authenticated path

natuplink asserted that an instance's auto-assigned public IP is still plumbed
after an EIP associated to the same instance is torn down. EC2 does the
opposite: associating an EIP releases the auto-assigned address back into the
pool, and it cannot be reused. Our datapath already does that — run 34059879666
shows 192.168.0.72's ingress removed at 07:09:44.000, 36ms before .73's was
installed — so the test was asserting behaviour neither AWS nor we have.

It now asserts the release at the point it happens, and that no public delivery
remains after the teardown. This surfaced only once the teardown itself started
working: the run's other assertions passed, including .73's removal, which was
the failure before.

Our records still disagree with our datapath — the ENI keeps the released
address, which is why termination tears the old one down twice and never the
current one. That is the remaining half of mulga-7zc9w and is not fixed here;
it needs an IPAM release on the associate path.

Separately, AuthDiagnostics read /version, which is anonymously readable, so its
200 was consistent with both branches it claimed to distinguish. It now runs
auth whoami, reads kube-system, and invokes the credential plugin directly,
reporting exit code, stderr and whether a token was produced — never the token,
since CI logs outlive it.

* test: capture per-guest state when a spread guest never answers SSH

TestMultinodeSpread's only failure dump is dumpPlacementNATDiag, which collects
OVN, OVS, ARP and route state aimed at the NAT and EIP half of the test. The
failure it actually hits is a guest that never accepted SSH — run 34040324968,
the guest on node3 at 10.100.2.5 — and for that it recorded no console, no port
binding and no tap state. There was no way to tell from the artifact whether the
guest booted at all.

Now dumps, per guest: the console, the NB logical switch port, the SB port
binding, the OVS interface backing its tap, and br-int's port list. Probed on
every node rather than the expected one, because a port bound on the wrong
chassis is exactly what a hosting-node-only dump cannot show.

Covers all three guests, not just the failing one, so a working guest is
available as the control.

* ci: sample hypervisor load either side of every cell

Eight runner names map onto three hypervisors, so cells that never share a
runner still share a host: when cell 10's SSH wait expired on run 34040324968,
cells 22, 17, 18 and 28 were concurrently live, two of them multi-node. Nothing
in the artifacts measured host load, which left co-tenancy as an explanation
that could be neither confirmed nor ruled out.

Records loadavg, MemAvailable, SwapFree, the cpu line carrying steal, and the
QEMU process count before and after each cell, into the cell artifact.

* ci: note that main's missing concurrency block is why the nightly runs ungrouped

Three refs carry three different groupings: main has no concurrency block at
all, dev uses e2e-nightly, this branch uses e2e-hypervisors. Scheduled runs load
the workflow from the default branch, so every cron nightly runs ungrouped and
cannot queue against anything — which is how a dispatch of 34050630627 ran
alongside the scheduled 34046316024 and cost it cells 24 and 27.

e2e-hypervisors is the right group and deliberately broader than dev's, since
e2e.yml, e2e-suites.yml and e2e-ddil.yml draw on the same runner pools. Nothing
on a feature branch can fix this; the block has to reach main.
2026-09-06 16:53:32 -05:00
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
Bryn Mailer 18aa67b0e9 feat: add the e2e guest disk-performance gate (#964)
* feat: add the e2e guest disk-performance gate

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

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

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

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

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

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

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

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

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

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

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

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

* feat: commit the measured disk-performance baseline

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

randwrite-4k's write tail is two orders above the mixed job's. It is
recorded as measured rather than zeroed: an ungated metric would miss the
regression class the gate exists to catch.
2026-09-03 14:33:21 +10:00
Julian Sommer 4522494c17 fix: define the per-suite e2e test timeout once, in suite-sets.sh
The timeout was written out separately in each workflow and drifted: rds
ran 50m on the PR gate and 30m nightly, where it panicked and took six
in-flight subtests with it. e2e-suites.yml had the same hardcoded 30m on
its single path, which also runs rds.

A new e2e_suite_timeout helper sits beside the eligibility lists it
already guards, and all four call sites resolve through it. e2e.yml loops
over suites on the remote VM, which has no checkout, so it resolves the
pairs on the runner and looks them up in the loop.

Also lift the nightly cell ceiling to 50 minutes so the rds cell is not
cut short by the job before its own timeout applies.
2026-09-03 09:21:17 +10:00
Julian Sommer be94beb52a ci: run eks, ecs and rds nightly in dedicated cells (#984) 2026-09-02 16:16:05 +10:00
Ben Duncan f41f5530a0 test: add an e2e suite proving quotas gate every capped resource 2026-08-20 02:26:27 +10:00
Julian Sommer 5e17169661 feat(e2e): complete the RDS end-to-end suite (#608) 2026-08-03 15:38:07 +10:00
Josh Siviour 68abc0bfb3 fix(ci): scope e2e telemetry per cell/job and define suite topology eligibility once (#573)
* feat(ci): extend per-cell telemetry scoping to every e2e workflow

ci.cell only reached e2e-nightly and e2e-suites, so a run of e2e, e2e-ddil
or gpu-e2e still landed in the sink as one undifferentiated bucket. Stamp it
on the remaining workflows: single/multi on e2e's two provisioning jobs, ddil
and gpu on their single-job workflows.

The best-effort numeric job id lookup moves out of e2e-nightly into a shared
composite action, so the four other provisioning jobs get ci.job_id without
copying the gh/jq incantation. The lookup now matches on env.JOB_NAME rather
than interpolating the name into the jq filter, which broke on names carrying
quotes or non-ASCII separators, and no-ops when telemetry is disabled.

gpu-e2e gets ci.cell but deliberately no job id lookup: nothing in that job
bakes the attrs into a daemon, so the API call would be paid for an env var
no exporter reads.

* fix(ci): define e2e suite topology eligibility once

Three workflows carried three different answers to which suites a topology
can run. e2e-suites and e2e-nightly ran lb on their single-node leg, where
every internet-facing subtest skips for want of a peer, so a fraction of the
suite reported as a green lb run. e2e-suites' multi leg ran multinode alone,
dropping the lb and cert coverage that only a multi-node env can complete —
so full lb coverage never ran there at all.

.github/scripts/suite-sets.sh is now the single definition, sourced by all
three. e2e.yml's two allow-list regexes derive from it, e2e-suites derives
both matrices from it (multi_provision gains the suite-matrix step
single_provision already had), and e2e-nightly's deliberately narrower
per-cell lists are declared there too, guarded by an assert that fails the
cell if a subset ever names a suite its topology cannot fully exercise.

The nightly stays narrow on purpose: its cells have a 35-minute budget and
exist to prove each install/network/host-OS permutation boots and serves.
Suite breadth is the PR gate's job and already runs on every push. That is
now stated in one place rather than implied by a third divergent list.
2026-07-22 17:18:55 +10:00
Josh Siviour 3415d9f3de fix(network): IGW route-table AWS parity + per-subnet egress gating (#270)
* fix(network): IGW default route AWS-parity via per-subnet LR policy

ADR-0006's IGWManager.AttachIGW installed a VPC-router-wide 0.0.0.0/0
static route at attach time, violating AWS semantics: every subnet on
the LR saw egress to the IGW regardless of its route table. Private
subnets that should be IGW-isolated were not.

Move IGW egress lifecycle onto the route-table API. CreateRoute(IGW,
0.0.0.0/0) emits vpc.add-igw-route per associated subnet; the network
subscriber installs an OVN Logical_Router_Policy matching
`inport == "rtr-<subnetID>" && ip4.dst == 0.0.0.0/0` with action
`reroute` via the IGW gateway port. DeleteRoute / DisassociateRouteTable
/ ReplaceRouteTableAssociation tear it down. AssociateRouteTable emits
add events so the terraform ordering (CreateRoute -> Associate) is
honored.

Additions:
- nbdb.LogicalRouterPolicy + Logical_Router_Policy registration
- ovn.Client {Add,Delete,Find,List}LogicalRouterPolicy + live + mock
- policy.RouteManager.{Add,Delete}SubnetEgress with priority constants
- external.IGWManager.{Ensure,Remove}SubnetEgress resolving nexthop +
  output port from the gateway state
- subscribers handleAddIGWRoute / handleDeleteIGWRoute on
  vpc.{add,delete}-igw-route
- routetable publishIGWRouteEvents / *ForAssociation / *ForSubnet

Test updates:
- network/policy: AddSubnetEgress idempotency, drift-replace, per-subnet
  separation, DeleteSubnetEgress
- network/external: AttachIGW must NOT install a router-wide default
  route; EnsureSubnetEgress installs the per-subnet policy with the
  resolved nexthop (link-local fallback + allocator override paths)
- handlers/ec2/routetable: CreateRoute(IGW), Associate, DeleteRoute,
  Disassociate publish events with the expected subnetID + igwID
- ovn/nbdb: table-count + Logical_Router_Policy presence
- subscribers: TestSubscribe_RegistersAllTopics covers the two new topics

* fix(network): NATGW per-subnet egress + DeleteRoute event symmetry

handlers/ec2/routetable:
- DeleteRoute now publishes vpc.delete-nat-gateway per associated subnet
  (mirror of the IGW DeleteRoute path) so the subscriber tears down both
  the SNAT rule and the per-subnet egress policy.
- publishNatGatewayEvent* signatures carry SubnetId + DestinationCidr so
  the subscriber can install the matching Logical_Router_Policy.

network/external:
- IGWManager: EnsureNATGatewaySubnetEgress / RemoveNATGatewaySubnetEgress
  install/remove a per-subnet LR policy at priority SubnetEgressPriorityNATGW.
  Same nexthop and output port as the IGW priority sibling; lower priority
  so an IGW route wins when both apply to the same subnet.
- EnsureSubnetEgress delegates to ensureSubnetEgressAtPriority for shared
  validation, lookup, and slog tagging.

network/subscribers:
- handleAddNATGateway: after SNAT install, parse DestinationCidr (defaults
  to 0.0.0.0/0) and call EnsureNATGatewaySubnetEgress. Without this the LR
  has no route to egress NAT'd packets and private-subnet traffic is
  dropped (E2E Phase 8d regression).
- handleDeleteNATGateway: remove the per-subnet policy FIRST, then detach
  SNAT, so packets are never routed unmasqueraded after SNAT removal.
- NATGatewayEvent gains SubnetId + DestinationCidr.

Tests:
- policy.AddSubnetEgress validation + drift-replace + propagation paths.
- mock LR-policy methods reject missing routers and tolerate dangling UUIDs.
- subscribers cover NATGW add/delete with invalid CIDR, bad JSON, missing
  router, IGW-route bad JSON + invalid CIDR. Full end-to-end add+delete
  via NATS asserting policy lifecycle.
- IGW manager covers RemoveSubnetEgress / RemoveNATGatewaySubnetEgress
  empty-arg rejection + delete error propagation.
- routetable: DeleteRoute(NATGW) publishes per associated subnet.

* fix(network): exclude VPC CIDR from per-subnet egress reroute predicate

In-VPC traffic (e.g. bastion -> private SSH) was being intercepted by the
NATGW per-subnet egress LR policy on `ip4.dst == 0.0.0.0/0` and forwarded
out the gateway with SNAT, breaking peer-subnet connectivity. Append
`&& ip4.dst != <vpcCIDR>` to the match so in-VPC destinations skip the
reroute and follow the directly-connected route. Apply to both IGW (1000)
and NATGW (900) priorities. CIDR is read from the LR ExternalIDs label
populated at VPC create.

DeleteSubnetEgress takes the same excludes so the match string is
identical on cleanup. igwManager.vpcExcludeCIDRs fetches from the router
each call; lookup failure returns nil and the policy installs without
exclusions (preserves prior behaviour as fallback).

* fix(routetable): publish per-subnet events for implicit main-RT membership

E2E run 26620082197 RCA: subnets with no explicit RT association inherit
the VPC main route table per AWS semantics, but publishIGWRouteEvents and
publishNatGatewayEvents iterated only record.Associations and skipped
Main:true/SubnetId:"" sentinels. Main RT carries exactly one such
sentinel, so CreateRoute(igw)/CreateRoute(natgw) on the main RT fanned
out to ZERO subnets — subscriber never called EnsureSubnetEgress, OVN
LR policy never installed, ping 8.8.8.8 from default-VPC VMs dropped at
the VPC router. Caused TestVPCSubnetE2E, TestNATGateway,
TestMultinodeSpread/NATGatewayInternet to fail.

Add subnetsImplicitlyOnMainRT helper that enumerates VPC subnets
without explicit non-main associations, and fan publish to them when
record.IsMain. Apply to all three publish helpers (IGW, NATGW,
NATGW-delete).

Symmetric fixes for effective-RT changes:
- AssociateRouteTable: subnet leaves implicit main → emit deletes for
  main RT's routes before adds for explicit RT
- DisassociateRouteTable: subnet returns to implicit main → emit adds
  for main RT's routes after deletes for departing RT

* chore(e2e): capture per-LR lr-route-list/lr-policy-list/lr-nat-list

ovn-nbctl show does not dump static routes or policies. Adds three
per-LR captures so RCA can verify policy/route install state after
the IGW per-subnet refactor.

* chore(e2e): tcpdump br-wan + neigh/fdb/conntrack capture during tests

EIP egress regression on feat/igw-route-table-aws-parity shows SNAT
packets present (n_packets>0) but pings still time out. Post-teardown
ovn.* snapshots can't tell whether replies actually arrived on br-wan
or got dropped at L2 / by upstream router.

start-net-capture.sh: nohup tcpdump on br-wan filtered to 192.168.0.0/23
(EIP pool) + ARP for same range, 200-byte snaplen, 64 MiB cap. Pair:
capture-net-diag.sh stops it via /tmp/spinifex-e2e-tcpdump.pid and copies
wan.pcap into net-diag/.

Also added neigh/bridge-fdb/conntrack dumps to capture-net-diag.sh so
L2/L3 binding state is visible alongside the existing OVN dumps.

Wired both single-VM and multinode workflows to start capture before
the e2e run; teardown step picks up the pcap automatically.

* fix(reconcile): rebuild IGW/NATGW per-subnet egress from KV

Publisher fires vpc.add-igw-route during default-VPC bootstrap before the
subscriber daemon attaches to the topic. NATS core pub/sub drops the
message, leaving the LR without a 0.0.0.0/0 policy and off-subnet egress
broken on the affected VPC for the lifetime of the process. Reproduced
in CI: TestVPCSubnetE2E bastion EIP could ARP and DNS its on-LAN
neighbour but never reach 8.8.8.8.

LoadIntentFromKV now fans every IGW or NAT-gateway RT route out over
its explicit + implicit-main subnets and the reconcile pass invokes
EnsureSubnetEgress / EnsureNATGatewaySubnetEgress for each one. Same
shape as publishIGWRouteEvents so the bootstrap and the runtime event
path produce identical OVN state.

* fix(routes): serialize OVN egress wiring via request-reply

vpc.igw-attach, vpc.add-igw-route and vpc.add-nat-gateway were fire-and-forget
Publish, racing the subscriber's AttachIGW barrier. AttachInternetGateway
returned before vpcd finished wiring the OVN gateway LRP, so a follow-up
CreateRoute(IGW) published an add-igw-route event that the subscriber
processed in a parallel goroutine — installing a Logical_Router_Policy with a
nexthop the LRP didn't yet expose. northd compiled an inert reroute that
never recovered, leaving the bastion's egress traffic blackholed (no packets
dst 8.8.8.8 from the failing VPC's EIP even though local DNS succeeded).

Switch the three add-* publishers to utils.RequestEvent. The subscriber ACKs
once OVN state is committed, so the gateway handler can't issue subsequent
route events against a half-wired router. delete-* paths stay Publish —
teardown ordering doesn't matter.

handleAddNATGateway now calls respond() at every exit so requesters don't
hang. Route-table tests gain an ackSub wrapper (async subscribe + auto-Reply)
so request-based publishers complete during unit tests.

* Revert "fix(routes): serialize OVN egress wiring via request-reply"

This reverts commit bf6f77d5bc8b21f72ed5867175d5a67c010066c8.

* fix(igw): restore default static route in AttachIGW/DetachIGW

OVN 25.03 LR pipeline runs lr_in_ip_routing (table 15) BEFORE lr_in_policy
(table 17). When this branch swapped the per-VPC default static route for
the per-subnet Logical_Router_Policy reroute installed by EnsureSubnetEgress,
external destinations started dropping at routing priority-0 before the
policy table ran. Reproduced locally: ovn-trace stops at table 15 drop, ping
8.8.8.8 from inside a fresh-VPC bastion produced zero packets on br-wan
until `ovn-nbctl lr-route-add <router> 0.0.0.0/0 <wan-gw> <gw-port>` was
issued manually — after which the same ping succeeded immediately. dev still
green because dev calls AddDefaultRoute on AttachIGW.

Restore the original AddDefaultRoute / DeleteDefaultRoute calls; keep the
per-subnet EnsureSubnetEgress machinery intact so future per-subnet egress
gating (DROP policies for subnets whose RT lacks a 0.0.0.0/0 route) can
layer on top. Flip the two TestAttachIGW assertions that pinned the
"AttachIGW must not install a default static route" expectation.

Pairs with revert of bf6f77d5 in the previous commit (request-reply
serialization treated a non-existent race; real bug was missing routing
fallback).

* feat(network): per-subnet egress drop gating for AWS RT parity

VPC LRs carry a router-wide 0.0.0.0/0 default static route while an IGW is
attached (lr_in_ip_routing runs before lr_in_policy on OVN 25.03, so per-
subnet reroute alone is insufficient). Subnets whose effective route table
lacks 0.0.0.0/0 were previously able to egress via that default route,
violating AWS semantics.

Install an LR_Policy DROP at SubnetEgressPriorityDrop=1100 (above IGW=1000
and NATGW=900) for unrouted subnets. Match excludes VPC CIDR, link-local
(169.254/16) and multicast (224/4) so intra-VPC, DHCP/metadata and
multicast still flow.

Wiring:
- policy.RouteManager gains AddSubnetEgressDrop / DeleteSubnetEgressDrop.
- external.IGWManager gains EnsureSubnetEgressDrop / RemoveSubnetEgressDrop.
- New events: SubnetEgressGateEvent / SubnetEgressUngateEvent on topics
  vpc.gate-subnet-egress / vpc.ungate-subnet-egress.
- routetable handler publishes gate/ungate alongside existing add/delete
  IGW/NATGW route events on every CreateRoute/DeleteRoute/Associate/
  Disassociate/Replace mutation, based on subnet's recomputed effective RT.
- reconciler bootstrap path emits DropGates intent for subnets in IGW-
  attached VPCs whose effective RT lacks 0.0.0.0/0.

Tests cover primitive idempotency, drift recovery, event publishing per
mutation surface, implicit-main fan-out, and intent computation.

* fix(igw): fan out subnet egress gate decisions on IGW attach/detach

The reconciler computes per-subnet DROP policies for any subnet in an
IGW-attached VPC whose effective route table lacks 0.0.0.0/0, but those
decisions were only re-evaluated by the periodic drift tick (every
5 min). AttachInternetGateway / DetachInternetGateway now publish
gate/ungate events for every subnet in the VPC immediately, closing
the window where a freshly-attached IGW could leave unrouted subnets
unprotected.

Wiring: IGWServiceImpl gains a nil-safe GatePublisher hook satisfied
by RouteTableServiceImpl. Daemon installs the hook once both services
exist. RouteTableServiceImpl.PublishGateDecisionsForVPC enumerates the
VPC's subnets and reuses publishSubnetEgressGateDecision per subnet.

Also fixes the LB e2e suite, which built VPC + IGW + subnet without
ever creating a 0.0.0.0/0 → IGW route — relying on the pre-DROP-gate
behaviour where unrouted subnets still had silent egress via the
router-wide static route. The fixture now installs the explicit
public route on the main RT (AWS-faithful semantics for a public
subnet).

* fix(igw): skip gate fan-out on attach to avoid bootstrap race

AttachInternetGateway fan-out races with the default-VPC bootstrap path
in ensureDefaultVPCInfrastructureFor, which attaches the IGW and
immediately publishes a 0.0.0.0/0 → IGW CreateRoute event. The two
events land on distinct NATS subjects (vpc.gate-subnet-egress vs
vpc.ungate-subnet-egress) with no cross-subject ordering guarantee.
When ungate arrived first the subscriber became a no-op and the
subsequent gate installed a permanent DROP on the default subnet,
killing the return path for guest SSH probes
(TestMultinodeGuestSSH connect timeout to the instance public IP).

Detach fan-out is retained — no follow-up CreateRoute fires after
DetachInternetGateway so the path is race-free.

Subnets without a default route still get DROPped during the
reconciler's periodic drift pass (loadSubnetDropGates). The window is
re-opened to ≤5 min on user-driven AttachInternetGateway, which is
the original behaviour pre-mulga-siv-163. A follow-up bead can close
that gap properly by converting the gate/ungate subjects into a
single recompute trigger that re-reads effective RT state.

* fix(vpc): prevent duplicate main route table from bootstrap race

Concurrent multi-node daemons running EnsureDefaultVPC for the admin
account (bootstrap-pinned VpcId) each minted a fresh random rtb-ID and
Put it with IsMain=true. The KV ended up with two main route table
records for the same VPC.

CreateRoute's post-write gate recompute (per-subnet egress drop gating)
then resolved effectiveRouteTable non-deterministically across the two
IsMain=true records; when the orphan (route-less) RT won, the gate
decision became "no default egress" and vpc.gate-subnet-egress fired.
The subscriber installed a Priority-1100 DROP on the default subnet's
egress, killing the return path for guest SSH probes
(TestMultinodeGuestSSH connect timeout to 192.168.0.161 in run
26699045536).

Two-part fix:

1. createMainRouteTable scans rtbKV for an existing IsMain=true record
   on the same VPC before Put-ing a new one. Race window narrows from
   "always duplicates under concurrent bootstrap" to a sub-ms TOCTOU
   between scan and Put.

2. effectiveRouteTable / mainRouteTable use a deterministic preferMain
   tiebreak when multiple IsMain=true records exist: more routes >
   oldest CreatedAt > smaller RouteTableId. Defensive — any duplicate
   that slips through (cross-AZ restore, manual KV edit, residual race)
   still produces consistent egress gate decisions instead of flapping
   on KV scan order.

* fix(policy): atomic ACL replacement for SG mutations

Add ovn.Client.ReplaceACLs — detach + delete existing ACL rows and
attach freshly-created rows in one OVSDB transaction. EnsureSG and
UpdateSG now funnel through ReplaceACLs instead of ClearACLs +
AddACLs, closing the mid-flight window where the port group held
zero ACLs and tenant LSP traffic defaulted to drop (observable on
ICMP and on TCP SYNs that miss conntrack).

Mock client exposes ClearACLCalls / ReplaceACLCalls counters so
tests can assert atomicity. New TestSGManager_UpdateSG_AtomicReplace
fails if SG mutations ever fall back to the split path.
2026-06-01 09:54:29 +10:00
Josh Siviour fd3dc6b680 feat(network): ADR-0006 layer redesign + IPsec/OVN hardening (#255)
* refactor(vpcd): remove macvlan bridge mode

Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).

Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
  already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh

Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
  verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
  (previously relied on empty-mode defaulting to centralized)

Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
  teardown role (migration for operator systems with stale spx-ext-*
  interfaces from pre-veth installs)

* feat(vpcd): resolve external CIDR at startup with bounded retry

Block service start until the WAN bridge has an IPv4 address, with a 30s
retry window. Phase 0 item 2 of the network layer redesign
(mulga-siv-125.1). Guards the boot race where vpcd starts before
systemd-networkd finishes DHCP on br-wan, or before netplan applies a
static config.

Skipped when ExternalMode is empty (overlay-only deployments).

Forward-compatible with ADR-0006: the new resolveExternalCIDR /
externalCIDRFromBridge helpers become the backing implementation of
HostWiring.ExternalCIDR() once the network/host package lands in Phase 1.
Until then the resolved value is only used to fail-start on missing
uplink.

* fix(vpcd): cover external CIDR startup paths

Extract launchService's CIDR-resolve block to ensureExternalCIDRReady
helper so it's unit-testable, and add direct tests for
externalCIDRFromBridge (loopback + unknown-interface) to exercise the
original literal body. Closes the diff-coverage gap on Phase 0 commit
15b564d which left these paths unreachable from unit tests.

* refactor(network/ovn): extract L1 OVN client to network/ovn package

Move OVNClient interface, LiveOVNClient, ACLSpec, ErrNATNotFound and
namedUUID helper from services/vpcd to a new spinifex/network/ovn
package. The mock relocates to spinifex/network/ovn/mock as a
sub-package. Storage fields on the mock become exported so vpcd tests
can continue to seed/inspect state across the new package boundary.

services/vpcd retains thin type-alias and var-alias shims so existing
callers compile unchanged; Phase 2 removes those shims as vpcd is torn
down.

Adds -coverpkg=./spinifex/... to test-cover so cross-package test
exercise of mock methods continues to count toward coverage now that
the mock lives outside vpcd.

First sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2). Pure refactor; no functional change. All
existing tests pass; preflight green.

* feat(network/topology): TopologyManager interface + vpcd adapter

Add spinifex/network/topology with Manager interface, VPC/Subnet/Port
specs, and OVN object naming helpers covering the L2 stable contract
(§7 of the redesign plan). Phase 1 keeps existing in-tree OVN names
(rtr-, ext-port-, sg_) and notes the §7.3 targets for a later
reconciliation migration.

TopologyHandler in services/vpcd grows EnsureVPC / DeleteVPC /
EnsureSubnet / DeleteSubnet / EnsurePort / DeletePort /
SetPortSecurityGroups methods that mirror the corresponding NATS
handlers' OVN ops with Spec inputs instead of nats.Msg envelopes,
satisfying topology.Manager via a compile-time check. Existing handlers
are unchanged; the duplication is short-lived (Phase 2 collapses them).

Smoke tests in topology_manager_test.go cover the idempotent create
path, delete cascade, and declarative SG membership move. Deliverable:
TopologyManager.EnsureVPC/Subnet/Port callable; all existing tests
green.

Second sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2).

* feat(network/host): L0 HostWiring interface + physical/veth impls

Phase 1 deliverable for mulga-siv-125.2. Introduces network/host (L0 of
ADR-0006): UplinkMode enum, Wiring interface, and Physical/Veth
implementations with injectable Runner + InterfaceReader for testability.
Not yet wired into daemon/vpcd — Phase 2 replaces daemon.SetupComputeNode
and vpcd's resolveExternalCIDR with calls through this package.

* feat(network/policy): L3 SecurityGroup/NAT/Route managers

Introduce network/policy as the data-plane policy layer (ADR-0006 L3).
Three managers, all backed by network/ovn:

- SecurityGroupManager: clear+add ACL replacement on an existing port
  group; port group lifecycle stays with L2 (topology.Manager).
- NATManager: bound to one NATMode at construction (derived from
  host.UplinkMode). Distributed sets logical_port/external_mac on EIP
  dnat_and_snat rules; centralised leaves them unset. Per-EIP, NAT GW,
  and IGW-default SNAT entrypoints; all deletes idempotent.
- RouteManager: AddStaticRoute is read-then-decide so retries no longer
  duplicate rows (closing the long-standing non-idempotency bug in
  vpcd/topology.go's IGW default-route path).

ACL priority constants and infrastructure ACL builders move from
vpcd/{security,acl}.go into policy/acl.go with their semantics preserved
(default-deny 900/800 logged, DHCP-allow 1050, tenant 1000). No vpcd
wiring changes here — additive only; the reconciler (bead .3.2) and
network/external (bead .3.4) will consume these managers.

bead: mulga-siv-125.3.1

* feat(network/external): L5 IGW/EIP/NATGW managers

Pure additive — new package, no topology.Manager changes, no rewiring of
existing vpcd handlers. IGWManager builds the external switch + localnet +
gateway LRP + default route + chassis HA, with a flows barrier callers can
inject (production: ovn-nbctl --wait=hv sync). EIP/NATGW are thin L5
wrappers over policy.NATManager. GatewayIPAllocator interface lets the
DHCP-backed allocator stay in vpcd until the DHCP-mgr removal bead lands;
LinkLocalAllocator + StaticRangeAllocator ship here.

* feat(network/reconcile): L6 intent-actual reconciler replaces 5-pass startup

Build spinifex/spinifex/network/reconcile/ package per Phase 2.2 of the
network layer redesign. Reads desired state from NATS KV filtered by
vpc.AZ (with legacy empty-AZ matches-all rule), scans actual state from
OVN NB DB, diffs and applies with VPC → Subnet → Port → SG → IGW →
EIP/NATGW topological ordering.

Atomic port-group join via CreateLogicalSwitchPortInGroups closes the
racy gap the legacy scanENIPortMembership pass used to revive 30s later.
A 5-minute drift goroutine (leader-gated via JetStream KV CAS) replaces
the old 30s ReconcileSGsLoop.

Adds AZ field to VPCRecord stamped at create time. Empty AZ on pre-Phase
2.2 records matches every local AZ, so no migration pass is required.

Replaces and removes the 5-pass startup sequence in services/vpcd:
  - Reconcile + ReconcileFromKV
  - ReconcileSGsOnce + ReconcileSGsLoop
  - RetrofitAllExternalLocalnetOptions
  - RetrofitAllGatewayPortNetworks

IGW fresh-create continues to flow through the legacy NATS handler path
(handleIGWAttach) so the dhcpManager wiring stays intact until Phase
2.3; the reconciler only rebinds gateway-chassis on existing IGW
topology.

Closes mulga-siv-125.3.2.

* refactor(network/topology): promote live Manager impl to network/topology

Move the topology.Manager implementation out of services/vpcd/topology_manager.go
into network/topology/manager_live.go as a standalone liveManager constructed
via NewLiveManager(ovn.Client, opts...). Inputs that varied per-handler
(DNS server resolution from the external pool) are now passed as functional
options (WithDNSServer).

TopologyHandler still satisfies topology.Manager but its methods delegate
to a cached liveManager. The shim stays until the rest of mulga-siv-129
routes NATS subscribers through topology.Manager directly.

Also exports SubnetGatewayCIDR + FormatDNSServerList from network/topology
so the reconciler in network/reconcile can share helpers with the live
manager (audit pass to follow per plan §6).

Test cases ported one-for-one from services/vpcd/topology_manager_test.go
to network/topology/manager_live_test.go; the vpcd-side test file is
deleted. New WithDNSServer test asserts the option propagates into
DHCPOptions rows.

Refs mulga-siv-129 (Phase 2.6 vpcd subscriber migration, Slice 1 of N).

* refactor(vpcd): collapse SG subscribers onto network/{topology,policy} managers

Phase 2.6 / mulga-siv-129 slice 2. Rewrites the vpc.create-sg /
vpc.update-sg / vpc.delete-sg NATS subscribers in services/vpcd as
thin wrappers over topology.Manager (port group lifecycle) and
policy.SecurityGroupManager (ACL programming). Deletes the ~1000
lines of duplicated ACL builder and provisioning logic — its
authoritative copy lives in network/policy.

Surface additions:

- ovn.ErrPortGroupNotFound sentinel; mock + live GetPortGroup wrap
  not-found via fmt.Errorf("%w: ...") so callers can use errors.Is.
- topology.Manager grows EnsureSGPortGroup / DeleteSGPortGroup
  (resolves the apply.go TODO from §2.1 of the redesign plan).
  liveManager implements them with GetPortGroup-then-Create /
  GetPortGroup-then-Clear+Delete idempotency.

Behaviour delta: the old provisionSG rolled back the OVN port group
on AddACLs failure ("all-or-nothing"). The new path lets the port
group survive and relies on network/reconcile to retry EnsureSG on
its next sweep. The all-or-nothing fail-fast test cases are dropped;
ACL emission semantics are covered by network/policy/sg_test.go.

* refactor(vpcd): migrate VPC/Subnet/ENI subscribers to topology.Manager

Phase 2.6 / mulga-siv-129 slice 3. Rewrites vpc.create / vpc.delete /
vpc.create-subnet / vpc.delete-subnet / vpc.create-port /
vpc.delete-port / vpc.update-port-sgs NATS subscribers as thin
wrappers (≤30 lines each) over topology.Manager. Subscriber bodies
now: parse event → translate to spec → delegate to manager.

Deletes:

- vpcd.subnetGateway (and helpers_test.go + topology_test.go's
  TestSubnetGateway block); parity coverage lives in
  network/topology.TestSubnetGatewayCIDR.
- vpcd.reconcilePortSGs (the live manager already exposes the same
  diff-then-transact via reconcilePortSGs on the L2 side).
- vpcd.TopologyHandler.dnsServer; lmDNSServer (passed via
  topology.WithDNSServer to the live manager) is the live path.

Behaviour delta: handlers now strict-parse PrivateIpAddress /
MacAddress / CidrBlock at the NATS boundary and reject malformed
payloads instead of degrading to "no DHCP options". The reconciler
guarantees that field validity is upheld upstream of the publish.

* refactor(network/reconcile): drive SG port groups via topology.Manager

applySGs now calls topology.Manager.EnsureSGPortGroup /
DeleteSGPortGroupByName instead of reaching directly into the OVN client.
Reconcile.Config gains a Topology field (required); the production caller
in services/vpcd passes the TopologyHandler shim, the reconcile package
tests construct a fresh topology.NewLiveManager bound to the mock OVN
client.

DeleteSGPortGroupByName is added to the topology.Manager interface for the
reconciler's orphan-removal path, which iterates raw OVN port-group names
without a groupID round-trip. The existing DeleteSGPortGroup(groupID)
delegates to it.

Resolves the TODO at apply.go:137 carried over from slice 2 of
mulga-siv-129.

* feat(network/policy): add FlowsBarrier option to NATManager

NATManager.AddEIP needs to block on `ovn-nbctl --wait=hv sync` after
writing the dnat_and_snat row so the NATS reply only returns once every
chassis has the rewrite flow. The legacy vpc.add-nat handler does this at
the tail of handleAddNAT; the manager has lacked it since slice 2.1.

NewNATManager grows a functional-options tail (Option / WithFlowsBarrier).
Defaults to a no-op for tests; production wiring in services/vpcd passes
waitForFlowsHV. AddEIP now invokes the barrier after AddNAT, logging at
warn level on failure to match the legacy handler's _ = waitForFlowsHV()
swallowing semantics.

Pre-work for slice 4 of mulga-siv-129 — without the barrier the NAT EIP
subscriber migration would lose the post-write flow-install gate.

* refactor(vpcd): migrate NAT GW subscribers to policy.NATManager

handleAddNATGateway / handleDeleteNATGateway now decode the NATS event
and delegate to policy.NATManager.{AddNATGateway,DeleteNATGateway}. The
manager carries identical OVN logic — same snat type, same ExternalIDs
(spinifex:vpc_id + spinifex:nat_gateway_id) — so existing
TestTopologyHandler_AddNATGateway / DeleteNATGateway suites stay green.

Lazy-constructed NATManager added on TopologyHandler (natmOnce). Mode is
derived from the bridge mode (veth → centralized, direct → distributed)
and the FlowsBarrier is bound to waitForFlowsHV for parity with the
legacy EIP path's tail barrier (used by the next slice, vpc.add-nat).

Slice 4 of mulga-siv-129, sub-step b.

* refactor(vpcd): migrate EIP NAT subscribers to policy.NATManager

handleAddNAT / handleDeleteNAT now decode the NATS event and delegate to
policy.NATManager.{AddEIP,DeleteEIP}. The behavioural contract is
preserved end-to-end:

- Idempotency-skip on a matching dnat_and_snat row (same VPC + LogicalIP,
  plus MAC + LogicalPort under distributed NAT) moves into AddEIP so
  duplicate vpc.add-nat publishes never tear down the rule the previous
  invocation just installed (mulga-siv-124).
- Stale-rule cleanup across every router stays in AddEIP.
- Post-write waitForFlowsHV barrier is now driven by the manager via the
  FlowsBarrier option wired in services/vpcd (slice 4a).
- DeleteEIP returns nil on ErrNATNotFound, matching the handler's
  fire-and-forget "already absent = success" semantic.

The vpcd "errors" import drops out with the inline error-comparison.

Slice 4 of mulga-siv-129, sub-step c.

* refactor(vpcd): migrate IGW subscribers to external.IGWManager (partial)

handleIGWAttach now branches at decode time:
- Centralised NAT + DHCP-sourced pool keeps using attachIGWLegacy because
  the gateway-LRP IP comes from vpc.dhcp.acquire, and that DHCP client is
  the vpcd-local manager that bead mulga-siv-125.3.3 will retire.
- All other shapes (distributed NAT, static-range pool, no pool)
  delegate to external.IGWManager.AttachIGW. The manager mirrors every
  step the legacy handler ran — external switch + localnet port +
  gateway LRP + switch-side router peer + default route + chassis HA +
  waitForFlowsHV tail barrier — so observable behaviour is unchanged.

handleIGWDetach drops its inline OVN walk entirely; both paths exit
through external.IGWManager.DetachIGW followed by releaseGatewayLRPLease.
The lease release stays in vpcd because the upstream client lives in
services/vpcd/dhcp; calling it after every detach is safe because the
upstream server expires unknown leases on its own.

Lazy-constructed IGWManager added on TopologyHandler (igwmOnce). Pool,
allocator (StaticRangeAllocator), chassis, NAT mode, NAT manager, route
manager, and FlowsBarrier are all derived from the existing
TopologyHandler state.

The DHCP-coupled legacy branch retains its full inline OVN logic; it
will disappear in slice-after-125.3.3 follow-on. Slice 4 of
mulga-siv-129, sub-step d.

* feat(ec2/instance): honor AssociatePublicIpAddress per-NIC override

RunInstances --network-interfaces "AssociatePublicIpAddress=true|false"
was silently ignored, only subnet.MapPublicIpOnLaunch gated public IP
assignment. AWS contract is per-NIC override wins.

Extend TestPrepareRunInstances_PublicIPAutoAssigned to table-driven
3 cases: subnet true no-override, subnet false override true, subnet
true override false.

* refactor(config): drop upstream-DHCP config surface (static WAN pool only)

Hard-error in LoadConfig on legacy keys: [network] external_dhcp,
[nodes.<n>.vpcd] dhcp_bind_bridge, and [[network.external_pools]]
source = "dhcp". Add pool range validation (must fit inside the
gateway/prefix_len CIDR; per-cluster non-overlap).

Strip ExternalDHCP from config.NetworkConfig + formation.NetworkConfig
+ admin.ConfigSettings, and DhcpBindBridge from config.VPCDConfig +
admin.ConfigSettings + vpcd.Config. vpcd.ExternalPoolConfig.IsDHCP()
short-circuits to false; the field and downstream DHCP branches in
external_ipam / topology.go remain for Slice C to delete together with
dhcp_manager.

cmd/spinifex/cmd/admin.go: drop useExternalDHCP path and the
detectedDhcpBindBridge helper; require --external-pool when
--external-mode=pool; auto-detect default no longer infers source=dhcp.

service.go's wan_bridge legacy-key rejection now advises removing the
key entirely (vpcd auto-detects br-wan), not renaming to
dhcp_bind_bridge.

Two topology_test cases that exercise the DHCP gateway-LRP path are
skipped — Slice C deletes attachIGWLegacy and these tests together.

* refactor(vpcd): delete dhcp_manager + DHCP gateway-LRP path (Slice C)

Removes upstream-DHCP-client gateway-LRP allocation in favor of static
WAN-pool allocation (StaticRangeAllocator). Part of bead mulga-siv-125.3.3.

- delete services/vpcd/dhcp_manager.go + dhcp/ subpackage (~865 lines)
- delete allocateGatewayLRPIPViaDHCP + releaseGatewayLRPLease paths
- drop ExternalPoolConfig.Source + DhcpBindBridge + IsDHCP() fields
- drop ExternalIPAllocation lease fields (Expiry/ServerID/HWAddr) and
  ExternalIPAMRecord.Source
- drop NewExternalIPAM nc *nats.Conn argument; update all 4 call sites
- rename verifyBridgeMode local dhcpBindBridge -> wanBridge; update error
  strings and tests

Slice D (delete attachIGWLegacy + dispatcher) intentionally deferred.

* refactor(vpcd): delete attachIGWLegacy + IsDHCP stub (Slice D)

Closes mulga-siv-129 slice 4d. Part of bead mulga-siv-125.3.3.

handleIGWAttach now unconditionally delegates to external.IGWManager.
attachIGWLegacy (~207 lines, ~5 OVN write steps + chassis scheduling)
deleted along with its dispatcher branch in handleIGWAttach. The
ExternalPoolConfig.IsDHCP() dead stub is gone.

No code paths or NATS subjects regress: the static-pool / distributed
branches were already going through external.IGWManager since Slice 4d
(6719dc3).

* feat(ipam): Purpose field on every IPAM record (Slice B)

Part of bead mulga-siv-125.3.3.

Adds a Purpose enum (handlers/ec2/vpc/purpose.go) tagging every IP
allocation with its semantic role (eni-primary, eni-public, eip,
igw-lrp, natgw-external, …) so multi-VPC clusters can reclaim/audit
by (owner, purpose).

External IPAM (spinifex-external-ipam, v1→v2):
- ExternalIPAllocation.Type → .Purpose; legacy values mapped via
  LegacyExternalTypeToPurpose (gateway→igw-lrp, auto_assign→eni-public,
  elastic_ip→eip). Unknown legacy values warn and become "unknown".
- AllocateIP / AllocateFromPool / allocateFromPool param rename
  allocType→purpose. All call sites pass Purpose* constants.
- migrate/002_external_ipam_purpose.go: idempotent CAS-retry migration
  with v1/v2-union record shape so partial migrations resume cleanly.

Internal IPAM (spinifex-vpc-ipam, v1→v2):
- IPAMRecord.Allocated []string → []IPEntry{IP, Purpose, OwnerID}.
- AllocateIP gains purpose+ownerID args; ReleaseIP unchanged (still
  keyed on IP).
- AllocatedIPs returns []IPEntry.
- eni.go reorders eniId generation before AllocateIP so the owning
  resource ID lands on the new entry.
- migrate/003_ipam_purpose.go: cross-bucket backfill that reads the
  ENI bucket via the new RunKVWithJetStream path. Orphaned IPs
  (no owning ENI) warn and become Purpose=unknown.

Migrate framework:
- KVContext gains an optional JetStream field; new
  Registry.RunKVWithJetStream method threads it through. RunKV
  signature unchanged — all other callers are unaffected.

Tests:
- ipam_test.go: AllocateIP signature updates; AllocatedIPs now asserts
  IP / Purpose / OwnerID on the returned entries.
- external_ipam_test.go: replaces "auto_assign"/"elastic_ip" literals
  with PurposeENIPublic/PurposeEIP and asserts alloc.Purpose.
- migrate/002 + 003 tests: schema-detect, idempotent rerun,
  no-JetStream fallback, empty bucket.

* refactor(daemon): tombstone obsolete spinifex-dhcp-leases KV bucket

The dhcp_manager removed in Phase 2.3 (bead mulga-siv-125.3.3) owned the
spinifex-dhcp-leases KV bucket. The migrate framework only handles
per-bucket schema version bumps, not bucket deletion, so the obsolete
bucket would linger on upgraded clusters.

Add a one-shot tombstone after initJetStream that deletes the bucket if
present. DeleteKVBucketIfExists treats ErrBucketNotFound and
ErrStreamNotFound as success, so the call is idempotent across restarts.

* feat(config): add network.ipsec_enabled cluster flag (default true)

Add IPSecEnabled to NetworkConfig in both the cluster TOML schema and
the formation handshake payload. Default true so AWS-parity intra-AZ
encryption is on by default. Operators can flip false in
[network] for trusted single-rack lab topologies.

Phase 2.5 (bead mulga-siv-125.3.5) consumes the flag from admin init
(strongSwan provisioning), formation server (propagates to joiners),
and daemon startup (ovs-vsctl ipsec_encapsulation toggle).

* feat(admin): IPsec peer cert generator signed by cluster CA

Add GenerateIPSecPeerCert + IPSecCertPaths in spinifex/admin so admin
init (and the future admin add-node path) can issue per-node IKEv2 peer
credentials signed by the existing cluster CA. The cert carries
id-kp-ipsecIKE (RFC 4945) so strongSwan accepts it as an IKEv2 peer
credential when ovs-monitor-ipsec materialises tunnel configs.

Cluster CA is reused directly — no intermediate strongSwan PKI today;
when intermediate CAs land that becomes a separate workstream.

The cert/key pair lives under {configDir}/ipsec/peer.{pem,key}; the
daemon points OVS Open_vSwitch.other_config (certificate, private_key,
ca_cert) at these paths when network.ipsec_enabled is true.

* feat(admin): wire IPsec peer cert into admin init + join paths

Add --ipsec bool flag (default true) on adminInitCmd. Single-node init
and the multi-node leader path both emit an IPsec peer cert after the
existing CA+server-cert generation. The multi-node formation server
propagates the IPSecEnabled flag to joining nodes via NetworkConfig;
admin join honours it and emits its own peer cert during the same
cert-issuance step that already produces the server cert.

spinifex.toml template gains an unconditional [network] block carrying
ipsec_enabled so the value is visible to operators on every install
regardless of whether external networking is configured. The daemon
reads it via cluster config (NetworkConfig.IPSecEnabled).

Bead mulga-siv-125.3.5.

* build: install openvswitch-ipsec for OVN native IPsec

openvswitch-ipsec provides ovs-monitor-ipsec, which reads the
Open_vSwitch.other_config cert/key/CA pointers and materialises
strongSwan configs for every Geneve tunnel managed by ovn-controller.
Pulls in strongswan + strongswan-pki as transitive deps.

Required by bead mulga-siv-125.3.5.

* feat(daemon): enable OVN native IPsec on intra-AZ Geneve at startup

When network.ipsec_enabled is true (default), the daemon points the
local OVS DB other_config at the per-node IPsec peer cert that admin
init / admin join provisioned (configDir/ipsec/peer.{pem,key} + the
cluster CA at configDir/ca.pem), then sets
other_config:ipsec_encapsulation=true.

ovs-monitor-ipsec (shipped by the openvswitch-ipsec package) consumes
those pointers and materialises strongSwan configs for every Geneve
tunnel ovn-controller programs — including OVN-IC inter-AZ tunnels
when federation lands in Phase 3a.

Missing cert files log a warning and leave Geneve plaintext; operators
upgrading an older cluster reissue certs via spx admin to close the
gap.

Bead mulga-siv-125.3.5.

* test(e2e): multinode IPsec wiring + xfrm SAs + ESP capture

TestMultinodeIPSec asserts:
- OVS DB on every node carries certificate/private_key/ca_cert pointers
  and other_config:ipsec_encapsulation=true (what daemon.enableOVNIPSec
  writes).
- ip xfrm state on every node has at least one AEAD SA in AES-GCM mode
  (rfc4106(gcm(aes)) or gcm(aes)) within 90s.
- tcpdump catches ESP (ip proto 50) on the underlay (best-effort —
  logged only; geneve may be idle during the 5s capture window).

Skips cleanly when the cluster was bootstrapped with --ipsec=false
(no ipsec_encapsulation key on first node).

Read-only over SSH; runs in the parallel bucket alongside the other
non-mutating multinode tests.

* fix(igw): write attachment State="attached" for AWS parity

Reconciler intent loader (network/reconcile/intent.go::loadIGWs) filters
IGW records with `State == "attached"`, but AttachInternetGateway was
writing `State = "available"`. The reconciler therefore never picked up
any attached IGW — `intent_igws=0` on every pass — so the OVN external
switch + gateway LRP + WAN bridge wiring built by IGWManager.AttachIGW
never ran for default-VPC IGWs (whose live `vpc.igw-attach` publish at
daemon bootstrap is lost — vpcd subscriber not yet wired).

Net effect on single-node e2e: every SSH probe to 192.168.x public IPs
timed out (TestSSHProbe, TestModifyInstanceAttribute, TestRebootInstance,
TestNATGateway, TestSGToSGDatapath) because SNAT/DNAT rules on the VPC
router had no path to the external network.

Match AWS InternetGatewayAttachment.State semantics:
 - Attach: State="attached"
 - Detach: State="detached"

Detach value is internal — recordToEC2 gates Attachments on VpcId != "",
so the detached State is never surfaced to API consumers.

Bead: mulga-siv-131

* fix(reconcile): call AttachIGW when IGW topology missing

applyIGWs previously deferred to the NATS handler when an intent IGW
had no external switch in OVN. When the vpc.igw-attach event is lost
(bootstrap-time default-VPC IGW published before vpcd subscriber is
ready), deferral never resolves and VM public-IP traffic dies.

Call IGWManager.AttachIGW directly — it is idempotent (short-circuits
when the external switch already exists), so the reconciler converges
default-VPC IGWs on first pass without duplicating work on subsequent
passes.

Closes mulga-siv-132.

* fix(test): align NewExternalIPAM call with 2-arg signature

Rebase onto dev pulled in daemon_system_instance_test.go which used
the legacy 3-arg NewExternalIPAM(jsNC, js, pools). The feature branch
already narrowed the signature to (js, pools) via the upstream-DHCP
config drop; align the test with the current signature.

* fix(vpcd): EnsureSubnet must EnsureVPC first to avoid NATS race

vpc.create-vpc and vpc.create-subnet are independent NATS events with
no inter-event ordering guarantee. Tenant bootstrap on iam.account.created
publishes both within <1ms, so the subnet handler's
CreateLogicalRouterPort can race the parent VPC's CreateLogicalRouter and
fail with "logical router not found". The cascade fails CreateNetworkInterface
which fails RunInstances which surfaces as InsufficientInstanceCapacity to
the SDK — masking the underlying topology race.

handleSubnetCreate now calls EnsureVPC(VPCID-only) before EnsureSubnet.
EnsureVPC short-circuits on existing LR (idempotent), so the race-winning
vpc.create-vpc handler's full CIDR/VNI ExternalIDs are preserved; if subnet
wins the race the LR is created with bare ExternalIDs (cosmetic) and the
follow-on vpc.create-vpc short-circuits.

Adds TestIntegration_SubnetBeforeVPC regression covering both orderings.

Closes mulga-siv-133.

* feat(ansible): require --external-pool, drop NAT, forward EXTRA_VARS

spx admin init now rejects --external-mode=nat and demands --external-pool
when mode=pool. The ansible init role silently skipped pool flags when the
range was unset, so installs fell over inside the spx validator. Make
targets also swallowed `-e` (make's env-override flag), so operators could
not override pool start/end without editing defaults.yml.

- vars/defaults.yml: rename nat_gateway_ip → external_gateway_ip; add
  spinifex_external_no_external opt-out; document the spx flag contract.
- roles/init/tasks/main.yml: drop NAT branch; assert pool range upfront
  with a fail_msg pointing at EXTRA_VARS / defaults.yml; emit gateway,
  prefix-len, gateway-ip only when explicitly set (let spx auto-detect);
  honour --no-external.
- playbooks/dev-reset.yml: rename captured var; coerce legacy
  external_mode=nat to pool with a warn; tighten gateway regex with \b.
- Makefile: ansible-dev-* targets now forward EXTRA_VARS="key=val ..."
  through --extra-vars so `make` callers can override without editing
  defaults.yml.
- scripts/ansible/README.md: document pool requirement, both invocation
  styles (direct -e vs make EXTRA_VARS), NAT removal.

* fix(ipsec): guard ipsec_encapsulation on ovs-monitor-ipsec liveness

enableOVNIPSec set OVS cert pointers + ipsec_encapsulation=true without
ensuring ovs-monitor-ipsec.service was running. Without that daemon, OVS
holds the cert pointers unused, strongSwan never gets IKE config, and
tunnel traffic ovn-controller marks for encryption gets silently
dropped. TestMultinodeIPSec fails on `node1 xfrm has no AEAD SAs`.

- systemd: spinifex-daemon Wants=/After= ovs-monitor-ipsec.service
- daemon: ensureOVSMonitorIPSecActive — systemctl enable --now +
  is-active poll; refuses to flip ipsec_encapsulation if daemon is dead
- daemon: single-node short-circuit — no peers means no tunnels means
  the flag is at best inert and at worst load-bearing-silent-drop
- ci: capture-net-diag.sh + workflow steps to snapshot OVS/OVN/xfrm
  state on failure, fan out across all 3 multinode nodes

Refs: mulga-siv-136

* test(e2e): sticky-skip downstream SSH tests when probe fails

When TestSSHProbe times out (3 min, typically because of mulga-siv-134
datapath bug), four downstream tests each re-probe SSH on the same
broken datapath and time out identically — single-node job runs ~24 min
instead of ~12 min, with the same error logged five times.

First waitForSSHReady timeout now sets a package-level atomic flag;
subsequent waitForSSHReady / trySSHReady / requireSSHHealthy calls skip
immediately. Preserves the original red signal on TestSSHProbe and on
any test that genuinely needed SSH (Skip ≠ Pass). Bool variant
(trySSHReady) consults but does not set the flag — its caller already
treats False as advisory warning, not fatal.

TestSGToSGDatapath has its own EventuallyErr SSH wait (not via
waitForSSHReady) and needs an explicit requireSSHHealthy(t) gate at
the top of runSGToSGDatapath. TestNATGateway already calls
waitForSSHReady first so the gate fires naturally.

Refs: mulga-siv-137

* fix(ipsec): use Debian unit name openvswitch-ipsec.service

CI artifact shows `Unit ovs-monitor-ipsec.service could not be found`
on every node. Debian's openvswitch-ipsec package ships the unit as
openvswitch-ipsec.service (the binary inside is ovs-monitor-ipsec, but
systemd's name follows the package). The previous commit guarded on
the wrong unit name, so:

- spinifex-daemon.service Wants=/After= silently no-op'd (missing unit)
- enableOVNIPSec's `systemctl enable --now ovs-monitor-ipsec.service`
  failed, daemon logged a warning and continued without flipping
  ipsec_encapsulation=true
- TestMultinodeIPSec skipped with `node1 reports ""` because OVS DB
  never received the cert pointers

Rename across all 5 spots (daemon, systemd unit, test, CI diag script,
workflow journal capture) to match the Debian package layout.

Note: the package itself still must be present on the CI VM image —
that's tracked in the mulga repo (image-builder provision.sh +
bootstrap-install.sh shim).

Refs: mulga-siv-136

* fix(e2e): authorize default SG tcp/22 + icmp in single-node fixture

admin init leaves the default SG closed (AWS parity); the Go single suite
never ported the Phase-5 SG-ingress block that run-e2e.sh has had since day
one, so every test runner SSH/ping probe hits the OVN port-group ACL drop.

- ec2helpers: add AuthorizeICMPIngress + EnsureDefaultSGOpen helpers.
- single/main_test: open default SG once per process in pkgFixOnce.Do.
- witness: extend ensureDefaultSGSSHIngress to authorize ICMP too.
- run-e2e.sh: append matching ICMP authorize next to tcp/22 block.

mulga-siv-134

* fix(network/reconcile): key DHCP_Options lookup by subnet_id not CIDR

Multi-tenant default VPCs share the same subnet CIDR (172.31.0.0/20) per
account. The existing FindDHCPOptionsByCIDR gate at apply.go:107 found
the first account's DHCP_Options row and skipped creation for every
subsequent subnet with the same CIDR. Those subnets' ports then attached
nothing (FindDHCPOptionsByExternalID misses on subnet_id), VMs got no
DHCPv4 lease, and SSH/ICMP into them silently failed even though SG
ACLs and NAT were correct.

Switch the gate to FindDHCPOptionsByExternalID keyed on spinifex:subnet_id
so each subnet owns its own row, matching the attach lookup at line 203.

Verified: account 000000000001 default VPC RunInstances now produces an
LSP with dhcpv4_options populated, VM cloud-init reports a 172.31.0.5/20
lease + default route via 172.31.0.1, and ping + ssh TCP handshake to
the EIP succeed end-to-end. Stale DHCP_Options row for the prior dead
subnet was wiped manually; reconciler recreated the correct row for the
live subnet on its next pass.

* fix(ipsec): enable openvswitch-ipsec at provision time, drop daemon write

Provisioning now runs `systemctl enable --now openvswitch-ipsec.service`
alongside openvswitch-switch and ovn-controller. The spinifex-daemon
process drops its `enable --now` shell-out and only polls is-active.

Sudoers gains one read-only line:
  spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/systemctl is-active openvswitch-ipsec.service

This mirrors the existing vpcd ovn-controller is-active precedent and
removes the daemon's need for systemd-write capability. Aligns with
least-privilege service account pattern: provision enables services,
daemon only reads status.

If the unit is inactive at runtime, the daemon refuses to flip
ipsec_encapsulation=true (silent-drop guard from mulga-siv-136) and
surfaces a clear error pointing at scripts/setup-ovn.sh.

* chore: unblock preflight on feat/network-layer-redesign

Two pre-existing failures blocked preflight on the branch:

- govulncheck flagged golang.org/x/net@v0.54.0 (GO-2026-5026, ASCII Punycode
  rejection bug in idna.ToASCII); reached via utils.DownloadFileWithProgress.
  Bump to v0.55.0; go mod tidy carries x/sys to v0.45.0.
- TestTopologyHandler_SubnetCreate_RouterPortFails asserted subnet create
  fails when the parent VPC router is missing. That contract changed in
  aed7b2e (mulga-siv-133), which made handleSubnetCreate call EnsureVPC
  idempotently before EnsureSubnet to avoid a NATS publish-order race.
  Delete the now-impossible failure case.

* refactor(network/ovn): relocate nbdb model package from services/vpcd

Phase 4 / mulga-siv-125.6 Slice A. Pure import rename, no logic change.

The OVN Northbound DB model (LogicalSwitch, LogicalRouter, DHCPOptions,
etc.) lived under services/vpcd/nbdb/ because that was the original home
of every OVN caller. After Phase 1+2 the L1 client (network/ovn) and all
L3/L5 managers (network/policy, network/external, network/topology,
network/reconcile) became the primary callers — 8 importers in network/*
vs 2 in services/vpcd/. The directory move puts the schema model where
its consumers are, and unblocks deleting services/vpcd/ entirely once
the subscriber/launcher relocations land (Slices B–D).

* refactor(vpcd): delete dead retrofit/reconcileIGW helpers + bridge-mode bleed

Phase 4 / mulga-siv-125.6 Slice B.

Production path no longer touches any of:

- RetrofitAllGatewayPortNetworks / RetrofitAllExternalLocalnetOptions —
  the 5-pass startup leftovers that network/reconcile (Phase 2.2) made
  redundant. Nothing called them outside tests.
- reconcileIGW + its allocateGatewayLRPIP / gwLrpRange helpers + the
  ipv4ToUint32 / uint32ToIPv4 utilities — superseded by
  external.IGWManager (Phase 2.4) which carries its own pool logic.
- ensureLocalnetOptions / ensureGatewayPortNetworks /
  expectedGatewayPortNetwork — read-before-write retrofit shims for the
  same code paths.
- generateMAC — only callers were reconcileIGW + its tests.

useCentralizedNAT (the bridge-mode bleed flagged in plan §2.2) is
replaced by a policy.NATMode value injected via WithNATMode(...). The
mode is resolved once in vpcd.go from host.UplinkMode and threaded into
both the topology-handler lazy managers and the reconciler stack, so
"is this veth?" is no longer a question topology.go can ask. The
TopologyHandler defaults to NATModeDistributed when WithNATMode is
omitted, matching the previous default.

Drops services/vpcd/topology.go from 1220 LOC to 693, deletes ~700 LOC
of obsolete tests (TestGenerateMAC, TestEnsureLocalnetOptions_*,
TestRetrofitAllExternalLocalnetOptions_FixesClearedOptions,
TestReconcileIGW_*, TestGwLrpRange), and frees the nbdb + utils +
net imports in topology.go.

Stale "phase 4 migrates those onto the new managers" comment in vpcd.go
fixed in passing — the subscribers were migrated in Phase 2.6
(mulga-siv-129); only the file-relocation parts of Phase 4 remain.
Stale ensureLocalnetOptions / ensureGatewayPortNetworks references in
network/ovn/{live.go,mock/mock.go} doc comments also cleaned up.

* refactor(network): move NATS VPC subscribers to network/subscribers

Phase 4 Slice C (mulga-siv-125.6). Promotes the NATS event handlers out of
services/vpcd into a new spinifex/network/subscribers package. Subscriber
holds the topology/policy/external managers directly — the lazy *Once
adapter shim (topology_manager.go) is deleted along with vpcd's local
event-struct copies.

launchService now constructs topology.LiveManager eagerly with a DNS
callback derived from external pools (preserving the unscoped-pool lookup
old TopologyHandler.lmDNSServer did), builds every manager up front, then
hands them to subscribers.New + Subscribe. reconcile.New receives
topology.Manager directly instead of through the adapter.

Bridge-mode constants and waitForFlowsHV move into vpcd.go alongside the
launcher; they were left behind as topology.go's last responsibilities.

Handler-layer tests (topology_test.go, security_test.go, integration_test.go)
are dropped: substance is covered by network/topology, network/policy,
network/external manager tests. subscribers/subscribers_test.go adds
focused coverage: missing-dep rejection, topic registration smoke, and
bad-JSON envelope per topic.

services/vpcd is now down to the launcher + the ovn/mock compat aliases
(removed in Slice D when the launcher promotes to cmd/vpcd/).

* refactor(vpcd): relocate services/vpcd → spinifex/vpcd; drop dead aliases

Phase 4 Slice D (mulga-siv-125.6). Final cleanup of the network-redesign
package layout. services/vpcd/ disappears entirely; the launcher remains
an in-process Service impl, just rehomed under spinifex/vpcd/ (no separate
binary — spx vpcd start stays the cobra entrypoint).

- Delete services/vpcd/mock_ovn.go + ovn.go (compat aliases now dead post
  Slice C; only callsite was launchService's NewLiveOVNClient, inlined to
  ovn.NewLiveClient).
- git-rename services/vpcd/vpcd.go + vpcd_test.go to spinifex/vpcd/.
- Retarget three importers (spinifex/service/service.go +
  service_test.go, cmd/spinifex/cmd/service.go) to the new path.
- Fix stale services/vpcd / TopologyHandler comments in network/reconcile/
  + network/external/types.go + spinifex/vpcd/vpcd.go.
- service-interfaces.yaml vpcd path updated.
- Plan doc §13/§14/§16 + §9.2/§19 updated: drop cmd/vpcd/main.go (was
  speculative; never built) in favour of in-process Service impl at
  spinifex/vpcd/vpcd.go. Plan §16 Created list now lists subscribers/.

Phase 4 plan items 1+2 (remove services/vpcd, entrypoint location)
satisfied. Items 3+4 remain blocked on mulga-siv-125.4 + 125.5 (bundled
join token, plan archive).

* fix(ovn): install openvswitch-ipsec + strongswan explicitly

ovs-monitor-ipsec.service fails to start without an IKE daemon. The
openvswitch-ipsec package only Recommends strongswan, so any apt
config that drops Recommends (or a base image with the recommends
already skipped) leaves the service in a Restart loop with 'IKE
daemon is not installed'. Hoist both packages into setup-ovn.sh so
provisioning is the single source of truth — the bootstrap shim in
mulga is no longer needed.

* test(network): pin ADR-0006 invariants to executable suite

Add static-analysis + behavioural tests that bind every safety and
liveness clause in ADR-0006 (S1-S9, L1-L3) to a named Go test. Failure
output quotes the ADR clause verbatim and points at the offending
file:line so future debugging sessions can't quietly bypass a layer
contract to make a test green.

Static-analysis suite (spinifex/network/invariants/):
- S1 layers_test.go: parses `go list -json`; rejects upward layer
  imports and layers pulling from cross-cutter packages
- S2 host_containment_test.go: AST-detects ovs-vsctl / ovn-nbctl /
  ovs-ofctl shell-out outside network/host/
- S3 nat_mode_test.go: rejects .UplinkMode() / NATModeFromUplinkMode()
  calls outside the init-time resolver locations
- S4 names_test.go: AST-detects OVN object name construction (concat,
  fmt.Sprintf, strings.Join) outside topology/names.go
- S5 policy_no_create_test.go: rejects CreateLogical{Switch,Router,...}
  and DeleteLogical{...} calls inside network/policy/
- S6 federation_isolation_test.go: rejects TransitSwitch /
  TransitRouterPort references and ovn-ic literals outside
  network/federation/ (pre-protects the layer before it lands)
- S8 ipsec_test.go: rejects strongswan / xfrm / IKEv2 / libreswan /
  openswan imports anywhere in network/
- S9 reconciler_singleton_test.go: rejects Reconcile…Loop and
  Reconcile…Pass<n> function names anywhere in network/
- S7 nats_subjects_test.go: scans for `vpc.<verb>` subjects missing
  the {azID} segment; calls t.Skip with the gap report so every test
  run prints the work-to-do without breaking CI. Flip to t.Fatalf
  when the cluster-wide subject rename ships.

Behavioural suite (spinifex/network/reconcile/liveness_test.go):
- L1 reconciler converges: scaled-intent (N=1,3,8) double-Reconcile
  property test; asserts second pass is a no-op
- L2 drift bound: structural check that DriftInterval is positive and
  ≤30 min support window; full fake-clock behavioural test deferred
- L3 federation re-enables: t.Skip stub pointing at the missing
  network/federation/ package

Fixes the one production S4 violation the new walker found:
network/external/igw.go was constructing "gw-port-"+vpcID inline.
Adds topology.GatewaySwitchPort(vpcID) helper and retargets both
AttachIGW and DetachIGW callsites through L2.

Plan + per-clause mechanic + phasing in
docs/development/improvements/network-invariants.md.

Refs: mulga-siv-140

* fix(ovn): install strongswan-charon (not strongswan metapackage)

The strongswan metapackage's '\''charon-systemd | strongswan-charon'\''
alternative resolves to charon-systemd by default, which ships
/usr/sbin/charon-systemd but not the legacy /usr/sbin/ipsec helper or
/usr/lib/ipsec/charon binary that ovs-monitor-ipsec --ike-daemon=strongswan
shells out to. Install strongswan-charon explicitly to pull
strongswan-starter (legacy ipsec controller) + the matching charon binary,
matching what openvswitch-ipsec.service expects.

Also reset-failed + restart openvswitch-ipsec.service after enable to
recover from the deb post-install starting the service before its
dependencies finished unpacking.

* chore: add CODEOWNERS — joshsiv-mulga gates network/ + vpcd/

Fall-through reviewer for any change is the maintainer.
network/ and vpcd/ are explicitly listed so the matching branch
protection rule ("Require review from Code Owners") routes those
PRs to joshsiv-mulga. Those trees are governed by ADR-0006
invariant tests (spinifex/network/invariants/); CODEOWNERS adds a
human review gate on top of the mechanical fence.

Refs: mulga-siv-141

* chore: CODEOWNERS fallthrough → @mulgadc/mulgadc-engineers team

Fallthrough reviewer should be the team, not a single maintainer, so
any engineer can approve PRs outside the explicitly-gated paths.
network/ and vpcd/ keep joshsiv-mulga as the required reviewer per
ADR-0006 invariant ownership.

Refs: mulga-siv-141

* feat(ci): add test_run workflow input for -test.run filter

Pass an optional regex through to each suite's '\''go test -test.run'\'' so
dispatches can target a single test (e.g. '\''^TestMultinodeIPSec$'\'') without
running the full multinode suite. Threaded through single, multinode, and
reboot suite invocations; empty input preserves current behaviour.

* fix(daemon): flip NB_Global.ipsec=true on management node

Setting Open_vSwitch.other_config:ipsec_encapsulation=true on each
chassis is necessary but not sufficient for OVN native IPsec — without
NB_Global.ipsec=true, ovn-controller never adds options:remote_name to
the Geneve tunnel ports, so ovs-monitor-ipsec sees no IPsec-enabled
tunnels and the kernel xfrm tables stay empty. After enabling
ipsec_encapsulation locally, also write NB_Global.ipsec=true when the
local ovnnb_db socket is present (management node only); workers skip
silently — one writer is enough for a cluster-wide flag.

Adds a scoped sudoers entry pinning the daemon to that exact ovn-nbctl
invocation so the grant can't be reused for other NB mutations.

* fix(ovn): grant charon AppArmor read on /etc/spinifex/ipsec

The strongswan-charon profile only allows reads from /etc/ipsec.*,
/etc/strongswan.*, and a small fixed allowlist. ovs-monitor-ipsec writes
the per-tunnel strongSwan config with absolute paths to our peer cert +
key under /etc/spinifex/ipsec/, so charon hits 'Permission denied'
loading them (it runs as uid 0 but AppArmor still blocks) and no xfrm
SAs come up. Drop a read grant for /etc/spinifex/ipsec/** into the
profile's local override hook + reload.

* fix(ovn): pin OVS system-id to hostname for IPsec cert identity match

ovs-monitor-ipsec uses chassis-id as the IKEv2 `@<name>` peer identity in
the strongSwan config it generates. Our per-node IPsec peer cert
(admin.GenerateIPSecPeerCert) carries the node name as CN + dnsName SAN.
With chassis-id left at the package-generated UUID, charon validates
`@<UUID>` against a cert with dnsName=<hostname> — mismatch returns
AUTHENTICATION_FAILED and no xfrm SAs are negotiated.

Pin system-id to hostname (which equals the node name on Mulga clusters)
by writing both the on-disk source of truth (/etc/openvswitch/system-id.conf,
read by ovs-ctl on boot) and the running OVS DB value. Writing only one
would drift on the next reboot — the original "do not override" comment
was right about that hazard, just wrong about the resolution.

Cross-link the invariant in admin/ipsec.go: changing the cert identity
without updating setup-ovn.sh (or vice versa) breaks IKE silently.

Refs: mulga-siv-136

* fix(ovn): take --node-name flag so chassis-id matches cluster identity

CI hostnames are bead-themed (env20-casuarina) and don't match the
cluster node name (node1/node2) baked into the IPsec peer cert dnsName
SAN at `spx admin init/join --node NAME`. Pinning system-id to
`hostname -s` produced env20-casuarina; charon then validated
`@node1` against dnsName=env20-casuarina and IKE failed with
AUTHENTICATION_FAILED.

Add a --node-name=NAME flag to setup-ovn.sh, default to hostname for
manual/dev invocations, and update the ansible install role to pass
{{ spinifex_node_name }}. The matching bootstrap-install.sh change
lives in mulga.

Refs: mulga-siv-136

* refactor(daemon): relocate L0 helpers to network/host + widen ADR-0006 S2

Phase 4 Slice E of mulga-siv-125.6. Cleans up spinifex/daemon/network.go
per the audit and widens the S2 invariant to forbid OVS/OVN shell-outs
in spinifex/daemon/ (daemon is an L0 consumer; S2 applies).

Moved to network/host/:
- OVSNetworkPlumber + SetupTap/CleanupTap → host.OVSPlumber (host.NewOVSPlumber)
- GetBridgeIPv4 → host.GetBridgeIPv4
- OVNHealthStatus + CheckOVNHealth → host.OVNHealth + host.HealthStatus
- daemon/ipsec.go OVS/OVN ops → host.SetIPSecCertPaths, host.EnableIPSecEncapsulation, host.SetNBGlobalIPSec

Moved to utils:
- sudoCommand → utils.SudoCommand + utils.SetSudoCommandForTest test seam
  (cross-package reassign would trip the reassign linter; helper keeps
  the indirection inside utils).

Moved to vm:
- generateMgmtMAC → vm.GenerateMgmtMAC (pairs with vm.GenerateDevMAC)

Deleted (dead):
- SetupComputeNode (replaced by network/host/bridges.go:ensureBridges +
  scripts/setup-ovn.sh:572-579)
- EnsureDataRoute
- findInterfaceByIP
- daemon/network.go + dead tests in daemon/network_test.go (duplicates of
  vm package tests retained; mgmt MAC test moved to vm/lifecycle_test.go)

S2 invariant (network/invariants/host_containment_test.go) now walks
both spinifex/network/ and spinifex/daemon/, skipping network/host/.

* fix(ovn): broaden AppArmor read grant to /etc/spinifex (covers CA cert)

The earlier override granted reads on /etc/spinifex/ipsec/** only, which
covers the peer cert + key but NOT the cluster CA at /etc/spinifex/ca.pem.
charon then can't load the CA, can't validate the peer cert chain, and
surfaces the failure as `no trusted RSA public key found for '<peer>'`
even though identity matching now succeeds — IKE_AUTH fails with
AUTHENTICATION_FAILED and no xfrm SAs negotiate.

Grant /etc/spinifex/** instead (covers ca.pem + ipsec/peer.{pem,key})
and always rewrite the override so re-runs replace the older narrower
rule.

Refs: mulga-siv-136

* fix(ipsec): symlink cluster CA into charon's default cacerts dir

ovs-monitor-ipsec writes per-conn `cacert=/etc/spinifex/ca.pem` references
in the strongSwan config, but strongSwan 6.0 only loads CAs declared in
standalone `ca` sections or files placed in /etc/ipsec.d/cacerts at
startup. Without this, charon authenticates the local side fine but
rejects every inbound peer cert as `no trusted RSA public key found for
'<peer>'`, AUTH_FAILED, and no xfrm SAs negotiate.

Do the symlink in admin.GenerateIPSecPeerCert (right after writing the
peer cert) — setup-ovn.sh runs before `spx admin init/join` creates
/etc/spinifex/ca.pem, so the wiring can't live there. Use a stage-and-
rename pattern so concurrent test invocations don't race on the symlink.

/etc/ipsec.d/cacerts is on charon's default AppArmor allowlist so no
profile change needed.

Refs: mulga-siv-136

* chore(setup-ovn): drop apt-get install_packages block

OVS/OVN/openvswitch-ipsec/strongswan-charon are baked into the CI gold
image via mulga's image-builder/scripts/provision.sh (commits 116bb05,
a9b3a9c). Keeping a runtime apt-get install path here pulled CI through
apt-cacher-ng on every bootstrap and tripped on intermittent "Message
has been manipulated" InRelease cache corruption (env21/env20 runs).

Drop the install block. Gold-image rebuild is the canonical place to
add new system packages. If a package is missing, downstream ovs/ovn
commands fail loudly during setup-ovn.sh's later steps — clearer signal
than silently re-installing through a flaky proxy.

setup.sh already supports INSTALL_SPINIFEX_SKIP_APT=1 (passed by mulga's
bootstrap-install.sh:237) so this removes the last runtime apt-get call
on the CI provisioning path.

* fix(ovn): gate system-id pin on explicit --node-name

setup-ovn.sh used to fall back to `hostname -s` when --node-name wasn't
passed, and then unconditionally wrote that value to
/etc/openvswitch/system-id.conf + ovs-vsctl external_ids:system-id.

On CI single-node, the gold image already registered a chassis with
name=<UUID>; setup-ovn.sh then rewrote system-id to the bead-themed
hostname (env10-banksia). vpcd's discoverChassis treats any chassis row
whose name != local system-id but hostname == local hostname as a stale
local chassis and skips it, so vpcd reported "no OVN chassis registered
in SBDB" and refused to start, cascading into the e2e + e2e-go failures
on run 26430839508.

Drop the hostname fallback. If --node-name is given, pin both the
on-disk file and the running OVS DB to it (callers that care about
IPsec cert identity matching still get it). If no --node-name is given,
leave the existing system-id alone — that's always safe whether the
value came from a gold-image UUID, bootstrap-install.sh writing nodeN
before this script runs, or a manual ansible value.

* fix(network/reconcile): skip orphan prune on startup; drift owns GC

The vpcd startup reconcile races against the daemon's EnsureDefaultVPC:
the daemon writes SG KV rows + publishes vpc.create-sg on each node in
parallel with vpcd starting up. The leader-gated reconcile can load
intent before any SG row is visible on its local JetStream replica
while subscribers on peer nodes are concurrently creating port groups
from the same vpc.create-sg events. A startup orphan-port-group sweep
then deletes managed sg_* port groups that intent hasn't caught up to.

CI run 26434124790 trace:
  node2 reconcile/lock: elected holder=env20-bottlebrush
  node2 reconcile: starting intent_sgs=0
  node2 reconcile/apply: removed orphan port group pg=sg_161c70ce23e704a2a
  node2 reconcile/apply: removed orphan port group pg=sg_a0d078650a3150391
  node2 reconcile/apply: removed orphan port group pg=sg_54b564b374e9162bb

Every subsequent RunInstances → vpc.create-port failed with
"port group not found" for ~3 minutes until the 5-minute drift tick
recreated the port groups. Tests give up after 60s; six multinode
suites cascade-fail on InsufficientInstanceCapacity.

Split the reconciler into Reconcile (full prune, drift loop) and
ReconcileApplyOnly (no prune, startup). applySGs takes a pruneOrphans
bool. vpcd startup calls ReconcileApplyOnly; drift loop unchanged.
Legitimate orphans get garbage-collected on the next drift tick — at
worst 5 minutes of lingering rows, no production impact.

Add TestReconcile_ApplyOnlyKeepsOrphanPortGroup to lock the contract.

* fix(ipsec): trigger charon rereadcacerts after CA symlink install

strongSwan scans /etc/ipsec.d/cacerts only at charon startup. The unit
openvswitch-ipsec.service is enabled at provision time and starts before
admin init writes the cluster CA symlink, so charon ends up running with
zero CAs loaded. Every IKE_AUTH then fails with `no trusted RSA public
key found for '<peer>'` and no SAs negotiate — the failure mode exposed
by TestMultinodeIPSec.

Append a stroke rereadcacerts call at the end of
installCAIntoCharonTrustStore so an already-running charon picks up the
freshly-placed CA without a service restart. Failure is swallowed at
debug — a missing/stopped charon at admin-init time is expected and a
later startup will scan cacerts naturally.

* refactor(network/host): move IPSec orchestrator from daemon (ADR-0006 S8)

ADR-0006 S8: "IPSec is OVN-native only. No layer implements custom IKEv2,
XFRM rules, or direct strongSwan management. IPSec SA lifecycle is delegated
entirely to OVN native IPSec and is invisible above L0."

The enableOVNIPSec orchestrator and its ensureOVSMonitorIPSecActive helper
lived in daemon/ipsec.go but composed L0 primitives (SetIPSecCertPaths,
EnableIPSecEncapsulation, SetNBGlobalIPSec) that already lived in
network/host. The orchestrator now lives alongside its callees as
host.EnableOVNIPSec, taking configPath and clusterConfig from the daemon at
the call site. Helper and test-override package vars (systemctlActiveTimeout,
ovnNBSocketPath) move with it; tests are translated to call EnableOVNIPSec
directly rather than via the Daemon struct.

Closes mulga-siv-142

* refactor(daemon): use topology.Port for OVN port names (ADR-0006 S4)

ADR-0006 S4 forbids any layer above L2 from constructing OVN object
names. Three daemon sites formatted "port-" + ENIId inline; route them
through topology.Port instead.

Closes mulga-siv-143

* refactor: use topology.Port for OVN port names in handlers + vm (ADR-0006 S4)

Replace inline "port-"+eniID formatting across five sites (eip, instance x2,
vpc/eni, vm/network_helpers) with topology.Port. OVSIfaceID stays as a thin
wrapper to preserve the OVN binding contract comment and existing call sites.

Closes mulga-siv-144

* test(network/invariants): widen scan roots to daemon/handlers/vm/testutil/utils (ADR-0006)

Widen ADR-0006 invariant scan roots so drift in upstack consumers surfaces
in CI. Backlog of violations in widened roots was drained by
mulga-siv-142/-143/-144; this commit makes the wider scan permanent.

  - S2 (host_containment): add vm/ alongside network/ + daemon/
  - S3 (nat_mode):         add daemon/ alongside network/ + vpcd/
  - S4 (names):            add daemon/, handlers/, vm/ alongside
                           network/ + vpcd/; add narrow AWS-ID exemption
                           (vpc-cidr-assoc-) so AWS resource IDs are not
                           misclassified as OVN object names
  - S6 (federation):       add daemon/, handlers/ alongside network/;
                           convert single-root walk to slice
  - S7 (nats_subjects):    add daemon/, handlers/, testutil/, utils/ —
                           stays t.Skip pending cluster-wide subject
                           rename; gap report now lists 58 subjects

CODEOWNERS: add /spinifex/network/invariants/ rule so widening or
narrowing the scan needs explicit owner review.

Closes mulga-siv-145
Refs: audit-adr-0006.md

* chore(e2e): disable IPsec in pseudo multi-node test

Pseudo multi-node runs three simulated nodes as unprivileged tf-user.
The charon trust-store install in 904b45e writes /etc/ipsec.d/cacerts,
which fails with EACCES under a non-root caller and aborts admin init.

Disable --ipsec for this suite — loopback-only nodes don't exercise
real Geneve encryption anyway. Real-multi-node + single-node e2e
(both run admin init via sudo) continue to cover the IPsec path.

* fix(ovn): atomic Ensure* primitives prevent duplicate logical entities

OVN NB has no unique constraint on Name. The previous
GetLogicalRouter→CreateLogicalRouter sequence in EnsureVPC was a TOCTOU
race: when vpc.create on one node and the defensive vpc.create-subnet
EnsureVPC call on another both observed absence, both inserted, yielding
two routers per VPC. The orphan held DNAT references blocking EIP
detach, and DNAT/flow installation landing on the orphan caused
TestMultinodeGuestSSH SSH timeouts.

Add EnsureLogicalRouter / EnsureLogicalSwitch / EnsurePortGroup to the
libovsdb client. Each builds a single OVSDB transaction combining a
wait-op (Until=equal, Rows=[]) gated on absence of a Name match with the
insert. The server serialises the wait+insert atomically; concurrent
callers see exactly one survivor and the rest fall through to a re-fetch
that returns the canonical row.

Switch all production callers (topology.EnsureVPC / EnsureSubnet /
EnsureSGPortGroup, external.AttachIGW, reconcile.applyVPCs /
applySubnets) to the Ensure* variants. Bare Create* remains available to
the L1 wrapper.

Add invariant test TestS4_OVNNamedCreatesUseEnsure (AST walk) to block
future reintroduction of the bare Create* call sites outside
network/ovn.

Mock implements Ensure* under its existing mutex so the concurrency
tests (50/30/30 goroutines for Router/Switch/PortGroup) prove a single
UUID survives.

Refs: mulga-siv-146

* fix(ovn): wait-op rows must be non-empty; flip to NotEqual predicate

The Ensure* primitives sent a wait-op with `Rows: []ovsdb.Row{}` plus
`Until: ==` (RFC 7047 §5.2.6 absence test). The libovsdb Operation
struct tags Rows as `json:"rows,omitempty"`, so the empty slice was
dropped on the wire and ovsdb-server rejected every transaction with
"Required 'rows' member is missing", breaking reconciler bootstrap and
every downstream create-port lookup ("logical switch X not found").

Flip the predicate to the equivalent `Until: !=` formulation with a
single-row projection containing the Name we want absent:

  no row    → projection []           != [{name}] → predicate holds
  row exists → projection [{name}]    == [{name}] → predicate fails

Same atomic guarantee, JSON now well-formed. Mock tests can't catch
this — only a real ovsdb-server validates the envelope.

* chore: remove generateMAC wrapper

* chore: remove all bead / plan references

* fix(network/policy): wait for chassis flows after AddNATGateway

AddEIP fires m.barrier() to block until ovn-northd compiles NB→SB and every
chassis installs the new dnat_and_snat flows; AddNATGateway returned right
after the NB commit. So `aws ec2 create-route … --nat-gateway-id` would
publish vpc.add-nat-gateway, the SNAT row would land in NB, and the API
call would succeed before the gateway chassis had programmed the SNAT flow
for the private subnet.

Phase 8d's 10×5s=50s ping budget races that flow-install latency on slow
CI hosts (last seen run 26455602615 single-node, identical flake on
unmodified dev run 26434711010 — pre-existing, not introduced by Ensure*).

Mirror AddEIP: call m.barrier() after AddNAT returns. Add unit test
TestNATManager_AddNATGateway_FlowsBarrier_Fires covering the new path.

* chore: remove more plan refs

* chore: remove GenerateIPSecPeerCertIfEnabled

* chore: remove dupliate subnetGatewayCIDR function

* chore(network): trim superfluous comments across network/ tree

Collapse multi-paragraph godocs to 1-2 sentences, drop WHAT-restatement
comments, remove section dividers grouping <10 items, and cut prose
narration in tests. ADR clause references, one-line WHY comments for
non-obvious OVN/OVSDB/AWS/NATS semantics, and exported-symbol godoc are
preserved. No behavioural changes.

Packages: invariants, topology, host, external, ovn, reconcile, policy,
subscribers.

* fix(igw): restore attachment State="available" for AWS parity

AWS InternetGatewayAttachment.State is "available" when an IGW is
attached to a VPC, per the AWS SDK doc ("the state is available when
attached to a VPC; otherwise, this value is not returned"). Commit
b7505f8 wrote "attached"/"detached" to align with the reconciler
filter, which inverted AWS parity.

Fix the producer to write "available" again and update the consumer:
 - handlers/ec2/igw: AttachInternetGateway and DetachInternetGateway
   both persist State="available"; IGWRecord comment narrowed.
 - network/reconcile/intent.loadIGWs: filter accepts "available" (was
   "attached"); attachment is identified by VpcId != "".
 - tests updated: igw service_impl_test, routetable service_impl_test
   fixture, reconcile intent_test fixture.

* Update spinifex.toml

* refactor(subscribers): route EIP/NATGW through L5 wrappers (ADR-0006 §S1)

Subscriber previously called policy.NATManager directly for EIP and NAT
Gateway operations, bypassing the L5 wrappers (EIPManager, NATGWManager)
that existed on paper. ADR-0006 §S1 requires every cross-layer call to
pass through the immediate lower neighbor's typed interface; IGW already
followed this pattern.

Wire EIPManager and NATGWManager into the Subscriber struct, construct
them in vpcd alongside IGWManager, and swap the four direct-L3 call
sites (AddEIP, DeleteEIP, AddNATGateway, DeleteNATGateway) onto the
L5 surface (AttachEIP, DetachEIP, AttachNATGateway, DetachNATGateway).
AttachEIP now fires the flows-barrier, giving callers the same
"reachable on return" guarantee IGW already had.

The reconciler still calls policy.NATManager directly; that's a
cross-cutter and a separate concern.

* test(reboot): port 8MB WAL pad workaround from bash run-reboot-e2e.sh

App VMs' user-data writes (unit file + index.html) sit in the on-host
WAL until WriteWALToChunk crosses the 4MB threshold. Host reboot before
that loses them — systemd then refuses to load the zero-byte unit and
the responder never starts. Mirror the bash driver's dd+sync pad so the
WAL flushes pre-reboot. Drop when the upstream WAL fix lands.

* test(e2e): dump OVN/OVS/SG state on Phase 8d NATGW failure

Phase 8d's existing failure path only prints lr-nat-list and
lr-route-list. CI artifacts are not uploaded when run-e2e.sh exits
nonzero, so debugging the NATGW regression requires inline diagnostics
in the journal. Expand the dump to include nb/sb show, chassis bindings,
port_group ACLs, br-int flows filtered to the NATGW IP/private subnet,
host routes, and the private VM's own ip/route/neigh state.

* fix(ovn,reconcile): scope NAT deletes to router; NATGW snat per associated subnet

Two compounding bugs caused Phase 8d NATGW E2E flake (~60% CI failure):

1. ovn.DeleteNAT/DeleteNATByExternalIP searched all NAT rows globally and
   only mutated the named router afterwards. With AWS subnet CIDRs repeating
   across VPCs, (type, logicalIP/externalIP) is not globally unique — the
   delete picked a NAT row owned by a different router, the mutate was a
   no-op, and the row delete failed with "remaining reference(s)", leaving
   the original router's NAT orphaned. Both calls now resolve the LR first
   and filter NAT lookups to lr.NAT UUIDs. Mock client mirrors the scoping.

2. reconcile.loadNATGWs used the NATGW's home (public) subnet CIDR as
   SubnetCIDR, producing an snat for the wrong CIDR that collided with the
   correct event-published snat. loadNATGWs now walks route tables and
   emits one NATGWSpec per (NATGW, associated private subnet) pair,
   mirroring publishNatGatewayEventsForAssociation. Intent map keyed by
   natgwID|subnetCIDR so multi-subnet NATGWs produce distinct specs.

Tests:
- nat_test.go: cross-router same-logicalIP isolation for DeleteEIP and
  DeleteNATGateway
- intent_test.go: NATGW snat uses associated private subnet CIDR, not the
  NATGW home subnet; NATGW with no associations produces no spec

---------

Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-27 21:05:01 +10:00
Josh Siviour 050ac8f861 feat(e2e): Go E2E harness + single-node migration to Ensure*/Discover* + reboot suite (#241)
* feat(e2e): add Go test harness + cert scenario, port run-cert-e2e.sh

Lands step 1 of docs/development/improvements/e2e-go-harness.md:
shared harness primitives (env, wait, artifacts, cert, tls) plus the
TestCertIssuance scenario that supersedes run-cert-e2e.sh. All files
gated by //go:build e2e so default builds and `make preflight` are
unaffected. Bash script stays in place for one CI cycle before deletion.

* feat(e2e): port run-lb-e2e.sh to TestLoadBalancer Go scenario

Step 3 of docs/development/improvements/e2e-go-harness.md. Replaces 1246
lines of bash with a typed Go scenario exercising all 4 LB variants
(ALB/NLB × internet-facing/internal) against a shared VPC + app instance
pool. Internet-facing phase auto-skips when no peer node is configured;
internal phase always runs.

Adds shared harness primitives:
- harness/aws.go: AWS SDK client factory wired to Spinifex AWS gateway
- harness/lb.go: WaitForLBActive, WaitForTargetsHealthy, WaitForENICleanup,
  WaitForInstanceRunning/Terminated, InstanceENI
- harness/traffic.go: HTTPRoundRobin, TCPRoundRobin, distribution asserts
- harness/ssh.go: minimal peer SSH wrapper shelling out to /usr/bin/ssh
  (replaced by typed x/crypto/ssh client hoisted from DDIL in mulga-siv-74)

User-data scripts embedded via //go:embed under lb/testdata/.

Bash script stays in place for one CI cycle before deletion.

* fix(e2e): log client launch params + dump daemon log on InvalidInstanceType

Adds diagnostics for the LB internal client launch path. Logs the exact
InstanceType, AMI, subnet, key name, and user-data size right before
RunInstances, and registers a daemon-log dump on failure so we can
correlate the 400 InvalidInstanceType with what spinifex saw.

* refactor(e2e): serialise LB suites + share probe client across internal tests

Splits TestLoadBalancer into 4 sequential subtests (ALB/NLB ×
internet-facing/internal). Each suite creates its own TG + LB + listener,
runs its traffic test, and tears down before the next starts. Replaces
the per-suite throwaway client VM with a single long-lived probe server
launched at fixture setup; per-suite internal traffic tests GET
/probe?ip=...&proto=... against it instead of cold-booting a fresh client.

Drops peak instance count from 5 to 4 (2 app + 1 LB + 1 client), so the
internal phase passes on capacity-constrained dev nodes that previously
hit InvalidInstanceType (see mulga-siv-76, mulga-siv-77 for the underlying
spinifex bugs).

* feat(daemon): make hostReserve env-configurable for dev capacity squeeze

Adds SPINIFEX_RESERVED_VCPU and SPINIFEX_RESERVED_MEM_GB env-var overrides
read at daemon startup. Invalid values are logged and ignored so a typo
doesn't silently widen the reserve. Unblocks the LB E2E on capacity-tight
dev hosts (8 vCPU box: default 2-vCPU reserve + 3x t3.nano test load
leaves 0 free for the sys.micro LB VM). Lays groundwork for the planned
operator-tunable [capacity] config.

bead: mulga-siv-78

* fix(e2e): revert LB to per-suite client + fail-fast on terminal LB state

Reverts the shared probe-server VM that wouldn't come up reliably during
the InternalFacing suites — back to the proven bash pattern of launching
a fresh client VM per suite (one of ALB_IP / NLB_IP populated based on
the suite kind). Adds renderClientUserData + a conditional template so
each client probes only its target LB.

WaitForLBActive now bails immediately on terminal LB states (failed,
provisioning_failed) and emits a "still waiting" log line every 30s so
hangs are visible instead of silently burning the full 5-minute timeout.

* ci(e2e): add e2e-go.yml workflow + GOTESTSUM Makefile opt-in

Standalone workflow runs the new Go-based E2E harness on the existing
ci-single self-hosted runner — provisions a tofu VM, bootstraps spinifex,
installs gotestsum, runs each requested suite via the e2e Makefile, and
uploads per-test artifacts + JUnit XML + JSON streams as a build artifact.
Test summary parses the JUnit and emits ::error file=...:: annotations so
failures surface in the PR diff.

Gated to the feat/e2e-go-harness branch + workflow_dispatch so it doesn't
interfere with the existing e2e.yml while the Go conversion lands.

tests/e2e/Makefile gains a GOTESTSUM=1 opt-in: when set, the e2e target
runs gotestsum with --junitfile and --jsonfile rooted in ARTIFACT_DIR
under a suite-tagged filename derived from the first PKG component.

* feat(e2e): share probe client across both internal LB suites + SG fixes

- Replaces per-suite probe client with a single shared VM launched in the
  fixture. Saves ~60s by avoiding a cold boot between the ALB and NLB
  internal suites.
- Client cloud-init exposes a JSON trigger endpoint on :9090 in addition
  to the http.server on :80 (results dir). Each suite POSTs
  {proto, ip, count, outfile, http_port, tcp_port} to run a probe loop
  against the LB private IP and writes the result file under /tmp/httpd
  for the driver to fetch back.
- configureDefaultSG now uses the structured IpPermissions form (top-level
  shortcut is silently ignored by vpcd — tracked separately) and opens
  port 9090 in addition to 80 + 9000.
- deleteLB captures the underlying sys.micro VM id via the LB ENI before
  delete, then polls DescribeLoadBalancers until LoadBalancerNotFound so
  the next suite doesn't race the capacity reclaim.
- Internal_NLB sleeps 15s up front to let the ALB sys.micro deallocate
  fully before its own createLB fires. DescribeLoadBalancers can report
  the ALB gone before the vCPU/memory allocation is actually released on
  capacity-tight dev hosts; without this settle, NLB's createLB trips the
  sys.micro reserve and lands in terminal state=failed. Still cheaper
  than cold-booting a per-suite client.

* ci(e2e): install Go on tofu VM before running Go E2E suites

The tofu-provisioned VM ships with the spx binary and bash test scripts
but no go toolchain, so the prior workflow's "Install gotestsum on VM"
step failed with `bash: line 2: go: command not found`. Add a curl-based
Go 1.26.3 install (matching go.mod) ahead of the gotestsum install and
update the test step's PATH so make e2e-* runs the freshly-installed
go binary.

* ci(e2e): bump gotestsum to @latest for Go 1.26 compatibility

gotestsum v1.12.0 pins golang.org/x/tools v0.19.0 which fails to compile
under Go 1.26 with:
  invalid array length -delta * delta (constant -256 of type int64)
Switching to @latest pulls a newer x/tools that builds clean.

* ci(e2e): ship spinifex source to VM so make -C tests/e2e can run there

Single-node tofu VM only has the spx binary + bash test bundle at
~/e2e-tests; the Go e2e workflow was running 'make -C tests/e2e e2e-cert'
from $HOME/Development/mulga/spinifex which doesn't exist, producing:
  make: *** No rule to make target 'e2e-cert'.  Stop.

Add a checkout of the spinifex repo on the runner and rsync it (minus
.git, build/, bin/, node_modules) to the VM as
~/Development/mulga/spinifex before installing Go + gotestsum.

Interim solution; mulga-siv-80 tracks replacing the rsync with shipping
pre-built test binaries from the runner.

* ci(e2e-go): replace rsync with tar-over-ssh; VM lacks rsync

bootstrap-install.sh ships a minimal binary VM with no rsync; the
ship-source and collect-artifacts steps failed with
'rsync: command not found'. Stream a tarball over ssh instead — same
exclude list, no extra dependency on the VM.

* fix(e2e): discover single-node IPs + awsgw bind in LoadEnv

run-cert-e2e.sh single mode probes awsgw.host from spinifex.toml and
adds every non-loopback global IPv4 to the node list. The Go harness
defaulted to 127.0.0.1 only, so CI on a tofu VM (awsgw bound to the
LAN IP, not loopback) saw 'connection refused' on every TLS check.

Port the same discovery: ModeSingle now walks net.InterfaceAddrs for
NodeIPs and grep-parses spinifex.toml for the awsgw bind. Both stay
overridable via SPINIFEX_NODE_IPS / SPINIFEX_SERVICE_IPS.

* ci(e2e-go): pre-build test binaries on runner; drop go toolchain from VM

Replaces the source-tree + go + gotestsum payload that the e2e-go.yml
workflow previously shipped to the tofu CI VM with self-contained
test binaries built on the runner via `go test -c`. testdata for the
LB suite is already //go:embed'd into the binaries, so the bundle on
the VM is just `<suite>.test` files (cert: 14M, lb: 17M uncompressed).

Workflow changes
- actions/setup-go on runner.
- New step builds binaries via `make e2e-build SUITES_BUILD=...`.
- Install go-junit-report on runner; convert each VM-side test log to
  junit-<suite>.xml after artifact pull so "Publish test summary" still
  parses JUnit XML.
- Drop "Install Go + gotestsum on VM" entirely (saves ~60s + network).
- Drop source-shipping rsync/tar (saves ~30s + a lot of bandwidth).
- VM step runs `./<suite>.test -test.v -test.timeout=30m` directly.

Makefile changes
- New `e2e-build` target compiles each `SUITES_BUILD` entry into
  `BIN_DIR/<suite>.test` via `go test -c -tags=e2e`. `clean` also
  removes BIN_DIR.

Closes mulga-siv-80.

* ci(e2e-go): disable setup-go cache; upload dominates self-hosted run time

* refactor(e2e): hoist generic DDIL harness primitives to shared tests/e2e/harness

Step 1.5 of docs/development/improvements/e2e-go-harness.md. Promotes
Cluster/Node/SSH/DaemonClient/LocalStateRevision/ResetAllNodes/Witness
into the shared package so the LB, multinode, and BM scenarios in
steps 4-5 can build on the same primitives instead of duplicating SSH
+ cluster plumbing.

Moves
- ddil/harness/{cluster,ssh,daemon_client,state,reset,witness}.go
  -> harness/ (plus the witness testdata embed dir).
- ddil/harness/{fault,netem,profiles,snapshot}.go -> ddil/fault/ as a
  new package; fault.PartitionNode/HealNode/KillNATS/StartNATS/
  RestartDaemonOnly/ApplyNetem/ClearNetem/LinkProfile*/TakeSnapshot
  now take harness.Cluster / harness.Node / harness.SSH explicitly.
- ddil/harness/retry.go::Run retry-with-reset extracted as
  harness.RetryWithReset in harness/wait.go; ddil/harness/quarantine.go
  keeps the DDIL-only quarantine wrapper that calls into it.
- WaitForMode moved from fault.go to harness/daemon_client.go since
  it polls daemon state and has no fault-injection concern.
- The pre-hoist harness/ssh.go (PeerSSH, subprocess ssh) is preserved
  as harness/peer_ssh.go so the lb_test suite keeps working unchanged;
  the typed x/crypto/ssh client from DDIL now owns harness/ssh.go.
- New harness/util.go exports ShellQuote (was unexported shellQuote in
  netem.go) so both ddil/fault and reset.go/witness.go can use it.

Env var rename
- SPINIFEX_NODES / SPINIFEX_SSH_USER / SPINIFEX_SSH_KEY replace
  DDIL_NODES / DDIL_SSH_USER / DDIL_SSH_KEY. Old names still honoured
  with a stderr deprecation note (envWithLegacy) so the DDIL CI keeps
  passing until .github/workflows/e2e-ddil.yml drops the old names.
  DDIL_QUARANTINED stays DDIL-specific.

DDIL parity preserved — scenarios still call ddilh.Run for quarantine
semantics; the underlying retry-with-reset is the same code path,
just shared.

Closes mulga-siv-74.

* fix(e2e): open default SG :22 ingress via structured form before witness launch

The daemon currently drops the top-level shortcut AuthorizeSecurityGroupIngress
form silently (mulga-siv-79), so the default SG starts with no inbound :22 and
the witness EIP awaitBaseline tunnel times out under DDIL CI. Mirror the
workaround already present in lb_test by issuing the structured IpPermissions
form before LaunchWitnessVM. Idempotent on InvalidPermission.Duplicate so reruns
in the same cluster are safe.

* ci: render JUnit into job summary + collapsible groups in noisy steps

Add a reusable composite action (.github/actions/junit-report) that parses
a glob of JUnit XML files, writes a totals table + collapsible per-failure
details into $GITHUB_STEP_SUMMARY, and emits one ::error annotation per
failed/errored test case so failures surface in the run header instead of
being buried in step logs.

Apply the action to both e2e-go.yml and e2e-ddil.yml (e2e-ddil gotestsum
now also emits --junitfile so the action has input).

Same workflows also wrap noisy phases (tofu apply/destroy, scrub-env,
tar-over-ssh, per-suite test runs, cluster-log collection) in
::group::/::endgroup:: markers so the Actions UI collapses the chatter
by default and the human can expand only the failing phase. Tofu output
still tees to $TOFU_LOG so the artifact bundle is unchanged.

Future workflows (multinode, baremetal) can adopt the same pattern via
the README in .github/actions/junit-report.

* test(ddil): quarantine Scenario C until predastore §2 lands

CI run 26016840819 surfaced that ScenarioC_CleanPartition silently
depends on predastore-ddil-hardening §2 (write-quorum with deferred
parity repair). Root cause from node3 daemon log:

  predastore: write udp ...->192.168.0.22:9991: sendmsg: operation not permitted
  predastore: putObjectViaQUIC: put failed
  spx QMP: BLOCK_IO_ERROR instanceId=<witness>
  spx: StopOrTerminateInstance: Terminating
  spx: Instance state transition running → shutting-down

The 'sendmsg: operation not permitted' is the EPERM Linux returns when
iptables drops an outbound packet — exactly the OUTPUT DROP rules
PartitionNode installs. The witness VM's block I/O is backed by
distributed predastore, so the same partition that severs node3↔peer
NATS traffic also severs the witness disk's cross-shard QUIC writes.
QEMU power-downs the guest after BLOCK_IO_ERROR, the daemon's
reconciler terminates the instance, and the next AssertProgressed dials
a sshd that's already gone (hence the 'handshake failed: EOF' the test
reports).

Quarantine by collapsing C to the same scenarioSkip stub D/E/F already
use, citing predastore-ddil-hardening §2 as the dependency. Flip C
back to ENABLED in the same PR that lands §2 — that's when the witness
disk can survive a peer partition without QEMU halting.

TEST_COVERAGE.md row C flipped ENABLED → SKIPPED; added a quarantine
note explaining the predastore dependency.

* ci: colored phase banners + quiet-on-success tofu output

Pretty up the live Actions log without flooding it. Three changes:

1. .github/scripts/ci-fmt.sh — sourceable bash helpers (banner, ok, bad,
   warn, note, quiet_run) using ANSI colors that the Actions UI renders.
2. e2e-go.yml + e2e-ddil.yml — tofu phases switch back to quiet redirect
   into $TOFU_LOG (the previous tee'd version regressed live noise vs
   the original silent redirect). Each phase now prints one colored
   ok/✗ line; on failure the tail of $TOFU_LOG dumps inside a single
   collapsed group, so the live log stays scannable but the failing tail
   is one click away.
3. junit-report render.py — compact summary: short status icons (✅/❌)
   in a single column, no separate err column, single-line failure list
   (truncated to 120 chars) instead of <details> blocks that wrap badly
   on narrow screens.

Per-suite test runs still wrap in ::group:: with an inline cyan banner
so pass output collapses and the suite name is obvious when expanded.

* test(e2e): add harness.Phase / Step / Detail for human-readable test output

Test fixtures were emitting flat walls of t.Logf with every line prefixed
by the same `lb_test.go:61:` (file:line of the call site that the
t.Helper() chain bubbles up to) and forced 6-space indent. Hard to skim,
hard to find where one phase ends and the next begins.

Add three helpers in tests/e2e/harness/log.go:

  Phase(t, fmt, ...)   — flush-left colored banner via fmt.Println.
                         Bypasses t.Logf so the line lands without the
                         testing framework's indent/file:line prefix.
                         Pure visual structure; the diagnostic detail
                         stays on t.Logf.
  Step(t, fmt, ...)    — single-line sub-phase marker via t.Logf.
                         Keeps file:line attribution so a triager can
                         find the source of a progress message.
  Detail(t, k, v, ...) — key=value resource IDs via t.Logf. Same
                         attribution rationale as Step — IDs are
                         diagnostic and want to land in JUnit XML
                         bucketed with the owning test.

Why the split: Phase is purely a banner; losing JUnit attribution costs
nothing because banners aren't diagnostic. Step/Detail carry triage
context (instance IDs, AMI, etc.) and a failing test's JUnit testcase
node should still contain them, so t.Logf attribution is kept.

ANSI colors are auto-disabled when stdout isn't a TTY and GITHUB_ACTIONS
isn't set, so piped local `go test -v` stays clean.

Apply to lb_test setupSharedFixture — five Phase banners replace the
implicit run of helper logs ("Discovering cluster capacity",
"Ensuring SSH key pair", "Creating shared VPC topology",
"Launching app instances", "Launching probe client") so the live CI log
reads as a story rather than a flat resource dump.

* ci(e2e-go): strip indent + file:line on display, propagate color env over SSH

Two display bugs spotted on the previous run:

1. Phase banners came out white instead of bold-cyan because the test
   binary runs over SSH on the tofu VM, where neither GITHUB_ACTIONS
   nor a TTY is present, so harness.log.go init() disables ANSI escapes.
   Fix: export GITHUB_ACTIONS=true and FORCE_COLOR=1 in the remote env
   so the helper keeps emitting color codes.

2. Every t.Logf line still rendered as
       lb_test.go:61: instance type: t3.nano
   (4-space indent + `<file>:<line>:` prefix) because t.Helper()
   bubbles file:line to the call site. Fix: pipe the test binary's
   stdout through `sed -E 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'`
   so the display strips both. Tee to the artifact log happens BEFORE
   the sed so raw output is preserved for triage; only the live UI
   sees the cleaned form.

* feat(e2e-harness): extend harness for single-node Go suite port

Stage A of mulga-siv-89 — add the primitives the single-node run-e2e.sh
port consumes:

- AWSClient gains IAM + STS service handles, plus NewAWSClientWithCreds
  for per-tenant account-scoping in Phase 8.
- awserror.go: ErrorCodeIs, AssertAWSError, ExpectError — Go equivalent
  of the bash expect_error helper used throughout the negative phases.
- poll.go: WaitForInstance/Volume/Snapshot/Image state to any target,
  with per-resource default timeouts tuned from the bash sleep loops.
- spxcli.go: thin shellouts for spx get nodes/vms, spx top nodes,
  spx admin account create with output parsing for tenant-id/access-key.
- rawhttp.go: signed raw POST against the AWS gateway for the Phase 8
  InvalidAction probe (gateway rejects on auth before action parsing).
- ovn.go: OvnNbctl/OvnSbctl wrappers + SkipIfNoOVN for Phase 8b-e
  datapath checks; WaitForPortGroupMember handles propagation polls.
- ec2helpers.go: InstancePublicSSHHost (public-IP-or-qemu-hostfwd),
  LsblkRootGiB, serial-console-access trio, DiscoverDefaultVPC,
  idempotent AuthorizeSSHIngress.
- account.go: AccountCarousel maps tenant name -> scoped AWSClient,
  built atop NewAWSClientWithCreds for the Phase 8 isolation tests.

Pure groundwork — no new tests, no production behaviour change. The
cert + lb scenarios still compile against the extended harness.

* ci(e2e-go): auto-cancel + reap orphans to prevent runner wedge

A cancelled run on the single-runner ci-single host has been leaving
orphan tofu/terraform procs (and stale tfstate locks) behind, which
caused the next push's tofu destroy to hang for 45m+ and saturate the
runner host until sshd refused new connections.

- Add `concurrency: e2e-go-${ref}, cancel-in-progress: true` so a new
  push to feat/e2e-go-harness cancels the in-flight run instead of
  stacking on the same self-hosted runner.
- New `Reap Orphan Procs` step at job start: SIGTERM then SIGKILL any
  lingering `tofu`/`terraform` from the runner user, and unlink any
  stale `.terraform.tfstate.lock.info`.
- Harden `Teardown`: drop `set -e` (scrub-env must always run as the
  libvirt-level fallback), reap orphans before destroy, and wrap
  `tofu destroy` in `timeout 600` so a wedged provider can't pin the
  job to its 45m timeout.

* ci(e2e-go): drop push trigger, manual dispatch only

Pull the on.push branch trigger so the workflow only fires from
workflow_dispatch — matches the e2e-ddil.yml Phase-1 pattern. Push
trigger lifts again once the single-node Go port (mulga-siv-89) is
green end-to-end and run-e2e.sh is retired.

* test(e2e-single): port run-e2e.sh phases 1-4 to Go

Stage B of mulga-siv-89. Lands the TestSingleNode orchestrator + Fixture
that threads state between sequential phase subtests, plus phases:

- Phase 1: KVM check, AWS gateway TLS reachability, daemon NATS
  readiness polling, region/AZ smoke.
- Phase 1b: spx get nodes / top nodes / get vms (single-node only).
- Phase 2: nano instance-type + arch discovery.
- Phase 2b: serial-console-access default/enable/disable round-trips.
- Phase 3: create-key-pair + import-key-pair lifecycle; test-key-1 PEM
  kept for Phase 5 instance launch (cleanup deferred to teardown phase
  in stage G).
- Phase 4: architecture-aware AMI discovery via name filter
  ami-ubuntu-24.04-{x86_64,arm64}, falls back from spx import output.

Phase ordering is contractual (Phase 5+ depend on AMI, key pair, AZ).
detectPoolMode reads [network] external_mode from spinifex.toml so
stages F/G can skip pool-only phases on dev_networking fixtures.

No production code changed. cert + lb scenarios continue to compile.

* test(e2e-single): port run-e2e.sh phases 5-5e to Go

Stage C of mulga-siv-89 — the instance/volume/snapshot/image lifecycle
that everything else hangs off:

- Phase 5: run-instances (default VPC + default SG with tcp/22 opened
  via idempotent harness.AuthorizeSSHIngress), wait running, resolve
  root volume from BlockDeviceMappings.
- Phase 5a-pre: spx get vms now contains the running instance.
- Phase 5a: describe-instances metadata round-trip (type/key/AMI/BDMs).
- Phase 5a-ii: TCP wait, ssh id, lsblk cross-check vs API root volume
  size (±1 GiB tolerance — lsblk rounds down), soft hostname check.
- Phase 5a-iii: get-console-output round-trip on InstanceId.
- Phase 5b: create-volume (10 GiB) → modify-volume (20 GiB) →
  attach-volume → detach-volume → delete-volume, polling each state
  transition through harness.WaitForVolumeState.
- Phase 5b-ii: describe-volume-status on the root volume.
- Phase 5c: create-snapshot → wait completed → copy-snapshot → both
  visible → delete original → copy survives → delete copy.
- Phase 5d: AMI snapshot-backed launch verification via
  DescribeImages.BlockDeviceMappings[].Ebs.SnapshotId. Predastore-side
  config.json reads are TODO (no S3 client in harness yet).
- Phase 5e: create-image --no-reboot, wait available, capture backing
  snapshot ID for Phase 9 cleanup.

Phase 7a (attach to stopped → error) stubbed in volume_test.go for
Stage D to fill once the lifecycle stop/start lands.

* test(e2e-single): port run-e2e.sh phases 5f-8 to Go

Stage D of the single-node Go port (mulga-siv-89). Adds four files
under tests/e2e/single/ covering Phase 5f (SG egress ACL enforcement),
Phase 6 (tag CRUD), Phases 7 / 7a / 7b / 7c-pre / 7c (stop, start,
attach-to-stopped negative, ModifyInstanceAttribute, reboot, multi-count
RunInstances), and Phase 8 negatives (18 sub-tests asserting AWS error
codes via harness.ExpectError / AssertAWSError / PostAWSAction).

Wired into TestSingleNode in main_test.go; phase ordering preserved
because Phase 7 chain mutates the shared instance state (stopped at end
of 7, .small after 7b, running after 7c-pre, untouched by 7c siblings)
and Phase 8 negatives need a running instance with the AMI/key created
earlier.

Removes the placeholder phase7a_AttachToStoppedError stub from
volume_test.go (real impl now in lifecycle_test.go).

run-e2e.sh stays in tree until step-7 cleanup per the parent plan.

* ci(e2e-go): wire e2e-single target + add single to build set

Lets the in-progress single-node Go port (mulga-siv-89, Stages A-D
landed) be exercised in CI via explicit `suites: e2e-single` dispatch
input ahead of Stage G flipping the workflow default.

Workflow default suites unchanged until Phase 9 teardown lands.

* test(e2e-single): port run-e2e.sh IAM 1-7 + Phase 8 account scoping to Go

Stage E of the single-node Go port (mulga-siv-89). Adds two files
under tests/e2e/single/:

- iam_test.go (903 lines): IAM Phases 1-7 — User CRUD, Access Key
  Lifecycle, User Authentication (via harness.NewAWSClientWithCreds),
  Policy CRUD (+ CreatePolicyVersion / SetDefaultPolicyVersion /
  DeletePolicyVersion which bash lacks), Policy Attachment &
  Enforcement (allow / deny / wildcard / root bypass), Policy Lifecycle
  (DeleteConflict while attached), and idempotent Cleanup.

- account_test.go (1186 lines): Phase 8 EC2 Account Scoping — 11 Steps
  (Account Setup, Instance / Volume / KeyPair / Snapshot / VPC-Subnet /
  IGW-EIGW Scoping, Account Settings, Global Resources, Edge Cases,
  Cleanup). Uses harness.AccountCarousel for the multi-profile
  credential map and SpxAdminAccountCreate for admin account
  bootstrap.

Fixture extended with IAM cross-phase state (IAMAdminAccount,
IAM{Alice,Bob,Charlie}{KeyID,Secret}) so Phase 7 cleanup is defensive
even when an intermediate IAM phase short-circuits. All 11 Phase 8
Steps share local state in the outer function; t.Cleanup mirrors
Step 11 LIFO so a mid-Step failure still tears down accounts.

Wired into TestSingleNode. run-e2e.sh stays in tree until step-7
cleanup per parent plan.

* fix(e2e-harness): use kebab-case is-default filter for DescribeVpcs

AWS EC2 DescribeVpcs filter names are kebab-case (is-default), and the
daemon's filter allowlist enforces that. The harness sent the camelCase
SDK field name (isDefault), so the daemon returned InvalidParameterValue
400 and DiscoverDefaultVPC fataled — leaving Phase 5 InstanceID empty
and cascading 16 downstream Single-Node E2E subtests to fail.

* test(e2e-single): port run-e2e.sh phases 8b-8e to Go

Stage F of the single-node Go port (mulga-siv-89). Adds three files
under tests/e2e/single/:

- vpc_test.go (553 lines): Phase 8b (pool-mode VPC public/private
  subnet E2E — CreateVpc, dual subnets with MapPublicIpOnLaunch
  assertions, IGW attach, custom route table + 0.0.0.0/0 route,
  instance launch + SSH + external connectivity probe) and Phase 8c
  (route table validation — main RT association, local + IGW route
  presence, custom RT CRUD + association/disassociation, negative
  delete-local + delete-main assertions).
- natgw_test.go (467 lines): Phase 8d (pool-mode NAT GW E2E —
  CreateNatGateway with EIP, private subnet route via NAT, bastion-
  hopped SSH into private VM, baseline egress drop / post-NAT egress
  succeed / post-teardown egress lost). Inline waitForNATGatewayState
  helper bounds the pending->available->deleted state machine via
  harness.EventuallyErr.
- datapath_test.go (326 lines): Phase 8e (OVN-gated SG-to-SG datapath
  — two SGs with UserIdGroupPair ingress rule, OVN port-group
  membership + SB address-set wait loops, allowed traffic curl,
  denied traffic nc, revoke->drop, re-add->restore).

Pool-mode gating via fix.PoolMode for 8b/8d; OVN gating via
harness.SkipIfNoOVN for 8e. Per-phase t.Cleanup LIFO ensures
teardown unwinds even on partial failure. Wired into TestSingleNode.

* test(e2e-single): port run-e2e.sh phases 9, 9a, 9b to Go

Stage G of the single-node Go port (mulga-siv-89) — final teardown +
post-teardown verification. Adds tests/e2e/single/teardown_test.go
(226 lines):

- phase9_Teardown: DeleteSnapshot(CustomAMISnapID) -> DeregisterImage
  (CustomAMIID) -> TerminateInstances(InstanceID) + wait terminated ->
  DeleteKeyPair(KeyName). Each call swallows *NotFound via
  harness.ErrorCodeIs; unexpected errors logged as warnings so 9a/9b
  always run.
- phase9a_VerifyTeardown: DescribeInstances must show terminated or
  NotFound; DescribeImages must NOT include the deregistered AMI;
  DescribeKeyPairs must NOT include the deleted key. Clears the
  consumed Fixture fields.
- phase9b_FinalClusterStats: harness.SpxGetVMs must NOT list the
  terminated InstanceID; orphan i- rows surface as soft Logf warnings
  to avoid flakes when parallel suites share the node.

Wired into TestSingleNode. With this, mulga-siv-89's single-node Go
port (Stages A-G) is feature-complete; run-e2e.sh remains in tree
until a separate cleanup bead deletes it after CI burn-in.

* ci(e2e-go): unbuffer sed in test pipeline for live log streaming

GNU sed defaults to block-buffered stdout when stdout is a pipe
(GitHub Actions runner). Accumulates 4-64KB before flushing, so
test output appeared in sporadic batches unaligned to test
boundaries. -u flag forces line-buffered output so each go test -v
line surfaces in the Actions UI as it is produced.

Side effect: none. No process count change, no SSH session change,
no test execution change. Pure output flushing on the runner host.

* fix(e2e-single): persist key PEM across phases and skip unimplemented IAM ops

Three bugs blocked the Single-Node Go E2E suite after the DescribeVpcs
filter fix:

1. Phase 3 wrote the SSH PEM into `t.TempDir()`, which Go cleans up when
   the Phase 3 subtest exits. Phases 5a-ii / 5a-iii / 7b / 7c-pre then
   saw "Permission denied (publickey)" / "Identity file not accessible"
   and the cascade left the instance in a state that broke 7c-pre's
   `start-instances`. Move the PEM under a TestSingleNode-scoped
   `fix.TmpDir` so it survives until the whole test exits.

2. IAM Phase 2's `require.NotEmpty(t, fix.IAMAdminAccount, "")` was
   intended as a no-op (Phase 4 populates the field), but require.NotEmpty
   fails on empty regardless of message. Removed the bogus check.

3. The daemon does not yet implement `UpdateUser` (IAM Phase 1) or the
   policy-version family (IAM Phase 4): `CreatePolicyVersion`,
   `ListPolicyVersions`, `SetDefaultPolicyVersion`, `DeletePolicyVersion`.
   They return `InvalidAction` and abort the phase. Wrap each block in a
   t.Run sub-test with t.Skip so the surrounding phase still runs and
   the skips show up explicitly in the test report.

* test(e2e): parallelise hot phases + tune fast-path polls

Squash merge of feat/e2e-parallel-quickwins (mulga-siv-96). Four
quick wins from decomposing run-e2e.sh into Go phase subtests:

- Phase 8 negatives (single/negative_test.go): t.Parallel() inside
  each of the 18 read-only error-path sub-tests. ~5-10x speedup.
- IAM Phase 4 policies (single/iam_test.go): EC2ReadOnly stays
  seeded sequentially (populates fix.IAMAdminAccount); the other
  four policies fan out as siblings under a wrapping
  create_policies_parallel t.Run. ~5x on the Phase 4 sub-step.
- Volume + snapshot fast paths (single/{volume,snapshot}_test.go):
  pass harness.WithPoll(500ms) on the five state transitions that
  finish in <1s on local QEMU. Halves Phase 5b / 5c wall-clock.
- Cert per-IP loop (cert/cert_test.go): t.Parallel() inside the 4
  inner t.Run(ip, ...) blocks. Cuts cert phase by len(nodes).

No harness changes, no Fixture changes, no test-execution-order
changes. mulga-siv-96.

* test(e2e-lb): resolve peer availability before shared fixture setup

setupSharedFixture builds a VPC + 2 app VMs + probe client (minutes
on capacity-tight dev hosts) and previously ran BEFORE the peer
pickup + ssh.Ping. On single-node, the InternetFacing_* subtests
would correctly t.Skip on peer == "" — but only after the fixture
work and once subtests started, so the wait appeared to be attached
to the first subtest name gotestsum printed.

Reorder: pickPeer + ssh.Ping first, log "internet-facing will skip"
immediately, then build the fixture for the internal subtests.
No behavior change for multi-node + peer-reachable; on single-node
the skip rationale surfaces before the fixture wait.

* test(e2e-single): extend SSH wait budget to 3m on slow boot paths

CI run 26034322018 timed out Phase5a_ii_SSH (60s budget) and
Phase8b_VPCSubnetE2E (2m budget) on the TCP-port-open poll while
sshd was still finishing pam/cloud-init. Bump all four wait sites
to 3m as a stop-gap:

- single/instance_test.go phase5aii_SSHProbe
- single/lifecycle_test.go waitForSSHReady (Phase 7b/7c/7c-pre)
- single/vpc_test.go phase8b_VPCSubnetE2E
- single/natgw_test.go phase8d bastion SSH wait

Proper fix tracked under mulga-siv-101 (hoist a harness.WaitForSSH
that probes the actual handshake, not just tcp/22 reachability).

* test(e2e-single): skip-gate IAM3/IAM5/IAM6 until daemon handlers land

CI run 26034322018 surfaced three IAM daemon gaps:

- IAM3_UserAuthentication: alice's freshly-created access key returns
  403 from DescribeInstances — daemon doesn't honour scoped IAM key
  signatures yet.
- IAM5_PolicyAttachmentEnforcement: cascades from IAM3 (alice can't
  sign).
- IAM6_PolicyLifecycle: DetachUserPolicy returns 404 even on
  attachments Phase 5 confirmed — daemon attachment ledger gap.

Skip-gate each phase with t.Skip + a pointer to mulga-siv-100 so the
remaining IAM coverage (IAM1/IAM2/IAM4/IAM7) keeps surfacing
regressions, and the suite as a whole doesn't fail on known gaps.
Flip to real assertions once the daemon handlers land.

* test(e2e-single): probe full SSH handshake (not just TCP port open)

CI run 26036025293 hit "Connection timed out during banner exchange"
on Phase5a-ii / 8b / 8d even with the 3m TCP-port-open budget: tcp/22
opens immediately, but sshd is still finishing pam/cloud-init, so the
first real runSSH call afterwards races the half-open daemon.

Switch waitForSSHReady to issue an actual ssh BatchMode + ConnectTimeout
handshake against the target, retrying until the daemon completes the
banner exchange. Drop the four TCP-only Eventually loops in favour of
the upgraded helper:

- instance_test.go phase5aii_SSHProbe
- lifecycle_test.go waitForSSHReady (Phase 7b / 7c-pre / 7c)
- vpc_test.go phase8b_VPCSubnetE2E
- natgw_test.go phase8d_NATGateway (bastion)

Signature: waitForSSHReady(t, host, port, keyPath). Backwards-compat
alias waitForSSHHandshake exposed for the instance_test caller. Proper
harness hoist tracked under mulga-siv-101.

* test(e2e-harness): dual-emit Step + Detail for live CI streaming

go test's testing framework buffers t.Logf output per-test and releases
it only on PASS/FAIL. Long subtests (Phase8Acct_AccountScoping ~115s,
Internal_NLB ~60s, Phase8b_VPCSubnetE2E 2-3m, Phase8d_NATGateway ~90s)
therefore went silent in CI for the entire subtest duration, even with
sed -u line-buffering in the workflow pipeline.

harness.Phase already wrote to os.Stdout directly for this reason.
Extend the pattern: have Step and Detail dual-emit — write the same
line to os.Stdout (streams live) AND t.Logf (keeps JUnit per-test
attribution). The duplicate on a failure is acceptable; both copies
carry identical content.

mulga-siv-103 (note: numbering skipped past siv-102 which was already
filed in the parent branch).

* test(e2e-harness): drop t.Logf duplicate from Step + Detail

The dual-emit (Fprintln + t.Logf) caused every Step/Detail line to
appear twice after long subtests: once live via stdout, once again
when the testing framework released its per-test buffer at
PASS/FAIL. Most visible after lb subtests (Internal_NLB ~60s) where
the bulk release dumped ~dozens of duplicated lines at once.

Drop the t.Logf copy. go-junit-report parses stdout between
`=== RUN` and `--- PASS/FAIL` markers, so per-test attribution in
JUnit XML is preserved without the duplicate.

Refs: mulga-siv-103

* test(e2e-harness): stream lb-waiter progress live in CI

WaitForLBActive, WaitForTargetsHealthy, WaitForENICleanup, and
WaitForInstanceRunning emitted progress via t.Logf, which the
testing framework buffers per-test. After the previous commit
dropped the dual-emit, that meant every poll loop went silent
until subtest end — visible in CI as 'nlb is active' hanging on
screen for the whole duration of the subsequent traffic check.

Route progress through harness.Step (direct stdout). Also emit a
'waiting for X' Step at the start of each helper so observers
know what the silent stretches actually represent.

Refs: mulga-siv-103

* test(e2e): dump VPC/IGW datapath diagnostics on Phase8b SSH timeout

Phase8b's 3min SSH timeout on a fresh-VPC VM (CI run 26037892507) is
the customer-visible surface of mulga-siv-105 — vpcd's handleIGWAttach
returns before ovn-northd compiles + the gateway chassis installs
flows. Two other cleanup-only bugs (siv-106 missing spinifex:cidr,
siv-107 dnat_and_snat double-delete) showed up in the same triage.

Keep the test hard-failing so the regression stays visible, but dump a
triage bundle on timeout so each occurrence is actionable:

- guest console output (last 4KB — boot + cloud-init state)
- ovn-nbctl show + ovn-sbctl show
- Logical_Router external_ids (confirms siv-106 reproduction)
- NAT rules (confirms siv-105 + siv-107 surface)
- Port_Binding chassis,up (confirms gateway port up state)

Diagnostics go straight to stdout (Step/Detail rationale) so they
stream live in CI rather than dumping at PASS/FAIL.

Refs: mulga-siv-105, mulga-siv-106, mulga-siv-107

* ci(junit-report): drop parent t.Run containers from tally + list skips with reasons

GitHub Actions test summary was counting Go's top-level Test* parent
plus its failing subtest as two distinct failures (the parent is just
a rollup of its children). The summary now de-dupes by dropping any
test case whose name is a strict prefix of another's, so the per-file
counts and the Failures block both match the actual leaf-test
outcome. CI run 26037892507 was reporting '2 failed' for single when
it should have been 1.

Also adds a collapsible Skipped section listing each skipped leaf test
with its t.Skipf call-site reason (the .go:<line>: line from the
CDATA). ANSI escapes from harness.Phase banners are stripped so the
reason is plain text. Reasons are useful triage signal — current run
has 9 skips spread across daemon gaps (mulga-siv-100) and pool-mode
gates (InternetFacing_*).

Refs: mulga-siv-89

* ci(e2e-go): propagate cancel to remote .test procs + nuke VM before destroy

Two related leaks made cancelled runs strand QEMU work on the libvirt host:

1. The long `ssh tf-user@VM "for SUITE in …; do ./$SUITE.test"` ran
   without -tt, so when the runner cancelled the step the local ssh
   died but sshd never sent SIGHUP through (no PTY). The remote
   `.test` binary orphaned and kept running until its own 30m
   `-test.timeout`, holding tmp dirs, NATS connections, and qcow2 file
   handles.
2. Teardown went straight to `tofu destroy` without first killing any
   stragglers on the VM, so destroy raced the .test's open file handles
   on the root disk — sometimes successfully, sometimes leaving the
   libvirt host with a half-released qcow2.

Fix:
 - Allocate a PTY on the run-suites ssh (`-tt`) so sshd HUPs children
   when the local ssh dies.
 - Add a trap (EXIT INT TERM) in the run-suites step that ssh's back
   to the VM and pkill's `/spinifex-e2e-bin/*.test` belt-and-braces.
 - Mirror the same pkill at the top of Teardown so destroy runs against
   a quiesced VM.

Both cleanups are scoped to the per-run wan_ip — safe to land while
multiple ci-single runner agents run concurrent jobs against different
tofu envs on the same host (we deliberately keep that parallelism).

Refs: runner host wedged 2026-05-19 (post-cancel orphans suspected;
no journal access yet to confirm).

* ci(e2e-go): scope orphan reap to GITHUB_WORKSPACE so it spares sibling runners

Reap Orphan Procs (job start) and the in-Teardown pkill block were using
`pkill -u $UID_SELF -x tofu/terraform` plus `find $HOME -maxdepth 6 -name
.terraform.tfstate.lock.info`. The single host runs 5+ self-hosted runners
all as `tf-user`, so every e2e-go run was SIGKILLing in-flight tofu from
sibling workflows (e2e.yml, e2e-ddil.yml, e2e-baremetal.yml, ...) and
deleting their state-lock files — exactly the kind of cross-runner
collateral the reap was meant to recover from.

Scope by `/proc/$pid/cwd` starting with $GITHUB_WORKSPACE; each runner's
workspace dir is unique on the host, so only this run's own tofu procs
match. Find for stale lockfiles is likewise scoped to $GITHUB_WORKSPACE.

* ci(e2e-go): bump setup-go v5->v6, upload-artifact v4->v7

GitHub now warns Node 20 actions are deprecated. e2e-go.yml lagged the
rest of the repo; bump to match versions already in use:

- actions/setup-go@v5 -> @v6   (matches spinifex.yml)
- actions/upload-artifact@v4 -> @v7  (matches release.yml, e2e-ddil.yml)

checkout@v6 and create-github-app-token@v3 are already current across
all workflows. The local junit-report action is `using: composite`
(shell-only), so the Node runtime change does not apply.

* fix(vpcd): add flows-ready barrier after IGW attach (mulga-siv-105)

handleIGWAttach wrote the external switch + gateway port + LRP + default
route to OVN NB and returned immediately, before ovn-northd had compiled
NB -> SB and before ovn-controller on the gateway chassis had installed
the resulting flows. A subsequent RunInstances -> vpc.add-nat could
complete while the datapath was still dark; the launched VM was then
unreachable on its public IP for whatever flow-install latency the
chassis happened to incur. Customer-visible as 'I just created a VPC,
launched a VM, and can't SSH in' for ~minutes.

Reproduced deterministically in CI run 26037892507 Phase8b: a VM in
a fresh VPC was unreachable at 192.168.0.202:22 for 3+ minutes, while
a VM at the same public IP in the warm default VPC (Phase8d) was
reachable in ~40 seconds.

Block at the end of handleIGWAttach on `ovn-nbctl --wait=hv sync`,
which bumps NB_Global.nb_cfg and waits for every connected chassis to
acknowledge the new sequence number — i.e. flows installed. Bounded by
--timeout=30; overruns log a WARN and continue (a 30s OVN stall is
already a deeper problem than failing VPC create would surface).

waitForFlowsHV is a package var so tests can stub it; in environments
without ovn-nbctl on PATH the shell-out fails fast and the WARN path
returns nil, so existing IGW tests stay green without a live OVN.

Refs: mulga-siv-105

* fix(vpcd): store spinifex:cidr on VPC create (mulga-siv-106)

handleVPCCreate built the OVN logical router with only spinifex:vpc_id
and spinifex:vni in ExternalIDs. Cleanup paths (handleIGWDetach + NAT
sweeps) read external_ids['spinifex:cidr'] to scope SNAT removal to
the VPC's own CIDR; when unset, they fell back to an overbroad
10.0.0.0/8 and emitted a WARN.

reconcileVPC already stored the same key at startup; the live-create
path drifted. Add the field to handleVPCCreate, log the cidr alongside
vpc_id, extend TestTopologyHandler_VPCCreate to assert the external_id
is set.

Refs: mulga-siv-106

* fix(vpcd): make handleDeleteNAT idempotent on 'already absent' (mulga-siv-107)

Instance termination fans out through multiple cleanup paths
(instanceCleanerAdapter.ReleasePublicIP AND
VPCServiceImpl.DeleteNetworkInterface), both of which publish
vpc.delete-nat for the same (vpc, logical_ip). The publish is
fire-and-forget (utils.PublishNATEvent uses PublishEvent for
vpc.delete-nat, not RequestEvent), so callers never branch on the
response. The second publisher then triggered a spurious
'ERROR vpcd: failed to delete dnat_and_snat rule ... not found' log
line ms after the first delete succeeded — confusing triage signal
with no actionable consequence.

Add ErrNATNotFound sentinel to the OVN client interface and wrap the
not-found return paths in both LiveOVNClient and MockOVNClient.
handleDeleteNAT matches errors.Is(err, ErrNATNotFound) and treats
that as success (logs INFO 'already absent (idempotent)').

Existing TestTopologyHandler_DeleteNAT_NotFound asserted the opposite
contract; the comment claimed EIP disassociation needed the error
signal, but DisassociateAddress also fire-and-forgets vpc.delete-nat,
so the contract never had a real consumer. Test renamed to
_Idempotent with assertSuccess.

Other failure modes (router missing, OVN unreachable, transact error)
still surface as ERROR + reply with the error string.

Refs: mulga-siv-107

* ci(e2e-go): cluster JUnit failures + name likely root cause (#230)

* ci(e2e-go): cluster JUnit failures + name likely root cause

New composite action `.github/actions/e2e-analyze` runs after
`go-junit-report` converts each suite log. For every `<failure>` it
strips ANSI, normalises dynamic noise (EC2 IDs, UUIDs, IPs, timestamps,
file:line) to a stable signature, picks the earliest non-cascade
failure per suite as the likely root cause, and buckets the rest by
signature. Output is appended to `$GITHUB_STEP_SUMMARY` and written to
`<log-dir>/analysis.md` so it travels with the artifact bundle.

Cascade markers (`must populate fix.`, `Should NOT be empty`,
`Expected value not to be nil`) collapse the typical "one upstream
phase fails, ten downstream `require.NotEmpty` assertions trip" pattern
into a single grouped entry under the real first failure.

The analyser is advisory — it always exits 0. The existing
"Fail job if any suite failed" gate keeps owning exit-code semantics.

Stage 1 of `docs/development/improvements/e2e-go-failure-analysis.md`
(mulga-siv-102). Stage 2 (per-failure log slicing) and Stage 3
(rule-based hints via `tests/e2e/.failure-rules.yaml`, placeholder
seeded here) are independent follow-ups.

CI wiring:
- e2e-go.yml: new "Analyze e2e failures" step between "Publish test
  summary" and the artifact upload, always-runs.
- spinifex.yml: new `make test-actions` step so the golden-file tests
  under `.github/actions/e2e-analyze/testdata/` run on every PR.
- Makefile: `test-actions` target; kept out of `test-cover` so CI-only
  tooling doesn't dilute coverage %.

Golden fixtures: a synthetic cascade-vpcs case (one Phase 5
InvalidParameterValue root + 15 downstream cascades), one real
single-failure case captured from run 26037892507, and a clean
no-failures case.

* ci(e2e-go): per-failure log bundles with time-window slicing

Stage 2 of the e2e failure analyzer. For each failure surfaced by
Stage 1, materialise <log-dir>/analysis/NN-<TestName>/ containing:

  failure.txt — summary (test, start, duration, file-site, error)
  journal.log — spinifex-journal.log sliced to
                [StartAt - 5s, StartAt + Duration + 5s]
  test.log    — test-<suite>.log block between `=== RUN <name>`
                and the matching `--- FAIL` marker
  <copies>    — any files the harness already dropped at
                <log-dir>/<TestName_with_/_replaced>/

Report links each entry to its bundle so a reader on the Actions
page goes straight to the relevant slice.

Workflow now invokes journalctl with --output=short-iso so the
analyzer can parse wall-clock timestamps for the journal window.
Lines without a parseable timestamp inherit the previous line's
classification (keeps multi-line records and stack traces
contiguous).

Render output is unchanged when BundlePath is empty, so the Stage 1
golden snapshots still match.

* ci(e2e-go): restore GOTOOLCHAIN=auto after setup-go v6 bump

setup-go@v6 flipped the GOTOOLCHAIN default from "auto" to "local",
which makes Go refuse to auto-download the toolchain version named
in go.mod. The self-hosted ci-single runner ships /usr/local/go
older than spinifex/go.mod's `go 1.26.3`, and build-install-artifacts.sh
prepends that path so the system Go wins the resolution race. Under
v5 ("auto"), Go silently fetched 1.26.3 on demand; under v6
("local"), the Build Install Artifacts step errors out:

  go: spinifex/go.mod requires go >= 1.26.3 (running go 1.26.1; GOTOOLCHAIN=local)

Re-set the env to "auto" so behaviour matches pre-v6 runs.

* ci(e2e-go): re-export GOTOOLCHAIN=auto after setup-go

Workflow-level env doesn't help: setup-go@v6 writes GOTOOLCHAIN=local
to $GITHUB_ENV, which overrides workflow env in every later step.
Add a dedicated step right after setup-go that appends
GOTOOLCHAIN=auto to $GITHUB_ENV so the Build Install Artifacts step
(which uses /usr/local/go from a stale runner path) can self-heal by
fetching the toolchain named in go.mod, matching pre-v6 behaviour.

* fix(vpcd): add flows-ready barrier after dnat_and_snat add

handleAddNAT wrote the NAT rule to OVN NB and returned immediately,
mirroring the gap siv-105 closed for handleIGWAttach but on the NAT
add path. ec2d/RunInstances then returned success while ovn-northd
was still compiling NB->SB and ovn-controller hadn't installed the
DNAT/SNAT flows on the gateway chassis, so the freshly launched VM
was unreachable on its public IP for whatever flow-install latency
the chassis happened to incur.

Reproduced in CI run 26072432957 Phase8b_VPCSubnetE2E: VM at
192.168.0.212 timed out at 3min while diagnostics showed
Port_Binding up=true for both the VM eni and the chassis-redirect
gateway. SB-side convergence was complete; the missing piece was the
same `ovn-nbctl --wait=hv sync` barrier siv-105 added to IGW attach,
applied at the end of handleAddNAT as well.

Reuses the existing package-var `waitForFlowsHV` so tests can stub
it; no new infra. Parallel unit test asserts the call site fires
exactly once per add-nat.

* ci(e2e-analyze): real-artifact fixes for journal slicing

Three bugs surfaced after Stage 2 ran against real CI artifacts in
run 26072432957:

1. Regex `[+-]\d{4}` rejected the colon-offset form (e.g. `+10:00`)
   that systemd's `journalctl --output=short-iso` actually emits on
   most versions. Added a parser that tries RFC3339 first and falls
   back to the no-colon `-0700` layout. Result: 0-line journal slices
   become populated.

2. go-junit-report's `timestamp` attr on <testsuite> records the
   moment the XML was produced (post-suite), not when the tests
   started. Computing StartAt as `that + cumulative-priors` placed
   every failure AFTER the journal collection ended, so windows
   always missed. Workflow now writes `${ARTIFACT_DIR}/test-<suite>.start`
   right before running each suite; analyzer prefers that file over
   the junit timestamp.

3. Persisted `OffsetFromSuiteStart` on each Failure so the
   suite-start override can recompute StartAt without re-parsing the
   XML.

Unit tests added for the colon-offset journal format. End-to-end
test (TestWriteBundles_EndToEnd) still passes with the new layout.

* ci(e2e-analyze): skip parent testcases when summing suite offsets

go-junit-report emits one <testcase> for the parent Go test AND each
subtest, with the parent's time = sum of its children. ParseFile was
summing every testcase into the suite-cumulative-offset counter, so
every leaf's offset was inflated by ~the parent's total duration.
Visible on run 26073817490 as Phase8b's StartAt landing 13 minutes
after the suite finished collecting logs — beyond the journal window,
so the per-failure journal slice was empty.

Pre-compute the leaf set per suite (a name is a leaf iff no other
testcase name starts with `<name>/`) and only accumulate cumul on
leaves. Golden snapshots updated; the offset for the only failure in
each fixture now matches the actual leaf-sum start time.

* test(e2e-harness): capture IP-targeted datapath state on Phase8b SSH fail

Phase 1 diagnostics for mulga-siv-111. The Phase8b SSH timeout
reproduced on the e2e-failure-analysis branch with siv-105's IGW
barrier AND the new add-nat barrier BOTH firing cleanly, so the
residual gap is downstream of `--wait=hv sync` — most likely stale
OVS conntrack, stale upstream-host ARP, or an OF flow-install miss.

DumpVPCFlowDiagnostics now takes a VPCDiagnosticsOpts carrying the
external IP, logical IP, and per-test artifact dir. When set, the
helper greps `ovs-ofctl dump-flows br-int`, `conntrack -L`, and
`ip -4 neigh show` for the external IP / logical IP and snapshots
`ovn-nbctl find NAT external_ip=…`. Each capture is both printed
inline (existing affordance) and written to a separate file under
fix.Artifacts so the Stage 2 analyzer bundler picks it up alongside
the journal + test-log slices, no analyzer changes needed.

Phase8b's caller now passes pubIP / privIP / fix.Artifacts so the
captures fire on the next failure; updates the t.Fatalf message to
point at siv-111 instead of siv-105 (which is the now-closed parent).

* fix(e2e-single): authorize SSH ingress on fresh-VPC default SG in Phase8b

Phase 8b stands up a non-default VPC and runs an instance there. Its
auto-created default SG denies all ingress, so the SSH probe times out
even when every datapath barrier converges — OF flow captures show the
SYN reaches OVN, gets DNAT'd at table 13, then drops at the ACL between
tables 13 and 17. The bash driver relies on admin init having pre-baked
this rule on the *default* VPC's default SG; a fresh VPC has not.

Add a DescribeSecurityGroups + AuthorizeSSHIngress step between
route-table setup and run-instances, using the existing harness helper.
Fix surfaced via mulga-siv-111's Phase 1 diagnostics (OF flow + ACL
drop counts), invalidating the H1/H2/H3 stale-state hypotheses in the
bead plan doc.

* test(e2e): dual-detect ubuntu image version (24.04/26.04) during gold-image transition

After mulga gold image bumped to v6-fastboot (stages ubuntu-26.04 / resolute
instead of ubuntu-24.04 / noble), all consumers that hardcoded 24.04 break on
the new image. Make them detect at runtime so v3 and v6 envs both pass.

- tests/e2e/run-e2e.sh: DescribeImages filter accepts both
  ami-ubuntu-{26,24}.04-<arch>, then derives IMAGE_NAME from the match.
- tests/e2e/run-pseudo-multinode-e2e.sh: prefer ~/images/ubuntu-26.04.img,
  fall back to ubuntu-24.04.img; pass detected version to spx import.
- tests/e2e/single/image_test.go: walk candidate catalog names (26.04 first,
  then 24.04), stop on the first one DescribeImages can resolve.
- .github/workflows/e2e-baremetal.yml: download resolute (26.04) into
  BM_ISO_CACHE; update BM_UBUNTU_IMAGE env var path.
- docs/terraform-workbooks/{bastion-private-subnet,s3-webapp,nginx-webserver}:
  AMI filter matches `*ubuntu-26.04*` OR `*ubuntu-24.04*` (AWS values=
  array is ORed).

Companion changes for mulga (bootstrap scripts, baremetal, iso pipeline)
land in scripts/tofu-cluster/* and scripts/{tofu-iso,baremetal,iso-builder}/*
under bead mulga-siv-114. Drop the 24.04 fallbacks once all envs are on the
v6 gold image and 24.04 entries are removed from spinifex/utils/images.go.

* feat(e2e): add service-interfaces.yaml manifest + validator

Source of truth for which spinifex services produce/consume which NATS
subjects, and which e2e suites cover which services. Consumed downstream
by the targeted suite selector (Bead 4) to derive subset E2E runs from a
PR diff so single-service changes stop paying the full 25 min suite cost.

- docs/service-interfaces.yaml: 7 services, 4 active suites (cert, lb,
  single, ddil). Subscribes / publishes / depends_on filled from grep
  of NATS subjects in source. spinifexui marked covers_no_suite so
  UI-only diffs skip e2e entirely. Future suites (multinode, reboot,
  tofu, baremetal) commented in until Go ports land.
- tests/e2e/manifest-check: schema + cross-reference validator (path
  existence, subject regex, duplicate detection, suite covers resolves
  to a real service, fixtures references resolve). 8 unit tests cover
  the failure modes plus a load-and-validate on the real manifest.
- Makefile: manifest-check target, hooked into preflight.
- docs/DESIGN.md: link to the manifest under "NATS Messaging".

mulga-siv-116. See docs/development/improvements/e2e-targeted-suite-selection.md.

* fix(e2e): drop catalog-download import path from phase4_Image

`spx admin images import --name ubuntu-26.04-x86_64` with wantErr=true
was meant to detect that bootstrap-install.sh already imported the AMI
and short-circuit on the duplicate exit code. After the v6 gold-image
bump preloaded `~/images/ubuntu-26.04.img` and bootstrap-install.sh
switched to `--file` for the import, two things broke at once:

  - The catalog `--name` path no longer hits a duplicate guard for the
    --file-imported AMI, so the import re-downloads ~857MB and either
    succeeds (failing the wantErr=true assertion) or wedges the job
    timeout while the catalog server crawls along at 16KB/s.
  - `--file` from the test won't work either: the test binary runs as
    `tf-user` over SSH, while bootstrap-install.sh placed the image at
    `/home/spinifex/images/...` (mode 0700 home dir).

bootstrap-install.sh is already responsible for staging the AMI. The
test's job is verifying DescribeImages round-trips by-name and by-ID —
not re-exercising import. Trust the bootstrap-staged AMI and stop
calling import.

Keeps the 26.04→24.04 dual-detect for the v3 gold image transition;
drop the 24.04 candidate when no envs remain on v3.

Refs mulga-siv-115 (caught by the EC2 cold-boot fast-path E2E run on
feat/ec2-coldboot-fastpath).

* ci(e2e-go): default to e2e-single + harden status-report SHA fallback

Two changes to .github/workflows/e2e-go.yml:

1. Default `suites` input now includes e2e-single so a no-input
   manual dispatch exercises the full single-node ladder. Single-node
   Go harness is greenable now (image_test fix at 10eb38a).

2. Report E2E (Go) Status job no longer gated on `inputs.sha` and
   falls back to `github.sha` when the input is unset. Previously a
   manual dispatch without a PR head SHA either skipped the status
   post entirely or hit `gh api repos/.../statuses/` with an empty
   path component (the 'invalid hash' error) — tests passed, the
   report step failed silently. Now status posts on every dispatch
   against the dispatched ref's commit when no explicit SHA given.

Closes mulga-siv-112.

* feat(e2e): add Ensure* fixture API with memoization + cleanup

Introduces tests/e2e/harness/fixtures.go: the memoized, cleanup-aware
provisioning helpers that Bead 3 (single-fixture-migration) will rewrite
phase tests around.

Each Ensure* helper:
- takes (t *testing.T, fx *Fixture, args...)
- memoizes by a key derived from args (sync.Mutex + singleflight)
- registers t.Cleanup on the Fixture's parent test so memoized resources
  survive child-subtest lifetimes
- bakes readiness polling into the helper (no sleeps in callers)

Exported helpers: EnsureKeyPair, EnsureAMI, EnsureDefaultVPC,
EnsureSubnet, EnsureSG, EnsureInstance, EnsureVolume, EnsureSnapshot,
EnsureNATGateway, EnsureLoadBalancer.

Fixture stores ec2iface.EC2API + elbv2iface.ELBV2API so unit tests can
inject fakes without building a real AWSClient (TLS + env). Compile-time
checks guard that the SDK concrete types satisfy the stored interfaces.

Manifest fixtures: block populated — Bead 4 selector will resolve test
coverage from AST-scanned Ensure* call sites via this map.

Makefile: new test-harness target runs the harness unit tests under
-tags=e2e (mocked, no infra). Hooked into preflight so signature drift
and memo/cleanup regressions surface locally.

Bead 2 of docs/development/improvements/e2e-targeted-suite-selection.md.
No existing tests modified — fixture API is net-new.

* feat(e2e): port run-reboot-e2e.sh to Go

Adds tests/e2e/reboot/reboot_test.go — Go port of the single-node host-reboot
resilience driver (cell #18). Runs from the GitHub Actions runner (not
shipped to the VM like cert/lb/single) because systemctl reboot kills any
in-VM process mid-step.

All AWS API traffic crosses the WAN to https://WAN_IP:9999 via the SDK;
SSH-level ops (reboot, ovn-nbctl, journalctl, HTTP burst) hop via
harness.PeerSSH. Trust path tries to SCP the cluster CA off the VM and
falls back to InsecureSkipVerify with a log message if SCP fails — the
cert suite already validates real trust, so the fallback is intentional.

Harness changes:
- env: WANHost field sourced from SPINIFEX_WAN_IP, distinct from
  ServiceIPs[0] (which is the awsgw bind IP, often loopback in-VM).
- aws: SPINIFEX_AWS_INSECURE=1 escape hatch when the runner has no CA on
  disk. Used as the fallback when CA fetch fails.
- cert: SPINIFEX_CA_CERT explicit path override so the reboot test can
  point ResolveCACert at the SCP'd tmp file.
- peer_ssh: IsReachable() probe wrapper for cleanup paths that must skip
  rather than fatal when the host is mid-reboot.

Workflow (.github/workflows/e2e-go.yml):
- New e2e_go_reboot job, opt-in via `e2e-reboot` in the SUITES dispatch
  input. needs: e2e_go_single to serialise on the ci-single self-hosted
  runner. Fetches AWS creds from the VM's ~/.aws/credentials over SSH
  (mirrors the cred hop already proven by e2e-ddil.yml).
- e2e_go_single filters 'reboot' out of its build + run loops so the
  in-VM driver doesn't try to ship reboot.test to the VM and reboot it
  from inside.
- e2e_go_status treats skipped reboot as success.

Refs mulga-siv-118. Plan: docs/development/improvements/e2e-go-reboot-port.md
(in the mulga parent repo).

* ci(e2e-go): run reboot job in parallel with single

Drop `needs: e2e_go_single` from e2e_go_reboot. Both jobs target the
same [self-hosted, ci-single] label, so GitHub Actions serialises on
runner allocation rather than blocking via job ordering — parallel-by-
spec, queued-by-runner. Lets a reboot-only dispatch start immediately
instead of waiting for an unrelated single-job pre-condition.

* fix(e2e/reboot): wait for both backends to serve before post-reboot burst

Phase 8.6 previously probed until ANY HTTP response from the ALB, then
fired a 20-req burst. Post-reboot, only one of the two backends may be
serving (the second is still bringing its responder online) while the
daemon's TG cache still shows pre-reboot 'healthy' for both — so
WaitForTargetsHealthy returns in ms instead of seconds, the ANY probe
matches the first-up backend, and the burst lands 20/0 instead of 10/10.

Probe until BOTH backends' instance_ids each appear at least once before
the round-robin burst — confirms recovery has reached both, not just one.

Underlying daemon-side TG cache staleness tracked separately as
mulga-siv-119. This wait is the test-side guard; the daemon fix will
make WaitForTargetsHealthy meaningful again post-reboot.

Refs mulga-siv-118.

* ci(e2e-go): skip single job when dispatch requests only reboot

Add if: gate to e2e_go_single that skips the job when the workflow_dispatch
suites input contains none of {e2e-single, e2e-cert, e2e-lb}. Push events
and the default suites list still trigger it, so plain PR runs are
unaffected. Avoids ~15min of wasted tofu provision+bootstrap+teardown
when only the runner-resident reboot suite is requested.

* fix(e2e/reboot): bump post-reboot LB recovery timeout to 5min

Pre-reboot the test creates the LB AFTER apps are already running, so
HAProxy starts probing against an already-serving responder — ~75s to
healthy. Post-reboot the daemon's recovery scan launches app VMs and
the ALB sys.micro VM simultaneously, so health checks start probing
within seconds of app boot beginning and both targets honestly report
unhealthy until the responder unit finishes starting. Cloud-init's
per-instance semaphore means no shortcut path on relaunch, so guest
second-boot can be slower than first boot.

Bash run-reboot-e2e.sh hid this behind stale 'healthy' state read from
the daemon's pre-reboot TG cache — fixed via ResetTargetHealthOnStartup
(mulga-siv-119), which made the test honestly wait on real lb-agent
reports. The 180s bash inherited turned out to be wishful thinking
backed by the cache bug, not measured recovery time.

5min matches the actual post-reboot eventually-consistent window
observed on run 26134156355. Refs mulga-siv-118.

* feat(e2e/reboot): ssh into guest VMs on failure for HTTP responder diag

Post-reboot failures bottom out at "0/2 healthy" with no insight into
whether the responder unit died, port 80 isn't bound, or the OVN path is
broken. Capture the keypair material from CreateKeyPair so OnFailure can
hop host -> qemu hostfwd:22 -> guest and dump systemctl status, port
listeners, curl localhost, and the responder journal per instance.

* fix(e2e/reboot): probe app instances via public IP (no QEMU hostfwd)

Previous hostfwd-SSH approach didn't apply: app VMs use pure OVN tap
interfaces (qemu argv has no hostfwd=tcp:*:*-:22), so the regex never
matched. Replace with host-side probes against the IGW-NAT'd public IP:
ping, /dev/tcp connect, and curl. Combined with the qemu ps check and
ovn-nbctl list nat dump, this localises post-reboot failures to one of:
VM not running, OVN DNAT missing, responder down, or sys.micro-only
reachability (path asymmetry).

* feat(e2e/reboot): probe ALB + dump host OVN/OVS state on failure

Last run showed apps unreachable from host post-reboot (ping 100% loss,
tcp refused) even though OVN dnat_snat rules looked correct. Need to
distinguish gateway/ext-bridge breakage from app-side breakage: add a
parallel ALB-public-IP probe (works pre-reboot, so any post-reboot
failure isolates the regression) plus a full host net-state dump (ip
route, ip neigh, ovn-nbctl show, ovs-vsctl show, br-int port list).

* feat(e2e/reboot): dump qemu serial console per app on failure

/run/spinifex/console-i-<id>.log captures cloud-init, kernel boot,
systemd, anything the guest emitted to ttyS0. Tail to 256KiB (~4x a
typical Linux boot) so the latest boot lands in full plus the tail of
the prior. Surfaces guest-side boot failures (kernel panic, fs
corruption, cloud-init OOM, etc.) that are invisible to host-side
network probes.

* feat(e2e/reboot): SSH into guest VMs via IGW for responder root-cause

Last run nailed the symptom: ALB returns 503, app public IPs answer TCP
with RST (port 80 not bound), so reboot-e2e-httpd.service isn't running
on relaunched VMs. To find out why (unit never installed? installed but
not enabled? failed to start?), open tcp/22 ingress on the shared SG and
add a guest-SSH diagnostic via the IGW NAT path. The diagnostic dumps
the unit state, port listeners, /var/lib/httpd, cloud-init result.json,
cloud-init logs, and the instance-id semaphore directory.

* test(e2e/reboot): always run guest SSH diag in phase 8

Last run passed after adding tcp/22 SG ingress. Two possibilities to
distinguish: (a) responder was running all along but blocked at the
OVN/SG layer, (b) responder wasn't running and HC was satisfied by some
other path. Capture in-guest unit + cloud-init state on every run so we
can confirm which without needing the test to fail.

* refactor(e2e/single): migrate VPC group to EnsureDefaultVPC [skip ci]

Bead 3a of mulga-siv-120 (e2e-single-fixture-migration). Replaces the
"discover default VPC + cache on Fixture" pattern in vpc_test.go,
natgw_test.go, securitygroup_test.go with harness.EnsureDefaultVPC.

- Adds Harness *harness.Fixture field on single.Fixture; constructed in
  TestSingleNode alongside AWSClient. Parent test for memoized Ensure*
  resources is TestSingleNode itself.
- Removes fix.DefaultVPCID / fix.DefaultSGID / fix.DefaultSubnetID reads
  in the three target files. Fields stay on the struct until 3e+3h
  migrate the instance / datapath / umbrella sites.
- Custom VPC graphs created by phase8b (10.99.0.0/16) and the bastion
  graph in phase8d remain inline — those are the resource lifecycle
  assertions, not prereqs.

CI gate skipped per dev request — full single-node suite verifies when
upstream main is unbroken.

* refactor(e2e/single): migrate Phase 3 key pair to EnsureKeyPair [skip ci]

Bead 3c of mulga-siv-120 (e2e-single-fixture-migration). Phase 3 now
calls harness.EnsureKeyPair for the primary key pair (Phase 5+ prereq).
The Ensure* path owns the create assertion (PEM shape), memoizes across
phases, and registers TestSingleNode-scoped cleanup — replacing the
former TODO(stage-G) deferred-cleanup marker.

- Drops hardcoded "test-key-1" name; EnsureKeyPair uses scratch-suffixed
  name so parallel CI runs don't collide on the namespace-flat AWS key
  pair name.
- fix.KeyName / fix.KeyPath still assigned for downstream Phase 5+
  readers. Per 3e migration plan, those reads move to in-test
  EnsureKeyPair calls and the Fixture fields drop with 3h.
- Import / describe / delete dance for test-key-2 stays inline — it is
  the lifecycle assertion for the import path, not a prereq.

* refactor(e2e/single): migrate Phase 4 + 5e AMI paths to EnsureAMI [skip ci]

Bead 3d of mulga-siv-120 (e2e-single-fixture-migration).

- Phase 4 (image_test.go): discover catalog AMI by canonical name, then
  route through harness.EnsureAMI({Existing}) so the state-available
  wait + memoization live with the fixture.
- Phase 5e (createimage_test.go): replace inline CreateImage block with
  harness.EnsureAMI({CreateFrom}). The custom AMI build now memoizes,
  registers DeregisterImage cleanup at TestSingleNode exit, and reuses
  the harness state poll.

Harness fix: extend AMICreateSpec with NoReboot bool — Phase 5e bakes
the AMI while leaving the source instance running for Phase 6/7
lifecycle assertions; Bead 2 missed this option.

fix.AMIID / fix.CustomAMIID / fix.CustomAMISnapID stay on Fixture for
downstream Phase 5/6/7 + Phase 9 cleanup. Those reads migrate with
3e/3h.

* refactor(e2e/single): migrate Phase 5 instance lifecycle to EnsureInstance [skip ci]

Bead 3e of e2e-single-fixture-migration.

- phase5_LaunchInstance: provision via EnsureInstance + re-describe for
  BDM/root-volume extraction; default VPC via EnsureDefaultVPC.
- phase8e_SGToSGDatapath: replace inline DiscoverDefaultVPC with
  EnsureDefaultVPC. createSG / runSGEInstance take vpcID/subnetID
  arguments so phase 8e no longer depends on fix.Default* fields.
- runSGEInstance stays direct RunInstances: phase 8e VMs are test
  subjects (need fresh ENI/private-IP per run), not memoized prereqs.
- harness.EnsureInstance memo key now includes InstanceType, SGID, and
  UserData hash — a second call with a different SG would have silently
  returned the first instance ID (Bead 2 bug).

* refactor(e2e/single): migrate Phase 5b/5c volume + snapshot to Ensure* [skip ci]

Bead 3f of e2e-single-fixture-migration.

- phase5b_VolumeLifecycle: create+wait via EnsureVolume; subsequent
  modify/attach/detach/delete stay inline (test subject lifecycle).
- phase5c_SnapshotLifecycle: create+wait via EnsureSnapshot. Drop the
  create-response State/Progress shape assertions — after EnsureSnapshot
  the snapshot is already "completed", and the describe block below
  re-asserts VolumeId / VolumeSize / Description.
- harness.EnsureSnapshot now takes SnapshotSpec{VolumeID, Description}
  so callers can pin a description (snapshot_test asserts on it). Memo
  key includes description hash so distinct-description calls do not
  silently collide on the volumeID prefix.

* refactor(e2e/single): isolate IAM stage E via parent-scoped cleanup [skip ci]

Bead 3g of e2e-single-fixture-migration.

- harness.Fixture: add RegisterCleanup helper that wraps fx.parent.Cleanup
  so test code can register parent-scoped teardown for resources created
  outside ensureOnce (IAM users / policies threaded across phase
  subtests). Registering on the calling subtest would tear down before
  the next dependent phase ran.
- phaseIAM1_UserCRUD: register parent-cleanup for alice/bob/charlie.
- phaseIAM4_PolicyCRUD: register parent-cleanup for the 5 test policies.
- IAM stage no longer relies solely on phaseIAM7 to clean up after a
  mid-stage failure; phase 7 still runs and now serves as an idempotent
  post-cleanup assertion.

No new Ensure* helper: IAM resources have no memoization story (users
and policies are test subjects, secrets only returned once on create).
IAM phases also have no EC2 prereqs, so EnsureDefaultVPC / EnsureInstance
are not applicable here.

* refactor(e2e/single): drop dead Fixture fields after Ensure* migration [skip ci]

First half of bead 3h. Removes per-phase Fixture fields with zero
remaining readers after bead 3a-3g:

- DefaultVPCID / DefaultSGID / DefaultSubnetID: superseded by
  harness.EnsureDefaultVPC (3a wired phase8b/8c/8d/5f; 3e wired phase5
  + phase8e).
- OVNAvailable: never assigned anywhere — leftover from the bash port.

Phase fields still in use (AMIID, InstanceID, IAM*, etc.) stay until
the umbrella TestSingleNode is replaced with top-level Test* + package-
level fixture singleton in the second half of 3h.

* refactor(e2e): add process-mode Fixture + TestMain singleton infra [skip ci]

Second half of bead 3h. Lands the infrastructure required to dismantle
the TestSingleNode umbrella in a follow-up bead, without changing the
umbrella body today.

harness.Fixture changes:
- NewProcessFixture(aws) builds a Fixture with no *testing.T parent.
  Cleanup callbacks queue on an internal LIFO drained by Close() —
  intended to be invoked from TestMain after m.Run(). Lets a singleton
  fixture span every top-level Test* in a package.
- Existing NewFixture(t, aws) unchanged; cleanups still route via
  t.Cleanup. registerCleanup() and logf() helpers fork on parent==nil
  so ensureOnce and RegisterCleanup work in either mode.
- TestProcessFixture_CloseRunsCleanupsLIFO covers Close ordering,
  one-shot semantics, and the no-op second-Close path.

single/main_test.go additions:
- TestMain owns process-level lifecycle: drains pkgFix.Harness.Close()
  and removes pkgFix.TmpDir after m.Run().
- requireSingleNodeFixture(t) returns the package-scoped singleton,
  building it lazily on first call. Skips when SPINIFEX_E2E is unset
  or mode != single; fails on construction error. Currently
  unreferenced — TestSingleNode keeps its own per-test Fixture for
  now. The helper exists so the follow-up bead (per-phase top-level
  Test* migration) has a stable API to call from day one.

Deferred to follow-up bead: extract Phase 4 AMI-name discovery + Phase
2 InstanceType/AZ selection into harness helpers so each phase function
can self-bootstrap, then promote phase subtests to top-level Test* and
delete the TestSingleNode umbrella.

* fix(e2e/harness): EnsureDefaultVPC filter name is-default not isDefault

Daemon rejects the camelCase `isDefault` form with 400
InvalidParameterValue. AWS CLI / DiscoverDefaultVPC use `is-default`.

Surfaced as Phase 5_LaunchInstance fatal in CI run 26140908505 —
cascade-failed every downstream phase that needed fix.InstanceID.

* fix(e2e/single): phase8 8j/8k probe fix.KeyName not hardcoded test-key-1

Bead 3c migrated phase 3 to harness.EnsureKeyPair which produces a
scratch-suffixed name (e2e-key-<random>), not the literal "test-key-1"
the bash port used. 8j_CreateKeyPairDuplicate / 8k_ImportKeyPairDuplicate
kept the old name and so created a fresh key pair instead of triggering
InvalidKeyPair.Duplicate — surfaced as the only two FAILs in CI run
26143227369 (everything else green after the EnsureDefaultVPC fix).

Read fix.KeyName (set by phase3_KeyPairs) so the probe hits the staged
primary key. Header comments updated to match.

make preflight diff-coverage against origin/dev fails at 57.9% due to
unrelated commits inherited from the recent rebase onto
feat/e2e-go-harness; against the fork base the diff is 0 instrumentable
lines (test-only change).

* refactor(e2e/single): dismantle TestSingleNode umbrella, per-phase self-bootstrap

mulga-siv-121. Each phase from run-e2e.sh now runs as a top-level Test*
in tests/e2e/single, runnable in isolation via `go test -run TestX`.

Highlights:
* TestSingleNode umbrella removed. Phase ordering dropped — each Test*
  self-bootstraps prerequisites through harness.Ensure*/harness.Discover*
  + the new package-local need*/iamEnsure* helpers.
* Fixture shrunk to {Env, AWS, Harness, Artifacts, TmpDir, PoolMode}.
  Every per-phase resource ID now lives on harness.Fixture via
  EnsureKeyPair/EnsureAMI/EnsureDefaultVPC/EnsureInstance/etc.
* TestMain owns the package singleton Fixture; harness.Fixture.Close
  drains every cleanup at process exit. phase9 + phase9a (in-line
  teardown) deleted; phase9b retained as TestFinalClusterStats.
* IAM Test* share state via sync.Once-gated helpers in
  iam_helpers_test.go (alice/bob/charlie users + keys + admin account).
* New harness/discover.go: DiscoverDefaultAZ, DiscoverNanoInstanceType,
  DiscoverUbuntuAMI — memoized on the Fixture and routed through
  EnsureAMI so any Test* can resolve them without phase coupling.
* New tests_test.go: descriptive top-level Test* wrappers
  (TestVPCSubnetE2E, TestKeyPairs, TestSnapshotLifecycle, …) that
  delegate to phaseN_X(t, requireSingleNodeFixture(t)).

* test(e2e/reboot): SSH as ec2-user, dump unit + network-online state

spinifex's cloud-init template hardcodes Username="ec2-user"
(handlers/ec2/instance/service_impl.go:1218) regardless of AMI,
so even on Ubuntu the SSH-injectable account is ec2-user, not
ubuntu. Switch the diag SSH-in to ec2-user so we actually get
in (was "Permission denied (publickey)").

Add diagnostics to distinguish the two leading post-reboot
hypotheses: (a) viperblock write loss = unit file gone vs
(b) network-online.target never fires = unit stuck on
After=network-online.target. New dumps: unit file content +
enable symlink, network-online.target status, networkctl,
ip addr / route, prior-boot journal grep for the responder.

* fix(e2e/single): resolve admin account ID before TestIAM7 deletes alice

iamEnsureAdminAccountID probes via GetUser(alice). TestIAM7_Cleanup
deleted alice/bob/charlie first, then called the helper at line 808 —
NoSuchEntity if IAM7 was the first IAM test to run (no memoized
account ID). Move the call above the deletion loop.
2026-05-20 21:04:13 +10:00