Files
soleprint/berth/ctrl/host.sh
2026-09-24 00:43:18 -03:00

307 lines
16 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# The remote box — the other half of the inventory.
#
# Usage:
# ./host.sh status|ports|services announce a batch; run nothing
# ./host.sh snapshot [--admin] print the probe that would run
# ./host.sh snapshot --run [--admin] run it, save it, then check it
# ./host.sh check [<snapshot-dir>] is the box healthy? offline, never ssh
#
# Always the ssh alias, never a hostname: there is no `Host mcrn.ar` block, so
# a bare hostname offers every agent key and trips MaxAuthTries.
#
# snapshot + check is rig's `make check` asked of a REMOTE machine. rig's check
# reports and instructs and never fixes; so does this. rig's check already reads
# /proc/meminfo through an override path so its branches can be exercised
# "against another machine's real numbers" — this is that idea taken the rest
# of the way: collect the other machine's numbers ONCE, over one announced ssh
# session, then answer every question from the saved copy. The analysis can
# then be re-run, tested against fixtures, and compared across days, and it
# never needs the box to be reachable — which, on a box that is failing, it
# may not be.
set -euo pipefail
cd "$(dirname "$0")"
source ./lib/config.sh
source ./lib/estate.sh
load_config
guard_alias() {
if [[ "$HOST" == *.* ]]; then
echo "HOST='$HOST' is a hostname, not a ~/.ssh/config alias. Refusing." >&2
exit 1
fi
}
announce_batch() {
echo "would run, over 'ssh $HOST':"
printf ' %s\n' "$@"
echo
echo "read-only, and NOT run. Announce-first applies to EACH batch, not"
echo "once per session — so the commands can be read and"
echo "learned rather than scrolled past."
}
# Overridable so selftest can point check at an empty root. snapshot still
# refuses to write anywhere git does not confirm is ignored — a root outside
# the repo is refused, not honoured.
SNAP_ROOT="${SNAP_ROOT:-./.snapshots}"
snapshot() {
local run="" admin=""
for a in "$@"; do
case "$a" in
--run) run=1 ;;
--admin) admin=1 ;;
*) echo "snapshot: unknown argument '$a'" >&2; exit 1 ;;
esac
done
if [ -z "$run" ]; then
echo "would send, over 'ssh $HOST bash -s', exactly this file — ctrl/remote/probe.sh:"
echo
sed 's/^/ | /' ./remote/probe.sh
if [ -n "$admin" ]; then
echo
echo "and, over 'ssh $HOST_ADMIN bash -s', exactly this — ctrl/remote/probe-admin.sh:"
echo
sed 's/^/ | /' ./remote/probe-admin.sh
fi
echo
echo "read-only, and NOT run. What you read above is the file that is sent;"
echo "there is no second copy. To run it:"
echo " bash ctrl/host.sh snapshot --run${admin:+ --admin}"
return 0
fi
local dir; dir="$SNAP_ROOT/$ESTATE/$(date -u +%Y%m%dT%H%M%SZ)"
assert_ignored "$dir/raw" # before anything is written, not after
mkdir -p "$dir"
echo "ssh $HOST bash -s < ctrl/remote/probe.sh"
if ! ssh -o BatchMode=yes "$HOST" bash -s < ./remote/probe.sh > "$dir/raw" 2> "$dir/stderr"; then
echo " ! ssh $HOST exited non-zero — output so far is kept in $dir" >&2
sed 's/^/ /' "$dir/stderr" >&2
fi
split_sections "$dir/raw" "$dir"
if [ -n "$admin" ]; then
echo "ssh $HOST_ADMIN bash -s < ctrl/remote/probe-admin.sh"
ssh -o BatchMode=yes "$HOST_ADMIN" bash -s < ./remote/probe-admin.sh \
> "$dir/raw-admin" 2>> "$dir/stderr" || \
echo " ! ssh $HOST_ADMIN exited non-zero — see $dir/stderr" >&2
split_sections "$dir/raw-admin" "$dir"
fi
echo "saved: $dir"
echo
check "$dir"
}
# One file per `### name` section. An empty section still gets its file, so
# "the probe ran and found nothing" is distinguishable from "it never ran".
split_sections() {
awk -v d="$2" '/^### /{ f = d "/" $2; printf "" > f; next } f { print > f }' "$1"
}
# docker prints decimal units (kB, MB, GB). Approximate on purpose: these are
# for choosing between levers, not for accounting.
AWK_MB='function mb(s, v, u) {
v = s; u = s; sub(/[A-Za-z]+$/, "", v); sub(/^[0-9.]+/, "", u)
if (u == "B") return v / 1e6
if (u == "kB" || u == "KB") return v / 1e3
if (u == "MB") return v + 0
if (u == "GB") return v * 1e3
if (u == "TB") return v * 1e6
return 0
}'
check() {
local f="${1:-}"
if [ -z "$f" ]; then
# `|| true`: with no snapshots, ls fails, pipefail fails the pipeline and
# set -e exits silently — on exactly the path that must explain itself.
f=$(ls -1d "$SNAP_ROOT/$ESTATE"/*/ 2>/dev/null | sort | tail -1) || true
f=${f%/}
fi
# Exit 1 here, unlike every other report in berth: with no snapshot there is
# no report, and a check that prints nothing alarming because it looked at
# nothing is the vacuous pass selftest exists to catch.
if [ -z "$f" ] || [ ! -f "$f/meta" ]; then
echo "no snapshot for estate '$ESTATE'. Take one — read-only, printed before it runs:"
echo " make host snapshot"
exit 1
fi
local taken box age_min
taken=$(sed -n 1p "$f/meta"); box=$(sed -n 2p "$f/meta")
age_min=$(( ( $(date -u +%s) - $(date -u -d "$taken" +%s 2>/dev/null || date -u +%s) ) / 60 ))
echo "snapshot $f"
echo " $box, taken $taken"
if [ "$age_min" -ge 60 ]; then
echo " ! $((age_min / 60))h old — this is how the box WAS. Re-snapshot before acting on it."
fi
# ── disk ────────────────────────────────────────────────────────────────
echo
echo "disk"
local rows; rows=$(awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); printf "%s\t%d\t%d\t%d\n", $6, p, $4, $2 }' "$f/df")
local full=0 mnt pct avail total
while IFS=$'\t' read -r mnt pct avail total; do
[ -z "$mnt" ] && continue
local mark=" "; [ "$pct" -ge 85 ] && mark=" !"
printf "%s %-22s %3d%% %7d MB free of %7d MB\n" "$mark" "$mnt" "$pct" "$avail" "$total"
[ "$pct" -ge 95 ] && full=1
done <<< "$rows"
awk 'NR > 1 && NF >= 6 { p = $5; sub(/%/, "", p); if (p + 0 >= 85) printf " ! inodes %s at %s%% — space can be free and writes still fail\n", $6, $5 }' "$f/df_inodes"
# The filesystem docker writes to: the longest mount point that prefixes
# /var/lib/docker. Usually /, but a separate /var would otherwise be missed.
local dmnt dpct davail dtotal
IFS=$'\t' read -r dmnt dpct davail dtotal < <(
awk -F'\t' '{ m = $1; if (index("/var/lib/docker", m) == 1 && (m == "/" || substr("/var/lib/docker", length(m) + 1, 1) == "/") && length(m) > best) { best = length(m); row = $0 } } END { print row }' <<< "$rows")
if [ "$full" = 1 ]; then
echo
echo " A full disk fails WRITES, not reads — so it rarely presents as 'disk':"
echo " pages render logged-out but 500 logged-in (sessions, DB writes)"
echo " databases crash-loop on start (they must write to start)"
echo " pushes and uploads die partway through"
fi
# ── memory — rig's check.sh mb_of, recomputed here (✖ B8), pointed at the box's /proc/meminfo ──
echo
echo "memory"
mb_of() {
awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } END { if (!found) printf "0" }' "$f/meminfo"
}
local mtotal mavail swap_used
mtotal=$(mb_of MemTotal); mavail=$(mb_of MemAvailable)
swap_used=$(( $(mb_of SwapTotal) - $(mb_of SwapFree) ))
printf " available %d MB of %d MB\n" "$mavail" "$mtotal"
[ "$mtotal" -gt 0 ] && [ $(( mavail * 100 / mtotal )) -lt 10 ] && \
echo " ! under 10% available — expect the OOM killer before anything says 'memory'"
[ "$swap_used" -gt 0 ] && printf " ! %d MB in swap\n" "$swap_used"
# ── containers ──────────────────────────────────────────────────────────
echo
echo "containers"
awk -F'\t' 'NF >= 3 { n++; if ($2 == "running") r++; else printf " ! %-26s %-11s %s\n", $1, $2, $3 }
END { printf " %d of %d running\n", r, n }' "$f/containers"
awk -F'\t' 'NF >= 3 && $3 + 0 > 0 { n = $2; sub(/^\//, "", n); printf "%d\t%s\n", $3, n }' "$f/container_config" \
| sort -rn | head -5 | awk -F'\t' '{ printf " restarted %4d× %s\n", $1, $2 }'
local unbounded
unbounded=$(awk -F'\t' 'NF >= 6 && $5 == "json-file" && ($6 == "" || $6 == "<no value>") { n = $2; sub(/^\//, "", n); printf "%s ", n }' "$f/container_config")
if [ -n "$unbounded" ]; then
echo " ! json-file logs with NO max-size — each grows until the disk is full:"
echo "$unbounded" | fold -s -w 70 | sed 's/^/ /'
fi
# ── what would free space, cheapest first ───────────────────────────────
echo
echo "space — what would free it, and what it costs (≈MB; printed, never run)"
local dfrows; dfrows=$(awk "$AWK_MB"'
NR > 1 && NF >= 5 {
line = $0; gsub(/\([0-9]+%\)/, "", line); n = split(line, a, " ")
t = a[1]; for (i = 2; i <= n - 4; i++) t = t " " a[i]
printf "%s\t%d\t%d\n", t, mb(a[n - 1]), mb(a[n])
}' "$f/docker_df")
rec() { awk -F'\t' -v t="$1" '$1 == t { print $3 }' <<< "$dfrows"; }
local r_cache r_img r_ctr r_vol j_mb logs_mb=""
r_cache=$(rec "Build Cache"); r_img=$(rec "Images"); r_ctr=$(rec "Containers"); r_vol=$(rec "Local Volumes")
j_mb=$(awk '/take up/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+[KMGT]$/) { v = $i; u = substr(v, length(v)); sub(/[KMGT]$/, "", v); m = (u == "K" ? v / 1024 : u == "M" ? v : u == "G" ? v * 1024 : v * 1048576); printf "%d", m } }' "$f/journal" 2>/dev/null)
printf " %6s build cache docker builder prune free — nothing running uses it\n" "${r_cache:-?}"
printf " %6s unused images docker image prune -a next deploy re-pulls or rebuilds\n" "${r_img:-?}"
printf " %6s stopped ctrs docker container prune they cannot be 'start'ed again\n" "${r_ctr:-?}"
printf " %6s host journal sudo journalctl --vacuum-size=200M older host logs are gone\n" "${j_mb:-?}"
if [ -f "$f/admin_container_logs" ]; then
logs_mb=$(awk '$1 ~ /^[0-9]+$/ { s += $1 } END { print s + 0 }' "$f/admin_container_logs")
printf " %6s container logs sudo truncate -s 0 <log> RELIEF, not cure: they refill\n" "$logs_mb"
awk -F'\t' 'NR == FNR { id[substr($1, 1, 12)] = $2; next }
$1 ~ /^[0-9]+$/ && $1 >= 50 { n = $2; sub(/.*containers\//, "", n); k = substr(n, 1, 12); nm = (k in id ? id[k] : k); sub(/^\//, "", nm); printf " %6d %s\n", $1, nm }' \
"$f/container_config" "$f/admin_container_logs"
else
echo " ? container logs not sized: /var/lib/docker is root-only"
echo " — 'make host snapshot' with --admin sizes them"
fi
printf " %6s unused VOLUMES (no command) DATA. Unreferenced is not unwanted —\n" "${r_vol:-?}"
echo " a dangling volume can be the only copy. Decide per volume."
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
local blobs; blobs=$(awk -F'\t' '$1 == "blobs_mb" { print $2 }' "$f/registry_repos")
printf " %6s registry blobs registry garbage-collect a DECISION — see registry below\n" "${blobs:-?}"
fi
# rig's check tells apart two failures with opposite fixes ("stopping that
# is what helps — a bigger VM would not"). The disk has the same pair.
if [ -n "$dmnt" ] && [ "${dtotal:-0}" -gt 0 ]; then
local deficit safe
deficit=$(( (dtotal - davail) - dtotal * 80 / 100 ))
safe=$(( ${r_cache:-0} + ${r_img:-0} + ${r_ctr:-0} + ${j_mb:-0} + ${logs_mb:-0} ))
echo
if [ "$deficit" -le 0 ]; then
printf " %s is under 80%% — no deficit.\n" "$dmnt"
elif [ "$safe" -ge "$deficit" ]; then
printf " %s needs ≈%d MB to reach 80%%; ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning is enough. A bigger disk would not be needed yet —"
echo " and if it refills, the cause is unbounded logs or builds, not size."
else
printf " %s needs ≈%d MB to reach 80%%; only ≈%d MB is reclaimable without touching data.\n" "$dmnt" "$deficit" "$safe"
echo " Pruning will NOT fix this. The rest is data — see the biggest directories."
echo " Grow the volume (EBS resizes online, then growpart + resize2fs), or move data off."
fi
fi
# ── where the space is ──────────────────────────────────────────────────
echo
echo "biggest directories (MB; unprivileged, so root-only trees are absent)"
awk -F'\t' 'NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_root" 2>/dev/null
echo " — under \$HOME:"
awk -F'\t' 'NR >= 2 && NR <= 8 { printf " %7d %s\n", $1, $2 }' "$f/du_home" 2>/dev/null
# ── registry ────────────────────────────────────────────────────────────
if [ -s "$f/registry_repos" ] && ! grep -q '^(not found' "$f/registry_repos"; then
echo
echo "registry (answers /v2/ anonymously — so read this list before collecting garbage)"
awk -F'\t' '$1 != "blobs_mb" { printf " %s %3s tag(s) %s\n", $1, $2, $3 }' "$f/registry_repos"
echo " A repository nobody recognises here is a finding, not clutter: with"
echo " anonymous push, it may be someone else's image in your deploy path."
fi
echo
echo "reports, never fixes. Every command above is printed; none was run."
}
guard_alias
case "${1:-status}" in
status)
announce_batch \
"uname -a; uptime; df -h /" \
"docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'" \
"docker network inspect gateway --format '{{range .Containers}}{{.Name}} {{end}}'" \
"systemctl list-units --type=service --state=running --no-pager" \
"systemctl list-timers --no-pager"
echo
echo "sudo-only, over 'ssh $HOST_ADMIN' and only where genuinely needed:"
echo " wg show # the WireGuard peers that exist nowhere in the tree"
;;
ports)
announce_batch "ss -ltnp"
echo "the estate declares these firewall rules:"
estate_get "firewall" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print(" %-6s %-5s %s" % (r["port"], r.get("proto","tcp"), r.get("desc","")))'
;;
services)
announce_batch "docker compose -f ~/ppl/gateway/docker-compose.yml ps"
echo "the estate declares $(estate_services "$TARGET" | grep -c . ) service(s) for target '$TARGET'."
echo "the gateway compose declares 8. The difference is sibling repos'"
echo "stacks joining the shared 'gateway' network — intended design, but"
echo "nothing in the tree lists it. The inventory produces that list."
;;
snapshot) shift; snapshot "$@" ;;
check) shift; check "$@" ;;
*) echo "usage: $0 [status|ports|services|snapshot [--run] [--admin]|check [<dir>]]" >&2; exit 1 ;;
esac