Files
lab/labsim/ovs.sh

162 lines
6.1 KiB
Bash
Raw Permalink Normal View History

feat(labsim): libvirt replica of the lab network with LACP + VyOS routing A throwaway copy of the production VLAN topology so routing and firewall changes can be tested before they touch the real network. Same VLAN IDs and roles as UniFi, deliberately different ranges (172.31.<vlan>.0/24) so nothing here can be mistaken for production. - OVS fabric: real 802.1Q. Access port per micro VM, host leg per VLAN (.2, for SSH only — NOT the VMs' default route, so inter-VLAN tests exercise the router rather than the host's routing table), and a trunk portgroup with VLAN 1 declared nativeMode='untagged'. - Six Alpine micro VMs (256MB, copy-on-write overlays on one 176MB image), SSH + a hello-world HTTP page naming the VLAN. - VyOS router installed to disk unattended over the console, with the SAME config shape as the VP2440s: two NICs in an LACP bond carrying the trunk, VLAN 1 native, bond0.<vlan> holding the .1 gateway on each. - labsim-matrix.py: full-mesh ICMP/TCP22/TCP80 probe, ~0.2s, --watch highlights cells that changed since the last sweep. Guest-side probe is python3 (already present via cloud-init) so nothing is installed on VMs that have no internet. - Prometheus + Grafana (anonymous auth, no login) with a provisioned dashboard: heatmap plus a state timeline showing exactly when a path flipped. Verified end to end: one VyOS rule took sum(labsim_reachable) from 90 to 84, blocking precisely kvm<->k8s across all three protocols. Traps found building this, all now encoded in the scripts: - virtio-net breaks 802.3ad: the guest's bonding driver reports slaves "MII Status: down" despite carrier=1 and never sends an LACPDU, so the bond sits in AD_STATE_DEFAULTED. e1000e fixes it with no other change. Matches the netdev thread "bonding (IEEE 802.3ad) not working with qemu/virtio". - OVS defaults bonds to active-backup, which does not speak LACP at all — bond_mode=balance-tcp is required. - LACP deadlock: OVS holds members disabled until negotiation while the partner needs carrier before it will send LACPDUs. lacp-fallback-ab breaks it. - LACPDUs are untagged, so a trunk with no native VLAN has nowhere to put them. - --boot cdrom,hd re-runs the ISO on every restart, so every commit+save went to a live system that evaporated. Install now switches the VM to boot hd. - cloud-init on Alpine: users stay locked without lock_passwd:false, one failing runcmd aborts the rest, busybox here has no httpd applet, and start-stop-daemon --exec /usr/bin/python3 matches cloud-init's own python3. - The user-data heredoc is unquoted, so backticks in a COMMENT were executed by the host shell and their output corrupted the YAML. build_seed now validates with yaml.safe_load before building the ISO. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-13 00:42:39 +01:00
#!/bin/bash
# Open vSwitch fabric for labsim — the "switch" the whole sim hangs off.
#
# Why OVS and not a Linux bridge: a Linux bridge cannot do LACP at all, and its
# VLAN support is awkward to drive from libvirt. OVS gives real 802.1Q access
# and trunk ports plus real LACP bonds, so a router VM can run the SAME bond0 +
# vif config as the production VP2440s instead of an approximation.
#
# Layout:
# ovs-labsim the switch
# ├─ vm ports access ports, tag=<vlan> (micro VM per VLAN)
# ├─ hostv<vlan> internal ports, tag=<vlan> (host leg, for SSH)
# └─ lag-vyos LACP bond, trunk of all VLANs (router under test)
# shellcheck disable=SC2034
OVS_BR="${OVS_BR:-ovs-labsim}"
OVS_NET="${OVS_NET:-labsim-ovs}" # libvirt network wrapping the bridge
LAG_NAME="${LAG_NAME:-lag-vyos}"
ovs() { sudo ovs-vsctl "$@"; }
ovs_require() {
command -v ovs-vsctl >/dev/null 2>&1 || die "openvswitch not installed (dnf install openvswitch)"
systemctl is-active --quiet openvswitch || sudo systemctl start openvswitch \
|| die "could not start openvswitch"
}
# All VLAN ids from the config, comma separated — used for trunk ports.
vlan_id_list() {
local ids=()
for entry in "${SELECTED[@]}"; do ids+=("${entry%%:*}"); done
(IFS=,; echo "${ids[*]}")
}
ovs_up() {
ovs_require
ovs --may-exist add-br "$OVS_BR"
# Host leg per VLAN: an OVS internal port carrying that VLAN's tag, given the
# .2 address. This is how you SSH to the VMs. It is deliberately NOT their
# default route (.1 is), so inter-VLAN tests exercise the router, not the
# host's routing table.
for entry in "${SELECTED[@]}"; do
IFS=: read -r vid _name prefix _real <<<"$entry"
local port="hostv${vid}"
ovs --may-exist add-port "$OVS_BR" "$port" tag="$vid" \
-- set interface "$port" type=internal
sudo ip link set "$port" up 2>/dev/null || true
sudo ip addr replace "${prefix}.2/24" dev "$port"
done
ovs_define_libvirt_net
}
# A libvirt network that hands out OVS ports: one portgroup per VLAN (access)
# plus a trunk portgroup for the router.
ovs_define_libvirt_net() {
local pg="" ids
for entry in "${SELECTED[@]}"; do
IFS=: read -r vid name _p _r <<<"$entry"
pg+=" <portgroup name='vlan${vid}'>
<vlan><tag id='${vid}'/></vlan>
</portgroup>
"
done
# Trunk: VLAN 1 native/untagged, everything else tagged — the production
# shape. libvirt expresses this declaratively via nativeMode='untagged'
# (see libvirt formatnetwork.html), so it does not need fixing up by hand.
# It also matters functionally: LACPDUs are untagged, and a trunk with no
# native VLAN has nowhere to put them.
local trunk=" <portgroup name='trunk'>
<vlan trunk='yes'>
"
for entry in "${SELECTED[@]}"; do
IFS=: read -r vid _n _p _r <<<"$entry"
if [ "$vid" = "1" ]; then
trunk+=" <tag id='1' nativeMode='untagged'/>
"
else
trunk+=" <tag id='${vid}'/>
"
fi
done
trunk+=" </vlan>
</portgroup>
"
local xml="<network>
<name>${OVS_NET}</name>
<forward mode='bridge'/>
<bridge name='${OVS_BR}'/>
<virtualport type='openvswitch'/>
${pg}${trunk}</network>"
if virsh_q net-info "$OVS_NET" >/dev/null 2>&1; then
virsh_q net-destroy "$OVS_NET" >/dev/null 2>&1 || true
virsh_q net-undefine "$OVS_NET" >/dev/null 2>&1 || true
fi
echo "$xml" | virsh_q net-define /dev/stdin >/dev/null
virsh_q net-start "$OVS_NET" >/dev/null
log "libvirt network $OVS_NET bound to $OVS_BR (access portgroups + trunk)"
}
# Replace the router VM's two individual OVS ports with a single LACP bond.
# libvirt attaches each NIC separately; only ovs-vsctl can bond them, and the
# taps only exist once the VM is running — so this runs post-start.
ovs_bond_router() {
local vm="$1"
local taps
# NB: domiflist indents its rows, so anchor on the FIELD not the line —
# /^vnet/ silently matches nothing and the bond never gets built.
taps="$(virsh_q domiflist "$vm" 2>/dev/null | awk '$1 ~ /^vnet/ {print $1}')"
local count; count="$(echo "$taps" | grep -c .)"
[ "$count" -eq 2 ] || { warn "router $vm has $count tap(s), expected 2 — skipping bond"; return 1; }
# Already bonded? (idempotent re-runs)
if ovs list-ports "$OVS_BR" 2>/dev/null | grep -qx "$LAG_NAME"; then
log "LACP bond $LAG_NAME already present"
return 0
fi
local t1 t2; t1="$(echo "$taps" | sed -n 1p)"; t2="$(echo "$taps" | sed -n 2p)"
log "bonding $t1 + $t2 into $LAG_NAME (LACP active, balance-tcp)"
ovs del-port "$OVS_BR" "$t1" 2>/dev/null || true
ovs del-port "$OVS_BR" "$t2" 2>/dev/null || true
# bond_mode=balance-tcp is REQUIRED: OVS defaults a bond to active-backup,
# which does not speak LACP at all (confirmed on ovs-discuss). It is also the
# equivalent of VyOS's 802.3ad + layer2+3 hashing.
#
# lacp-fallback-ab breaks a genuine deadlock: OVS keeps members disabled
# until LACP negotiates, while the partner needs carrier before it will send
# LACPDUs. Falling back to active-backup brings the links up so negotiation
# can start.
#
# native-untagged + tag=1 carries the untagged LACPDUs and the management
# VLAN, matching production. libvirt's portgroup VLAN config does NOT apply
# here — the bond is a port libvirt never created — so set it inline.
local tagged; tagged="$(vlan_id_list | tr ',' '\n' | grep -vx 1 | paste -sd, -)"
ovs add-bond "$OVS_BR" "$LAG_NAME" "$t1" "$t2" \
lacp=active bond_mode=balance-tcp \
vlan_mode=native-untagged tag=1 trunks="$tagged" \
-- set port "$LAG_NAME" other_config:lacp-time=fast \
-- set port "$LAG_NAME" other_config:lacp-fallback-ab=true
}
ovs_bond_status() {
echo "--- ovs bond ---"
sudo ovs-appctl bond/show "$LAG_NAME" 2>/dev/null | grep -E "bond_mode|lacp_status|^member|may_enable" || echo "(no bond)"
echo "--- lacp ---"
sudo ovs-appctl lacp/show "$LAG_NAME" 2>/dev/null | grep -E "status|aggregation key|^member|attached" || true
}
ovs_down() {
virsh_q net-destroy "$OVS_NET" >/dev/null 2>&1 || true
virsh_q net-undefine "$OVS_NET" >/dev/null 2>&1 || true
if command -v ovs-vsctl >/dev/null 2>&1; then
ovs --if-exists del-br "$OVS_BR" 2>/dev/null || true
fi
}