diff --git a/deploy/server-backup/restic-backup.env.example b/deploy/server-backup/restic-backup.env.example index e138d80..9031fdf 100644 --- a/deploy/server-backup/restic-backup.env.example +++ b/deploy/server-backup/restic-backup.env.example @@ -5,7 +5,7 @@ # ── Restic repositories ──────────────────────────────────────────────────── # Local NAS path (must be mounted before the timer fires; see runbook). -RESTIC_REPO_NAS=/mnt/backup-server/restic/scrum4me-srv +RESTIC_REPO_NAS=/mnt/nas/backups/restic/scrum4me-srv # Backblaze B2 repo, format: b2:: # Bucket must have Object Lock (Governance) with default retention >= 30 days. @@ -26,7 +26,7 @@ B2_ACCOUNT_KEY=REPLACE_WITH_B2_APPLICATION_KEY # ── Forgejo backup target (optional — set to skip if Forgejo not deployed) ─ # Container name as it appears in `docker ps`. Set to "" or comment out to # skip the Forgejo phases entirely. -FORGEJO_CONTAINER=forgejo +FORGEJO_CONTAINER=scrum4me-forgejo # Path to app.ini INSIDE the Forgejo container (used by `forgejo dump -c`). FORGEJO_CONFIG=/data/gitea/conf/app.ini # Postgres database name for Forgejo (empty = use SQLite, skip forgejo_db_dump). diff --git a/deploy/server-backup/restore-test.sh b/deploy/server-backup/restore-test.sh index d05ae3d..0cc8dae 100644 --- a/deploy/server-backup/restore-test.sh +++ b/deploy/server-backup/restore-test.sh @@ -80,9 +80,19 @@ if [ -z "$SNAPSHOT_ID" ]; then exit 1 fi -echo "Restoring snapshot $SNAPSHOT_ID …" +echo "Restoring snapshot $SNAPSHOT_ID (filtered) …" +# Restore ALLEEN de paden waar we op asserten — een full restore zou disk +# nodig hebben gelijk aan de restore-size van de snapshot (honderden GiB) en +# is voor een correctheids-test onnodig. /tmp is vaak tmpfs of klein — +# vandaar dat een full restore daar onmiddellijk vastloopt op ENOSPC. +# Houd deze lijst gesynchroniseerd met ASSERTION_PATHS hieronder. RESTORE_RC=0 -restic -r "$REPO" restore "$SNAPSHOT_ID" --target "$RESTORE_DIR" || RESTORE_RC=$? +restic -r "$REPO" restore "$SNAPSHOT_ID" --target "$RESTORE_DIR" \ + --include /srv/scrum4me/compose/docker-compose.yml \ + --include /srv/scrum4me/caddy/Caddyfile \ + --include /etc/restic-backup.env \ + --include /var/backups/databases \ + || RESTORE_RC=$? if [ "$RESTORE_RC" -ne 0 ]; then echo "ERROR: restic restore exited $RESTORE_RC" diff --git a/deploy/server-backup/server-backup.service b/deploy/server-backup/server-backup.service index 6d4fc4b..a1034a9 100644 --- a/deploy/server-backup/server-backup.service +++ b/deploy/server-backup/server-backup.service @@ -1,8 +1,11 @@ [Unit] Description=Server-wide backup (pg_dumpall + restic to NAS + B2) -Documentation=file:///srv/ops/repos/ops-dashboard/docs/runbooks/server-backup.md +Documentation=file:///srv/scrum4me/ops-dashboard/docs/runbooks/server-backup.md After=network-online.target docker.service Wants=network-online.target +# NAS-mount moet beschikbaar zijn voordat restic naar de NAS-repo schrijft; +# triggert de cifs automount voor /mnt/nas/backups als die nog niet actief is. +RequiresMountsFor=/mnt/nas/backups [Service] Type=oneshot @@ -14,8 +17,10 @@ Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 # Sandboxing — backup needs root for /etc + docker exec, but limit the rest. +# /mnt/nas/backups MOET in ReadWritePaths anders kan restic niet naar de +# NAS-repo schrijven door ProtectSystem=strict. ProtectSystem=strict -ReadWritePaths=/var/backups /srv/backups /run /tmp +ReadWritePaths=/var/backups /srv/backups /run /tmp /mnt/nas/backups ProtectHome=read-only NoNewPrivileges=yes PrivateTmp=yes diff --git a/deploy/server-backup/server-backup.sh b/deploy/server-backup/server-backup.sh index 96042eb..0afdb55 100644 --- a/deploy/server-backup/server-backup.sh +++ b/deploy/server-backup/server-backup.sh @@ -176,8 +176,12 @@ dump_forgejo() { # `forgejo dump -f -` streams the zip to stdout. We run as the `git` user # inside the container (standard Forgejo image convention). + # + # NB: Forgejo 11.x heeft GEEN `--skip-db` flag (verwijderd na de Gitea-fork); + # de DB komt dus mee in de zip. Onze separate `forgejo_db_dump`-fase blijft + # de autoritatieve restore-bron — de in-zip DB-dump is een redundante kopie. set -o pipefail - docker exec -u git "$fj" forgejo dump --skip-db -c "$config" --type zip -f - > "$tmp" + docker exec -u git "$fj" forgejo dump -c "$config" --type zip -f - > "$tmp" local rc=$? set +o pipefail @@ -460,15 +464,18 @@ determine_exit_code() { critical_failure=true fi + # NB: deze functie wordt direct (niet via $(...)) aangeroepen, anders gaan + # de OVERALL_STATUS-assignments verloren in de subshell — write_status_json + # zou dan "unknown" wegschrijven en de eind-banner idem. if [ "$critical_failure" = true ]; then OVERALL_STATUS="failed" - echo 1 + EXIT_CODE=1 elif [ "$has_failure" = true ] || [ "$has_degraded" = true ]; then OVERALL_STATUS="partial_failure" - echo 75 + EXIT_CODE=75 else OVERALL_STATUS="success" - echo 0 + EXIT_CODE=0 fi } @@ -482,7 +489,7 @@ run_phase forget_nas restic_forget_nas run_phase check_nas restic_check_nas run_phase check_b2 restic_check_b2 -EXIT_CODE=$(determine_exit_code) +determine_exit_code # sets OVERALL_STATUS + EXIT_CODE in this shell write_status_json echo "" diff --git a/docs/runbooks/server-backup.md b/docs/runbooks/server-backup.md index dd1fe11..d96c093 100644 --- a/docs/runbooks/server-backup.md +++ b/docs/runbooks/server-backup.md @@ -39,7 +39,7 @@ versioned backup met twee onafhankelijke kopieën — **NAS** lokaal en ## Voorwaarden (input van de gebruiker nodig) -- **NAS-mount** — pad zoals `/mnt/backup-server` met genoeg ruimte (initieel ≥ 100 GB; restic is gededupliceerd, dus daarna groeit het traag). +- **NAS-mount** — pad zoals `/mnt/nas/backups` met genoeg ruimte (initieel ≥ 100 GB; restic is gededupliceerd, dus daarna groeit het traag). - **Backblaze B2-account** — credit-card geregistreerd, bucket aanmaken vereist een operator-actie. - **Restic-wachtwoord** — `openssl rand -hex 24`, bewaard in je password manager **én** in `/etc/restic-backup.password` op de server. Beide nodig — kwijt op één plek = repo onleesbaar. - **B2 maintenance-key** — bewaard alleen op je laptop in passwordmanager. Niet op de server. @@ -64,11 +64,34 @@ Uit te voeren als `root` op `scrum4me-srv`. sudo chmod 0750 /srv/backups/logs /srv/backups/status ``` -3. **NAS-mount controleren / aanmaken** +3. **NAS-mount aanmaken** — een nieuwe mount op `/mnt/nas/backups` die naar + de subdir `backups` van de bestaande `ssd`-share op de NAS wijst. Geen + nieuwe Samba-share op de NAS nodig — de cifs-`prefixpath`-optie mount het + subpad direct. + ```bash - mountpoint -q /mnt/backup-server && echo "OK" || echo "NIET gemount" + # 1. Subdir op de NAS aanmaken via de bestaande ssd-mount + sudo mkdir -p /mnt/nas/ssd/backups + + # 2. cifs-utils geïnstalleerd? (voor andere /mnt/nas-shares is dat al zo) + dpkg -l | grep -q '^ii cifs-utils' || sudo apt install -y cifs-utils + + # 3. Fstab-regel toevoegen — uid/gid=0 + mode 0700/0600 = root-only + sudo tee -a /etc/fstab <<'EOF' + //192.168.0.155/ssd /mnt/nas/backups cifs credentials=/etc/samba/credentials-nas,uid=0,gid=0,iocharset=utf8,vers=3.0,nofail,_netdev,x-systemd.automount,prefixpath=backups,file_mode=0600,dir_mode=0700 0 0 + EOF + + # 4. systemd reload + mount + sudo systemctl daemon-reload + sudo mount /mnt/nas/backups + mountpoint -q /mnt/nas/backups && echo "OK" || echo "FAIL" + df -h /mnt/nas/backups ``` - Zo nee: `fstab`-regel toevoegen, `systemctl daemon-reload`, `mount -a`. Zorg dat de mount automatisch terugkomt bij reboot — anders crashed de eerste backup-run na een reboot. + + `_netdev,x-systemd.automount,nofail` zorgt dat de mount automatisch terugkomt + bij reboot zónder de boot te laten hangen als de NAS even weg is. De + `RequiresMountsFor=/mnt/nas/backups` in `server-backup.service` triggert + bovendien de automount voor de timer-run. 4. **Restic-wachtwoord genereren en plaatsen** ```bash @@ -113,7 +136,7 @@ Doel: een bucket waarvan **bestaande** snapshots niet door de server gewist kunn 4. **`/etc/restic-backup.env` aanmaken** ```bash - sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/restic-backup.env.example \ + sudo cp /srv/scrum4me/ops-dashboard/deploy/server-backup/restic-backup.env.example \ /etc/restic-backup.env sudo chmod 0600 /etc/restic-backup.env sudo chown root:root /etc/restic-backup.env @@ -170,16 +193,16 @@ Doel: een bucket waarvan **bestaande** snapshots niet door de server gewist kunn 1. **Scripts kopiëren** ```bash - sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.sh /srv/backups/scripts/ - sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/restore-test.sh /srv/backups/scripts/ + sudo cp /srv/scrum4me/ops-dashboard/deploy/server-backup/server-backup.sh /srv/backups/scripts/ + sudo cp /srv/scrum4me/ops-dashboard/deploy/server-backup/restore-test.sh /srv/backups/scripts/ sudo chmod 0750 /srv/backups/scripts/*.sh sudo chown root:root /srv/backups/scripts/*.sh ``` 2. **Systemd-units kopiëren** ```bash - sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.service /etc/systemd/system/ - sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.timer /etc/systemd/system/ + sudo cp /srv/scrum4me/ops-dashboard/deploy/server-backup/server-backup.service /etc/systemd/system/ + sudo cp /srv/scrum4me/ops-dashboard/deploy/server-backup/server-backup.timer /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now server-backup.timer ``` @@ -241,8 +264,8 @@ Noteer: ### F2. Configpaden in de container ```bash -docker inspect --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}' -docker exec ls -la /data/gitea/conf/app.ini +docker inspect scrum4me-forgejo --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}' +docker exec scrum4me-forgejo ls -la /data/gitea/conf/app.ini ``` Standaard: `app.ini` in `/data/gitea/conf/app.ini` binnen de container. Wijkt dat af, pas `FORGEJO_CONFIG=` in `/etc/restic-backup.env` aan. @@ -250,7 +273,7 @@ Standaard: `app.ini` in `/data/gitea/conf/app.ini` binnen de container. Wijkt da ### F3. DB-koppeling controleren ```bash -docker exec grep -E '^DB_TYPE|^HOST|^NAME|^USER' /data/gitea/conf/app.ini +docker exec scrum4me-forgejo grep -E '^DB_TYPE|^HOST|^NAME|^USER' /data/gitea/conf/app.ini ``` - `DB_TYPE=postgres` met `NAME=forgejo` ⇒ zet `FORGEJO_DB_NAME=forgejo`, en als de Postgres-container niet `scrum4me-postgres` is: `FORGEJO_DB_CONTAINER=...`. @@ -425,7 +448,7 @@ Doel: B2-snapshots ouder dan retention-policy daadwerkelijk pruning, plus een di | Symptoom | Oorzaak | Fix | |---|---|---| -| `unable to open repository ... no such file or directory` (NAS) | NAS-mount weg na reboot | `mountpoint -q /mnt/backup-server` — fix `fstab`/`autofs`; herstart `server-backup.service` | +| `unable to open repository ... no such file or directory` (NAS) | NAS-mount weg na reboot | `mountpoint -q /mnt/nas/backups` — fix `fstab`/`autofs`; herstart `server-backup.service` | | `unable to open repository ... AccessDenied` (B2) | server-key heeft verkeerde capabilities of bucket-prefix | check `b2 application-key list`; capabilities moeten `listBuckets,listFiles,readFiles,writeFiles` zijn, name-prefix moet matchen | | `Object Lock In Place` bij `forget --prune` op B2 | server probeert ten onrechte B2 te prunen (heeft die capability niet) | het script prune'd alleen NAS — als deze fout opduikt: handmatige `restic forget` op B2 gedraaid (zou off-server moeten); gebruik maintenance-key | | `restic snapshot tag scheduled` ontbreekt in UI | run heeft `--tag scheduled` niet meegekregen | check script — `restic_backup_to` zet beide tags hardcoded | @@ -453,10 +476,87 @@ Doel: B2-snapshots ouder dan retention-policy daadwerkelijk pruning, plus een di --- -# Addendum — uitvoering +# Addendum — uitvoering 2026-05-15 -> Vul deze sectie na de eerste uitvoering met alle afwijkingen van het plan -> hierboven: exacte Forgejo container-naam, image-versie, eventuele paden die -> anders bleken, sudoers-precieze regels, Object Lock-retention die je gekozen -> hebt, B2 key-IDs (geredacteerd), tijden van eerste runs, etc. Zelfde -> discipline als [tailscale-setup.md](tailscale-setup.md). +Eerste install op `scrum4me-srv`. Werk begon met dat alleen `restic` + `jq` +geïnstalleerd waren — Deel A2-Deel E + restore-test draaiden in deze sessie. + +## Vastgestelde topologie en concrete waarden + +| Plan-placeholder | Werkelijkheid op `scrum4me-srv` | +|---|---| +| Repo-pad in deploy-stappen | `/srv/scrum4me/ops-dashboard/` (runbook had `/srv/ops/repos/...` — bestaat niet op deze server). Ook in `recovery.md` en `deploy/ops-dashboard-updater/update.sh` staan nog `/srv/ops/repos`-verwijzingen — losse cleanup-taak. | +| NAS-mount | `/mnt/nas/backups` via cifs-`prefixpath=backups` op `//192.168.0.155/ssd`. **Geen aparte Samba-share aangemaakt** — de subdir `backups/` op de bestaande `ssd`-share is genoeg dankzij `prefixpath`. fstab-regel: `uid=0,gid=0,prefixpath=backups,file_mode=0600,dir_mode=0700,_netdev,x-systemd.automount,nofail`. | +| B2-bucket-naam | **`ScrumForMeSrvBackup`** (PascalCase) — niet de in het plan voorgestelde `scrum4me-srv-backup`. `RESTIC_REPO_B2=b2:ScrumForMeSrvBackup:scrum4me-srv` (case-sensitive). | +| B2-bucket-instellingen | Object Lock = Enabled, Mode = Governance, Default Retention = 30 days. Geen lifecycle rules. | +| B2 server-key capabilities | `listBuckets,listFiles,readFiles,writeFiles` (gemaakt via webportal als "Read and Write" — daar zat de juiste capability-set automatisch in). Geen `deleteFiles`, geen `bypassGovernance`. | +| B2 storage-cap | $10/maand. Bij 16 GB op B2 (zie cijfers onder) is dat $0,10/maand storage — ruim binnen de cap. | +| B2 maintenance-key | **Nog niet aangemaakt** — pas nodig bij eerste maandelijkse prune. Aanmaken vanaf laptop, met `Allow file deletes` en `Allow bypass governance retention` aangevinkt. | +| Forgejo-container | **`scrum4me-forgejo`** (image `codeberg.org/forgejo/forgejo:11`). | +| Forgejo `git`-user | uid 1000, bestaat ✓ — `docker exec -u git scrum4me-forgejo` werkt. | +| Forgejo data-locatie | docker named volume `forgejo_forgejo-data` (NIET `/srv/forgejo/data/...` zoals het runbook nog noemt — die paden bestaan niet maar de excludes zijn no-ops). | +| Forgejo-DB | rol `forgejo`, db `forgejo`, in `scrum4me-postgres`-container (zelfde Postgres als ops_dashboard, scrum4me). | +| Postgres data live | bind-mount `/srv/scrum4me/postgres/` (excluded). | +| restic-password locatie | `/etc/restic-backup.password` (mode 0400, root:root). Óók in passwordmanager onder "restic — scrum4me-srv". | +| systemd-timer | `server-backup.timer` enabled, dagelijks 03:30 + max 10 min randomized delay. | + +## Wijzigingen aan de in commit `ab87c0f` gemergede code + +Door deze sessie heen vier kleine fixes nodig gebleken — allemaal in deze PR: + +- **`server-backup.sh`**: `--skip-db` weggehaald uit `dump_forgejo`. Forgejo + 11.x heeft die flag niet (verwijderd na de Gitea-fork). Output van + `forgejo dump --help`: alleen `--skip-repository|-log|-custom-dir|-lfs-data|-attachment-data|-package-data|-index|-repo-archives`. De DB komt nu mee in de zip — redundant met `forgejo_db_dump`-fase, maar onschuldig. +- **`server-backup.sh`**: subshell-bug in `determine_exit_code` — werd aangeroepen via `EXIT_CODE=$(determine_exit_code)`, dus `OVERALL_STATUS=...` werd in de subshell gezet en lekte niet naar de parent. Resultaat: `last-run.json` schreef altijd `overall_status: "unknown"` en de eind-banner idem. Fix: directe call die zowel `OVERALL_STATUS` als `EXIT_CODE` in de parent-shell zet. +- **`restore-test.sh`**: deed een **full restore** zonder `--include`-filter — probeerde 476 GiB naar `/tmp` (7.6 GB tmpfs) te schrijven, ENOSPC + 3.3M errors + alle assertions "missing". Gefixt met `--include` op alleen de assertion-paden (`/etc/restic-backup.env`, `/srv/scrum4me/{compose/docker-compose.yml,caddy/Caddyfile}`, `/var/backups/databases`). Restore is nu 59 MB in 10s. +- **`server-backup.service`**: `RequiresMountsFor=/mnt/nas/backups` toegevoegd (triggert cifs-automount bij timer-fire), `ReadWritePaths` uitgebreid met `/mnt/nas/backups` (`ProtectSystem=strict` blokkeert anders schrijven naar de NAS-repo), `Documentation=`-URL gecorrigeerd. De pre-existing `RuntimeMaxSec= has no effect with Type=oneshot`-warning is cosmetisch en niet aangepakt. + +## Onderweg geleerde quirks + +- **`sudo -E` werkt niet op deze sudoers** — geeft warning `preserving the entire environment is not supported`. Niet erg: de scripts sourcen het env-file binnen de sudo'd shell zelf (`sudo bash -c '. /etc/restic-backup.env; ...'`), dus `-E` is overbodig. +- **B2 401-error op `b2_list_buckets` was misleidend** — keys waren prima (`b2_authorize_account` werkte), het probleem was dat `RESTIC_REPO_B2` een andere bucket-naam had dan waar de key voor scoped is. B2 geeft dan 401 i.p.v. 403/404. +- **B2 cap-error verschijnt als `403: Cannot upload files, storage cap exceeded`** — niet 402/payment-related. Cap kan op nul staan voor accounts die nog nooit een bucket vol hadden; verhogen via *Account → Caps & Alerts → Storage Cap*. +- **47× dedup** op de eerste snapshot — vooral door de drie git-repos in `/srv/scrum4me/repos/` plus de ~12k worker-log files in `/srv/scrum4me/worker-logs/idea/runs/` met veel overlap. + +## Eerste-run-cijfers + +``` +NAS: 16 GB op disk (du) + Restore-size: 974 GiB (over 2 snapshots; ~487 GiB per snap) + Raw-data: 20.6 GiB (post-dedup) + On-disk: 15.6 GiB (post-compressie, 1.32x) + Snapshots: 2 (eerste run + post-fix re-run) + +B2: ~16 GB op disk (vergelijkbare dedup + compressie) + Snapshots: 1 (na cap-bump + script-fix) + Storage-kost: ≈ $0,10/maand bij huidige grootte + +Eerste run (forgejo+B2 faalden): 47:42 wall-clock +Tweede run (alles success): ~15 min +Restore-test (na --include fix): 10s, 59 MiB gerestored +Files in snapshot: ~2.1M (1.9M unique blobs) +``` + +## Verificatie-status + +| Plan-stap | Status | +|---|---| +| 1. Eerste run slaagt | ✓ (na 2 attempts; success in run 2 om 15:23) | +| 2. Snapshots zichtbaar | ✓ NAS×2, B2×1 | +| 3. Restore-test slaagt | ✓ 4/4 assertions ok in 10s | +| 4. Forgejo-restore-stack (F5) | ✗ niet uitgevoerd — separate vervolg | +| 5. Reboot-test | ✗ niet uitgevoerd — productie-reboot, los moment | +| 6. Failure-injectie | ✗ niet bewust uitgevoerd; we hebben **wel** organisch failure paths gezien (B2-cap, forgejo `--skip-db`) en die rapporteerden zoals verwacht (exit 75, juiste per-phase-status) | +| 7. Concurrency | ✗ niet getest — `flock`-pad zit in script | +| 8. Maandelijkse maintenance vanaf laptop | — over een maand, met dan-aan-te-maken maintenance-key | + +## Te bewerken bestanden op `scrum4me-srv` + +- `/etc/fstab` — extra cifs-regel voor `/mnt/nas/backups` (zie Deel A3). +- `/etc/restic-backup.env` — secrets, mode 0600 root:root. +- `/etc/restic-backup.password` — mode 0400 root:root, óók in passwordmanager. +- `/etc/systemd/system/server-backup.{service,timer}` — uit de repo's + `deploy/server-backup/`. +- `/srv/backups/{scripts,logs,status}/`, `/var/backups/databases/`. +- `/srv/scrum4me/ops-dashboard/deploy/server-backup/*` — code uit deze PR + (na `git pull` op de server).