Commit Graph

175 Commits

Author SHA1 Message Date
Josh Siviour 9d662f9889 feat: EKS EBS CSI driver (Viperblock) — egress, attach, node discovery, e2e (#402)
* feat: bake aws-ebs-csi-driver addon + mdev by-id bridge

Add the aws-ebs-csi-driver entry (v1.40.1) to the eks addon catalog and
ship its baked manifests (RBAC, IRSA-wired controller, node DaemonSet,
default gp3 StorageClass) under the eks-node image.

The node plugin locates EBS volumes via /dev/disk/by-id, which busybox
mdev does not populate. Add mulga-ebs-byid: an mdev rule + helper that
symlinks nvme-Amazon_Elastic_Block_Store_<serial> from the virtio-blk
serial, plus a boot oneshot that points the kernel hotplug helper at
mdev and coldplugs volumes present at boot.

* test: e2e EBS CSI volume — PVC bind + mount + reschedule survival

Add a TestEKS/EBSCSIVolume subtest that installs the aws-ebs-csi-driver
managed addon bound to an IRSA role, provisions a default gp3 PVC + pod,
and asserts a marker written by the first pod survives a reschedule onto a
fresh pod backed by the same PVC. Exercises the full data path:
CreateVolume(Viperblock)/AttachVolume virtio-blk hotplug with serial set to
the volume-id, mdev /dev/disk/by-id symlink, and ext4 mount.

* feat: eks-node provider-id + topology/instance-type labels for EBS CSI

mulga-eks-provider-id boot oneshot writes a K3s config.yaml.d drop-in
from IMDS before K3s starts: kubelet provider-id plus the region, zone
and instance-type node labels. The aws-ebs-csi-driver node plugin reads
this metadata for NodeGetInfo (instance id from providerID, region/zone
from topology labels, instance type for the volume-limit family lookup).
k3s runs no AWS cloud-provider so none of it is set otherwise, which left
the node plugin's metadata init failing, NodeGetInfo panicking in
IsNitroInstanceType, and the CSINode carrying no topology key. Wire the
service into the image manifest (INSTALL_FILES + ENABLE_SERVICES) and
chmod it in setup.

* test: e2e EBS CSI controller IRSA policy + failure diagnostics

awsgw enforces IAM on assumed-role EC2 calls, so the controller's
CreateVolume/AttachVolume need a permission policy on the IRSA role.
Attach a customer-managed EC2 policy (AWS-managed ARNs are opaque and
grant nothing) covering the EBS CSI action set; CreateVolume then
succeeds. Add an OnFailure dump of the CSI control path (provisioner and
plugin logs, pvc/pod describe, events, csinode) to the EBSCSIVolume
subtest so provisioning/attach failures are diagnosable post-teardown.

* feat: EBS CSI obeys control-plane taint; e2e worker nodegroup

Drop the CriticalAddonsOnly/NoExecute tolerations from the EBS CSI
controller + node manifests so the driver runs only on customer worker
nodes, never the system control-plane VM. The e2e creates a 1-node
worker nodegroup and the writer/reader pods drop their CP tolerations so
the customer-owned volume attaches in the customer space.

* fix: EBS CSI attach — decouple hotplug PCIe port from device name; iothread rollback

virtio-blk-pci hot-plug bus was derived from the AWS device-name letter
(/dev/sd[f-p]); the EBS CSI driver sends arbitrary names (/dev/xvdaa) that
map to no slot, so device_add fell back to pcie.0 — which rejects hot-plug —
and the attach hung. A failed attach also left the per-volume iothread object
behind, so every retry died on object-add 'duplicate property'.

nextHotplugEBSPort allocates the lowest free hotplug-ebs port from live
query-block state, independent of the device name. delIothreadBestEffort
removes the iothread on every attach rollback path.

* fix: EBS CSI node discovery — un-shadow mdev by-id rule, mint nvme link

Alpine eks-node runs busybox mdev (no eudev), which stops at the first
matching /etc/mdev.conf rule. setup.sh appended the mulga-ebs-byid rule after
the stock 'vd[a-z].* -> persistent-storage' line, so it never fired on
hot-attach: only /dev/disk/by-id/virtio-<serial> was created, while the
upstream aws-ebs-csi-driver resolves nvme-Amazon_Elastic_Block_Store_<volid>.

Swap the stock vd* rule in place to route through mulga-ebs-byid, which now
delegates to persistent-storage (preserving stock links) before minting the
nvme-style link the node plugin expects.

* test: EKS e2e — NAT Gateway egress for private worker nodegroup

The customer-VPC worker is private; AttachIGW programs no SNAT (mirrors AWS:
an IGW serves only public-IP instances), so an IGW-only fixture left the worker
unable to reach the public NLB endpoint or pull public CSI images — the
nodegroup never joined.

createWorkerEgress now builds the real topology: an IGW-fronted public subnet
hosting a NAT Gateway, with the worker subnet default-routed to it. Associating
the worker route table triggers the subnet-wide SNAT egress reroute. Teardown
drains the NAT Gateway before releasing the public subnet + EIP it pins.
2026-06-19 14:43:20 +10:00
Josh Siviour 57737af09e feat(ecr): require repository to exist before push (#400)
The OCI /v2 registry auto-created a repository on first push (ensureRepo).
AWS ECR does not: a push or PutImage to a repository that was never created
with CreateRepository is rejected. Replace ensureRepo with requireRepo, which
returns OCI NAME_UNKNOWN on the data plane and RepositoryNotFoundException on
the JSON PutImage path. Enforcement sits at the single push choke point
(startUpload + StoreManifest).
2026-06-19 12:38:15 +10:00
Josh Siviour f9858d4e5d test(e2e): ECR end-to-end suite (control plane + docker/crane/skopeo) (#398)
* test(e2e): add ECR end-to-end suite (control plane + docker/crane/skopeo)

New tests/e2e/ecr/ suite plus harness support:
- harness: ECR SDK client, control-plane helpers (repo CRUD, get-login-password,
  describe-images), and docker/crane/skopeo CLI wrappers that skip when the
  binary or registry-host resolution is absent.
- suite: control-plane CRUD + policy + lifecycle round-trip; docker login/push/
  pull; crane append/pull and skopeo copy exercising the Bearer /v2/token flow.
- registered e2e-ecr in the Makefile + service manifest.

Predastore capacity/load leg is a skipped placeholder pending mulga-siv-359/356.

* test(e2e): carry awsgw port in ECR registry host for dev clusters

Dev clusters bind awsgw on a non-standard port (e.g. 9999); production uses
:443. ECRRegistryHost now appends SPINIFEX_ECR_PORT when set so docker/crane/
skopeo address the right endpoint, and RequireRegistryResolves strips the port
before DNS lookup.
2026-06-19 11:34:38 +10:00
Julian Sommer e977683195 fix: imds e2e harness 2026-06-19 09:29:36 +10:00
Julian Sommer 83e538f2f2 feat: IMDS version discovery, instance-identity document, and cheap metadata routes (#382) 2026-06-18 15:26:14 +10:00
Julian Sommer eced3f733a feat: split IAM e2e (#372) 2026-06-17 15:06:16 +10:00
Julian Sommer 9288ecc7dc chore: delete no op tests + speedup e2e 2026-06-17 12:19:13 +10:00
Julian Sommer 058da3eff4 fix(viperblockd): seal volume block-map to predastore on detach + durability e2es (#371) 2026-06-17 11:24:03 +10:00
Julian Sommer 2e9501524b feat(e2e): more e2e coverage (#365) 2026-06-17 09:52:50 +10:00
Josh Siviour 9a474220ce fix: preserve terminated-instance describe-visibility window in GC reaper (#363)
* fix: preserve terminated-instance describe-visibility window in GC reaper

The TerminatedTeardownReaper purged a terminated record the moment it drove
the instance's pending NAT/OVN teardown to done. Normal terminations always
arrive NAT/OVN-pending, so the first 2m GC sweep completed and purged them
immediately, removing the instance from DescribeInstances before clients could
observe state=terminated — an AWS-compat divergence that flaked e2e-single
TestRunInstancesMultiCount.

Stamp TerminatedAt on transition and purge a completed record only once it is
older than a 15m visibility window; fresher records are left to the terminated
bucket's 1h TTL, keeping the instance describable as terminated. The ADR-0003
§3 purge-on-completion invariants still hold (their seeded records predate the
window). Closes mulga-siv-328.

* test(e2e): poll intra-SG east-west ping until L2 converges

TestSameSGComms fired a single un-retried 'ping -c 3 -W 2' immediately after
the source instance's SSH became reachable, but the ICMP target is the
destination's east-west data plane. Two instances boot in parallel and the
destination guest NIC / OVN port flows come up several seconds after the EC2
running transition the launch waits on. When the probe wins that race the
source kernel reports 'Destination Host Unreachable' (ARP unresolved), not an
ACL drop, and the test flaked.

Replace the one-shot ping with pingConverged, a bounded poll that retries
until 0% packet loss or 45s. A genuine self-ingress/datapath break still
stays at 100% loss for the full window and fails, so coverage is preserved.
2026-06-16 11:38:05 +10:00
Julian Sommer 1f7adcc4dd chore: more e2e tests for IAM + STS + IMDS (#364) 2026-06-16 11:11:12 +10:00
Josh Siviour 1873f45d26 Resilient resource lifecycle (ADR-0001..0006): EKS multi-node teardown, idempotent deletes, GC backstops (#357)
* fix: idempotent Delete* across VPC family + volume (RLC1)

Delete handlers now return empty success when the resource is already
absent (nats.ErrKeyNotFound), instead of *NotFound. Transient read
errors still map to ServerInternal. Covers DeleteVpc, DeleteSubnet,
DeleteSecurityGroup, DeleteNetworkInterface, DeleteInternetGateway,
DeleteNatGateway, DeleteRouteTable and DeleteVolume.

Makes destroy retries converge instead of failing mid-graph, removing a
source of orphaned/zombie resources between deploys. Adds a
TestRLC1_*DeleteIdempotentOnAbsent invariant per package and updates the
daemon delegation table; supersedes the old absent->NotFound tests.

* feat: prune orphan ENI LSPs in reconcile applyPorts (ADR-0003 §4)

applyPorts was create-or-update only, so a guest LSP whose
spinifex:eni_id no longer maps to an intent ENI survived instance
terminate and VM-host reinstall — the orphan-LSP capacity-drift root
cause. It now takes pruneOrphans (mirroring applySGs): on a full
reconcile it lists LSPs and tears down each managed ENI port absent from
intent via the composed topology.DeletePort cascade (clears port-group
memberships, then deletes the LSP). The apply-only startup path does not
prune, preserving in-flight ports until subscribers converge.

Adds ListLogicalSwitchPorts to the OVN client (live + mock). Invariant
TestRLC5_OrphanLSPPrunedAfterReconcile guards ADR-0003 §4; a startup-race
test guards the apply-only path.

* feat: idempotent + tracked instance terminate (ADR-0003 §1/§2)

Make terminate idempotent across all three layers and break the
un-terminable-ENI deadlock, and record per-dependency teardown so the
GC backstop can later tell a fully-reaped instance from a dangling one.

- vm.Manager.Terminate: absent / already-terminated / shutting-down now
  return nil instead of NotFound / invalid-transition (ADR-0003 §2).
- VM.Teardown map + teardown.go marks: sync steps stamped Done/Failed via
  cleaner error returns; fire-and-forget OVN LSP and NAT rule stamped
  Pending for the reconciler / GC reaper (ADR-0003 §1).
- InstanceCleaner: DeleteVolumes / ReleasePublicIP / DetachAndDeleteENI /
  RemoveFromPlacementGroup / ReleaseGPU now return error.
- vpc ForceDeleteInstanceENI bypasses the in-use guard for the owning
  instance, breaking the failed-detach deadlock; plain delete still
  rejects an in-use ENI held by another instance.
- gateway TerminateInstances: KV-health-gated idempotent absence (a failed
  terminated-bucket query does not fabricate success, ADR-0003 §3).
- Invariants: TestRLC1_TerminateIdempotentOnAbsent, TestRLC4 teardown-map
  stamping + failed-dependency, TestRLC_ENINonDeadlock.

* feat: reconciler GC backstop framework + terminated-teardown reaper (ADR-0003 §3)

Add the shared reality->desired garbage-collection framework (inverse of
Restore) and its first reaper, closing the gap 295.3 deferred: nothing
retried pending/failed teardown or purged the terminated record when its
dependents reached done.

- vm/gc.go: GarbageCollector + Reaper interface (Class/Scope/Sweep).
  KV-health gated — holds and alarms when the desired-state cannot be
  trusted, never reaps against it. ClusterWide reapers run only on the
  elected leader (optional elector); NodeLocal run on every node. Start
  sweeps immediately then every GCInterval.
- vm/teardown_reaper.go: TerminatedTeardownReaper (node-local). Scans this
  node's terminated records, re-drives each not-done dependent through the
  idempotent cleaner, and purges the record once all reach done. Records
  already complete on arrival are left to the bucket's 1h TTL.
- StateStore gains DeleteTerminatedInstance (JetStream impl already existed).
- daemon: wires the GC after the pending watchdog, gated on a cheap
  JetStream AccountInfo health probe.
- Cluster OVN-LSP arm already lands via the reconciler drift prune (295.2).
- Invariants: TestRLC5_GCRetriesPendingTeardownToDoneThenPurges,
  TestRLC5_GCNeverPurgesIncompleteRecord, TestRLC5_GCHoldsWhenKVDegraded,
  TestRLC5_GCSkipsOtherNodesRecords.

* feat: VPC-family dependency guards (ADR-0004 rule #3)

DeleteSubnet blocks while a live ENI attachment (a resident instance)
remains; available/orphan ENIs do not pin it. DeleteNetworkInterface
guards on the structured attachment (eniIsLiveAttachment), not a bare
in-use string. DeleteNatGateway blocks while a live route table forwards
to it and disassociates its EIP keeping the allocation (AWS parity);
CreateNatGateway now marks the EIP associated so the disassociate is
real and a second NAT GW cannot reuse the allocation.

Binding invariants: TestRLC3 dependency-guard completeness across the
subnet/ENI/NAT-gateway handlers.

* feat: EBS volume data-safety GC override (ADR-0005)

The shared GC backstop reaps actual-desired for every other class; for
volumes that default is forbidden because they carry customer data. Add
VolumeLeakReaper (node-local, rides the 295.5 GC): it marks a volume left
attached to a definitively-gone instance as orphaned (a non-destructive
tag) and alarms, but NEVER issues a delete. The daemon feeds it only this
node's terminated instances whose volumes-teardown failed, so the shared
predastore scan can never false-mark another node's live-instance volume.

DeleteVolume idempotency + the detach/snapshot guards + the
DeleteOnTermination teardown retry already landed (295.1/295.3/295.5).

Binding invariants: TestRLC5 (GC marks+alarms, never deletes data),
TestRLC3 (detach-before-delete).

* test(elbv2): lock ADR-0002 §3 live-only TG in-use guard (mulga-siv-295.4)

* feat(eks): idempotent DeleteCluster on absent + ADR-0006 lifecycle invariants (mulga-siv-295.8)

DeleteCluster returns empty success when the cluster meta is already swept
(Common Resource Lifecycle Contract #1), so tofu destroy retries converge.
Adds TestRLC1 (idempotent delete), TestRLC2 (billable-before-KV-sweep ordering)
and TestRLC3 (NLB target-group no-orphan, riding ADR-0002/172).

* fix(eks): GC reaper terminates orphan control-plane VM after cluster delete (mulga-siv-294)

A normal DeleteCluster can leave a running control-plane VM behind (terminate
completes async; a daemon restart re-launches it from saved running-state) after
the cluster meta is swept — a billable orphan with no owner. Add EKSBillableReaper
on the 0003 GC backstop: a running managed-by=eks CP VM whose cluster meta is
definitively gone is terminated. The CP ENI now carries the customer-account tag
so a system-account VM can be mapped back to its cluster meta; the reaper never
reaps on uncertainty (ENI gone/untagged or meta present).

* feat: reclaim orphan EKS NLB + NAT-GW EIP in billable reaper

The billable GC reaper terminated an orphan control-plane VM but left the
cluster's NAT-gateway EIP and NLB front-end NAT behind when the meta was
gone. After a successful terminate, run the name/tag-driven DeleteClusterNLB
+ DeleteClusterCPVPC teardown (idempotent, meta-independent) so the billable
NAT-GW EIP and NLB NAT are reclaimed too. ADR-0006 §5.

* fix: persist NAT-GW & route-table tags and reorder CP-VPC teardown to reclaim EKS NAT-GW EIP

CreateNatGateway and CreateRouteTable both dropped input.TagSpecifications,
so the tag-driven DeleteClusterCPVPC teardown could not discover the cluster
NAT gateway or its private route table. The explicit DeleteNatGateway +
ReleaseAddress were skipped and the billable NAT-GW EIP leaked under the
system account on every clean DeleteCluster.

Three fixes:
- CreateNatGateway persists ExtractTags(TagSpecifications, natgateway).
- CreateRouteTable persists ExtractTags(TagSpecifications, route-table).
- DeleteClusterCPVPC tears down route tables before the NAT gateway, so the
  rule #3 route-ref guard (DependencyViolation / InvalidAddress.Locked) is
  not tripped and the EIP releases.

Adds tag-persistence regression tests for both create paths and a teardown
ordering invariant (TestRLC5). Validated live: NAT-GW EIP released cleanly,
no DependencyViolation/Locked.

* fix: route EKS control-plane VM terminate cross-node + DELETING teardown backstop

Multi-node EKS cluster delete wedged in DELETING and stranded the billable
NAT-GW EIP. Two root causes, both invisible on a single-node box:

Cross-node terminate was never routed. TerminateSystemInstance was node-local,
so terminating a CP VM owned by another node returned NotFound (treated as
idempotent) and the teardown deleted a still-attached ENI
(InvalidNetworkInterface.InUse). That error short-circuited purgeClusterInfra
before the managed CP-VPC teardown that releases the NAT-GW EIP. Split
TerminateSystemInstance into a local body plus a routing wrapper that issues
system.TerminateInstance.<id> to the owning node. Bind that subscription for
every system-managed VM (elbv2 + eks) via the OnInstanceUp funnel, so CP VMs
placed on the coordinator's own node (and recovered after a daemon restart) are
reachable too, not only remotely-launched ones.

No DELETING backstop. A failed synchronous DeleteCluster left the cluster
DELETING with nothing to re-drive it. Add EKSDeletingReaper (vm.Reaper, class
eks-deleting) that re-runs purgeClusterInfra for clusters wedged past a min-age,
gated by the per-cluster reconciler leader lease. Stamp ClusterMeta.DeletingSince
on the transition into DELETING.

TerminateK3sServerVM additionally waits out any residual ENI-InUse with a bounded
budget as defense-in-depth.

Validated live on env19 3-node: first delete-cluster converges in 29s, both
remote CP VMs terminated cross-node, NAT-GW EIP released, backstop never fires.

* fix(network): gate gateway reconcile on real datapath reachability

Post-reboot the external OVN datapath can stay dead while every
control-plane signal is green: the chassisredirect SB binding is claimed
(up=true), NAT rows are present and the host neigh is primed, yet
ovn-controller leaves stale gateway/localnet flows so EIP north-south and
the VM->gateway heartbeat forward nothing. ensureGatewayClaimed only
nudges recompute when the binding is unclaimed, so a claimed-but-dead
datapath is never detected or repaired.

Add ensureGatewayDatapath: after the SB claim converges, probe the
gateway LRP IP (OVN answers ICMP natively, no guest or security-group
dependency), and on a miss force one ovn-controller recompute and
re-probe until a short deadline. Reuses the existing recompute nudge and
no-ops when no verifier is wired or no gateway IP resolves.

* fix: self-heal external uplink after reboot boot-race

Post-reboot, ovs-vswitchd/ovn-controller order after the passive
network.target rather than network-online.target, so they start
concurrently with systemd-networkd. Under a heavy boot the WAN-glue veth
(veth-wan-ovs) can come up admin-down before networkd brings it up,
leaving br-wan with no carrier and the external datapath dead while every
control-plane signal reconciles green.

Broaden the datapath health-gate remediation from recompute-only to
RepairDatapath: re-assert the veth admin state (covers the admin-down
link, which a flow recompute cannot revive) then recompute (covers a
stale gateway ofport). Idempotent; the veth ops are skipped in
physical-uplink mode where the device is absent.

Also capture ip -d link (veth admin/carrier) and ovs-ofctl show br-ext
(ofport) in the reboot diag bundle to disambiguate the two failure shapes
from CI artifacts.

* fix: restore AWS-faithful Delete* NotFound; move idempotency to teardown callers (mulga-siv-306)

RLC1 (f9218982) made EC2/VPC/EBS Delete* handlers return success on an absent
resource, breaking AWS compatibility and the cross-account isolation signal (a
cross-account delete reads as absent -> success instead of *.NotFound).

Restore the AWS contract at the API while keeping lifecycle convergence:
- Handlers return canonical *.NotFound on absent: DeleteVpc, DeleteSubnet,
  DeleteSecurityGroup, DeleteInternetGateway, DeleteNatGateway, DeleteRouteTable,
  DeleteVolume, and the public DeleteNetworkInterface (ForceDeleteInstanceENI
  keeps its idempotent force path, ADR-0003 section 2).
- Add awserrors.IsNotFound; destroy orchestration (instance terminate, EKS
  CP-VPC teardown, EKS IGW/SG cleanup, k3s rollback, ELBv2 ENI/SG teardown)
  tolerates NotFound so re-issued/double-targeted deletes converge.
- Flip the RLC1 unit invariants to assert *.NotFound on absent; revert the
  daemon round-trip cases; add IsNotFound + CP-VPC convergence tests.

ELBv2 deletes and TerminateInstances stay idempotent (AWS itself is).

* fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence

TestMultinodeGuestSSH intermittently times out (Connection timed out, not
#355's reset-by-peer) connecting to a fresh trio instance's public IP after
the suite churns instances across all three chassis. The instance is running
and AddEIP has installed the DNAT row, flushed/primed the host neigh, and
barriered the gateway-chassis flow — but none of that proves the
gatewayLRP->guest geneve hop: until the guest ENI's southbound Port_Binding is
up on its hosting chassis the cross-node flow is missing and the DNAT-translated
packet blackholes.

Add ensureGuestPortDatapath, the guest-side analog of ensureGatewayDatapath:
after AddEIP, probe the guest LSP's SB Port_Binding (ovn-sbctl up column); on a
miss force one ovn-controller recompute (binds the port / installs the remote
flow) and re-probe until a short deadline. Recompute, not RepairDatapath: this
is flow/binding convergence, not a WAN-uplink fault. No-op when no verifier is
wired or the EIP carries no guest port. spec.PortName is populated for every
associated EIP regardless of NAT mode.

Extends GatewayClaimVerifier with GuestPortUp and wires the gate into
applyEIPs so it runs in the continuous reconcile loop on every node.

* Revert "fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence"

This reverts commit 728b1a9cec6eaf5c7ef5e9eda4469f239b4d8fc0.

* fix(network): key EIP DNAT delete on external IP, not logical IP

A dnat_and_snat row's identity on the VPC gateway router is its external
IP. The logical (private) IP is not a stable key: the EIP pool round-robins
and instance private IPs are reused as instances recycle, so the same
logical IP is held by different EIPs over time.

DeleteEIP deleted the row by logical IP, so a stale or retried delete for a
freed EIP clobbered whichever live EIP now held that recycled private IP --
the next owner's row vanished with no re-add and its public IP went dark
(SSH SYN never left br-wan: Connection timed out). Lifecycle-resilience
teardown/GC retries amplified this by firing vpc.delete-nat repeatedly.

Key the delete on the external IP via DeleteNATByExternalIP and skip when
no external IP is supplied (no EIP means no dnat_and_snat row). A stale
delete now targets the freed EIP's own external IP (ErrNATNotFound no-op)
and can no longer touch the live row.

* refactor: relax DeleteNatGateway to AWS-faithful

Remove the route-reference dependency guard (checkNatGatewayRouteRefs) so a
NAT gateway deletes even while route tables still forward to it, matching AWS
(the route blackholes). The reconciler's loadNATGWs drops the SNAT once the
record leaves the active bucket, so a forwarding route is not a delete-blocking
dependent. EIP is still disassociated on delete (no leak).

* test: invert NAT-GW route-ref invariant to AWS-faithful

TestRLC3_NatGatewayBlocksWhileRouted asserted DependencyViolation while a
route forwarded to the NAT gateway, which is AWS-infaithful. Replace with
TestRLC3_NatGatewayDeletesWhileRouted: delete must succeed whether or not a
live route still targets it (the route blackholes; the reconciler drops the
SNAT). Per ADR-0004 §2.

* fix: tear down NAT-GW SNAT on delete (restore delete-time fan-out)

The guard commit (53ed55a9) dropped publishDeleteEventsForNatGateway on the
assumption a routed NAT GW could never be deleted. With the route-ref guard
removed, deleting a NAT GW while routes still target it left the SNAT in
place (applyNATGWs is create-only, so the reconciler never prunes it) and
egress kept working — TestNATGateway's post-teardown 'lost internet' check
failed. Restore the fan-out: on delete, scan route tables and publish
vpc.delete-nat-gateway per associated subnet so egress stops, while the
route entry survives as a blackhole (AWS-faithful).

* fix: probe EIP datapath in reboot self-heal, not the gateway LRP IP

The post-reboot gateway datapath self-heal probed the gateway LRP IP for
liveness, which OVN answers natively at the router — so it stayed green even
when the EIP NAT pipeline was stranded (ofproto/trace dropped EIP traffic at
table 0). RepairDatapath never fired and TestRebootResilience timed out, with
the drift loop re-probing the same false-green every cycle.

Prefer probing an associated EIP: force a fresh ARP of the dnat_and_snat
external IP (flush neigh, ping to trigger, read the resolved MAC). OVN answers
ARP for the external IP from the gateway chassis independent of the guest, so a
resolved MAC proves the WAN uplink forwards and the NAT flows are installed —
the signal the LRP IP cannot give. Fall back to the LRP IP for VPCs with no
EIP. The existing drift-loop cadence now re-heals a later flow strand.

* fix: lb-agent heartbeats over mgmt port, not the WAN/EIP datapath

The lb-agent heartbeat to awsgw is control-plane and must survive a host
reboot that strands the OVN/EIP north-south data plane. Customer
(internet-facing) lb-agents heartbeated over the WAN GatewayURL, riding the
EIP datapath, so after a reboot the ALB never reported healthy until the
datapath self-healed. System LBs already heartbeated over the on-link
br-mgmt NIC; this extends that path to every lb-agent.

buildLBAgentEnv now always uses mgmtGatewayURL (on-link MgmtBridgeIP, the
mgmt NIC the lb-agent VM already carries), falling back to the WAN URL only
when no mgmt bridge exists. Drops the account-id branch.

* fix: re-heal guest ENI SB Port_Binding post-reboot so EIP ingress converges

Post-reboot every EIP in a VPC goes dark north-south: the DNAT row, host
neigh and gateway-chassis flow are all in place (ARP to the EIP resolves)
but the gatewayLRP->guest flow is missing until ovn-controller binds the
guest ENI's SB Port_Binding, so the DNAT-translated packet blackholes and
the public IP is unreachable on an otherwise-running instance.

Restore the guest-port convergence gate in applyEIPs: probe the guest LSP's
SB Port_Binding up column; on a miss force an ovn-controller recompute and
re-probe until a deadline. Unlike the earlier reverted form, recompute on
every miss, not once: after a host reboot the guest tap is replumbed several
seconds after the reconcile runs, so a single early nudge fires before the
port exists and never binds it. The window is widened to span guest boot.

This is the southbound analog of the gateway datapath self-heal; the EIP
ARP probe cannot detect it because the gateway chassis answers ARP for the
external IP even while the guest delivery flow is absent.

* fix(network): force post-reboot EIP recompute on guest-port bind, startup-only

The reboot suite passed only by accident: the pre-#351 gateway-claim verifier
polled the always-chassis-less gateway LRP, so it fired an ovn-controller
inc-engine/recompute on every reconcile. That spurious recompute was load-bearing
- it reinstalled the centralised gateway DNAT flows that ovn-controller's
incremental engine misses on a cold (post-reboot) start. #351 correctly switched
the check to the chassisredirect port, eliminating the every-cycle recompute, and
the EIP datapath went dark post-reboot with every control-plane signal green.

Restore the kick, scoped to the post-reboot startup path: applyEIPs now gates a
single recompute on the guest ENI's SB Port_Binding chassis claim - the reliable
signal the tap is back, unlike the up column which stays stale-true across a
reboot. Drift never recomputes, preserving the #351 fix for freshly attached
EIPs. Probe renamed GuestPortUp -> GuestPortBound (chassis, not up).

* Revert "fix(network): force post-reboot EIP recompute on guest-port bind, startup-only"

This reverts commit 792e318211ef03efbe24febeb470749d88c0b03c.

* test(e2e/reboot): capture dataplane diag on ALB serve failure

The post-reboot ALB-serve assertion (8.6) failed with no flow/ARP evidence:
the OVN/OVS dataplane dumps only ran on the 8.5 targets-unhealthy path, but
8.5 passes on the daemon's own health view while the north-south EIP datapath
is dark, so the serve failure (8.6) left nothing to localise the drop.

On a serve failure, capture dumpALBProbe/dumpOVNDataplane/dumpOVNClaimState/
dumpHeartbeatDiagnostics/dumpAppDiagnostics before failing. Add to the OVN
dataplane dump: dump-flows br-ext and fdb/show br-ext (north-south bridge,
previously only br-int), and a real ARP probe that flushes the neigh entry
first so primeReachability's pre-seeded reachable entry can no longer mask an
unanswered ARP. Pure diagnostics; no production change.

* test(e2e/reboot): decisive datapath diag — untruncate br-int, real-ingress ofproto/trace, conntrack + tap capture

* test(e2e/reboot): add south->north return-path ofproto/trace to localize egress drop

* test(e2e/reboot): derive LRP MAC from realized flow + remediation experiment (recompute vs ovn-controller restart)

* test(e2e/reboot): dump persisted SB MAC_Binding + external MACs; flush experiments (conntrack vs MAC_Binding) to isolate recompute-resistant return-path drop

* test(e2e/reboot): untruncate Port_Binding + enumerate DGP/chassisredirect claim state (does cr-gw row exist + resident chassis)

* test(e2e/reboot): dump NB source of return-path drop (lflow stage, NAT external_mac/options, LR policy, LSP port_security)

* fix: exempt public-IP instances from subnet egress drop gate (mulga-siv-311)

Post-reboot reconcile installs the priority-1100 subnet egress drop gate for an
IGW-attached subnet that has no 0.0.0.0/0 route, but nothing punched the subnet's
public-IP instances through it. lr_in_policy runs before lr_out un-DNAT/SNAT, so an
inbound connection's reply still carries its private source when it hits the gate and
is dropped. The ALB/EIP datapath goes dark south->north post-reboot while NAT, ARP,
chassis and guest-port binding all read green; the pre-reboot path survives only
because the event-driven gate never evaluates a subnet created after IGW attach.

Install the existing priority-1200 /32 reroute (above the gate) for every public-IP
instance during reconcile. Reroute only: the instance's dnat_and_snat already supplies
SNAT, unlike EnsureSystemInstanceEgress which also adds a plain snat. Public IPs come
from two disjoint sources -- auto-assigned/ELB addresses on the ENI record and user
EIPs in the EIP bucket -- so drive the exemption from both intent.Ports (new
PortSpec.PublicIP) and intent.EIPs.

* refactor: scope public-IP drop-gate exemption to gated subnets

Only install the priority-1200 reroute when the instance's subnet actually carries
a drop gate. Routed subnets egress via their priority-1000 reroute and need no
exemption, so bounding on gate presence keeps the change inert for IGW-routed and
NATGW-routed subnets (no redundant or overriding policy). Adds a negative test.

* refactor: drop unused RemoveEIPInstanceEgress

No production caller; the exemption is install-only in reconcile. Removed the
interface method and impl rather than leave dead API surface.

* test: dump guest console + OVN/datapath diag on SG-to-SG client SSH timeout

TestSGToSGDatapath waited for client-vm SSH via EventuallyErr and reported
only 'exit status 255' on timeout — no guest-side or datapath state. When the
fresh public-IP VM is unreachable for the full window the failure was
undiagnosable from CI artifacts.

Swap the wait to the non-fatal trySSHReady probe and, on timeout, call
DumpVPCFlowDiagnostics (guest console tail, ovn-nbctl/sbctl show, NAT rows,
br-int flows / conntrack / host ARP keyed on the public + private IP) before
Fatal, mirroring vpc_test/baseline_test. Captures persist to the artifact dir
for the Stage 2 analyzer bundle.

* fix: ownership-scope public-IP teardown to survive IP recycling

A live instance's dnat_and_snat rule, host ARP, and IPAM lease were torn down
by the delayed garbage-collection teardown of a different, long-dead instance
that previously held the same recycled public IP. The 2-min vm/gc
instance-teardown reaper re-runs ReleasePublicIP on a stale terminated record;
because the external IPAM round-robins a small pool, the IP had already been
reassigned to a live instance, and both teardown primitives acted on the IP by
value with no ownership check:

- policy.DeleteEIP deleted the dnat_and_snat row by external IP alone,
  discarding the logical IP it receives.
- StaticPoolAllocator.Release freed the pool entry by IP value.

Scope both to the current owner so a stale or duplicated teardown for a
recycled IP is a no-op:

- DeleteEIP looks up the row by external IP and skips the delete (and the ARP
  flush) when its logical IP no longer matches, mirroring AddEIP's idempotency
  lookup. Defeats both external- and logical-IP reuse races.
- Allocator.Release / ExternalIPAM.ReleaseIP take an ownerENIID; the static
  allocator no-ops a release whose ENI differs from the live allocation, and
  treats an already-freed owner-scoped release as idempotent. Teardown and
  rollback callers pass their ENI; user-driven EIP release passes none.

Proven via E2E single TestSGToSGDatapath (run 27515293563): client-vm SSH
black-holed after its NAT was deleted by the dead instance's GC sweep.

* fix(eks): single-flight DeleteCluster teardown per cluster

SDK retries of one DeleteCluster get fanned across nodes by the worker
queue group, so purgeClusterInfra raced the same cluster on multiple
nodes: duplicate ENI/NLB teardown, duplicate TargetGroupNotFound
deregisters, and a real double-release risk on the billable NAT-GW EIP.
Idempotency kept it correct but noisy.

Add a dedicated per-cluster teardown lease (teardownLeaderKey =
reconcilerLeaderKey + "/teardown"), distinct from the reconciler lease
so a delete never contends with the still-live reconciler that holds the
reconciler lease until it observes DELETING and exits. DeleteCluster
acquires it after the NotFound-idempotency check: the loser returns the
cluster as DELETING (AWS-async semantics) without running the teardown;
the winner drives purgeClusterInfra and releases. The DELETING backstop
reaper shares the same lease, so at most one purgeClusterInfra runs per
cluster cluster-wide.

A nil leaderKV (single-node) skips gating, so local behavior is
unchanged.

* fix: env-driven SSH-ready budget for slow baremetal guests

Add SPINIFEX_SSH_READY_TIMEOUT override for the single-node SSH-handshake
probes (default unchanged at 3m). Baremetal boots q35 guests slower (OVMF +
NBD-backed disks) than CI loop devices and intermittently misses the 3m
window; the harness can now widen it to 5m without affecting shared-runner
e2e. Applied at all single-node probe sites; failure messages now report the
actual budget.

* fix: shared external switch to stop per-VPC localnet ARP collision

Per-VPC external switches each attached an untagged localnet port to the same
physical network (br-ext). OVN cannot keep multiple untagged localnets on one
physical network L2-separate, so with two or more concurrent public-IP VPCs ARP
for a public IP landed on the wrong VPC's switch and the owning gateway router's
ARP responder never fired, leaving the public IP unreachable.

Move to a single shared external switch (provider-network pattern): one switch
with one localnet on network_name=external. Each VPC's gateway LRP attaches via
its own router-type switch port; per-VPC NAT isolation is unchanged. AttachIGW
ensures the shared switch idempotently and is migration-safe; DetachIGW removes
only the per-VPC port + LRP and preserves the shared switch. DeleteVPC removes
the per-VPC gateway switch port from the shared switch so it does not dangle.
Reconcile detects attachment via the gateway LRP since the shared switch carries
no vpc_id.

* fix: owner-scope DeleteEIP by ENI port to survive identical pair recycling

A stale or GC-re-emitted vpc.delete-nat for a terminated instance tore down a
live instance's dnat_and_snat rule (and flushed the host ARP) when the public
IP and private IP both recycled, together, to that live instance in the same
VPC. The (external_ip, logical_ip) pair-key matches under identical recycling,
so the existing logical-IP guard fell through.

Stamp the owning ENI port (spinifex:logical_port) on the dnat_and_snat row at
AddEIP, thread the port through handleDeleteNAT -> DetachEIP -> DeleteEIP, and
skip the delete when the row's stamped port differs from the incoming one. The
IPAM release was already owner-scoped; this mirrors that guard at the NAT layer
so any stale delete for a recycled pair is a no-op. Empty port preserves the
prior pair-key behaviour.
2026-06-15 23:27:39 +10:00
Josh Siviour b23fa3bf60 feat(eks): managed control-plane VPC (Set B) + control-plane lifecycle hardening (#356)
* feat(eks): VM-side managed-addon delivery transport

Stage CreateAddon manifests in KV host-side; the control-plane VM pulls the
staged set through the AWS gateway (signed GET /clusters/{name}/internal-addons/
{accountId}), renders the baked bundles into the K3s auto-deploy dir, and
reports per-addon delivery status on a dedicated eks.addon.*.status channel that
the cluster reconciler CASes onto AWS-visible AddonStatus (CREATING->ACTIVE).

- gateway: ListInternalAddons route + ListStagedAddonManifests NATS action
- handlers: addon status reconciler (nextAddonStatus), staging installer
- eks-node AMI: eks-gateway-fetch binary + mulga-eks-addon-sync agent
- agent renders idempotently (cmp before replace) so K3s auto-deploy does not
  re-apply every tick and clear the Addon CR .status.gvks readiness signal
- agent mirrors per-tick delivery outcomes to the serial console for host-side
  observability

* fix(eks): provision cluster NLB synchronously so the endpoint carries the real front-end IP

The cluster NLB front-end IP is assigned by the async LB-VM launch
(launchLBVMAsync), but EnsureClusterNLB read it back synchronously right after
CreateLoadBalancer returned — losing the race and falling back to the synthetic
<lb>.elb.spinifex.local DNS name. That name is unresolvable (no Route53) and is
not in the apiserver serving-cert SAN, so kubectl against the cluster never
connects.

Split the elbv2 create: the public CreateLoadBalancer stays async (267.4 — an
inline multi-minute VM boot head-of-line-blocks the gateway responder), and a
new CreateLoadBalancerSync drives the data-plane launch inline and returns the
LB carrying its front-end address. The EKS cluster-create worker runs off the
gateway responder, so it uses the sync entry and reads the IP from the return
value instead of racing persisted state. No polling.

- elbv2: factor provisionLBDataPlane out of launchLBVMAsync; add CreateLoadBalancerSync
- eks: ensureClusterLB uses the sync entry, sets FrontendIP from the result,
  drops the read-back; EnsureClusterNLB fails loud (ErrClusterNLBFrontendIPUnavailable)
  when no front-end IP is allocated rather than shipping the DNS-name fallback
- eks: drop the DNS-name endpoint fallback; the endpoint is always the front-end IP

* feat: auto-wire IGW for internet-facing EKS cluster endpoint

An internet-facing cluster NLB allocates an external front-end IP and the
OVN dnat_and_snat for it, but the external switch + localnet + gateway LRP
that make that IP answerable on the wire are built only on IGW attach. The
control-plane NLB is a system instance, so ensure an IGW on cluster build
and reclaim it on teardown.

EnsureClusterIGW (idempotent, reuses a customer IGW untouched) runs before
EnsureClusterNLB when the endpoint is public; DeleteClusterIGW (ownership-
scoped) removes only the cluster-owned IGW in purgeClusterInfra, before the
VPC delete. New IGW dep wired from the daemon.

* fix(eks): tear down infra on failed cluster launch to stop LB EIP leak

A CreateCluster that fails during its background launch flipped the meta
to FAILED but performed no teardown, relying on a later same-name create
to reclaim and purge the infra. EKS cluster names are unique per create,
so that reclaim never fires: the internet-facing load-balancer VM keeps
running with its allocated+associated dnat_and_snat EIP indefinitely.
Over repeated create/delete cycles the EIP pool fills with allocations
bound to terminated instances, so a later allocation's advertised
endpoint and its live OVN NAT diverge onto different pool addresses.

- failClusterLaunch now runs a best-effort purgeClusterInfra on the
  in-memory meta (freshest refs even when the failing step was the meta
  write itself) before MarkClusterFailed. purgeClusterInfra is
  idempotent, so a later reclaim or DeleteCluster re-purge is a no-op;
  the FAILED meta is retained for observability.
- Daemon releaseSystemInstanceEIP extracted and called from both
  TerminateSystemInstance and cleanupFailedSystemInstance, so a launch
  that fails after AssociateAddress releases the eipService allocation
  instead of leaking it (MarkFailed's ReleasePublicIP only knows the
  externalIPAM path).

* feat: EKS managed control-plane VPC (AWS Set B) composed from EC2 APIs

CreateCluster now auto-builds the managed control-plane VPC under the
system account, the spinifex analogue of AWS EKS's hidden managed-account
VPC: a public subnet for the internet-facing NLB, private subnet(s) for the
k3s control-plane VM(s), and a NAT gateway giving the private CP egress for
image pulls. It is composed from the real EC2 VPC-family APIs
(CreateVpc/Subnet, IGW, RouteTable/Route, NatGateway), so per-subnet egress
gating is wired by the existing topology subscribers off the route-table
events those APIs publish — no direct OVN mutation.

This resolves the mulga-siv-292 root cause: the internet-facing NLB and the
private CP were crammed into one customer subnet with no IGW route, so both
were treated as private and dropped by the lr_in_policy 1100 rule, killing
the LB-VM return leg and lb-agent heartbeat. The CP only survived via a
per-instance 1200 reroute bandaid, now removed — the topology expresses
egress instead.

Placement is uniform single/multi-node (CP always in the private subnet).
The system LB VM heartbeats over the mgmt bridge (SystemGatewayURL) rather
than the WAN. Managed-VPC teardown reclaims NAT GW + EIP, route tables,
subnets, IGW, and the VPC in dependency order. Customer subnetIds (Set A /
worker side) remain consumed-not-created and are deferred to the worker
round.

* fix(eks): bake cluster-owner account into CP-VM EKS_ACCOUNT_ID

The managed control-plane VPC (Set B) launches the K3s control-plane VMs and
their ENIs under the system (infra) account, but the on-VM agents — bootstrap
publish, state report, token-review, and add-on fetch — must key on the
cluster-OWNER (customer) account that owns the cluster meta, namespaces, and
AccessEntry KV. Add ClusterAccountID to K3sServerInput and bake it into the
VM's EKS_ACCOUNT_ID, decoupling it from the infra AccountID the VM launches
under. Without this the VM reported state and fetched add-ons against the
system account, so the cluster never observed a healthy control plane (stuck
CREATING, null CA) and kubectl/add-on delivery timed out.

Also refresh the e2e suite doc comment to describe the Set B topology and the
data-plane subtests (kubectl + managed-addon delivery) that now run.

* fix(eks): explicitly delete addon objects on unstage

K3s does not delete the applied resources when an auto-deploy manifest file is
removed from the server manifests dir (documented behaviour). The addon-sync
GC pass relied on that, so after DeleteAddon the rendered manifest was dropped
but its Namespace/ConfigMap stayed standing — the e2e AddonDelivery GC wait
timed out. Copy the manifest aside, drop the original so K3s stops tracking it,
then kubectl delete the objects (--wait=false to avoid finalizer stalls).
2026-06-12 22:29:24 +10:00
Julian Sommer a57ebd0ad9 chore: remove references to pseudo multi node 2026-06-12 15:28:23 +10:00
Julian Sommer 50b5495d26 chore: more comment cleanup 2026-06-12 12:38:31 +10:00
Julian Sommer 58842e5e8c chore: fix stale comments 2026-06-12 11:42:19 +10:00
Julian Sommer 1dda4cb37c feat(gateway): enforce assumed-role session policies (#354) 2026-06-12 11:34:53 +10:00
Josh Siviour d076a434d4 Feat/e2e targeted suites (#352)
* test(e2e): add targeted suite selector + shadow-mode CI step

select-suites maps a diff to the e2e suites it must run: changed files →
services (manifest path/additional_paths) → consumer closure over
depends_on vs publishes/subscribes → suites whose covers intersect.
Special cases force-all (infra/harness/workflow, ≥3 services, submodule
bumps) or a lone suite dir; empty diffs fall back to default suites. An
AST scan of each suite's harness.Ensure* call graph derives per-test
service coverage and emits an optional -test.run subset (RUN_PATTERN).

Wired into e2e.yml as a non-gating 'Select Suites (shadow)' job that
logs the computed selection to the run summary so predicted vs actual
can be compared before the selector drives SUITES.

* ci(e2e): add dispatch-only per-suite job blocks grouped by env

Restructure the looped e2e flow into env-grouped provision -> matrix
suites -> teardown blocks so each suite surfaces as its own node in the
Actions graph instead of a single green tick:

  single_provision -> single_suite [single,cert,lb,eks] -> single_teardown
  multi_provision  -> multi_suite  [multinode]          -> multi_teardown

Provision resolves the VM/cluster and ships test binaries, emitting WAN
IP + ssh key path as job outputs; each matrix leg runs one suite over
SSH and uploads its own junit/analysis artifacts; teardown (if:always)
reuses the persisted tofu workspace on the same runner to destroy.

workflow_dispatch only -- runs alongside the gating e2e.yml for
validation and posts no commit status.

* test(e2e): add manifest drift lint (fixture-create + NATS subject)

Bead 5 of the targeted-suite-selection plan. Two ratcheted drift guards in
tests/e2e/manifest-lint, wired into make preflight and the spinifex.yml PR job:

- Fixture lint: AST-flags direct resource-create AWS calls
  (c.EC2.RunInstances, c.ELBv2.CreateLoadBalancer, ...) in suite test files,
  steering them through harness.Ensure* fixtures. ~104 pre-existing call sites
  are captured in a checked-in baseline; the lint fails only on NEW creates.
  Intentional create-path tests opt out with an inline // e2e:allow-create.

- Subject lint: extracts NATS subject literals from the natsSub{} registry and
  literal Subscribe/Publish/Request args, cross-referenced wildcard-aware
  against service-interfaces.yaml. Code subjects missing from the manifest fail
  (ratcheted); manifest subjects with no static reference warn only (many are
  produced dynamically). Fixed real drift surfaced on first run: declared the
  five spinifex.cluster.shutdown.* phase subjects under spinifex_daemon.

manifest-check is now CI-enforced alongside manifest-lint (previously
preflight/local-only).

* feat: run-scoped e2e teardown sweep

Tag every Ensure* fixture resource with e2e:run=<run-id> when
SPINIFEX_E2E_RUN_ID is set, and add a teardown.test suite that sweeps
everything carrying that tag at end of run — reclaiming resources leaked
by a crashed suite, an interrupted run, or a persistent cluster.

- harness/sweep.go: tagRunResources (EC2 CreateTags) + tagRunELB (ELBv2
  AddTags) + SweepRunResources in dependency order (LB, instances, NAT,
  snapshots, volumes, images, SGs, subnets, key pairs). Blank run id
  no-ops both tagging and sweeping, so the sweep can never touch an
  untagged or foreign resource.
- harness/fixtures.go: stamp the run tag on each created resource.
- tests/e2e/teardown: thin suite that runs the sweep; skips without a
  cluster or run id, logs the report, never fails the run.
- e2e-suites.yml: build + ship teardown.test, pass the run id into suite
  legs, sweep on the VM in both teardown jobs before tofu destroy.
2026-06-11 21:09:50 +10:00
Julian Sommer c4926d423f feat: IMDS public-keys (#350) 2026-06-11 15:19:01 +10:00
joshsiv-mulga a5b6a74a0b fix(e2e): serialise TestMultinodeGuestSSH after cluster restabilises
TestMultinodeGuestSSH ran with t.Parallel(), so it resumed immediately
after the sequential bucket and probed trio[0] — which CrossNodeOps
stop/starts but only waits to reach state "running", not for sshd to
answer. The guest was often still booting, producing
kex_exchange_identification resets and 2m timeouts that flaked CI red.

Drop t.Parallel() and declare the test last in the sequential bucket so
it runs after CrossNodeOps + NodeFailure/Recovery + Spread have
restabilised the cluster, giving every trio member time to settle and
removing concurrent VM churn.
2026-06-11 13:00:35 +10:00
Julian Sommer 620b96f9c6 feat: Terraform S3 Webapp using IMDS (#348) 2026-06-11 12:35:15 +10:00
Julian Sommer 3a9a1564eb chore: delete orphaned bash e2e scirpts 2026-06-11 10:34:58 +10:00
Josh Siviour 22d316f76e test(reboot): capture OVN chassis-identity churn + ovn-controller claim/connection state on post-reboot failure (#342)
Run 27254808154 showed TestRebootResilience fail with ovn-controller on
the live chassis claiming NO local Port_Bindings post-reboot: ENIs stuck
on a dead chassis UUID, cr-gw chassis empty, NAT external_mac empty —
while NB intent was correct and taps carried the right iface-id. The SB
Chassis _uuid churned across the reboot (old UUID gone, new one created)
while the SB DB persisted, orphaning every claim.

The current artifacts cannot pin WHY the new chassis won't reclaim: the
ovn-controller vlog lives under /var/log/ovn, not journald, so it is
absent from the systemd-journal capture.

Add:
- phase5Snapshot: capture chassis identity PRE-reboot (system-id, SB
  Chassis/Chassis_Private, get-connection) to ovn-pre-chassis.txt as the
  churn baseline.
- dumpOVNClaimState: at post-reboot failure, capture the same chassis
  state POST-reboot plus ovn-controller connection-status/debug, the
  gw/eni Port_Binding claim detail, ovn-central-vs-ovn-controller boot
  ordering, and tails of ovn-controller.log / ovn-northd.log →
  diag-ovn-claim.txt.

Disambiguates chassis-identity churn from an SB/NB startup-ordering race
on the next reboot-suite run. Diagnostic only; no behavior change.
2026-06-10 16:19:34 +10:00
Josh Siviour f8482ad05a test(reboot): heartbeat-path diagnostics on post-reboot health timeout (#337)
* test(reboot): capture heartbeat-path diagnostics on post-reboot health timeout

When post-reboot ALB targets fail to go healthy, the lb-agent's heartbeat to the
gateway times out with 'context deadline exceeded while awaiting headers' — a
symptom that alone cannot distinguish a network-level drop from an app/gateway
stall. Replace the fatal WaitForTargetsHealthy with a non-fatal poll that, on
timeout, tcpdumps br-wan for the heartbeat 5-tuple + ARP for the ALB floating IP
and pulls the lb-agent console + awsgw journal, then fails. Disambiguates the
layer before any fix (mulga-siv-265).

* test(reboot): re-aim heartbeat capture + add OVN dataplane flow-dump

Run 27250238163 proved the post-reboot failure is network-level (all 3 floating
IPs 100% packet-loss from host) but NOT neigh/NAT priming — neigh is primed and
NAT rows are present, only br-int forwarding is dead. The prior capture watched
br-wan for the ALB floating IP, while the real lb-agent heartbeat posts to the
gateway port on the host's advertise interface, so the L4 classifier could never
see the heartbeat and defaulted regardless.

- heartbeat tcpdump now watches the gateway port on `any` (real path) plus a
  secondary br-wan floating-IP capture for north-south; classifier keys off
  gateway-port L4 presence.
- new dumpOVNDataplane captures ovs-vsctl show, ovs-ofctl dump-flows br-int,
  ovn-sbctl chassis + Port_Binding, dnat_and_snat NAT rows, host neigh, and a
  host->floating-IP ping + ofproto/trace, to pin the flow-restore step.
- failure path dumps diag-ovn-flows.txt alongside diag-heartbeat.txt.

Test-only, build-tagged e2e; no production code.
2026-06-10 14:56:34 +10:00
Josh Siviour 90369371ee Feat/nightly e2e fix (#330)
* ci(e2e-nightly): run Go harness suites instead of bash run-*.sh

Swap nightly cells 1-3 (single), 8-10 (real-multi) and 18 (reboot) to the
compiled <suite>.test binaries, mirroring e2e.yml: SPINIFEX_E2E=1 with
SPINIFEX_MODE single|multinode, per-suite logs to ARTIFACT_DIR, then
go-junit-report + e2e-analyze + upload-artifact. Adds a spinifex checkout for
the composite actions and pulls /run/spinifex/console-*.log so LB microVM
(lb-agent) state is captured on failure. Cells 17 (tofu) and 20 (baremetal)
stay on bash — no Go suite exists.

* ci(e2e-nightly): cell-17 RCA analytics + cell-20 cosmetic alignment

run-tofu-examples-e2e.sh now emits go test -v markers per workbook so the
cell-17 step's tee'd test-tofu.log flows through the same go-junit-report ->
e2e-analyze -> upload-artifact chain as the Go suites, producing junit-tofu.xml
and an analysis bundle for RCA. Align e2e-baremetal.yml (cell 20) ARTIFACT_DIR,
analyze title, and artifact name with the matrix cells so CI-RCA discovers its
bundle uniformly.

* ci(e2e-nightly): wire host journal + VM consoles into RCA bundles

Collect now emits the canonical spinifex-journal.log (concat of all nodes)
that the e2e-analyze bundler slices into each failure's journal.log, so
elbv2-service/vpcd/daemon lines in the failure window travel with the bundle
(previously the journal uploaded as spinifex-journal-node*.log, a name the
bundler never reads). Rename the console capture to vm-console-node* and note
it also carries guest cloud-init/sshd consoles, covering the cell-3 internal-LB,
cell-18 reboot-datapath, and cell-9/10 guest-ssh blind spots.

* ci(e2e): self-heal runner /dev + --one-file-system on workspace clean

Clean Workspace now detaches any leaked iso-builder rootfs mount, recreates
/dev/null if a prior build clobbered it, and uses rm --one-file-system so the
clean can't descend into a still-mounted devtmpfs and delete host device nodes.
Guards both the matrix cells and the bare-metal job against the host-wide
/dev/null wipe that fails go build with 'open /dev/null: no such file or
directory'.
2026-06-10 07:52:09 +10:00
Josh Siviour 4ef19fc488 feat(ui): EKS console (#310) 2026-06-09 10:57:50 +10:00
Julian Sommer 15cd1a1ad7 feat: sts get session token (#321) 2026-06-09 10:51:28 +10:00
Josh Siviour e44221d653 feat(eks): public/private endpoint access + publicAccessCidrs-driven NLB scheme & SG (#315)
* feat(eks): public/private endpoint access drives NLB scheme + IP:port reachability

Parse resourcesVpcConfig.endpointPublicAccess/endpointPrivateAccess/
publicAccessCidrs (defaults public=true, private=false, cidrs=0.0.0.0/0) and
reject the both-disabled combination. Public access provisions an
internet-facing NLB and publishes its external frontend IP as the cluster
endpoint; private access keeps the NLB internal and uses the VPC frontend IP.

Inject the resolved endpoint IP into the k3s apiserver tls-san so the
published IP:443 endpoint serves a cert valid for verified TLS, and surface
the access fields in DescribeCluster. The e2e suite now connects via the
verified endpoint with TLS verification on, dropping the insecure mgmt-IP
shim.

* fix(eks): authorize NLB->apiserver ingress on control-plane SG

The cluster control-plane security group was created with empty ingress, so
the NLB's backing LB VM could not reach the apiserver on 6443 from inside the
VPC. The target group stayed unhealthy and the published endpoint never
served. Authorize tcp/6443 on the control-plane SG from the VPC CIDR after the
SGs are created; public exposure remains gated at the NLB front-end by
publicAccessCidrs, so this only opens the in-VPC LB->apiserver hop.

Extend SubnetVPCResolver with GetVPCCIDR so the service can resolve the VPC
CIDR for the rule.

* feat(eks): thread publicAccessCidrs to NLB SetLoadBalancerIngressCIDRs

Wire the cluster's publicAccessCidrs (parsed from CreateCluster
ResourcesVpcConfig) through EnsureClusterNLB to the ELBv2
SetLoadBalancerIngressCIDRs override so a public cluster endpoint can be
narrowed below the wide-open scheme default. The override only fires for
an internet-facing NLB whose CIDRs restrict below 0.0.0.0/0; internal
NLBs and the default keep the LB's out-of-the-box ingress.
2026-06-08 11:27:48 +10:00
Josh Siviour 527308c342 fix(baremetal): empty modules.dep + capacity-aware single-node e2e (#305)
* feat(elbv2): localize internal-LB heartbeat failures for baremetal debug

Add diagnostics so a stuck-in-provisioning internal LB can be pinned to
one of three points on the heartbeat path:

- lb-agent failure log now carries the gateway target + region, so the
  microVM serial console shows whether the heartbeat hit a transport
  error (never reached AWSGW) or an HTTP-level rejection.
- AWSGW LBAgentHeartbeat logs receipt (debug) and warns on the
  previously-silent not-found/account-mismatch reject path, so the
  journal distinguishes 'heartbeat arrived but cannot transition' from
  'heartbeat never arrived'.

* test(e2e/lb): fail fast on stuck internal LB + probe fw_cfg on microvm boot

- lb suite: gate the remaining internal subtests on Internal_ALB. The
  internal LBs share one mgmt-return datapath and fixture, so if the
  first never reaches active the rest time out identically — skip them
  instead of burning ~5m per subtest (~20m saved on a known-bad run).
- microvm init.sh: widen the fw_cfg netcfg poll window and, on the
  not-available path, dump DMI / loaded modules / qemu_fw_cfg by_name
  listing / dmesg fw_cfg to the serial console before init exits, so a
  boot panic is diagnosable from the captured console log.

* fix(microvm): insmod fallback for fw_cfg + fail build on bad modules.dep

Baremetal-built microVM images shipped an unusable modules.dep (the build
host's depmod errors were swallowed), so busybox modprobe in the guest init
silently no-oped, qemu_fw_cfg never loaded, /sys/firmware/qemu_fw_cfg was
absent, init could not read opt/spinifex/netcfg, and the LB microVM panicked
before lb-agent ran — leaving internal LBs stuck in provisioning.

init.sh: load_mod() tries modprobe, then falls back to insmod by explicit
path (.ko/.ko.gz) which needs no modules.dep, so the guest boots regardless
of which host built the image.

build-microvm-image.sh: require depmod, drop the error swallow, and assert
modules.dep is non-empty after depmod so a boot-broken image fails the build
loudly instead of silently.

* fix(microvm): decompress kernel modules before depmod

Debian trixie's kmod 34 depmod silently emits an empty modules.dep when fed
Alpine's gzip-compressed (.ko.gz) modules — reproduced: 928 modules in, 0-byte
modules.dep out. The guest's busybox modprobe then no-ops, qemu_fw_cfg never
loads, and internal LB microVMs panic before lb-agent runs (stuck provisioning
on bare metal; GitHub-hosted runners ship a kmod that reads .gz, so normal CI
was unaffected).

Decompress kept modules to plain .ko after the keep-list strip, before depmod.
Host depmod then produces a usable map regardless of its gzip support, and the
guest loads modules without a decompressor.

* feat(e2e): capacity-aware scheduling for dense single-node runs

Denominate daemon schedulable capacity in physical cores instead of
logical threads, and auto-serialise e2e launches when the cluster is at
capacity, so a single hyperthreaded node stops over-packing guests.

Daemon: NewResourceManager sizes hostVCPU from physicalCoreCount()
(distinct physical-id:core-id pairs in /proc/cpuinfo) rather than
runtime.NumCPU(). Falls back to logical CPUs when topology is absent
(non-Linux, container, ARM cpuinfo without core id). On a hyperthreaded
box this halves the over-pack: with the unchanged 2 vCPU reserve the
i5-12600H schedules 10 vCPU (12 cores - 2) instead of 14, keeping the
boot-spike CPU within real cores.

Harness: capacityRetryEC2 decorates the EC2 client and, on
InsufficientInstanceCapacity, waits and retries RunInstances instead of
failing — turning the daemon admission gate into back-pressure so
concurrent subtests queue. AWSClient.EC2 is now the wrapped
ec2iface.EC2API; EC2Conf keeps the raw *ec2.EC2 for .Config access.

Adds unit tests for parsePhysicalCores (hyperthreaded/hybrid/dual-socket/
fallback) and the retry decorator (retry-then-succeed, non-capacity
passthrough, deadline).

* fix(daemon): SPINIFEX_HOST_VCPU override so small CI hosts can start

Denominating capacity by physical cores starves tiny hosts: a 2-physical-
core CI runner leaves 0 schedulable after the default 2-vCPU reserve, so
NewResourceManager refuses to start and every daemon-constructing unit test
fails. The previous runtime.NumCPU() count (4 logical on the same runner)
incidentally cleared the reserve floor.

Add a SPINIFEX_HOST_VCPU escape hatch (symmetric with the existing
SPINIFEX_RESERVED_* overrides) that pins the detected core count for hosts
whose /proc/cpuinfo topology misreports, and pin it to 4 in the daemon
TestMain so unit tests are deterministic regardless of runner topology.
Reserve stays at the default 2.
2026-06-07 21:34:39 +10:00
Josh Siviour 496424c41c test(eks): EKS control-plane E2E suite (Sprint 6g) (#304)
* test(eks): EKS control-plane e2e suite (Phase 1, API-level)

Add tests/e2e/eks/eks_test.go driving the EKS lifecycle against awsgw:
CreateCluster -> ACTIVE (endpoint + CA asserted), kubeconfig artifact
assembly, AccessEntry CRUD, get-token presign decode, DeleteCluster -> gone.

Harness: EKS SDK client on AWSClient + WaitForEKSCluster{Active,Deleted}
pollers. Wire e2e-eks into the Makefile and the single-node CI suite list.

* test(eks): Phase 2 kubectl get-nodes data-plane gate

Add the reachability shim (harness/eks_mgmt.go) that reads the control-plane
VM's br-mgmt IP from the cluster KV meta over NATS, plus a kubectl wrapper
(harness/kubectl.go) that skips when kubectl is absent. KubectlGetNodes points
kubectl at https://<mgmtIP>:6443 (insecure-skip-tls-verify; cert SANs the NLB
DNS, not the mgmt IP) with an aws eks get-token exec block and polls until the
control-plane node is Ready — the get-token webhook auth regression gate.

IRSA pod + the real NLB/Route53 passthrough are deferred to a follow-up.

* test(eks): tolerate doubled NotFound code + dial loopback NATS

DeleteCluster detection matched the SDK constant ResourceNotFoundException,
but awsgw emits ResourceNotFoundExceptionException on the wire — match the
NotFound code by substring so deletion is detected immediately instead of
timing out. The mgmt-IP shim dialed NATS.Host verbatim (0.0.0.0:4222), which
the serving cert does not SAN; rewrite the wildcard bind to loopback.

Validated live: all 6 subtests green, kubectl get nodes shows the
control-plane node Ready via the get-token webhook auth path.

* feat: build system images with libguestfs (no host block devices)

Replace the qemu-nbd + sudo mount + chroot + /dev-bind flow in
build-system-image.sh with libguestfs (virt-customize / virt-resize /
guestfish). Every operation runs in an isolated userspace appliance VM with
its own kernel, so the build touches no host block device, no host mount, and
needs no sudo — removing the failure class that wiped the host's /dev on shared
CI runners (a leaked /dev bind traversed by rm -rf).

Applies to all distros: alpine resizes via guestfish e2fsck+resize2fs, ubuntu
via virt-resize; customization (packages, binaries, files, setup script,
service enable, cache cleanup) is one ordered virt-customize invocation.
LIBGUESTFS_BACKEND=direct (no libvirt on runners).

Wire the eks-node AMI into the single-node e2e job: the runner builds the
device-free raw, ships it to the VM, and the VM imports it (predastore/NATS are
VM-only) so the eks suite's CreateCluster has an AMI to boot.

* feat(eks): bootstrap + state-report via AWS gateway broker

Converge the EKS control-plane → host channel onto the ELBv2 lb-agent model:
the K3s server VM publishes its bootstrap envelopes and periodic state reports
via SigV4-signed HTTPS POST to the AWS gateway, instead of dialing core NATS
directly. A new gateway action (PublishInternal, POST
/clusters/{name}/internal-publish) relays the payload verbatim onto the
existing eks.bus.* / eks.state.* subjects, so the daemon-side subscriber, KV
persistence/encryption, and reconciler are unchanged.

This removes the VM's need to reach NATS at all, so NATS can stay
cluster-internal behind its bind/firewall/mTLS controls — the prior direct
path broke when NATS was not VM-reachable (e2e CreateCluster timeout) and was
DDIL-fragile (fire-and-forget nats pub, no ack/retry).

- gateway/eks: PublishInternal broker + route; closed-set channel/kind validation
- cmd/eks-gateway-publish: one-shot SigV4 publisher (retry-until-ack) baked into
  the eks-node AMI; replaces the nats-cli publisher in k3s-first-boot.sh and
  mulga-eks-state-report.sh
- handlers/eks + daemon: hand the K3s server VM the mgmt-reachable gateway URL +
  system (Predastore) SigV4 creds + gateway CA instead of NATS URL/token/CA;
  new resolveSystemGatewayBaseURL (mgmt-bridge-first)
- eks-node image: build/install eks-gateway-publish, drop nats-cli download

* feat: migrate eks-token-webhook to the AWS gateway broker

The TokenReview webhook dialed core NATS directly (STS verify + AccessEntry
KV read). On a mgmt-only control-plane VM with NATS bound off-subnet that
fails, so the webhook exited at startup, the apiserver token-webhook
kubeconfig was never written, and the apiserver crash-looped — the cluster
never reached ACTIVE.

Converge it onto the same gateway-broker model as bootstrap/state-report and
ELBv2's lb-agent: the webhook SigV4-signs an HTTPS POST {accountId,token} to
/clusters/{name}/token-review, and the gateway runs the verify + lookup
host-side and returns the resolved identity. The VM now speaks no core NATS.

- internal/eksgw: shared on-VM SigV4 client (also adopted by
  eks-gateway-publish).
- handlers/eks/token_review.go: Authenticate decision core (moved from the
  webhook binary) + ResolveTokenReview host-side wiring.
- gateway/eks token-review route + WebhookTokenReview handler.
- webhook: drop NATS, relay via eksgw.Client; broker fault -> 503 (retryable)
  not a silent deny.
- initd: export EKS_GATEWAY_* / EKS_ACCESS_* creds, not SPINIFEX_NATS_*.

* fix: build eks-node helpers with GOFIPS140; cover token-review broker

eks-gateway-publish blank-imports internal/fipsboot, whose init() panics
unless the binary is built with GOFIPS140=v1.0.0. The eks-node AMI build
omitted the flag, so the binary paniced at first boot right after k3s came
up — k3s-first-boot (set -eu) died before publishing the bootstrap token and
the cluster never reached ACTIVE. Build both eks-node helpers with
GOFIPS140=v1.0.0, matching cmd/lb-agent.

Add NATS-backed unit tests for ResolveTokenReview and the WebhookTokenReview
gateway handler (verify responder + account KV) to cover the new broker path.

* fix: export first-boot env so eks-gateway-publish child sees it

k3s-first-boot.sh and mulga-eks-state-report.sh sourced /etc/spinifex-eks/
first-boot.env but never exported the vars, so the eks-gateway-publish child
process inherited an empty environment and exited '--account-id is required'.
The control-plane VM came up healthy but never published its bootstrap
envelopes, so the cluster never reached ACTIVE. Wrap the source in set -a /
set +a so the KEY=value lines export to child processes.

* fix: make awsgw start --host override actually bind

The 'awsgw start --host' override printed 'Overwriting awsgw host' but the
assignment was commented out, so AWSGW always bound the spinifex.toml host
(a specific WAN IP, e.g. 192.168.0.10). A system instance (EKS K3s control
plane, internal LB agent) has a mgmt-only NIC on-link to the br-mgmt IP with
no route to the WAN IP, so its SigV4 POSTs to the gateway were refused and the
cluster never bootstrapped. Honour --host (e2e passes 0.0.0.0:9999) so AWSGW
listens on all interfaces incl. br-mgmt; the serving cert already SANs every
local interface IP, and WAN clients still reach it.

* test(eks): install kubectl on e2e VM so KubectlGetNodes runs

The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
aws eks get-token -> kubectl -> apiserver -> token-webhook -> gateway broker
-> STS verify path — the only end-to-end exercise of the token-review broker.
The e2e binaries run on the VM, where kubectl was absent, so
harness.NewKubectl t.Skip'd it and the path went unvalidated. Install a
checksum-verified kubectl (pinned to the k3s server version) on the VM,
gated on e2e-eks.
2026-06-07 20:42:25 +10:00
tomnewton-mulga 412eb618b0 Adding GPU passthrough e2e tests - intended to be run on Wattle 2026-06-05 13:47:11 +10:00
joshsiv-mulga 8e38faaede fix(elbv2): make NLB nginx health-check pathway robust in CI
E2E NLB suites timed out at "0/2 healthy" while ALB suites on the same
target instances went healthy in ~5s. The host daemon journal shows the
NLB agent reporting an empty server list (no probe results at all), which
happens when the lb-agent never enters nginx mode — i.e. `nginx -t`/start
fails closed inside the LB microVM, so the agent never probes and reports
nothing. The data plane itself is verified working; this is purely the
active-health-check pathway failing to converge in CI.

- lbagent: pre-create nginx runtime prefix dirs (pid dir + /var/lib/nginx,
  /var/lib/nginx/tmp, /var/log/nginx) before `nginx -t`, mirroring
  reloadHAProxy's socket-dir pre-create. A stripped Alpine initramfs may
  lack these, dead-ending the whole NLB health path.
- build-microvm-image.sh: assert the nginx binary and ngx_stream_module.so
  are present in the image and fail the build loudly, instead of surfacing
  as an opaque e2e health timeout.
- e2e: capture the LB VM serial console to the artifact bundle on NLB
  health-wait failure. The agent's engine activation and any `reload nginx:`
  error land on the guest console, which the host journal never sees.
2026-06-05 08:14:18 +10:00
Josh Siviour 554c5d96c7 feat(elbv2): NLB L4 data plane on nginx stream + UI parity (#295)
* feat(elbv2): NLB L4 data plane on nginx stream (mulga-siv-952)

Network load balancers render an nginx `stream` config (TCP/UDP/TLS/
TCP_UDP) instead of HAProxy, which cannot load-balance UDP in any
release. The gateway tags GetLBConfig with Engine=nginx; the LB agent
dispatches to the nginx engine (config/pid/cert paths + reload) and
skips HAProxy stats polling. ALB stays on HAProxy. nginx is added to
the microvm image.

* docs(elbv2): note NLB runs nginx stream, ALB runs HAProxy (mulga-siv-952)

* fix(elbv2): load nginx stream module for NLB data plane

Alpine's `nginx` apk is built without the stream module — it ships
separately as `nginx-mod-stream`. Without it, the generated NLB config
fails `nginx -t` with [emerg] unknown directive "stream", so the agent
never applies it. Add the package to the microvm image and emit a
`load_module .../ngx_stream_module.so;` directive at the top of the
generated config.

* fix(elbv2-ui): allow single-subnet LB + full NLB protocols in listener edit

Create-LB form required min 2 subnets while the CLI/backend accept 1;
relax to min(1) to match. Listener add/edit only offered HTTP/HTTPS —
add the NLB protocol set (TCP, UDP, TLS, TCP_UDP), driven by LB type,
with cert/ssl-policy fields gated on secure protocols (HTTPS/TLS).

* feat(elbv2): active target health checks for nginx NLBs

nginx stream has no active upstream probing, so NLB targets never
advanced past initial — DescribeTargetHealth stayed initial forever and
WaitForTargetsHealthy timed out for every NLB. GetLBConfig now delivers
per-target HealthTarget specs (daemon-computed server name, probe
address, protocol/path) for network LBs; the lb-agent probes each target
every tick (TCP dial or HTTP/HTTPS GET) and reports UP/DOWN via the
existing heartbeat path, so the daemon's threshold hysteresis advances
targets to healthy. ALBs are unchanged (HAProxy stats).

Adds an Internal_NLB_UDP e2e suite gating on WaitForTargetsHealthy plus a
UDP datagram round-trip through the VIP.
2026-06-05 00:11:51 +10:00
joshsiv-mulga eef337fb80 test(e2e): make egress baseline reproduce the real IGW gap (fresh VPC)
The previous new-subnet egress baseline used the default VPC, whose main
route table is prebaked with 0.0.0.0/0 -> IGW at bootstrap and reconciled by
the vpcd drift loop, so a subnet created in it is reachable regardless of the
create-time gap — the test passed and never flagged the bug.

Replace it with runNewVPCEgressBaseline: a brand-new VPC where the IGW route
is added to the MAIN route table BEFORE the public subnet is created. The
subnet then implicitly joins a main RT that already carries the route, but
CreateSubnet does not recompute per-subnet egress, so the subnet's OVN egress
LRP is never installed and the instance has no datapath to the IGW. With an
open SG, routing is the only barrier, so the instance is unreachable — red
until CreateSubnet triggers a per-subnet egress recompute.

PoolMode-gated like runVPCSubnetE2E (fresh VPC needs OVN IPAM for a public
IP). runVPCSubnetE2E doesn't catch this: it associates a custom RT then adds
the route, so the route mutation enumerates the subnet and installs the LRP.
2026-06-04 20:14:32 +10:00
joshsiv-mulga c9a4e904a8 test(e2e): fresh-install reachability baselines + drop qemu hostfwd fallback
Add baseline tests that confirm two fresh-install regressions the existing
suite masks (it authorizes tcp/22 on the default SG before every SSH probe):

  - default SG default-deny gates external reach of instances in the
    default subnet (SG, not routing)
  - a newly-created public subnet never gets its OVN IGW egress LRP, so it
    has no route to the VPC IGW despite MapPublicIpOnLaunch

Plus a positive test: instances sharing the default SG can communicate via
ICMP (same-host single-node, cross-host multinode).

Single-node baselines run after TestClusterStatsCLI (0-VM gate); multinode
after TestMultinodePreflight (fixture init). Instances are self-cleaning so
they don't perturb the singleton-VM ordering.

Remove the qemu hostfwd SSH fallback from the harness (InstancePublicSSHHost,
GuestSSHEndpoint) - it bypasses the OVN datapath and masks these regressions.
Endpoints now t.Fatal when no public IP is assigned.
2026-06-04 19:56:02 +10:00
Julian Sommer ff0dc42562 feat: IMDS subnet level (#292) 2026-06-04 16:33:45 +10:00
Julian Sommer bcb49cd771 feat: IMDS v1 (#266) 2026-06-04 13:32:44 +10:00
Julian Sommer 044d35737e feat: remove bash e2e for e2e-go 2026-06-03 10:32:30 +10:00
joshsiv-mulga 40deb1e4c2 fix(e2e): add explicit IGW default route in run-lb-e2e
After PR #270 the VPC-LR-wide 0.0.0.0/0 -> IGW static route is no longer
installed implicitly at attach-internet-gateway. Subnets whose effective
route table lacks an internet-bound route get an OVN drop policy at
SubnetEgressPriorityDrop (1100), which kills LB target reply traffic
before un-DNAT can fire and stalls the LB internal-client probe until
job timeout.

Add the standard AWS pattern: describe-route-tables -> create-route
0.0.0.0/0 -> IGW. This fires vpc.add-igw-route + vpc.ungate-subnet-egress,
removing the drop policy and installing a reroute policy at
SubnetEgressPriorityIGW (1000).
2026-06-02 23:49:23 +10:00
Josh Siviour dd65a2dfa6 feat(elbv2): ListenerRules CRUD with HAProxy ACL routing (#275)
* feat(elbv2): ListenerRules CRUD with HAProxy ACL routing

Implement CreateRule, ModifyRule, DeleteRule, DescribeRules, and
SetRulePriorities for ALB listeners. Rules support host-header,
path-pattern, http-header, http-request-method, source-ip, and
query-string condition fields. Actions limited to forward (single
target group); redirect and fixed-response deferred.

HAProxy frontends emit per-rule ACLs with `use_backend ... if <acls>`
ordered by priority. Wildcards in host/path map to HAProxy match
modes (`-m beg/end/sub/str`). Condition values are validated at
store-time and re-validated at render-time to prevent config
injection. Target groups referenced by rule actions are protected
against deletion. Deleting a listener cascades its rules.

Frontend adds a Rules tab beneath each listener row showing rules
with conditions and target groups, plus add/edit/delete dialogs
with field-typed condition inputs.

mulga-950

* test(elbv2): rule CRUD e2e + render coverage for all condition fields

Adds Internal_ALB_ListenerRules subtest exercising CreateRule, ModifyRule,
DeleteRule, and DescribeRules over a real ALB with HAProxy. Two TGs with
disjoint backends (app#0 -> tgA, app#1 -> tgB) let the probe responder
identify which TG actually served the request, so path-pattern routing
can be asserted without inspecting internal state.

Extends app-userdata.sh with alpha/ and beta/ sub-paths and extends
client-userdata.sh trigger to accept optional http_path and host fields
so probes can target specific paths and Host headers. Both changes are
backward-compatible: existing callers default to / with no Host override.

Adds unit-test coverage for every condition-field render path
(http-header, http-request-method, query-string with and without key,
source-ip) plus error-path validation tests for CreateRule, ModifyRule,
DeleteRule, DescribeRules, and SetRulePriorities. Lifts diff coverage
above the 60% gate.

* test(elbv2): wait for tgB healthy after rule creation, not before

Spinifex only health-checks a target group once it is referenced by a
listener default action or a rule action. The previous ordering waited
for tgB healthy before CreateRule wired it, so health checks never ran
and the wait timed out. Move the WaitForTargetsHealthy(tgB) call after
createPathRule.

* fix(elbv2): include rule-action TGs in TargetGroupsForLB

healthChecker uses Store.TargetGroupsForLB to map HAProxy server
statuses back to TargetGroupRecord entries. The previous implementation
walked only listener DefaultActions, so a target group referenced only
by a CreateRule action was invisible to the health checker — HAProxy
probed its backends but the resulting health never propagated to the
store. DescribeTargetHealth reported the targets as 'initial' forever.

Extend TargetGroupsForLB to walk rule actions per listener as well.
Adds a unit test that wires a TG via CreateRule and asserts the
resolver returns it.

* test(elbv2): fix wait-for-rule logic to poll away from default backend

waitForPathRouted required the caller to name the expected new host, but
at the post-CreateRule wait point we only know the default host (appA);
the rule target hostname (appB) is discovered by the subsequent probe.
The previous code passed appA as both "new" and "old", so the wait
checked "see appA" against "/alpha/" responses — which never matched
because the rule had successfully routed appA out.

Replace with two narrower helpers: waitForPathRoutedAway (poll until
host X stops appearing) and waitForPathRoutedTo (poll until host Y
appears). Update the three call sites to use the variant that matches
what the caller actually knows at that point.

* fix(elbv2): IAM-style error envelope + ModifyTargetGroup + listener attr stubs

ELBv2 success responses use the IAM-style XML envelope (<ErrorResponse>),
but error responses were emitted with the EC2 envelope (<Response>),
which aws-sdk-go-v2 cannot deserialize for ELBv2 and surfaces as
"UnknownError: UnknownError" — defeating provider-side InvalidAction
ignores (e.g. terraform-provider-aws v5.100's DescribeCapacityReservation
swallow). Route elasticloadbalancing errors through GenerateIAMErrorResponse.

Add ModifyTargetGroup end-to-end (interface, impl, NATS shim, daemon
handler, gateway dispatch) so terraform-provider-aws can set health-check
fields after CreateTargetGroup without infinite drift. Persist
HealthCheckEnabled on TargetGroupRecord and emit it in tgRecordToSDK so
the provider sees the configured value across refresh cycles.

Stub DescribeListenerAttributes / ModifyListenerAttributes with
hand-rolled input/output structs (aws-sdk-go v1.55 lacks these types).
Returns empty attribute list — sufficient for default-config workflows.

* test(elbv2): cover ModifyTargetGroup + listener attribute stubs

Extend handler tests with all-fields, invalid path/matcher, wrong-account
ModifyTargetGroup cases plus HealthCheckEnabled assertion on
CreateTargetGroup. Add gateway-layer validation tests for the new
DescribeListenerAttributes, ModifyListenerAttributes, and ModifyTargetGroup
dispatchers. Raises diff coverage from 58.8% to 63.7%.
2026-06-02 12:15:42 +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 287487a4f2 feat(elbv2): implement ModifyListener (#268)
* feat(elbv2): implement ModifyListener

Adds AWS-compatible ModifyListener to the spinifex ELBv2 stack so
listeners can be edited in place — port, protocol, and default
actions — without the current delete+create dance that briefly drops
the listener from HAProxy.

- Handler interface, impl, and NATS wrapper get ModifyListener;
  validation mirrors CreateListener (LB type ↔ protocol, dup-port
  excluding self, target-group existence + protocol compatibility).
- Daemon subscribes to elbv2.ModifyListener on the spinifex-workers
  queue and a new gateway action dispatches it.
- Frontend exposes a per-row edit button that pre-fills a dialog from
  the existing listener and calls ModifyListenerCommand, replacing
  the "Listeners cannot be edited" note.
- Unit + NATS round-trip + frontend tests cover the new flow.

Cert / SslPolicy / Alpn handling stays out of scope and lands with
HTTPS listener support.

* test(e2e): cover ModifyListener in lb harness

Adds an Internal_ALB_ModifyListener subtest to TestLoadBalancer that
exercises in-place listener edits end-to-end:

- Provisions tgA + tgB sharing the same app instances.
- Brings up an internal ALB with HTTP:80 → tgA, baselines round-robin.
- ModifyListener port=8090, asserts DescribeListeners shows the new
  port and that probes on 8090 still round-robin — proves HAProxy is
  reconciled without dropping the listener (the regression mulga-944
  fixes; the frontend previously delete+created on every edit).
- ModifyListener DefaultActions → tgB, asserts traffic still flows.

Also factors a probeAtPort helper so the order JSON can target any
listener port, and updates TEST_COVERAGE.md with phase 3e.

* fix(ui): widen listener add/edit dialog so content stays inside the box

AlertDialogContent defaults to max-w-xs (20rem) which clipped the listener
form on the right edge. Use a 32rem cap with grid-cols-[minmax(0,1fr)] +
min-w-0 on the form so CSS grid tracks shrink instead of expanding to
max-content.

* test: cover ModifyListener + system instance dispatch

Lifts ModifyListener diff coverage by exercising nil/empty arn paths,
NLB protocol acceptance + rejection, protocol-only revalidation against
existing target groups, and the launcher-nats wire format error paths
(missing input, missing instance type, decode failures, no responder,
default timeout fallback).

Adds a new spinifex/daemon/daemon_system_dispatch_test.go covering
splitSubjectTail edge cases, respondWithSystem{Launch,Terminate}{Output,
OK,Error} envelope shape, handler entry points for invalid JSON and
missing instance ID, and subscribeSystemTerminate nil-conn + idempotent
re-subscribe behaviour.

Fixes the flaky TestHandleEC2DescribeInstanceTypes/NoFilter… subtest by
selecting a 2-vCPU instance type that also fits schedulable memory, so
hosts with smaller schedulableMem (CI 4 vCPU / ~13 GiB) don't pick a
16 GiB-mem type that fails allocation.

* test(e2e): wait for tgB after listener swap, not before

Spinifex health-checks only target groups wired into a listener's
HAProxy config. Waiting for tgB to report healthy before
modifyListenerDefaultTG attaches it to the listener is impossible —
the agent never probes its targets, so they stay in initial and the
2m EventuallyErr trips.

Drop the pre-swap tgB wait. After modifyListenerDefaultTG, replace
the fixed 3s sleep with WaitForTargetsHealthy on tgB (post-swap),
which is now the listener's DefaultAction.

* test(e2e): derive LB name from ARN in lbENI/deleteLB ENI filter

The ENI description filter was building the LB-name token from a
hard-coded "alb"/"nlb" template (e.g. `ELB app/lb-e2e-alb-int/<id>`),
which only matched LBs whose name happened to be `lb-e2e-alb-*` or
`lb-e2e-nlb-*`. The ModifyListener suite names its LB `lb-e2e-mod`,
so lbENI's EventuallyErr could never find the ENI and tripped after
30s. deleteLB had the same bug: sysInstanceID stayed empty and
WaitForENICleanup waited for a non-existent filter, allowing the
next suite's createLB to race the sys.micro VM reclaim.

Parse the LB name straight out of lb.ARN (penultimate path segment,
same shape createLB already relies on) so the filter matches any
caller-chosen name. Drop the now-redundant scheme arg from lbENI.

* test(e2e): poll for listener serving on new port after ModifyListener

The post-modifyListenerPort settle was a hard 3s sleep, but lb-agent
polls for new HAProxy config on a 5s tick. After the API returns, the
agent has typically not yet detected the config-hash change, so
HAProxy is still bound to the old port — every probe to the new port
RSTs and probeAtPort sees 0/0 successful.

Replace the sleep with waitForListenerServing: a polling loop that
fires 2 probes via the shared trigger client every 2s until at least
one returns a parsable instance_id, with a 60s ceiling. Factor the
trigger-and-fetch out of probeAtPort into probeOnce so the polling
helper can reuse it without the require.* asserts.
2026-05-30 09:18:52 +10:00
Julian Sommer 94aa482931 feat: sts v1 (#261) 2026-05-29 10:42:37 +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
Julian Sommer f525006d87 feat: IAM Roles part 2 (#256) 2026-05-27 15:44:18 +10:00
Julian Sommer d323facf65 fix: pseudo multi node, old behaviour asserted wrong 2026-05-22 11:26:54 +10:00
joshsiv-mulga 38202206bc fix(e2e/single): TestClusterStatsCLI back to sequential pre-launch
TestClusterStatsCLI asserts baseline cluster state ("0/" CPU used, "No VMs found") which doesn't hold once the singleton VM is up. Moving it into bucket #1 parallel (siv-128) ran it AFTER all sequential VM launches, so the spx top nodes output reported 4/12 CPU + 1 VM and the assertion failed.

Move it back into the sequential block, ahead of TestInstanceLaunch, so the baseline assertion sees the cluster pre-launch.
2026-05-21 23:14:30 +10:00
Josh Siviour 40f8c4da94 refactor(e2e/single): drop phase numbers + parallel bucket #1 (#247)
- Rename 36 phaseN_X / phaseIAM_N funcs to runX.
- Drop numeric suffix from 7 TestIAMN_X wrappers (TestIAM1_UserCRUD -> TestIAMUserCRUD).
- Rewrite harness.Phase log strings "Phase N - X" -> "Single - X" to mirror multinode convention.
- Add t.Parallel() to bucket #1: 10 read-only / own-everything top-level Tests (Environment, ClusterStatsCLI, Discovery, SerialConsoleAccess, KeyPairs, Image, TagManagement, AccountScoping, VPCSubnetE2E, RouteTableValidation). Sequential bucket retains source-order singleton lifecycle (InstanceLaunch through FinalClusterStats).
2026-05-21 22:47:26 +10:00
Josh Siviour 92fa467c90 refactor(e2e/multinode): flatten spread + parallel bucket + drop phase numbers (#245)
* refactor(e2e/multinode): flatten phase 11 + parallelize bucket #1 + drop phase numbers

Split monolithic phase11_PlacementAndNATGateway (~430 lines, 8 inline
sub-steps) into 6 top-level Tests, each owning its own VPC graph via
t.Cleanup and layered setup helpers (spreadSetupVPC, spreadSetupBastion,
spreadSetupPrivateTrio, spreadSetupNATGW). The cleanup-ordering test
flips *bool latches as each explicit teardown step succeeds so the
LIFO unwind is a no-op on the success path.

Add t.Parallel() to read-only / independent Tests: ClusterHealth,
InstanceDistribution, GuestSSH, VPCNetworking. Deviates from the
mulga-siv-127 bead's bucket-#1 listing by keeping CrossNodeGateway
sequential (asserts a snapshot equality across gateways that the
parallel VPC test would break by churning instance count mid-assert)
and CrossNodeOps sequential (stops/starts trio[0], would race
GuestSSH). Both deviations documented in tests_test.go + plan doc.

Drop the bash phase numbering (phaseN_X funcs + "Phase N" log strings)
from the rest of the suite — 10 funcs renamed to runX. Phase numbers
were a port-time crutch; the Test names now describe each scenario and
phase numbers ageing into noise as bash is retired in Stage H.

* refactor(e2e/multinode): collapse spread Tests + control execution order

Two changes wedged together since they touch the same files:

1. Collapse the 6 top-level TestMultinodeSpread* Tests into a single
   TestMultinodeSpread with 6 t.Run sub-tests sharing one VPC + bastion +
   private trio + NAT GW setup chain. The 6-test split paid setup cost
   6× — bastion launch + private trio launch + NAT GW provision per Test
   pushed phase-11 wall-clock near 60min vs ~15min monolithic. Shared-
   setup layout keeps JUnit granularity (one row per sub-test) while
   restoring the monolithic budget. Setup helpers register t.Cleanup
   against the outer test so teardown runs once at the end (LIFO:
   NAT GW → trio → bastion → VPC).

2. Move the TestMultinodeSpread wrapper into tests_test.go so file-source
   order controls execution position. Previously the func lived in
   placement_nat_test.go which sorts before preflight_test.go and
   tests_test.go alphabetically — Spread ran FIRST in the sequential
   bucket, defeating Preflight's fail-fast on a sick cluster. Wrapper now
   runs after NodeRecovery so the cluster has been fully degrade-tested
   before Spread's 4-VM + NAT GW + custom-VPC graph spins up.
2026-05-21 20:53:59 +10:00
Josh Siviour bedc35344f feat(e2e/multinode) (#242)
* feat(e2e/harness): multinode cluster wait helpers + AWSClientForGateway (Stage A)

Stage A of mulga-siv-90. Compile-only; no tests wired yet.

multinode_cluster.go:
- Cluster.WaitNATSPeers polls /routez per node, dedupes by remote_id.
  want=2 on healthy 3-node, =1 after a node-down event.
- Cluster.WaitPredastoreHealthy / WaitGatewayHealthy probe HTTPS per node
  via insecureHTTPClient (multinode certs are self-signed across peers).
- Cluster.WaitDaemonReady runs DescribeInstanceTypes through every
  node's gateway, not just env.ServiceIPs[0] — catches a daemon that's
  only registered on one node.

multinode_aws_via.go:
- AWSClientForGateway clones *Env, swaps ServiceIPs to a single node's
  Addr, returns a fresh AWSClient. Lets phases 6/7 issue describe/
  stop/start across every node in turn (bash aws_via equivalent).

Init/Join helpers deferred to the pseudo-multinode bead (mulga-siv-91)
where they belong — real-multinode bootstrap is owned by tofu +
bootstrap-install.sh before the Go suite starts.

* feat(e2e/multinode): TestMultinodePreflight + TestMultinodeClusterHealth (Stage B)

Stage B of mulga-siv-90. First runnable Test* against a 3-node cluster.

multinode/main_test.go:
- Package-scoped singleton fixture, mirrors single/main_test.go.
- requireMultiNodeFixture skips when SPINIFEX_E2E unset or mode != multinode.
- Fixture = {Env, AWS, Harness, Cluster, Artifacts, TmpDir} — adds Cluster
  to the single-node shape via harness.ClusterFromEnv (reads SPINIFEX_NODES
  + SPINIFEX_SSH_USER/KEY).
- TestMain drains harness.Fixture.Close + removes TmpDir at process exit.

multinode/helpers_test.go:
- needAZ / needInstanceTypeArch / needAMI shims (memoized via harness).
- readyNodeCount substring counter — matches bash `grep -c "Ready"`.

multinode/tests_test.go:
- Top-level Test* wrappers (TestMultinodePreflight, TestMultinodeClusterHealth).

multinode/preflight_test.go (phase 1):
- /dev/kvm writable check (no /dev/kvm = test environment is wrong, fail fast).
- peer_ssh hostname on every remote node. Skips node[0] to match bash —
  the runner's loopback SSHd is not part of the contract.

multinode/cluster_health_test.go (phase 2):
- Cluster.WaitNATSPeers >= 2 per node.
- Cluster.WaitPredastoreHealthy / WaitGatewayHealthy across nodes.
- Cluster.WaitDaemonReady (DescribeInstanceTypes per gateway).
- spx get nodes shows N Ready, where N = cluster size.
- spx get vms returns (length only — content may be empty).

* feat(e2e/multinode): TestMultinodeInstanceDistribution + GuestSSH (Stage C)

Stage C of mulga-siv-90. Phase 3 (distribution) + phase 4 (guest SSH).

harness/multinode_distribution.go:
- InstanceHostingNode: SSH each peer, ps grep qemu-system | <id>.
  Reuses NewPeerSSH; returns nil on no match (caller decides fatality).
- CountInstancesPerNode + AssertSpreadAcrossNodes: fan-out + minNodes
  gate. Bash phase 3 warns-on-stacking; Go matches by accepting
  minNodes=1 + caller-side log.

harness/multinode_guest_ssh.go:
- GuestSSHPort polls hosting node's qemu cmdline for hostfwd=tcp:...
  reusing the existing hostfwdRE from ec2helpers.go (single regex, two
  capture groups: host + port).
- GuestSSHEndpoint prefers PublicIpAddress, falls back to hosting-node
  + hostfwd. Mirrors the phase 4 conditional.
- GuestSSHReady polls `echo ready` via SSH with bash-equivalent -o flags.

multinode/helpers_test.go:
- needKeyPair (EnsureKeyPair shim).
- needInstanceTrio: pkg-level sync.Once launches 3 nano instances on
  default VPC, registers terminate-on-process-exit. Phase 4+ reuse the
  same trio without re-launch.

multinode/distribution_test.go (phase 3):
- launch trio + assert >= 1 node hosting (log warn on stacking).
- spx get vms contains every trio ID.

multinode/guest_ssh_test.go (phase 4):
- For each trio instance: resolve endpoint, poll SSH ready, assert id
  reports ec2-user, log lsblk line count.

multinode/tests_test.go: TestMultinodeInstanceDistribution + TestMultinodeGuestSSH.

* feat(e2e/multinode): TestMultinode{Volume,CrossNodeGateway,CrossNodeOps} (Stage D)

Stage D of mulga-siv-90. Phases 5/6/7 — pure AWS API exercise (no
node-state churn).

volume_test.go (phase 5):
- Create 10GiB volume in discovered AZ, attach to trio[0] at /dev/sdf,
  detach, delete. Inline lifecycle — DeleteVolume in-test would race a
  registered Ensure* cleanup, surface as InvalidVolume.NotFound.

gateway_test.go (phase 6):
- Baseline DescribeInstances via the local gateway, then fan out
  AWSClientForGateway per non-local node. Require.Equal across all —
  cross-node fan-out regression surfaces immediately.

crossnode_ops_test.go (phase 7):
- Find trio[0]'s hosting node, stop via a different gateway, start via
  a third (or same other on 2-node fallback). Returns to running so
  later Test*s (phase 4 SSH, etc.) don't cascade-fail. otherTwoGateways
  mirrors the bash fallback chain.

* feat(e2e/multinode): TestMultinodeNode{Failure,Recovery} (Stage E)

Stage E of mulga-siv-90. Phase 8 (failure) + phase 9 (recovery) +
diagnostic primitives.

harness/multinode_node_lifecycle.go:
- StopNode SSHes `sudo systemctl stop spinifex.target`. Non-fatal on
  exit error (bash matches — cluster shutdown races the SSH conn).
- StartNode mirrors with sudo systemctl start.
- WaitNodeServiceReady polls a single node's HTTPS gateway. Distinct
  from cluster-wide WaitGatewayHealthy so a recovering node can be
  tracked without re-checking peers.

harness/multinode_diag.go:
- DumpAllNodeLogs fans out journalctl -u 'spinifex-*' per node into
  artifactsDir/journal-<node>.log. Tolerates per-node SSH failure.
- SpxGetNodesAcrossCluster strips trailing whitespace off SpxGetNodes.

multinode/node_failure_test.go (phase 8):
- Stop node2, register t.Cleanup restart safety net.
- WaitNATSPeers want=1 (degraded), DescribeInstanceTypes via node1+node3,
  DescribeInstances still returns the trio.

multinode/node_recovery_test.go (phase 9):
- StartNode node2 (idempotent — phase 9 in isolation still works).
- WaitNATSPeers want=2, WaitNodeServiceReady, spx get nodes 3 Ready,
  DescribeInstanceTypes via the recovered gateway.

* feat(e2e/multinode): TestMultinodeVPCNetworking (Stage F)

Ports phase 10 from run-multinode-e2e.sh. Stands up a fresh non-default
VPC + subnet (10.200.0.0/16 + 10.200.1.0/24), launches 3 instances into
the subnet, then asserts:

  - PrivateIpAddress is non-empty and matches 10.200.1.0/24
  - All 3 IPs are unique
  - SubnetId / VpcId on the instance match the freshly-created ids
  - IPs persist across stop -> start cycles

Independent of the package-level trio (which uses the default VPC) —
this phase needs full control over subnet allocation so resources are
created and cleaned up inline via t.Cleanup (LIFO: instances ->
subnet -> VPC).

* feat(e2e/multinode): TestMultinodePlacementAndNAT (Stage G)

Ports phase 11 from run-multinode-e2e.sh — the largest single phase
(~340 bash lines covering spread placement + NAT Gateway egress). Eight
sub-steps preserved with matching harness.Step markers so the Go run
cross-references cleanly with the bash log:

  Step 1: VPC + pub/priv subnets + IGW + main RT route + 2 SGs
          (bastion: tcp/22 anywhere; private: tcp/22 from bastion-sg,
           icmp from VPC)
  Step 2: bastion launch, wait SSH, scp key
  Step 3: spread placement group + 3 private instances via the group
  Step 4: validate spread (>=2 unique hosting nodes; >=3 = full PASS)
  Step 5: pre-NAT baseline — confirm NO internet from private VMs
  Step 6: allocate EIP + create NAT GW + private RT + 0.0.0.0/0 -> NGW
  Step 7: post-NAT — every private VM reaches 8.8.8.8 via NAT GW
  Step 8: inline teardown of NAT GW + private RT + EIP

Each resource is owned by the test (no Ensure* memoization) so a failure
mid-flight tears the full VPC graph down via t.Cleanup in LIFO order,
matching the bash teardown sequence. NAT GW state polling is duplicated
locally (waitForNATGatewayStateFatal/Best); the equivalents in
single/natgw_test.go are unexported. Promote to harness/poll.go once a
third consumer lands.

* ci(e2e-go): add multi-node Go E2E job + cert/lb on 3-node cluster (Stage H)

Adds e2e_go_multinode as a parallel job alongside e2e_go_single on the
e2e-go workflow. The single-node job is untouched apart from extending
its SUITES filter to drop the multinode suite (which only runs on the
ci-multi cluster).

What the new job does:

  - Provisions a 3-node tofu cluster via the same build-install-artifacts
    + bootstrap-install path as e2e_real_multi (bash).
  - Builds multinode.test / cert.test / lb.test on the runner, ships
    them to node1.
  - Runs each suite with SPINIFEX_MODE=multinode and the cluster csv
    in SPINIFEX_NODES / SPINIFEX_NODE_IPS. cert / lb branch internally
    on env.Mode so the same binaries cover both single + multi.
  - Pulls per-node journals (spinifex-journal-node1..N.log) for the
    e2e-analyze action so a node2/3 failure isn't blind.
  - Same JUnit + analyze + artifact-upload + status pipeline as the
    single-node job.

Other moves:

  - Default SUITES now includes e2e-multinode so push triggers fan
    out across single + multi automatically. e2e_go_reboot stays
    explicit-opt-in (unchanged).
  - tests/e2e/Makefile: e2e-multinode now sets MODE=multinode so
    `make e2e-multinode` doesn't fall back to single (which would
    skip every TestMultinode* on the SPINIFEX_MODE check).
  - tests/e2e/TEST_COVERAGE.md: cross-reference the Go port at the
    head of the Real Multi-Node section. The bash driver stays in
    place as executable spec until the Go run is soak-tested.
  - e2e_go_status now needs e2e_go_multinode; multi=skipped counts
    as success so a single-only dispatch still posts green.

* fix(e2e/multinode): NATS routez via peer_ssh + StartNode timeout + spx best-effort

CI run 26161792146 surfaced three regressions in the freshly-landed
multinode suite:

  1. WaitNATSPeers dialled node.Addr:8222 directly. NATS monitor binds
     127.0.0.1:8222 only (cmd/spinifex/cmd/templates/nats.conf:65), so
     every routez poll returned "connection refused". Switched to
     peer_ssh + curl http://127.0.0.1:8222/routez per node — matches
     the bash run-multinode-e2e.sh phase 2 pattern.

  2. StartNode / StopNode capped ssh at a 30s context. spinifex.target
     has 6+ dependent units (predastore, NATS, awsgw, vpcd, daemon, ui)
     and a clean systemctl start runs 60-90s; the 30s budget was
     killing ssh mid-command ("signal: killed"). Bump StartNode to 5min
     and StopNode to 3min.

  3. SpxGetVMs is best-effort in bash (`spx get vms --timeout 5s
     2>/dev/null` with no exit-code check) because the CLI's NATS dial
     can race the cluster join shortly after bootstrap. The Go port
     fatal'd on non-zero exit and broke TestMultinodeClusterHealth +
     TestMultinodeInstanceDistribution downstream. Added
     harness.SpxRunBestEffort and rewired both call sites.

* fix(e2e/multinode): spx CLI lenience + trio capacity retry + diag

Run 26163994815 cascaded from two issues that bash silently tolerated.
The Go port hard-failed where bash counts the failure and continues, so
3+ tests cascaded off transient first-failures.

- spx get nodes: cluster_health + node_recovery now downgrade <Ready
  count to WARN with SpxRunBestEffort, matching bash phase 2
  fail_test counter and phase 9 `|| echo ""`. Gateway+daemon health
  checks above already prove end-to-end NATS routing.
- needInstanceTrio: retry RunInstances up to 6× on
  InsufficientInstanceCapacity (10s backoff). Daemon resourceMgr can
  briefly report 0 capacity right after node bootstrap before its
  first inventory scan completes — AWS itself surfaces this
  transiently. Stagger 2s between launches to match bash phase 3.
- CI workflow: new "Pre-flight diagnostics on node1" step dumps
  groups/perms/spx output/systemctl status/journal tail to
  preflight-diag.log so next run's artifacts surface what tf-user
  sees before the test binary starts.

* fix(e2e): strip SPINIFEX_* from spx child env

Root cause of `spx get nodes` returning "nats: no servers available for
connection" in the multinode Go suite (mulga-siv-90 runs 26163994815,
26195522455): spx CLI calls viper.AutomaticEnv() with prefix "SPINIFEX"
inside config.LoadConfig. The CI workflow exports SPINIFEX_NODES (csv
of cluster WAN IPs) so harness.ClusterFromEnv can build the *Cluster.
viper picks SPINIFEX_NODES up as the `nodes` config key and silently
clobbers the loaded `[nodes.*]` map → nodeConfig.NATS.Host = "" →
nats.Connect("") → ErrNoServers.

Bash never set SPINIFEX_NODES so the bash port was unaffected. Direct
ssh `spx get nodes` from the runner also worked because the env was
clean. Only the test-binary-spawned spx hit the collision.

Fix: SpxRun / SpxRunBestEffort build a sanitized child env that
preserves the small SPINIFEX_* set spx's root command explicitly
BindEnvs (config path, AWS keys, host, base-dir, NATS dial overrides)
and drops everything else.

* fix(vpcd): make handleAddNAT idempotent (mulga-siv-124)

PR #232 (commit 5887927) added a second vpc.add-nat publish in
daemon/vm_adapters.go:onInstanceUpHook to cover instance recovery.
The publish fires on every instance up event including fresh launches,
racing the original publish from handlers/ec2/instance/service_impl.go.

Both publishes are queue-grouped to "vpcd-workers" so two cluster vpcd
instances handle them ~13s apart. handleAddNAT's existing
DeleteAllNATsByExternalIP -> AddNAT sequence is not idempotent under
concurrent writers: the second invocation deletes the rule the first
installed (after ovn-northd compiled SB flows and chassis installed
them), creating a flow-install gap during which the EIP is unreachable.

Symptom: TestMultinodePlacementAndNAT fails with ssh: Connection timed
out to the bastion EIP for 150s (mulga-siv-90 CI runs 26195522455,
26197248656). Bash port last green on e2e-baremetal run 26008057481
before #232 merged.

Fix: before delete+add, look up the existing rule via
FindNATByExternalIP. If it exists and its LogicalIP + spinifex:vpc_id
ExternalID (plus ExternalMAC + LogicalPort under distributed-NAT mode)
already match the target, no-op. Falls through to the existing
delete-then-add for the IP-reuse-across-VPCs case the original code
already handled.

Adds TestTopologyHandler_AddNAT_Idempotent covering the UUID-witness +
flow-barrier counter (two adds with identical state must not recreate
the row or re-arm the barrier).

* test(e2e/multinode): bump bastion SSH wait + ICMP probe

Run 26198221557 confirmed the vpcd dnat_and_snat race (mulga-siv-124)
was fixed — no "cleaned up stale NAT" lines for the bastion EIP, only
one writer + an idempotent skip. But TestMultinodePlacementAndNAT
still timed out at exactly 150s on SSH to the bastion's public IP,
suggesting a separate OVN flow-install lag on the freshly-created
VPC's external switch.

- Bump waitForBastionSSH budget 150s -> 5min. Default-VPC GuestSSH
  reaches sshd in ~70s; a brand-new VPC needs longer for ovn-northd
  to compile SB + every chassis to install the external ARP
  responder + dnat_and_snat flow. 5min covers cloud-init + OVN warm.
- Add an ICMP probe per iteration. Pure diagnostic — ping failure
  does not fail the step, but the next iteration's SSH error
  carries ping_output so the log splits "datapath down" (ping fail)
  from "sshd not up" (ping ok, ssh refused/timeout). Matches the
  RCA agent's fix candidate 3.

* test(e2e/multinode): dump cluster datapath state on phase 11 failure

waitForBastionSSH currently fails opaquely when the EIP datapath is
broken — the log shows "ping=fail" but doesn't pin which node owns the
gw chassis or whether ARP / OVN NAT rules / chassis encap IPs are sane.

Register a t.Cleanup hook (after bastionPubIP is known) that fans out
arp / ip route / ovs-vsctl / ovn-sbctl / ovn-nbctl probes to every
cluster node and writes results to <Artifacts>/diag-<node>-<probe>.log
on failure only. Plus a local-side ping / traceroute / ip route block
so a future post-mortem can distinguish runner-network breakage from
cluster datapath breakage.

Best-effort: per-node SSH errors log but never re-fail the test.

* test(e2e/multinode): use IpPermissions form for SG authorize calls

Two AuthorizeSecurityGroupIngress calls in phase 11 used the legacy flat
form (top-level IpProtocol/FromPort/ToPort/CidrIp) instead of the
IpPermissions array. The Spinifex VPC handler at
spinifex/handlers/ec2/vpc/security_group.go:667 reads only
input.IpPermissions and silently drops the flat fields — newRules=0,
no error returned.

Run 26200626265 journal:
  groupId=sg-922de57bc74b85c9c (natgw-bastion) newRules=0
  groupId=sg-758b05b4a5e7161fd (natgw-private) newRules=1

Bastion SG had zero ingress rules; TCP SYN to bastion :22 was dropped
by ACL, causing waitForBastionSSH to time out after 5min.

Bash version passes because AWS CLI v2 normalizes --protocol/--port/--cidr
shorthand into IpPermissions.N.* on the wire.

Fix: re-shape both calls (bastion tcp/22, private icmp) to use
IpPermissions, matching the third authorize call in the same function
(line 163-175) that already works.

Server-side fix to honor flat form follows in a separate bead.
2026-05-21 15:40:00 +10:00