Files
spinifex/scripts/setup-ovn.sh
T
Josh Siviour 69404b38ce fix(vpcd): public-IP boot race + DHCP DORA hardening (siv-33/34/36/37/38/39) (#177)
* 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

* 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

* 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.

* 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.

* revert(vpcd/dhcp): drop AF_PACKET promisc wrapper, restore nclient4.New

ecfbd4f swapped nclient4.New for openPromiscBridgeConn + NewWithConn to
catch unicast OFFER replies that bypass the bridge netdev. On dev hosts
where upstream honors the BOOTP BROADCAST flag, the wrapper regressed
DORA — public-IP allocation failed where main works. Reverting until
the promisc path is validated against both broadcast- and unicast-OFFER
servers.

* fix(setup-ovn): mark br-int/br-ext Unmanaged for systemd-networkd (mulga-siv-37)

Fresh ISO install on Trixie+systemd-networkd left the OVS internal netdevs
admin-down: networkd has no matching .network so it tags them as unmanaged
but never brings them up. OVS dataplane works but ovn-controller flow
programming and any tooling that probes link state misbehaves.

Drop /etc/systemd/network/05-spinifex-ovs-internal.network with
Unmanaged=yes so OVS itself owns the link state — `ovs-vsctl add-br`
already runs the netdev up at create time.

* fix(vpcd): allocate gateway LRP IP from pool in centralized NAT (mulga-siv-36)

In veth/macvlan modes the gateway LRP carries all egress, so it must hold
a WAN-subnet IP — otherwise the upstream router silently drops ARP for
the WAN nexthop (RFC 826) and the default route never resolves. The
hardcoded 169.254.0.1/30 was correct for distributed NAT but regressed
centralized NAT (introduced under mulga-siv-26).

Add gw_lrp_range_start/end to ExternalPool. In centralized mode at IGW
attach the gateway LRP gets a fresh IP from that range, persisted as
external_ids:spinifex:gateway_ip on the LRP for idempotent reconcile and
retrofit. Direct mode keeps link-local. Per-VM EIP allocator skips the
gw range; ValidatePoolConfig rejects overlap with the EIP range.

Adds TestTopologyHandler_IGWAttach_CentralizedNATAllocatesGwLrpIP and
extends RetrofitAllGatewayPortNetworks to migrate stale link-local LRPs
to allocated pool IPs at startup.

* fix(vpcd): drop link-local fallback, auto-derive gw_lrp_range (mulga-siv-36)

In centralized NAT (veth/macvlan) the gateway LRP must hold a WAN-subnet
IP — link-local cannot ARP-resolve the upstream nexthop, so the upstream
router silently drops egress. Hardware deployment confirmed the previous
fallback to 169.254.0.1/30 was always reached when gw_lrp_range was not
explicitly configured.

Auto-derive a 16-IP range at the top of the WAN subnet (broadcast - 16
.. broadcast - 1) when gw_lrp_range is unset; shift below RangeStart on
overlap with the per-VM EIP range; clamp inside the subnet; guard the
upstream gateway IP. IGW attach now errors out instead of falling back
to link-local when allocation fails — surfaces misconfiguration loudly.

Adds focused unit tests for gwLrpRange, ValidatePoolConfig and
nextAvailableExternalIP gw-lrp skip; updates the multi-VPC and
PoolWithGatewayIP regression tests.

* feat(vpcd): DHCP-acquire gateway LRP IPs (mulga-siv-38)

Per-VPC OVN gateway LRPs now obtain their WAN-subnet IP via vpc.dhcp.acquire
when the pool is source="dhcp", instead of carving from a static gw_lrp_range.
The upstream DHCP server becomes the single arbiter of LAN address space; a
cluster grows by N nodes without operator-side range carving in spinifex.toml.

- Consolidate ObtainDHCPLease/ReleaseDHCPLease into the dhcp package as
  RequestAcquire/RequestRelease so vpcd and the daemon-side ExternalIPAM
  share one helper. handlers/ec2/vpc keeps its existing ENI/EIP DHCP path.

- TopologyHandler dispatches by pool source: DHCP pools call DHCPManager
  via NATS (client-id "gw-lrp-<vpcId>", idempotent on retry); static pools
  keep the siv-36 gw_lrp_range / auto-derive allocator. Direct mode and
  no-pool deployments still use link-local.

- IGW detach issues vpc.dhcp.release so the upstream server reclaims the
  IP. Best-effort — server-side expiry is the safety net.

- Tests: TestTopologyHandler_IGWAttach_DHCPGwLrpIP (3 VPCs share one DHCP
  pool, distinct IPs, idempotent attach, release on detach),
  IGWAttach_DHCPAcquireFailureAborts (no link-local fallback when DHCP
  errors), TestPrefixFromMask. Existing static / auto-derive tests
  preserved.

* fix(vpcd): subscribe DHCPManager before reconcile to fix boot race (mulga-siv-38)

DHCPManager.Subscribe ran AFTER ReconcileFromKV / RetrofitAllGatewayPortNetworks,
so retrofit's vpc.dhcp.acquire requests for existing IGWs hit "no responders" and
burned the 60-attempt × 1 s cold-boot retry budget per VPC before failing. On a
dev box with one bootstrap VPC the symptom is a 60 s vpcd startup hang followed
by "allocate gw LRP IP failed", and the bootstrap IGW topology missing from OVN
until the next reconcile pass.

Move dhcpManager.Subscribe ahead of the two reconcile passes so the subscription
is live before any topology code can fan out an acquire.

* fix(vpcd): also subscribe DHCPManager before bootstrap reconcile pass

Previous fix moved DHCPManager.Subscribe ahead of ReconcileFromKV but
missed Pass 1 (the bootstrap-config Reconcile that runs even earlier).
On a dev box with a source="dhcp" pool and a bootstrap IGW, that earlier
pass still hit "no responders" and burned the 60-retry cold-boot budget
before the Manager came up. Move both reconcile passes after Subscribe.

* fix(vpcd): RFC 2131 §4.1 DORA exponential backoff (mulga-siv-39)

DHCPManager.Acquire previously did one DISCOVER and gave up after 15s
because the outer ctx.WithTimeout matched the per-attempt timeout —
nclient4's WithRetry(3) could never fire. Real DHCP servers (consumer
routers, ISC dhcpd, dnsmasq) silently drop OFFERs under load; the spec
mandates retransmission with exponential backoff, and a single lost
DISCOVER was killing legitimate boots.

