v0.7.96-beta - backups echt getest, terugrollen, pullen, reconfigure en purge
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 16m26s

- tools/backup_rondgang.py draait backup en terugzetten tegen een echte
  Docker-daemon in de CI: postgres-sidecar vullen, backuppen, alles weggooien,
  terugzetten, en controleren dat de database daarna nog kan schrijven. Alle
  bestaande backuptests vervangen Docker door een nep, dus die keten was nooit
  bewezen
- daardoor gevonden: het terugzetten gaf de appdata de verkeerde eigenaar. Het
  archief bevat de echte uid (postgres draait als 70 en zegt dat nergens in de
  metadata); die gaat nu voor op PUID en op onze eigen uid
- een mislukte --update rolt terug naar de commit van ervoor en start die
  opnieuw, in plaats van een stilstaande server en een rijtje commando's
- met een registry-image (SU_IMAGE) wordt er gepulld in plaats van gebouwd
- nieuw --reconfigure: bind, poort, account of hoofdmap wijzigen zonder de
  broncode aan te raken
- nieuw --uninstall --purge: ook het volume, de gegevensmap en het account,
  per onderdeel gevraagd. Zonder --purge somt --uninstall nu op wat blijft
- --dir wordt op een volledig pad gecontroleerd, het tijdelijke bestand voor
  het beheerdersaccount is niet meer voorspelbaar, en er gaat nog een
  rondgang naar de docker-daemon in plaats van twee

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-03 15:45:14 +02:00
parent 3bee664484
commit 504bb310ee
9 changed files with 755 additions and 40 deletions

View file

@ -236,6 +236,28 @@ jobs:
cd "$DEPLOY_DIR" && docker compose logs --tail=200
exit 1
# De backuptests vervangen Docker allemaal door een nepversie. Dat toetst
# de logica maar niet de keten: pg_dump via `docker exec`, het archief,
# het terugzetten, en of de database daarna nog schrijft. Juist daar
# betekent fout zijn gegevensverlies.
#
# Draait op het zojuist gebouwde image — dus met exact de Python en de
# docker-CLI van productie. De werkmap wordt op hetzelfde pad gemount,
# want de bind mount van de postgres-container wordt door de daemon op de
# host opgezocht, niet in deze container.
- name: Backup en terugzetten met echte Docker
run: |
set -euo pipefail
PROEF="$DEPLOY_DIR/.backup-rondgang"
rm -rf "$PROEF"; mkdir -p "$PROEF"
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PROEF:$PROEF" \
-v "$PWD:/w" -w /w \
-e PYTHONDONTWRITEBYTECODE=1 \
"server-up:${VERSION}" \
python tools/backup_rondgang.py "$PROEF"
# `--doctor` is ruim honderd regels shell die alleen tegen een échte
# installatie draaien. De pytest-suite komt niet verder dan "er is geen
# Docker"; hier wel.

View file

@ -1,3 +1,55 @@
# v0.7.96-beta — Backups eindelijk écht getest, en terugrollen
**Backups waren nooit tegen een echte Docker-daemon gedraaid.** Alle
backuptests vervangen Docker door een nepversie — vijftien keer in
`test_backups.py` alleen al. Dat toetst de logica maar niet de keten waar het om
gaat, en juist dáár betekent fout zijn gegevensverlies.
`tools/backup_rondgang.py` draait die keten nu wel echt, in de CI, op het image
dat ook in productie draait: een stack met een postgres-sidecar starten en
vullen, backuppen, de tabel en de appdata weggooien, terugzetten, en controleren
dat de rij er weer staat én dat de database nog kan schríjven. Dat laatste is de
vraag waar het om draait — een datamap van de verkeerde eigenaar geeft pas bij
de eerste schrijfactie een fout.
Dat bracht meteen een fout aan het licht die de unittests niet konden zien:
**het terugzetten gaf de appdata de verkeerde eigenaar.** `tarfile` met
`filter="data"` laat uid en gid vallen, en de terugval was de `PUID` uit de
metadata — of anders onze eigen uid. Maar een postgres-container draait als uid
70 en zegt dat nergens in de metadata. Het archief wéét het wel: die uid staat
er gewoon in. Die gaat nu voor, met de `PUID` als tweede en onze eigen uid als
laatste antwoord.
**Een mislukte `--update` rolt nu terug.** De commit van vóór het bijwerken
wordt onthouden; komt de nieuwe versie niet omhoog, dan gaat de broncode terug
en start die opnieuw. Je houdt geen stilstaande server over en geen rijtje
commando's dat je zelf moet uitvoeren.
**Installeren gaat sneller als er een registry-image is.** Staat er een
`SU_IMAGE` met een registrypad in `.env`, dan haalt het script dat op in plaats
van zelf te bouwen — dat scheelt de paar minuten die een build kost. Zonder
blijft het bouwen, want dan bestaat het image nergens anders.
**Nieuw: `--reconfigure`.** Bind-adres, poort, account of hoofdmap wijzigen
zonder de broncode aan te raken. Daarvoor moest je `--update` draaien, en dan
haalde je ongewild ook nieuwe code binnen.
**Nieuw: `--uninstall --purge`.** Verwijdert ook het `su-data`-volume, je
gegevensmap en het aangemaakte account — per onderdeel apart gevraagd, met de
omvang van de gegevensmap erbij. Zonder `--purge` somt `--uninstall` nu op wat
er blijft staan, inclusief het account, in plaats van dat te verzwijgen.
Verder:
- `--dir` wordt net als `--base-dir` op een volledig pad gecontroleerd. Met
`curl … | sh` is de werkmap je thuismap, en dit pad gaat letterlijk naar
compose.
- Het tijdelijke bestand voor het aanmaken van de beheerder viel terug op
`/tmp/su-setup.$$` als `mktemp` faalde. Die naam is voorspelbaar en `/tmp` is
voor iedereen schrijfbaar.
- `controleer_docker` deed twee rondgangen naar de daemon (`docker info` én
`docker version`) waar er één volstaat.
# v0.7.95-beta — Bijwerken vanuit de interface, en een script dat nakijkt
Het tweede deel van de controle op het installatiepad.

