From af284d7d26648d86e8bfa1f53d833f0546f3bf16 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 19:30:19 +0200 Subject: [PATCH] Give the bootstrap a spine and a way to report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/bootstrap/clupilot-bootstrap.sh | 145 ++++++++++++ deploy/bootstrap/lib/report.sh | 215 ++++++++++++++++++ ...-07-30-host-uebernahme-bootstrap-skript.md | 4 +- 3 files changed, 362 insertions(+), 2 deletions(-) create mode 100755 deploy/bootstrap/clupilot-bootstrap.sh create mode 100644 deploy/bootstrap/lib/report.sh 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` ---