diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh new file mode 100755 index 0000000..11bab7f --- /dev/null +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -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 Ausweis für den Rückweg, 32 Zeichen + --wg-private WireGuard-Privatschlüssel, von CluPilot erzeugt + --wg-ip zugeteilte Tunnel-Adresse, z. B. 10.66.0.11/24 + --hub-pubkey öffentlicher Schlüssel des Hubs + --hub-endpoint Adresse des Hubs + --api 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 "$@" diff --git a/deploy/bootstrap/lib/report.sh b/deploy/bootstrap/lib/report.sh new file mode 100644 index 0000000..371cbb9 --- /dev/null +++ b/deploy/bootstrap/lib/report.sh @@ -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": "", "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
[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
— 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
— 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 — 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 +} diff --git a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md index 588324d..c66cc63 100644 --- a/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md +++ b/docs/superpowers/plans/2026-07-30-host-uebernahme-bootstrap-skript.md @@ -69,7 +69,7 @@ macht, beim Nachlesen im Fehlerfall unbrauchbar ist. - Produces: `report
[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` ---