diff --git a/README.md b/README.md
index dd29fcc..b63eff5 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ shows, read-only, what the provisioning would do. `--post-check` reports the las
Live WAN state: `cat /run/van-failover/state.json` lists each WAN with `device`, `connection`,
`present` (device and connection both resolved), `up`, `healthy`, `active` (holds the best default
-route) and `route_metric`. `present: false` for an existing device is a problem; `--post-check` warns.
+route) and `route_metric`. An unplugged or out-of-range uplink (`present: false`, no carrier) is expected in a van; `present: false` on a device that has link is the actionable case (not handed to NM yet, or its profile is missing), and `--post-check` warns about exactly that.
Known limitation: there is no boot-time recovery yet. If power is lost during an apply, the new
config boots ungated and the status file still says `running`; `--apply-network` then refuses to run
diff --git a/deploy.sh b/deploy.sh
index d4dedc7..b34fee1 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -22,6 +22,7 @@
# VANLINK_SYSNET=
# test override: sysfs net dir used by --post-check (default /sys/class/net)
# VANLINK_FAILOVER_STATE= # test override: van-failover state file read by --post-check (default /run/van-failover/state.json)
# VANLINK_APPLY_STATUS= # test override: apply-network status file read by --post-check (default /var/lib/vanlink/apply-network.status)
+# VANLINK_WAN_NETPLAN= # test override: installed WAN netplan compared by nm_wan_state_netplan (default /etc/netplan/50-van-wan.yaml)
# VANLINK_NM_DIR= # test override: where existing NM *.nmconnection files are looked up (default /etc/NetworkManager/system-connections)
# Idempotent. Netplan (the NM-managed WANs; eth0 is a LAN port on br0) is never
# changed by a routine deploy (applying it flaps the uplinks). ENABLE_NM_WAN=0:
@@ -106,8 +107,9 @@ render_wan_netplan() { # ap/50-van-wan.yaml, plus the wifis stanza when the hos
}
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
+ local f=${VANLINK_WAN_NETPLAN:-/etc/netplan/50-van-wan.yaml}
+ if [ ! -f "$f" ]; then echo missing
+ elif diff -q <(render_wan_netplan) "$f" >/dev/null 2>&1; then echo matches
else echo differs; fi
}
@@ -294,29 +296,42 @@ post_check() { # read-only live checks; every probe is guarded, warns instead o
done
# i. cellular connection
if [ -n "${CELLULAR_APN:-}" ]; then
- if { nmcli -t -f NAME connection show 2>/dev/null || true; } | grep -qxF "$CELLULAR_CONN_NAME" || cellular_profile_exists; then
+ if cellular_profile_exists; then
echo " ok NetworkManager connection $CELLULAR_CONN_NAME exists"
else warn "post-deploy: NetworkManager connection $CELLULAR_CONN_NAME not found"; fi
fi
- # j. van-failover state: every WAN whose device exists must be present
+ # j. van-failover state: "present" = the device has an active NM connection, so an
+ # unplugged/out-of-range WAN is normal; only a device WITH link and present:false is actionable
fst=${VANLINK_FAILOVER_STATE:-/run/van-failover/state.json}
- if out=$(python3 - "$fst" "$sysnet" 2>/dev/null <<'PY'
-import json, os, sys
+ fo_read() { # parses the state file (needs a valid "updated" and "wans"); sets $out
+ out=$(python3 - "$fst" "$sysnet" 2>/dev/null <<'PY'
+import json, os, sys, time
try:
with open(sys.argv[1]) as f:
- wans = json.load(f)["wans"]
- for w in wans:
+ d = json.load(f)
+ time.strptime(d["updated"], "%Y-%m-%dT%H:%M:%S%z")
+ for w in d["wans"]:
dev = w.get("device") or ""
- if dev and os.path.exists(os.path.join(sys.argv[2], dev)):
- print(("ok" if w.get("present") is True else "notpresent"), w.get("name", "?"), dev)
+ if not dev or not os.path.exists(os.path.join(sys.argv[2], dev)):
+ continue
+ try:
+ with open(os.path.join(sys.argv[2], dev, "carrier")) as c:
+ if c.read().strip() != "1":
+ continue
+ except Exception:
+ continue
+ print(("ok" if w.get("present") is True else "nocon"), w.get("name", "?"), dev)
except Exception:
sys.exit(3)
PY
- ); then
+ )
+ }
+ if ! command -v python3 >/dev/null 2>&1; then warn "post-deploy: python3 not available — cannot read the van-failover state"
+ elif retry fo_read; then
while read -r as_f as_l n; do
[ -n "$as_f" ] || continue
- if [ "$as_f" = ok ]; then echo " ok van-failover: WAN $as_l ($n) present"
- else warn "post-deploy: van-failover reports WAN $as_l ($n) not present although the device exists"; fi
+ if [ "$as_f" = ok ]; then echo " ok van-failover: WAN $as_l ($n) has an active NetworkManager connection"
+ else warn "post-deploy: $as_l ($n) has link but no active NetworkManager connection — not handed to NM yet (./deploy.sh --apply-network) or its profile is missing"; fi
done <<<"$out"
else warn "post-deploy: van-failover state $fst missing or unreadable"; fi
# k. netplan drift
@@ -325,10 +340,12 @@ PY
# l. last --apply-network
as_f=${VANLINK_APPLY_STATUS:-/var/lib/vanlink/apply-network.status}
if [ -r "$as_f" ]; then
- as_l=$(head -n1 "$as_f" 2>/dev/null) || as_l=
+ as_l=$(grep -m1 '[^[:space:]]' "$as_f" 2>/dev/null) || as_l=
case "${as_l%% *}" in
+ "") ;;
ok) echo " ok last --apply-network: $as_l" ;;
running|rolled-back|rollback-failed) warn "post-deploy: last --apply-network status: $as_l — see /var/log/vanlink-apply-network.log" ;;
+ *) warn "post-deploy: unrecognised apply-network status: $as_l" ;;
esac
fi
fi
diff --git a/tests/check-templates.sh b/tests/check-templates.sh
index 3a0aeaf..c8d590d 100755
--- a/tests/check-templates.sh
+++ b/tests/check-templates.sh
@@ -451,20 +451,56 @@ for w in "running 2026-01-01T00:00:00+00:00 /bak" "rolled-back 2026-01-01T00:00:
pcs "$tc/st" | grep -qF -- "-> post-deploy: last --apply-network status: $w — see /var/log/vanlink-apply-network.log" && ok "19b: status ${w%% *} -> warn" || bad "19b: status ${w%% *} warn missing"
done
pcs "$tc/none" | grep -q 'last --apply-network' && bad "19b: absent status file must print nothing" || ok "19b: absent status file prints nothing"
-# 19c. van-failover state.json (VANLINK_FAILOVER_STATE, VANLINK_SYSNET)
-mkdir -p "$tc/sys/dev1" "$tc/sys/dev2"
-fo() { POSTCHECK_WAIT=0 VANLINK_SYSNET="$tc/sys" VANLINK_FAILOVER_STATE="$1" VANLINK_APPLY_STATUS="$tc/none" ./deploy.sh --post-check hosts/van.conf 2>&1 || true; }
+# 19c. van-failover state.json (VANLINK_FAILOVER_STATE, VANLINK_SYSNET); inline output only (before "== warnings ==")
+mkdir -p "$tc/sys/up1" "$tc/sys/up2" "$tc/sys/down"
+echo 1 > "$tc/sys/up1/carrier"; echo 1 > "$tc/sys/up2/carrier"; echo 0 > "$tc/sys/down/carrier"
+fo() { POSTCHECK_WAIT=0 VANLINK_SYSNET="$tc/sys" VANLINK_FAILOVER_STATE="$1" VANLINK_APPLY_STATUS="$tc/none" ./deploy.sh --post-check hosts/van.conf 2>&1 | sed '/^== warnings ==$/,$d' || true; }
cat > "$tc/f.json" <<'J'
-{"wans":[{"name":"starlink","device":"dev1","present":false},{"name":"cell","device":"dev2","present":true},
- {"name":"wifi","device":"","present":false},{"name":"gone","device":"nodev","present":false}]}
+{"updated":"2026-01-01T00:00:00+0000","wans":[{"name":"starlink","device":"up1","present":false},{"name":"cell","device":"up2","present":true},
+ {"name":"nolink","device":"down","present":false},{"name":"wifi","device":"","present":false},{"name":"gone","device":"nodev","present":false}]}
J
o=$(fo "$tc/f.json")
-echo "$o" | grep -q -- '-> post-deploy: .*WAN starlink' && ok "19c: existing device present:false -> warn" || bad "19c: starlink warn missing"
-echo "$o" | grep -q ' ok .*WAN cell' && ok "19c: present:true -> ok" || bad "19c: cell ok missing"
-echo "$o" | grep -qE 'WAN (wifi|gone)' && bad "19c: empty/nonexistent device must be ignored" || ok "19c: empty/nonexistent device ignored"
+echo "$o" | grep -q -- '-> post-deploy: starlink (up1) has link but no active NetworkManager connection' && ok "19c: link up + present:false -> warn" || bad "19c: starlink warn missing"
+echo "$o" | grep -q ' ok .*WAN cell' && ok "19c: link up + present:true -> ok" || bad "19c: cell ok missing"
+echo "$o" | grep -qE 'nolink|wifi|gone' && bad "19c: no carrier / empty / absent device must be ignored" || ok "19c: no carrier, empty and absent devices ignored"
echo '{ not json' > "$tc/bad.json"
-o=$(fo "$tc/bad.json"); [ "$(echo "$o" | grep -- '-> post-deploy: .*failover state' | sort -u | wc -l)" = 1 ] && ok "19c: invalid JSON -> one warn" || bad "19c: invalid JSON warn count"
-o=$(fo "$tc/nofile"); [ "$(echo "$o" | grep -- '-> post-deploy: .*failover state' | sort -u | wc -l)" = 1 ] && ok "19c: missing file -> one warn" || bad "19c: missing file warn count"
+o=$(fo "$tc/bad.json"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: invalid JSON -> one warn" || bad "19c: invalid JSON warn count"
+o=$(fo "$tc/nofile"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: missing file -> one warn" || bad "19c: missing file warn count"
+echo '{"wans":[]}' > "$tc/noupd.json"
+o=$(fo "$tc/noupd.json"); [ "$(echo "$o" | grep -c -- 'failover state')" = 1 ] && ok "19c: no updated stamp -> one warn" || bad "19c: no-updated warn count"
+# 19d. status file: unrecognised / blank
+echo "bogus 1 2" > "$tc/st"; pcs "$tc/st" | grep -qF -- '-> post-deploy: unrecognised apply-network status: bogus 1 2' && ok "19d: unrecognised status -> warn" || bad "19d: unrecognised status warn missing"
+printf '\n \n' > "$tc/st"; pcs "$tc/st" | grep -qE 'last --apply-network|unrecognised' && bad "19d: blank status must be silent" || ok "19d: blank status silent"
+# 19e. probes h (unmanaged), i (cellular profile), k (netplan) in both branches, with a stub nmcli first on PATH
+mkdir -p "$tc/stub" "$tc/nm" "$tc/sys2/wlan1" "$tc/sys2/wlan2" "$tc/sys2/lan"
+for i in $(sed -n 's/^WIFI_5G_IFACE=\([^ ]*\).*/\1/p;s/^WIFI_2G_IFACE=\([^ ]*\).*/\1/p;s/^LAN_IFACE=\([^ ]*\).*/\1/p' hosts/van.conf | tr -d '"'); do mkdir -p "$tc/sys2/$i"; done
+cat > "$tc/stub/nmcli" <<'S'
+#!/bin/sh
+case "$*" in
+ *DEVICE,STATE*) cat "$STUB_DEV" ;;
+ *"NAME connection show"*) cat "$STUB_CONN" ;;
+esac
+S
+chmod +x "$tc/stub/nmcli"
+ifs=$(sed -n 's/^WIFI_5G_IFACE=\([^ ]*\).*/\1/p;s/^WIFI_2G_IFACE=\([^ ]*\).*/\1/p;s/^LAN_IFACE=\([^ ]*\).*/\1/p' hosts/van.conf | tr -d '"')
+conn=$(sed -n 's/^CELLULAR_CONN_NAME=//p' hosts/van.conf | tr -d '"')
+nm() { PATH="$tc/stub:/usr/bin:/bin" STUB_DEV="$tc/dev" STUB_CONN="$tc/conn" VANLINK_NM_DIR="$tc/nm" VANLINK_SYSNET="$tc/sys2" \
+ VANLINK_WAN_NETPLAN="$tc/np.yaml" VANLINK_APPLY_STATUS="$tc/none" POSTCHECK_WAIT=0 ./deploy.sh --post-check hosts/van.conf 2>&1 | sed '/^== warnings ==$/,$d' || true; }
+: > "$tc/dev"; : > "$tc/conn"
+for i in $ifs; do echo "$i:unmanaged" >> "$tc/dev"; done
+echo "$conn" > "$tc/conn"; VANLINK_CONF=hosts/van.conf ./deploy.sh --render-wan-netplan > "$tc/np.yaml"
+o=$(nm)
+first=$(echo "$ifs" | head -1)
+echo "$o" | grep -q " ok NetworkManager leaves $first unmanaged" && ok "19e: unmanaged -> ok" || bad "19e: unmanaged ok missing"
+echo "$o" | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular profile present -> ok" || bad "19e: cellular ok missing"
+echo "$o" | grep -q ' ok WAN netplan matches' && ok "19e: netplan matches -> ok" || bad "19e: netplan ok missing"
+echo "$first:connected" > "$tc/dev"; : > "$tc/conn"; echo '# differs' >> "$tc/np.yaml"
+o=$(nm)
+echo "$o" | grep -q -- "-> post-deploy: NetworkManager manages $first" && ok "19e: managed -> warn" || bad "19e: managed warn missing"
+echo "$o" | grep -q -- "-> post-deploy: NetworkManager connection $conn not found" && ok "19e: cellular missing -> warn" || bad "19e: cellular warn missing"
+echo "$o" | grep -q -- '-> post-deploy: WAN netplan differs' && ok "19e: netplan differs -> warn" || bad "19e: netplan warn missing"
+printf 'id=%s\n' "$conn" > "$tc/nm/x.nmconnection"
+nm | grep -q " ok NetworkManager connection $conn exists" && ok "19e: cellular keyfile scan -> ok" || bad "19e: keyfile scan ok missing"
rm -rf "$tc"
exit "$fail"