From 6feb3603e3c70031e45aa0a29ce9fe890edb1254 Mon Sep 17 00:00:00 2001 From: Benjamin Blarr Date: Tue, 15 Sep 2026 14:09:25 +0200 Subject: [PATCH] Update deployment documentation and add restore probe script for database backups --- .gitignore | 5 + DEPLOYMENT.md | 221 +++++++++++++++++++++++++++++++++++----- deploy/restore_probe.sh | 113 ++++++++++++++++++++ 3 files changed, 312 insertions(+), 27 deletions(-) create mode 100644 deploy/restore_probe.sh diff --git a/.gitignore b/.gitignore index bcaf305..7c6c43f 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,11 @@ target/ profile_default/ ipython_config.py +# Docu + +Coderr_Checklist.md +Coderr_CLAUDE.md + # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 7aa6234..0282503 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -100,6 +100,8 @@ Warum umgestellt wurde, steht in Abschnitt 7. /etc/systemd/system/gunicorn-coderr.service Dienstdefinition /etc/ssh/sshd_config.d/00-hardening.conf SSH-Absicherung /usr/local/bin/backup-coderr.sh tägliche Sicherung +/var/backups/coderr/ nächtliche Sicherungen, 14 Tage +/home/benni/backups/coderr/ Sicherungen vor jedem Deploy, letzte 10 /swapfile 2 GB Swap ``` @@ -788,14 +790,37 @@ server { ### 5.1 Code-Änderung ausrollen +Seit dem 14.09.2026 automatisch. Ein Push auf `main`, also ein gemergter +Pull Request, startet in GitHub Actions den Workflow `CI/CD`. Sind `Lint` +und `Tests` grün, schickt der Job `Deploy` das Skript `deploy/deploy.sh` +per SSH an den Server. Das Skript + +1. bricht ab, wenn auf dem Server versionierte Dateien geändert wurden, +2. sichert die Datenbank nach `~/backups/coderr/` (siehe 5.5), +3. setzt den Code mit `git merge --ff-only` auf genau den geprüften Commit, +4. führt `pip install`, `check`, `migrate` und `collectstatic` aus, +5. lädt Gunicorn per `HUP` neu, ohne laufende Anfragen abzubrechen. + +Danach prüft der Job von außen, ob `/api/base-info/` JSON und eine +statische Datei CSS liefert. + +Schritt 4 läuft bei jedem Deploy, auch ohne Änderung. Scheitert ein +Deploy nach dem Merge, holt **Re-run jobs** ihn deshalb vollständig nach. + +Nur wenn GitHub Actions nicht verfügbar ist, von Hand vom Arbeitsrechner +in **Git Bash** (PowerShell 5.1 kennt die Umleitung mit `<` nicht), im +Repository: + +```bash +git fetch +ssh vps "bash -s -- $(git rev-parse origin/main)" < deploy/deploy.sh +``` + +Bekommt Gunicorn selbst eine neue Version, reicht `HUP` nicht. Dann +einmal von Hand: + ```bash -cd /var/www/coderr/backend -git pull -.venv/bin/pip install -r requirements.txt # nur bei neuen Paketen -.venv/bin/python manage.py migrate # nur bei neuen Migrationen -.venv/bin/python manage.py collectstatic --noinput sudo systemctl restart gunicorn-coderr -sudo systemctl status gunicorn-coderr --no-pager ``` ### 5.2 Logs ansehen @@ -845,13 +870,24 @@ Der Befehl ist wiederholbar. Vorhandene Datensätze werden aktualisiert, nicht doppelt angelegt. Er läuft in einer Transaktion, bei einem Fehler bleibt der vorherige Zustand erhalten. -### 5.5 Datenbank sichern +### 5.5 Datenbank sichern und wiederherstellen + +Es gibt zwei Sicherungen, beide als gepacktes SQL aus `pg_dump`. + +| | nächtlich | vor jedem Deploy | +|---|---|---| +| Auslöser | `coderr-backup.timer`, täglich 03:30 UTC | `deploy/deploy.sh`, vor dem Merge | +| Ablage | `/var/backups/coderr/db-.sql.gz` | `~/backups/coderr/_.sql.gz` | +| erstellt als | `postgres`, über `/usr/local/bin/backup-coderr.sh` | `coderr`, Passwort aus der `.env` | +| Aufbewahrung | 14 Tage | die letzten 10 | +| Media-Ordner | ja, als `media-.tar.gz` | nein | + +**Der Kurz-Hash im Dateinamen ist der Commit, der ausgerollt werden +sollte, nicht der, zu dem die Sicherung passt.** Die Sicherung entsteht +vor dem Merge und enthält den Stand davor. Genau diese Datei braucht man, +wenn ein Deploy mit Migration zurückgenommen werden muss. -Läuft automatisch täglich um 03:30 Uhr Serverzeit (UTC) über -`/usr/local/bin/backup-coderr.sh`, ausgelöst von -`coderr-backup.timer`. Gesichert werden die Datenbank und der -`media`-Ordner, Ablage unter `/var/backups/coderr/`, Aufbewahrung -14 Tage. +Die nächtliche Sicherung: ```bash # Status und naechster Lauf @@ -862,10 +898,6 @@ sudo journalctl -u coderr-backup -n 30 --no-pager # Sofort ausfuehren sudo /usr/local/bin/backup-coderr.sh - -# Sicherung einspielen -gunzip -c /var/backups/coderr/db-2026-07-27_0330.sql.gz \ - | sudo -u postgres psql coderr ``` Der Timer nutzt `Persistent=true`, ein wegen Neustart verpasster Lauf @@ -877,6 +909,134 @@ gesamten Servers. Ein manueller Snapshot vor riskanten Änderungen ist im hPanel kostenlos möglich, allerdings nur einer gleichzeitig und mit einem Tag Haltbarkeit. +#### Einspielen proben + +`deploy/restore_probe.sh` spielt eine Sicherung in die Wegwerf-Datenbank +`coderr_restore_test` ein und löscht sie am Ende wieder, auch nach einem +Fehler. Dazwischen vergleicht es die Zeilenzahl jeder Tabelle mit der +echten Datenbank, prüft, ob eine ID-Sequenz hinter der höchsten ID liegt, +und fragt Django mit `migrate --plan`, ob der ausgerollte Code an der +Kopie noch etwas migrieren würde. Die echte Datenbank wird nur gelesen. +`sudo` ist nicht nötig, weil `coderr` das Recht `CREATEDB` hat +(Abschnitt 3.8). + +Vom Arbeitsrechner, in PowerShell im Repository: + +```powershell +scp deploy/restore_probe.sh vps:restore_probe.sh +ssh vps "bash ~/restore_probe.sh ~/backups/coderr/.sql.gz" +ssh vps "bash ~/restore_probe.sh /var/backups/coderr/.sql.gz" +ssh vps "rm ~/restore_probe.sh" +``` + +So wird das Ergebnis gelesen: + +- **Unter `messages` steht nichts.** Das Einspielen läuft mit + `ON_ERROR_STOP`, schon der erste Fehler bricht ab und steht dort. +- **`<- differs` ist nicht automatisch ein Fehler.** Die Live-Datenbank + ist neuer als die Sicherung. Ein Unterschied muss sich mit dem erklären + lassen, was seitdem passiert ist. +- **Unter `Sequences` steht nichts.** Eine Sequenz unter der höchsten ID + lässt den nächsten neuen Datensatz an einem doppelten Schlüssel + scheitern, und das zeigt keine Zeilenzahl. +- **`No planned migration operations`.** Das gilt nur, solange seit der + Sicherung keine Migration ausgerollt wurde. + +Geprobt am 15.09.2026 mit beiden Arten: beide in einer Sekunde und ohne +Meldung eingespielt, Sequenzen in Ordnung, keine offene Migration. Die +Deploy-Sicherung vom 14.09. abends hatte eine Kontaktnachricht und einen +Cache-Eintrag weniger als die Live-Datenbank, die nächtliche vom 15.09. +stimmte in allen 17 Tabellen überein. Der Media-Ordner war nicht Teil der +Probe. + +#### Notfall: Datenbank aus einer Sicherung zurückholen + +Für einen Deploy, dessen Migration zurückgenommen werden muss. **Nie in +die laufende Datenbank `coderr` einspielen.** `pg_dump` schreibt reines +SQL ohne vorheriges Löschen. In eine gefüllte Datenbank eingespielt, +scheitern die Tabellen an „existiert bereits“, die Daten an doppelten +Schlüsseln, und übrig bleibt ein Mischstand. + +Stattdessen entsteht die Kopie neben der echten Datenbank, und beim +Tausch bleibt die kaputte als `coderr_defekt` zum Vergleich liegen. +**Geprobt ist das Einspielen bis Schritt 3, der Tausch ab Schritt 4 noch +nicht** (Abschnitt 8). + +**1. Sicherung wählen.** Die Datei, deren Kurz-Hash der gescheiterte +Commit ist: + +```bash +ls -1t ~/backups/coderr/ +``` + +**2. Zugang als `coderr` setzen.** Das Passwort kommt aus der `.env` und +erscheint nicht auf dem Bildschirm: + +```bash +cd /var/www/coderr/backend +export PGHOST=localhost PGUSER=coderr +export PGPASSWORD="$(grep -m1 '^DB_PASSWORD=' .env | cut -d= -f2- | tr -d "\r\"'")" +``` + +**3. In eine neue Datenbank einspielen**, während Coderr noch läuft: + +```bash +createdb coderr_neu +gunzip -c ~/backups/coderr/.sql.gz | psql -X -q -v ON_ERROR_STOP=1 -d coderr_neu +``` + +Bricht das ab: `dropdb coderr_neu`. Die echte Datenbank ist unberührt. + +**4. Anhalten und den Code zurücksetzen.** Ab hier sind Coderr und das +Kontaktformular des Portfolios offline. Der Commit vor dem Deploy steht +im Protokoll des Deploy-Jobs in der Zeile `Server:`, auf dem Server im +Reflog als Eintrag vor dem `merge`: + +```bash +sudo systemctl stop gunicorn-coderr +git reflog -5 +git reset --hard +``` + +**5. Die Datenbanken tauschen.** Umbenennen darf `coderr` als Besitzer +mit `CREATEDB`. Es scheitert, solange noch jemand mit der Datenbank +verbunden ist: + +```bash +psql -X -d postgres -c 'ALTER DATABASE coderr RENAME TO coderr_defekt' +psql -X -d postgres -c 'ALTER DATABASE coderr_neu RENAME TO coderr' +``` + +**6. Prüfen und starten:** + +```bash +.venv/bin/pip install -r requirements.txt +.venv/bin/python manage.py migrate --plan +.venv/bin/python manage.py collectstatic --noinput +sudo systemctl start gunicorn-coderr +``` + +`migrate --plan` muss `No planned migration operations` melden, bevor +Gunicorn startet. Sonst passen Code und Sicherung nicht zusammen. + +**7. Danach.** `unset PGPASSWORD`. Den gescheiterten Commit auf GitHub +per Revert-PR zurücknehmen. **Den gescheiterten Deploy-Job nicht mit +Re-run jobs neu starten**, er würde denselben Commit samt Migration +wieder ausrollen. `coderr_defekt` erst mit `dropdb coderr_defekt` +löschen, wenn klar ist, dass daraus nichts mehr gebraucht wird. + +**Ohne Migration** braucht es keine Sicherung und keine Ausfallzeit: + +```bash +cd /var/www/coderr/backend +git reset --hard +.venv/bin/pip install -r requirements.txt +.venv/bin/python manage.py collectstatic --noinput +kill -HUP "$(systemctl show -p MainPID --value gunicorn-coderr)" +``` + +Auch dann gilt Schritt 7: Revert-PR, kein Re-run. + ### 5.6 Kontaktformular Das Formular des Portfolios postet an `/api/contact/`. Zuständig ist @@ -1077,20 +1237,27 @@ Für die Zukunft: Secret Key ab dem ersten Commit in die `.env`. ## 8. Offene Punkte -- [ ] Wiederherstellung einer Sicherung einmal proben, mit einer - Kopie der Datenbank und nicht mit der echten -- [ ] Entscheiden, ob `benjaminblarr.dev` über den 06.02.2027 hinaus - verlängert wird. Die automatische Verlängerung steht derzeit auf - aus. Solange die Domain lebt, funktionieren alte Links aus - Bewerbungen und von LinkedIn über die Weiterleitung weiter. - Läuft sie aus, laufen diese Links ins Leere und der Name wird - für jeden frei -- [ ] Alte DNS-Einträge in der `.dev`-Zone aufräumen, sobald über den - Punkt darüber entschieden ist: `A ftp` auf den alten +- [ ] Das Umbenennen aus 5.5, Schritt 5, einmal proben. Das geht ohne + Ausfallzeit an einer Kopie: einspielen, umbenennen, löschen +- [ ] Alte DNS-Einträge in der `.dev`-Zone aufräumen, seit der + Entscheidung vom 15.09.2026 freigegeben: `A ftp` auf den alten Webhosting-Server, die drei `hostingermail-*._domainkey`, die beiden `MX`, `autodiscover`, `autoconfig` und der SPF-Eintrag. **Nicht anfassen:** `A @`, `AAAA @` und `CNAME www`, die zeigen - auf den VPS und tragen die Weiterleitung + auf den VPS und tragen die Weiterleitung bis zum Ablauf +- [ ] Ab dem 09.02.2027, nach dem Ablauf: den Nginx-Block + `benjaminblarr-dev` (Abschnitt 4.6) samt Verweis in + `sites-enabled` und das Zertifikat für `benjaminblarr.dev` vom + Server entfernen. Sonst versucht certbot weiter, ein Zertifikat + für eine Domain zu erneuern, die es nicht mehr gibt + +### Erledigt am 15.09.2026 + +- [x] Einspielen beider Sicherungsarten in eine Kopie der Datenbank + geprobt, fehlerfrei, siehe 5.5 und `deploy/restore_probe.sh` +- [x] Entschieden: `benjaminblarr.dev` wird nicht verlängert und läuft + am 06.02.2027 aus. Alte Links aus Bewerbungen und von LinkedIn + führen danach ins Leere, und der Name wird für jeden frei ### Erledigt am 29.07.2026 diff --git a/deploy/restore_probe.sh b/deploy/restore_probe.sh new file mode 100644 index 0000000..5d555e1 --- /dev/null +++ b/deploy/restore_probe.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Restore probe for the Coderr database. Loads one backup into a throwaway +# database, checks it against the live database and drops it again. +# The live database is only read, never written. +# ssh vps "bash -s -- " < restore_probe.sh +set -euo pipefail + +APP_DIR=/var/www/coderr/backend +TEST_DB=coderr_restore_test + +backup="${1:-}" +if [ ! -r "$backup" ]; then + echo "Usage: restore_probe.sh " >&2 + exit 1 +fi + +# Never add "set -x" to this script: it would print the database password. +env_value() { + local value + value="$(grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\r\"'")" || true + if [ -z "$value" ]; then + echo "$1 is missing in .env" >&2 + exit 1 + fi + printf '%s' "$value" +} + +cd "$APP_DIR" +live_db="$(env_value DB_NAME)" +PGHOST="$(env_value DB_HOST)" +PGPORT="$(env_value DB_PORT)" +PGUSER="$(env_value DB_USER)" +PGPASSWORD="$(env_value DB_PASSWORD)" +export PGHOST PGPORT PGUSER PGPASSWORD + +if [ "$live_db" = "$TEST_DB" ]; then + echo "The test database name equals the live database, aborting." >&2 + exit 1 +fi + +sql() { + psql -X -At -v ON_ERROR_STOP=1 -d "$1" -c "$2" +} + +if [ "$(sql "$live_db" "select count(*) from pg_database where datname = '$TEST_DB'")" != "0" ]; then + echo "$TEST_DB already exists and is left untouched, aborting." >&2 + exit 1 +fi + +created=0 +errors="$(mktemp)" +cleanup() { + rm -f "$errors" + if [ "$created" = 1 ]; then + dropdb --if-exists "$TEST_DB" && echo "--- Dropped $TEST_DB ---" + fi +} +trap cleanup EXIT + +echo "Backup: $backup ($(du -h "$backup" | cut -f1))" +echo "Live: $live_db, PostgreSQL $(sql "$live_db" 'show server_version')" + +createdb "$TEST_DB" +created=1 + +started=$SECONDS +if ! gunzip -c "$backup" | psql -X -q -v ON_ERROR_STOP=1 -d "$TEST_DB" >/dev/null 2>"$errors"; then + echo "--- Restore FAILED ---" + cat "$errors" + exit 1 +fi +echo "--- Restore finished in $((SECONDS - started)) s, messages below (empty is good) ---" +cat "$errors" + +# Exact counts: n_live_tup in pg_stat_user_tables is only an estimate. +count_rows() { + sql "$1" "select c.relname || ' ' || (xpath('/row/n/text()', query_to_xml( + format('select count(*) as n from %I.%I', n.nspname, c.relname), false, true, '')))[1] + from pg_class c join pg_namespace n on n.oid = c.relnamespace + where c.relkind = 'r' and n.nspname = 'public'" | LC_ALL=C sort +} + +echo "--- Row counts: table, backup, live ---" +LC_ALL=C join -a1 -a2 -e missing -o 0,1.2,2.2 \ + <(count_rows "$TEST_DB") <(count_rows "$live_db") \ + | awk '{ printf "%-45s %8s %8s%s\n", $1, $2, $3, ($2 == $3 ? "" : " <- differs") }' + +# A sequence below the highest id makes the next insert fail with a +# duplicate key, which a row count alone would never reveal. +echo "--- Sequences behind their table (empty is good) ---" +sql "$TEST_DB" " + select t.tbl || '.' || t.col || ' max=' || t.max_id || ' seq=' || coalesce(t.seq_value::text, 'unused') + from ( + select format('%I.%I', n.nspname, c.relname) as tbl, + a.attname as col, + (xpath('/row/m/text()', query_to_xml( + format('select max(%I) as m from %I.%I', a.attname, n.nspname, c.relname), + false, true, '')))[1]::text::bigint as max_id, + pg_sequence_last_value( + pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname)::regclass + ) as seq_value + from pg_attribute a + join pg_class c on c.oid = a.attrelid + join pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'public' and c.relkind = 'r' and a.attnum > 0 and not a.attisdropped + and pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) is not null + ) t + where coalesce(t.max_id, 0) > coalesce(t.seq_value, 0)" + +# load_dotenv() does not override variables that are already set, so this +# points Django at the restored copy while everything else comes from .env. +echo "--- Migrations the deployed code would still apply to the copy ---" +DB_NAME="$TEST_DB" .venv/bin/python manage.py migrate --plan