Files
tesm/deploy/upgrade.sh
T
alientimandClaude Opus 5 337a52cc38 sudoers ohne requiretty, und ein Rueckweg fuer fehlgeschlagene Upgrades
Auf einem frisch aufgesetzten Server brach das Upgrade mitten drin ab:

    /tmp/tmp.XXXX:8:24: syntax error: unknown setting: 'requiretty'
    visudo: invalid sudoers file
     !! sudoers-Datei ist fehlerhaft

"Defaults:<user> !requiretty" stand in beiden sudoers-Dateien. Auf Debian und
Ubuntu ist requiretty nie gesetzt -- die Zeile war von Anfang an wirkungslos --
und ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
Datei ab. Sie ist entfernt; ein Hygienetest haelt fest, dass sie nicht
zurueckkommt, und prueft gleich mit, dass in diesen Dateien genau eine Regel
steht und die auf den Helfer zeigt.

Schlimmer als der Fehler war die Folge: der alte Dienst war zu diesem Zeitpunkt
gestoppt und deaktiviert, das Verzeichnis teils ersetzt, der neue Dienst nicht
vorhanden. upgbrade.sh faengt den Fehlschlag jetzt ab und dreht den Lauf
zurueck -- und mit "--rollback" auch spaeter noch, aus einer vorhandenen
Sicherung.

Dafuer muss die Sicherung mehr enthalten als das Verzeichnis. Bei einem Upgrade
in dasselbe Verzeichnis schreibt install.sh auch systemd-Unit, nginx-Site,
logrotate und Banner neu und ersetzt das venv; ohne Kopie davon liesse sich der
alte Stand nicht herstellen. Ein Manifest haelt Quelle und Art des Umzugs fest,
damit ein spaeterer Rueckweg nicht raten muss.

Beim Nachstellen des Fehlschlags fiel ein zweiter Fund auf, gefaehrlicher als
der erste: die Liste der "alten Units" war fest verdrahtet -- fuer --app tesm
immer tesm.service. Nach einem rename.sh heisst so aber die *laufende*
Installation. Wer die archivierte 1.x in eine eigene Instanz hochzieht und
--keep-old-running vergisst, haette damit den Produktionsdienst gestoppt und
deaktiviert. Die Units werden jetzt daran erkannt, dass sie in genau das
Quellverzeichnis zeigen; Timer und Hilfsdienste kommen ueber den Namen der so
gefundenen Unit mit.

Am System geprueft, nicht behauptet: Fehlschlag nachgestellt (1.2.8 mit eigener
Unit, eigener Site und erkennbarem venv), Rueckweg lief -- VERSION 1.2.8, das
alte venv und die alte Unit wieder da, die neue Struktur weg, die laufende
Produktion unberuehrt. Danach derselbe Lauf mit heiler sudoers-Datei bis zum
Ende (4 Clients, 2 Zugangsdaten, DHCP), und zuletzt "--rollback" als eigener
Aufruf auf dieselbe Installation.

Version 2.0.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:13:43 +02:00

