LibrePortal/scripts/docker/health/docker_health_scan.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

98 lines
4.7 KiB
Bash

#!/bin/bash
# Read-only rootless-docker / control-plane health scan — the shared detection
# used by both the WebUI status generator (webuiSystemHealthCheck) and the heal
# verb (system health heal), so the two never diverge.
#
# dockerHealthScan sets these globals (call it DIRECTLY, never in $(...) — a
# subshell would drop them):
# HEALTH_DAEMON_OK "true"/"false" — rootless docker daemon reachable
# HEALTH_WEBUI_PRESENT "true"/"false" — the core WebUI container exists
# HEALTH_WEBUI_RUNNING "true"/"false" — ...and is running (not exited/restarting)
# HEALTH_WEBUI_PORT — its published host port (docker port), "" if none
# HEALTH_WEBUI_REACHABLE "true"/"false"/"unknown" — that host port accepts a TCP connect
# HEALTH_SCAN_ERROR — human note when the daemon is off (else "")
# HEALTH_CRASHLOOPS (array) — "app|container|restartcount" per container
# stuck restarting (State=restarting and
# RestartCount >= CFG_HEALTH_CRASHLOOP_LIMIT):
# a crash-loop churning the shared network.
#
# The incident this guards: an app crash-loop (trivy, offline, FATAL on every
# boot) churned the rootless port-forwarder until the WebUI's published port was
# torn down — the container stayed healthy INSIDE, but nothing on the host could
# reach it. So we probe the host-visible port forward, not just container state.
#
# Nothing here mutates state.
# The core WebUI container + its internal port (the control plane we must keep
# reachable). Kept as tiny functions so a rename only touches one place.
_healthWebuiContainer() { echo "libreportal-service"; }
_healthWebuiInternalPort() { echo "1111"; }
# TCP-connect probe from the host (manager context). Success => the published
# port forward is live. Uses bash /dev/tcp (always present) with a timeout so a
# black-holed forward can't hang the poll.
_healthTcpReachable() {
local host="$1" port="$2"
[[ -n "$port" ]] || return 2
timeout 4 bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
}
dockerHealthScan() {
HEALTH_DAEMON_OK="false"; HEALTH_WEBUI_PRESENT="false"; HEALTH_WEBUI_RUNNING="false"
HEALTH_WEBUI_PORT=""; HEALTH_WEBUI_REACHABLE="unknown"; HEALTH_SCAN_ERROR=""
HEALTH_CRASHLOOPS=()
local limit="${CFG_HEALTH_CRASHLOOP_LIMIT:-3}"
[[ "$limit" =~ ^[0-9]+$ ]] || limit=3
# Daemon reachable? Never alarm on what we can't verify — a daemon blip (or a
# mid-recycle window) is transient, not a conflict.
if ! dockerCommandRun "docker info" >/dev/null 2>&1; then
HEALTH_SCAN_ERROR="docker daemon unreachable"
return 0
fi
HEALTH_DAEMON_OK="true"
# Crash-loopers: containers docker reports as "restarting" whose RestartCount
# has already climbed past the limit — i.e. actively churning, not a one-off
# restart. Container name is enough to name the offender in the badge; derive
# a friendly app label by trimming the conventional "-service" suffix.
local names name rc app
names=$(dockerCommandRun "docker ps -a --filter status=restarting --format '{{.Names}}'" 2>/dev/null)
while IFS= read -r name; do
[[ -n "$name" ]] || continue
rc=$(dockerCommandRun "docker inspect --format '{{.RestartCount}}' '$name'" 2>/dev/null | tr -dc '0-9')
[[ -n "$rc" ]] || rc=0
if (( rc >= limit )); then
app="${name%-service}"
HEALTH_CRASHLOOPS+=("${app}|${name}|${rc}")
fi
done <<< "$names"
# Core WebUI container: present? running? host port reachable?
local webui iport wstate
webui="$(_healthWebuiContainer)"
iport="$(_healthWebuiInternalPort)"
wstate=$(dockerCommandRun "docker inspect --format '{{.State.Status}}' '$webui'" 2>/dev/null | tr -d '[:space:]')
if [[ -z "$wstate" ]]; then
return 0 # container not found — nothing more to probe
fi
HEALTH_WEBUI_PRESENT="true"
[[ "$wstate" == "running" ]] && HEALTH_WEBUI_RUNNING="true"
# docker's view of the published host port (e.g. "1111/tcp -> 0.0.0.0:9781").
# Note: this reports the INTENDED mapping even when the rootless forward has
# been torn down — which is exactly why we then TCP-probe it for real.
HEALTH_WEBUI_PORT=$(dockerCommandRun "docker port '$webui' '$iport'" 2>/dev/null | head -1 | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p')
if [[ "$HEALTH_WEBUI_RUNNING" == "true" && -n "$HEALTH_WEBUI_PORT" ]]; then
if _healthTcpReachable "127.0.0.1" "$HEALTH_WEBUI_PORT"; then
HEALTH_WEBUI_REACHABLE="true"
else
HEALTH_WEBUI_REACHABLE="false"
fi
fi
}