Merge pull request #13 from madhura68/feat/server-backup-host-paths

fix(server-backup): host-paths + script bugs uit eerste install
This commit is contained in:
Janpeter Visser 2026-05-15 17:01:10 +02:00 committed by GitHub
commit ec7c5a616a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 152 additions and 30 deletions

View file

@ -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-name>:<prefix>
# 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).

View file

@ -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"

View file

@ -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

View file

@ -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 ""

View file

@ -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 <forgejo> --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}'
docker exec <forgejo> 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 <forgejo> 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).