View file

@ -1 +1 @@
0.7.95-beta
0.7.96-beta

View file

@ -60,6 +60,10 @@ schrijven én `docker` aanroepen, dan blijft alles onder je eigen account.
--create-admin Alleen het account aanmaken, bij een draaiende installatie
--update Bijwerken naar de nieuwste versie
--uninstall Stoppen en verwijderen (gegevens blijven staan)
--purge Bij --uninstall: ook het volume, je gegevensmap en het
aangemaakte account weg. Vraagt per onderdeel.
--reconfigure Bind, poort, account of hoofdmap wijzigen zonder opnieuw te
installeren
--doctor Een bestaande installatie doorlichten
--yes Niets vragen
--dry-run Alleen tonen wat er zou gebeuren
@ -201,6 +205,37 @@ De exitcode is 1 als er fouten zijn, zodat je hem in een controle kunt hangen.
---
## Instellingen wijzigen
Wil je alleen het bind-adres, de poort of het account veranderen, dan hoef je
niet bij te werken — dat zou ongewild ook nieuwe code binnenhalen:
```bash
sh /opt/server-up/install.sh --reconfigure
```
Hij toont wat er nu staat, stelt dezelfde vragen als bij de installatie en start
de container opnieuw. De broncode blijft ongemoeid.
---
## Bijwerken en terugrollen
```bash
sh /opt/server-up/install.sh --update
```
Komt de nieuwe versie niet omhoog, dan zet het script de broncode terug op de
commit van vóór het bijwerken en start die opnieuw. Lukt dat, dan draai je weer
op je oude versie en is de exitcode 1 — geen stilstaande server en geen rijtje
commando's dat je zelf moet uitvoeren.
Draait je installatie vanaf een registry-image (`SU_IMAGE` in `.env`), dan haalt
het script dat op in plaats van zelf te bouwen. Dat scheelt de paar minuten die
een build kost.
---
## Het beheerdersaccount
Server Up beheert de Docker-daemon, en wie containers kan starten kan
@ -286,8 +321,17 @@ sh /opt/server-up/install.sh --uninstall
Stopt en verwijdert de container. **Je gegevens blijven staan**: het
docker-volume `su-data` (instellingen, accounts, auditlog) en je stackmappen
onder `BASE_DIR` (standaard `/opt/serverup`). Het script vertelt daarna hoe je
die alsnog opruimt.
onder `BASE_DIR` (standaard `/opt/serverup`). Het script somt op wat er blijft.
Alles definitief weg:
```bash
sh /opt/server-up/install.sh --uninstall --purge
```
Dat vraagt **per onderdeel** apart — het volume, je gegevensmap, het account dat
bij de installatie is aangemaakt, en de installatiemap zelf. Bij de gegevensmap
toont het eerst hoe groot die is, want daar staat alles van al je apps in.
---

View file

