Commit Graph

847 Commits

Author SHA1 Message Date
joshsiv-mulga ecfbd4fd19 fix(vpcd/dhcp): enable AF_PACKET promisc on bridge for DORA (mulga-siv-33)
Real-world DHCP servers commonly ignore the BOOTP BROADCAST flag and
reply with a unicast OFFER addressed to the synthetic per-tenant chaddr
(e.g. 02:f3:...). A Linux bridge floods unknown unicast to slave ports
but does NOT pass it up to the bridge netdev unless IFF_PROMISC is set,
so vpcd's AF_PACKET socket on br-wan misses the OFFER and DORA times
out at 15s ("unable to receive an offer: context deadline exceeded").

Dev/CI didn't hit this:
- CI uses spx_wan_mode=static — no DORA via vpcd.
- QEMU dev DHCP servers (SLIRP/dnsmasq) honor the BROADCAST flag, so
  their replies are L2 broadcast which the bridge always passes up.

Replace the three nclient4.New(iface, ...) call sites in Acquire/Renew/
Release with nclient4.NewWithConn(...) wrapping a custom AF_PACKET
DGRAM conn that calls SetPromiscuous(true). Promisc is per-socket
(per-DORA, ~15s) and refcounted by the kernel — auto-clears on close.
2026-04-30 10:53:59 +10:00
joshsiv-mulga 9bc15aa306 fix(setup-ovn): declare Linux WAN bridge as NetDev so networkd-managed veth resolves Bridge= on reboot (mulga-siv-33)
The veth-mode reboot persistence drop-ins added in mulga-998.b were
incomplete: setup-ovn.sh wrote `15-spinifex-veth-wan.network` with
`Bridge=br-wan`, but never declared br-wan itself to networkd.

br-wan is created by `networking.service` (ifupdown via
`/etc/network/interfaces`, generated by `install.go writeNetworkConfig`),
not networkd. networkd's `Bridge=` directive requires the target to be a
known NetDev — when it isn't, networkd silently logs
`br-wan NetDev could not be found, ignoring assignment` and the veth
ends up with no master. vpcd's bridge-mode sanity check then fails on
every reboot.

Fix: also write `14-spinifex-br-wan.netdev` declaring br-wan as
`Kind=bridge`. networkd matches the existing kernel bridge by name+kind
(the `Failed to create netdev: File exists` log is harmless), and
`Bridge=br-wan` resolves on next boot. ifupdown still owns DHCP and the
IP — networkd just needs the NetDev declaration to enslave the veth.

The write is gated to avoid clobbering operator-managed networkd config:
if any other `.netdev` under `/etc/systemd/network`,
`/run/systemd/network`, or `/usr/lib/systemd/network` already declares
the WAN bridge by name, the write is skipped with a log line pointing
at the operator file. Cleanup hook also removes the new file when
switching away from veth mode.

Verified end-to-end via QEMU repro: clean install + first boot + reboot,
veth-wan-br master persists across reboots, vpcd starts cleanly.
2026-04-29 23:35:42 +10:00
joshsiv-mulga 619f944d80 fix(ipam): widen vpc.dhcp.acquire retry budget to 60 s (mulga-siv-34)
ObtainDHCPLease retried only 5 attempts × 1 s before returning a hard
error. On a cold ISO boot, NATS comes up well before vpcd's reconcile
leader-election + dhcp.Manager subscribe (typical 5–30 s, pathological
~60 s during full reconcile), so daemon's NewExternalIPAM races vpcd's
dhcp.acquire subscription and fails permanently — d.externalIPAM stays
nil for the daemon's lifetime, daemon_handlers_instance.go silently
skips all auto-public-IP allocation, and instances launch with no
public IP, no dnat_and_snat, and no NAT event ever published.

Bump maxAttempts to 60 with 1 s spacing; expose both as package vars
so tests can shrink the budget for fast timeout-path coverage. Pure
widening of the existing retry loop — no behaviour change on the
success path, only the worst-case wallclock when vpcd never comes up.

The dominant cause of slow vpcd starts (mulga-siv-33: systemd-networkd
not enabled in the ISO → veth pair vanishes on reboot → vpcd
crashloops) is fixed alongside this. A 60 s budget fits comfortably
above all observed healthy-startup times.