641 lines
27 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
#
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
#
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
# sudo bash upgrade.sh --app tesm
#
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
# dessen install.sh benutzt statt eines Downloads:
#
# sudo bash deploy/upgrade.sh --app tesm
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
#
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
#
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
# Sicherung.
#
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
#
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
# Kopie aus Schritt 1 der Export.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
SOURCE=""
PASSPHRASE=""
ASSUME_YES=0
DRY_RUN=0
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.
#:
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
#: dem gesucht wird, nicht der erste.
ARCHIV_SUFFIX="sonnet5"
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
info_quelle=""
EXTRA=()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--source <pfad> Verzeichnis der alten Installation
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
--instance <name> Neue Installation als benannte Instanz anlegen, statt
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
alte Installation liegen und ist zurueckholbar.
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
erzeugt und in der Sicherung abgelegt.
--no-data Nur installieren, keine Datenuebernahme
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
erzeugen, statt den alten zu uebernehmen. Dann muessen
alle Kunden den Ausstellerwechsel bestaetigen -- die
Vorgabe ist deshalb die Uebernahme.
--tag <vX.Y.Z> 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
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
oder --domain.
Beispiele:
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
sudo bash deploy/upgrade.sh --app tesm
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--source) SOURCE="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
--no-data) WITH_DATA=0; shift ;;
--keep-old-running) STOP_OLD=0; shift ;;
--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 ;;
*) EXTRA+=("$1"); shift ;;
esac
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
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
#
# Erwartet: $APP und $TAG. Setzt: $TAG (aufgeloest) und $SRC (entpacktes
# Release). Raeumt sein Arbeitsverzeichnis beim Beenden auf.
#
# Zwei Fallen des Vorgaengers sind hier vermieden:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
OWNER="${TESM_RELEASE_OWNER:-alientim}"
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
# machte weiter, als waere alles in Ordnung.
hat_crlf() {
local cr
cr="$(printf '\r')"
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
LC_ALL=C grep -qU "$cr" "$1"
}
BOOTSTRAP_WORK=""
bootstrap_aufraeumen() { [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"; }
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-$APP}" asset
for werkzeug in curl tar python3 find; do
command -v "$werkzeug" >/dev/null 2>&1 || {
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
return 1
}
done
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
return 1
}
echo " $TAG"
fi
asset="$repo-$TAG.tar.gz"
echo "==> Lade $asset ..."
curl -fsSL -o "$BOOTSTRAP_WORK/$asset" \
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
return 1
}
echo "==> Entpacke ..."
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
-printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || {
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
return 1
}
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
return 1
fi
}
# --8<-- Ende Bootstrap ------------------------------------------------------
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
# ungefragt ein anderes geladen wird. Sonst hol das Release.
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
SRC="$REPO_DIR"
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
else
release_holen || fail "Das Release liess sich nicht holen."
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
fi
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
if [[ -z "$SOURCE" ]]; then
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
done
if [[ -z "$SOURCE" ]]; then
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
warn " /srv/$APP (dort liegt sie normalerweise)"
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
warn " der Hauptname schon vergeben war)"
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
fail "Bitte --source setzen."
fi
info_quelle="gefunden: $SOURCE"
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"
IN_PLACE=0
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
# 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[*]-}
}
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"
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
info "Sicherung : $ALT_KOPIE"
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Sichern -------------------------------------------------------------- #
step "Sichern"
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
chmod 700 "$BACKUP"
# 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"
for unit in "${ALTE_UNITS[@]}"; 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 und deaktiviert"
fi
done
else
step "Altes System bleibt in Betrieb"
fi
# -- 3. Neu installieren ----------------------------------------------------- #
step "Neu installieren"
INSTALL_ARGS=(--app "$APP" --yes)
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
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"
info "Sicherung der alten Installation: $ALT_KOPIE"
exit 0
fi
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
if [[ -z "$PASSPHRASE" ]]; then
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
# auch als Rueckweg.
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
chmod 600 "$BACKUP/passphrase.txt"
fi
if [[ "$APP" == "tesm" ]]; then
# -- 4. Exportieren -------------------------------------------------------- #
step "Daten der alten Installation lesen"
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
--passphrase "$PASSPHRASE" --force
chmod 600 "$EXPORT_ALT"
# -- 5. Uebersetzen -------------------------------------------------------- #
step "Uebersetzen"
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
chmod 600 "$IMPORT_NEU"
# -- 6. Einspielen --------------------------------------------------------- #
step "Einspielen"
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
else
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
step "Lizenzbestand uebernehmen"
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
# wenn die neue Installation schon einen eigenen Schluessel hat.
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
"$ADMIN" "${MIGRATE_ARGS[@]}"
if [[ $ADOPT_KEY -eq 0 ]]; then
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
warn "Ausstellerwechsel einmal bestaetigen."
fi
fi
# -- Abschluss --------------------------------------------------------------- #
step "Fertig"
info "Sicherung der alten Installation : $ALT_KOPIE"
if [[ "$APP" == "tesm" ]]; then
info "Export im alten Format : $EXPORT_ALT"
info "Uebersetzte Datei : $IMPORT_NEU"
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
fi
cat <<TEXT
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
sudo -u $APP $ADMIN create-admin
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
TEXT
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
# einem alten Stand ueberschreiben.
if [[ $IN_PLACE -eq 1 ]]; then
cat <<TEXT
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
bringt es zurueck:
sudo systemctl disable --now $NAME
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
# venv der alten Installation neu bauen:
sudo python3 -m venv $SOURCE/venv
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
else
cat <<TEXT
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
die neue Instanz abzuschalten:
sudo systemctl disable --now $NAME $NAME-monitor
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
fi