Move retransmission into DHCPManager.acquireWithBackoff with a
4s/8s/16s/32s schedule (±1s jitter, ~60s worst case). NClient4Client
becomes single-attempt; manager owns retry policy. Default
acquireTimeout lifts from 15s to 90s (sum of schedule + slack).

* fix(vpcd): retry NewDHCPManager on JetStream-not-ready (mulga-siv-40)

NewDHCPManager creates the spinifex-dhcp-leases KV bucket via
utils.GetOrCreateKVBucket → js.CreateKeyValue with no retry. After the
mulga-siv-38 launchService reorder, this runs before any other JetStream
user, so on multi-node cold-boot vpcd hits "JetStream system temporarily
unavailable" and exits before cluster quorum forms. Pseudo-multinode CI
calls start_node_services sequentially, so node2's vpcd failure aborts
the script before node3 ever launches and quorum then never forms.

Wrap NewDHCPManager in newDHCPManagerWithRetry (500ms→10s, 5 min cap,
ctx-cancellable) mirroring daemon.initJetStream so vpcd waits as
patiently as the daemon for the cluster to come up.

* fix(daemon): idempotent DetachVolume QMP path (mulga-siv-41)

DetachVolume's QMP sequence was not retry-safe. First call: device_del
succeeds, blockdev-del fails with "Node nbd-vol-X is in use" because NBD
client teardown / in-flight I/O still hold the block node. Handler bails
ServerInternal "leaving volume state intact" but the guest device is
actually gone. AWS-CLI retries: device_del returns DeviceNotFound,
handler bails again. After 2 retries aws cli exits 254 — Single-Node
Phase 8 Step 3 reproducibly fails this way.

Phase 1: treat DeviceNotFound on device_del as already-detached, log and
continue. Detach is now idempotent across retries.

Phase 2: tryBlockdevDel polls blockdev-del up to 20×detachDelay on "in
use" errors so the I/O drain can complete instead of failing the whole
detach. Other QMP errors fall through immediately as before.
2026-05-01 09:48:52 +10:00

865 lines
34 KiB
Bash
Executable File