@ -42,6 +42,11 @@ BASE_DIR_EXPLICIET=0
# Heeft de server na het starten daadwerkelijk geantwoord? Bepaalt het slot en
# de exitcode; bij --dry-run is er niets om op te wachten.
GEREED=1
# De commit waar een --update vandaan kwam, zodat terugrollen kan als de nieuwe
# versie niet opkomt.
VORIGE_COMMIT=""
# Verwijdert --uninstall ook je gegevens? Standaard niet.
PURGE=0
# ── Uitvoer ──────────────────────────────────────────────────────────────────
# Kleuren alleen als we naar een terminal schrijven; in een pipe of logbestand
@ -132,6 +137,10 @@ Gebruik: install.sh [opties]
--update Bijwerken naar de nieuwste versie
--doctor Een bestaande installatie doorlichten
--uninstall Stoppen en verwijderen (je gegevens blijven staan)
--purge Bij --uninstall: ook het volume, je gegevensmap en het
aangemaakte account weg. Vraagt per onderdeel.
--reconfigure Bind, poort, account of hoofdmap wijzigen zonder
opnieuw te installeren
--yes Niets vragen, overal ja op antwoorden
--dry-run Alleen tonen wat er zou gebeuren
--help Deze uitleg
@ -167,6 +176,8 @@ while [ $# -gt 0 ]; do
fout "Gebruik --admin-password-file of SU_ADMIN_PASSWORD; een wachtwoord op de opdrachtregel is voor iedereen zichtbaar met 'ps'." ;;
--update) ACTIE="update"; shift ;;
--doctor) ACTIE="doctor"; shift ;;
--reconfigure) ACTIE="reconfigure"; shift ;;
--purge) PURGE=1; shift ;;
--uninstall) ACTIE="uninstall"; shift ;;
--create-admin) ACTIE="create-admin"; shift ;;
--yes|-y) JA=1; shift ;;
@ -190,6 +201,14 @@ geldig_bind() {
}
geldig_bind "$BIND" || fout "Ongeldig bind-adres: $BIND (verwacht een IP-adres, bijvoorbeeld 127.0.0.1 of 0.0.0.0)"
# Een relatief pad hangt af van waar je toevallig stond toen je het script
# aanriep, en met `curl … | sh` is dat je thuismap. De compose-aanroepen
# gebruiken dit pad letterlijk.
case "$DOEL" in
/*) DOEL="${DOEL%/}" ;;
*) fout "--dir vereist een volledig pad, beginnend met / (kreeg: $DOEL)" ;;
esac
# BASE_DIR wordt in de container op zichzelf gemount, dus een relatief pad zou
# binnen de container ergens anders uitkomen dan op de host. En een handvol
# systeemmappen wil je hier niet per ongeluk vullen met appdata.
@ -267,8 +286,12 @@ controleer_docker() {
fi
if heeft docker; then
if $SUDO docker info >/dev/null 2>&1; then
goed "Docker draait ($($SUDO docker version --format '{{.Server.Version}}' 2>/dev/null || echo '?'))"
# Eén vraag aan de daemon in plaats van twee: `docker version` heeft de
# daemon net zo goed nodig als `docker info`, en levert meteen iets om
# te tonen. Lukt het niet, dan reageert hij niet.
_dv="$($SUDO docker version --format '{{.Server.Version}}' 2>/dev/null || true)"
if [ -n "$_dv" ]; then
goed "Docker draait ($_dv)"
else
fout_tenzij_droog "Docker is geïnstalleerd maar de daemon reageert niet. Start hem met: systemctl start docker"
fi
@ -345,6 +368,11 @@ haal_broncode() {
zet_token_klaar
if [ -d "$DOEL/.git" ]; then
log "Bestaande installatie bijwerken in $DOEL"
# Onthouden waar we vandaan komen, zodat we terug kunnen als de nieuwe
# versie niet opkomt. Voor de update pas ophalen: bij een verse
# installatie is er niets om naar terug te gaan.
VORIGE_COMMIT="$($SUDO git -C "$DOEL" rev-parse HEAD 2>/dev/null || echo '')"
[ -n "$VORIGE_COMMIT" ] && log "Huidige versie: $(printf '%.9s' "$VORIGE_COMMIT")"
git_doe -C "$DOEL" fetch --depth 1 origin "$BRANCH"
# FETCH_HEAD en niet origin/$BRANCH: een tag krijgt geen origin/-ref,
# dus `--branch v0.7.92-beta` installeerde prima maar kon daarna nooit
@ -430,6 +458,12 @@ kies_bind() {
goed "Luistert op $BIND"
}
env_waarde() {
# env_waarde SLEUTEL — de waarde uit .env van de installatie, of leeg.
[ -f "$DOEL/.env" ] || return 0
sed -n "s/^$1=//p" "$DOEL/.env" 2>/dev/null | tail -1
}
zet_env_regel() {
# zet_env_regel BESTAND SLEUTEL WAARDE — vervangt de regel of voegt hem toe.
# Waarden zijn hier altijd al gevalideerd (cijfers, punten, dubbele punten).
@ -705,14 +739,41 @@ schrijf_env() {
}
# ── Starten ──────────────────────────────────────────────────────────────────
# Draait deze installatie vanaf een kant-en-klaar image uit een registry? Dan
# scheelt ophalen minuten ten opzichte van zelf bouwen. `SU_IMAGE` is het teken:
# staat er een registry-pad (met een schuine streep) in, dan valt er iets te
# halen. Zonder blijft het bouwen, want dan bestaat het image nergens anders.
uit_registry() {
_img="$(sed -n 's/^SU_IMAGE=//p' "$DOEL/.env" 2>/dev/null | tail -1)"
case "$_img" in
*/*) return 0 ;;
*) return 1 ;;
esac
}
start() {
stap "Bouwen en starten"
_compose="$SUDO docker compose -f $DOEL/docker-compose.yml --project-directory $DOEL"
if [ "$DROOG" -eq 0 ] && [ ! -f "$DOEL/docker-compose.yml" ]; then
fout "Geen docker-compose.yml in $DOEL — het ophalen van de broncode is misgegaan."
fi
if uit_registry; then
stap "Ophalen en starten"
log "Deze installatie draait vanaf een registry-image."
# shellcheck disable=SC2086
if doe $_compose pull; then
# shellcheck disable=SC2086
doe $_compose up -d --remove-orphans
goed "Container gestart"
return 0
fi
waarschuw "Ophalen mislukte; ik bouw het image zelf."
fi
stap "Bouwen en starten"
log "Dit duurt de eerste keer enkele minuten."
doe $SUDO docker compose -f "$DOEL/docker-compose.yml" --project-directory "$DOEL" \
up -d --build --remove-orphans
# shellcheck disable=SC2086
doe $_compose up -d --build --remove-orphans
goed "Container gestart"
}
@ -861,7 +922,12 @@ maak_beheerder() {
# Het wachtwoord gaat via stdin naar curl, niet via de opdrachtregel:
# argumenten zijn voor elke gebruiker op de server zichtbaar in 'ps'.
antwoord="$(mktemp 2>/dev/null || echo /tmp/su-setup.$$)"
# Geen voorspelbaar pad als terugval: /tmp is voor iedereen schrijfbaar,
# dus een ander kan er alvast een symlink neerleggen. Lukt mktemp niet,
# dan is er iets grondig mis en stoppen we liever.
antwoord="$(mktemp "${TMPDIR:-/tmp}/su-setup.XXXXXX")" \
|| fout "Kon geen tijdelijk bestand aanmaken."
chmod 600 "$antwoord"
code="$(printf '{"username":"%s","password":"%s"}' \
"$(json_tekst "$BEHEERDER")" "$(json_tekst "$BEHEERDER_WW")" \
| curl -sS -o "$antwoord" -w '%{http_code}' \
@ -944,6 +1010,27 @@ afsluiting() {
printf ' Documentatie: %s/README.md\n\n' "$DOEL"
}
# ── Terugrollen ──────────────────────────────────────────────────────────────
# Een update die niet opkomt liet je met een stilstaande server zitten en een
# rijtje commando's om zelf uit te voeren. De vorige commit is bekend, dus dat
# kan het script net zo goed zelf doen.
terugrollen() {
[ -n "$VORIGE_COMMIT" ] || return 1
stap "Terugrollen"
log "Terug naar $(printf '%.9s' "$VORIGE_COMMIT")"
if ! $SUDO git -C "$DOEL" reset --hard "$VORIGE_COMMIT" >/dev/null 2>&1; then
waarschuw "Kon niet terug naar de vorige versie."
return 1
fi
start
if wacht_op_gereed; then
goed "Terug op de vorige versie; die draait weer."
return 0
fi
waarschuw "Ook de vorige versie komt niet op; er is meer aan de hand."
return 1
}
# ── Doorlichten ──────────────────────────────────────────────────────────────
# De fouten die dit script had, waren allemaal van dezelfde soort: iets klopte
# niet en niemand merkte het, omdat er nergens werd nagekeken. Dit is het
@ -955,12 +1042,6 @@ dok_ok() { goed "$1"; }
dok_waar() { waarschuw "$1"; DOK_WAARSCHUWINGEN=$((DOK_WAARSCHUWINGEN + 1)); }
dok_fout() { printf ' %s✖%s %s\n' "$C_FOUT" "$C_UIT" "$1"; DOK_FOUTEN=$((DOK_FOUTEN + 1)); }
_dok_env() {
# _dok_env SLEUTEL — de waarde uit .env, of leeg.
[ -f "$DOEL/.env" ] || return 0
sed -n "s/^$1=//p" "$DOEL/.env" 2>/dev/null | tail -1
}
_dok_inspect() {
# _dok_inspect FORMAAT — docker inspect op de container, of leeg.
$SUDO docker inspect server-up --format "$1" 2>/dev/null || true
@ -1011,8 +1092,8 @@ doorlichten() {
[ -n "$_img" ] && log "Image: $_img"
stap "Bereikbaarheid"
_poort="$(_dok_env PORT)"; _poort="${_poort:-$POORT}"
_bind="$(_dok_env BIND)"
_poort="$(env_waarde PORT)"; _poort="${_poort:-$POORT}"
_bind="$(env_waarde BIND)"
[ -n "$_bind" ] && geldig_bind "$_bind" && BIND="$_bind"
POORT="$_poort"
_adres="http://$(probe_adres):${POORT}"
@ -1024,7 +1105,7 @@ doorlichten() {
fi
stap "Account"
_uid_env="$(_dok_env SU_UID)"
_uid_env="$(env_waarde SU_UID)"
_uid_echt="$($SUDO docker exec server-up id -u 2>/dev/null || echo '')"
if [ -z "$_uid_echt" ]; then
dok_waar "Kon niet in de container kijken onder welk account hij draait"
@ -1041,7 +1122,7 @@ doorlichten() {
fi
stap "Mappen"
_basis="$(_dok_env BASE_DIR)"; _basis="${_basis:-/opt/serverup}"
_basis="$(env_waarde BASE_DIR)"; _basis="${_basis:-/opt/serverup}"
log "BASE_DIR: $_basis"
if $SUDO docker exec server-up test -d "$_basis" 2>/dev/null; then
# Bestaan is niet genoeg: zonder mount schrijft hij in zijn eigen
@ -1100,11 +1181,63 @@ verwijderen() {
doe $SUDO docker compose -f "$DOEL/docker-compose.yml" --project-directory "$DOEL" \
down --remove-orphans
goed "Container gestopt en verwijderd"
printf '\n'
log "Je gegevens staan nog in het docker-volume 'su-data' en in je stackmappen."
log "Alles definitief weg? Dan:"
log " $SUDO docker volume rm su-data"
log " $SUDO rm -rf $DOEL"
_basis="$(env_waarde BASE_DIR)"; _basis="${_basis:-/opt/serverup}"
_naam="$(getent passwd "$(env_waarde SU_UID)" 2>/dev/null | cut -d: -f1)"
if [ "$PURGE" -eq 0 ]; then
printf '\n'
log "Je gegevens staan nog op deze plekken:"
log " docker-volume 'su-data' — instellingen, accounts, auditlog"
log " $_basis — je stacks, appdata en backups"
log " $DOEL — de installatie zelf"
[ -n "$_naam" ] && [ "$_naam" != "root" ] \
&& log " het account '$_naam', dat bij de installatie is aangemaakt"
printf '\n'
log "Alles definitief weg? Draai dan: sh $DOEL/install.sh --uninstall --purge"
printf '\n'
return 0
fi
# ── Opruimen ─────────────────────────────────────────────────────────────
# Per onderdeel apart vragen. Dit gooit gegevens weg die nergens anders meer
# staan, dus "ja op alles" is hier geen dienst.
stap "Opruimen"
# Let op de vorm: `doe … >/dev/null` zou ook de [droog]-regel wegslikken,
# waardoor een proefdraai niet meer laat zien wat hij zou doen.
if vraag "Het docker-volume 'su-data' verwijderen (instellingen, accounts, auditlog)?"; then
if doe $SUDO docker volume rm su-data; then
goed "Volume su-data verwijderd"
else
waarschuw "Volume su-data niet gevonden of nog in gebruik"
fi
fi
if [ -d "$_basis" ]; then
_omvang="$(du -sh "$_basis" 2>/dev/null | cut -f1)"
waarschuw "$_basis bevat je stacks, appdata en backups (${_omvang:-?})."
waarschuw "Dit is de map met de gegevens van al je apps."
if vraag "Deze map écht verwijderen?"; then
doe $SUDO rm -rf "$_basis" && goed "$_basis verwijderd"
else
log "$_basis blijft staan."
fi
fi
if [ -n "$_naam" ] && [ "$_naam" != "root" ]; then
if vraag "Het account '$_naam' verwijderen?"; then
if doe $SUDO userdel "$_naam"; then
goed "Account $_naam verwijderd"
else
waarschuw "Kon $_naam niet verwijderen; misschien bezit het nog bestanden."
fi
fi
fi
# De installatiemap als laatste: daar staat dit script zelf in.
if vraag "De installatiemap $DOEL verwijderen?"; then
doe $SUDO rm -rf "$DOEL" && goed "$DOEL verwijderd"
fi
printf '\n'
}
@ -1124,6 +1257,33 @@ case "$ACTIE" in
bepaal_rechten
verwijderen
;;
reconfigure)
# Instellingen wijzigen zonder de broncode aan te raken. Handig als je
# de server op je netwerk wilt zetten, een andere poort wilt, of alsnog
# van root af wilt — dingen waarvoor je anders --update draaide en dus
# ongewild ook nieuwe code binnenhaalde.
[ -f "$DOEL/docker-compose.yml" ] || fout "Geen installatie gevonden in $DOEL."
bepaal_rechten
controleer_docker
lees_env_terug "$DOEL/.env"
printf '\n Nu ingesteld: BIND=%s PORT=%s BASE_DIR=%s, draaiend als %s\n' \
"$BIND" "$POORT" "${BASE_DIR:-/opt/serverup}" \
"$(getent passwd "$(env_waarde SU_UID)" 2>/dev/null | cut -d: -f1 || echo root)"
kies_bind
# Zonder --user opnieuw vragen: dat is meestal juist waarvoor je hier bent.
[ "$GEBRUIKER_EXPLICIET" -eq 1 ] || SU_UID=""
kies_gebruiker
schrijf_env
zet_mappen_klaar
start
if wacht_op_gereed; then
printf '\n'
goed "Instellingen bijgewerkt."
afsluiting
else
fout "De container kwam niet op met de nieuwe instellingen. Bekijk de logs."
fi
;;
doctor)
# Alleen kijken, nooit wijzigen. Daarom geen bepaal_rechten: die kan
# afbreken omdat je niet kunt schrijven, terwijl lezen prima gaat.
@ -1154,10 +1314,14 @@ case "$ACTIE" in
goed "Bijgewerkt."
else
printf '\n'
log "Terug naar de vorige versie kan met:"
log " cd $DOEL && git log --oneline -5 # kies een eerdere versie"
log " $SUDO docker compose -f $DOEL/docker-compose.yml up -d --build"
fout "Bijwerken mislukt: de nieuwe versie kwam niet omhoog."
waarschuw "De nieuwe versie kwam niet omhoog."
if terugrollen; then
printf '\n'
fout "Bijwerken mislukt; je draait weer op de vorige versie. Bekijk de logs voordat je het opnieuw probeert."
fi
log "Bekijk de logs met:"
log " $SUDO docker compose -f $DOEL/docker-compose.yml logs --tail=50"
fout "Bijwerken mislukt en terugrollen lukte niet."
fi
;;
create-admin)

View file

@ -622,7 +622,12 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
with tarfile.open(archief, "r:gz") as tar:
# filter="data" weigert absolute paden, '..' en apparaatbestanden.
# `filter="data"` weigert absolute paden, '..' en
# apparaatbestanden — maar laat ook uid en gid vallen (PEP 706).
# Die lezen we daarom apart uit, want alleen het archief weet
# van wie de appdata was.
eigenaars = {m.name: (m.uid, m.gid) for m in tar.getmembers()
if m.name.startswith("appdata/")}
tar.extractall(tmp, filter="data")
if not (tmp / stack).is_dir():
raise BackupError(
@ -630,7 +635,7 @@ def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
shutil.move(str(tmp / stack), str(doel))
# De appdata gaat terug naar waar hij vandaan kwam; dat pad staat in
# de metadata die we net hebben teruggezet.
_zet_appdata_terug(stack, tmp / "appdata", log_fn=log_fn)
_zet_appdata_terug(stack, tmp / "appdata", eigenaars, log_fn=log_fn)
dumps = sorted((tmp / "dumps").glob("*")) if (tmp / "dumps").is_dir() else []
for dp in dumps:
shutil.copy2(dp, doel / f".restore-{dp.name}")
@ -694,12 +699,20 @@ def _zet_eigenaar(wortel: Path, uid: int, gid: int, log_fn=None) -> bool:
return False
def _zet_appdata_terug(stack: str, bron: Path, log_fn=None):
"""De appdata-tak uit het archief terug naar de appdata-map."""
def _zet_appdata_terug(stack: str, bron: Path, eigenaars: dict | None = None,
log_fn=None):
"""De appdata-tak uit het archief terug naar de appdata-map.
`eigenaars` is `{arcnaam: (uid, gid)}` zoals het in het archief stond. Dat
is het enige dat écht klopt: een postgres-container draait als uid 70 en
zegt dat nergens in de metadata. Ontbreekt het, dan is `PUID`/`PGID` uit de
stack het volgende antwoord, en pas daarna onze eigen uid.
"""
if not bron.is_dir():
return
eigenaars = eigenaars or {}
waarden = (_stack_meta(stack).get("values") or {})
uid, gid = _verwachte_eigenaar(waarden)
terugval = _verwachte_eigenaar(waarden)
basis = Path(str(waarden.get("appdata_dir")
or cfg.load().get("DATA_DIR") or "")).resolve()
if basis == Path("/") or not basis.parent.exists():
@ -714,9 +727,29 @@ def _zet_appdata_terug(stack: str, bron: Path, log_fn=None):
if doel.exists():
shutil.rmtree(doel, ignore_errors=True)
shutil.move(str(m), str(doel))
_zet_eigenaar(doel, uid, gid, log_fn=log_fn)
n = _herstel_eigenaars(doel, f"appdata/{m.name}", eigenaars, terugval,
log_fn=log_fn)
if log_fn:
log_fn(f"Appdata teruggezet: {m.name} ({uid}:{gid})")
log_fn(f"Appdata teruggezet: {m.name} ({n} keer eigenaar gezet)")
def _herstel_eigenaars(wortel: Path, arcnaam: str, eigenaars: dict,
terugval: tuple[int, int], log_fn=None) -> int:
"""Geef alles onder `wortel` de eigenaar die in het archief stond."""
gedaan = 0
gemeld = []
for pad, naam in [(wortel, arcnaam)] + [
(p, f"{arcnaam}/{p.relative_to(wortel)}") for p in wortel.rglob("*")]:
uid, gid = eigenaars.get(naam, terugval)
try:
os.chown(pad, uid, gid, follow_symlinks=False)
gedaan += 1
except (OSError, NotImplementedError) as e:
if not gemeld and log_fn:
gemeld.append(1)
log_fn(f"Let op: eigenaar van {wortel.name} kon niet gezet "
f"worden ({e}). De app kan mogelijk niet bij zijn gegevens.")
return gedaan
def _speel_dumps_in(stack: str, stackmap: Path, log_fn=None) -> list[str]:

View file

@ -362,10 +362,16 @@ def test_wat_ontbreekt_staat_in_de_metadata(env, monkeypatch):
assert "skipped" in backups.list_for("umami")[0]
def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch):
"""filter="data" laat uid/gid vallen, dus zonder chown wordt alles van wie
uitpakt en start de app niet meer."""
def test_teruggezette_appdata_krijgt_de_eigenaar_uit_het_archief(env, monkeypatch):
"""`filter="data"` laat uid/gid vallen, dus zonder chown wordt alles van wie
uitpakt en start de app niet meer.
Het archief is het enige dat wéét van wie de appdata was: een
postgres-container draait als uid 70 en zegt dat nergens in de metadata.
Die waarde gaat daarom vóór de PUID uit de stack.
"""
import json as _json
import tarfile as _tarfile
from core import backups, docker
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
@ -377,6 +383,11 @@ def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch):
meta_pad.write_text(_json.dumps(inhoud))
meta = backups.create("umami")
with _tarfile.open(backups.backup_dir() / meta["file"]) as tar:
uit_archief = {m.name: (m.uid, m.gid) for m in tar.getmembers()
if m.name.startswith("appdata/")}
assert uit_archief, "het archief bevat geen appdata"
gevraagd = []
# `backups.os` ís de os-module, dus dit vangt ook wat tarfile zelf chownt —
# als root doet die dat namelijk wél. Daarom toetsen we op het exacte
@ -387,8 +398,33 @@ def test_teruggezette_appdata_krijgt_de_puid_van_de_stack(env, monkeypatch):
assert ok, msg
doel = str(appdata / "umami")
assert (doel, 1005, 1006) in gevraagd, \
f"de teruggezette appdata kreeg niet de PUID van de stack: {gevraagd}"
verwacht = uit_archief["appdata/umami"]
assert (doel, *verwacht) in gevraagd, \
f"de appdata kreeg niet de eigenaar uit het archief: {gevraagd}"
assert not any(u == 1005 for _p, u, _g in gevraagd), \
"de PUID uit de metadata gaat vóór wat er in het archief staat"
def test_zonder_gegevens_in_het_archief_telt_de_puid(env, tmp_path):
"""Een ouder archief kent geen eigenaars; dan is de PUID het beste antwoord."""
from core import backups
bron = tmp_path / "uitgepakt" / "appdata" / "umami"
bron.mkdir(parents=True)
(bron / "a.txt").write_text("x")
_stack_met_appdata(env)
import json as _json
d = env["lib"] / "umami"
inhoud = _json.loads((d / ".serverup.json").read_text())
inhoud["values"].update({"puid": "1005", "pgid": "1006"})
(d / ".serverup.json").write_text(_json.dumps(inhoud))
gevraagd = []
import unittest.mock as mock
with mock.patch.object(backups.os, "chown",
lambda p, u, g, **kw: gevraagd.append((u, g))):
backups._zet_appdata_terug("umami", tmp_path / "uitgepakt" / "appdata",
eigenaars={})
assert gevraagd and all(g == (1005, 1006) for g in gevraagd), gevraagd
def test_zonder_puid_valt_hij_terug_op_ons_eigen_account(env):

