feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war.

deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert
nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen
im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie
ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und
die verschluesselten Bind-Passwoerter waeren unlesbar.

deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin
auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die
Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist
#024.

Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle
behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest
localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in
keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein
shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen
Port.

Closes #19
This commit is contained in:
Florian Wilde
2026-09-26 15:53:01 +02:00
parent fbbb634e56
commit bf1e52cf73
20 changed files with 3663 additions and 103 deletions
+963
View File
@@ -0,0 +1,963 @@
#!/usr/bin/env bash
#
# Laufzettel in einen frischen LXC auf Proxmox VE ausrollen.
#
# Aufruf auf der Proxmox-Shell als root:
#
# bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)"
#
# Dieses Skript legt den Container an, startet ihn und ruft darin deploy/install.sh
# auf. Es installiert selbst nichts: die gesamte Installationslogik steht in
# install.sh, damit der native Weg (Debian ohne Proxmox) und der LXC-Weg nicht
# auseinanderlaufen (#019).
#
# Trockenlauf ohne Proxmox-Host: bash deploy/proxmox/laufzettel.sh --dry-run
#
set -Eeuo pipefail
# ---------------------------------------------------------------------------
# Feste Werte
# ---------------------------------------------------------------------------
readonly SKRIPTNAME="laufzettel.sh"
readonly REPO_ROH="https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch"
readonly REPO_GIT="https://git.wilde-server.de/florian.wilde/Laufzettel.git"
readonly ENV_DATEI="/etc/laufzettel/laufzettel.env"
# Leihgabe von install.sh, kein Aufbewahrungsort: dieses Skript liest das
# Erstpasswort daraus und löscht die Datei sofort wieder. Siehe abschluss_zeigen().
readonly PASSWORT_DATEI="/root/laufzettel-erstpasswort"
# Deutsche Anführungszeichen als Variablen: in doppelten Anführungszeichen
# hält shellcheck sie für einen Tippfehler (SC1111), als Literal in einfachen
# ist es eindeutig. Lesbar bleibt der Text trotzdem richtig.
readonly ANF_AUF='„'
readonly ANF_ZU='“'
# Vorgaben des Standardwegs. Der Erweitert-Dialog nimmt sie als Vorbelegung.
readonly STD_HOSTNAME="laufzettel"
readonly STD_DEBIAN="13"
readonly STD_KERNE="2"
readonly STD_RAM="2048"
readonly STD_PLATTE="8"
readonly STD_SWAP="512"
readonly STD_BRUECKE="vmbr0"
readonly STD_IP="dhcp"
readonly STD_UNPRIV="1"
# Laufzeitwerte, aus dem Dialog oder aus den Vorgaben.
CTID=""
HOSTNAME_CT="$STD_HOSTNAME"
DEBIAN="$STD_DEBIAN"
KERNE="$STD_KERNE"
RAM="$STD_RAM"
PLATTE="$STD_PLATTE"
SWAP="$STD_SWAP"
BRUECKE="$STD_BRUECKE"
IP_WUNSCH="$STD_IP"
GATEWAY=""
UNPRIV="$STD_UNPRIV"
SPEICHER_ROOT=""
SPEICHER_VORLAGE=""
VORLAGE=""
CT_IP=""
TROCKEN="nein"
MODUS="frage" # frage | standard | erweitert
NUR_AKTUALISIEREN="nein"
ZWEIG="main"
# Steht auf ja, sobald "pct create" gelaufen ist, und wieder auf nein, sobald
# der Container fertig eingerichtet ist. Nur dazwischen darf das Aufräumen ihn
# wieder entfernen.
HALB_ANGELEGT="nein"
# Ergebnis der letzten Leseoperation, siehe pve_lesen().
ANTWORT=""
# Nummer des laufenden Abschnitts. Kommt aus der Position, wird nie gespeichert.
SCHRITT=0
# ---------------------------------------------------------------------------
# Ausgabe
# ---------------------------------------------------------------------------
# Farbe nur, wenn die Ausgabe wirklich an ein Terminal geht — sonst landen die
# Steuerzeichen in Protokollen und Pipes.
if [ -t 1 ]; then
F_AUS=$'\033[0m'
F_FETT=$'\033[1m'
F_GRAU=$'\033[90m'
F_LIME=$'\033[38;5;190m'
F_ROT=$'\033[31m'
F_GELB=$'\033[33m'
else
F_AUS=""
F_FETT=""
F_GRAU=""
F_LIME=""
F_ROT=""
F_GELB=""
fi
kopf() {
SCHRITT=$((SCHRITT + 1))
printf '\n%s%02d · %s%s\n' "$F_FETT" "$SCHRITT" "$1" "$F_AUS"
printf '%s%s%s\n' "$F_GRAU" "------------------------------------------------------------" "$F_AUS"
}
info() { printf ' %s\n' "$1"; }
notiz() { printf ' %s%s%s\n' "$F_GRAU" "$1" "$F_AUS"; }
erfolg() { printf ' %sok%s %s\n' "$F_LIME" "$F_AUS" "$1"; }
warnung() { printf ' %sAchtung%s %s\n' "$F_GELB" "$F_AUS" "$1" >&2; }
fehlertext() { printf ' %sFehler%s %s\n' "$F_ROT" "$F_AUS" "$1" >&2; }
# Bricht mit einem Satz ab, der sagt, was fehlt. Der ERR-Trap wird vorher
# abgeräumt, damit ein absichtlicher Abbruch nicht als Programmfehler mit
# Zeilennummer hinterhergemeldet wird.
abbrechen() {
trap - ERR
fehlertext "$1"
exit 1
}
# Wie abbrechen(), lässt den Container aber ausdrücklich stehen. Für alles, was
# nach dem Start schiefgeht: dort steckt meist ein Netzfehler, den man sich
# ansehen will, und die Meldung verspricht, dass der Container noch da ist.
abbrechen_und_behalten() {
HALB_ANGELEGT="nein"
abbrechen "$1"
}
# ---------------------------------------------------------------------------
# Spinner
# ---------------------------------------------------------------------------
SPINNER_PID=""
# Zeigt einen Spinner, solange ein langer Schritt läuft. Ohne Terminal (Pipe,
# Protokoll, Trockenlauf) bleibt es bei einer Zeile Text, damit die Ausgabe
# lesbar bleibt.
spinner_an() {
if [ ! -t 1 ] || [ "$TROCKEN" = "ja" ]; then
info "$1 …"
return 0
fi
local text="$1"
(
# Der Unterprozess setzt eigene Traps, damit ein Abbruch im Hauptprozess
# nicht erst durch die Fehlerbehandlung des Spinners läuft.
trap 'exit 0' TERM INT
# Der Backslash steht als $'\\', damit er nicht wie ein missglückter
# Maskierungsversuch aussieht (shellcheck SC1003).
local zeichen=('|' '/' '-' $'\\')
local i=0
while :; do
i=$(((i + 1) % ${#zeichen[@]}))
printf '\r %s%s%s %s' "$F_LIME" "${zeichen[i]}" "$F_AUS" "$text"
sleep 0.2
done
) &
SPINNER_PID=$!
}
spinner_aus() {
if [ -n "$SPINNER_PID" ]; then
kill "$SPINNER_PID" 2>/dev/null || true
wait "$SPINNER_PID" 2>/dev/null || true
SPINNER_PID=""
printf '\r\033[2K'
fi
}
# ---------------------------------------------------------------------------
# Abbruch und Aufräumen
# ---------------------------------------------------------------------------
# ERR-Trap mit Zeilennummer: bei einem Fehler soll niemand raten müssen,
# welcher der vielen pct-Aufrufe es war.
bei_fehler() {
local status="$1" zeile="$2" befehl="$3"
# Nur die erste Meldung zählt: set -e lässt den Fehler aus der Funktion
# heraus noch einmal aufschlagen, und der zweite Aufschlag kennt die
# Zeilennummer nicht mehr.
trap - ERR
spinner_aus
fehlertext "Zeile $zeile, Status $status: $befehl"
}
# Läuft in jedem Fall. Räumt nur weg, was dieser Lauf selbst angelegt hat, und
# nur solange im Container noch nichts steckt, was jemand ansehen wollen könnte:
# sobald install.sh angelaufen ist, bleibt der Container stehen und wird
# ausdrücklich benannt, statt still verschwunden zu sein.
aufraeumen() {
local status=$?
spinner_aus
if [ "$status" -ne 0 ] && [ "$HALB_ANGELEGT" = "ja" ] && [ -n "$CTID" ]; then
warnung "Container $CTID ist nur halb angelegt und wird wieder entfernt."
pve_ausfuehren pct stop "$CTID" --skiplock || true
pve_ausfuehren pct destroy "$CTID" --force --purge || true
fi
return "$status"
}
trap 'bei_fehler "$?" "$LINENO" "$BASH_COMMAND"' ERR
trap aufraeumen EXIT
trap 'fehlertext "Durch Signal abgebrochen."; exit 130' INT TERM
# ---------------------------------------------------------------------------
# Proxmox-Aufrufe
# ---------------------------------------------------------------------------
# Setzt ein Argument so, dass man es von Hand nachtippen kann. Harmlose Zeichen
# bleiben nackt — "printf %q" würde jedes Komma in --net0 mit einem Backslash
# versehen, und der Aufruf wäre zwar richtig, aber nicht mehr zu lesen.
argument_zeigen() {
case "$1" in
"" | *[!A-Za-z0-9_@%+=:,./-]*)
printf "'%s'" "${1//\'/\'\\\'\'}"
;;
*)
printf '%s' "$1"
;;
esac
}
# Zeigt einen Befehl so, dass man ihn von Hand nachtippen kann.
trocken_zeigen() {
local teil ausgabe=""
for teil in "$@"; do
ausgabe="$ausgabe $(argument_zeigen "$teil")"
done
printf ' %s[trocken]%s%s\n' "$F_GELB" "$F_AUS" "$ausgabe"
}
# Zeigt einen Befehl, der als Text vorliegt, weil er in einer Pipeline steckt.
trocken_text() {
printf ' %s[trocken]%s %s\n' "$F_GELB" "$F_AUS" "$1"
}
# Jeder ändernde Aufruf (pct, pveam, pvesh) läuft hierüber. Im Trockenlauf wird
# er nur ausgegeben.
pve_ausfuehren() {
if [ "$TROCKEN" = "ja" ]; then
trocken_zeigen "$@"
return 0
fi
"$@"
}
# Lesende Aufrufe. Das Ergebnis steht danach in $ANTWORT und wird nicht per
# Kommandosubstitution zurückgegeben: im Trockenlauf sollen auch die
# Lesebefehle auf der Standardausgabe erscheinen und dürfen nicht im
# eingefangenen Ergebnis landen. Das erste Argument ist der Platzhalter, mit
# dem der Trockenlauf weiterrechnet — nur so läuft er ohne PVE-Host bis zum
# Abschlussbildschirm durch. Das zweite Argument ist der Befehl als Text: die
# Leser stecken in Pipelines und werden darum von kleinen Funktionen gekapselt,
# und deren Namen wollte im Trockenlauf niemand sehen.
pve_lesen() {
local platzhalter="$1" anzeige="$2"
shift 2
if [ "$TROCKEN" = "ja" ]; then
trocken_text "$anzeige"
ANTWORT="$platzhalter"
if [ -n "$platzhalter" ]; then
notiz "Im Trockenlauf angenommen: $platzhalter"
else
notiz "Im Trockenlauf angenommen: leer, also kein Treffer"
fi
return 0
fi
ANTWORT="$("$@")"
}
# ---------------------------------------------------------------------------
# Hilfe
# ---------------------------------------------------------------------------
hilfe_zeigen() {
cat <<'ENDE'
laufzettel.sh — rollt Laufzettel in einen frischen LXC auf Proxmox VE aus.
Aufruf auf der Proxmox-Shell als root:
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)"
Was es tut:
1. Prüft root und Proxmox VE 8 oder 9.
2. Fragt per whiptail Standard gegen Erweitert. Standard nimmt die nächste
freie CTID, Hostname laufzettel, Debian 13, 2 Kerne, 2 GB RAM, 8 GB Platte,
unprivilegiert, DHCP auf vmbr0. Ohne Terminal oder ohne whiptail gelten
die Standardwerte.
3. Holt die Debian-Vorlage per pveam, falls sie noch fehlt.
4. Legt den Container an (pct create), startet ihn, wartet auf das Netz.
5. Führt deploy/install.sh im Container aus: PostgreSQL, Node, Build,
Migration, Seed, systemd-Dienst.
6. Zeigt Adresse, Admin-Kennung und das erzeugte Passwort.
Ein zweiter Aufruf erkennt einen vorhandenen Container am Hostnamen und
bietet an, ihn zu aktualisieren, statt einen zweiten anzulegen.
Optionen:
--dry-run Gibt alle pct-, pveam- und pvesh-Aufrufe nur aus, legt
nichts an und braucht keinen Proxmox-Host. Auch die
Prüfungen auf root und PVE werden übersprungen.
--standard Ohne Dialog, alle Standardwerte nehmen.
--erweitert Direkt in den Erweitert-Dialog.
--aktualisieren Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es
keinen Container mit dem Hostnamen laufzettel gibt.
--zweig <name> Git-Zweig, aus dem install.sh geholt wird (Vorgabe: main).
--hilfe, --help Diese Hilfe.
Alles Weitere steht in docs/betrieb/.
ENDE
}
# ---------------------------------------------------------------------------
# Argumente
# ---------------------------------------------------------------------------
argumente_lesen() {
while [ $# -gt 0 ]; do
case "$1" in
--dry-run | --trockenlauf)
TROCKEN="ja"
;;
--standard)
MODUS="standard"
;;
--erweitert)
MODUS="erweitert"
;;
--aktualisieren)
NUR_AKTUALISIEREN="ja"
;;
--zweig)
[ $# -ge 2 ] || abbrechen "--zweig braucht einen Namen."
ZWEIG="$2"
shift
;;
--hilfe | --help | -h)
hilfe_zeigen
exit 0
;;
*)
trap - ERR
fehlertext "Unbekannte Option: $1"
printf '\n'
hilfe_zeigen
exit 2
;;
esac
shift
done
}
# ---------------------------------------------------------------------------
# Umgebung
# ---------------------------------------------------------------------------
umgebung_pruefen() {
kopf "Umgebung"
if [ "$TROCKEN" = "ja" ]; then
notiz "Trockenlauf: Prüfung auf root und auf Proxmox VE 8/9 übersprungen."
notiz "Es wird nichts angelegt, nichts geladen, nichts gestartet."
return 0
fi
[ "$(id -u)" -eq 0 ] || abbrechen "Dieses Skript muss als root auf der Proxmox-Shell laufen."
command -v pveversion >/dev/null 2>&1 || abbrechen \
"pveversion fehlt — das ist kein Proxmox-VE-Host. Für Debian ohne Proxmox gibt es deploy/install.sh."
command -v pct >/dev/null 2>&1 || abbrechen "pct fehlt — Proxmox VE ist unvollständig installiert."
command -v pveam >/dev/null 2>&1 || abbrechen "pveam fehlt — Proxmox VE ist unvollständig installiert."
command -v pvesm >/dev/null 2>&1 || abbrechen "pvesm fehlt — Proxmox VE ist unvollständig installiert."
# pveversion liefert zum Beispiel: pve-manager/8.4.1/2a5fa54a8503f96d
local voll haupt
voll="$(pveversion | sed -n 's#^pve-manager/\([0-9][0-9.]*\)/.*#\1#p' | head -n 1)"
haupt="${voll%%.*}"
case "$haupt" in
8 | 9)
erfolg "Proxmox VE $voll"
;;
"")
abbrechen "Die Version von Proxmox VE ist nicht erkennbar (pveversion sagt: $(pveversion | head -n 1))."
;;
*)
abbrechen "Proxmox VE $voll wird nicht unterstützt — dieses Skript ist für PVE 8 und 9 gebaut."
;;
esac
erfolg "Als root auf $(hostname)"
}
# ---------------------------------------------------------------------------
# Dialog
# ---------------------------------------------------------------------------
# Liest einen Wert per whiptail in die genannte Variable, deren bisheriger
# Inhalt die Vorgabe ist. Der Weg über eine Namensreferenz statt über eine
# Kommandosubstitution ist Absicht: bricht jemand den Dialog ab, muss das
# ganze Skript enden, und ein „exit“ in einer Kommandosubstitution beendet nur
# die Subshell — der Wert wäre leer und das Skript liefe weiter.
frage_wert() {
local -n ziel="$1"
local titel="$2" wert
if ! wert="$(whiptail --title "Laufzettel einrichten" \
--inputbox "$titel" 9 64 "$ziel" 3>&1 1>&2 2>&3)"; then
abbrechen "Abgebrochen. Es wurde nichts angelegt."
fi
[ -n "$wert" ] || wert="$ziel"
ziel="$wert"
}
zahl_pruefen() {
case "$2" in
'' | *[!0-9]*) abbrechen "$1 muss eine Zahl sein, war aber $ANF_AUF$2$ANF_ZU." ;;
esac
}
dialog_fuehren() {
kopf "Werte"
# Ohne Terminal oder ohne whiptail gibt es keinen Dialog. Dann gilt der
# Standardweg, und das wird ausdrücklich gesagt — auch wenn --erweitert
# verlangt war, denn fragen kann das Skript dann niemanden.
if [ "$MODUS" != "standard" ]; then
if [ ! -t 0 ]; then
MODUS="standard"
notiz "Kein Terminal an der Standardeingabe — es gelten die Standardwerte."
elif ! command -v whiptail >/dev/null 2>&1; then
MODUS="standard"
notiz "whiptail ist nicht installiert — es gelten die Standardwerte."
fi
fi
if [ "$MODUS" = "frage" ]; then
local wahl
if ! wahl="$(whiptail --title "Laufzettel einrichten" \
--menu "Wie soll der Container aussehen?" 13 68 2 \
"standard" "$STD_KERNE Kerne, $((STD_RAM / 1024)) GB RAM, $STD_PLATTE GB Platte, DHCP" \
"erweitert" "Jeden Wert selbst setzen" 3>&1 1>&2 2>&3)"; then
abbrechen "Abgebrochen. Es wurde nichts angelegt."
fi
MODUS="$wahl"
fi
# Die nächste freie CTID kommt immer aus dem Cluster, im Erweitert-Weg nur
# als Vorbelegung.
pve_lesen "100" "pvesh get /cluster/nextid" naechste_ctid_holen
CTID="$ANTWORT"
[ -n "$CTID" ] || abbrechen "pvesh get /cluster/nextid hat keine CTID geliefert."
if [ "$MODUS" = "erweitert" ]; then
frage_wert CTID "CTID des neuen Containers"
frage_wert HOSTNAME_CT "Hostname"
frage_wert DEBIAN "Debian-Hauptversion (13 ist erprobt)"
frage_wert KERNE "Kerne"
frage_wert RAM "Arbeitsspeicher in MB"
frage_wert SWAP "Swap in MB"
frage_wert PLATTE "Plattengröße in GB"
frage_wert BRUECKE "Netzwerkbrücke"
frage_wert IP_WUNSCH "IP-Adresse: dhcp oder zum Beispiel 192.168.1.50/24"
if [ "$IP_WUNSCH" != "dhcp" ]; then
frage_wert GATEWAY "Gateway zur festen IP-Adresse"
[ -n "$GATEWAY" ] || abbrechen "Zu einer festen IP-Adresse gehört ein Gateway."
fi
frage_wert SPEICHER_ROOT "Speicher für das Wurzeldateisystem"
frage_wert SPEICHER_VORLAGE "Speicher für die Debian-Vorlage"
if whiptail --title "Laufzettel einrichten" \
--yesno "Unprivilegierten Container anlegen?\n\nEmpfohlen ist ja." 10 60; then
UNPRIV="1"
else
UNPRIV="0"
fi
fi
zahl_pruefen "Die CTID" "$CTID"
zahl_pruefen "Die Zahl der Kerne" "$KERNE"
zahl_pruefen "Der Arbeitsspeicher" "$RAM"
zahl_pruefen "Die Plattengröße" "$PLATTE"
zahl_pruefen "Der Swap" "$SWAP"
zahl_pruefen "Die Debian-Version" "$DEBIAN"
[ "$CTID" -ge 100 ] || abbrechen "Die CTID muss mindestens 100 sein, war aber $CTID."
[ "$KERNE" -gt 0 ] || abbrechen "Der Container braucht mindestens einen Kern."
[ "$RAM" -ge 1024 ] || abbrechen "Unter 1024 MB reicht der Speicher für ${ANF_AUF}next build$ANF_ZU nicht."
[ "$PLATTE" -ge 8 ] || abbrechen "Unter 8 GB reicht die Platte für Node, pnpm-Store und PostgreSQL nicht."
[ -n "$SPEICHER_ROOT" ] || abbrechen "Ohne Speicher für das Wurzeldateisystem geht es nicht."
[ -n "$SPEICHER_VORLAGE" ] || abbrechen "Ohne Speicher für die Vorlage geht es nicht."
info "CTID $CTID · $HOSTNAME_CT · Debian $DEBIAN · $KERNE Kerne · $RAM MB RAM · $PLATTE GB Platte"
if [ "$UNPRIV" = "1" ]; then
info "unprivilegiert · $BRUECKE · $IP_WUNSCH"
else
info "privilegiert · $BRUECKE · $IP_WUNSCH"
fi
}
# Alle Leser enden auf „|| true“: ein leeres Ergebnis soll beim Aufrufer eine
# verständliche Meldung auslösen und nicht den ERR-Trap mit einer Zeilennummer,
# die niemandem weiterhilft. Ohne das würde schon ein grep ohne Treffer wegen
# pipefail als Programmfehler durchschlagen.
naechste_ctid_holen() {
# pvesh gibt die Zahl je nach Ausgabeformat in Anführungszeichen zurück.
pvesh get /cluster/nextid 2>/dev/null | tr -d '"[:space:]' || true
}
# ---------------------------------------------------------------------------
# Speicher
# ---------------------------------------------------------------------------
# Erster aktive Speicher, der den gewünschten Inhalt aufnehmen kann. Die
# Reihenfolge bevorzugt, was auf einem Standard-PVE üblich ist.
speicher_suchen() {
local inhalt="$1" liste bevorzugt
liste="$(pvesm status --content "$inhalt" 2>/dev/null |
awk 'NR > 1 && $3 == "active" { print $1 }' || true)"
for bevorzugt in local-lvm local-zfs local-btrfs local; do
if printf '%s\n' "$liste" | grep -qx -- "$bevorzugt"; then
printf '%s' "$bevorzugt"
return 0
fi
done
printf '%s\n' "$liste" | head -n 1 || true
}
speicher_waehlen() {
kopf "Speicher"
pve_lesen "local-lvm" "pvesm status --content rootdir" speicher_suchen rootdir
SPEICHER_ROOT="$ANTWORT"
[ -n "$SPEICHER_ROOT" ] || abbrechen \
"Kein aktiver Speicher mit Inhalt ${ANF_AUF}rootdir$ANF_ZU gefunden — ohne den kann kein Container angelegt werden."
pve_lesen "local" "pvesm status --content vztmpl" speicher_suchen vztmpl
SPEICHER_VORLAGE="$ANTWORT"
[ -n "$SPEICHER_VORLAGE" ] || abbrechen \
"Kein aktiver Speicher mit Inhalt ${ANF_AUF}vztmpl$ANF_ZU gefunden — dort müsste die Debian-Vorlage liegen."
erfolg "Wurzeldateisystem auf $SPEICHER_ROOT, Vorlage auf $SPEICHER_VORLAGE"
}
# ---------------------------------------------------------------------------
# Debian-Vorlage
# ---------------------------------------------------------------------------
# Neueste passende Vorlage aus der Liste von pveam. Der Dateiname ändert sich
# mit jedem Punktrelease (debian-13-standard_13.1-1_amd64.tar.zst), darum wird
# er gesucht und nicht festgeschrieben.
vorlage_suchen() {
pveam available --section system 2>/dev/null |
awk '{ print $2 }' |
grep -E "^debian-$1-standard_" |
sort -V |
tail -n 1 || true
}
vorlage_vorhanden() {
if pveam list "$1" 2>/dev/null | awk '{ print $1 }' | grep -qF -- "vztmpl/$2"; then
printf 'ja'
else
printf 'nein'
fi
}
vorlage_besorgen() {
kopf "Debian-Vorlage"
spinner_an "Vorlagenliste auffrischen"
pve_ausfuehren pveam update >/dev/null 2>&1 ||
warnung "pveam update ist fehlgeschlagen, es gilt die zwischengespeicherte Liste."
spinner_aus
pve_lesen "debian-$DEBIAN-standard_$DEBIAN.1-1_amd64.tar.zst" \
"pveam available --section system" vorlage_suchen "$DEBIAN"
VORLAGE="$ANTWORT"
[ -n "$VORLAGE" ] || abbrechen \
"In ${ANF_AUF}pveam available$ANF_ZU steht keine Vorlage debian-$DEBIAN-standard. Andere Debian-Version wählen oder die Vorlage von Hand laden."
pve_lesen "nein" "pveam list $SPEICHER_VORLAGE" vorlage_vorhanden "$SPEICHER_VORLAGE" "$VORLAGE"
if [ "$ANTWORT" = "ja" ]; then
erfolg "Vorlage $VORLAGE liegt schon auf $SPEICHER_VORLAGE"
else
spinner_an "Vorlage $VORLAGE laden"
pve_ausfuehren pveam download "$SPEICHER_VORLAGE" "$VORLAGE"
spinner_aus
erfolg "Vorlage $VORLAGE geladen"
fi
}
# ---------------------------------------------------------------------------
# Container anlegen und starten
# ---------------------------------------------------------------------------
container_anlegen() {
kopf "Container anlegen"
# Ohne firewall=…: auf der Befehlszeile ist die Firewall am Gerät ohnehin aus,
# und der Aufruf soll genau das zeigen, was er tut.
local netz="name=eth0,bridge=$BRUECKE"
if [ "$IP_WUNSCH" = "dhcp" ]; then
netz="$netz,ip=dhcp"
else
netz="$netz,ip=$IP_WUNSCH,gw=$GATEWAY"
fi
# nesting=1 ist hier keine Bequemlichkeit: die systemd-Unit aus install.sh
# arbeitet mit ProtectSystem=strict und PrivateTmp=yes, und diese Sandkästen
# brauchen im unprivilegierten LXC eigene Mount-Namensräume (#019).
# Ab hier darf das Aufräumen den Container wieder entfernen.
HALB_ANGELEGT="ja"
spinner_an "Container $CTID anlegen"
pve_ausfuehren pct create "$CTID" "$SPEICHER_VORLAGE:vztmpl/$VORLAGE" \
--hostname "$HOSTNAME_CT" \
--ostype debian \
--arch amd64 \
--cores "$KERNE" \
--memory "$RAM" \
--swap "$SWAP" \
--rootfs "$SPEICHER_ROOT:$PLATTE" \
--net0 "$netz" \
--unprivileged "$UNPRIV" \
--features nesting=1 \
--onboot 1 \
--timezone host \
--description "Laufzettel — Checklisten für wiederkehrende IT-Arbeiten. Angelegt von deploy/proxmox/$SKRIPTNAME."
spinner_aus
erfolg "Container $CTID angelegt"
spinner_an "Container starten"
pve_ausfuehren pct start "$CTID"
spinner_aus
erfolg "Container $CTID gestartet"
}
# Wartet, bis im Container Befehle laufen und Namen aufgelöst werden. Ohne das
# scheitert das erste apt-get mit einem irritierenden DNS-Fehler.
netz_abwarten() {
kopf "Netz"
if [ "$TROCKEN" = "ja" ]; then
trocken_zeigen pct exec "$CTID" -- test -d /run/systemd/system
trocken_zeigen pct exec "$CTID" -- ip -4 -o addr show scope global
trocken_zeigen pct exec "$CTID" -- getent hosts deb.debian.org
CT_IP="192.0.2.42"
notiz "Im Trockenlauf wird nicht gewartet; als Adresse steht hier die Beispiel-IP $CT_IP."
return 0
fi
local versuch
spinner_an "Auf den Start des Containers warten"
for ((versuch = 1; versuch <= 60; versuch++)); do
if pct exec "$CTID" -- test -d /run/systemd/system >/dev/null 2>&1; then
break
fi
if [ "$versuch" -eq 60 ]; then
spinner_aus
abbrechen_und_behalten "Container $CTID ist nach 120 Sekunden nicht betriebsbereit. Er bleibt stehen, nachsehen mit: pct enter $CTID"
fi
sleep 2
done
spinner_aus
spinner_an "Auf IP-Adresse und Namensauflösung warten"
for ((versuch = 1; versuch <= 60; versuch++)); do
CT_IP="$(pct exec "$CTID" -- ip -4 -o addr show scope global 2>/dev/null |
awk 'NR == 1 { split($4, teile, "/"); print teile[1] }')"
if [ -n "$CT_IP" ] && pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then
break
fi
if [ "$versuch" -eq 60 ]; then
spinner_aus
abbrechen_und_behalten "Container $CTID hat nach 120 Sekunden kein brauchbares Netz. Brücke $BRUECKE und DHCP prüfen; er bleibt stehen, nachsehen mit: pct enter $CTID"
fi
sleep 2
done
spinner_aus
erfolg "Container $CTID erreichbar unter $CT_IP"
}
# ---------------------------------------------------------------------------
# Laufzettel einrichten
# ---------------------------------------------------------------------------
# install.sh wird als einzelne Datei per curl geholt, das Repo wird hier nicht
# geklont: install.sh legt den Quellbaum selbst nach /opt/laufzettel und braucht
# ihn dort auch für den Aktualisierungsweg (git pull). Ein zweiter Klon daneben
# wäre eine zweite Wahrheit. Geholt wird von derselben Adresse, von der auch
# dieses Skript kam — wer einen eigenen Spiegel nutzt, bekommt beide Teile aus
# demselben Spiegel.
anwendung_einrichten() {
local zweck="$1" # einrichten | aktualisieren
kopf "Laufzettel $zweck"
# Ab hier wird der Container nicht mehr weggeräumt: scheitert install.sh, will
# man das Protokoll darin lesen und nicht vor einem verschwundenen Container
# stehen. Jeder Fehler ab hier nennt darum die CTID.
HALB_ANGELEGT="nein"
local roh_url="$REPO_ROH/$ZWEIG/deploy/install.sh"
spinner_an "Paketliste und Grundwerkzeuge"
if ! pve_ausfuehren pct exec "$CTID" -- bash -c \
'export DEBIAN_FRONTEND=noninteractive; apt-get update -qq && apt-get install -y -qq curl git ca-certificates'; then
spinner_aus
abbrechen "apt-get ist im Container $CTID fehlgeschlagen. Nachsehen mit: pct enter $CTID"
fi
spinner_aus
erfolg "curl, git und ca-certificates sind vorhanden"
# Erst herunterladen, dann ausführen: „curl | bash“ würde einen abgebrochenen
# Download stillschweigend als halbes Skript starten.
spinner_an "install.sh holen"
if ! pve_ausfuehren pct exec "$CTID" -- bash -c \
"curl -fsSL --retry 3 -o /root/laufzettel-install.sh '$roh_url' && test -s /root/laufzettel-install.sh"; then
spinner_aus
abbrechen "install.sh war unter $roh_url nicht zu holen. Zweig richtig geschrieben? Kommt der Container an den Gitea-Server?"
fi
spinner_aus
erfolg "install.sh aus Zweig $ZWEIG geholt"
# APP_URL und BIND_ADDR stehen erst hier fest, weil erst jetzt die IP bekannt
# ist. An APP_URL hängen die QR-Codes und die OIDC-Rückrufadresse. Bei einer
# Aktualisierung werden beide bewusst nicht mitgegeben: wer einen Reverse
# Proxy davorgesetzt und APP_URL auf den richtigen Namen gestellt hat, soll
# den nicht beim nächsten Aufruf gegen eine IP-Adresse getauscht bekommen.
local umgebung="LAUFZETTEL_REPO='$REPO_GIT' LAUFZETTEL_ZWEIG='$ZWEIG'"
umgebung="$umgebung LAUFZETTEL_PASSWORT_DATEI='$PASSWORT_DATEI'"
if [ "$zweck" = "einrichten" ]; then
umgebung="APP_URL='http://$CT_IP:3000' BIND_ADDR=0.0.0.0 $umgebung"
fi
# Hier läuft ausdrücklich kein Spinner: install.sh meldet seine neun Schritte
# selbst, und das dauert Minuten. Ein Spinner würde diese Meldungen
# überschreiben und wäre das Gegenteil von Fortschrittsanzeige.
info "install.sh läuft: PostgreSQL, Node, Build, Migration, Seed. Das dauert einige Minuten."
notiz "Ab hier spricht install.sh selbst."
printf '\n'
if ! pve_ausfuehren pct exec "$CTID" -- bash -c "$umgebung bash /root/laufzettel-install.sh"; then
abbrechen "install.sh ist im Container $CTID fehlgeschlagen. Der Container bleibt stehen: pct enter $CTID, dann journalctl -u laufzettel -n 50"
fi
printf '\n'
erfolg "install.sh ist durchgelaufen"
}
# Fragt von außen nach, ob der Dienst antwortet. install.sh prüft dasselbe
# selbst, aber nur auf seiner Bindeadresse im Container — erreichbar aus dem Netz
# ist damit noch nichts bewiesen. Ein Befund bricht nicht ab: die Installation
# steht dann schon, und die Adresse gehört auf den Abschlussbildschirm.
gesundheit_pruefen() {
kopf "Erreichbarkeit"
local adresse="http://$CT_IP:3000/api/gesundheit"
if [ "$TROCKEN" = "ja" ]; then
trocken_text "curl -fsS $adresse"
notiz "Im Trockenlauf wird nicht gefragt."
return 0
fi
local versuch antwort=""
spinner_an "Auf Antwort von $adresse warten"
for ((versuch = 1; versuch <= 30; versuch++)); do
antwort="$(curl -fsS --max-time 5 "$adresse" 2>/dev/null || true)"
case "$antwort" in
*'"ok"'*) break ;;
esac
antwort=""
sleep 2
done
spinner_aus
if [ -n "$antwort" ]; then
erfolg "$adresse antwortet: $antwort"
else
warnung "$adresse antwortet nicht. Dienst: pct exec $CTID -- systemctl status laufzettel · Protokoll: pct exec $CTID -- journalctl -u laufzettel -n 50"
fi
}
# ---------------------------------------------------------------------------
# Abschluss
# ---------------------------------------------------------------------------
# Wie das Passwort hierher kommt: install.sh zeigt das beim ersten Lauf erzeugte
# Administrator-Passwort genau einmal auf seiner eigenen Ausgabe an — und die
# läuft hier hinter einem Spinner durch pct exec vorbei. In die Umgebungsdatei
# schreibt install.sh es ausdrücklich nicht, in der Datenbank steht nur der
# argon2-Hash. Für genau diesen Fall hat install.sh --passwort-datei
# beziehungsweise LAUFZETTEL_PASSWORT_DATEI: eine Datei mit 0600 und root, aus
# der ein maschineller Aufrufer das Passwort abliest und die er danach löscht.
# Das ist der Weg hier — lesen, einmal anzeigen, sofort wegwerfen. Die Ausgabe
# von install.sh mitzuschneiden und zu durchsuchen wäre von deren
# Formulierungen abhängig und würde das Passwort zusätzlich durch Pipes und
# Protokolldateien schleifen.
#
# Die Kennung dagegen steht in der Umgebungsdatei und wird von dort gelesen.
erstpasswort_lesen() {
pct exec "$CTID" -- sh -c \
"sed -n 's/^SEED_ADMIN_PASSWORD=//p' '$PASSWORT_DATEI' 2>/dev/null | tail -n 1" 2>/dev/null || true
}
env_wert_lesen() {
pct exec "$CTID" -- sh -c "sed -n 's/^$1=//p' '$ENV_DATEI' | tail -n 1" 2>/dev/null |
tr -d '\r' | sed -e 's/^"//' -e 's/"$//' || true
}
abschluss_zeigen() {
local kennung passwort text
kopf "Fertig"
pve_lesen "admin@firma.example" "pct exec $CTID -- grep SEED_ADMIN_EMAIL $ENV_DATEI" \
env_wert_lesen SEED_ADMIN_EMAIL
kennung="$ANTWORT"
[ -n "$kennung" ] || kennung="steht in $ENV_DATEI"
if [ "$TROCKEN" = "ja" ]; then
trocken_text "pct exec $CTID -- cat $PASSWORT_DATEI"
passwort="(im Trockenlauf gibt es keines)"
else
pve_lesen "" "pct exec $CTID -- cat $PASSWORT_DATEI" erstpasswort_lesen
passwort="$ANTWORT"
fi
# Die Leihgabe wird zurückgegeben, gelesen oder nicht: das Passwort soll nicht
# im Klartext im Container liegen bleiben.
pve_ausfuehren pct exec "$CTID" -- rm -f "$PASSWORT_DATEI" || true
if [ -z "$passwort" ]; then
passwort="unverändert (das Konto bestand schon)"
fi
text="$(
cat <<ENDE
Laufzettel läuft.
Adresse http://$CT_IP:3000
Admin-Kennung $kennung
Passwort $passwort
Das Passwort steht nur hier: in der Datenbank liegt allein der argon2-Hash,
in $ENV_DATEI steht es nicht. Nach der ersten
Anmeldung ändern. Verloren? Dann in der Anwendung ein neues setzen.
Reverse Proxy: der Dienst spricht nur HTTP auf Port 3000. Davor gehört ein
Proxy mit TLS, und APP_URL im Container muss auf den Namen zeigen, unter dem
Laufzettel dann erreichbar ist — sonst führen die QR-Codes und die
OIDC-Rückrufadresse ins Leere. Siehe docs/betrieb/reverse-proxy.md.
Weiter in docs/betrieb/: inbetriebnahme.md, proxmox.md, authentik.md und
sicherung.md (Sicherung per pg_dump über den Unix-Socket).
Shell im Container pct enter $CTID
Dienst ansehen pct exec $CTID -- systemctl status laufzettel
Gesundheitsprüfung curl http://$CT_IP:3000/api/gesundheit
Kennung nachsehen pct exec $CTID -- grep SEED_ADMIN_EMAIL $ENV_DATEI
ENDE
)"
if [ -t 1 ] && [ "$TROCKEN" != "ja" ] && command -v whiptail >/dev/null 2>&1; then
whiptail --title "Laufzettel — fertig" --msgbox "$text" 30 78
fi
printf '%s\n' "$text" | sed 's/^/ /'
printf '\n'
}
# ---------------------------------------------------------------------------
# Vorhandenen Container erkennen
# ---------------------------------------------------------------------------
container_status() {
pct status "$1" 2>/dev/null | awk '{ print $2 }' || true
}
# „pct list“ gibt VMID, Status, gegebenenfalls Lock und den Namen aus. Der Name
# ist die letzte Spalte, und Hostnamen enthalten keine Leerzeichen — darum $NF.
container_mit_hostname() {
pct list 2>/dev/null | awk -v gesucht="$1" 'NR > 1 && $NF == gesucht { print $1 }' |
head -n 1 || true
}
# Liefert 0, wenn der vorhandene Container aktualisiert werden soll.
aktualisierung_anbieten() {
local id="$1"
if [ "$NUR_AKTUALISIEREN" = "ja" ]; then
notiz "--aktualisieren war gesetzt — Container $id wird aktualisiert."
return 0
fi
if [ ! -t 0 ] || ! command -v whiptail >/dev/null 2>&1; then
notiz "Kein Dialog möglich — Container $id wird aktualisiert."
return 0
fi
whiptail --title "Laufzettel ist schon da" \
--yes-button "Aktualisieren" --no-button "Abbrechen" \
--yesno "Container $id trägt schon den Hostnamen $ANF_AUF$STD_HOSTNAME$ANF_ZU.
Aktualisieren heißt: install.sh erneut ausführen — git pull, Build, Migration, Dienst neu starten. Geheimnisse und Daten bleiben unberührt.
Einen zweiten Laufzettel legt dieses Skript nicht daneben; dafür im Erweitert-Weg einen anderen Hostnamen wählen." 17 72
}
# ---------------------------------------------------------------------------
# Ablauf
# ---------------------------------------------------------------------------
haupt() {
argumente_lesen "$@"
printf '\n%sLaufzettel — Proxmox-LXC%s\n' "$F_FETT" "$F_AUS"
if [ "$TROCKEN" = "ja" ]; then
printf '%sTrockenlauf: es wird nur gezeigt, was geschehen würde.%s\n' "$F_GELB" "$F_AUS"
fi
umgebung_pruefen
# Vor allem anderen: gibt es Laufzettel hier schon?
kopf "Vorhandener Container"
# Im Trockenlauf gibt es kein pct list. Mit --aktualisieren wird darum eine
# vorhandene CTID angenommen, sonst keine — so lässt sich jeder der beiden
# Wege trocken durchspielen.
local angenommen=""
[ "$NUR_AKTUALISIEREN" = "ja" ] && angenommen="100"
pve_lesen "$angenommen" "pct list" container_mit_hostname "$STD_HOSTNAME"
local vorhanden="$ANTWORT"
if [ -z "$vorhanden" ] && [ "$NUR_AKTUALISIEREN" = "ja" ]; then
abbrechen "Kein Container mit dem Hostnamen $STD_HOSTNAME — mit --aktualisieren wird keiner angelegt."
fi
if [ -z "$vorhanden" ]; then
erfolg "Kein Container mit dem Hostnamen $STD_HOSTNAME — es wird ein neuer angelegt."
speicher_waehlen
dialog_fuehren
vorlage_besorgen
container_anlegen
netz_abwarten
anwendung_einrichten "einrichten"
gesundheit_pruefen
abschluss_zeigen
return 0
fi
info "Container $vorhanden trägt den Hostnamen $STD_HOSTNAME."
if ! aktualisierung_anbieten "$vorhanden"; then
abbrechen "Abgebrochen. Es wurde nichts geändert."
fi
CTID="$vorhanden"
# Der Container kann gestoppt sein. Erst fragen, dann starten — „pct start“
# auf einem laufenden Container meldet nur einen Fehler, den niemand braucht.
pve_lesen "running" "pct status $CTID" container_status "$CTID"
if [ "$ANTWORT" = "running" ]; then
info "Container $CTID läuft."
else
spinner_an "Container $CTID starten"
pve_ausfuehren pct start "$CTID"
spinner_aus
fi
netz_abwarten
anwendung_einrichten "aktualisieren"
gesundheit_pruefen
abschluss_zeigen
}
haupt "$@"