Hardened the NUT UPS outage automation on telep-mainframe so that services shed during a battery event are always brought back — even when the mains-restore (ONLINE) event never fires because the box was powered off when power returned. Root-caused a real overnight failure where kb-qdrant stayed down and knowledgebase.service crash-looped 2360 times.
For Agents
Shared restore logic:
/usr/local/bin/power-restore.sh— idempotent, logs tagged[RECONCILE]. Called by BOTH: (1) the NUTONLINEhandler in/etc/nut/nut-outage-handler.sh, and (2) a new boot-time oneshotpower-restore-reconcile.service(enabled,Afterdocker + network-online).kb-qdrantnow runs with--restart unless-stopped.knowledgebase.servicegets a drop-inknowledgebase.service.d/wait-qdrant.confthat polls6333/readyz(~60 s) before starting instead of crash-looping. Handler backup:/etc/nut/nut-outage-handler.sh.bak-*.
Root cause (the overnight failure)
The sequence, last night:
- Mains dropped → NUT
ONBATT→ outage handler shed non-essential services (KB, Qdrant, camwall, AirPlay, GPU/index jobs). Critically,kb-qdrantwas explicitlydocker stopped during the shed. - Battery drained to
LOWBATT→ upsmon did a gracefulsystemctl poweroff. Correct behaviour. - Mains returned WHILE THE BOX WAS OFF.
- BIOS “Restore on AC Power Loss” (which is ON — see telep-mainframe-handover §4) auto-powered-on the box → it booted.
- But the NUT
ONLINE/ restore event never fired — because the box was off during the mains-return transition, upsmon was never running to observeOB → OL. So the restore handler that restarts shed services never ran. - Result:
kb-qdrant(explicitly stopped during shed, with no boot recovery because it had no restart policy) stayed down.knowledgebase.servicecame up on boot, found no Qdrant at127.0.0.1:6333, and crash-looped 2360× against the missing dependency.
The core design flaw
Restore was coupled only to the NUT
ONLINEevent. That event is unobservable if the box is powered off across the mains-return. A graceful poweroff + BIOS auto-power-on is a completely normal UPS outcome, so “power came back while off” is not an edge case — it’s the expected path for any outage that outlasts the battery.
Fix (all on the box)
1. Extract restore into a shared, idempotent script
Restore logic was pulled out of the inline NUT handler into /usr/local/bin/power-restore.sh:
- Idempotent — safe to run whether services are already up or still shed; it reconciles to the desired “everything running” state rather than blindly issuing starts.
- Logs are
[RECONCILE]-tagged so boot-time reconciliation is distinguishable from a liveONLINE-triggered restore in the journal.
2. Call it from two places
- The NUT
ONLINEhandler (/etc/nut/nut-outage-handler.sh) still calls it for the normal case (power returns while the box is up). Backup at/etc/nut/nut-outage-handler.sh.bak-*. - A new boot-time oneshot
power-restore-reconcile.service(enabled, orderedAfterdocker +network-online.target) calls it on every boot. So a missedONLINEcan never again leave services shed — the box reconciles itself into the running state as it comes up.
3. Give Qdrant its own boot recovery
kb-qdrant set to --restart unless-stopped so docker brings it back on boot on its own — belt-and-braces with the reconcile service. (unless-stopped still respects a deliberate manual docker stop.)
4. Stop KB crash-looping on a cold Qdrant
Drop-in knowledgebase.service.d/wait-qdrant.conf makes knowledgebase.service poll 6333/readyz (~60 s) before starting instead of failing instantly and letting systemd restart-loop it. KB now waits for its dependency to be ready rather than hammering a missing one 2360 times.
Gotcha — sudo tee silently dropped a shebang
While installing a script, sudo tee silently dropped the #!/usr/bin/env bash shebang line. On direct execution the kernel fell back to /bin/sh (dash), which choked on a bash array in the script.
Transfer shebang-bearing files as base64
When writing an executable script to the box through
sudo tee/ heredoc / SSH stdin, the first line (shebang) can be eaten. Use base64 transfer (base64 -don the far side) for any file whose first line is load-bearing. Same class of quoting/first-line hazard seen elsewhere in this homelab’s remote-write workflows.
Result
- A battery event that outlasts the UPS → graceful poweroff → BIOS auto-power-on → boot now self-heals: reconcile brings back every shed service, Qdrant is up (restart policy + reconcile), and KB waits for Qdrant instead of crash-looping.
- The failure is no longer coupled to observing a NUT event that may never fire.
Related
- telep-mainframe-handover — §2 Power & UPS resilience (this extends it; BIOS auto-power-on note in §4)
- 2026-08-04-telep-mainframe-airplay-receiver-uxplay — the AirPlay service is one of the shed/restored services; its own 2026-08-08 fix was part of the same post-outage recovery pass
- 2026-08-05-power-root-cause-nvme-damage-ups-kb-handover — the UPS + NUT install this hardens
- 2026-08-06-power-root-cause-gpu-12v-connector-stress-test-pass — the underlying load-crash root cause
- telep-mainframe
- SESSION-HANDOVER