View file

@ -1007,3 +1007,167 @@ def test_doctor_staat_in_de_hulptekst():
r = _draai("--help")
assert "--doctor" in r.stdout
assert "--base-dir" in r.stdout
# ── Terugrollen ──────────────────────────────────────────────────────────────
# Een update die niet opkomt liet je met een stilstaande server zitten en een
# rijtje commando's om zelf uit te voeren.
def test_bijwerken_onthoudt_waar_het_vandaan_kwam(tmp_path):
"""Zonder de vorige commit valt er niets terug te rollen."""
import subprocess as sp
if not shutil.which("git"):
pytest.skip("git niet aanwezig")
bron = _repo_met_tag(tmp_path)
doel = tmp_path / "installatie"
sp.run(["git", "clone", "-q", "--depth", "1", "--branch", "v1.0.0",
f"file://{bron}", str(doel)], check=True, capture_output=True)
oude = sp.run(["git", "-C", str(doel), "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip()
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; BRANCH="v1.1.0"\n'
'haal_broncode\n'
'printf "VORIGE=%s\\n" "$VORIGE_COMMIT"\n', tmp_path)
assert f"VORIGE={oude}" in uit, uit
assert (doel / "f").read_text() == "later", "niet bijgewerkt"
def test_terugrollen_zet_de_broncode_terug(tmp_path):
import subprocess as sp
if not shutil.which("git"):
pytest.skip("git niet aanwezig")
bron = _repo_met_tag(tmp_path)
doel = tmp_path / "installatie"
sp.run(["git", "clone", "-q", "--branch", "v1.1.0",
f"file://{bron}", str(doel)], check=True, capture_output=True)
oude = sp.run(["git", "-C", str(doel), "rev-parse", "v1.0.0"],
capture_output=True, text=True).stdout.strip()
# start en wacht_op_gereed uitschakelen: hier gaat het om de broncode.
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; VORIGE_COMMIT="{oude}"\n'
'start() { echo " (start overgeslagen)"; }\n'
'wacht_op_gereed() { return 0; }\n'
'terugrollen && echo "UITKOMST=teruggerold"\n', tmp_path)
assert "UITKOMST=teruggerold" in uit, uit
assert (doel / "f").read_text() == "eerst", "de broncode staat niet terug"
def test_zonder_vorige_versie_wordt_er_niet_teruggerold(tmp_path):
"""Bij een verse installatie is er niets om naar terug te gaan."""
uit = _sh(f'DROOG=0; SUDO=""; DOEL="{tmp_path}"; VORIGE_COMMIT=""\n'
'terugrollen || echo "UITKOMST=niets-te-doen"\n', tmp_path)
assert "UITKOMST=niets-te-doen" in uit, uit
assert "Terugrollen" not in uit
def test_een_mislukte_update_rolt_terug_in_plaats_van_uitleggen():
"""Vroeger drukte hij een rijtje commando's af en liet hij je zitten."""
tekst = SCRIPT.read_text(encoding="utf-8")
update = tekst[tekst.index("\n update)"):]
update = update[:update.index("\n install)")]
assert "terugrollen" in update, "de update-tak rolt niet terug"
# ── Pullen in plaats van bouwen ──────────────────────────────────────────────
# Elke installatie bouwde het image lokaal — minuten werk — terwijl compose
# SU_IMAGE/SU_TAG al kent.
def _installatie(tmp_path, env_inhoud=""):
doel = tmp_path / "su"
doel.mkdir()
(doel / "docker-compose.yml").write_text("services: {}\n")
if env_inhoud:
(doel / ".env").write_text(env_inhoud)
return doel
def test_met_een_registry_image_wordt_er_gepulld(tmp_path):
doel = _installatie(tmp_path, "SU_IMAGE=10.0.20.22:3000/bes-r/server-up\nSU_TAG=1.0\n")
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
assert " pull" in uit, uit
assert "--build" not in uit, "hij bouwt alsnog"
def test_zonder_registry_image_bouwt_hij(tmp_path):
"""Een lokaal gebouwd image bestaat nergens anders, dus er valt niets te halen."""
doel = _installatie(tmp_path, "SU_TAG=1.0\n")
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
assert "--build" in uit, uit
assert " pull" not in uit, uit
def test_een_image_zonder_registrypad_telt_niet(tmp_path):
"""`SU_IMAGE=server-up` is geen registry: geen schuine streep, niets te halen."""
doel = _installatie(tmp_path, "SU_IMAGE=server-up\n")
uit = _sh(f'DROOG=1; SUDO=""; DOEL="{doel}"\nstart\n', tmp_path)
assert "--build" in uit, uit
# ── Kleinigheden ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("pad", ["relatief/pad", "su", "."])
def test_dir_moet_een_volledig_pad_zijn(pad):
"""Met `curl … | sh` is de werkmap je thuismap, en de compose-aanroepen
gebruiken dit pad letterlijk."""
r = _draai("--dir", pad, "--dry-run", "--yes")
assert r.returncode != 0
assert "volledig pad" in r.stderr
def test_geen_voorspelbaar_tijdelijk_bestand():
"""/tmp is voor iedereen schrijfbaar; een vaste naam laat zich vooraf
kapen met een symlink."""
tekst = SCRIPT.read_text(encoding="utf-8")
assert "/tmp/su-setup.$$" not in tekst
assert 'mktemp "${TMPDIR:-/tmp}/su-setup.XXXXXX"' in tekst
def test_de_daemon_wordt_een_keer_bevraagd():
"""`docker info` gevolgd door `docker version` zijn twee rondgangen naar de
daemon terwijl er één nodig is: zonder daemon faalt version net zo goed."""
tekst = SCRIPT.read_text(encoding="utf-8")
blok = tekst[tekst.index("controleer_docker()"):]
blok = blok[:blok.index("\n# \u2500\u2500 Broncode")]
aanroepen = [l.strip() for l in blok.splitlines()
if "$SUDO docker " in l and not l.strip().startswith("#")]
detectie = [l for l in aanroepen if "docker info" in l or "docker version" in l]
assert len(detectie) == 1, f"meer dan een rondgang naar de daemon: {detectie}"
# ── --uninstall en --purge ───────────────────────────────────────────────────
def test_uninstall_noemt_alles_wat_blijft_staan(tmp_path):
doel = _installatie(tmp_path, "BASE_DIR=/tmp/su-proef-basis\nSU_UID=0\n")
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=0; DOEL="{doel}"\nverwijderen\n', tmp_path)
for stuk in ("su-data", "/tmp/su-proef-basis", str(doel), "--purge"):
assert stuk in uit, f"{stuk!r} wordt niet genoemd: {uit}"
def test_purge_vraagt_per_onderdeel(tmp_path):
basis = tmp_path / "gegevens"
(basis / "appdata").mkdir(parents=True)
doel = _installatie(tmp_path, f"BASE_DIR={basis}\nSU_UID=0\n")
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=1; DOEL="{doel}"\nverwijderen\n', tmp_path)
assert "[droog]" in uit
assert "volume rm su-data" in uit, uit
assert f"rm -rf {basis}" in uit, uit
assert basis.is_dir(), "een proefdraai heeft toch verwijderd"
def test_purge_waarschuwt_voor_de_gegevensmap(tmp_path):
basis = tmp_path / "gegevens"
(basis / "appdata").mkdir(parents=True)
doel = _installatie(tmp_path, f"BASE_DIR={basis}\n")
uit = _sh(f'DROOG=1; SUDO=""; JA=1; PURGE=1; DOEL="{doel}"\nverwijderen\n', tmp_path)
assert "gegevens van al je apps" in uit, uit
def test_purge_staat_in_de_hulptekst():
r = _draai("--help")
assert "--purge" in r.stdout and "--reconfigure" in r.stdout
def test_reconfigure_zonder_installatie_stopt(tmp_path):
r = _draai("--reconfigure", "--dir", str(tmp_path / "bestaatniet"))
assert r.returncode != 0
assert "Geen installatie gevonden" in r.stderr

200
tools/backup_rondgang.py Normal file
View file

@ -0,0 +1,200 @@
"""Backup maken en terugzetten met een échte Docker-daemon.
Elke backuptest in `tests/` vervangt Docker door een nepversie vijftien keer
in `test_backups.py` alleen al. Dat toetst de logica, maar niet de keten waar
het om gaat: `pg_dump` via `docker exec`, het archief, het terugzetten, en of de
database daarna nog opkomt met het eigenaarschap dat wij hebben gezet.
Juist daar betekent fout zijn *gegevensverlies*, en juist daar was niets van
bewezen. Dit script draait die rondgang wel echt, in de CI, op het image dat ook
in productie draait.
python tools/backup_rondgang.py /pad/naar/werkmap
De werkmap moet op de host op hetzelfde pad bestaan als hier: de postgres-
container krijgt zijn datamap als bind mount, en die wordt door de daemon op de
host opgezocht niet in deze container.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
STACK = "proef"
WACHTWOORD = "rondgang-wachtwoord"
COMPOSE = """\
services:
{stack}-db:
image: postgres:16-alpine
container_name: {stack}-db
environment:
- POSTGRES_USER={stack}
- POSTGRES_DB={stack}
- POSTGRES_PASSWORD=${{DB_PASSWORD}}
volumes:
- {appdata}/{stack}-db:/var/lib/postgresql/data
"""
def kop(tekst: str) -> None:
print(f"\n\033[1m{tekst}\033[0m", flush=True)
def ok(tekst: str) -> None:
print(f" \033[32m✔\033[0m {tekst}", flush=True)
def mislukt(tekst: str) -> None:
print(f" \033[31m✖\033[0m {tekst}", file=sys.stderr, flush=True)
raise SystemExit(1)
def psql(sql: str, verwacht_fout: bool = False) -> str:
"""Voer SQL uit in de draaiende container."""
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={WACHTWOORD}", f"{STACK}-db",
"psql", "-U", STACK, "-d", STACK, "-tAc", sql],
capture_output=True, text=True, timeout=120)
if r.returncode != 0 and not verwacht_fout:
mislukt(f"psql mislukte: {(r.stderr or r.stdout).strip()[:300]}")
return (r.stdout or "").strip()
def wacht_op_postgres(pogingen: int = 60) -> None:
for _ in range(pogingen):
r = subprocess.run(["docker", "exec", f"{STACK}-db",
"pg_isready", "-U", STACK],
capture_output=True, text=True)
if r.returncode == 0:
return
time.sleep(2)
mislukt("postgres kwam niet op")
def main() -> None:
if len(sys.argv) < 2:
mislukt("Geef een werkmap op die ook op de host bestaat.")
basis = Path(sys.argv[1]).resolve()
lib = basis / "stacks"
appdata = basis / "appdata"
backupmap = basis / "backups"
for d in (lib, appdata, backupmap):
d.mkdir(parents=True, exist_ok=True)
os.environ.update({
"SU_CONFIG": str(basis / "config.json"),
"SU_AUDIT": str(basis / "audit.db"),
"LIBRARY_DIR": str(lib),
"DATA_DIR": str(appdata),
"BACKUP_DIR": str(backupmap),
})
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import backups, docker # noqa: E402
stackmap = lib / STACK
stackmap.mkdir(parents=True, exist_ok=True)
(stackmap / "docker-compose.yml").write_text(
COMPOSE.format(stack=STACK, appdata=appdata), encoding="utf-8")
(stackmap / ".env").write_text(f"DB_PASSWORD={WACHTWOORD}\n", encoding="utf-8")
(stackmap / ".serverup.json").write_text(json.dumps({
"source": STACK,
"values": {"appdata_dir": str(appdata), "service_name": STACK},
}), encoding="utf-8")
try:
kop("1. Stack starten en vullen")
if docker.compose_up(stackmap, name=STACK) != 0:
mislukt("de stack startte niet")
wacht_op_postgres()
psql("CREATE TABLE proef (id int, tekst text);")
psql("INSERT INTO proef VALUES (1, 'dit moet de rondgang overleven');")
assert psql("SELECT tekst FROM proef;") == "dit moet de rondgang overleven"
ok("tabel aangemaakt en gevuld")
datamap = appdata / f"{STACK}-db"
ok(f"appdata staat in {datamap} (eigenaar uid {datamap.stat().st_uid})")
# Een tweede appdata-map, van de app zelf. Die heeft geen container die
# hem bij het starten rechtzet — anders dan de datamap van postgres,
# die het officiële image zelf chownt. Alleen hier kun je dus zien of
# het terugzetten het eigenaarschap bewaart.
appmap = appdata / STACK
appmap.mkdir(parents=True, exist_ok=True)
(appmap / "instellingen.txt").write_text("van de app zelf\n", encoding="utf-8")
for p in (appmap, appmap / "instellingen.txt"):
os.chown(p, 1234, 5678)
ok(f"tweede appdata-map {appmap} op uid 1234:5678 gezet")
kop("2. Backup maken")
meta = backups.create(STACK, reden="rondgang", log_fn=lambda m: print(f" · {m}"))
archief = backupmap / meta["file"]
if not archief.is_file():
mislukt("er is geen archief geschreven")
import tarfile
with tarfile.open(archief) as tar:
namen = tar.getnames()
if f"dumps/{STACK}-db.sql" not in namen:
mislukt(f"geen pg_dump in het archief: {[n for n in namen[:20]]}")
if not any(n.startswith(f"appdata/{STACK}-db") for n in namen):
mislukt("de appdata zit niet in het archief")
if meta.get("skipped"):
mislukt(f"er is data overgeslagen: {meta['skipped'][:5]}")
ok(f"archief bevat de dump én de appdata ({len(namen)} items)")
kop("3. Alles kwijtraken")
psql("DROP TABLE proef;")
docker.compose_down(stackmap, name=STACK)
shutil.rmtree(datamap, ignore_errors=True)
if datamap.exists():
mislukt("de appdata is niet weg te krijgen")
ok("tabel weg, container weg, appdata weg")
kop("4. Terugzetten")
gelukt, melding = backups.restore(STACK, meta["file"],
log_fn=lambda m: print(f" · {m}"))
if not gelukt:
mislukt(f"terugzetten mislukte: {melding}")
ok(melding)
kop("5. Controleren")
if not datamap.is_dir():
mislukt("de appdata is niet teruggezet")
# Het archief is het enige dat weet van wie de appdata was: een
# postgres-container draait als uid 70 en zegt dat nergens in de
# metadata. Zonder dit kreeg alles de uid van wie er uitpakte.
if not (appmap / "instellingen.txt").is_file():
mislukt("de tweede appdata-map is niet teruggezet")
st = (appmap / "instellingen.txt").stat()
if (st.st_uid, st.st_gid) != (1234, 5678):
mislukt(f"eigenaar niet bewaard: uid {st.st_uid}:{st.st_gid} "
f"in plaats van 1234:5678")
ok("eigenaar van de appdata bewaard gebleven (uid 1234:5678)")
wacht_op_postgres()
terug = psql("SELECT tekst FROM proef;")
if terug != "dit moet de rondgang overleven":
mislukt(f"de gegevens zijn niet terug: {terug!r}")
ok("de rij staat er weer")
# Dit is de vraag waar het om draait: kan de database ook nog schríjven?
# Een datamap van de verkeerde eigenaar geeft pas bij de eerste schrijf
# een fout, niet bij het lezen.
psql("INSERT INTO proef VALUES (2, 'en schrijven kan ook nog');")
if psql("SELECT count(*) FROM proef;") != "2":
mislukt("de database kan niet meer schrijven na het terugzetten")
ok("de database kan ook nog schrijven")
kop("Rondgang geslaagd")
finally:
subprocess.run(["docker", "rm", "-f", f"{STACK}-db"],
capture_output=True, timeout=120)
shutil.rmtree(basis, ignore_errors=True)
main()