diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 663e4c7..a1b28ce 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6486c61..303d612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/VERSION b/VERSION index 1e78732..292edbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.95-beta +0.7.96-beta diff --git a/docs/installeren.md b/docs/installeren.md index fbb5a00..8b4a613 100644 --- a/docs/installeren.md +++ b/docs/installeren.md @@ -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. --- diff --git a/install.sh b/install.sh index 1f8e67e..f95eea0 100755 --- a/install.sh +++ b/install.sh @@ -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) diff --git a/server-up/core/backups.py b/server-up/core/backups.py index b81c46d..fe61edd 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -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]: diff --git a/tests/test_backups.py b/tests/test_backups.py index 7c54774..c7620a5 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -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): diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 2f48ffa..dd78ea7 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -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 diff --git a/tools/backup_rondgang.py b/tools/backup_rondgang.py new file mode 100644 index 0000000..470c544 --- /dev/null +++ b/tools/backup_rondgang.py @@ -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()