Plan: docs/development/bugs/iso-public-ip-boot-race.md
2026-04-29 21:54:23 +10:00
joshsiv-mulga 7b8bb92905 fix(installer): emit kernel cmdline to ttyS0 alongside tty0 (mulga-siv-33)
GRUB_CMDLINE_LINUX in /etc/default/grub only listed console=tty0, so the
installed kernel printed nothing on serial after the GRUB menu. Headless
installs (CI, racks with serial-only IPMI, remote-dev SSH-to-QEMU) saw a
black screen forever and had no path to debug boot failures.

Append console=ttyS0,115200 — the last `console=` on the cmdline becomes
the system console, so ttyS0 wins on serial-only hardware while tty0
still receives output on local displays.

Plan: docs/development/bugs/iso-public-ip-boot-race.md
2026-04-29 21:54:07 +10:00
joshsiv-mulga 9b9bcc5b8f fix(setup): mask dhcpcd.service even when apt install skipped
bc33c72 added the mask but placed it after the SKIP_APT early-return.
CI bootstrap runs setup.sh with INSTALL_SPINIFEX_SKIP_APT=1 against
runners that already have dhcpcd-base, so the mask never ran. On
Debian-13 the auto-enabled dhcpcd.service kept binding br-wan and
racing vpcd's nclient4, reproducing the mulga-siv-32 DORA timeout
on the public-IP allocation path. Lift the disable+mask out of the
apt branch so it runs unconditionally.
2026-04-29 16:10:59 +10:00
Julian Sommer 6538a6cb69 feat: DescribeVolumeModifications (#174) 2026-04-29 15:54:43 +10:00
Josh Siviour 04f63ad8c6 feat(iso-installer): DHCP race fix + vpcd reconcile gate + ELB mgmt route + HashMAC + diagnostics (#172)
* preflight tweak

* fix: pin gateway LRP to link-local, end intra-cluster ARP collision

Every VPC's IGW gateway-LRP claimed pool.GatewayIP || pool.RangeStart on
attach. Multi-VPC clusters all GARP'd the same .160 with three different
deterministic MACs on the shared br-ext localnet, flapping the upstream
ARP cache and dropping 100% of inbound traffic to dnat_and_snat external
IPs (mulga-siv-26).

handleIGWAttach and reconcileIGW now unconditionally set the gateway
router port to 169.254.0.1/30. The default route's OutputPort already
pins the LRP, so the WAN nexthop need not be on-subnet. Pool IPs stay
allocated only for per-VM dnat_and_snat external addresses, which are
unique by construction.

Self-heal: RetrofitAllGatewayPortNetworks runs at startup and rewrites
stale Networks in place via the new UpdateLogicalRouterPort path. Needed
because Reconcile gates IGW work on "ext switch missing", so a cluster
shipped with the buggy CIDR never re-enters reconcileIGW.

Regression guards: TestTopologyHandler_IGWAttach_MultiVPC_NoIPCollision
and TestReconcileIGW_RewritesStaleGatewayPortNetworks. Existing tests
updated to assert link-local and OutputPort=&gwPortName. Log field
gateway_ip renamed to lrp_network to signal the semantic shift.

* fix: rewrite utils.HashMAC to SHA-256 / 46-bit output

The old 24-bit rolling hash with literal class prefix had birthday-paradox
collision probability of 1% at ~580 ids and 50% at 4096 — well within the
range of a single deployment's resource population (LRPs, ENIs, dev/mgmt
NICs). On a shared L2 segment, two clusters could trivially produce
duplicate MACs, breaking neighbour discovery and DHCP.

Switch HashMAC to SHA-256 across the entire input, taking the low 6 bytes
and forcing the IEEE 802 reserved bits (bit0=0 unicast, bit1=1 LAA) on the
first octet. Output entropy goes from 24 bits to 46 bits — 1% collision
boundary moves from ~580 to ~1.2M ids.

Drop the redundant prefix arg: it was no longer the literal output prefix
under the new scheme, just folded into the hash input. Class disambiguation
moves to the caller, where it's explicit. Three of four callers had nothing
to disambiguate (eni-, subnet-, gw-vpc- ids are already unique); dev and
mgmt NICs share instance.ID and now compose "dev:" / "mgmt:" tags.

Also consolidate vpcd.generateMAC, which was a local duplicate of the
broken algorithm rather than a HashMAC wrapper, into a HashMAC delegation.

Plan: docs/development/bugs/vpcd-mac-collisions-shared-l2.md
Bead: mulga-siv-27

* fix: pin HashMAC first octet to 0x02

Previous impl forced only the IEEE 802 reserved bits (bit0=0 unicast,
bit1=1 LAA) on the first octet, leaving the upper 6 bits hash-derived.
Technically valid LAA unicast, but produced MACs like 4a:.. and fe:..
that visually encroach on vendor OUI space and tripped CI/ops checks
expecting all generated MACs to start with 02:.

Pin byte 0 to literal 0x02; hash bytes 1-5 from sha256[0:5]. Output
entropy drops from 46 to 40 bits — birthday-paradox 1% collision at
~150k ids, still 256x the old 24-bit bound (~580).

* chore(e2e): dump cross-chassis OVN state on guest SSH failure

Adds chassis registrations, port_binding claims, gateway_chassis rows,
ovs-vsctl topology, Geneve tunnel state, and br-int tunnel flows to
dump_guest_ssh_diagnostics so we can pinpoint where the cross-chassis
dataplane stalls (cr-gw chassis vs remote chassis with the VM bound).

* fix: gate vpcd startup reconcile behind NATS KV leader election

N vpcds in a multi-node cluster all hit Get-then-Create on Logical_Router
with no atomicity, producing duplicate rows that ovn-nbctl rejects with
"Multiple logical routers named '...'. Use a UUID."

Add AcquireReconcileLeader using NATS KV CAS (kv.Create on a single key
in spinifex-vpcd-reconcile bucket). Only the elected vpcd runs Reconcile,
ReconcileFromKV, and the two retrofit passes — others Subscribe and rely
on the vpcd-workers queue group for runtime events. MaxAge=60s on the
bucket caps recovery time when a leader crashes mid-reconcile.

Falls through (elected=true) when KV is unavailable so first-boot does
not deadlock on a missing JetStream.

mulga-siv-29

* fix(elbv2): force mgmt host route for internal-scheme LBs (mulga-siv-30)

Single-node CI e2e Phase 3b ALB internal hangs in 'provisioning' for 270s
because the lb-agent inside the ALB VM has no return path to AWSGW.

The gateway-URL decision tree in daemon.wireLBAgentConfig picks advertiseIP
for single-node and deliberately skips adding the /32 mgmt host route — the
WAN-return-path concern in the comment is real but applies only to
internet-facing LBs, which have an EIP and an OVN dnat_and_snat pair that
gives reply traffic a way home. Internal LBs have no EIP / no SNAT, so when
the agent dials advertiseIP the reply targets the VM's private subnet IP from
the host's WAN with no route back.

Make lbVMUserData scheme-aware. New helper resolveMgmtRoute returns the
existing (MgmtRouteGateway, MgmtRouteTarget) when set (multi-node case) and
falls back to (MgmtBridgeIP, AdvertiseIP) when scheme is internal — only
internal LBs need the /32 route, so internet-facing single-node still gets
no bootcmd and the host-initiated WAN path stays clean.

Daemon now exposes MgmtBridgeIP and AdvertiseIP unconditionally so the
fallback has the inputs it needs.

* fix(daemon): stub sudoCommand in TestSetupComputeNode_ValidatesArgs (mulga-siv-31)

The test was calling real SetupComputeNode("chassis-test", ...) which shelled
out via sudoCommand to set external_ids:system-id=chassis-test on the host's
live OVS. On a dev box with OVS installed this overwrote the persistent UUID
(owned by openvswitch-switch and re-applied only on reboot), so vpcd's
discoverChassis filtered out the legitimate SBDB chassis row as 'stale local'
and crashlooped — taking out all VPC ops including ALB ENI provisioning.

Bind sudoCommand to a var so tests can swap it. Stub it in the test to return
exec.Command("/bin/false") and assert SetupComputeNode returns an error. The
test's intent (signature + error-path coverage) is preserved; nothing on the
host is touched.

* chore(vpcd): log DHCP Acquire concurrency + duration (mulga-siv-32)

Diagnostics-only change to surface whether nightly e2e cell-3 / cell-8
DORA timeouts correlate with concurrent Acquire calls on the same
bridge. Tracks per-bridge in-flight count and wallclock duration of each
Acquire; logs WARN on failure with both fields, DEBUG on success.

No behavioural change.

* ci(e2e-nightly): dump cluster journals on failure (mulga-siv-32)

run-multinode-e2e.sh dumps node journals via its own EXIT trap, but
run-cert-e2e.sh / run-lb-e2e.sh / reboot-e2e do not. When those scripts
fail the cluster is destroyed in Teardown without surfacing any daemon
logs, blocking root-cause analysis.

Add a workflow step that runs after the test scripts but before
Teardown when a previous step failed, ssh'ing to each node and pulling
the last 200 lines of every spinifex-* unit. Best-effort: skips
gracefully when inventory or nodes are unreachable.

Diagnostic-only; no behavioural change to the test path.

* fix(setup): mask dhcpcd.service to stop it racing vpcd's DHCP client (mulga-siv-32)

Debian 13 (Trixie) auto-enables a standalone dhcpcd.service when
dhcpcd-base is installed. It binds br-wan and races vpcd's in-process
nclient4 client for upstream OFFERs, draining the lab DHCP pool with
duplicate leases and causing intermittent DORA timeouts in the e2e
public-IP path (worst on cell-8 real-multi/dhcp/debian-13).

The ISO installer already masks the unit at install time
(cmd/installer/install/install.go:316). The binary install path used by
e2e nightly missed it, so debian hosts installed via setup.sh ran with
both DHCP clients live. Mirror the ISO mask here.

Ubuntu hosts are unaffected (dhcpcd-base does not auto-enable
dhcpcd.service on Noble).

* ci(e2e-nightly): also dump dhcpcd.service state on failure (mulga-siv-32)

Confirms or rules out the dhcpcd-vs-vpcd race hypothesis. Pulls the
dhcpcd.service journal alongside the spinifex-* units, plus
systemctl is-enabled / is-active and the DHCP client port (UDP 68)
listener so we can see directly whether dhcpcd is running and bound
to br-wan when an Acquire times out.

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
2026-04-29 14:38:29 +10:00
Julian Sommer 3494ea4eb0 fix: don't log secret key 2026-04-29 14:31:39 +10:00
Julian Sommer 050054ad0f fix: enable alpine x86 image 2026-04-29 12:03:58 +10:00
Julian Sommer bc9f881034 feat: AWS input validation compliance (#171) 2026-04-29 11:19:44 +10:00
Julian Sommer 3dad452b09 feat: reserve compute for spinifex services (#170) 2026-04-28 16:11:49 +10:00
Julian Sommer 5911a878c6 fix: flaky daemon test 2026-04-28 14:40:37 +10:00
Julian Sommer 94735e7d19 chore: interface compliance comp time check 2026-04-28 14:27:33 +10:00
Julian Sommer b0d58ff0f6 chore: add missing tests 2026-04-28 14:18:56 +10:00
Julian Sommer a3d1d5f7b3 chore: delete zero-op tests (#169) 2026-04-28 14:10:46 +10:00
Julian Sommer a3994510e0 feat: e2e nightly reboot tests (#168) 2026-04-28 13:47:58 +10:00
Julian Sommer 3da6e3cfa9 docs: update DESIGN.md 2026-04-28 11:37:31 +10:00
Julian Sommer 138083f6c8 chore: only run e2e nightly mon to friday 2026-04-28 11:20:24 +10:00
Julian Sommer f2dcb2f33f fix: tofu nightly e2e 2026-04-28 10:58:05 +10:00
Julian Sommer 7128dc785c chore: dhcp e2e nightly 2026-04-28 10:54:19 +10:00
dependabot[bot] d663d143a4 chore: bump the npm-dependencies group across 1 directory with 22 updates (#164) 2026-04-28 10:37:24 +10:00
Josh Siviour dd30790d51 fix: reconcile external IPAM pool on config drift (mulga-siv-24) (#167)
initPool's first guard inverted: !errors.Is(err, ErrKeyNotFound) was
true when err==nil, so initPool returned immediately when a record
existed and never reached the drift-reconcile or already-initialized
branches. As a result, range_start/range_end/source changes in
spinifex.toml never propagated and nextAvailableExternalIP failed
with "invalid IP range:  - " on dhcp -> static transitions.

Restructure to: bail on hard errors, reconcile or no-op when record
exists, fall through to create when ErrKeyNotFound.

Verified on dev cluster: dhcp -> static toml change now reconciles
KV bounds on restart and RunInstances allocates from the new range.

Stale Allocated entries and stale gateway_ip on source change remain
out of scope for this hotfix; tracked under mulga-siv-24 follow-ups.
2026-04-28 10:20:51 +10:00
Julian Sommer a7f965526b fix: lint error 2026-04-28 09:59:28 +10:00
dependabot[bot] f3cfee3189 chore: bump actions/upload-artifact in the github-actions group (#163) 2026-04-28 09:16:05 +10:00
dependabot[bot] b2921dbad6 chore: bump golang.org/x/crypto (#162) 2026-04-28 09:11:29 +10:00
dependabot[bot] 1f5aee54d7 chore: bump postcss (#166) 2026-04-28 09:11:07 +10:00
Ben Duncan b18bad5b93 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 21:56:10 +10:00
Ben Duncan a2107bba8a Manual testing, spinifex.toml changes for dhcp/static, pool names, retry timeout for DHCP lease 2026-04-27 21:56:00 +10:00
joshsiv-mulga 05d5148611 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 21:18:40 +10:00
Ben Duncan 94600d7b84 OVN chassis issues, new reconsile function, reboot fixed (#165) 2026-04-27 15:48:47 +10:00
Ben Duncan a25bc6ef19 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 14:47:45 +10:00
joshsiv-mulga 8735275d84 Merge commit 'eeb7ebc5db77e06db11ed7a58b9a70cdcc4128fb' into feat/iso-installer 2026-04-27 13:09:51 +10:00
joshsiv-mulga 187b641726 fix: order spinifex-daemon after ovn-controller too
Symmetric to the openvswitch-switch ordering: ensures ovn-controller
shutdown waits for spinifex-daemon's instance teardown, the correct
general invariant for VM cleanup since logical port programming
flows through OVN. PR1a flagged this as optional follow-up.

Bead: mulga-siv-20.1
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1a)
2026-04-27 12:39:58 +10:00
joshsiv-mulga 660fe5227e fix: idempotent tap setup + race-free terminate cleanup
Three minimal edits to close the orphan-OVS-port failure mode that
survives PR1a's systemd ordering fix (host crash, OOM-kill of ovsdb,
operator manually stopping OVS on a live host) and the
terminate-during-pending race that leaks taps when a user aborts a
slow launch.

1. SetupTapDevice (network.go): unconditional `ovs-vsctl --if-exists
   del-port br-int <tap>` + separately-gated kernel `ip tuntap del`.
   The previous kernel-side-gated cleanup missed orphan OVS ports
   because OVS conf.db survives reboot while kernel taps don't.
   Rejected `--may-exist add-port`: it would silently keep stale
   external_ids:iface-id/attached-mac from a prior launch.

2. stopInstance mgmt-tap branch (daemon.go) + CleanupMgmtTapDevice
   (network.go): derive the mgmt tap name from instance.ID rather
   than reading instance.MgmtTap, since that field is set only after
   SetupMgmtTapDevice returns and a terminate racing mid-launch
   would skip cleanup. CleanupMgmtTapDevice gains a kernel-presence
   short-circuit so unconditional invocation doesn't log spurious
   "Device does not exist" errors for instances that never reached
   SetupMgmtTapDevice.

3. LaunchInstance (daemon.go): `launchStillValid` helper re-checks
   instance status before StartInstance and TransitionState, in
   addition to the entry guard. On detection of shutting-down /
   terminated, log Info and return nil — the concurrent terminate
   goroutine owns cleanup; this is an expected race outcome, not an
   error. Removes the spurious "Failed to transition instance to
   running" ERROR that surfaced on every legitimate
   terminate-during-pending. Plus the entry path now returns nil
   instead of an error for the same reason.

Also lands `scripts/start-then-terminate-pending.sh` (added during
investigation) which reproduces the runtime race for manual
verification.

Bead: mulga-siv-20.2
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1b)
2026-04-27 12:33:54 +10:00
joshsiv-mulga 58bdcd43f5 fix: order spinifex-daemon after openvswitch-switch on shutdown
systemd reverses After= on shutdown so daemon now runs CleanupTapDevice
while ovsdb-server still answers. Eliminates orphan OVS port left in
conf.db when host reboots — root trigger for "port already exists" on
post-reboot RunInstances. spinifex-vpcd.service already had this; the
daemon unit was missed.

Also pull openvswitch-switch + ovn-controller into spinifex.target Wants
so the platform brings them up explicitly rather than relying on
implicit ordering from spinifex-vpcd alone.

Bead: mulga-siv-20.1
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1a)
2026-04-27 12:19:40 +10:00
Julian Sommer eeb7ebc5db chore: enable nightly nginx-alb workbook (#161) 2026-04-24 17:14:57 +10:00
Ben Duncan e799af48e0 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-24 16:33:36 +10:00
joshsiv-mulga 1059412e28 fix: localSystemID + discoverChassis use Output() to skip sudo PAM noise
Follow-up to d509df7. The sudo PAM audit bug exists in two more vpcd call
sites beyond portToBr: localSystemID() and discoverChassis().

When vpcd.service's AmbientCapabilities trip sudo's PAM, stderr carries
"sudo: unable to send audit message: Operation not permitted". CombinedOutput
merges that into stdout and corrupts the parsed value:

  localSystemID → localID="sudo: ...\n\"add0104e-..."
  discoverChassis → tries to parse the stderr banner as chassis name/hostname

Downstream effect: discoverChassis skips the real local chassis as "stale",
vpcd falls back to cfg.ChassisNames (e.g. "chassis-josh-banksia") which no
chassis in SBDB actually uses, so every gateway_chassis row points at a
phantom chassis. cr-gw* chassisredirect ports never bind → no proxy-ARP
for EIPs → external ping fails with ARP timeout.

Switch both to Output() + exec.ExitError.Stderr, matching d509df7's pattern.

Found during mulga-998.b reboot drill while verifying external reachability:
chassis discovery log showed `localID="sudo: unable to send audit message...`,
`cr-gw-vpc-* chassis=[]`, and host ping to EIP returned Destination Host
Unreachable. With this fix applied, discoverChassis returns the real system-id
and (after stale gateway_chassis rows are cleared) cr-gw binds, proxy-ARP
responds, and EIP ping succeeds end-to-end.

Note: existing gateway_chassis rows written pre-fix still carry the phantom
chassis_name. Cleanup is one-shot — delete those rows and trigger an IGW
detach+attach, or run reset-dev-env.sh. vpcd does not reconcile existing
rows on startup (potential future retrofit, similar to Fix 3's
RetrofitAllExternalLocalnetOptions).
2026-04-24 16:31:19 +10:00
Ben Duncan 0434a823cc Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-24 16:14:39 +10:00
Ben Duncan 40a42cbb73 grub branding tweaks 2026-04-24 16:14:30 +10:00
joshsiv-mulga d509df731a fix: portToBr uses Output() to skip sudo PAM audit noise
vpcd.service sets AmbientCapabilities which trips sudo's pam_limits into
emitting 'sudo: unable to send audit message: Operation not permitted'
on stderr. CombinedOutput merged that into stdout and poisoned the
port-to-br result, causing verifyBridgeMode to fail with a bogus bridge
name on service start after reinstall.

Switch to Output() (stdout only) + exec.ExitError.Stderr on error.
2026-04-24 16:10:55 +10:00
joshsiv-mulga c01207b342 merge: fix/dhcp-vpcd-issues (mulga-998.b Fixes 1+1b+2+3) into feat/iso-installer
Fixes DHCP/vpcd external-path issues discovered during reboot drill on josh-banksia:
- Fix 1: persist veth-wan pair via networkd .netdev/.network
- Fix 1b: admin-up veth-wan-ovs via second .network unit (peer stayed LOWERLAYERDOWN)
- Fix 2: loud detectBridgeMode + verifyBridgeMode sanity check
- Fix 3: retrofit nat-addresses=router + network_name=external on every vpcd startup via RetrofitAllExternalLocalnetOptions

Bead: mulga-siv-13 (closed). Plan: docs/development/bugs/dhcp-vpcd-issues.md.
Reboot-drill evidence: smoketest 9/9, Drill 2 retrofit verified post-reboot.

# Conflicts:
#	scripts/reset-dev-env.sh
#	spinifex/handlers/ec2/vpc/external_ipam.go
#	spinifex/services/vpcd/vpcd.go
2026-04-24 15:59:57 +10:00
joshsiv-mulga a00550a200 fix: retrofit localnet options on every vpcd startup (mulga-998.b Fix 3)
ensureLocalnetOptions was only invoked inside reconcileIGW, which is gated
on the external logical switch being absent — so a port whose options were
cleared or drifted from the current bridge mode would never be corrected
on restart. Drill 2 (stale-options recovery) exposed this: clearing
ext-port options and restarting vpcd left the port with options={}.

Changes:
- Extend ensureLocalnetOptions to also restore network_name=external (not
  just nat-addresses). A manual `ovn-nbctl clear` wipes both, and the port
  is non-functional without network_name.
- Add RetrofitAllExternalLocalnetOptions: walks every OVN logical switch
  tagged spinifex:role=external and calls ensureLocalnetOptions on its
  ext-port-<vpcId>. OVN is the source of truth; NATS KV records may be
  missing or stale.
- Call RetrofitAllExternalLocalnetOptions after ReconcileFromKV during
  vpcd startup.
- Tests: network_name restoration in both centralized and direct modes;
  RetrofitAll across multiple switches with mixed stale / correct / non-
  external state.
2026-04-24 15:31:42 +10:00
joshsiv-mulga 6b75470b0f fix: admin-up veth-wan-ovs via networkd (mulga-998.b Fix 1 follow-up)
Fix 1 persisted the veth pair across reboot via
15-spinifex-veth-wan.{netdev,network}, but only configured
veth-wan-br. Its peer veth-wan-ovs had no .network unit, so
networkd left it unmanaged and admin-DOWN after reboot. That
cascades: peer goes LOWERLAYERDOWN, br-wan loses carrier,
external DHCP/NAT path broken — despite vpcd correctly
detecting mode=veth and the sanity check passing.

OVS does not admin-up external (non-internal) ports — that's
networkd's job. Pre-reboot the setup-ovn.sh 'ip link set
veth-wan-ovs up' made it work, but that doesn't persist.

Write a second unit 16-spinifex-veth-wan-ovs.network with
ConfigureWithoutCarrier=yes so networkd flips admin state
without taking ownership of bridge/address assignment (OVS
retains that). Cleanup paths in the non-veth branch of
setup-ovn.sh and reset-dev-env.sh updated to remove the new
unit too.

Verified live via 'networkctl reload': veth-wan-ovs and
veth-wan-br both UP, br-wan LOWER_UP. Reboot drill per
docs/development/bugs/dhcp-vpcd-issues.md 'Live drill 1'
still required to confirm persistence.
2026-04-24 15:03:05 +10:00
joshsiv-mulga 825f7202b4 test: cover verifyBridgeMode empty-parameter branches
Add four branch tests for verifyBridgeMode that were missing from the
initial Fix 2 commit:
- direct mode rejects empty external_interface
- direct mode rejects empty dhcp_bind_bridge
- veth mode rejects empty dhcp_bind_bridge
- veth mode fails when veth-wan-br link missing

Lifts diff coverage from 59.5% to 65.6%, meeting the 65% gate.
2026-04-24 14:18:00 +10:00
joshsiv-mulga 853da4cc2a fix: loud detectBridgeMode + verifyBridgeMode sanity check (mulga-998.b Fix 2)
Make bridge-mode detection loud and fail-start when runtime state does
not match config. Closes D4/D12/D18 of dhcp-vpcd-issues.md.

Changes:
- detectBridgeMode: slog.Info at each decision, slog.Warn on fall-through
  (was silent Debug — let the veth persistence bug hide for weeks).
- Extract resolveBridgeConfig so mode/bind-bridge resolution is unit
  testable. Empty mode now stays empty so verifyBridgeMode rejects it
  with an explicit list of supported values.
- Add verifyBridgeMode: refuses to start when external iface missing
  (direct), when external iface not on the OVS bridge (direct), when
  veth-wan-ovs missing or attached to the wrong OVS bridge, or when
  veth-wan-br missing or on the wrong Linux bridge. References
  OvnExternalBridge (= "br-ext") so the sanity check tracks the contract,
  not a hardcoded string.
- Wire verifyBridgeMode into launchService. Error is loud and includes
  operator guidance ("re-run scripts/setup-ovn.sh").
- Injectable probes (ifaceIsMacvlan, ifaceExists, portToBr,
  readLinkMaster) so tests can exercise every branch without touching
  real interfaces.
- Tests: 16 new unit tests covering detectBridgeMode, resolveBridgeConfig,
  and all verifyBridgeMode branches.
2026-04-24 13:08:04 +10:00
joshsiv-mulga 904071099a fix: retrofit nat-addresses=router on mode transition (mulga-998.b Fix 3)
CreateLogicalSwitchPort only seeds options:nat-addresses at first
creation. If a VPC's localnet port was created during a vpcd run that
saw direct mode (e.g. pre-Fix 1 reboot without the veth pair), the port
keeps stale options forever — OVN never GARPs for the external IP and
centralized NAT stays broken.

Add TopologyHandler.ensureLocalnetOptions: read the LSP, diff
options["nat-addresses"] against useCentralizedNAT(), update only on
mismatch. Called from reconcileIGW after CreateLogicalSwitchPort so the
retrofit runs on every reconcile pass (no-op when already correct).

Mock tracks UpdateLogicalSwitchPortCalls so the idempotency test
asserts no write was issued.
2026-04-24 12:40:48 +10:00
joshsiv-mulga abd93098fc fix: persist veth-wan pair across reboot (mulga-998.b Fix 1)
setup-ovn.sh's veth branch creates veth-wan-br ↔ veth-wan-ovs but nothing
recreates the kernel interfaces on reboot. OVSDB remembers veth-wan-ovs
as a port on br-ext but the peer is gone — the external path silently
breaks and vpcd falls through to direct mode.

- setup-ovn.sh: write /etc/systemd/network/15-spinifex-veth-wan.{netdev,network}
  in the veth branch so systemd-networkd recreates the pair on boot. Add
  a single pre-case guard that rips down stale veth persistence + the OVS
  port + the kernel veth whenever WAN_BRIDGE_MODE != veth, so mode
  transitions are idempotent (D17).
- reset-dev-env.sh: delete the netdev/network units during cleanup so
  `make reset` gives a truly clean slate.
- spinifex-vpcd.service: After=systemd-networkd.service openvswitch-switch.service
  so vpcd's sanity checks see a populated kernel + OVSDB (D5).
2026-04-24 12:37:01 +10:00
Julian Sommer 5126a1df8d tweak: README.md link 2026-04-24 12:34:04 +10:00
joshsiv-mulga 112738198f fix: rename wan_bridge to dhcp_bind_bridge (mulga-998.a)
The old wan_bridge name misled operators into pointing the field at
the OVN-side bridge 'br-ext', which never sees LAN DHCP traffic. On
consumer-router LANs (WAN NIC enslaved to a Linux bridge) the vpcd
DHCP client's AF_PACKET bind failed silently, blocking the default
install path.

- Rename Go field WanBridge -> DhcpBindBridge and TOML key wan_bridge
  -> dhcp_bind_bridge across config, vpcd service, external IPAM,
  daemon pool wiring, admin settings, and spinifex.toml template.
- Rewrite detectedWanBridge() -> detectedDhcpBindBridge(): the
  Linux-bridge branch now returns the bridge name verbatim (e.g.
  "br-wan") instead of synthesising "br-ext". OVS-vs-Linux probe via
  ovs-vsctl is no longer needed for this field.
- Add checkLegacyWanBridgeKey() in cmd/service.go: vpcd fail-starts
  if the deprecated 'wan_bridge' TOML key or SPINIFEX_VPCD_WAN_BRIDGE
  env var is set. No silent alias, no auto-rewrite (per plan D3/D13).
- Template carries an operator-facing comment warning never to set
  the field to 'br-ext'.
- 7 new unit tests for the detector branches and fail-start paths.

Smoke on ben-bottlebrush: spx admin init --force writes
dhcp_bind_bridge matching the live br-wan Linux bridge; VM launched
with --map-public-ip-on-launch reached from LAN; restoring a
legacy-key backup causes vpcd to enter failed state with the
deprecated-key guidance log.

Plan: docs/development/bugs/dhcp-vpcd-issues.md (Fix 4)
Bead: mulga-998.a (unblocker for .b / .c)
2026-04-24 12:27:41 +10:00