CluPilotCloud/deploy/bootstrap/lib/report.sh

265 lines
9.5 KiB
Bash

# shellcheck shell=sh
#
# Melden und Nachreichen — und die HTTP-Grundlage für alle anderen Abschnitte.
#
# Diese Datei kennt als Einzige den Weg zu CluPilot. Sie hält zusätzlich
# `http_get` und `http_download`, damit der Rückfall von curl auf wget an EINER
# Stelle steht: ein Rettungssystem bringt mal das eine mit und mal das andere,
# und diese Fallunterscheidung fünfmal zu schreiben heißt, sie viermal falsch zu
# schreiben.
#
# Zwei Regeln, aus denen sich alles andere ergibt:
#
# 1. `report` schreibt IMMER zuerst lokal, dann erst der Sendeversuch. Vor
# `wireguard_joined` gibt es keinen Weg zu CluPilot (Spec §5) — die Zeilen
# sammeln sich und werden von `flush_reports` nachgereicht.
# 2. Ein gescheiterter Sendeversuch ist NORMAL und darf den Lauf nie abbrechen.
# Das Skript läuft unter `set -e`; jede Netzoperation hier ist deshalb
# ausdrücklich abgefangen.
#
# Der Zeitstempel ist der des Ereignisses, nicht der der Ankunft. Sonst sieht
# eine zwanzigminütige Installation in der Konsole aus wie eine Sekunde, und
# niemand kann erkennen, welcher Abschnitt lange gedauert hat.
#
# ---------------------------------------------------------------------------
# Der Umschlag, gegen den die Plattform schreibt
# ---------------------------------------------------------------------------
#
# Die Abschnittsschlüssel sind laut Plan die einzige Absprache zwischen den zwei
# Plänen. Der Umschlag darum war nirgends festgelegt — er wird hier festgelegt,
# damit die Plattformseite EINE Stelle zum Nachlesen hat:
#
# POST {api}/host/progress
# Content-Type: application/json
#
# {"code": "<einmal-code>", "events": [
# {"section": "rescue_checked", "state": "done",
# "message": "...", "occurred_at": "2026-07-30T18:04:11Z"}
# ]}
#
# `state` ist `done` oder `failed`. Einen Zustand für „läuft gerade" gibt es
# absichtlich nicht: die Konsole leitet „offen" aus dem Fehlen einer Meldung ab
# (Spec §7), und ein dritter Zustand wäre eine zweite Wahrheit über dieselbe
# Sache.
#
# `message` ist immer vorhanden, notfalls leer — ein Feld, das mal da ist und
# mal nicht, ist die teurere Variante.
# Überschreibbar, damit sich die Melde-Mechanik ohne root und ohne echte
# Maschine durchspielen lässt. Im Betrieb setzt niemand die Variable: der
# Adminbereich schreibt sie nicht in die kopierte Zeile, und das Skript läuft
# ohnehin als root.
CLUPILOT_STATE_DIR="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}"
CLUPILOT_PROGRESS_LOG="${CLUPILOT_STATE_DIR}/progress.jsonl"
CLUPILOT_PROGRESS_SENT="${CLUPILOT_STATE_DIR}/progress.sent"
# Kurz gehalten, weil das hier im Regelfall gegen eine Adresse läuft, die es
# noch nicht gibt. Ein langer Zeitablauf je Abschnitt summiert sich zu Minuten,
# in denen nichts passiert und niemand sieht warum.
CLUPILOT_HTTP_CONNECT_TIMEOUT=5
CLUPILOT_HTTP_MAX_TIME=15
# Legt das Zustandsverzeichnis an. 0700, weil hier später auch die
# Aufrufargumente liegen und der WireGuard-Schlüssel darin steht.
init_reporting() {
mkdir -p "$CLUPILOT_STATE_DIR"
chmod 700 "$CLUPILOT_STATE_DIR"
[ -f "$CLUPILOT_PROGRESS_LOG" ] || : > "$CLUPILOT_PROGRESS_LOG"
[ -f "$CLUPILOT_PROGRESS_SENT" ] || echo 0 > "$CLUPILOT_PROGRESS_SENT"
}
# Macht aus beliebigem Text den Rumpf eines JSON-Strings.
#
# Der Text kommt oft aus der Ausgabe eines fehlgeschlagenen Befehls, ist also
# nicht unter unserer Kontrolle. Reihenfolge zählt: der Backslash zuerst, sonst
# verdoppelt der Lauf danach die gerade gesetzten Fluchtzeichen wieder mit.
# Zeilenumbrüche und Tabulatoren werden zu Leerzeichen — eine Meldung ist eine
# Zeile, und eine eingebettete Zeile zerlegte die JSONL-Datei.
json_escape() {
printf '%s' "$1" \
| tr '\n\r\t' ' ' \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}
# Der einzige Zeitstempel-Erzeuger im ganzen Skript. UTC mit `Z`, weil das
# Skript auf Maschinen mit jeder denkbaren Zonen-Einstellung läuft und die
# Konsole nach R19 ohnehin in UTC speichert und erst bei der Anzeige umrechnet.
now_iso() {
date -u '+%Y-%m-%dT%H:%M:%SZ'
}
# report <section> <state> [message]
#
# Gibt immer 0 zurück. Ein Melden, das den Lauf abbricht, wäre die Diagnose, die
# den Patienten umbringt.
report() {
_section="$1"
_state="$2"
_message="${3:-}"
printf '{"section":"%s","state":"%s","message":"%s","occurred_at":"%s"}\n' \
"$(json_escape "$_section")" \
"$(json_escape "$_state")" \
"$(json_escape "$_message")" \
"$(now_iso)" \
>> "$CLUPILOT_PROGRESS_LOG"
log "[${_state}] ${_section}${_message:+ — ${_message}}"
flush_reports
return 0
}
# Reicht alles nach, was noch nicht bestätigt gesendet wurde.
#
# Der Stand steht als Zeilenzahl in einer eigenen Datei, statt die Zeilen in der
# Protokolldatei umzuschreiben: `progress.jsonl` ist damit reines Anhängen und
# übersteht einen Stromausfall mitten im Schreiben ohne Sonderbehandlung.
#
# Gibt immer 0 zurück.
flush_reports() {
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 0
_sent="$(cat "$CLUPILOT_PROGRESS_SENT" 2>/dev/null || echo 0)"
_total="$(wc -l < "$CLUPILOT_PROGRESS_LOG")"
_sent="$(printf '%s' "$_sent" | tr -cd '0-9')"
[ -n "$_sent" ] || _sent=0
[ "$_total" -gt "$_sent" ] || return 0
_pending="$(sed -n "$((_sent + 1)),\$p" "$CLUPILOT_PROGRESS_LOG")"
[ -n "$_pending" ] || return 0
_events="$(printf '%s' "$_pending" | tr '\n' ',' | sed -e 's/,$//')"
_body="$(printf '{"code":"%s","events":[%s]}' "$(json_escape "$CLUPILOT_CODE")" "$_events")"
if post_json "${CLUPILOT_API}/host/progress" "$_body"; then
# Erst nach der bestätigten Antwort. Andersherum ginge genau der
# Abschnitt verloren, dessen Zustellung gescheitert ist.
echo "$_total" > "$CLUPILOT_PROGRESS_SENT"
log "nachgereicht: $((_total - _sent)) Meldung(en)"
fi
return 0
}
# section_done <section> — 0, wenn dieser Abschnitt schon einmal `done` gemeldet
# hat.
#
# Die Fortschrittsdatei IST der Zustand. Es gibt keine zweite Buchführung
# darüber, was schon lief — eine zweite wäre eine zweite Wahrheit, und nach dem
# Neustart in Task 3 wäre genau sie diejenige, die fehlt.
#
# Bewusst gegen den festen Rumpf geprüft, den `report` schreibt, statt gegen ein
# lose passendes Muster: `grep rescue_checked` fände auch eine Meldung, in deren
# `message` der Name zufällig vorkommt.
section_done() {
[ -f "$CLUPILOT_PROGRESS_LOG" ] || return 1
grep -q "^{\"section\":\"$1\",\"state\":\"done\"," "$CLUPILOT_PROGRESS_LOG"
}
# run_section <section> <funktion> — führt einen Abschnitt aus, wenn er noch
# nicht erledigt ist, und meldet sein Ergebnis.
#
# Das `if` statt `&&` ist Absicht: unter `set -e` bricht ein `&&`-Ausdruck den
# Lauf ab, bevor `report ... failed` je liefe — und dann stünde in der Konsole
# nichts, obwohl genau dieser Fall der ist, für den sie gebaut wurde.
run_section() {
_key="$1"
_fn="$2"
if section_done "$_key"; then
log "übersprungen: ${_key} (schon erledigt)"
return 0
fi
if "$_fn"; then
report "$_key" done "${CLUPILOT_SECTION_NOTE:-}"
CLUPILOT_SECTION_NOTE=''
return 0
fi
report "$_key" failed "${CLUPILOT_SECTION_NOTE:-abgebrochen in ${_key}}"
CLUPILOT_SECTION_NOTE=''
return 1
}
# post_json <url> <body> — 0 bei 2xx, sonst 1. Schreibt nichts nach stdout.
#
# Der Rückfall auf wget ist kein Luxus: Rettungssysteme sind unterschiedlich
# bestückt, und genau hier ist noch keine Gelegenheit gewesen, etwas
# nachzuinstallieren.
post_json() {
_url="$1"
_payload="$2"
if command -v curl >/dev/null 2>&1; then
# `-f` lässt curl bei 4xx/5xx mit Fehler enden, statt die Fehlerseite
# als Erfolg zu behandeln.
curl -sS -f \
--connect-timeout "$CLUPILOT_HTTP_CONNECT_TIMEOUT" \
--max-time "$CLUPILOT_HTTP_MAX_TIME" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-X POST --data "$_payload" \
"$_url" >/dev/null 2>&1
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null \
--timeout="$CLUPILOT_HTTP_MAX_TIME" \
--header='Content-Type: application/json' \
--header='Accept: application/json' \
--post-data="$_payload" \
"$_url"
return $?
fi
return 1
}
# http_get <url> — Rumpf nach stdout, 0 bei Erfolg.
#
# Für kleine Antworten: Verzeichnislisten, Prüfsummendateien. Nicht für ISOs;
# dafür gibt es `http_download`, das nicht durch den Speicher geht.
http_get() {
if command -v curl >/dev/null 2>&1; then
curl -sS -fL --connect-timeout 10 --max-time 120 "$1" 2>/dev/null
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O - --timeout=120 "$1" 2>/dev/null
return $?
fi
return 1
}
# http_download <url> <ziel> — 0 bei Erfolg.
#
# Kein `-C -`/`--continue`: eine halb geladene ISO, die beim zweiten Lauf
# fortgesetzt wird, ergibt eine Datei mit richtiger Größe und falschem Inhalt,
# wenn sich die Quelle dazwischen geändert hat. Lieber ganz neu — die Prüfsumme
# fängt es zwar auch ab, aber erst nach dem Herunterladen.
http_download() {
_url="$1"
_target="$2"
rm -f "$_target"
if command -v curl >/dev/null 2>&1; then
curl -sS -fL --connect-timeout 10 --max-time 3600 -o "$_target" "$_url" 2>/dev/null
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O "$_target" --timeout=3600 "$_url" 2>/dev/null
return $?
fi
return 1
}