#!/bin/bash
# OVN Compute Node Setup for Spinifex VPC Networking
#
# This script bootstraps a compute node for OVN-based VPC networking:
# 1. Installs OVN/OVS packages (if not present)
# 2. Enables required services (openvswitch-switch, ovn-controller)
# 3. Creates br-int with secure fail-mode
# 4. Configures WAN bridge for public subnet uplink (auto-detected or manual)
# 5. Configures OVS external_ids for OVN chassis identity
# 6. Applies sysctl tuning for overlay networking
#
# Usage:
# ./scripts/setup-ovn.sh [options]
#
# Options:
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --wan-bridge=NAME OVS bridge for WAN traffic (default: auto-detect from default route)
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge or --macvlan)
# --macvlan Create macvlan off --wan-iface instead of moving NIC directly.
# SSH-safe for single-NIC hosts where WAN NIC carries SSH.
# --dhcp Obtain gateway IP via DHCP on the WAN bridge interface
# --mgmt-bridge=NAME OVS bridge for system-instance control plane (default: br-mgmt)
# --mgmt-cidr=CIDR IPv4 CIDR to assign on the mgmt bridge (default: 10.15.8.1/24)
# --mgmt-iface=NAME Physical/virtual NIC to enslave to the mgmt bridge (multi-node only)
# --no-mgmt-bridge Skip mgmt bridge provisioning (for dev-networking hosts)
# --ovn-remote=ADDR OVN SB DB address (default: tcp:127.0.0.1:6642)
# --encap-ip=IP Geneve tunnel endpoint IP (default: auto-detect)
#
# WAN Bridge Auto-Detection:
# When no --wan-bridge is given, the script checks the default route interface:
# - If it's an OVS bridge → use it directly for bridge-mappings
# - If it's a Linux bridge → create OVS br-ext + veth pair to link them
# (non-destructive, Linux bridge keeps IP/routes, no interruption)
# - If it's a physical NIC → stop and print guidance (cannot safely move NIC)
#
# Examples:
# # WAN is already on a bridge (tofu-cluster, production):
# ./scripts/setup-ovn.sh --management
#
# # Dedicated WAN NIC (not your SSH NIC — you take responsibility):
# ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
#
# # Single-NIC host (SSH-safe macvlan):
# ./scripts/setup-ovn.sh --management --macvlan --wan-iface=eth0
#
# # Compute node joining an existing cluster:
# ./scripts/setup-ovn.sh --ovn-remote=tcp:10.0.0.1:6642 --encap-ip=10.0.0.2
#
# # No WAN bridge (overlay-only, no public subnet):
# ./scripts/setup-ovn.sh --management --encap-ip=10.0.0.1
set -e
# Defaults
MANAGEMENT=false
WAN_BRIDGE=""
WAN_IFACE=""
MACVLAN_MODE=false
EXTERNAL_DHCP=false
MGMT_BRIDGE_ENABLED=true
MGMT_BRIDGE="br-mgmt"
MGMT_CIDR="10.15.8.1/24"
MGMT_IFACE=""
OVN_REMOTE="tcp:127.0.0.1:6642"
ENCAP_IP=""
# Parse arguments
for arg in "$@"; do
case "$arg" in
--management) MANAGEMENT=true ;;
--macvlan) MACVLAN_MODE=true ;;
--dhcp) EXTERNAL_DHCP=true ;;
--wan-bridge=*) WAN_BRIDGE="${arg#*=}" ;;
--wan-iface=*) WAN_IFACE="${arg#*=}" ;;
--mgmt-bridge=*) MGMT_BRIDGE="${arg#*=}" ;;
--mgmt-cidr=*) MGMT_CIDR="${arg#*=}" ;;
--mgmt-iface=*) MGMT_IFACE="${arg#*=}" ;;
--no-mgmt-bridge) MGMT_BRIDGE_ENABLED=false ;;
--ovn-remote=*) OVN_REMOTE="${arg#*=}" ;;
--encap-ip=*) ENCAP_IP="${arg#*=}" ;;
--help|-h)
head -50 "$0" | tail -48
exit 0
;;
*)
echo "Unknown option: $arg"
exit 1
;;
esac
done
# --- WAN bridge auto-detection ---
# Determine the WAN bridge name and how to set it up.
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", "macvlan", or ""
LINUX_BRIDGE="" # Set when WAN_BRIDGE_MODE="veth" — the Linux bridge behind the veth pair
detect_wan_bridge() {
# If --wan-bridge was explicitly given, use it
if [ -n "$WAN_BRIDGE" ]; then
if [ "$MACVLAN_MODE" = true ] && [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="macvlan"
elif [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="direct"
elif sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
WAN_BRIDGE_MODE="existing"
else
# Bridge doesn't exist yet and no --wan-iface — create empty OVS bridge
WAN_BRIDGE_MODE="existing"
fi
return
fi
# If --macvlan was given without --wan-bridge, we need --wan-iface
if [ "$MACVLAN_MODE" = true ]; then
if [ -z "$WAN_IFACE" ]; then
echo "ERROR: --macvlan requires --wan-iface=<NIC>"
exit 1
fi
WAN_BRIDGE="br-wan"
WAN_BRIDGE_MODE="macvlan"
return
fi
# Auto-detect: find the default route interface
local default_dev
default_dev=$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1)
if [ -z "$default_dev" ]; then
echo " No default route found — no WAN bridge configured"
echo " (VMs will not have external connectivity)"
return
fi
# Check if the default route device is a bridge (Linux or OVS)
local is_bridge=false
if ip -d link show "$default_dev" 2>/dev/null | grep -q "bridge"; then
is_bridge=true
fi
if sudo ovs-vsctl br-exists "$default_dev" 2>/dev/null; then
is_bridge=true
fi
if [ "$is_bridge" = true ]; then
if sudo ovs-vsctl br-exists "$default_dev" 2>/dev/null; then
# Already an OVS bridge — use it directly for bridge-mappings
WAN_BRIDGE="$default_dev"
WAN_BRIDGE_MODE="existing"
echo " Auto-detected WAN bridge: $WAN_BRIDGE (OVS bridge, default route)"
else
# Linux bridge — link to OVS via veth pair (non-destructive)
LINUX_BRIDGE="$default_dev"
WAN_BRIDGE="br-ext"
WAN_BRIDGE_MODE="veth"
echo " Auto-detected Linux bridge: $LINUX_BRIDGE (default route)"
echo " Will create OVS bridge br-ext + veth pair to link them"
fi
return
fi
# Default route is a physical NIC — cannot safely move it to OVS
# because it might be carrying SSH.
local wan_ip
wan_ip=$(ip -4 -o addr show "$default_dev" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
echo ""
echo "============================================================"
echo " WAN interface '$default_dev' ($wan_ip) is a physical NIC."
echo " Cannot auto-create a bridge — this may drop your connection."
echo ""
echo " Options:"
echo ""
echo " 1. Create a WAN bridge first (e.g. via netplan), then re-run:"
echo " ./scripts/setup-ovn.sh --management"
echo ""
echo " 2. Dedicated WAN NIC (NOT your SSH connection):"
echo " ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=$default_dev"
echo ""
echo " 3. Single-NIC host (SSH-safe macvlan):"
echo " ./scripts/setup-ovn.sh --management --macvlan --wan-iface=$default_dev"
echo ""
echo " 4. No external networking (overlay-only):"
echo " ./scripts/setup-ovn.sh --management --encap-ip=$wan_ip"
echo "============================================================"
echo ""
exit 1
}
detect_wan_bridge
# Auto-detect encap IP if not specified
if [ -z "$ENCAP_IP" ]; then
# Prefer br-vpc IP if it exists (dedicated VPC data plane)
if ip -4 addr show br-vpc >/dev/null 2>&1; then
ENCAP_IP=$(ip -4 -o addr show br-vpc 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
if [ -n "$ENCAP_IP" ]; then
echo "Auto-detected encap IP from br-vpc: $ENCAP_IP"
fi
fi
# Fall back to default route source IP
if [ -z "$ENCAP_IP" ]; then
ENCAP_IP=$(ip -4 route get 8.8.8.8 2>/dev/null | awk '/src/{print $7}' | head -1)
if [ -z "$ENCAP_IP" ]; then
ENCAP_IP="127.0.0.1"
fi
echo "Auto-detected encap IP: $ENCAP_IP"
fi
fi
echo "=== Spinifex OVN Compute Node Setup ==="
echo " Management node: $MANAGEMENT"
if [ -n "$WAN_BRIDGE" ]; then
echo " WAN bridge: $WAN_BRIDGE ($WAN_BRIDGE_MODE)"
if [ -n "$LINUX_BRIDGE" ]; then
echo " Linux bridge: $LINUX_BRIDGE (linked via veth pair)"
fi
if [ -n "$WAN_IFACE" ]; then
echo " WAN interface: $WAN_IFACE"
fi
else
echo " WAN bridge: none (overlay-only)"
fi
echo " OVN Remote (SB): $OVN_REMOTE"
echo " Encap IP: $ENCAP_IP"
echo ""
# --- Step 1: Install packages ---
echo "Step 1: Checking OVN/OVS packages..."
install_packages() {
local missing=()
for pkg in openvswitch-switch ovn-host; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
missing+=("$pkg")
fi
done
if [ "$MANAGEMENT" = true ]; then
if ! dpkg -s ovn-central >/dev/null 2>&1; then
missing+=("ovn-central")
fi
fi
if [ ${#missing[@]} -gt 0 ]; then
echo " Installing: ${missing[*]}"
sudo apt-get update -qq
sudo apt-get install -y -qq "${missing[@]}"
else
echo " All packages installed"
fi
}
install_packages
# --- Step 2: Enable services ---
echo ""
echo "Step 2: Enabling services..."
sudo systemctl enable openvswitch-switch
sudo systemctl start openvswitch-switch
echo " openvswitch-switch: started"
if [ "$MANAGEMENT" = true ]; then
sudo systemctl enable ovn-central
sudo systemctl start ovn-central
echo " ovn-central: started (NB DB + SB DB + ovn-northd)"
# Wait for OVN NB DB socket to become available
for i in $(seq 1 15); do
if sudo ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
break
fi
echo " Waiting for OVN NB DB... ($i/15)"
sleep 1
done
# Allow remote connections to NB and SB databases
sudo ovn-nbctl set-connection ptcp:6641
sudo ovn-sbctl set-connection ptcp:6642
echo " OVN NB DB listening on tcp:6641"
echo " OVN SB DB listening on tcp:6642"
fi
# --- Step 3: Create and configure br-int ---
echo ""
echo "Step 3: Configuring br-int..."
sudo ovs-vsctl --may-exist add-br br-int
sudo ovs-vsctl set Bridge br-int fail-mode=secure
sudo ovs-vsctl set Bridge br-int other-config:disable-in-band=true
sudo ip link set br-int up
echo " br-int: created, fail-mode=secure, up"
# Mark OVS internal netdevs Unmanaged for systemd-networkd. Without this,
# Trixie's networkd takes ownership of any unconfigured iface and may bring
# br-int/br-ext admin-down after setup-ovn.sh's `ip link set up` (no Match
# rule => default management => no carrier => link down). OVS dataplane
# still forwards, but ovn-controller flow programming and any tooling that
# probes link state misbehave. Unmanaged=yes keeps OVS in sole control.
# (mulga-siv-37)
OVS_INTERNAL_NET=/etc/systemd/network/05-spinifex-ovs-internal.network
if [ ! -f "$OVS_INTERNAL_NET" ]; then
sudo tee "$OVS_INTERNAL_NET" >/dev/null <<'NETWORK'
[Match]
Name=br-int br-ext
[Link]
Unmanaged=yes
NETWORK
sudo networkctl reload 2>/dev/null || true
echo " wrote $OVS_INTERNAL_NET (Unmanaged=yes for br-int br-ext)"
fi
# --- Step 3b: Configure WAN bridge for public subnet uplink ---
if [ -n "$WAN_BRIDGE" ]; then
echo ""
echo "Step 3b: Configuring WAN bridge ($WAN_BRIDGE) for public subnet uplink..."
# Rip down any stale veth persistence from a previous veth-mode install
# when switching to any non-veth mode. Idempotent — each command uses
# --if-exists / 2>/dev/null to tolerate absence. Without this, the
# veth pair re-materialises on reboot and fights the current mode's
# bridge plumbing (Fix 1, mulga-998.b, per D17).
if [ "$WAN_BRIDGE_MODE" != "veth" ]; then
sudo rm -f /etc/systemd/network/14-spinifex-br-wan.netdev \
/etc/systemd/network/15-spinifex-veth-wan.netdev \
/etc/systemd/network/15-spinifex-veth-wan.network \
/etc/systemd/network/16-spinifex-veth-wan-ovs.network
sudo networkctl reload 2>/dev/null || true
sudo ovs-vsctl --if-exists del-port "$WAN_BRIDGE" veth-wan-ovs
sudo ip link del veth-wan-br 2>/dev/null || true
fi
case "$WAN_BRIDGE_MODE" in
existing)
# Already an OVS bridge (from a previous run or explicit --wan-bridge).
if ! sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
echo " created OVS bridge: $WAN_BRIDGE"
fi
sudo ip link set "$WAN_BRIDGE" up
echo " $WAN_BRIDGE: OVS bridge, up"
;;
veth)
# Linux bridge detected (e.g. br-wan from cloud-init/netplan).
# OVN bridge-mappings require an OVS bridge. Rather than converting
# the Linux bridge (destructive, causes WAN interruption), we create
# a separate OVS bridge and link them with a veth pair:
#
# br-wan (Linux, keeps IP/routes) ←→ veth pair ←→ br-ext (OVS, for OVN)
#
# No network interruption. The Linux bridge is untouched.
# Create OVS bridge
if ! sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
echo " created OVS bridge: $WAN_BRIDGE"
fi
sudo ip link set "$WAN_BRIDGE" up
# Create veth pair (idempotent)
if ! ip link show veth-wan-br >/dev/null 2>&1; then
sudo ip link add veth-wan-br type veth peer name veth-wan-ovs
echo " created veth pair: veth-wan-br ↔ veth-wan-ovs"
else
echo " veth pair already exists: veth-wan-br ↔ veth-wan-ovs"
fi
# Enslave veth-wan-br to the Linux bridge
if ! ip link show veth-wan-br 2>/dev/null | grep -q "master $LINUX_BRIDGE"; then
sudo ip link set veth-wan-br master "$LINUX_BRIDGE"
echo " veth-wan-br → $LINUX_BRIDGE (Linux bridge)"
fi
sudo ip link set veth-wan-br up
# Add veth-wan-ovs to the OVS bridge
if ! sudo ovs-vsctl port-to-br veth-wan-ovs >/dev/null 2>&1; then
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" veth-wan-ovs
echo " veth-wan-ovs → $WAN_BRIDGE (OVS bridge)"
fi
sudo ip link set veth-wan-ovs up
echo " $LINUX_BRIDGE (Linux) ↔ veth pair ↔ $WAN_BRIDGE (OVS)"
echo " $LINUX_BRIDGE keeps its IP and routes — no interruption"
# Persist the veth pair across reboot via systemd-networkd. Veths
# are kernel-only and vanish on reboot; without persistence vpcd
# starts with the OVS port pointing at a nonexistent peer and
# silently falls back to direct mode (Fix 1, mulga-998.b).
#
# Declare the Linux bridge as a NetDev too (even though it's
# already created by ifupdown) so networkd recognises it and the
# `Bridge=$LINUX_BRIDGE` directive below resolves. Without this,
# networkd logs `br-wan NetDev could not be found, ignoring
# assignment` and veth-wan-br ends up orphaned with no master,
# which crashes vpcd's veth bridge mode sanity check on reboot.
# `Failed to create netdev: File exists` is harmless — networkd
# then matches the existing kernel bridge by name+kind.
#
# Gate: skip the write if any operator-managed .netdev (cloud-init,
# netplan, manual op) already declares this bridge — don't clobber
# custom networkd config. networkd searches /etc, /run, /usr/lib in
# that priority order; check all three. Our own file is excluded so
# idempotent re-runs still rewrite when needed.
BR_WAN_NETDEV="/etc/systemd/network/14-spinifex-br-wan.netdev"
VETH_NETDEV="/etc/systemd/network/15-spinifex-veth-wan.netdev"
VETH_NETWORK="/etc/systemd/network/15-spinifex-veth-wan.network"
VETH_OVS_NETWORK="/etc/systemd/network/16-spinifex-veth-wan-ovs.network"
EXISTING_BR_NETDEV=$(grep -rls --include="*.netdev" -E "^\s*Name=$LINUX_BRIDGE\s*$" \
/etc/systemd/network /run/systemd/network /usr/lib/systemd/network 2>/dev/null \
| grep -v "^$BR_WAN_NETDEV$" || true)
if [ -n "$EXISTING_BR_NETDEV" ]; then
echo " skipping $BR_WAN_NETDEV — operator-managed NetDev already declares $LINUX_BRIDGE: $EXISTING_BR_NETDEV"
else
sudo tee "$BR_WAN_NETDEV" >/dev/null <<NETDEV
[NetDev]
Name=$LINUX_BRIDGE
Kind=bridge
NETDEV
fi
sudo tee "$VETH_NETDEV" >/dev/null <<NETDEV
[NetDev]
Name=veth-wan-br
Kind=veth
[Peer]
Name=veth-wan-ovs
NETDEV
sudo tee "$VETH_NETWORK" >/dev/null <<NETWORK
[Match]
Name=veth-wan-br
[Network]
Bridge=$LINUX_BRIDGE
ConfigureWithoutCarrier=yes
NETWORK
# Second unit admin-ups the OVS end of the pair. OVS owns the port
# (enslaved via ovs-vsctl add-port above) but does not flip admin
# state on external ports — that's networkd's job. Without this,
# veth-wan-ovs stays DOWN after reboot, peer goes LOWERLAYERDOWN,
# br-wan loses carrier (Fix 1 follow-up, mulga-998.b).
sudo tee "$VETH_OVS_NETWORK" >/dev/null <<NETWORK
[Match]
Name=veth-wan-ovs
[Link]
RequiredForOnline=no
[Network]
ConfigureWithoutCarrier=yes
NETWORK
sudo networkctl reload 2>/dev/null || true
echo " wrote $VETH_NETDEV + $VETH_NETWORK + $VETH_OVS_NETWORK (veth persists on reboot)"
;;
direct)
# Add WAN NIC directly to OVS bridge. The NIC becomes an OVS slave —
# its IP (if any) is no longer reachable from the host. The user has
# confirmed this NIC is NOT their SSH connection.
if ! ip link show "$WAN_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $WAN_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
sudo ip link set "$WAN_BRIDGE" up
if sudo ovs-vsctl port-to-br "$WAN_IFACE" >/dev/null 2>&1; then
echo " $WAN_IFACE already on $(sudo ovs-vsctl port-to-br "$WAN_IFACE")"
else
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" "$WAN_IFACE"
echo " added $WAN_IFACE directly to $WAN_BRIDGE"
fi
sudo ip link set "$WAN_IFACE" up
echo " $WAN_BRIDGE: direct bridge on $WAN_IFACE"
echo " NOTE: $WAN_IFACE is now an OVS port — no host IP on this NIC"
;;
macvlan)
# Create a macvlan sub-interface in bridge mode off the WAN NIC.
# The host keeps its IP on the parent NIC — SSH-safe. OVN localnet
# traffic flows through the macvlan to the physical wire.
if ! ip link show "$WAN_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $WAN_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
sudo ip link set "$WAN_BRIDGE" up
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan $MACVLAN_NAME already exists"
else
sudo ip link add "$MACVLAN_NAME" link "$WAN_IFACE" type macvlan mode bridge
echo " created macvlan: $MACVLAN_NAME (bridge mode) on $WAN_IFACE"
fi
sudo ip link set "$MACVLAN_NAME" up
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" "$MACVLAN_NAME"
echo " $WAN_BRIDGE: macvlan port $MACVLAN_NAME on $WAN_IFACE"
echo " NOTE: host keeps its IP on $WAN_IFACE (SSH-safe)"
echo " QUIRK: host cannot reach VMs at their public IPs (macvlan isolation)"
;;
esac
# --- DHCP: obtain gateway IP for OVN SNAT ---
if [ "$EXTERNAL_DHCP" = true ]; then
echo ""
echo "Step 3c: Obtaining external gateway IP via DHCP..."
# For macvlan mode, DHCP on the macvlan interface (it has L2 access to WAN).
# For direct/existing bridge, DHCP on the bridge itself.
if [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
DHCP_IFACE="spx-ext-${WAN_IFACE}"
else
DHCP_IFACE="$WAN_BRIDGE"
fi
# Run DHCP client to get a lease
if command -v dhcpcd >/dev/null 2>&1; then
sudo dhcpcd --waitip=4 --timeout 15 "$DHCP_IFACE" 2>/dev/null || true
elif command -v dhclient >/dev/null 2>&1; then
sudo dhclient -1 -timeout 15 "$DHCP_IFACE" 2>/dev/null || true
else
echo " WARNING: no DHCP client found (dhcpcd or dhclient)"
echo " Install dhcpcd-base or isc-dhcp-client, or set gateway_ip manually"
fi
# Read the obtained IP
DHCP_IP=$(ip -4 addr show dev "$DHCP_IFACE" 2>/dev/null | awk '/inet /{print $2}' | head -1 | cut -d/ -f1)
if [ -n "$DHCP_IP" ]; then
echo " DHCP obtained: $DHCP_IP on $DHCP_IFACE"
# Write the gateway IP to the spinifex config so vpcd can use it
CONFIG_DIR="${CONFIG_DIR:-$HOME/spinifex/config}"
CONFIG_FILE="$CONFIG_DIR/spinifex.toml"
if [ -f "$CONFIG_FILE" ]; then
if grep -q "gateway_ip" "$CONFIG_FILE"; then
sed -i "s/gateway_ip.*/gateway_ip = \"$DHCP_IP\"/" "$CONFIG_FILE"
else
sed -i "/^gateway *=.*/a gateway_ip = \"$DHCP_IP\"" "$CONFIG_FILE"
fi
echo " Updated $CONFIG_FILE with gateway_ip = $DHCP_IP"
else
echo " WARNING: $CONFIG_FILE not found — set gateway_ip manually"
fi
else
echo " WARNING: DHCP failed to obtain IP on $DHCP_IFACE"
echo " VMs will not have external connectivity until gateway_ip is configured"
fi
fi
fi
# --- Step 3d: Management bridge for system-instance control plane ---
# br-mgmt is an OVS bridge (not L2-learning Linux bridge, not part of OVN
# overlay). fail-mode=standalone makes it behave like a plain L2 switch
# without OVN flows; it is excluded from ovn-bridge-mappings so
# ovn-controller ignores it. System instance TAPs attach via ovs-vsctl
# add-port (see SetupMgmtTapDevice in daemon/network.go).
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
echo ""
echo "Step 3d: Configuring management bridge ($MGMT_BRIDGE)..."
sudo ovs-vsctl --may-exist add-br "$MGMT_BRIDGE"
sudo ovs-vsctl set Bridge "$MGMT_BRIDGE" \
fail-mode=standalone \
other-config:disable-in-band=true
sudo ip link set "$MGMT_BRIDGE" up
echo " $MGMT_BRIDGE: OVS bridge, fail-mode=standalone, up"
# Enslave physical/virtual mgmt NIC when provided (multi-node deployments
# where the node's mgmt subnet spans multiple hosts).
if [ -n "$MGMT_IFACE" ]; then
if ! ip link show "$MGMT_IFACE" >/dev/null 2>&1; then
echo " ERROR: mgmt interface $MGMT_IFACE does not exist"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
# Drop any existing IP on the NIC — IP belongs on the bridge.
sudo ip addr flush dev "$MGMT_IFACE" || true
sudo ovs-vsctl --may-exist add-port "$MGMT_BRIDGE" "$MGMT_IFACE"
sudo ip link set "$MGMT_IFACE" up
echo " $MGMT_IFACE: port on $MGMT_BRIDGE"
fi
if [ -n "$MGMT_CIDR" ]; then
sudo ip addr replace "$MGMT_CIDR" dev "$MGMT_BRIDGE"
echo " $MGMT_BRIDGE: address $MGMT_CIDR"
# Persist the IP across reboots via a systemd-networkd drop-in. The
# OVS bridge definition itself survives in ovsdb; only the L3 IP
# needs re-applying on boot.
MGMT_NETD_UNIT="/etc/systemd/network/10-spinifex-mgmt.network"
sudo tee "$MGMT_NETD_UNIT" >/dev/null <<NETD
[Match]
Name=$MGMT_BRIDGE
[Network]
Address=$MGMT_CIDR
ConfigureWithoutCarrier=yes
NETD
echo " wrote $MGMT_NETD_UNIT (IP persists on reboot via systemd-networkd)"
fi
fi
# --- Step 4: Configure OVN external_ids ---
echo ""
echo "Step 4: Setting OVS external_ids for OVN..."
if [ -n "$WAN_BRIDGE" ]; then
BRIDGE_MAPPINGS="external:${WAN_BRIDGE}"
sudo ovs-vsctl set Open_vSwitch . \
external_ids:ovn-remote="$OVN_REMOTE" \
external_ids:ovn-encap-ip="$ENCAP_IP" \
external_ids:ovn-encap-type="geneve" \
external_ids:ovn-bridge-mappings="$BRIDGE_MAPPINGS"
echo " ovn-bridge-mappings: $BRIDGE_MAPPINGS"
else
sudo ovs-vsctl set Open_vSwitch . \
external_ids:ovn-remote="$OVN_REMOTE" \
external_ids:ovn-encap-ip="$ENCAP_IP" \
external_ids:ovn-encap-type="geneve"
fi
# system-id is owned by the openvswitch-switch package (persisted in
# /etc/openvswitch/system-id.conf and re-applied on every boot). Read it back
# rather than overriding — overriding here would drift from the on-disk value
# and the next reboot would silently flip the chassis identity (mulga-999).
echo " system-id: $(sudo ovs-vsctl get open . external_ids:system-id)"
echo " ovn-remote: $OVN_REMOTE"
echo " ovn-encap-ip: $ENCAP_IP"
echo " ovn-encap-type: geneve"
# --- Step 5: Start ovn-controller ---
echo ""
echo "Step 5: Starting ovn-controller..."
# Set ovn-controller file log level to WARN so it doesn't spam the log with
# connection-retry INFO messages ("OVNSB commit failed") when the SB DB
# isn't running. Uses a systemd ExecStartPost so it persists across restarts.
OVN_CTRL_OVERRIDE="/etc/systemd/system/ovn-controller.service.d/log-level.conf"
sudo mkdir -p "$(dirname "$OVN_CTRL_OVERRIDE")"
sudo tee "$OVN_CTRL_OVERRIDE" >/dev/null <<'OVERRIDE'
[Service]
ExecStartPost=/bin/sh -c 'OVS_RUNDIR=/var/run/ovn exec /usr/bin/ovs-appctl -t ovn-controller vlog/set file:warn'
OVERRIDE
sudo systemctl daemon-reload
echo " ovn-controller log level: file:warn (via systemd drop-in)"
sudo systemctl restart ovn-controller
echo " ovn-controller: started"
# --- Step 6: Sysctl tuning ---
echo ""
echo "Step 6: Applying sysctl for overlay networking..."
sudo tee /etc/sysctl.d/99-spinifex-vpc.conf >/dev/null <<'SYSCTL'
# Spinifex VPC networking: enable IP forwarding and disable rp_filter
# for Geneve overlay traffic on OVS bridges.
net.ipv4.ip_forward=1
net.ipv4.conf.all.rp_filter=0
net.ipv4.conf.default.rp_filter=0
SYSCTL
sudo sysctl --system -q
echo " ip_forward=1, rp_filter=0"
# --- Step 6b: Ensure data NIC routing for Geneve tunnels ---
echo ""
echo "Step 6b: Configuring data NIC routing for Geneve tunnels..."
# When management and data NICs share the same subnet (e.g. both on 10.1.0.0/16),
# the kernel may route Geneve tunnel traffic through the management NIC with the
# wrong source IP. This causes remote OVS nodes to drop incoming tunnel packets
# because the source IP doesn't match the configured tunnel remote_ip.
# Fix: lower the route metric on the data NIC so it's preferred.
DATA_IFACE=$(ip -o -4 addr show | awk -v ip="$ENCAP_IP" '$0 ~ ip"/" {print $2}')
if [ -n "$DATA_IFACE" ]; then
SUBNET=$(ip -o -4 route show dev "$DATA_IFACE" proto kernel scope link | awk '{print $1}' | head -1)
if [ -n "$SUBNET" ]; then
sudo ip route replace "$SUBNET" dev "$DATA_IFACE" src "$ENCAP_IP" metric 50
echo " data route: $SUBNET via $DATA_IFACE src $ENCAP_IP (metric 50)"
else
echo " skipped: no kernel route found for $DATA_IFACE"
fi
else
echo " skipped: could not find interface for $ENCAP_IP"
fi
# --- Step 7: Verify Geneve kernel support ---
echo ""
echo "Step 7: Verifying Geneve kernel module..."
if sudo modprobe geneve 2>/dev/null; then
echo " geneve module: loaded"
else
echo " WARNING: geneve module not available (tunnels may not work)"
fi
# --- Step 8: Grant non-root access to OVS/OVN ---
echo ""
echo "Step 8: Configuring non-root access..."
# Open OVS DB socket so non-root processes can use ovs-vsctl
OVS_SOCK="/var/run/openvswitch/db.sock"
if [ -S "$OVS_SOCK" ]; then
sudo chmod 0666 "$OVS_SOCK"
echo " OVS DB socket: opened ($OVS_SOCK)"
fi
# Open OVN runtime directory and ctl sockets for ovs-appctl access
if [ -d "/var/run/ovn" ]; then
sudo chmod 0755 /var/run/ovn
sudo chmod 0666 /var/run/ovn/*.ctl 2>/dev/null || true
echo " OVN ctl sockets: opened (/var/run/ovn/)"
fi
if [ -d "/var/run/openvswitch" ]; then
sudo chmod 0666 /var/run/openvswitch/*.ctl 2>/dev/null || true
fi
# Create persistent systemd override so permissions survive OVS restarts
OVERRIDE_DIR="/etc/systemd/system/openvswitch-switch.service.d"
if [ ! -f "$OVERRIDE_DIR/spinifex-perms.conf" ]; then
sudo mkdir -p "$OVERRIDE_DIR"
sudo tee "$OVERRIDE_DIR/spinifex-perms.conf" >/dev/null <<'OVERRIDE'
[Service]
ExecStartPost=/bin/chmod 0666 /var/run/openvswitch/db.sock
OVERRIDE
sudo systemctl daemon-reload
echo " systemd override: created (db.sock permissions persist across restarts)"
else
echo " systemd override: already exists"
fi
# Sudoers rules for spinifex-daemon and spinifex-vpcd are managed by setup.sh
# (install_sudoers). Skip writing here to avoid conflicts.
SUDOERS_FILE="/etc/sudoers.d/spinifex-network"
if [ -f "$SUDOERS_FILE" ]; then
echo " sudoers rule: already exists ($SUDOERS_FILE, managed by setup.sh)"
else
echo " sudoers rule: not found — run setup.sh first, or install manually"
fi
# --- Step 9: Configure OVN log rotation ---
# The ovn-common package provides /etc/logrotate.d/ovn-common which handles
# rotation and vlog/reopen. We just add maxsize + rotate to cap disk usage.
echo ""
echo "Step 9: Configuring OVN log rotation..."
OVN_LOGROTATE="/etc/logrotate.d/ovn-common"
if [ -f "$OVN_LOGROTATE" ]; then
if ! grep -q 'maxsize' "$OVN_LOGROTATE"; then
sudo sed -i '/^\/var\/log\/ovn\/\*\.log {/a\ rotate 5\n maxsize 100M' "$OVN_LOGROTATE"
echo " added maxsize 100M + rotate 5 to $OVN_LOGROTATE"
else
echo " $OVN_LOGROTATE already has maxsize configured"
fi
else
echo " WARNING: $OVN_LOGROTATE not found — install ovn-common package"
fi
# Remove our old custom config if present (superseded by patching ovn-common)
if [ -f /etc/logrotate.d/ovn-spinifex ]; then
sudo rm -f /etc/logrotate.d/ovn-spinifex
echo " removed obsolete /etc/logrotate.d/ovn-spinifex"
fi
# --- Step 10: Enable auto-start on boot ---
# OVN services should start with the system in production. ovn-controller
# retries when the SB DB isn't ready; file log level is set to WARN (Step 5)
# to prevent log spam during those retries.
echo ""
echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable openvswitch-switch 2>/dev/null || true
sudo systemctl enable ovn-controller 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
# --- Step 11: Health check ---
echo ""
echo "Step 11: Verifying setup..."
OK=true
# Check br-int
if sudo ovs-vsctl br-exists br-int; then
echo " br-int: OK"
else
echo " br-int: FAILED"
OK=false
fi
# Check WAN bridge (only if configured)
if [ -n "$WAN_BRIDGE" ]; then
if sudo ovs-vsctl br-exists "$WAN_BRIDGE"; then
echo " $WAN_BRIDGE:$(printf '%*s' $((15 - ${#WAN_BRIDGE})) '') OK"
if [ "$WAN_BRIDGE_MODE" = "veth" ]; then
if ip link show veth-wan-br >/dev/null 2>&1 && ip link show veth-wan-ovs >/dev/null 2>&1; then
echo " veth pair: OK (veth-wan-br ↔ veth-wan-ovs)"
echo " linux bridge: $LINUX_BRIDGE (untouched)"
else
echo " veth pair: FAILED (veth-wan-br/veth-wan-ovs not found)"
OK=false
fi
elif [ "$WAN_BRIDGE_MODE" = "direct" ]; then
if sudo ovs-vsctl port-to-br "$WAN_IFACE" >/dev/null 2>&1; then
echo " direct bridge: OK ($WAN_IFACE on $WAN_BRIDGE)"
else
echo " direct bridge: FAILED ($WAN_IFACE not on $WAN_BRIDGE)"
OK=false
fi
elif [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan: OK ($MACVLAN_NAME)"
else
echo " macvlan: FAILED ($MACVLAN_NAME not found)"
OK=false
fi
fi
else
echo " $WAN_BRIDGE:$(printf '%*s' $((15 - ${#WAN_BRIDGE})) '') FAILED"
OK=false
fi
fi
# Check mgmt bridge (only if enabled)
if [ "$MGMT_BRIDGE_ENABLED" = true ]; then
if sudo ovs-vsctl br-exists "$MGMT_BRIDGE"; then
MGMT_IP_ACTUAL=$(ip -4 -o addr show dev "$MGMT_BRIDGE" 2>/dev/null | awk '{print $4}' | head -1)
echo " $MGMT_BRIDGE:$(printf '%*s' $((15 - ${#MGMT_BRIDGE})) '') OK (${MGMT_IP_ACTUAL:-no IP})"
else
echo " $MGMT_BRIDGE:$(printf '%*s' $((15 - ${#MGMT_BRIDGE})) '') FAILED"
OK=false
fi
fi
# Check ovn-controller
if sudo ovs-appctl -t ovn-controller version >/dev/null 2>&1 || systemctl is-active --quiet ovn-controller 2>/dev/null; then
echo " ovn-controller: OK"
else
echo " ovn-controller: FAILED (may still be starting)"
OK=false
fi
# Check chassis registration (may take a moment)
if [ "$MANAGEMENT" = true ]; then
sleep 2
CHASSIS_COUNT=$(sudo ovn-sbctl show 2>/dev/null | grep -c "Chassis" || true)
echo " chassis count: $CHASSIS_COUNT"
fi
echo ""
if [ "$OK" = true ]; then
echo "=== OVN compute node setup complete ==="
else
echo "=== Setup completed with warnings (check above) ==="
fi