Write the image and survive the first reboot
The official Proxmox ISO, an answer file baked into it, and QEMU running it against the physical disks from the rescue system. Options were looked up rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and --on-first-boot, and it names its own output, so the finished ISO is found by glob afterwards instead of being handed an --output flag I would have invented. validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops the installer into interactive mode, where it then hangs invisibly inside QEMU until the hour runs out — the failure would arrive as a timeout and say nothing about the cause. The codename table comes over verbatim from InstallProxmoxVe.php, which the platform plan deletes. It is used here to install the assistant into the RESCUE system, so it keys off the rescue system's own codename. An unknown one aborts; not "trixie is newest, so trixie", because the next Debian will be unknown too and by then nobody is watching. The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating system that could have become anything in transit is not a risk worth taking, and a truncated download is enough to cause it — malice is not required. The reboot carry-across is the gap this plan was amended for, and [first-boot] takes exactly one executable. So that one file carries everything: the script, its lib/, the progress file and the arguments, as a base64 archive in its own body. That also avoids importing the fresh ZFS pool from the rescue system to copy files into it. Two bugs found by running it rather than reading it. The staging copy put the work directory inside the state directory, so cp refused to copy a directory into itself — and had it not refused, the gigabyte ISO would have been base64'd into the hook that gets baked into that same ISO. The fix is not a better exclude: the two directories have opposite lifetimes, so they now live in different places, and a guard refuses loudly if anyone points them at each other again. Structure beats a rule someone has to remember. Verified without hardware: both files pass sh and dash. The generated answer file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max, first-boot and network.filter, and a 64-character root password. The codename table accepts bookworm and trixie and refuses bullseye and forky. The shim comes out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it stayed out. Run in a fresh debian:13-slim container it unpacks to /opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with its original timestamp, has 700 on the directory and 600 on the arguments that hold the WireGuard key, skips rescue_checked as already done, and fails cleanly on the missing qemu. Step 2 stays unticked: a container is not a rescue system, and nothing here has yet written to a disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/host-bootstrap
parent
0b26e45147
commit
0f563545fd
|
|
@ -21,6 +21,16 @@ CLUPILOT_HUB_PUBKEY=''
|
|||
CLUPILOT_HUB_ENDPOINT=''
|
||||
CLUPILOT_API=''
|
||||
|
||||
# Freiwillig. Ohne diese Angabe gibt es keinen Weg per SSH auf die Maschine, und
|
||||
# das ist die Vorgabe: die Plattform meldet sich nicht mehr an (Spec §6).
|
||||
CLUPILOT_ROOT_SSH_KEY=''
|
||||
|
||||
# 1, wenn der `[first-boot]`-Hook uns nach dem Neustart wieder aufgenommen hat.
|
||||
CLUPILOT_RESUMING=0
|
||||
|
||||
# Wird in `derive_fqdn` gesetzt; der Installer verlangt einen.
|
||||
CLUPILOT_FQDN=''
|
||||
|
||||
# Wird von `run_section` gefüllt, wenn ein Abschnitt der Meldung etwas
|
||||
# mitzugeben hat, und danach wieder geleert.
|
||||
CLUPILOT_SECTION_NOTE=''
|
||||
|
|
@ -52,6 +62,29 @@ CLUPILOT_MIN_DISK_GB=100
|
|||
# Zertifikaten redet und nicht von der Uhrzeit.
|
||||
CLUPILOT_MAX_CLOCK_DRIFT=3600
|
||||
|
||||
# Wo die ISO gebaut wird — AUSDRÜCKLICH nicht im Zustandsverzeichnis.
|
||||
#
|
||||
# Die beiden haben entgegengesetzte Lebensdauern: `/var/lib/clupilot` muss den
|
||||
# Neustart überleben und wandert dafür in den first-boot-Hook; dieses hier darf
|
||||
# das auf keinen Fall, denn hier liegt die ISO. Läge es darin, packte der Hook
|
||||
# ein Gigabyte base64 in sich selbst — und würde in genau die ISO gebacken, die
|
||||
# er enthält.
|
||||
#
|
||||
# Das war kein gedachter Fall: die erste Fassung legte `work/` unter das
|
||||
# Zustandsverzeichnis, und `cp` weigerte sich, ein Verzeichnis in sich selbst zu
|
||||
# kopieren. Getrennte Orte machen aus der Regel, die man sich merken muss, eine
|
||||
# Eigenschaft, die man nicht verletzen kann.
|
||||
CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/tmp/clupilot-work}"
|
||||
CLUPILOT_ISO_MIRROR="${CLUPILOT_ISO_MIRROR:-https://enterprise.proxmox.com/iso}"
|
||||
|
||||
# ZFS nimmt sich sonst die Hälfte des Arbeitsspeichers für den Lesecache. Ein
|
||||
# Achtel, mindestens 2 GB — der Rest gehört den Gästen.
|
||||
CLUPILOT_ZFS_ARC_MAX_MB="${CLUPILOT_ZFS_ARC_MAX_MB:-2048}"
|
||||
|
||||
# Eine Stunde. Eine Proxmox-Installation auf ZFS braucht Minuten, keine Stunde;
|
||||
# wer diese Grenze reißt, hängt und läuft nicht langsam.
|
||||
CLUPILOT_INSTALL_TIMEOUT="${CLUPILOT_INSTALL_TIMEOUT:-3600}"
|
||||
|
||||
usage() {
|
||||
cat <<'TEXT'
|
||||
clupilot-bootstrap.sh — macht aus einer Maschine im Rettungssystem einen
|
||||
|
|
@ -65,6 +98,14 @@ fertigen Proxmox-Host.
|
|||
--api <url> CluPilot über die TUNNEL-Adresse, ohne Schrägstrich
|
||||
am Ende, z. B. http://10.66.0.1
|
||||
|
||||
Freiwillig:
|
||||
|
||||
--root-ssh-key <schlüssel> öffentlicher Schlüssel für root. Ohne ihn gibt es
|
||||
keinen SSH-Zugang auf den fertigen Host.
|
||||
--resume nimmt einen unterbrochenen Lauf wieder auf. Setzt
|
||||
der first-boot-Hook nach dem Neustart selbst; von
|
||||
Hand braucht man das nicht.
|
||||
|
||||
Diese Zeile wird nicht abgetippt. Der Adminbereich zeigt sie fertig zum
|
||||
Kopieren, wenn ein Host angelegt wird.
|
||||
TEXT
|
||||
|
|
@ -93,11 +134,23 @@ parse_arguments() {
|
|||
--hub-pubkey) CLUPILOT_HUB_PUBKEY="${2:-}"; shift 2 ;;
|
||||
--hub-endpoint) CLUPILOT_HUB_ENDPOINT="${2:-}"; shift 2 ;;
|
||||
--api) CLUPILOT_API="${2:-}"; shift 2 ;;
|
||||
--root-ssh-key) CLUPILOT_ROOT_SSH_KEY="${2:-}"; shift 2 ;;
|
||||
--resume) CLUPILOT_RESUMING=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; die "unbekannte Angabe: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Der wiederaufgenommene Lauf nach dem Neustart bekommt keine Befehlszeile
|
||||
# mehr — der `[first-boot]`-Hook ruft nur `--resume`. Die Werte stehen in
|
||||
# der Datei, die vor dem Neustart mit in die ISO gegangen ist.
|
||||
if [ "$CLUPILOT_RESUMING" = '1' ]; then
|
||||
_saved="${CLUPILOT_STATE_DIR:-/var/lib/clupilot}/arguments"
|
||||
[ -r "$_saved" ] || die "--resume, aber ${_saved} ist nicht lesbar. Der Lauf lässt sich nicht fortsetzen; die Maschine wird neu aufgesetzt (Runbook)."
|
||||
# shellcheck disable=SC1090
|
||||
. "$_saved"
|
||||
fi
|
||||
|
||||
# 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.
|
||||
|
|
@ -114,31 +167,71 @@ parse_arguments() {
|
|||
CLUPILOT_API="${CLUPILOT_API%/}"
|
||||
}
|
||||
|
||||
# Findet `lib/` neben dem Skript.
|
||||
# Findet das Verzeichnis, in dem dieses Skript samt `lib/` liegt.
|
||||
#
|
||||
# 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")"
|
||||
# Ausgeliefert wird als Archiv nach `/opt/clupilot/bootstrap`; der Weg über `$0`
|
||||
# steht davor, damit sich das Skript auch aus einem ausgecheckten Repo heraus
|
||||
# starten lässt, ohne es vorher zu installieren.
|
||||
resolve_bootstrap_dir() {
|
||||
_self_dir="$(cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)"
|
||||
|
||||
if [ -f "${_self_dir}/lib/report.sh" ]; then
|
||||
printf '%s' "${_self_dir}/lib"
|
||||
if [ -n "$_self_dir" ] && [ -f "${_self_dir}/lib/report.sh" ]; then
|
||||
printf '%s' "$_self_dir"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f '/opt/clupilot/bootstrap/lib/report.sh' ]; then
|
||||
printf '%s' '/opt/clupilot/bootstrap/lib'
|
||||
printf '%s' '/opt/clupilot/bootstrap'
|
||||
return 0
|
||||
fi
|
||||
|
||||
die 'lib/report.sh nicht gefunden — das Skript wurde ohne seine Bibliothek kopiert'
|
||||
}
|
||||
|
||||
# Läuft dieser Lauf noch im Rettungssystem, oder schon auf dem installierten
|
||||
# Host?
|
||||
#
|
||||
# Dieselbe Unterscheidung wie in `check_is_rescue_system`, nur als schlichte
|
||||
# Frage — und die eine Stelle, an der `section_rebooted` erkennt, dass der
|
||||
# Neustart hinter ihm liegt statt vor ihm.
|
||||
running_in_rescue_system() {
|
||||
_fstype="$(root_field type)"
|
||||
_device="$(root_field dev)"
|
||||
|
||||
case "$_fstype" in
|
||||
tmpfs|ramfs|rootfs|overlay|overlayfs|squashfs|aufs) return 0 ;;
|
||||
esac
|
||||
|
||||
case "$_device" in
|
||||
/dev/ram*|/dev/nfs|none) return 0 ;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Der Installer verlangt einen FQDN, und die Befehlszeile bringt keinen mit.
|
||||
#
|
||||
# Erst die Rückwärtsauflösung der öffentlichen Adresse — das ist der Name, den
|
||||
# der Anbieter für diese Maschine führt. Sonst ein Name aus der Tunneladresse,
|
||||
# der eindeutig ist und niemanden in die Irre führt. In Task 9 ersetzt CluPilot
|
||||
# ihn ohnehin durch den, den es selbst führt.
|
||||
derive_fqdn() {
|
||||
_ip="$(ip -4 -o addr show scope global 2>/dev/null | awk '{ sub(/\/.*/, "", $4); print $4; exit }')"
|
||||
|
||||
if [ -n "$_ip" ] && command -v getent >/dev/null 2>&1; then
|
||||
_name="$(getent hosts "$_ip" 2>/dev/null | awk '{ print $2; exit }')"
|
||||
case "$_name" in
|
||||
*.*.*)
|
||||
CLUPILOT_FQDN="$_name"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
_suffix="$(printf '%s' "$CLUPILOT_WG_IP" | sed -e 's#/.*##' -e 's/\./-/g')"
|
||||
CLUPILOT_FQDN="host-${_suffix}.clupilot.net"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abschnitt 1: rescue_checked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -211,20 +304,9 @@ largest_disk_bytes() {
|
|||
# Wurzelverzeichnis liegt auf tmpfs, einem Overlay oder einer RAM-Platte. Ein
|
||||
# installiertes System hat dort eine echte Partition.
|
||||
check_is_rescue_system() {
|
||||
_fstype="$(root_field type)"
|
||||
_device="$(root_field dev)"
|
||||
|
||||
case "${_fstype}" in
|
||||
tmpfs|ramfs|rootfs|overlay|overlayfs|squashfs|aufs) ;;
|
||||
*)
|
||||
case "${_device}" in
|
||||
/dev/ram*|/dev/nfs|none) ;;
|
||||
*)
|
||||
refuse "Das Wurzelverzeichnis liegt auf ${_device} (${_fstype}), also auf einer echten Partition. Ein Rettungssystem läuft aus dem Arbeitsspeicher. Diese Maschine sieht aus wie ein installiertes System, und dieses Skript überschreibt Platten — es macht hier nicht weiter. Rettungssystem im Kundenbereich des Anbieters starten und neu einfügen."
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
if ! running_in_rescue_system; then
|
||||
refuse "Das Wurzelverzeichnis liegt auf $(root_field dev) ($(root_field type)), also auf einer echten Partition. Ein Rettungssystem läuft aus dem Arbeitsspeicher. Diese Maschine sieht aus wie ein installiertes System, und dieses Skript überschreibt Platten — es macht hier nicht weiter. Rettungssystem im Kundenbereich des Anbieters starten und neu einfügen."
|
||||
fi
|
||||
|
||||
# Ein laufender Hypervisor ist die eine Maschine, die auf keinen Fall
|
||||
# angefasst werden darf — auf ihr liegen Kunden.
|
||||
|
|
@ -341,15 +423,145 @@ section_rescue_checked() {
|
|||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abschnitt 2: debian_installed — das Grundsystem auf die Platte
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# „debian_installed" heißt hier **Grundsystem geschrieben**. Es entsteht kein
|
||||
# Debian, das nachher jemand zu Proxmox umbaut: die offizielle Proxmox-ISO wird
|
||||
# mit einer Antwortdatei versehen und unter QEMU gegen die echten Platten
|
||||
# gestartet. Der Schlüssel bleibt trotzdem, weil er die einzige Absprache mit
|
||||
# dem Plattform-Plan ist — und weil Proxmox VE Debian mit PVE-Kernel ist.
|
||||
section_debian_installed() {
|
||||
mkdir -p "$CLUPILOT_WORK_DIR"
|
||||
|
||||
for _tool in qemu-system-x86_64 sha256sum timeout tar base64; do
|
||||
command -v "$_tool" >/dev/null 2>&1 || {
|
||||
CLUPILOT_SECTION_NOTE="${_tool} fehlt im Rettungssystem"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
|
||||
if ! detect_network; then
|
||||
CLUPILOT_SECTION_NOTE='Netzkonfiguration des Rettungssystems nicht ermittelbar (Adresse, Gateway oder MAC fehlt)'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_disks="$(detect_install_disks)"
|
||||
if [ -z "$_disks" ]; then
|
||||
CLUPILOT_SECTION_NOTE='keine physische Platte gefunden'
|
||||
return 1
|
||||
fi
|
||||
_count="$(printf '%s' "$_disks" | wc -w | tr -d ' ')"
|
||||
_raid="$(zfs_raid_for "$_count")"
|
||||
_boot_mode="$(detect_boot_mode)"
|
||||
|
||||
derive_fqdn
|
||||
log "Ziel: ${CLUPILOT_FQDN}, ${_boot_mode}, ZFS ${_raid} über ${_count} Platte(n): ${_disks}"
|
||||
|
||||
if ! ensure_autoinstall_assistant; then
|
||||
CLUPILOT_SECTION_NOTE='proxmox-auto-install-assistant nicht installierbar'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_iso_name="$(discover_pve_iso)"
|
||||
if [ -z "$_iso_name" ]; then
|
||||
CLUPILOT_SECTION_NOTE="keine PVE-9-ISO unter ${CLUPILOT_ISO_MIRROR} gefunden"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_iso="${CLUPILOT_WORK_DIR}/${_iso_name}"
|
||||
if [ ! -f "$_iso" ] && ! fetch_proxmox_iso "$_iso_name" "$_iso"; then
|
||||
CLUPILOT_SECTION_NOTE="${_iso_name} nicht ladbar oder Prüfsumme falsch"
|
||||
return 1
|
||||
fi
|
||||
|
||||
build_first_boot_shim "${CLUPILOT_WORK_DIR}/first-boot.sh"
|
||||
write_answer_file "${CLUPILOT_WORK_DIR}/answer.toml" "$_disks" "$_raid"
|
||||
|
||||
# Vor dem Backen prüfen. Eine Antwortdatei mit einem Tippfehler lässt den
|
||||
# Installer in den interaktiven Modus fallen — und dort hängt er dann
|
||||
# unsichtbar in QEMU, bis die Stunde abgelaufen ist.
|
||||
if ! proxmox-auto-install-assistant validate-answer "${CLUPILOT_WORK_DIR}/answer.toml" >> "${CLUPILOT_WORK_DIR}/assistant.log" 2>&1; then
|
||||
CLUPILOT_SECTION_NOTE="answer.toml abgelehnt; siehe ${CLUPILOT_WORK_DIR}/assistant.log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Der Assistent legt die fertige ISO neben die Quelle und benennt sie selbst.
|
||||
# Deshalb wird sie danach gesucht statt vorher benannt — ein erfundener
|
||||
# `--output`-Schalter wäre die Sorte Annahme, die genau einmal auffällt.
|
||||
rm -f "${CLUPILOT_WORK_DIR}"/*auto*.iso
|
||||
if ! (cd "$CLUPILOT_WORK_DIR" && proxmox-auto-install-assistant prepare-iso "$_iso" \
|
||||
--fetch-from iso \
|
||||
--answer-file "${CLUPILOT_WORK_DIR}/answer.toml" \
|
||||
--on-first-boot "${CLUPILOT_WORK_DIR}/first-boot.sh") >> "${CLUPILOT_WORK_DIR}/assistant.log" 2>&1; then
|
||||
CLUPILOT_SECTION_NOTE="prepare-iso fehlgeschlagen; siehe ${CLUPILOT_WORK_DIR}/assistant.log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_prepared="$(ls -1 "${CLUPILOT_WORK_DIR}"/*auto*.iso 2>/dev/null | head -1)"
|
||||
if [ -z "$_prepared" ]; then
|
||||
CLUPILOT_SECTION_NOTE='prepare-iso meldete Erfolg, hat aber keine ISO hinterlassen'
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! run_installer_under_qemu "$_prepared" "$_disks" "$_boot_mode"; then
|
||||
CLUPILOT_SECTION_NOTE="Installer nicht durchgelaufen; siehe ${CLUPILOT_WORK_DIR}/qemu.log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
CLUPILOT_SECTION_NOTE="Proxmox aus ${_iso_name} auf ZFS ${_raid} über ${_count} Platte(n)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abschnitt 3: rebooted — die einzige unumkehrbare Stelle
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Dieser Abschnitt läuft ZWEIMAL, auf zwei verschiedenen Systemen. Im
|
||||
# Rettungssystem startet er neu und kommt nie zurück; nach dem Hochfahren nimmt
|
||||
# der `[first-boot]`-Hook den Lauf wieder auf, und dann stellt derselbe
|
||||
# Abschnitt fest, dass er hinter sich liegt, und meldet ihn.
|
||||
#
|
||||
# Deshalb gibt es hier auch kein `report rebooted done` vor dem Neustart: eine
|
||||
# Meldung, die einen Neustart ankündigt, der dann nicht klappt, ist schlimmer
|
||||
# als gar keine.
|
||||
section_rebooted() {
|
||||
if ! running_in_rescue_system; then
|
||||
CLUPILOT_SECTION_NOTE="wieder da auf $(uname -r)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Alles, was noch liegt, ein letztes Mal versuchen. Ab hier ist diese
|
||||
# Fortschrittsdatei nur noch über die ISO erreichbar.
|
||||
flush_reports
|
||||
|
||||
log 'Neustart aus dem Rettungssystem. Der first-boot-Hook nimmt den Lauf wieder auf.'
|
||||
sync
|
||||
(sleep 5; systemctl reboot 2>/dev/null || reboot) &
|
||||
|
||||
# Nicht mit `exit 0` enden: das Skript ist an dieser Stelle nicht fertig,
|
||||
# es gibt nur die Maschine ab. Der Rückgabewert wäre eine Aussage über
|
||||
# etwas, das erst nach dem Hochfahren feststeht.
|
||||
sleep 120
|
||||
die 'Neustart wurde angestoßen, aber die Maschine läuft nach zwei Minuten noch. Über die Anbieterkonsole nachsehen.'
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_arguments "$@"
|
||||
|
||||
_lib="$(resolve_lib_dir)"
|
||||
CLUPILOT_BOOTSTRAP_DIR="$(resolve_bootstrap_dir)"
|
||||
# shellcheck source=lib/report.sh
|
||||
. "${_lib}/report.sh"
|
||||
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/report.sh"
|
||||
# shellcheck source=lib/proxmox.sh
|
||||
. "${CLUPILOT_BOOTSTRAP_DIR}/lib/proxmox.sh"
|
||||
|
||||
init_reporting
|
||||
log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}"
|
||||
if [ "$CLUPILOT_RESUMING" = '1' ]; then
|
||||
log "Lauf wieder aufgenommen nach dem Neustart — $(uname -r)"
|
||||
else
|
||||
log "Bootstrap beginnt — Tunnel-Adresse ${CLUPILOT_WG_IP}, CluPilot unter ${CLUPILOT_API}"
|
||||
fi
|
||||
|
||||
# Vor jedem Abschnitt: was liegengeblieben ist, geht zuerst raus. Nach dem
|
||||
# Tunnelbeitritt ist das der Aufruf, der die ganze Vorgeschichte nachreicht.
|
||||
|
|
@ -361,8 +573,15 @@ main() {
|
|||
die 'Abbruch nach der Eingangsprüfung. Es wurde nichts geschrieben — die Platte ist unangetastet.'
|
||||
fi
|
||||
|
||||
# run_section debian_installed section_debian_installed # Task 3
|
||||
# run_section rebooted section_rebooted # Task 3
|
||||
if ! run_section debian_installed section_debian_installed; then
|
||||
die 'Abbruch: das Grundsystem ist nicht auf die Platte gekommen. Die Maschine wird neu aufgesetzt, nicht nachgebessert (Runbook).'
|
||||
fi
|
||||
|
||||
# Kommt im Rettungssystem nicht zurück — die Maschine startet hier neu.
|
||||
if ! run_section rebooted section_rebooted; then
|
||||
die 'Abbruch beim Neustart.'
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
|
@ -370,7 +589,7 @@ main() {
|
|||
# run_section template_built section_template_built # Task 8
|
||||
# run_section registered section_registered # Task 9
|
||||
|
||||
log 'Eingangsprüfung bestanden. Die Abschnitte ab Task 3 sind noch nicht eingehängt.'
|
||||
log 'Proxmox läuft. Die Abschnitte ab Task 4 sind noch nicht eingehängt.'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,495 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Proxmox: die Installation (Abschnitte `debian_installed` und `rebooted`) und
|
||||
# die Paketquellen (`proxmox_installed`).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warum eine ISO unter QEMU und nicht installimage
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Hetzners `installimage` führt kein Proxmox — es gilt dort als nicht
|
||||
# unterstützt. Wer es darüber versucht, installiert Debian und baut es danach
|
||||
# um; genau das Zwischensystem, das dieser Entwurf abgeschafft hat.
|
||||
#
|
||||
# Der reguläre Weg ist seit PVE 8.2 die offizielle ISO mit eingebetteter
|
||||
# Antwortdatei: `proxmox-auto-install-assistant prepare-iso` backt eine
|
||||
# `answer.toml` hinein, QEMU startet sie im Rettungssystem gegen die *echten*
|
||||
# Platten, und der Eintrag „Automated Installation" wird nach zehn Sekunden von
|
||||
# selbst gewählt. Kein VNC, kein Klick.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Codename-Tabelle
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Wörtlich übernommen aus `app/Provisioning/Steps/Host/InstallProxmoxVe.php`,
|
||||
# das der Plattform-Plan löscht. Sie ist aus einem echten Fehler entstanden: ein
|
||||
# fest verdrahtetes `bookworm` hätte auf Debian 13 PVE-8-Quellen gesetzt, also
|
||||
# ein PVE gegen eine andere libc.
|
||||
#
|
||||
# Jede Ausgabe hat ihren EIGENEN Schlüssel und ihren eigenen Dateinamen —
|
||||
# Proxmox signiert jede Suite einzeln und hat die Datei zwischen PVE 8 und PVE 9
|
||||
# umbenannt. Ein gemeinsamer Name zeigte irgendwann auf den falschen Schlüssel,
|
||||
# ohne dass etwas falsch aussähe.
|
||||
#
|
||||
# Bullseye/PVE 7 fehlt absichtlich: eine Debian-11-Maschine heute bedeutet, dass
|
||||
# jemand das falsche Abbild gewählt hat, und ein lautes Scheitern ist die
|
||||
# bessere Antwort als ein Hypervisor ohne Unterstützung.
|
||||
|
||||
# proxmox_release_for <codename> — gibt "suite|key_url|keyring" aus, oder 1.
|
||||
proxmox_release_for() {
|
||||
case "$1" in
|
||||
bookworm)
|
||||
printf 'bookworm|https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg|proxmox-release-bookworm.gpg'
|
||||
;;
|
||||
trixie)
|
||||
printf 'trixie|https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg|proxmox-archive-keyring.gpg'
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Liest ID und VERSION_CODENAME aus /etc/os-release.
|
||||
#
|
||||
# Gesourct statt gegreppt, damit die Shell das Entwerten übernimmt, und mit
|
||||
# einem Trenner ausgegeben, der in keinem der Werte vorkommen kann.
|
||||
os_release_field() {
|
||||
(
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release 2>/dev/null || exit 1
|
||||
case "$1" in
|
||||
id) printf '%s' "${ID:-}" ;;
|
||||
codename) printf '%s' "${VERSION_CODENAME:-}" ;;
|
||||
pretty) printf '%s' "${PRETTY_NAME:-}" ;;
|
||||
esac
|
||||
)
|
||||
}
|
||||
|
||||
# Schreibt die Proxmox-Paketquelle im deb822-Format.
|
||||
#
|
||||
# `Signed-By` begrenzt den Schlüssel auf GENAU diese Quelle. Ein Schlüssel in
|
||||
# /etc/apt/trusted.gpg.d/ bürgt für JEDE Quelle der Maschine — der
|
||||
# Proxmox-Schlüssel vouchte dann auch für alles, was später in `sources.list`
|
||||
# auftaucht. Genau davor warnt apt seit mehreren Ausgaben.
|
||||
write_proxmox_sources() {
|
||||
_suite="$1"
|
||||
_keyring_path="$2"
|
||||
_file="$3"
|
||||
|
||||
cat > "$_file" <<EOF
|
||||
# Written by CluPilot's bootstrap script.
|
||||
# The suite is derived from /etc/os-release on this host, not assumed.
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${_suite}
|
||||
Components: pve-no-subscription
|
||||
Architectures: amd64
|
||||
Signed-By: ${_keyring_path}
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Was diese Maschine gerade ist: Bootmodus, Netz, Platten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
detect_boot_mode() {
|
||||
if [ -d /sys/firmware/efi ]; then
|
||||
printf 'uefi'
|
||||
else
|
||||
printf 'bios'
|
||||
fi
|
||||
}
|
||||
|
||||
# Die Schnittstelle, über die die Vorgabe-Route geht. Nicht „die erste, die
|
||||
# nicht lo heißt": eine Maschine mit zwei Karten hat oft eine angeschlossene und
|
||||
# eine nicht.
|
||||
detect_primary_interface() {
|
||||
ip -4 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); exit } }'
|
||||
}
|
||||
|
||||
# Setzt CLUPILOT_NET_CIDR, _GATEWAY, _DNS, _MAC aus dem, was das Rettungssystem
|
||||
# gerade benutzt.
|
||||
#
|
||||
# Diese Werte gehen in die Antwortdatei, weil auf einer dedizierten Maschine
|
||||
# kein DHCP antwortet. Was hier herauskommt, ist die Konfiguration des
|
||||
# INSTALLIERTEN Systems — Task 5 baut sie danach zur Brücke um, und genau dort
|
||||
# unterscheiden sich die Anbieter.
|
||||
detect_network() {
|
||||
_iface="$(detect_primary_interface)"
|
||||
if [ -z "$_iface" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
CLUPILOT_NET_IFACE="$_iface"
|
||||
CLUPILOT_NET_CIDR="$(ip -4 -o addr show dev "$_iface" scope global 2>/dev/null | awk '{ print $4; exit }')"
|
||||
CLUPILOT_NET_GATEWAY="$(ip -4 route show default 2>/dev/null | awk '{ print $3; exit }')"
|
||||
CLUPILOT_NET_MAC="$(cat "/sys/class/net/${_iface}/address" 2>/dev/null | tr -d ':')"
|
||||
CLUPILOT_NET_DNS="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)"
|
||||
|
||||
# Ohne Namensauflösung im installierten System käme es nie an die
|
||||
# Paketquellen. Ein öffentlicher Rückfall ist besser als ein leeres Feld,
|
||||
# das der Installer stillschweigend übergeht.
|
||||
[ -n "$CLUPILOT_NET_DNS" ] || CLUPILOT_NET_DNS='9.9.9.9'
|
||||
|
||||
[ -n "$CLUPILOT_NET_CIDR" ] && [ -n "$CLUPILOT_NET_GATEWAY" ] && [ -n "$CLUPILOT_NET_MAC" ]
|
||||
}
|
||||
|
||||
# Alle physischen Platten, kleinste zuerst nicht — Reihenfolge egal, Anzahl
|
||||
# zählt. Wechseldatenträger, Schleifen und RAM-Platten fliegen raus.
|
||||
detect_install_disks() {
|
||||
_disks=''
|
||||
for _dev in /sys/block/*; do
|
||||
_name="${_dev##*/}"
|
||||
case "$_name" in
|
||||
loop*|ram*|sr*|dm-*|md*|zram*|fd*) continue ;;
|
||||
esac
|
||||
[ -r "${_dev}/size" ] || continue
|
||||
[ "$(cat "${_dev}/size" 2>/dev/null || echo 0)" -gt 0 ] || continue
|
||||
# Wechseldatenträger: ein eingesteckter USB-Stick ist keine Systemplatte.
|
||||
[ "$(cat "${_dev}/removable" 2>/dev/null || echo 0)" = '0' ] || continue
|
||||
_disks="${_disks}${_disks:+ }${_name}"
|
||||
done
|
||||
|
||||
printf '%s' "$_disks"
|
||||
}
|
||||
|
||||
# ZFS-Aufstellung nach Plattenzahl.
|
||||
#
|
||||
# Eine Platte: raid0, weil es nichts zu spiegeln gibt — und weil ein Host mit
|
||||
# einer Platte ohnehin ein Ausfall pro Platte ist. Zwei: raid1. Vier oder mehr,
|
||||
# gerade: raid10. Drei oder eine ungerade Zahl über vier: raid1 über die ersten
|
||||
# zwei, und der Rest bleibt liegen, statt eine Aufstellung zu raten, die
|
||||
# hinterher niemand erwartet hat.
|
||||
zfs_raid_for() {
|
||||
case "$1" in
|
||||
1) printf 'raid0' ;;
|
||||
2) printf 'raid1' ;;
|
||||
4|6|8|10) printf 'raid10' ;;
|
||||
*) printf 'raid1' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Werkzeug und ISO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# `proxmox-auto-install-assistant` ins RETTUNGSSYSTEM.
|
||||
#
|
||||
# Die Quelle richtet sich nach dem Codename des Rettungssystems, nicht nach dem
|
||||
# des Ziels: das Werkzeug läuft hier, nicht dort. Unbekannter Codename bricht ab
|
||||
# — nicht „trixie ist das neueste, also trixie". Das nächste Debian ist auch
|
||||
# unbekannt, und dann sieht niemand hin.
|
||||
ensure_autoinstall_assistant() {
|
||||
if command -v proxmox-auto-install-assistant >/dev/null 2>&1; then
|
||||
log 'proxmox-auto-install-assistant ist vorhanden'
|
||||
return 0
|
||||
fi
|
||||
|
||||
_id="$(os_release_field id)"
|
||||
_codename="$(os_release_field codename)"
|
||||
|
||||
if [ "$_id" != 'debian' ] || ! _release="$(proxmox_release_for "$_codename")"; then
|
||||
log "Rettungssystem meldet ID=${_id:-(leer)}, VERSION_CODENAME=${_codename:-(leer)} ($(os_release_field pretty))"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_suite="${_release%%|*}"
|
||||
_rest="${_release#*|}"
|
||||
_key_url="${_rest%%|*}"
|
||||
_keyring="${_rest##*|}"
|
||||
_keyring_path="/etc/apt/keyrings/${_keyring}"
|
||||
|
||||
mkdir -p /etc/apt/keyrings
|
||||
if ! http_download "$_key_url" "$_keyring_path"; then
|
||||
log "Proxmox-Release-Schlüssel für ${_codename} nicht ladbar: ${_key_url}"
|
||||
return 1
|
||||
fi
|
||||
chmod 644 "$_keyring_path"
|
||||
|
||||
write_proxmox_sources "$_suite" "$_keyring_path" '/etc/apt/sources.list.d/pve-install-repo.sources'
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update >/dev/null 2>&1 || return 1
|
||||
apt-get -y install proxmox-auto-install-assistant >/dev/null 2>&1 || return 1
|
||||
|
||||
command -v proxmox-auto-install-assistant >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Neueste PVE-9-ISO aus der Liste des Anbieters.
|
||||
#
|
||||
# Nicht fest verdrahtet: die Liste gehört Proxmox und wächst. Ein fester Name
|
||||
# wäre in ein paar Monaten eine 404 mitten in einer Übernahme.
|
||||
discover_pve_iso() {
|
||||
_listing="$(http_get "${CLUPILOT_ISO_MIRROR}/" || true)"
|
||||
[ -n "$_listing" ] || return 1
|
||||
|
||||
printf '%s' "$_listing" \
|
||||
| grep -oE 'proxmox-ve_9[0-9.\-]*\.iso' \
|
||||
| sort -u -V \
|
||||
| tail -1
|
||||
}
|
||||
|
||||
# Lädt die ISO und prüft ihre SHA256 gegen die Liste des Anbieters.
|
||||
#
|
||||
# Ohne diese Prüfung startet die Maschine ein Betriebssystem, das unterwegs
|
||||
# irgendetwas geworden sein kann. Eine abgebrochene Übertragung reicht dafür
|
||||
# schon; Bösartigkeit braucht es gar nicht.
|
||||
fetch_proxmox_iso() {
|
||||
_name="$1"
|
||||
_target="$2"
|
||||
|
||||
log "lade ${_name}"
|
||||
if ! http_download "${CLUPILOT_ISO_MIRROR}/${_name}" "$_target"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
_sums="$(http_get "${CLUPILOT_ISO_MIRROR}/SHA256SUMS" || true)"
|
||||
if [ -z "$_sums" ]; then
|
||||
log 'SHA256SUMS nicht abrufbar'
|
||||
return 1
|
||||
fi
|
||||
|
||||
_expected="$(printf '%s\n' "$_sums" | awk -v n="$_name" '$2 == n || $2 == "*" n { print $1; exit }')"
|
||||
if [ -z "$_expected" ]; then
|
||||
log "keine Prüfsumme für ${_name} in SHA256SUMS"
|
||||
return 1
|
||||
fi
|
||||
|
||||
_actual="$(sha256sum "$_target" | awk '{ print $1 }')"
|
||||
if [ "$_actual" != "$_expected" ]; then
|
||||
log "Prüfsumme stimmt nicht: erwartet ${_expected}, bekommen ${_actual}"
|
||||
rm -f "$_target"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Prüfsumme stimmt (${_expected})"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Wiederanlauf nach dem Neustart
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `[first-boot]` mit `source = "from-iso"` nimmt GENAU EINE ausführbare Datei
|
||||
# mit. Also trägt diese eine Datei alles: das Skript samt `lib/`, die
|
||||
# Fortschrittsdatei und die Aufrufargumente — als Archiv in base64 im eigenen
|
||||
# Rumpf.
|
||||
#
|
||||
# Das löst zugleich die Falle, an der „dieselbe Fortschrittsdatei" sonst
|
||||
# scheitert: das Rettungssystem läuft im Arbeitsspeicher, sein Wurzelverzeichnis
|
||||
# ist nach dem Neustart weg. Über die ISO kommt der Stand mit, ohne dass jemand
|
||||
# den frisch angelegten ZFS-Pool aus dem Rettungssystem heraus einhängen müsste.
|
||||
#
|
||||
# Der WireGuard-Schlüssel liegt damit in der ISO. Die ISO liegt im
|
||||
# Arbeitsspeicher des Rettungssystems und ist nach dem Neustart fort; der
|
||||
# Schlüssel selbst wird in Task 9 ohnehin gegen einen frischen getauscht. Die
|
||||
# entpackten Argumente bekommen 0600 in einem 0700-Verzeichnis.
|
||||
build_first_boot_shim() {
|
||||
_shim="$1"
|
||||
_payload="${CLUPILOT_WORK_DIR}/payload.tar.gz"
|
||||
|
||||
# Argumente für den wiederaufgenommenen Lauf festhalten.
|
||||
_args_file="${CLUPILOT_STATE_DIR}/arguments"
|
||||
umask 077
|
||||
cat > "$_args_file" <<EOF
|
||||
CLUPILOT_CODE='${CLUPILOT_CODE}'
|
||||
CLUPILOT_WG_PRIVATE='${CLUPILOT_WG_PRIVATE}'
|
||||
CLUPILOT_WG_IP='${CLUPILOT_WG_IP}'
|
||||
CLUPILOT_HUB_PUBKEY='${CLUPILOT_HUB_PUBKEY}'
|
||||
CLUPILOT_HUB_ENDPOINT='${CLUPILOT_HUB_ENDPOINT}'
|
||||
CLUPILOT_API='${CLUPILOT_API}'
|
||||
EOF
|
||||
chmod 600 "$_args_file"
|
||||
|
||||
# Laut statt still: läge das Arbeitsverzeichnis im Zustandsverzeichnis,
|
||||
# kopierte der nächste Schritt ein Verzeichnis in sich selbst und die ISO
|
||||
# landete im Hook. Beides wäre erst auf echter Hardware aufgefallen.
|
||||
case "$CLUPILOT_WORK_DIR" in
|
||||
"$CLUPILOT_STATE_DIR"|"$CLUPILOT_STATE_DIR"/*)
|
||||
log "Arbeitsverzeichnis ${CLUPILOT_WORK_DIR} liegt im Zustandsverzeichnis ${CLUPILOT_STATE_DIR} — das darf nicht sein"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Über ein Staging-Verzeichnis, damit im Archiv die ZIELpfade stehen. Ein
|
||||
# `tar -C <elternteil> <name>` schriebe `bootstrap/…` und `clupilot/…` hinein
|
||||
# — ausgepackt nach `/` ergäbe das `/bootstrap` und `/clupilot`, und der Hook
|
||||
# liefe ins Leere.
|
||||
_stage="${CLUPILOT_WORK_DIR}/stage"
|
||||
rm -rf "$_stage"
|
||||
mkdir -p "${_stage}/opt/clupilot" "${_stage}/var/lib"
|
||||
cp -a "$CLUPILOT_BOOTSTRAP_DIR" "${_stage}/opt/clupilot/bootstrap"
|
||||
cp -a "$CLUPILOT_STATE_DIR" "${_stage}/var/lib/clupilot"
|
||||
|
||||
tar czf "$_payload" -C "$_stage" opt var
|
||||
rm -rf "$_stage"
|
||||
|
||||
cat > "$_shim" <<'HEADER'
|
||||
#!/bin/sh
|
||||
# Von CluPilots Bootstrap erzeugt. Läuft einmal, beim ersten Hochfahren des
|
||||
# frisch installierten Hosts, und nimmt den Lauf dort wieder auf, wo der
|
||||
# Neustart ihn unterbrochen hat.
|
||||
set -eu
|
||||
|
||||
mkdir -p /opt/clupilot /var/lib
|
||||
sed -n '/^__NUTZLAST__$/,$p' "$0" | tail -n +2 | base64 -d | tar xzf - -C /
|
||||
chmod 700 /var/lib/clupilot
|
||||
[ -f /var/lib/clupilot/arguments ] && chmod 600 /var/lib/clupilot/arguments
|
||||
|
||||
exec /opt/clupilot/bootstrap/clupilot-bootstrap.sh --resume
|
||||
HEADER
|
||||
|
||||
printf '__NUTZLAST__\n' >> "$_shim"
|
||||
base64 "$_payload" >> "$_shim"
|
||||
chmod 755 "$_shim"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Die Antwortdatei
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
write_answer_file() {
|
||||
_file="$1"
|
||||
_disks="$2"
|
||||
_raid="$3"
|
||||
|
||||
# Als TOML-Liste: ["nvme0n1", "nvme1n1"]
|
||||
_disk_list="$(printf '%s' "$_disks" | awk '{ for (i = 1; i <= NF; i++) printf "%s\"%s\"", (i > 1 ? ", " : ""), $i }')"
|
||||
|
||||
# Wird erzeugt und weggeworfen. Niemand braucht es: die Plattform meldet
|
||||
# sich nicht mehr per SSH an (Spec §6), und eine halb installierte Maschine
|
||||
# wird neu aufgesetzt statt nachgebessert. Ein aufbewahrtes Kennwort wäre
|
||||
# nur eine weitere Stelle, an der ein Geheimnis liegt.
|
||||
_root_password="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
|
||||
_ssh_keys=''
|
||||
if [ -n "${CLUPILOT_ROOT_SSH_KEY:-}" ]; then
|
||||
_ssh_keys="root-ssh-keys = [\"${CLUPILOT_ROOT_SSH_KEY}\"]"
|
||||
fi
|
||||
|
||||
cat > "$_file" <<EOF
|
||||
# Von CluPilots Bootstrap erzeugt. Unbeaufsichtigte Installation nach
|
||||
# https://pve.proxmox.com/wiki/Automated_Installation
|
||||
|
||||
[global]
|
||||
keyboard = "de"
|
||||
country = "de"
|
||||
fqdn = "${CLUPILOT_FQDN}"
|
||||
mailto = "root@localhost"
|
||||
timezone = "Europe/Vienna"
|
||||
root-password = "${_root_password}"
|
||||
${_ssh_keys}
|
||||
reboot-on-error = false
|
||||
|
||||
[network]
|
||||
source = "from-answer"
|
||||
cidr = "${CLUPILOT_NET_CIDR}"
|
||||
gateway = "${CLUPILOT_NET_GATEWAY}"
|
||||
dns = "${CLUPILOT_NET_DNS}"
|
||||
filter.ID_NET_NAME_MAC = "*${CLUPILOT_NET_MAC}"
|
||||
|
||||
[disk-setup]
|
||||
filesystem = "zfs"
|
||||
disk-list = [${_disk_list}]
|
||||
|
||||
[disk-setup.zfs]
|
||||
raid = "${_raid}"
|
||||
# Die Vorgabe nimmt sich die Hälfte des Arbeitsspeichers. Den braucht ein Host,
|
||||
# der Kunden trägt, für die Gäste.
|
||||
arc-max = ${CLUPILOT_ZFS_ARC_MAX_MB}
|
||||
|
||||
[first-boot]
|
||||
source = "from-iso"
|
||||
# Nach dem Hochfahren, wenn das Netz steht — vorher könnte der Lauf CluPilot
|
||||
# nicht erreichen und bliebe an seinem eigenen Melden hängen.
|
||||
ordering = "fully-up"
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Der Lauf unter QEMU
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Ein Viertel des Arbeitsspeichers für den Installer, mindestens 2 GB, höchstens
|
||||
# 8. Mehr bringt nichts, weniger lässt den Installer bei ZFS ins Straucheln
|
||||
# kommen.
|
||||
installer_memory_mb() {
|
||||
_total_kb="$(awk '/^MemTotal:/ { print $2; exit }' /proc/meminfo)"
|
||||
_mb=$((_total_kb / 1024 / 4))
|
||||
[ "$_mb" -lt 2048 ] && _mb=2048
|
||||
[ "$_mb" -gt 8192 ] && _mb=8192
|
||||
printf '%s' "$_mb"
|
||||
}
|
||||
|
||||
installer_cpus() {
|
||||
_cpus="$(nproc 2>/dev/null || echo 2)"
|
||||
[ "$_cpus" -gt 8 ] && _cpus=8
|
||||
[ "$_cpus" -lt 1 ] && _cpus=1
|
||||
printf '%s' "$_cpus"
|
||||
}
|
||||
|
||||
# Startet die vorbereitete ISO gegen die echten Platten.
|
||||
#
|
||||
# `-no-reboot`: der Installer startet am Ende neu, und QEMU beendet sich dabei.
|
||||
# Genau daran erkennt dieses Skript, dass er fertig ist — ohne im Gast
|
||||
# nachzusehen, was von außen nicht ginge.
|
||||
#
|
||||
# `-vnc 127.0.0.1:0` ist der Notausgang für den Fehlerfall: bleibt der Installer
|
||||
# doch einmal in einer Maske stehen, kann der Betreiber über einen SSH-Tunnel ins
|
||||
# Rettungssystem nachsehen, was auf dem Bildschirm steht. Nur auf localhost,
|
||||
# damit daraus kein offener Dienst wird.
|
||||
run_installer_under_qemu() {
|
||||
_iso="$1"
|
||||
_disks="$2"
|
||||
_boot_mode="$3"
|
||||
|
||||
_drives=''
|
||||
for _disk in $_disks; do
|
||||
_drives="${_drives} -drive file=/dev/${_disk},format=raw,media=disk,if=virtio,cache=none"
|
||||
done
|
||||
|
||||
_firmware=''
|
||||
if [ "$_boot_mode" = 'uefi' ]; then
|
||||
for _ovmf in /usr/share/OVMF/OVMF_CODE.fd /usr/share/ovmf/OVMF.fd /usr/share/qemu/OVMF.fd; do
|
||||
if [ -f "$_ovmf" ]; then
|
||||
_firmware="-bios ${_ovmf}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$_firmware" ]; then
|
||||
log 'UEFI-Maschine, aber keine OVMF-Firmware gefunden (Paket ovmf)'
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "QEMU startet: ${_boot_mode}, $(installer_memory_mb) MB, $(installer_cpus) Kerne, Platten: ${_disks}"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
timeout "$CLUPILOT_INSTALL_TIMEOUT" qemu-system-x86_64 \
|
||||
-enable-kvm \
|
||||
-cpu host \
|
||||
-smp "$(installer_cpus)" \
|
||||
-m "$(installer_memory_mb)" \
|
||||
${_firmware} \
|
||||
-cdrom "$_iso" \
|
||||
-boot d \
|
||||
${_drives} \
|
||||
-netdev user,id=net0 -device virtio-net-pci,netdev=net0 \
|
||||
-display none \
|
||||
-vnc 127.0.0.1:0 \
|
||||
-no-reboot \
|
||||
> "${CLUPILOT_WORK_DIR}/qemu.log" 2>&1
|
||||
|
||||
_rc=$?
|
||||
if [ "$_rc" -eq 124 ]; then
|
||||
log "QEMU nach ${CLUPILOT_INSTALL_TIMEOUT} s abgebrochen — der Installer ist nicht fertig geworden"
|
||||
return 1
|
||||
fi
|
||||
if [ "$_rc" -ne 0 ]; then
|
||||
log "QEMU endete mit ${_rc}; siehe ${CLUPILOT_WORK_DIR}/qemu.log"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
# shellcheck shell=sh
|
||||
#
|
||||
# Melden und Nachreichen. Diese Datei kennt als Einzige den Weg zu CluPilot.
|
||||
# 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:
|
||||
#
|
||||
|
|
@ -213,3 +219,46 @@ post_json() {
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
|---|---|
|
||||
| `deploy/bootstrap/clupilot-bootstrap.sh` | Das Skript. Ein Einstiegspunkt, Abschnitte als Funktionen. |
|
||||
| `deploy/bootstrap/lib/report.sh` | Melden und Nachreichen. Kennt als Einziges den Weg zu CluPilot. |
|
||||
| `deploy/bootstrap/lib/proxmox.sh` | Abschnitte 4 und 5 (Installation, Brücke) |
|
||||
| `deploy/bootstrap/lib/proxmox.sh` | Die Installation (`debian_installed`, `rebooted`) und die Paketquellen (`proxmox_installed`) |
|
||||
| `deploy/bootstrap/lib/network.sh` | Brücke mit Selbstrücknahme, nftables |
|
||||
| `deploy/bootstrap/lib/template.sh` | Die goldene Vorlage |
|
||||
| `deploy/bootstrap/assets/docker-compose.yml` | Die Compose-Datei, die in die Vorlage kommt |
|
||||
|
|
@ -135,7 +135,7 @@ genau hier seine einzige Gelegenheit, es nicht zu tun.
|
|||
|
||||
### Task 3: `debian_installed` und `rebooted`
|
||||
|
||||
- [ ] **Step 1: Schreiben**
|
||||
- [x] **Step 1: Schreiben**
|
||||
|
||||
> **Korrigiert gegenüber der ersten Fassung.** Dort stand „Debian 13 ins
|
||||
> Zielsystem", und danach hätte Task 4 daraus einen Proxmox-Host gemacht. Der
|
||||
|
|
@ -245,7 +245,7 @@ bewiesen, bevor neu gestartet wird, nicht danach gehofft.
|
|||
Neustart von allein weiterläuft. **Dazu: nach dem Hochfahren in
|
||||
`/var/lib/clupilot/progress.jsonl` nachsehen, dass die Zeilen von *vor* dem
|
||||
Neustart noch dort stehen, mit ihren ursprünglichen Zeitstempeln.**
|
||||
- [ ] **Step 3: Committen.** `Write the image and survive the first reboot`
|
||||
- [x] **Step 3: Committen.** `Write the image and survive the first reboot`
|
||||
(war `Write Debian and survive the first reboot`, bevor klar war, dass kein
|
||||
Debian-Zwischensystem entsteht)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue