Give the bootstrap a spine and a way to report

Argument parsing, the state directory, and the half of the script that knows
the way back to CluPilot. No sections are hooked up yet; main lists the nine it
will get, in the order from spec §7, so what is missing stays visible.

report() writes locally first and only then tries to send, because before
wireguard_joined there is no path at all — the lines queue up and flush_reports
delivers them later WITH THEIR OWN TIMESTAMPS. That last part is the whole
point: a twenty-minute install that arrives in one batch looks like one second
in the console, and nobody can see which section was slow.

Every network operation is explicitly caught. The script runs under `set -e`,
and a failing send is the normal case for the first five sections, not an
error — a report that aborts the run would be the diagnosis that kills the
patient.

What is sent had no agreed shape. The section keys were the only agreement
between the two plans; the envelope around them was not written down anywhere,
so it is written down here, at the top of report.sh, for the platform side to
read once. There is deliberately no "running" state: the console derives "open"
from the absence of a report (spec §7), and a third state would be a second
truth about the same thing.

The sent-marker is a line count in its own file rather than a flag rewritten
into progress.jsonl. That keeps the log append-only, so a power cut mid-write
costs at most a partial last line instead of a rewritten file — and after the
reboot in Task 3 that file is the only thing that remembers anything.

Verified here, without hardware: both syntax checks pass; two reports against
an unreachable CluPilot queue up without aborting and leave progress.sent at 0;
a receiver brought up afterwards gets both in one batch, carrying 17:28:55 and
17:28:57 rather than the 17:29:22 they arrived at; a message containing quotes,
a backslash, a tab and a newline survives as valid JSON; and section_done
counts a `done` as done and a `failed` as not. Step 2 of this task is NOT
ticked — none of that is a rescue system, and the plan is right to insist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 19:30:19 +02:00
parent 61c2501136
commit af284d7d26
3 changed files with 362 additions and 2 deletions

View File

