Files
homelab-infra/services/posture-check/compose-runtime-drift.sh
T
Micha ad9bb40b95 Harden posture/borg audit scripts (robustness + coverage)
Working-tree improvements to the audit scripts (authored locally, not by me;
reviewed for correctness + bash -n clean before commit):

- compose-runtime-drift: prefer `docker compose config` for the expected image
  with a raw-parse fallback; raw parser now resolves YAML anchors (*alias) so
  anchor-based composes (e.g. dawarich) no longer mis-report drift.
- komodo-stack-hygiene: treat an unreachable Komodo API as critical and exit 3
  so the Healthchecks EXIT trap sends /fail (the monitor itself is down, not
  "all green"); git fetch before hash-drift compare; clearer "cannot compare"
  message; pin in-container km host to localhost:9120.
- cert-token-check: expand monitored cert domains to the full set incl.
  hc.kaleschke.info.
- gitea-bundle-mirror: skip empty repos without refs instead of failing.
- unraid-user-scripts.md: document SEND_NTFY/NTFY_TOPIC for the daily report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:35:55 +02:00

208 lines
5.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
OUTPUT_PATH="${OUTPUT_PATH:-/mnt/user/services/posture-check/compose-runtime-drift-last.json}"
NTFY_SCRIPT="${NTFY_SCRIPT:-$REPO_ROOT/ops/restore-tests/send-ntfy.sh}"
NTFY_TOPIC="${NTFY_TOPIC:-homelab-alerts}"
SEND_NTFY="${SEND_NTFY:-1}"
TMP_DIR="${TMP_DIR:-/tmp/kallilab-compose-runtime-drift}"
mkdir -p "$TMP_DIR"
RESULTS_FILE="$TMP_DIR/results.$$"
: > "$RESULTS_FILE"
# Healthchecks Heartbeat (endpoint-agnostisch; Capability-URL ist ein Secret, nie ins Repo)
HC_URL_FILE="${HC_URL_FILE:-/mnt/user/appdata/secrets/healthchecks_compose_drift_url}"
hc_url=""; [ -r "$HC_URL_FILE" ] && hc_url="$(tr -d '[:space:]' < "$HC_URL_FILE")"
hc_ping() { [ -n "$hc_url" ] || return 0; curl -fsS -m 10 --retry 3 "${hc_url}${1:-}" >/dev/null 2>&1 || true; }
trap 'hc_rc=$?; rm -f "$RESULTS_FILE"; [ "$hc_rc" -le 2 ] && hc_ping "" || hc_ping "/fail"' EXIT
hc_ping "/start"
json_escape() {
sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g'
}
add_result() {
printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$RESULTS_FILE"
}
parse_normalized_compose() {
awk '
function clean(value) {
gsub(/\r/, "", value)
gsub(/["'\''"]/, "", value)
return value
}
function emit() {
if (in_services && service && image) {
print clean(container) "\t" clean(image)
}
}
/^services:/ {
emit()
in_services=1
service=""
image=""
container=""
next
}
/^[A-Za-z0-9_.-]+:/ && $0 !~ /^services:/ {
if (in_services) {
emit()
in_services=0
service=""
image=""
container=""
}
}
in_services && /^ [A-Za-z0-9_.-]+:/ {
emit()
service=$1
sub(/:$/, "", service)
image=""
container=service
next
}
in_services && service && /^ image:/ {
image=$0
sub(/^[[:space:]]*image:[[:space:]]*/, "", image)
next
}
in_services && service && /^ container_name:/ {
container=$0
sub(/^[[:space:]]*container_name:[[:space:]]*/, "", container)
next
}
END { emit() }
'
}
parse_compose_with_docker() {
local compose="$1"
local dir
local file
command -v docker >/dev/null 2>&1 || return 1
dir="$(dirname "$compose")"
file="$(basename "$compose")"
(
cd "$dir"
docker compose -f "$file" config 2>/dev/null
) | parse_normalized_compose
}
parse_compose_raw() {
local compose="$1"
awk '
function clean(value) {
gsub(/\r/, "", value)
gsub(/["'\''"]/, "", value)
return value
}
function emit() {
if (service && image && !has_profile) {
if (image ~ /^\*/) {
alias=image
sub(/^\*/, "", alias)
if (alias in anchors) {
image=anchors[alias]
}
}
print clean(container) "\t" clean(image)
}
}
/^x-[A-Za-z0-9_.-]+:[[:space:]]*&[A-Za-z0-9_.-]+[[:space:]]+/ {
alias=$0
sub(/^.*&/, "", alias)
sub(/[[:space:]].*$/, "", alias)
value=$0
sub(/^.*&[A-Za-z0-9_.-]+[[:space:]]+/, "", value)
anchors[alias]=value
next
}
/^ [A-Za-z0-9_.-]+:/ {
emit()
service=$1
sub(/:$/, "", service)
image=""
container=service
has_profile=0
next
}
service && /^ image:/ {
image=$0
sub(/^[[:space:]]*image:[[:space:]]*/, "", image)
next
}
service && /^ container_name:/ {
container=$0
sub(/^[[:space:]]*container_name:[[:space:]]*/, "", container)
next
}
service && /^ profiles:/ {
has_profile=1
next
}
END { emit() }
' "$compose"
}
parse_compose() {
local compose="$1"
if parse_compose_with_docker "$compose"; then
return 0
fi
parse_compose_raw "$compose"
}
while IFS= read -r -d '' compose; do
while IFS="$(printf '\t')" read -r container expected_image; do
[ -n "$container" ] || continue
if ! runtime_image="$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null)"; then
add_result "warning" "$container" "Container missing for compose image $expected_image ($compose)"
continue
fi
if [ "$runtime_image" = "$expected_image" ]; then
add_result "ok" "$container" "Runtime image matches $expected_image"
else
add_result "warning" "$container" "Runtime image '$runtime_image' differs from compose '$expected_image' ($compose)"
fi
done < <(parse_compose "$compose")
done < <(find "$REPO_ROOT" -path "$REPO_ROOT/.git" -prune -o -type f \( -name docker-compose.yml -o -name docker-compose.yaml -o -name compose.yml -o -name compose.yaml \) -print0)
timestamp="$(date -Iseconds)"
warning_count="$(awk -F '\t' '$1 == "warning" { count++ } END { print count + 0 }' "$RESULTS_FILE")"
status="ok"
[ "$warning_count" -gt 0 ] && status="warning"
mkdir -p "$(dirname "$OUTPUT_PATH")"
{
printf '{\n'
printf ' "timestamp": "%s",\n' "$(printf '%s' "$timestamp" | json_escape)"
printf ' "status": "%s",\n' "$status"
printf ' "warning_count": %s,\n' "$warning_count"
printf ' "checks": [\n'
first=1
while IFS="$(printf '\t')" read -r severity name message; do
if [ "$first" -eq 0 ]; then printf ',\n'; fi
first=0
printf ' {"severity":"%s","name":"%s","message":"%s"}' \
"$(printf '%s' "$severity" | json_escape)" \
"$(printf '%s' "$name" | json_escape)" \
"$(printf '%s' "$message" | json_escape)"
done < "$RESULTS_FILE"
printf '\n ]\n}\n'
} > "$OUTPUT_PATH.tmp"
mv "$OUTPUT_PATH.tmp" "$OUTPUT_PATH"
cat "$OUTPUT_PATH"
if [ "$warning_count" -gt 0 ]; then
if [ "$SEND_NTFY" = "1" ] && [ -x "$NTFY_SCRIPT" ]; then
"$NTFY_SCRIPT" "$NTFY_TOPIC" "Compose/runtime drift detected" "$warning_count drift warning(s). See $OUTPUT_PATH" high || true
fi
exit 1
fi