Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9
29 KiB
NetworkManager-owned WANs (deploy.sh provisioning) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let deploy.sh provision NetworkManager-owned WANs (Starlink port, optional Wi-Fi uplink, cellular modem) on any host with ENABLE_NM_WAN=1, so van-failover works on the R5C exactly as on the Pi 5, without ever flapping uplinks during a routine deploy.
Architecture: New host variables select the behaviour. A routine deploy installs missing packages (NetworkManager only after the AP/LAN "unmanaged" file is in place), enables NM/ModemManager, writes the cellular NM connection as a keyfile if absent, and warns when the WAN netplan file is pending. The WAN netplan change happens only through an explicit ./deploy.sh --apply-network, which backs up /etc/netplan, installs the rendered file, moves Armbian's DHCP rule aside, and applies from a detached transient unit that verifies connectivity and rolls back automatically.
Tech Stack: bash (set -euo pipefail), sed/awk, systemd (systemd-run), NetworkManager keyfiles, netplan, apt. Tests: tests/check-templates.sh (plain bash, ok/FAIL lines).
Spec: docs/superpowers/specs/2026-09-23-nm-wan-provisioning-design.md (builds on 2026-09-23-host-config-design.md)
Global Constraints
- Subagents never touch a live host, never push, and never run
./deploy.shwithout a sub-command (--check,--diff,--templates,--post-check,--classify-unit,--classify-radio,--redact,--wan-plan,--print-apply-helper,renderare the safe ones; they need no root and change nothing). - New required flag
ENABLE_NM_WAN(exactly 0 or 1). New optional varsCELLULAR_CONN_NAME,CELLULAR_APN;WIFI_WAN_IFACEbecomes optional (empty = no Wi-Fi uplink). Derived varCELLULAR_UUID. - Guard convention (test check 6): units/paths of a flagged feature only appear inside a column-0
if [ "$ENABLE_X" = 1 ]; then … figuard with 4-space-indented contents (comment andecholines are exempt). - A routine
./deploy.shnever changes/etc/netplan/*or restarts the network for WAN purposes. Only--apply-networkdoes. - Rendered
50-van-wan.yamlforhosts/wan.confmust stay byte-identical to what the pre-split single template rendered (the Pi 5 must see no change). - Hosts:
hosts/van.conf(R5C,van) andhosts/wan.conf(Pi 5,wan). Both getENABLE_NM_WAN=1,CELLULAR_CONN_NAME=Koodo,CELLULAR_APN=sp.koodo.com.hosts/van.confgetsWIFI_WAN_IFACE=(empty);hosts/wan.confkeepsWIFI_WAN_IFACE=wlan0. - Commit messages end with two trailer lines:
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9. - Never put secrets in
hosts/*.conf; the Wapana PSK stays exactly where it is (moved verbatim into the fragment).
File Structure
| File | Responsibility |
|---|---|
deploy.sh (modify) |
vars/flags, apt auto-install, NM section, --wan-plan, --apply-network, --print-apply-helper, drift warning, post_check additions |
ap/50-van-wan.yaml (modify) |
ethernet (Starlink) part only |
ap/50-van-wan-wifi.yaml (create) |
the wifis: stanza (Wapana AP), appended only when WIFI_WAN_IFACE is set |
ap/cellular.nmconnection (create) |
NM keyfile template for the cellular connection |
failover/config.json (modify) |
connection becomes @CELLULAR_CONN_NAME@ |
hosts/vanlink.conf.example, hosts/van.conf, hosts/wan.conf (modify) |
new variables |
tests/check-templates.sh (modify) |
new checks 14–19 |
README.md (modify) |
short multi-WAN paragraph |
Task 1: Variables, template split, cellular keyfile template
Files: modify deploy.sh (variable arrays, load_conf, drift check, new render_wan_netplan), ap/50-van-wan.yaml, failover/config.json, hosts/*.conf*, tests/check-templates.sh; create ap/50-van-wan-wifi.yaml, ap/cellular.nmconnection.
Interfaces:
-
Produces in
deploy.sh:ENABLE_NM_WANinREQUIRED_VARSand inload_conf's flag loop;WIFI_WAN_IFACEmoved fromREQUIRED_VARStoOPTIONAL_VARS;CELLULAR_CONN_NAME CELLULAR_APNappended toOPTIONAL_VARS;CELLULAR_UUIDappended toDERIVED_VARSand set inload_conf; functionrender_wan_netplan(stdout = renderedap/50-van-wan.yaml, plus renderedap/50-van-wan-wifi.yamlwhenWIFI_WAN_IFACEis non-empty). -
Consumed later: all of the above.
-
Step 1: Write failing tests (checks 14–16) — insert before the final
exit "$fail"oftests/check-templates.sh:
# 14. the netplan split leaves the Pi 5's rendered WAN netplan byte-identical, and the R5C (no Wi-Fi WAN) omits wifis
old=$(mktemp)
git show 223652b:ap/50-van-wan.yaml > "$old"
want=$(VANLINK_CONF=hosts/wan.conf ./deploy.sh render "$old")
tmpw=$(mktemp)
# render_wan_netplan is internal; expose it through --render-wan-netplan (documented in the header)
VANLINK_CONF=hosts/wan.conf ./deploy.sh --render-wan-netplan > "$tmpw"
if [ "$want" = "$(cat "$tmpw")" ]; then ok "wan.conf: split netplan renders identical to the old single template"; else bad "wan.conf: netplan render changed by the split"; diff <(echo "$want") "$tmpw" | head -10 | sed 's/^/ /'; fi
if VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan | grep -q '^ wifis:'; then bad "van.conf: wifis stanza must be omitted (WIFI_WAN_IFACE empty)"; else ok "van.conf: no wifis stanza"; fi
rm -f "$tmpw" "$old"
# 15. cellular connection name comes from the host config
expect van failover/config.json '"connection": "Koodo"'
expect wan failover/config.json '"connection": "Koodo"'
# 16. cellular keyfile template renders a valid NM gsm profile with a stable uuid
for h in van wan; do
k=$(VANLINK_CONF=hosts/$h.conf ./deploy.sh render ap/cellular.nmconnection)
for pat in '^id=Koodo$' '^type=gsm$' '^apn=sp.koodo.com$' '^route-metric=300$' '^never-default=true$' '^uuid=[0-9a-f-]{36}$'; do
if echo "$k" | grep -qE "$pat"; then ok "$h: cellular keyfile has $pat"; else bad "$h: cellular keyfile lacks $pat"; fi
done
done
git show 223652b:… is the last commit before this work touched the netplan template (the spec commit); the test needs that object in history, so do not squash it away. Run: ./tests/check-templates.sh; echo exit=$? — expected FAILs for 14–16 (no --render-wan-netplan, no keyfile template, no variables), exit=1.
-
Step 2: Split the netplan template. In
ap/50-van-wan.yaml, cut the wholewifis:block (from the linewifis:through the last line of the file, including the Wapanaaccess-pointsentry and itspassword) and paste it verbatim into the new fileap/50-van-wan-wifi.yaml, with no header comment and no blank line beforewifis:. Make sureap/50-van-wan.yamlstill ends with a newline. Thenrender_wan_netplan(Step 5) forhosts/wan.confmust reproduce the old single template byte for byte: check 14 proves it. (Explain the split in a comment next torender_wan_netplanindeploy.shinstead.) -
Step 3: Cellular keyfile template — create
ap/cellular.nmconnection(installed 0600 by Task 2):
# NetworkManager connection for the cellular WAN (see failover/config.json, van-failover).
# Created by deploy.sh only when absent; never rewritten. No modem needs to be present.
# never-default: van-failover is the sole owner of the default route (same as the Wi-Fi/Starlink profiles).
[connection]
id=@CELLULAR_CONN_NAME@
uuid=@CELLULAR_UUID@
type=gsm
autoconnect=true
[gsm]
apn=@CELLULAR_APN@
[ipv4]
method=auto
route-metric=300
never-default=true
[ipv6]
method=auto
-
Step 4:
failover/config.json: change"connection": "Koodo",to"connection": "@CELLULAR_CONN_NAME@",. -
Step 5:
deploy.shvariables and functions.REQUIRED_VARS: removeWIFI_WAN_IFACE, addENABLE_NM_WANafterENABLE_FRIGATE.OPTIONAL_VARS: addWIFI_WAN_IFACE CELLULAR_CONN_NAME CELLULAR_APN.DERIVED_VARS=(ONBOARD_LAN_UNMANAGED CELLULAR_UUID).- In
load_conf's flag loop addENABLE_NM_WAN; after theONBOARD_LAN_UNMANAGED=line add:CELLULAR_UUID=$(uuidgen -s -n @dns -N "vanlink-${CELLULAR_CONN_NAME:-}" 2>/dev/null || true) - Add, next to
render():render_wan_netplan() { # ap/50-van-wan.yaml, plus the wifis stanza when the host has a Wi-Fi uplink render ap/50-van-wan.yaml if [ -n "${WIFI_WAN_IFACE:-}" ]; then render ap/50-van-wan-wifi.yaml fi } - In the
caseblock add:--render-wan-netplan) load_conf "$VANLINK_CONF"; render_wan_netplan; exit 0 ;;and document it in the header comment. Change the netplan drift check to userender_wan_netplaninstead ofrender ap/50-van-wan.yaml(all three places, including thediff -uand the two warn texts). - Test check 2's
exemptlist gainsap/50-van-wan-wifi.yamlandap/cellular.nmconnection(installed by dedicated code, notinstall_rendered <src> /abs/dstliteral lines) — add a comment saying so.
-
Step 6: Host files.
hosts/vanlink.conf.example: documentENABLE_NM_WAN,CELLULAR_CONN_NAME,CELLULAR_APN, and markWIFI_WAN_IFACEoptional (empty = none).hosts/van.conf:WIFI_WAN_IFACE=(empty, with a comment: no spare radio; the R5C's two radios are APs),ENABLE_NM_WAN=1,CELLULAR_CONN_NAME=Koodo,CELLULAR_APN=sp.koodo.com.hosts/wan.conf: same three additions,WIFI_WAN_IFACE=wlan0unchanged. -
Step 6b: Fix the existing check-5 assertions that assume the R5C has
wlan0. Withhosts/van.confnowWIFI_WAN_IFACE=(empty) these existing lines intests/check-templates.shfail and must change forvanonly (leave thewanones):expect van failover/config.json '"device": "wlan0"'becomes'"device": ""';expect van router-status/config.json '"wifi_wan_iface": "wlan0"'becomes'"wifi_wan_iface": ""';expect van failover/van-wlan-watchdog.service 'van-wlan-watchdog wlan0'becomes'ExecStart=/usr/local/sbin/van-wlan-watchdog'(the script defaults towlan0when given no argument, exactly like today's behaviour on a host without that device). Add the matchingwanassertions if they are missing ("device": "wlan0",van-wlan-watchdog wlan0). Controller already verified thatvan-failoverhandles an emptydevicesafely:resolve()falls to theconnectionbranch,ip_iface(None)returnsNone, andsh()swallowsOSError, so the WAN simply reads "not present". -
Step 7: Verify. Run
./tests/check-templates.sh(expect all ok, exit 0),./deploy.sh --check hosts/van.conf,./deploy.sh --check hosts/wan.conf,bash -n deploy.sh. -
Step 8: Commit (
Template NM WAN pieces: netplan split, cellular keyfile, new host variables).
Task 2: Package auto-install, NM section, --wan-plan
Files: modify deploy.sh, tests/check-templates.sh.
Interfaces:
-
Consumes (Task 1):
ENABLE_NM_WAN,CELLULAR_*,render,render_wan_netplan,load_conf. -
Produces:
--wan-plan [conf](read-only, no root) printing lineswan-plan: ENABLE_NM_WAN=<0|1>, then when 1:wifi WAN: <iface>|none (wifis stanza omitted),packages: network-manager <installed|would install>,netplan: /etc/netplan/50-van-wan.yaml <missing|differs|matches>,armbian: /etc/netplan/10-dhcp-all-interfaces.yaml <present -> would move aside|absent>,cellular profile: <name> apn=<apn> <exists|would create>orcellular profile: skipped (no CELLULAR_APN). Functionsnm_wan_state_netplan(echomissing|differs|matches) reused by Task 3. -
Step 1: Failing tests (check 17):
# 17. --wan-plan describes what would happen (config-derived lines only; no root, no changes)
for h in van wan; do
if ! p=$(VANLINK_CONF=hosts/$h.conf ./deploy.sh --wan-plan 2>&1); then bad "$h: --wan-plan failed"; echo "$p" | sed 's/^/ /'; continue; fi
echo "$p" | grep -q '^wan-plan: ENABLE_NM_WAN=1$' && ok "$h: wan-plan header" || bad "$h: wan-plan header"
echo "$p" | grep -q 'cellular profile: Koodo apn=sp.koodo.com' && ok "$h: wan-plan cellular" || bad "$h: wan-plan cellular"
done
VANLINK_CONF=hosts/van.conf ./deploy.sh --wan-plan | grep -q 'wifi WAN: none' && ok "van: wan-plan says no Wi-Fi WAN" || bad "van: wan-plan Wi-Fi WAN"
VANLINK_CONF=hosts/wan.conf ./deploy.sh --wan-plan | grep -q 'wifi WAN: wlan0' && ok "wan: wan-plan Wi-Fi WAN wlan0" || bad "wan: wan-plan Wi-Fi WAN"
Do NOT extend check 6's awk need[...] table for the NM names: network-manager and .nmconnection legitimately appear in unguarded functions and sub-command arms (--wan-plan, --apply-network), and NetworkManager/nmcli already appear in unconditional install paths and lines. The NM section itself still follows the column-0 guard convention. Run: expect FAIL for 17 (no --wan-plan).
-
Step 2: Auto-install of required packages. In
deploy.sh,network-manageris NOT inREQUIRED_PACKAGES. Replace the loopfor pkg in "${REQUIRED_PACKAGES[@]}"; do dpkg -s "$pkg" >/dev/null 2>&1 || { echo "MISSING REQUIRED: $pkg (apt install $pkg)"; exit 1; } donewith:
missing_pkgs=() for pkg in "${REQUIRED_PACKAGES[@]}"; do dpkg -s "$pkg" >/dev/null 2>&1 || missing_pkgs+=("$pkg") done if [ ${#missing_pkgs[@]} -gt 0 ]; then echo " installing missing required packages: ${missing_pkgs[*]}" apt-get update -qq || echo " -> apt-get update failed (continuing with the cached lists)" DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing_pkgs[@]}" \ || { echo "deploy: apt-get install failed for: ${missing_pkgs[*]}" >&2; exit 1; } fi -
Step 3: The NM section. Add a guarded block after the access-point section (so
van-ap-unmanaged.confis already installed) and before the== apply ==section:
if [ "$ENABLE_NM_WAN" = 1 ]; then
echo "== networkmanager WANs =="
if ! dpkg -s network-manager >/dev/null 2>&1; then
# Installed only now: the package starts NM, which manages Wi-Fi/gsm devices by
# default, so van-ap-unmanaged.conf (AP radios, LAN ports) must already be in place.
apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y network-manager \
|| { echo "deploy: apt-get install network-manager failed" >&2; exit 1; }
fi
systemctl enable --now NetworkManager ModemManager >/dev/null 2>&1 || true
nmcli general reload conf 2>/dev/null || true
for nm_if in "$WIFI_5G_IFACE" "$WIFI_2G_IFACE" "$LAN_IFACE" ${ONBOARD_LAN_IFACE:-}; do
[ -e "/sys/class/net/$nm_if" ] || continue
nm_state=$(nmcli -t -f DEVICE,STATE device 2>/dev/null | awk -F: -v d="$nm_if" '$1 == d { print $2 }') || true
[ "$nm_state" = unmanaged ] \
|| warn "NetworkManager manages $nm_if (state: ${nm_state:-unknown}) — AP/LAN ports must be unmanaged (see ap/van-ap-unmanaged.conf)"
done
if [ -n "${CELLULAR_APN:-}" ]; then
nm_keyfile="/etc/NetworkManager/system-connections/${CELLULAR_CONN_NAME}.nmconnection"
if [ -e "$nm_keyfile" ]; then
echo " cellular connection ${CELLULAR_CONN_NAME} exists — left unchanged"
else
install_rendered ap/cellular.nmconnection "$nm_keyfile" 0600
nmcli connection reload 2>/dev/null || true
echo " created cellular connection ${CELLULAR_CONN_NAME} (apn ${CELLULAR_APN}) — no modem needed"
fi
fi
if [ "$(nm_wan_state_netplan)" != matches ]; then
warn "WAN netplan is not provisioned/up to date: run ./deploy.sh --apply-network (backs up /etc/netplan and rolls back automatically if the uplink does not come back)"
fi
fi
Requirements: inside the guard, contents indented 4 spaces; the existing drift check (== netplan drift check ==) must be replaced by this block's warning when ENABLE_NM_WAN=1 and left as-is for hosts with the flag at 0 (wrap the old check in if [ "$ENABLE_NM_WAN" != 1 ]; then … fi — a != guard is fine for check 6 since it contains no flagged unit names). Add to the enable list (set_units): when ENABLE_NM_WAN=1, NetworkManager ModemManager are appended to ENABLE_UNITS (keep the column-0 guard style used by set_units). Add near render_wan_netplan:
nm_wan_state_netplan() { # missing | differs | matches (the installed WAN netplan vs the rendered one)
if [ ! -f /etc/netplan/50-van-wan.yaml ]; then echo missing
elif diff -q <(render_wan_netplan) /etc/netplan/50-van-wan.yaml >/dev/null 2>&1; then echo matches
else echo differs; fi
}
- Step 4:
--wan-planin thecaseblock (needsload_conf, no root, no changes):
--wan-plan) # ./deploy.sh --wan-plan [conf] — what the NM WAN provisioning would do; changes nothing
load_conf "${2:-$VANLINK_CONF}"
echo "wan-plan: ENABLE_NM_WAN=$ENABLE_NM_WAN"
if [ "$ENABLE_NM_WAN" = 1 ]; then
if [ -n "${WIFI_WAN_IFACE:-}" ]; then echo " wifi WAN: $WIFI_WAN_IFACE"; else echo " wifi WAN: none (wifis stanza omitted)"; fi
if dpkg -s network-manager >/dev/null 2>&1; then echo " packages: network-manager installed"; else echo " packages: network-manager would install"; fi
echo " netplan: /etc/netplan/50-van-wan.yaml $(nm_wan_state_netplan)"
if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml present -> would move aside"; else echo " armbian: /etc/netplan/10-dhcp-all-interfaces.yaml absent"; fi
if [ -z "${CELLULAR_APN:-}" ]; then
echo " cellular profile: skipped (no CELLULAR_APN)"
elif [ -e "/etc/NetworkManager/system-connections/${CELLULAR_CONN_NAME}.nmconnection" ]; then
echo " cellular profile: ${CELLULAR_CONN_NAME} apn=${CELLULAR_APN} exists"
else
echo " cellular profile: ${CELLULAR_CONN_NAME} apn=${CELLULAR_APN} would create"
fi
fi
exit 0 ;;
Document --wan-plan in the deploy.sh header comment. Because nm_wan_state_netplan and render_wan_netplan are defined before the case block (place both next to render), they are usable here.
- Step 5: Verify.
./tests/check-templates.sh(all ok, exit 0),bash -n deploy.sh,./deploy.sh --wan-plan hosts/van.confandhosts/wan.conf(eyeball output). - Step 6: Commit (
deploy: install missing packages, NetworkManager WAN section, --wan-plan).
Task 3: --apply-network with rollback
Files: modify deploy.sh, tests/check-templates.sh.
Interfaces:
-
Consumes (Tasks 1–2):
render_wan_netplan,nm_wan_state_netplan,load_conf,die,ENABLE_NM_WAN,STARLINK_IFACE. -
Produces:
apply_network(root);apply_helper_script <backup-dir>printing the detached helper script to stdout; sub-commands--apply-network(root) and--print-apply-helper [conf](no root; for tests and review; uses a fake backup dir path). -
Step 1: Failing test (check 18) — before the final
exit:
# 18. the detached apply helper: syntax, ordering, rollback branch
h=$(VANLINK_CONF=hosts/van.conf ./deploy.sh --print-apply-helper 2>&1) || { bad "--print-apply-helper failed"; echo "$h" | sed 's/^/ /'; h=; }
if [ -n "$h" ]; then
echo "$h" | bash -n && ok "apply helper: bash -n" || bad "apply helper: bash -n"
echo "$h" | grep -q 'netplan generate' && echo "$h" | grep -q 'netplan apply' && ok "apply helper applies netplan" || bad "apply helper lacks netplan generate/apply"
echo "$h" | grep -q 'restoring' && ok "apply helper has a rollback branch" || bad "apply helper lacks rollback"
echo "$h" | grep -q 'enP2p33s0' && ok "apply helper waits on the Starlink port" || bad "apply helper does not reference STARLINK_IFACE"
# rollback must come after the wait loop, and the backup is restored wholesale
a=$(echo "$h" | grep -n 'netplan apply' | head -1 | cut -d: -f1); r=$(echo "$h" | grep -n 'restoring' | head -1 | cut -d: -f1)
[ "$a" -lt "$r" ] && ok "apply helper: apply precedes rollback" || bad "apply helper: rollback ordering"
fi
Run: expect FAIL (no sub-command).
- Step 2: Helper generator (place with the other functions, before the
caseblock):
apply_helper_script() { # apply_helper_script <backup-dir> — the script the detached unit runs
local bak=$1
cat <<HELPER
#!/bin/bash
# Generated by deploy.sh --apply-network: apply the WAN netplan, verify the uplink, roll back if it did not come back.
exec >>/var/log/vanlink-apply-network.log 2>&1
echo "== apply-network \$(date -Is) (backup: $bak)"
netplan generate && netplan apply
ok=0
for i in \$(seq 1 60); do
have_default=0; ip route show default | grep . >/dev/null && have_default=1
nm_ok=1
if [ -e /sys/class/net/$STARLINK_IFACE ]; then
nm_ok=0
nmcli -t -f DEVICE,STATE device 2>/dev/null | grep -Fx '$STARLINK_IFACE:connected' >/dev/null && nm_ok=1
fi
if [ \$have_default = 1 ] && [ \$nm_ok = 1 ] && ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then ok=1; break; fi
sleep 1
done
if [ \$ok = 1 ]; then
echo "apply-network OK"
else
echo "apply-network FAILED — restoring $bak"
rm -f /etc/netplan/*
cp -a $bak/. /etc/netplan/
netplan generate && netplan apply
echo "apply-network ROLLED BACK"
exit 1
fi
HELPER
}
(Unquoted heredoc on purpose: $bak and $STARLINK_IFACE expand now; \$… stay for run time. The first netplan apply failing must also reach the rollback: change the first netplan generate && netplan apply line to netplan generate && netplan apply || echo "netplan apply failed" so the loop still runs and rollback triggers on failure of the connectivity gate.)
- Step 3:
apply_networkand the sub-commands:
apply_network() { # ./deploy.sh --apply-network — explicit, root; see spec §4
[ "$(id -u)" = 0 ] || die "--apply-network needs root (sudo)"
[ "$ENABLE_NM_WAN" = 1 ] || die "ENABLE_NM_WAN is 0 in $VANLINK_CONF — nothing to apply"
dpkg -s network-manager >/dev/null 2>&1 || die "network-manager is not installed — run ./deploy.sh first"
local stamp bak tmp
stamp=$(date +%Y%m%d-%H%M%S)
bak=/var/backups/vanlink-netplan-$stamp
install -d -m0700 "$bak"
cp -a /etc/netplan/. "$bak/"
tmp=$(mktemp)
render_wan_netplan > "$tmp"
check_rendered ap/50-van-wan.yaml "$tmp" || { rm -f "$tmp"; exit 1; }
install -m0600 "$tmp" /etc/netplan/50-van-wan.yaml
rm -f "$tmp"
if [ -f /etc/netplan/10-dhcp-all-interfaces.yaml ]; then
mv /etc/netplan/10-dhcp-all-interfaces.yaml /etc/netplan/10-dhcp-all-interfaces.yaml.vanlink-disabled
fi
: > /var/log/vanlink-apply-network.log
apply_helper_script "$bak" > /run/vanlink-apply-network.sh
chmod 0700 /run/vanlink-apply-network.sh
echo "backup: $bak log: /var/log/vanlink-apply-network.log"
echo "applying from a detached unit (survives an SSH drop); waiting up to 90 s ..."
systemctl reset-failed vanlink-apply-network 2>/dev/null || true
systemd-run --unit=vanlink-apply-network --collect /bin/bash /run/vanlink-apply-network.sh >/dev/null
local t=0
while systemctl is-active --quiet vanlink-apply-network && [ "$t" -lt 90 ]; do sleep 1; t=$((t + 1)); done
tail -n 6 /var/log/vanlink-apply-network.log 2>/dev/null || true
grep -q 'apply-network OK' /var/log/vanlink-apply-network.log 2>/dev/null \
|| die "apply-network did not report OK — see /var/log/vanlink-apply-network.log (previous config: $bak)"
}
In the case block add (documented in the header):
--apply-network)
load_conf "$VANLINK_CONF"
apply_network
exit 0 ;;
--print-apply-helper) # for review/tests: the helper script, with a placeholder backup dir; no root, no changes
load_conf "${2:-$VANLINK_CONF}"
apply_helper_script /var/backups/vanlink-netplan-EXAMPLE
exit 0 ;;
The log is truncated at the start of each run (the : > line above), so the apply-network OK grep only ever sees the current run.
- Step 4: Verify.
./tests/check-templates.sh(all ok, exit 0),bash -n deploy.sh,./deploy.sh --print-apply-helper hosts/van.conf | bash -n. Read the printed helper once and confirm: rollback removes/etc/netplan/*then restores the backup wholesale (so the moved-aside Armbian file returns), exit status 1 on failure. - Step 5: Commit (
deploy: --apply-network with detached apply, connectivity gate and automatic rollback).
Task 4: Post-check additions and docs
Files: modify deploy.sh (post_check), tests/check-templates.sh, README.md.
Interfaces: consumes post_check structure, warn, retry/shared deadline, ENABLE_NM_WAN, CELLULAR_*, LAN/AP iface vars, /run/van-failover/state.json.
- Step 1: Failing test (check 19): with a temp conf outside the repo built from
hosts/van.confwithENABLE_NM_WAN=0forced (same pattern as check 11(ii)),POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tmpconf"output must not containNetworkManager; withhosts/van.confas is,POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.confoutput must mentionNetworkManager(as a unit check or a warning — on the laptop it will be a warning or an ok line, either way present). - Step 2:
post_checkadditions, all insideif [ "$ENABLE_NM_WAN" = 1 ]; then … fiwith indented contents, each probe guarded forset -euo pipefail:- NetworkManager/ModemManager: already covered by check (a) because Task 2 appended them to
ENABLE_UNITS. - For each of
WIFI_5G_IFACE WIFI_2G_IFACE LAN_IFACE ONBOARD_LAN_IFACEthat exists under/sys/class/net: NM state must beunmanaged(nmcli -t -f DEVICE,STATE device), elsewarn "post-deploy: NetworkManager manages <if> …"; ok line otherwise. - If
CELLULAR_APNis set: the connection$CELLULAR_CONN_NAMEexists (nmcli -t -f NAME connection show, or its keyfile), else warn. /run/van-failover/state.json: via a guardedpython3 -c(no eval, no jq dependency), for each WAN whosedeviceis non-empty and exists in/sys/class/net,presentmust be true; warn per WAN where it is false; warn once if the file is absent.- Netplan:
nm_wan_state_netplanmust bematches, else warnpost-deploy: WAN netplan <state> — run ./deploy.sh --apply-network(the routine-deploy warning is repeated here so--post-checkshows it too).
- NetworkManager/ModemManager: already covered by check (a) because Task 2 appended them to
- Step 3: README. Add a short section "Multi-WAN (NetworkManager)" under the header note: which hosts use it (
ENABLE_NM_WAN), thatdeploy.shinstalls the packages and the cellular NM connection, that the WAN netplan is applied only by./deploy.sh --apply-network(with automatic rollback and log/var/log/vanlink-apply-network.log), and how to read/run/van-failover/state.json. - Step 4: Verify.
./tests/check-templates.sh(exit 0, no FAIL),bash -n deploy.sh,POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf(runs without abort on the laptop). - Step 5: Commit (
post-check and docs for NetworkManager WANs).
Task 5: Roll out to the R5C — controller-run, needs the user's go-ahead
Not delegated. Preconditions to confirm with the user: console or ZeroTier access path (192.168.196.43 survives only if the uplink survives), the test dongle wlx200bd04dc756 set as WIFI_WAN_IFACE in the R5C's local hosts/van.conf (uncommitted edit, like the 2.4GHz temp dongle), and that home Wi-Fi ("Wapana") is in range if the Wi-Fi WAN is to be tested.
Steps: rsync the checkout to the R5C excluding hosts/van.conf → ./deploy.sh --check → ./deploy.sh --diff (review; secrets are redacted) → ./deploy.sh --wan-plan → routine sudo ./deploy.sh (installs network-manager, keyfile, warns about pending netplan) → sudo ./deploy.sh --apply-network → cat /var/log/vanlink-apply-network.log, nmcli device, nmcli connection show, cat /run/van-failover/state.json, ./deploy.sh --post-check. Roll back path if it fails and the automatic one did not: restore /var/backups/vanlink-netplan-*.
Task 6: Pi 5 — routine deploy only, needs the user's go-ahead
Preconditions: the open questions from the host-config rollout (which of Frigate/Cockpit/Li3/nvme-watch are live; maintenance window). ./deploy.sh --diff must show 50-van-wan.yaml unchanged; ./deploy.sh --wan-plan must say netplan: … matches. --apply-network is not run on the Pi.
Self-Review (done)
Spec coverage: variables (Task 1); package auto-install incl. NM ordering and unmanaged verification (Task 2); routine-deploy behaviour incl. cellular keyfile, no netplan changes, warning text (Task 2); --apply-network steps 1–6 incl. backup, Armbian file moved aside, detached unit, connectivity gate, rollback, log (Task 3); template split with byte-identical Pi render and omitted wifis on the R5C (Task 1, check 14); post-check additions (Task 4); tests incl. --wan-plan, flag guards, rollback ordering (checks 14–19); rollout order (Tasks 5–6). Deviation from the spec text, deliberate: the cellular profile is written as a NetworkManager keyfile from a template instead of via nmcli connection add (deterministic, reviewable, no nmcli quirks for interface-less gsm profiles).
Placeholders: none. Names: ENABLE_NM_WAN, CELLULAR_CONN_NAME, CELLULAR_APN, CELLULAR_UUID, render_wan_netplan, nm_wan_state_netplan, apply_helper_script, apply_network, sub-commands --render-wan-netplan --wan-plan --apply-network --print-apply-helper are used consistently across tasks and tests.