Files

396 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.sh` without a sub-command (`--check`, `--diff`, `--templates`, `--post-check`, `--classify-unit`, `--classify-radio`, `--redact`, `--wan-plan`, `--print-apply-helper`, `render` are the safe ones; they need no root and change nothing).
- New required flag `ENABLE_NM_WAN` (exactly 0 or 1). New optional vars `CELLULAR_CONN_NAME`, `CELLULAR_APN`; `WIFI_WAN_IFACE` becomes optional (empty = no Wi-Fi uplink). Derived var `CELLULAR_UUID`.
- Guard convention (test check 6): units/paths of a flagged feature only appear inside a column-0 `if [ "$ENABLE_X" = 1 ]; then … fi` guard with 4-space-indented contents (comment and `echo` lines are exempt).
- A routine `./deploy.sh` never changes `/etc/netplan/*` or restarts the network for WAN purposes. Only `--apply-network` does.
- Rendered `50-van-wan.yaml` for `hosts/wan.conf` must stay byte-identical to what the pre-split single template rendered (the Pi 5 must see no change).
- Hosts: `hosts/van.conf` (R5C, `van`) and `hosts/wan.conf` (Pi 5, `wan`). Both get `ENABLE_NM_WAN=1`, `CELLULAR_CONN_NAME=Koodo`, `CELLULAR_APN=sp.koodo.com`. `hosts/van.conf` gets `WIFI_WAN_IFACE=` (empty); `hosts/wan.conf` keeps `WIFI_WAN_IFACE=wlan0`.
- Commit messages end with two trailer lines: `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>` and `Claude-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_WAN` in `REQUIRED_VARS` and in `load_conf`'s flag loop; `WIFI_WAN_IFACE` moved from `REQUIRED_VARS` to `OPTIONAL_VARS`; `CELLULAR_CONN_NAME CELLULAR_APN` appended to `OPTIONAL_VARS`; `CELLULAR_UUID` appended to `DERIVED_VARS` and set in `load_conf`; function `render_wan_netplan` (stdout = rendered `ap/50-van-wan.yaml`, plus rendered `ap/50-van-wan-wifi.yaml` when `WIFI_WAN_IFACE` is non-empty).
- Consumed later: all of the above.
- [ ] **Step 1: Write failing tests (checks 14–16)** — insert before the final `exit "$fail"` of `tests/check-templates.sh`:
```bash
# 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 whole `wifis:` block (from the line ` wifis:` through the last line of the file, including the Wapana `access-points` entry and its `password`) and paste it **verbatim** into the new file `ap/50-van-wan-wifi.yaml`, with no header comment and no blank line before ` wifis:`. Make sure `ap/50-van-wan.yaml` still ends with a newline. Then `render_wan_netplan` (Step 5) for `hosts/wan.conf` must reproduce the old single template byte for byte: check 14 proves it. (Explain the split in a comment next to `render_wan_netplan` in `deploy.sh` instead.)
- [ ] **Step 3: Cellular keyfile template** — create `ap/cellular.nmconnection` (installed 0600 by Task 2):
```ini
# 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.sh` variables and functions.**
1. `REQUIRED_VARS`: remove `WIFI_WAN_IFACE`, add `ENABLE_NM_WAN` after `ENABLE_FRIGATE`.
2. `OPTIONAL_VARS`: add `WIFI_WAN_IFACE CELLULAR_CONN_NAME CELLULAR_APN`.
3. `DERIVED_VARS=(ONBOARD_LAN_UNMANAGED CELLULAR_UUID)`.
4. In `load_conf`'s flag loop add `ENABLE_NM_WAN`; after the `ONBOARD_LAN_UNMANAGED=` line add:
```bash
CELLULAR_UUID=$(uuidgen -s -n @dns -N "vanlink-${CELLULAR_CONN_NAME:-}" 2>/dev/null || true)
```
5. Add, next to `render()`:
```bash
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
}
```
6. In the `case` block 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 use `render_wan_netplan` instead of `render ap/50-van-wan.yaml` (all three places, including the `diff -u` and the two warn texts).
7. Test check 2's `exempt` list gains `ap/50-van-wan-wifi.yaml` and `ap/cellular.nmconnection` (installed by dedicated code, not `install_rendered <src> /abs/dst` literal lines) — add a comment saying so.
- [ ] **Step 6: Host files.** `hosts/vanlink.conf.example`: document `ENABLE_NM_WAN`, `CELLULAR_CONN_NAME`, `CELLULAR_APN`, and mark `WIFI_WAN_IFACE` optional (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=wlan0` unchanged.
- [ ] **Step 6b: Fix the existing check-5 assertions that assume the R5C has `wlan0`.** With `hosts/van.conf` now `WIFI_WAN_IFACE=` (empty) these existing lines in `tests/check-templates.sh` fail and must change for `van` only (leave the `wan` ones): `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 to `wlan0` when given no argument, exactly like today's behaviour on a host without that device). Add the matching `wan` assertions if they are missing (`"device": "wlan0"`, `van-wlan-watchdog wlan0`). Controller already verified that `van-failover` handles an empty `device` safely: `resolve()` falls to the `connection` branch, `ip_iface(None)` returns `None`, and `sh()` swallows `OSError`, 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 lines `wan-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>` or ` cellular profile: skipped (no CELLULAR_APN)`. Functions `nm_wan_state_netplan` (echo `missing|differs|matches`) reused by Task 3.
- [ ] **Step 1: Failing tests (check 17)**:
```bash
# 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-manager` is NOT in `REQUIRED_PACKAGES`. Replace the loop
```bash
for pkg in "${REQUIRED_PACKAGES[@]}"; do
dpkg -s "$pkg" >/dev/null 2>&1 || { echo "MISSING REQUIRED: $pkg (apt install $pkg)"; exit 1; }
done
```
with:
```bash
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.conf` is already installed) and before the `== apply ==` section:
```bash
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`:
```bash
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-plan`** in the `case` block (needs `load_conf`, no root, no changes):
```bash
--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.conf` and `hosts/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`:
```bash
# 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 `case` block):
```bash
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_network` and the sub-commands:**
```bash
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):
```bash
--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.conf` with `ENABLE_NM_WAN=0` forced (same pattern as check 11(ii)), `POSTCHECK_WAIT=0 ./deploy.sh --post-check "$tmpconf"` output must not contain `NetworkManager`; with `hosts/van.conf` as is, `POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf` output must mention `NetworkManager` (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_check` additions**, all inside `if [ "$ENABLE_NM_WAN" = 1 ]; then … fi` with indented contents, each probe guarded for `set -euo pipefail`:
1. NetworkManager/ModemManager: already covered by check (a) because Task 2 appended them to `ENABLE_UNITS`.
2. For each of `WIFI_5G_IFACE WIFI_2G_IFACE LAN_IFACE ONBOARD_LAN_IFACE` that exists under `/sys/class/net`: NM state must be `unmanaged` (`nmcli -t -f DEVICE,STATE device`), else `warn "post-deploy: NetworkManager manages <if> …"`; ok line otherwise.
3. If `CELLULAR_APN` is set: the connection `$CELLULAR_CONN_NAME` exists (`nmcli -t -f NAME connection show`, or its keyfile), else warn.
4. `/run/van-failover/state.json`: via a guarded `python3 -c` (no eval, no jq dependency), for each WAN whose `device` is non-empty and exists in `/sys/class/net`, `present` must be true; warn per WAN where it is false; warn once if the file is absent.
5. Netplan: `nm_wan_state_netplan` must be `matches`, else warn `post-deploy: WAN netplan <state> — run ./deploy.sh --apply-network` (the routine-deploy warning is repeated here so `--post-check` shows it too).
- [ ] **Step 3: README.** Add a short section "Multi-WAN (NetworkManager)" under the header note: which hosts use it (`ENABLE_NM_WAN`), that `deploy.sh` installs 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.