From 258e90bf4f6db3a21655985ef90a4e917f0ee5bf Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Wed, 9 Sep 2026 00:06:44 +0200 Subject: [PATCH] Rueckweg bei jedem Fehlschlag, install.sh oben im Paket, eigener Port je Instanz --- deploy/install.sh | 185 +++++++++++++++++++++++++++++++++++++++++----- deploy/update.sh | 65 ++++++++++++++-- deploy/upgrade.sh | 73 ++++++++++++++---- install.sh | 181 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 39 deletions(-) create mode 100644 install.sh diff --git a/deploy/install.sh b/deploy/install.sh index a2d718b..6a4bd06 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -28,6 +28,9 @@ set -euo pipefail APP="" INSTANCE="" +#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die +#: Portsuche in einem Test wirklich ausfuehren laesst. +ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}" PORT="" HTTP_PORT="" SKIP_PACKAGES=0 @@ -46,6 +49,62 @@ 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' "$*"; } +#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist. +SICHERUNG_STEHT=0 +INSTALL_FERTIG=0 +ZURUECKGEROLLT=0 + +zurueckrollen_install() { + [[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0 + # Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen + # gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen. + [[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; } + ZURUECKGEROLLT=1 + warn "Alten Stand wiederherstellen." + systemctl stop "$UNIT" 2>/dev/null || true + [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true + rm -rf "$FAILED_DIR" + mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true + cp -a "$BACKUP_DIR" "$ROOT" + systemctl start "$UNIT" 2>/dev/null || true + [[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true + warn "Wiederhergestellt aus $BACKUP_DIR" + warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR" +} + +# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe: +# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst +# gestoppt und das Verzeichnis teils ersetzt zurueck. +bei_fehler() { + local code=$? + [[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code" + if [[ $SICHERUNG_STEHT -eq 1 ]]; then + zurueckrollen_install + else + # Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im + # Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein + # anderes Skript haelt (upgrade.sh). + # Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab, + # gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum + # Fehler machen. + if [[ ${IS_UPDATE:-0} -eq 1 ]]; then + # Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet. + warn "Abgebrochen (Code $code), bevor etwas geaendert wurde." + warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen." + else + warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung," + warn "also wurde nichts wiederhergestellt und nichts geloescht." + fi + # Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage + # existiert das Verzeichnis noch nicht, und die Zeile log dann. + if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then + warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)." + fi + fi + return "$code" +} +trap bei_fehler EXIT + usage() { cat <<'USAGE' Verwendung: install.sh --app [Optionen] @@ -112,13 +171,24 @@ MONITOR_UNIT="$NAME-monitor.service" NGINX_SITE="/etc/nginx/sites-available/$NAME" ENV_PREFIX="TESM_" [[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" + +# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket +# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die +# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht. +# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer +# Einstellung: eine Einstellung liesse sich vor Ort umstellen. +MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master" +EDITION="standard" +if [[ "$APP" == "tesm-license" ]]; then + if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi +fi # Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst # verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz # faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80 # lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie # beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was # nicht ausdruecklich neu gesetzt wird. -ENV_FILE="/etc/tesm/$NAME.env" +ENV_FILE="$ETC_TESM/$NAME.env" if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)" fi @@ -127,10 +197,30 @@ if [[ -f "$NGINX_SITE" ]]; then [[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)" fi +port_belegt() { + local p="$1" + # Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind + # die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem + # beanspruchen. + grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null && + return 0 + # Oder gerade in Benutzung, von was auch immer. + command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0 + return 1 +} + if [[ -z "$PORT" ]]; then - PORT=5000 - [[ "$APP" == "tesm-license" ]] && PORT=5001 - [[ -n "$INSTANCE" ]] && PORT=$((PORT + 100)) + BASISPORT=5000 + [[ "$APP" == "tesm-license" ]] && BASISPORT=5001 + if [[ -n "$INSTANCE" ]]; then + # Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie + # ("Address already in use") -- und die Gesundheitspruefung merkte es + # nicht, weil die erste auf demselben Port antwortete. + PORT=$((BASISPORT + 100)) + while port_belegt "$PORT"; do PORT=$((PORT + 1)); done + else + PORT=$BASISPORT + fi fi if [[ -z "$HTTP_PORT" ]]; then HTTP_PORT=80 @@ -212,12 +302,24 @@ fi # Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte # gibt es ausschliesslich ueber den Helfer. +# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der +# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach +# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen -- +# und niemand haette es gesagt. +if [[ "$EDITION" == "sub" && -d "$ROOT/$MASTER_TEILPAKET" ]]; then + fail "Hier laeuft die Master-Ausgabe, dieses Paket ist die Ausgabe fuer Sub-Lizenzserver. + Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen. + Passendes Paket: Repository tesm-license-master (privat). + Bewusster Rueckbau: $ROOT/$MASTER_TEILPAKET von Hand entfernen, dann erneut." +fi + # -- Backup ------------------------------------------------------------------ if [[ $IS_UPDATE -eq 1 ]]; then step "Sicherung vor der Aktualisierung" systemctl stop "$UNIT" 2>/dev/null || true [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true cp -a "$ROOT" "$BACKUP_DIR" + SICHERUNG_STEHT=1 info "Sicherung: $BACKUP_DIR" fi @@ -347,8 +449,8 @@ step "Umgebung" # EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das # wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere # Datenbank im Arbeitsverzeichnis anlegen. -install -d -o root -g root -m 0755 /etc/tesm -ENV_FILE="/etc/tesm/$NAME.env" +install -d -o root -g root -m 0755 "$ETC_TESM" +ENV_FILE="$ETC_TESM/$NAME.env" TMP_ENV="$(mktemp)" { echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten." @@ -491,7 +593,11 @@ fi step "Gesundheitspruefung" HEALTHY=0 for attempt in $(seq 1 20); do - if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then + # Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu + # pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete + # eine andere Instanz, und ein toter Dienst galt als gesund. + if systemctl is-active --quiet "$UNIT" && + curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then HEALTHY=1 info "Antwort nach ${attempt}x" break @@ -502,13 +608,9 @@ done if [[ $HEALTHY -eq 0 ]]; then warn "Die Anwendung antwortet nicht." if [[ $IS_UPDATE -eq 1 ]]; then - warn "Rollback wird ausgefuehrt." - systemctl stop "$UNIT" 2>/dev/null || true - [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true - mv "$ROOT" "$FAILED_DIR" - cp -a "$BACKUP_DIR" "$ROOT" - systemctl start "$UNIT" || true - [[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true + # Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun + # sollen, laufen sonst irgendwann auseinander. + zurueckrollen_install fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt. Fehlgeschlagene Version : $FAILED_DIR Sicherung : $BACKUP_DIR @@ -568,12 +670,45 @@ if [[ $HTTPS -eq 1 ]]; then fi fi +# -- Lizenzserver einrichten ------------------------------------------------- +# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder +# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe +# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon -- +# ein bestehender Schluessel darf nie angetastet werden. +if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then + # Das Datenverzeichnis steht in der Umgebungsdatei -- es muss nicht + # $ROOT/data sein, wenn jemand es dort umgestellt hat. + DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" | tail -1)" + DATEN_DIR="${DATEN_DIR:-$ROOT/data}" + step "Signaturschluessel" + if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then + info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern." + else + fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server + keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50" + fi + + if [[ "$EDITION" == "master" ]]; then + step "Eigene Master-Lizenz" + if [[ -s "$DATEN_DIR/license.json" ]]; then + info "Es liegt schon eine Lizenzdatei -- unangetastet." + elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then + systemctl restart "$UNIT" + info "Dienst neu gestartet, damit die Lizenz gelesen wird." + else + fail "Die Master-Lizenz liess sich nicht ausstellen. + Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap" + fi + fi +fi + if [[ $IS_UPDATE -eq 1 ]]; then rm -rf "$BACKUP_DIR" info "Sicherung entfernt (Aktualisierung erfolgreich)" fi # -- Abschluss --------------------------------------------------------------- +INSTALL_FERTIG=1 step "Fertig" ADMIN_CMD="tesm-admin" [[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin" @@ -589,14 +724,24 @@ cat < Master-Ausgabe erkannt -- Release aus $REPO." + fi +fi + # --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. +# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG +# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis +# beim Beenden auf. +# +# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen +# koennen: die Master-Ausgabe des Lizenzservers liegt in +# "tesm-license-master", die Installation heisst weiter "tesm-license". # # Zwei Fallen des Vorgaengers sind hier vermieden: # * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die @@ -48,6 +77,15 @@ EXTRA=("$@") BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}" OWNER="${TESM_RELEASE_OWNER:-alientim}" +# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit +# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die +# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf +# dem Server liegt, ist keine Entscheidung eines Skripts. +TOKEN="${TESM_RELEASE_TOKEN:-}" +if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then + TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)" +fi + # 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 -- @@ -63,11 +101,24 @@ hat_crlf() { } BOOTSTRAP_WORK="" -bootstrap_aufraeumen() { [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"; } +bootstrap_aufraeumen() { + # Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn. + local code=$? + [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK" + # Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem + # Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere + # der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release + # bliebe unter /tmp liegen. + declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code" + return "$code" +} trap bootstrap_aufraeumen EXIT release_holen() { - local repo="${TESM_RELEASE_REPO:-$APP}" asset + local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset + # Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise. + local -a kopf=() + [[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN") 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 @@ -78,9 +129,11 @@ release_holen() { if [[ "$TAG" == "latest" ]]; then echo "==> Ermittle aktuellen Release-Tag ..." - TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \ + TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$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 + [[ -z "$TOKEN" ]] && + echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2 echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2 return 1 } @@ -89,7 +142,7 @@ release_holen() { asset="$repo-$TAG.tar.gz" echo "==> Lade $asset ..." - curl -fsSL -o "$BOOTSTRAP_WORK/$asset" \ + curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \ "$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || { echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2 return 1 diff --git a/deploy/upgrade.sh b/deploy/upgrade.sh index 92fd894..d1864e6 100644 --- a/deploy/upgrade.sh +++ b/deploy/upgrade.sh @@ -53,6 +53,11 @@ STOP_OLD=1 ADOPT_KEY=1 TAG="latest" ROLLBACK="" + +#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet +#: die EXIT-Falle, ob sie zurueckrollt. +SICHERUNG_STEHT=0 +UPGRADE_FERTIG=0 #: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der #: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein #: Hygienetest vergleicht sie. @@ -252,6 +257,20 @@ zurueckrollen() { info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}" } +# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode. +# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an +# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C. +beim_beenden() { + local code="${1:-0}" + [[ "$code" == "0" ]] && return 0 + [[ $UPGRADE_FERTIG -eq 1 ]] && return 0 + if [[ $SICHERUNG_STEHT -eq 0 ]]; then + warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen." + return 0 + fi + zurueckrollen "$BACKUP" "Abgebrochen (Code $code)." +} + neueste_sicherung() { local kandidat kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)" @@ -286,8 +305,13 @@ fi # 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. +# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG +# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis +# beim Beenden auf. +# +# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen +# koennen: die Master-Ausgabe des Lizenzservers liegt in +# "tesm-license-master", die Installation heisst weiter "tesm-license". # # Zwei Fallen des Vorgaengers sind hier vermieden: # * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die @@ -297,6 +321,15 @@ fi BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}" OWNER="${TESM_RELEASE_OWNER:-alientim}" +# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit +# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die +# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf +# dem Server liegt, ist keine Entscheidung eines Skripts. +TOKEN="${TESM_RELEASE_TOKEN:-}" +if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then + TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)" +fi + # 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 -- @@ -312,11 +345,24 @@ hat_crlf() { } BOOTSTRAP_WORK="" -bootstrap_aufraeumen() { [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"; } +bootstrap_aufraeumen() { + # Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn. + local code=$? + [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK" + # Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem + # Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere + # der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release + # bliebe unter /tmp liegen. + declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code" + return "$code" +} trap bootstrap_aufraeumen EXIT release_holen() { - local repo="${TESM_RELEASE_REPO:-$APP}" asset + local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset + # Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise. + local -a kopf=() + [[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN") 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 @@ -327,9 +373,11 @@ release_holen() { if [[ "$TAG" == "latest" ]]; then echo "==> Ermittle aktuellen Release-Tag ..." - TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \ + TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$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 + [[ -z "$TOKEN" ]] && + echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2 echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2 return 1 } @@ -338,7 +386,7 @@ release_holen() { asset="$repo-$TAG.tar.gz" echo "==> Lade $asset ..." - curl -fsSL -o "$BOOTSTRAP_WORK/$asset" \ + curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \ "$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || { echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2 return 1 @@ -514,6 +562,7 @@ for unit in "${ALTE_UNITS[@]}"; do 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" +SICHERUNG_STEHT=1 # -- 2. Altes System abschalten ---------------------------------------------- # if [[ $STOP_OLD -eq 1 ]]; then @@ -532,15 +581,12 @@ fi 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 +# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag +# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C. +bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"} if [[ $WITH_DATA -eq 0 ]]; then + UPGRADE_FERTIG=1 step "Fertig -- ohne Datenuebernahme" info "Sicherung der alten Installation: $ALT_KOPIE" exit 0 @@ -592,6 +638,7 @@ else fi # -- Abschluss --------------------------------------------------------------- # +UPGRADE_FERTIG=1 step "Fertig" info "Sicherung der alten Installation : $ALT_KOPIE" if [[ "$APP" == "tesm" ]]; then diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..9aa0c37 --- /dev/null +++ b/install.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# ============================================================================= +# Installation -- der kurze Weg. +# +# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf: +# +# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm +# sudo bash install.sh --app tesm --https +# +# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie +# sich das aktuelle Release selbst. +# +# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh +# sudo bash install.sh --app tesm --https +# +# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium +# tesm-license-master. Allein-Betrieb dafuer: +# +# export TESM_RELEASE_REPO=tesm-license-master +# export TESM_RELEASE_TOKEN= +# sudo -E bash install.sh --app tesm-license --https +# +# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches +# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel +# und eigene Master-Lizenz selbst an. +# +# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe: +# +# bash install.sh --help +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den +# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert. +APP="" +TAG="latest" +ARGUMENTE=("$@") +for ((i = 0; i < $#; i++)); do + case "${ARGUMENTE[i]}" in + --app) APP="${ARGUMENTE[i + 1]:-}" ;; + --tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;; + esac +done + +# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu +# finden -- die beiden Anwendungen liegen in getrennten Repositorien. +if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then + [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { + echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2 + echo "dafuer braucht sie --app ." >&2 + echo "Verwendung: install.sh --app [weitere Optionen]" >&2 + exit 2 + } +fi + +# --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, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG +# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis +# beim Beenden auf. +# +# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen +# koennen: die Master-Ausgabe des Lizenzservers liegt in +# "tesm-license-master", die Installation heisst weiter "tesm-license". +# +# 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 privates Repositorium -- tesm-license-master -- gibt sein Release nur mit +# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die +# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf +# dem Server liegt, ist keine Entscheidung eines Skripts. +TOKEN="${TESM_RELEASE_TOKEN:-}" +if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then + TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)" +fi + +# 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() { + # Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn. + local code=$? + [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK" + # Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem + # Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere + # der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release + # bliebe unter /tmp liegen. + declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code" + return "$code" +} +trap bootstrap_aufraeumen EXIT + +release_holen() { + local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset + # Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise. + local -a kopf=() + [[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN") + 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 ${kopf[0]+"${kopf[@]}"} "$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 + [[ -z "$TOKEN" ]] && + echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&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 ${kopf[0]+"${kopf[@]}"} -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 "/", also + # liegt das Skript unter /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 ------------------------------------------------------ + +if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then + # Im Paket: der Installer liegt daneben. + exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"} +fi + +# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst +# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp. +release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; } +bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}