diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index d9c2088..1490224 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.0.9" +version = "2.0.10" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/deploy/sudoers/tesm b/deploy/sudoers/tesm index d9fbff4..e3dea6a 100644 --- a/deploy/sudoers/tesm +++ b/deploy/sudoers/tesm @@ -5,5 +5,11 @@ # # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Problem, indem die gesamte Anwendung als root lief. -Defaults:tesm !requiretty +# +# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist +# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und +# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze +# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten +# Server brach die Installation deshalb mitten drin ab. Nicht wieder +# hinzufuegen; ein Hygienetest haelt das fest. tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper diff --git a/deploy/sudoers/tesm-license b/deploy/sudoers/tesm-license index 0f42b61..5732459 100644 --- a/deploy/sudoers/tesm-license +++ b/deploy/sudoers/tesm-license @@ -5,5 +5,11 @@ # # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Problem, indem die gesamte Anwendung als root lief. -Defaults:tesm-license !requiretty +# +# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist +# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und +# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze +# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten +# Server brach die Installation deshalb mitten drin ab. Nicht wieder +# hinzufuegen; ein Hygienetest haelt das fest. tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper diff --git a/deploy/upgrade.sh b/deploy/upgrade.sh index 552f96b..92fd894 100644 --- a/deploy/upgrade.sh +++ b/deploy/upgrade.sh @@ -52,6 +52,7 @@ WITH_DATA=1 STOP_OLD=1 ADOPT_KEY=1 TAG="latest" +ROLLBACK="" #: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der #: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein #: Hygienetest vergleicht sie. @@ -95,6 +96,10 @@ Verwendung: upgrade.sh --app [Optionen] Vorgabe ist deshalb die Uebernahme. --tag Bestimmte Version installieren (Vorgabe: latest). Wird nur beachtet, wenn das Release geladen wird. + --rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte + Installation aus der Sicherung wiederherstellen und + ihren Dienst wieder einschalten. Ohne Pfad wird die + neueste Sicherung dieses Namens genommen. --dry-run Nur zeigen, was geschehen wuerde --yes Keine Rueckfrage -h, --help Diese Hilfe @@ -122,6 +127,11 @@ while [[ $# -gt 0 ]]; do --new-key) ADOPT_KEY=0; shift ;; --adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit --tag) TAG="${2:?}"; shift 2 ;; + --rollback) + # Der Pfad ist optional: allein genommen heisst es "die neueste". + if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2 + else ROLLBACK="auto"; shift; fi + ;; --dry-run) DRY_RUN=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; -h|--help) usage; exit 0 ;; @@ -132,6 +142,144 @@ done [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; } [[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren" +NAME="$APP" +[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE" +ADMIN="$NAME-admin" +NEW_ROOT="/srv/$NAME" +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP="/srv/$NAME-upgrade-$STAMP" +ALT_KOPIE="$BACKUP/alt" +SYSTEM_KOPIE="$BACKUP/system" +EXPORT_ALT="$BACKUP/export-alt.json" +IMPORT_NEU="$BACKUP/import-neu.json" + +# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst +# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen. +# -- Rueckweg ---------------------------------------------------------------- # +# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem +# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung. +SYSTEMDATEIEN=( + "/etc/systemd/system/$NAME.service" + "/etc/systemd/system/$NAME-monitor.service" + "/etc/nginx/sites-available/$NAME" + "/etc/logrotate.d/$NAME" + "/etc/update-motd.d/50-$NAME" + "/etc/tesm/$NAME.env" +) + +zurueckrollen() { + local sicherung="$1" grund="${2:-}" + local alt="$sicherung/alt" system="$sicherung/system" + [[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts." + + # Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf + # abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen + # Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht + # IN_PLACE zwar fest, beim spaeteren --rollback aber nicht. + local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt + if [[ -f "$system/manifest" ]]; then + vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)" + [[ -n "$vermerkt" ]] && ziel="$vermerkt" + vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)" + [[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt" + fi + [[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben." + + [[ -n "$grund" ]] && warn "$grund" + step "Rueckweg: alten Stand wiederherstellen" + + # Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt + # werden. + for unit in "$NAME.service" "$NAME-monitor.service"; do + if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then + systemctl disable --now "$unit" >/dev/null 2>&1 || true + info "$unit gestoppt" + fi + done + + # Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die + # alte Installation nie angefasst. + if [[ "$gleiches_verzeichnis" == "1" ]]; then + rm -rf "$ziel" + mkdir -p "$ziel" + tar -C "$alt" -cf - . | tar -C "$ziel" -xf - + info "$ziel aus der Sicherung wiederhergestellt" + [[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt." + else + info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist" + if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then + rm -rf "$NEW_ROOT" + info "$NEW_ROOT entfernt" + fi + fi + + # Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat. + local site_wieder=0 + if [[ -f "$system/manifest" ]]; then + while read -r zeile; do + case "$zeile" in + unit-enabled\ *|site-enabled\ *) continue ;; + esac + local name; name="$(basename "$zeile")" + if [[ -e "$system/$name" ]]; then + cp -a "$system/$name" "$zeile" + info "wiederhergestellt: $zeile" + [[ "$zeile" == /etc/nginx/* ]] && site_wieder=1 + fi + done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true) + fi + + systemctl daemon-reload + if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then + nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \ + && info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen" + fi + + # Und die Dienste wieder einschalten, die vorher liefen. + if [[ -f "$system/manifest" ]]; then + while read -r _ unit; do + systemctl enable --now "$unit" >/dev/null 2>&1 \ + && info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten" + done < <(grep '^unit-enabled ' "$system/manifest" || true) + fi + + step "Zurueckgerollt" + info "Sicherung bleibt liegen: $sicherung" + # Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und + # bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es. + local hinweis + hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)" + info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}" +} + +neueste_sicherung() { + local kandidat + kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)" + [[ -n "$kandidat" ]] || return 1 + printf '%s' "${kandidat%/}" +} + +if [[ -n "$ROLLBACK" ]]; then + if [[ "$ROLLBACK" == "auto" ]]; then + ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben." + info "Neueste Sicherung: $ROLLBACK" + fi + [[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht" + if [[ $DRY_RUN -eq 1 ]]; then + step "--dry-run: es wuerde $ROLLBACK zurueckgerollt." + exit 0 + fi + if [[ $ASSUME_YES -eq 0 ]]; then + read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer + [[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; } + fi + zurueckrollen "$ROLLBACK" + exit 0 +fi + +# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon +# beendet. Erst jetzt braucht es ein Release und eine Quelle. + # --8<-- Bootstrap: Release holen -------------------------------------------- # Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden # Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und @@ -255,28 +403,55 @@ fi [[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)" ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung" -NAME="$APP" -[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE" -ADMIN="$NAME-admin" -NEW_ROOT="/srv/$NAME" IN_PLACE=0 [[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1 -STAMP="$(date +%Y%m%d-%H%M%S)" -BACKUP="/srv/$NAME-upgrade-$STAMP" -ALT_KOPIE="$BACKUP/alt" -EXPORT_ALT="$BACKUP/export-alt.json" -IMPORT_NEU="$BACKUP/import-neu.json" +# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses +# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh +# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie +# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene +# Instanz hochzieht. +# +# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur +# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit. +alte_units_finden() { + local quelle="$1" gefunden=() datei name runde + shopt -s nullglob + for datei in /etc/systemd/system/*.service; do + grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")") + done + # Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann + # noch einmal fuer deren eigene Timer. + for runde in 1 2; do + for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do + name="$(basename "$datei")" + [[ " ${gefunden[*]-} " == *" $name "* ]] && continue + for treffer in ${gefunden[*]-}; do + if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi + done + done + done + shopt -u nullglob + printf '%s\n' ${gefunden[*]-} +} -# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst -# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen. -if [[ "$APP" == "tesm" ]]; then - ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service - tesm-check-restart.timer tesm-check-restart.service) -else - ALTE_UNITS=(tesm-license.service) +mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE") +if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then + if [[ "$SOURCE" == "/srv/$APP" ]]; then + # Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der + # Fassung 1.x als Rueckfall. + if [[ "$APP" == "tesm" ]]; then + ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service + tesm-check-restart.timer tesm-check-restart.service) + else + ALTE_UNITS=(tesm-license.service) + fi + else + ALTE_UNITS=() + fi fi + step "Vorhaben" info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" info "Neue Fassung : $RELEASE_HERKUNFT" @@ -302,15 +477,44 @@ fi # -- 1. Sichern -------------------------------------------------------------- # step "Sichern" -mkdir -p "$ALT_KOPIE" +mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE" chmod 700 "$BACKUP" -# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt. -# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar, -# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad. -tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf - -info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)" + +# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort +# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem +# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation +# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen +# anderen Pfad). +if [[ $IN_PLACE -eq 1 ]]; then + tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf - + info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)" +else + tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf - + info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)" +fi [[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch" +# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit, +# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand +# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht. +: > "$SYSTEM_KOPIE/manifest" +# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer +# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach +# und mit anderen Schaltern. +printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest" +printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest" +for pfad in "${SYSTEMDATEIEN[@]}"; do + [[ -e "$pfad" ]] || continue + cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")" + printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest" +done +# Welche Units liefen, und war die Site eingeschaltet? +for unit in "${ALTE_UNITS[@]}"; do + systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest" +done +[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest" +info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt" + # -- 2. Altes System abschalten ---------------------------------------------- # if [[ $STOP_OLD -eq 1 ]]; then step "Altes System abschalten" @@ -328,7 +532,13 @@ fi step "Neu installieren" INSTALL_ARGS=(--app "$APP" --yes) [[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE") -bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"} +if ! bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}; then + # Hier stand das System bisher halb umgebaut da: alter Dienst aus, neuer + # nicht vorhanden. Der Rueckweg bringt es in den Zustand von vorher, damit + # ein zweiter Versuch nach der Ursache moeglich ist. + zurueckrollen "$BACKUP" "Die Installation ist fehlgeschlagen." + fail "Upgrade abgebrochen und zurueckgerollt. Ursache oben nachsehen, dann erneut versuchen." +fi if [[ $WITH_DATA -eq 0 ]]; then step "Fertig -- ohne Datenuebernahme" diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index d6c8991..2327020 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -174,6 +174,24 @@ sudo bash deploy/upgrade.sh --app tesm-license Eine bestimmte Version statt `latest`: `--tag v2.0.8`. +**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den +Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der +Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das +Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe +Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und +Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon, +und ein Manifest hält fest, woher der Stand kam. + +Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht +gefallen hat: + +```bash +sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung +sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428 +``` + +Die Sicherung bleibt danach liegen; gelöscht wird nichts. + Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter `/srv/` und unter `/srv/-sonnet5`. Der zweite Ort ist der, an den `rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 730dc51..2f2feb3 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.0.9" +version = "2.0.10" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 94d1840..231a216 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -134,7 +134,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.0.9", + app_version: str = "2.0.10", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index b278b98..9f43ec2 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.0.9" +version = "2.0.10" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.0.9", "cryptography>=41"] +dependencies = ["tesm-licensing==2.0.10", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index b2fe289..22cd5f3 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.0.9" +version = "2.0.10" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]