LibrePortal/scripts/docker/health/docker_health_heal.sh
librelad 2d1e4aa98f feat(health): self-healing control-plane watchdog + crash-loop failure cap
An offline trivy install crash-looped (server FATALs when it can't fetch the
vuln DB), and on rootless docker the restart storm churned the shared network's
port-forwarder until the WebUI's own published host port was torn down — the
WebUI stayed healthy INSIDE its container but was unreachable from the host, with
nothing detecting or healing it.

Three fixes, in the house self-healing style (mirrors the network-drift trio):

1. Control-plane health checker wired into the existing task-processor idle poll
   (maybeRegenPoll), no new daemon. dockerHealthScan (read-only) detects daemon
   down, a WebUI running-but-host-port-unreachable (the port-forward corruption),
   and crash-looping containers. webuiSystemHealthCheck writes
   frontend/data/system/health_status.json + self-dispatches a heal — the user
   can't click a button on a dead WebUI, so the poll drives the fix. Frontend
   health-notifier surfaces a topbar badge + dashboard banner + details panel.

2. Failure cap, enforced centrally by dockerHealthHeal (task-gated): stops
   crash-loopers (removing the churn), restarts the WebUI to re-publish a lost
   port forward, and — only if that fails — recycles the rootless daemon and
   restarts the core container. Caps every app immediately, no template churn.

3. Trivy no longer crash-loops offline: the server runs in a shell retry-loop so
   the container stays Up and quietly retries on a backoff instead of exiting
   FATAL. Verified: container stays Up across repeated DB-download failures.
   Core WebUI compose gains restart: unless-stopped so it self-recovers after a
   reboot / daemon recycle instead of staying down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-07-17 22:03:02 +01:00

84 lines
3.8 KiB
Bash

#!/bin/bash
# Rootless-docker / control-plane health heal — the mutating half of the
# detector. Runs ONLY through the task system (see cli_system_commands.sh
# `health heal`, which enqueues unless LIBREPORTAL_TASK_EXEC=1), never a direct
# API. It re-scans before each heavier step so it does the least it can.
#
# Escalation ladder:
# 1) Stop crash-loopers — an app FATAL-ing on every boot (e.g. trivy offline)
# churns the rootless port-forwarder; stopping it removes the churn. This is
# the failure cap: a broken opt-in app can't take the control plane down.
# (unless-stopped honours a manual stop across a daemon recycle, so a
# stopped crash-looper stays down and can't resume the churn.)
# 2) Restart the WebUI container — re-publishes a mildly-lost port forward.
# 3) Recycle the rootless docker daemon — rebuilds the netns/port-forward state
# when churn has already corrupted it (a container restart alone won't fix
# it), then explicitly starts the core container (it may carry no restart
# policy, so a daemon recycle would otherwise leave it down).
#
# Re-runs the read-only check at the end to rewrite health_status.json (the badge
# clears, or stays if anything is still unhealed).
#
# dockerHealthHeal
dockerHealthHeal() {
isHeader "Healing docker / control-plane health"
dockerHealthScan # populate HEALTH_* (call direct, NOT in $(...))
if [[ "$HEALTH_DAEMON_OK" != "true" ]]; then
isError "Docker daemon unreachable — cannot heal from here."
declare -f webuiSystemHealthCheck >/dev/null 2>&1 && webuiSystemHealthCheck "force" >/dev/null 2>&1
return 1
fi
local rootless="true"
[[ "${CFG_DOCKER_INSTALL_TYPE:-rootless}" == "rootless" ]] || rootless="false"
local acted=0
# 1) Stop crash-loopers (the failure cap).
local row cname
for row in "${HEALTH_CRASHLOOPS[@]}"; do
cname="${row#*|}"; cname="${cname%%|*}" # app|container|rc -> container
[[ "$cname" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || continue
isNotice "Stopping crash-looping container '$cname' (restart storm churning the network)…"
dockerCommandRun "docker stop '$cname'" >/dev/null 2>&1
((acted++))
done
# 2/3) Restore the WebUI host port if the container is up but unreachable.
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && "$HEALTH_WEBUI_REACHABLE" == "false" ]]; then
local webui; webui="$(_healthWebuiContainer)"
isNotice "WebUI up but host port ${HEALTH_WEBUI_PORT:-?} unreachable — restarting '$webui' to re-publish…"
dockerCommandRun "docker restart '$webui'" >/dev/null 2>&1
((acted++))
sleep 4
dockerHealthScan
if [[ "$HEALTH_WEBUI_REACHABLE" != "true" && "$rootless" == "true" ]]; then
isNotice "Still unreachable — recycling the rootless docker daemon to rebuild port-forward state…"
dockerCommandRun "systemctl --user restart docker.service" >/dev/null 2>&1
sleep 8
# The core container may have no restart policy — bring it back explicitly.
dockerCommandRun "docker start '$webui'" >/dev/null 2>&1
sleep 4
((acted++))
fi
fi
# Rewrite the status file + report what (if anything) remains.
declare -f webuiSystemHealthCheck >/dev/null 2>&1 && webuiSystemHealthCheck "force" >/dev/null 2>&1
dockerHealthScan
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && "$HEALTH_WEBUI_REACHABLE" == "false" ]]; then
isError "Health heal ran ${acted} action(s); WebUI still unreachable on port ${HEALTH_WEBUI_PORT:-?} — manual inspection needed."
return 1
fi
if (( acted == 0 )); then
isSuccessful "Nothing to heal — control plane healthy."
else
isSuccessful "Health heal complete — ${acted} action(s); control plane reachable."
fi
}