syncova-backup/scripts/update.sh
Jerrit Fritzsche 4762fa29c3
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 34s
CI / Sicherheitsprüfungen (push) Successful in 24s
Fehlervorlage und ein Diagnoseskript, das sie fuellt
Eine Vorlage allein bringt wenig — sie stellt Fragen, die der Meldende meist
nicht beantworten kann. Deshalb zwei Teile:

diagnose.sh sammelt in einem Zug, was zur Analyse gebraucht wird: Fassungen
aller acht Programme, Betriebssystem, Container ja/nein, Dateisystem des
Repositorys, PostgreSQL-Fassung, Schemastand, Dienstzustand, Gesundheitsbericht
(der auch bei 503 den vollstaendigen Bericht traegt), Bestand, die letzten nicht
erfolgreichen Laeufe mit Fehlercode UND Fehlerklasse, die gemessene
Durchsetzungsstufe und die letzten Fehlerzeilen. Es liest nur.

Geheimnisse kommen nicht hinein, und der Weg dahin ist umgekehrt: Es gibt eine
Liste der Werte, die gezeigt werden duerfen. Eine Sperrliste vergaesse den
naechsten neuen Wert. Zusaetzlich werden die tatsaechlichen Geheimnisse gelesen
und aus JEDER Ausgabe entfernt — auch aus Protokollzeilen, in die sie auf einem
unvorhergesehenen Weg geraten sind. Real geprueft: weder Datenbankpasswort noch
Schluessel noch Administratorpasswort stehen im Bericht.

Die Vorlage beginnt mit sieben Faellen, die wie ein Fehler aussehen und gewolltes
Verhalten sind — "advisory" statt "filesystem", ein Teilfehler, "geloescht aber
nichts frei", 503 mit vollstaendigem Bericht. Das ist keine Abwehr, sondern
spart beiden Seiten einen halben Tag. Pflichtfelder sind Beobachtung, Erwartung,
Schritte, Bereich, Datenrisiko, Haeufigkeit und der Diagnosebericht; Fehlercode
und request_id stehen eigens da, weil sie die beiden wertvollsten Angaben sind.

Beim Erproben zwei Funde:

- Die Installationsanleitung verlangte PostgreSQL 17. setup.sh installiert auf
  Debian 12 aber 15 — und alles lief, bis hin zu einem echten Sicherungslauf.
  Die Anforderung lautet jetzt 15 (geprueft gegen 17 in CI und Entwicklung,
  gegen 15 auf Debian 12), und setup.sh lehnt aeltere Fassungen ab statt sie
  stillschweigend zu nehmen.
- Ein Repository auf der Platte, das nicht in der Control Plane eingetragen ist,
  faellt niemandem auf: Die Sicherung laeuft nie, weil der Server das Ziel nicht
  kennt. Der Bericht benennt diesen Fall jetzt ausdruecklich.

Gegen das echte v1.0.0-rc1-Paket gefahren: Installation, erzeugte Stoerung
(ALL_SOURCES_FAILED / source), Bericht zeigt Code, Klasse, "overlayfs" und
"nie gemessen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:58:00 +02:00

