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
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:
parent
3bee664484
commit
504bb310ee
9 changed files with 755 additions and 40 deletions
|
|
@ -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.
|
||||
|
|
|
|||
52
CHANGELOG.md
52
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.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.7.95-beta
|
||||
0.7.96-beta
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
212
install.sh
212
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"
|
||||
|
||||
_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 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"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
200
tools/backup_rondgang.py
Normal 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()
|
||||
Loading…
Reference in a new issue