@ -0,0 +1,145 @@
#!/bin/sh
set -eu
# Läuft im Rettungssystem des Anbieters, mit genau den Werten, die der
# Adminbereich in die kopierte Zeile geschrieben hat. Es holt nichts ab: alles,
# was vor dem Tunnel gebraucht wird, steht bereits hier (Spec §5).
#
# Der Ablauf sind Abschnitte. Jeder meldet sich zurück, sobald ein Weg zu
# CluPilot existiert; die davor werden lokal mitgeschrieben und nachgereicht.
# Die Abschnittsschlüssel sind die einzige Absprache mit dem Plattform-Plan und
# stehen unten in `main` in genau der Reihenfolge aus Spec §7.
#
# Wiederanlauf: die Fortschrittsdatei ist der Zustand. Ein Abschnitt, der schon
# `done` gemeldet hat, läuft nicht erneut — so nimmt der systemd-Dienst nach dem
# Neustart (Task 3) denselben Lauf wieder auf, statt bei Null anzufangen.
CLUPILOT_CODE=''
CLUPILOT_WG_PRIVATE=''
CLUPILOT_WG_IP=''
CLUPILOT_HUB_PUBKEY=''
CLUPILOT_HUB_ENDPOINT=''
CLUPILOT_API=''
# Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas
# mitzugeben hat, und danach wieder geleert.
CLUPILOT_SECTION_NOTE=''
usage() {
cat <<'TEXT'
clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen
fertigen Proxmox-Host.
--code <einmal-code> Ausweis für den Rückweg, 32 Zeichen
--wg-private <schlüssel> WireGuard-Privatschlüssel, von CluPilot erzeugt
--wg-ip <adresse/präfix> zugeteilte Tunnel-Adresse, z. B. 10.66.0.11/24
--hub-pubkey <schlüssel> öffentlicher Schlüssel des Hubs
--hub-endpoint <host:port> Adresse des Hubs
--api <url> CluPilot über die TUNNEL-Adresse, ohne Schrägstrich
am Ende, z. B. http://10.66.0.1
Diese Zeile wird nicht abgetippt. Der Adminbereich zeigt sie fertig zum
Kopieren, wenn ein Host angelegt wird.
TEXT
}
# Nach stderr, damit stdout frei bleibt, und zusätzlich in eine Datei — der
# Betreiber sieht die Konsole, aber wer hinterher nachsieht, hat nur die Datei.
log() {
_line="$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*"
printf '%s\n' "$_line" >&2
[ -d "${CLUPILOT_STATE_DIR:-}" ] && printf '%s\n' "$_line" >> "${CLUPILOT_STATE_DIR}/bootstrap.log"
return 0
}
die() {
printf 'clupilot-bootstrap: %s\n' "$*" >&2
exit 1
}
parse_arguments() {
while [ $# -gt 0 ]; do
case "$1" in
--code) CLUPILOT_CODE="${2:-}"; shift 2 ;;
--wg-private) CLUPILOT_WG_PRIVATE="${2:-}"; shift 2 ;;
--wg-ip) CLUPILOT_WG_IP="${2:-}"; shift 2 ;;
--hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;;
--hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;;
--api) CLUPILOT_API="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unbekannte Angabe: $1" ;;
esac
done
# Alle sechs sind Pflicht. Es gibt keine sinnvolle Vorgabe für einen davon,
# und ein fehlender fällt sonst erst mitten in einer Installation auf, die
# die Platte schon überschrieben hat.
[ -n "$CLUPILOT_CODE" ] || die '--code fehlt'
[ -n "$CLUPILOT_WG_PRIVATE" ] || die '--wg-private fehlt'
[ -n "$CLUPILOT_WG_IP" ] || die '--wg-ip fehlt'
[ -n "$CLUPILOT_HUB_PUBKEY" ] || die '--hub-pubkey fehlt'
[ -n "$CLUPILOT_HUB_ENDPOINT" ] || die '--hub-endpoint fehlt'
[ -n "$CLUPILOT_API" ] || die '--api fehlt'
# Ein Schrägstrich am Ende ergäbe `…//host/progress`. Manche Router
# beantworten das, manche nicht — und der Unterschied fiele erst beim ersten
# Melden auf, also nach dem Tunnelbeitritt.
CLUPILOT_API="${CLUPILOT_API%/}"
}
# Findet `lib/` neben dem Skript.
#
# OFFEN, und zwar außerhalb dieses Skripts: der Adminbereich zeigt laut
# Plattform-Plan Task 7 eine Zeile mit `curl` darin, aber weder Spec noch Plan
# sagen, WAS geladen wird. Ein `curl … | sh` kann keine `lib/` haben; der Plan
# verlangt aber getrennte Dateien, damit `network.sh` und `template.sh`
# unabhängig wiederholbar bleiben. Beides zugleich geht nur, wenn die Zeile ein
# Archiv holt und auspackt. Bis das entschieden ist, sucht das Skript seine
# Bibliothek neben sich und fällt auf den Ort zurück, an den Task 3 sie kopiert.
resolve_lib_dir() {
_self_dir="$(dirname -- "$0")"
if [ -f "${_self_dir}/lib/report.sh" ]; then
printf '%s' "${_self_dir}/lib"
return 0
fi
if [ -f '/opt/clupilot/bootstrap/lib/report.sh' ]; then
printf '%s' '/opt/clupilot/bootstrap/lib'
return 0
fi
die 'lib/report.sh nicht gefunden — das Skript wurde ohne seine Bibliothek kopiert'
}
main() {
parse_arguments "$@"
_lib="$(resolve_lib_dir)"
# shellcheck source=lib/report.sh
. "${_lib}/report.sh"
init_reporting
log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}"
# Vor jedem Abschnitt: was liegengeblieben ist, geht zuerst raus. Nach dem
# Tunnelbeitritt ist das der Aufruf, der die ganze Vorgeschichte nachreicht.
flush_reports
# Die Abschnitte kommen mit den Tasks 2 bis 9 dazu, in der Reihenfolge aus
# Spec §7. Der Rumpf steht schon hier, damit sichtbar ist, was noch fehlt:
#
# run_section rescue_checked section_rescue_checked # Task 2
# run_section debian_installed section_debian_installed # Task 3
# run_section rebooted section_rebooted # Task 3
# run_section proxmox_installed section_proxmox_installed # Task 4
# run_section network_bridged section_network_bridged # Task 5
# run_section wireguard_joined section_wireguard_joined # Task 6
# run_section traefik_running section_traefik_running # Task 7
# run_section template_built section_template_built # Task 8
# run_section registered section_registered # Task 9
log 'Gerüst steht. Es sind noch keine Abschnitte eingehängt (Tasks 2 bis 9).'
}
main "$@"

View File

@ -0,0 +1,215 @@
# shellcheck shell=sh
#
# Melden und Nachreichen. Diese Datei kennt als Einzige den Weg zu CluPilot.
#
# 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
}

View File

@ -69,7 +69,7 @@ macht, beim Nachlesen im Fehlerfall unbrauchbar ist.
- Produces: `report <section> <state> [message]` — schreibt lokal nach `/var/lib/clupilot/progress.jsonl` **mit Zeitstempel** und versucht zu senden. `flush_reports` — reicht alles Ungesendete nach.
- Consumes: aus der Befehlszeile `--code`, `--wg-private`, `--wg-ip`, `--hub-pubkey`, `--hub-endpoint`, `--api`.
- [ ] **Step 1: Das Gerüst schreiben**
- [x] **Step 1: Das Gerüst schreiben**
```sh
#!/bin/sh
@ -92,7 +92,7 @@ Rettungssystem starten, Skript mit erfundenen Werten aufrufen, prüfen dass
`/var/lib/clupilot/progress.jsonl` entsteht und die Sendeversuche sauber
scheitern statt das Skript abzubrechen.
- [ ] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report`
- [x] **Step 3: Committen.** Nachricht: `Give the bootstrap a spine and a way to report`
---