422 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Aktualisiert eine vorhandene Syncova-Installation.
#
# Die Reihenfolge ist nicht verhandelbar und der Grund fuer dieses Skript:
#
# sichern -> anhalten -> Programme tauschen -> migrieren -> starten -> pruefen
#
# Laeuft der Dienst noch, waehrend sich das Schema aendert, arbeitet er auf
# einem Stand, den er nicht kennt. Und ohne die Sicherung davor gibt es keinen
# Weg zurueck: Ein Rollback nimmt genau eine Migration zuruck, und Daten in
# Tabellen, die es vorher nicht gab, sind danach weg.
#
# Scheitert ein Schritt, wird die vorige Fassung zurueckgeholt.
#
# ./update.sh prueft, fragt, aktualisiert
# ./update.sh --pruefen sieht nur nach und aendert nichts
# ./update.sh --ja ohne Rueckfrage
#
# Das Repository wird nicht angefasst. Der Verschluesselungsschluessel auch
# nicht — er bleibt, wie er ist, sonst waeren alle Geheimnisse unlesbar.
set -euo pipefail
# ---------------------------------------------------------------------------
# Feste Orte — gleichlautend in setup.sh und uninstall.sh
# ---------------------------------------------------------------------------
readonly installationRoot="/opt/syncova"
readonly configurationDirectory="/etc/syncova"
readonly configurationFile="${configurationDirectory}/syncova.env"
readonly serviceName="syncova-api"
readonly backupDirectory="/var/backups/syncova"
# previousReleaseRoot nimmt die abgeloeste Fassung auf.
#
# Sie bleibt liegen, bis die neue nachweislich laeuft. Ein Update, das die alte
# Fassung sofort loescht, hat keinen Rueckweg.
readonly previousReleaseRoot="${installationRoot}.vorherige"
# ---------------------------------------------------------------------------
# Ausgabe
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
readonly colorReset=$'\033[0m'
readonly colorBold=$'\033[1m'
readonly colorRed=$'\033[31m'
readonly colorGreen=$'\033[32m'
readonly colorYellow=$'\033[33m'
else
readonly colorReset='' colorBold='' colorRed='' colorGreen='' colorYellow=''
fi
writeStep() { printf '%s==>%s %s\n' "${colorBold}" "${colorReset}" "$1"; }
writeDetail() { printf ' %s\n' "$1"; }
writeSuccess() { printf ' %s✓%s %s\n' "${colorGreen}" "${colorReset}" "$1"; }
writeWarning() { printf ' %s!%s %s\n' "${colorYellow}" "${colorReset}" "$1" >&2; }
abortWithMessage() {
printf '\n%sAbbruch:%s %s\n' "${colorRed}${colorBold}" "${colorReset}" "$1" >&2
exit 1
}
# ---------------------------------------------------------------------------
# Schalter
# ---------------------------------------------------------------------------
checkOnly="nein"
assumeYes="nein"
packageDirectory=""
showUsage() {
cat <<'USAGE'
Verwendung:
update.sh [--paket <verzeichnis>] [--pruefen] [--ja]
Schalter:
--paket <verzeichnis> Entpacktes neues Paket (Vorgabe: Verzeichnis des Skripts)
--pruefen Nur nachsehen: installierte und neue Fassung vergleichen
--ja Ohne Rueckfrage aktualisieren
--hilfe Diese Uebersicht
Vor dem Tausch werden Datenbank und Konfiguration gesichert nach:
/var/backups/syncova
Nicht angetastet werden das Repository und der Verschluesselungsschluessel.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--paket) packageDirectory="${2:-}"; shift 2 ;;
--pruefen) checkOnly="ja"; shift ;;
--ja) assumeYes="ja"; shift ;;
--hilfe|-h|--help) showUsage; exit 0 ;;
*) showUsage >&2; abortWithMessage "unbekannter Schalter: $1" ;;
esac
done
# ---------------------------------------------------------------------------
# 1. Was ist installiert?
# ---------------------------------------------------------------------------
printf '\n%sSyncova Backups — Aktualisierung%s\n\n' "${colorBold}" "${colorReset}"
writeStep "Vorhandene Installation"
if [[ "$(id -u)" -ne 0 ]]; then
abortWithMessage "Dieses Skript braucht root-Rechte (sudo ./update.sh)."
fi
if [[ ! -x "${installationRoot}/bin/syncova-api" ]]; then
abortWithMessage "Unter ${installationRoot} ist keine Installation zu finden.
Zum Einrichten: ./setup.sh"
fi
if [[ ! -f "${configurationFile}" ]]; then
abortWithMessage "Die Konfiguration ${configurationFile} fehlt.
Ohne sie laesst sich weder sichern noch migrieren."
fi
installedVersion="$("${installationRoot}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')"
[[ -n "${installedVersion}" ]] || installedVersion="unbekannt"
writeSuccess "Installiert: ${installedVersion}"
# Der Schemastand gehoert zur Bestandsaufnahme: Steht er auf "unsauber", ist
# eine fruehere Migration abgebrochen, und ein Update darauf waere fahrlaessig.
set -a
# shellcheck disable=SC1090
. "${configurationFile}"
set +a
schemaState="$("${installationRoot}/bin/syncova-migrate" status 2>&1 | tail -2 | tr '\n' ' ')"
writeDetail "Schema: ${schemaState}"
if printf '%s' "${schemaState}" | grep -qi "unsauber\|dirty"; then
printf '\n'
writeWarning "Der Schemastand ist als unsauber vermerkt."
writeDetail "Eine fruehere Migration ist abgebrochen. Bringen Sie das Schema"
writeDetail "zuerst in Ordnung ('syncova-migrate force <n>' nach Ruecksprache),"
writeDetail "bevor Sie aktualisieren."
abortWithMessage "Ein Update auf einem unsauberen Schema wird nicht durchgefuehrt."
fi
serviceWasRunning="nein"
if systemctl is-active --quiet "${serviceName}" 2>/dev/null; then
serviceWasRunning="ja"
writeSuccess "Dienst laeuft"
else
writeDetail "Der Dienst laeuft derzeit nicht."
fi
# ---------------------------------------------------------------------------
# 2. Was ist neu?
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Neues Paket"
if [[ -z "${packageDirectory}" ]]; then
packageDirectory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fi
if [[ ! -x "${packageDirectory}/bin/syncova-api" ]]; then
abortWithMessage "Unter ${packageDirectory} liegt kein Auslieferungspaket.
Geben Sie es mit --paket <verzeichnis> an."
fi
packageVersion="$("${packageDirectory}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}')"
writeSuccess "Neu: ${packageVersion}"
if [[ -f "${packageDirectory}/SHA256SUMS" ]] && command -v sha256sum >/dev/null 2>&1; then
if ( cd "${packageDirectory}" && sha256sum --quiet -c SHA256SUMS >/dev/null 2>&1 ); then
writeSuccess "Pruefsummen stimmen"
else
abortWithMessage "Die Pruefsummen des neuen Pakets stimmen nicht.
Ein unvollstaendig uebertragenes Paket wird nicht eingespielt."
fi
fi
if [[ "${installedVersion}" == "${packageVersion}" ]]; then
printf '\n'
writeDetail "Installiert und neu sind dieselbe Fassung (${packageVersion})."
if [[ "${checkOnly}" == "ja" ]]; then
exit 0
fi
if [[ "${assumeYes}" != "ja" ]]; then
read -r -p " Trotzdem neu einspielen? [j/N]: " reinstallAnswer
[[ "${reinstallAnswer}" =~ ^[jJyY] ]] || { printf '\n Nichts zu tun.\n\n'; exit 0; }
else
printf '\n Nichts zu tun.\n\n'
exit 0
fi
fi
if [[ "${checkOnly}" == "ja" ]]; then
printf '\n'
printf ' %s%s%s → %s%s%s\n\n' "${colorBold}" "${installedVersion}" "${colorReset}" \
"${colorBold}${colorGreen}" "${packageVersion}" "${colorReset}"
printf ' Zum Aktualisieren: %s\n\n' "$0"
exit 0
fi
# ---------------------------------------------------------------------------
# 3. Rueckfrage
# ---------------------------------------------------------------------------
if [[ "${assumeYes}" != "ja" ]]; then
printf '\n'
printf ' %s%s%s → %s%s%s\n\n' "${colorBold}" "${installedVersion}" "${colorReset}" \
"${colorBold}${colorGreen}" "${packageVersion}" "${colorReset}"
writeDetail "Vor dem Tausch werden Datenbank und Konfiguration gesichert."
writeDetail "Repository und Verschluesselungsschluessel bleiben unberuehrt."
writeDetail "Der Dienst ist waehrend des Updates kurz nicht erreichbar."
printf '\n'
read -r -p " Jetzt aktualisieren? [j/N]: " updateAnswer
[[ "${updateAnswer}" =~ ^[jJyY] ]] || { printf '\n Abgebrochen. Es wurde nichts geaendert.\n\n'; exit 0; }
fi
# ---------------------------------------------------------------------------
# 4. Sichern
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Sicherung vor dem Update"
install -d -m 0700 "${backupDirectory}"
backupTimestamp="$(date -u '+%Y%m%d-%H%M%S')"
backupPrefix="${backupDirectory}/${backupTimestamp}-vor-${packageVersion}"
# Die Konfiguration zuerst: Sie ist klein und enthaelt den Schluessel.
install -m 0600 "${configurationFile}" "${backupPrefix}.env"
writeSuccess "Konfiguration: ${backupPrefix}.env"
# Die Datenbank. Ohne pg_dump gibt es keinen Weg zurueck — deshalb bricht das
# Skript hier ab, statt "auf gut Glueck" weiterzumachen.
if ! command -v pg_dump >/dev/null 2>&1; then
printf '\n'
writeWarning "pg_dump ist nicht vorhanden."
writeDetail "Ohne Datenbanksicherung gibt es nach einer misslungenen Migration"
writeDetail "keinen Weg zurueck: Ein Rollback nimmt genau eine Migration"
writeDetail "zurueck, und Daten in neuen Tabellen sind danach weg."
printf '\n'
writeDetail "Installieren Sie postgresql-client, oder sichern Sie von Hand und"
writeDetail "starten Sie danach mit --ja erneut."
abortWithMessage "Ohne Datenbanksicherung wird nicht aktualisiert."
fi
databaseDumpFile="${backupPrefix}.dump"
if ! PGPASSWORD="${SYNCOVA_DB_PASSWORD:-}" pg_dump \
--host "${SYNCOVA_DB_HOST:-127.0.0.1}" \
--port "${SYNCOVA_DB_PORT:-5432}" \
--username "${SYNCOVA_DB_USER:-syncova}" \
--dbname "${SYNCOVA_DB_NAME:-syncova}" \
--format=custom --file "${databaseDumpFile}" 2>/tmp/syncova-pgdump.log; then
printf '\n'
writeWarning "Die Datenbanksicherung schlug fehl:"
head -5 /tmp/syncova-pgdump.log | sed 's/^/ /' >&2
rm -f /tmp/syncova-pgdump.log
abortWithMessage "Ohne Sicherung wird nicht aktualisiert."
fi
rm -f /tmp/syncova-pgdump.log
chmod 0600 "${databaseDumpFile}"
dumpSize="$(du -h "${databaseDumpFile}" | awk '{print $1}')"
writeSuccess "Datenbank: ${databaseDumpFile} (${dumpSize})"
# ---------------------------------------------------------------------------
# 5. Tauschen
# ---------------------------------------------------------------------------
# updateFailed holt die vorige Fassung zurueck.
#
# Aufgerufen aus dem trap. Die Reihenfolge ist die Umkehrung des Updates: erst
# den Dienst anhalten, dann die Programme zuruecktauschen, dann starten.
updateFailed() {
local exitCode=$?
[[ ${exitCode} -eq 0 ]] && exit 0
printf '\n%sDas Update wird zurueckgenommen.%s\n' "${colorYellow}${colorBold}" "${colorReset}" >&2
systemctl stop "${serviceName}" 2>/dev/null || true
if [[ -d "${previousReleaseRoot}" ]]; then
rm -rf "${installationRoot}"
mv "${previousReleaseRoot}" "${installationRoot}"
writeDetail "Die vorige Fassung ${installedVersion} liegt wieder an ihrem Platz."
fi
if [[ "${serviceWasRunning}" == "ja" ]]; then
systemctl start "${serviceName}" 2>/dev/null || \
writeWarning "Der Dienst liess sich nicht wieder starten: systemctl status ${serviceName}"
fi
printf '\n' >&2
writeDetail "Die Datenbank wurde NICHT zurueckgespielt — das geht nur von Hand:"
writeDetail ""
writeDetail " systemctl stop ${serviceName}"
writeDetail " pg_restore --clean --if-exists -d ${SYNCOVA_DB_NAME:-syncova} ${databaseDumpFile}"
writeDetail " systemctl start ${serviceName}"
writeDetail ""
writeDetail "Notwendig ist das nur, wenn die Migration bereits gelaufen war."
writeDetail "Der Schemastand sagt es: ${installationRoot}/bin/syncova-migrate status"
exit "${exitCode}"
}
trap updateFailed EXIT
printf '\n'
writeStep "Programme tauschen"
if [[ "${serviceWasRunning}" == "ja" ]]; then
systemctl stop "${serviceName}"
writeSuccess "Dienst angehalten"
fi
rm -rf "${previousReleaseRoot}"
mv "${installationRoot}" "${previousReleaseRoot}"
install -d -m 0755 "${installationRoot}"
cp -R "${packageDirectory}/bin" "${installationRoot}/"
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
[[ -d "${packageDirectory}/docs" ]] && cp -R "${packageDirectory}/docs" "${installationRoot}/"
[[ -d "${packageDirectory}/deployment" ]] && cp -R "${packageDirectory}/deployment" "${installationRoot}/"
chmod -R 0755 "${installationRoot}/bin"
[[ -f "${packageDirectory}/diagnose.sh" ]] && \
install -m 0755 "${packageDirectory}/diagnose.sh" "${installationRoot}/diagnose.sh"
printf '%s\n' "${packageVersion}" > "${installationRoot}/VERSION"
writeSuccess "Programme ausgetauscht"
# ---------------------------------------------------------------------------
# 6. Migrieren
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Datenbankschema"
# Mit dem NEUEN Programm und bei angehaltenem Dienst. Beides gehoert zusammen:
# Die neue Fassung bringt ihre Migrationen mit, und ein laufender Dienst
# arbeitete waehrenddessen auf einem Stand, den er nicht kennt.
migrationOutput="$("${installationRoot}/bin/syncova-migrate" up 2>&1 | tail -1)"
writeDetail "${migrationOutput}"
if printf '%s' "${migrationOutput}" | grep -qi "error\|fehler"; then
abortWithMessage "Die Migration schlug fehl."
fi
writeSuccess "Schema aktuell"
# ---------------------------------------------------------------------------
# 7. Starten und pruefen
# ---------------------------------------------------------------------------
printf '\n'
writeStep "Dienst"
systemctl daemon-reload
systemctl start "${serviceName}"
healthAddress="${SYNCOVA_HTTP_LISTEN_ADDRESS:-127.0.0.1:8080}"
serviceIsReady="nein"
for _attempt in $(seq 1 20); do
if curl -fsS "http://${healthAddress}/health/ready" >/dev/null 2>&1; then
serviceIsReady="ja"
break
fi
sleep 1
done
if [[ "${serviceIsReady}" != "ja" ]]; then
printf '\n'
writeWarning "Der Dienst meldet sich nach dem Update nicht als betriebsbereit."
journalctl -u "${serviceName}" -n 15 --no-pager 2>/dev/null | sed 's/^/ /' >&2 || true
abortWithMessage "Das Update wird zurueckgenommen."
fi
# Die laufende Fassung ist eine Beigabe, kein Pruefkriterium — sie wird aus dem
# Programm gelesen, nicht aus der Gesundheitsantwort.
#
# Der erste Anlauf holte sie mit "grep -o" aus der Antwort von /health/ready.
# Die enthaelt aber gar kein Versionsfeld; grep endet dann mit 1, und mit
# "set -o pipefail" nahm das Skript ein **gelungenes** Update wieder zurueck.
# Ein Rollback wegen einer Zeile, die nur der Ausgabe dient.
runningVersion="$("${installationRoot}/bin/syncova-api" --version 2>/dev/null | awk '{print $2}' || true)"
writeSuccess "Dienst laeuft und ist betriebsbereit${runningVersion:+ (${runningVersion})}"
# Ab hier ist das Update gelungen.
trap - EXIT
# ---------------------------------------------------------------------------
# Abschluss
# ---------------------------------------------------------------------------
printf '\n%sDie Aktualisierung ist abgeschlossen.%s\n\n' "${colorGreen}${colorBold}" "${colorReset}"
printf ' %s → %s\n\n' "${installedVersion}" "${packageVersion}"
printf ' Sicherung %s.dump\n' "${backupPrefix}"
printf ' Vorige Fassung %s\n' "${previousReleaseRoot}"
printf '\n'
writeDetail "Die vorige Fassung bleibt liegen, bis Sie sie entfernen:"
writeDetail " rm -rf ${previousReleaseRoot}"
printf '\n'
writeDetail "Sehen Sie vorher nach, ob die neue Fassung im Betrieb traegt —"
writeDetail "ein Update, das man am selben Tag entsorgt, hat keinen Rueckweg."
printf '\n'