diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index c153fd3..5ade4cf 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -236,6 +236,27 @@ jobs: cd "$DEPLOY_DIR" && docker compose logs --tail=200 exit 1 + # `--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. + - name: install.sh --doctor doorlichten + if: success() + run: | + set -uo pipefail + uit="$(sh "$DEPLOY_DIR/install.sh" --doctor --dir "$DEPLOY_DIR" 2>&1 || true)" + printf '%s\n' "$uit" + # De exitcode toetsen we bewust niet: deze runner zit zelf in een + # container, dus de bereikbaarheidscontrole kan niet slagen — die + # kijkt naar 127.0.0.1 op de host. Of de deploy gezond is, is al + # vastgesteld door de healthcheck-stap hierboven. Wat hier wél moet + # kloppen is dat het doorlichten van begin tot eind draait. + for kop in "Installatie" "Docker" "Container" "Account" "Mappen" \ + "Bijwerken vanuit de interface"; do + printf '%s\n' "$uit" | grep -q "^$kop$" || { + echo "FOUT: --doctor kwam niet tot de sectie '$kop'"; exit 1; } + done + echo "--doctor liep van begin tot eind" + - name: Oude images opruimen (behoud laatste 5) if: success() run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index d704927..29ff984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +# v0.10.20-beta — Bijwerken vanuit de interface, en een script dat nakijkt + +Het tweede deel van de controle op het installatiepad. + +**Bijwerken vanuit de interface kon nooit werken.** `selfupdate` schreef +`SU_TAG` naar `/.env` — maar die map is nergens in de container +gemount. Er zijn maar drie mounts: de docker-socket, het `su-data`-volume en +`BASE_DIR`. De installatiemap zit daar niet bij, dus schrijven mislukte. Dat +viel niet op omdat een zelfgebouwd image de knop toch al uitschakelt; zette je +`SU_IMAGE` naar een registry — de manier die `docs/updates.md` noemt om hem aan +te zetten — dan faalde hij altijd. De helper-container mount die map wél en +draait als root, dus die zet de tag nu. + +**Een vastgepinde versie kon nooit bijgewerkt worden.** `--branch v0.9.00-beta` +installeerde prima, maar bijwerken deed `git reset --hard origin/v0.9.00-beta` +en tags krijgen geen `origin/`-ref: "fatal: ambiguous argument". Het resetten +gaat nu naar `FETCH_HEAD`, wat voor takken en tags allebei klopt. De test liet +zien dat het na een tag-installatie óók voor takken stuk was. + +**De token stond in `.git/config` en in `ps`.** In de URL schrijft git hem +verbatim in de config, waar hij blijft staan; als argument staat hij in +`/proc//cmdline` en leest elke gebruiker op de server hem met `ps`. Dat is +precies waarom dit script `--admin-password` weigert. Hij gaat nu via een +bestand met modus 0600 dat na afloop weer weg is — git leest het via een +credential-helper, curl via `--config`; alleen het pad staat op de opdrachtregel. + +Twee nieuwe opties: + +- **`--base-dir PAD`** zet de hoofdmap voor stacks, appdata en backups bij de + installatie, in plaats van achteraf in `.env` — waarna je de data alsnog moet + verhuizen. Relatieve paden en systeemmappen worden geweigerd. +- **`--doctor`** licht een bestaande installatie door zonder iets te wijzigen: + container, bereikbaarheid *op het ingestelde adres*, onder welk account hij + draait versus wat er in `.env` staat, of `BASE_DIR` echt gekoppeld is (bestaan + is niet genoeg), de rechten van `.env`, en hoeveel ruimte er over is. Exitcode + 1 bij fouten. De CI draait hem na elke deploy tegen de echte installatie — + ruim honderd regels shell die je nergens anders getest krijgt. + # v0.10.10-beta — Het installatiescript loog op drie plekken Een controle van het hele installatiepad leverde drie fouten op die alledrie op diff --git a/VERSION b/VERSION index cabb3e2..133642e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.10-beta +0.10.20-beta diff --git a/docs/installeren.md b/docs/installeren.md index 9ef2803..fbb5a00 100644 --- a/docs/installeren.md +++ b/docs/installeren.md @@ -50,6 +50,8 @@ schrijven én `docker` aanroepen, dan blijft alles onder je eigen account. --bind ADRES Waarop de poort luistert (standaard: 127.0.0.1) --branch NAAM Branch om te installeren (standaard: main) --token TOKEN Toegangstoken, als je repo niet openbaar is +--base-dir PAD Hoofdmap voor stacks, appdata en backups (standaard: + /opt/serverup) --user NAAM Account waaronder Server Up draait ('serverup' wordt zo nodig aangemaakt; 'root' is de oude situatie) --admin NAAM Maak meteen een beheerdersaccount met deze naam @@ -58,6 +60,7 @@ 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) +--doctor Een bestaande installatie doorlichten --yes Niets vragen --dry-run Alleen tonen wat er zou gebeuren ``` @@ -133,7 +136,14 @@ komen is een aparte keuze: dat is `BASE_DIR` in `.env`, standaard /opt/serverup/backups de backups ``` -Wil je alles onder `/srv`, zet dat dan vóór de eerste installatie in `.env`: +Wil je alles onder `/srv`, geef dat dan bij de installatie mee: + +```bash +sh install.sh --base-dir /srv/serverup +``` + +Achteraf kan het ook, maar dan verhuist je data niet mee — daarvoor is +Instellingen → Paden (zie hieronder): ```bash echo 'BASE_DIR=/srv/serverup' >> .env @@ -165,6 +175,32 @@ zien. Ligt de nieuwe map buiten de huidige `BASE_DIR`, zet die dan eerst in --- +## Doorlichten + +Werkt er iets niet, of wil je weten of alles klopt: + +```bash +sh /opt/server-up/install.sh --doctor +``` + +Hij kijkt alleen; er wordt niets gewijzigd. Wat hij nagaat: + +- of er een installatie staat, en of `.env` afgeschermd is (daar staan tokens in) +- of de Docker-daemon reageert en de container draait en gezond is +- of de webinterface antwoordt **op het adres waarop compose publiceert**, niet + op een aanname +- onder welk account hij draait, en of dat overeenkomt met `SU_UID` in `.env` — + staat er iets anders, dan is de container nog niet hercreëerd +- of `BASE_DIR` echt in de container gekoppeld is. Bestaan is niet genoeg: een + niet-gekoppelde map bestaat wél binnen de container, maar wat daar geschreven + wordt komt nooit op de host terecht +- hoeveel er in `stacks`, `appdata` en `backups` staat, van wie die mappen zijn, + en hoeveel ruimte er over is + +De exitcode is 1 als er fouten zijn, zodat je hem in een controle kunt hangen. + +--- + ## Het beheerdersaccount Server Up beheert de Docker-daemon, en wie containers kan starten kan diff --git a/docs/updates.md b/docs/updates.md index 515c254..f9b9835 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -178,6 +178,11 @@ PORT=5000 registry en werkt de updateknop. Staat het er niet, dan bouwt de server lokaal zoals voorheen en meldt de interface netjes waarom bijwerken niet kan. +> Server Up schrijft `SU_TAG` niet zelf in je `.env`, en dat is met opzet: de +> installatiemap is nergens in de container gemount. De helper-container die de +> hercreatie doet, mount hem wél en zet de tag daar. Tot v0.10.20 probeerde +> Server Up het zelf, en faalde dat dus altijd. + Eerste keer overstappen van lokaal bouwen naar de registry: ```bash diff --git a/install.sh b/install.sh index 7a14a7b..ba4aeaf 100755 --- a/install.sh +++ b/install.sh @@ -38,6 +38,7 @@ SU_UID="" SU_GID="" SU_NAAM="" BASE_DIR="${BASE_DIR:-}" +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 @@ -114,6 +115,9 @@ Gebruik: install.sh [opties] maar zet er dan wel een reverse proxy met TLS voor. --branch NAAM Branch om te installeren (standaard: ${BRANCH}) --token TOKEN Toegangstoken, als je git-repo niet openbaar is + --base-dir PAD Hoofdmap voor stacks, appdata en backups + (standaard: /opt/serverup). Later wijzigen kan via + Instellingen → Paden, die de data meeverhuist. --user NAAM Account waaronder Server Up draait ('serverup' wordt zo nodig aangemaakt; 'root' is de oude situatie). Zonder deze optie wordt ernaar gevraagd. @@ -126,6 +130,7 @@ Gebruik: install.sh [opties] --create-admin Alleen het beheerdersaccount aanmaken, bij een installatie die al draait. Werkt zolang er nog geen account bestaat. --update Bijwerken naar de nieuwste versie + --doctor Een bestaande installatie doorlichten --uninstall Stoppen en verwijderen (je gegevens blijven staan) --yes Niets vragen, overal ja op antwoorden --dry-run Alleen tonen wat er zou gebeuren @@ -153,12 +158,15 @@ while [ $# -gt 0 ]; do --token) TOKEN="${2:?--token vereist een waarde}"; shift 2 ;; --user) GEBRUIKER="${2:?--user vereist een accountnaam}" GEBRUIKER_EXPLICIET=1; shift 2 ;; + --base-dir) BASE_DIR="${2:?--base-dir vereist een pad}" + BASE_DIR_EXPLICIET=1; shift 2 ;; --admin) BEHEERDER="${2:?--admin vereist een gebruikersnaam}"; shift 2 ;; --admin-password-file) BEHEERDER_WW_BESTAND="${2:?--admin-password-file vereist een pad}"; shift 2 ;; --admin-password) 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 ;; --uninstall) ACTIE="uninstall"; shift ;; --create-admin) ACTIE="create-admin"; shift ;; --yes|-y) JA=1; shift ;; @@ -182,6 +190,21 @@ geldig_bind() { } geldig_bind "$BIND" || fout "Ongeldig bind-adres: $BIND (verwacht een IP-adres, bijvoorbeeld 127.0.0.1 of 0.0.0.0)" +# 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. +if [ -n "$BASE_DIR" ]; then + case "$BASE_DIR" in + /*) ;; + *) fout "--base-dir vereist een volledig pad, beginnend met / (kreeg: $BASE_DIR)" ;; + esac + BASE_DIR="${BASE_DIR%/}" + case "$BASE_DIR" in + ''|/|/etc|/bin|/sbin|/usr|/var|/tmp|/root|/boot|/dev|/proc|/sys) + fout "--base-dir mag niet $BASE_DIR zijn; kies een eigen map, bijvoorbeeld /srv/serverup." ;; + esac +fi + # Waar bereiken wíj de server na het starten? Compose publiceert op $BIND, dus # met een specifiek adres luistert er niets op 127.0.0.1. Daar toch naartoe # vragen leverde "reageerde niet binnen anderhalve minuut" en een overgeslagen @@ -270,21 +293,66 @@ controleer_docker() { } # ── Broncode ophalen ───────────────────────────────────────────────────────── -bron_url() { - if [ -n "$TOKEN" ]; then - printf 'https://%s@%s/%s.git' "$TOKEN" "$GIT_HOST" "$GIT_PAD" +# De token gaat nooit in de URL en nooit in een argument. In de URL schrijft git +# hem verbatim in .git/config, waar hij blijft staan; als argument staat hij in +# /proc//cmdline en is hij voor elke gebruiker op de server te lezen met +# 'ps'. Dat is precies waarom dit script --admin-password weigert. +# +# In plaats daarvan: een bestand met modus 0600 dat na afloop weer weg is. Git +# leest het via een credential-helper, curl via --config; in beide gevallen +# staat alleen het pád op de opdrachtregel. +bron_url() { printf 'https://%s/%s.git' "$GIT_HOST" "$GIT_PAD"; } + +TOKEN_BESTAND="" +CURL_BESTAND="" + +opruimen() { + [ -n "$TOKEN_BESTAND" ] && rm -f "$TOKEN_BESTAND" + [ -n "$CURL_BESTAND" ] && rm -f "$CURL_BESTAND" + TOKEN_BESTAND=""; CURL_BESTAND="" +} +trap opruimen EXIT HUP INT TERM + +zet_token_klaar() { + [ -n "$TOKEN" ] || return 0 + [ -n "$TOKEN_BESTAND" ] && return 0 + TOKEN_BESTAND="$(mktemp "${TMPDIR:-/tmp}/su-token.XXXXXX")" + CURL_BESTAND="$(mktemp "${TMPDIR:-/tmp}/su-curl.XXXXXX")" + chmod 600 "$TOKEN_BESTAND" "$CURL_BESTAND" + # Schema en host uit bron_url halen in plaats van https hard in te vullen: + # git zoekt de opgeslagen inloggegevens op exact schema + host, dus een + # verschil daarin betekent stilzwijgend geen inloggegevens. + _url="$(bron_url)" + _schema="${_url%%://*}" + _rest="${_url#*://}" + _host="${_rest%%/*}" + # Formaat van `git credential store`: één URL per regel met de gegevens erin. + printf '%s://%s:x-oauth-basic@%s\n' "$_schema" "$TOKEN" "$_host" > "$TOKEN_BESTAND" + printf 'header = "Authorization: token %s"\n' "$TOKEN" > "$CURL_BESTAND" +} + +git_doe() { + # git_doe — met de token uit het bestand als die er is. + if [ -n "$TOKEN_BESTAND" ]; then + doe $SUDO git -c "credential.helper=store --file=$TOKEN_BESTAND" "$@" else - printf 'https://%s/%s.git' "$GIT_HOST" "$GIT_PAD" + doe $SUDO git "$@" fi } haal_broncode() { stap "Broncode ophalen" + zet_token_klaar if [ -d "$DOEL/.git" ]; then log "Bestaande installatie bijwerken in $DOEL" - doe $SUDO git -C "$DOEL" fetch --depth 1 origin "$BRANCH" - doe $SUDO git -C "$DOEL" reset --hard "origin/$BRANCH" - goed "Bijgewerkt naar de nieuwste $BRANCH" + git_doe -C "$DOEL" fetch --depth 1 origin "$BRANCH" + # FETCH_HEAD en niet origin/$BRANCH: een tag krijgt geen origin/-ref, + # dus `--branch v0.9.00-beta` installeerde prima maar kon daarna nooit + # meer bijgewerkt worden ("fatal: ambiguous argument"). FETCH_HEAD wijst + # na een fetch altijd naar wat er net opgehaald is, tak of tag. + git_doe -C "$DOEL" reset --hard FETCH_HEAD + goed "Bijgewerkt naar $BRANCH" + opruimen return 0 fi @@ -292,8 +360,9 @@ haal_broncode() { if heeft git; then # Zonder .git kunnen we later niet netjes bijwerken, dus git heeft de # voorkeur boven een los archief. - if doe $SUDO git clone --depth 1 --branch "$BRANCH" "$(bron_url)" "$DOEL"; then + if git_doe clone --depth 1 --branch "$BRANCH" "$(bron_url)" "$DOEL"; then goed "Gekloond naar $DOEL" + opruimen return 0 fi waarschuw "Klonen mislukte; ik probeer het archief." @@ -303,12 +372,13 @@ haal_broncode() { log "Downloaden: $archief" if [ "$DROOG" -eq 0 ]; then tijdelijk="$(mktemp -d)" - curl -fsSL ${TOKEN:+-H "Authorization: token $TOKEN"} "$archief" \ + curl -fsSL ${CURL_BESTAND:+--config "$CURL_BESTAND"} "$archief" \ | tar xz -C "$tijdelijk" --strip-components=1 \ || fout "Ophalen mislukt. Is de repo openbaar? Zo niet, gebruik --token." $SUDO cp -r "$tijdelijk/." "$DOEL/" rm -rf "$tijdelijk" fi + opruimen goed "Uitgepakt naar $DOEL" } @@ -540,7 +610,7 @@ lees_env_terug() { case "$_gid" in ''|*[!0-9]*) SU_GID="$_uid" ;; *) SU_GID="$_gid" ;; esac ;; esac fi - if [ -z "$BASE_DIR" ]; then + if [ -z "$BASE_DIR" ] && [ "$BASE_DIR_EXPLICIET" -eq 0 ]; then _basis="$(printf '%s\n' "$_inhoud" | sed -n 's/^BASE_DIR=//p' | tail -1)" case "$_basis" in /*) BASE_DIR="$_basis" ;; esac fi @@ -592,6 +662,18 @@ schrijf_env() { fi gewijzigd=1 fi + # Alleen als je er expliciet om vroeg: anders zou een --update de + # hoofdmap terugzetten terwijl je data ergens anders staat. + if [ "$BASE_DIR_EXPLICIET" -eq 1 ] && [ -n "$BASE_DIR" ]; then + if [ "$DROOG" -eq 1 ]; then + printf ' [droog] BASE_DIR=%s in bestaande .env zetten\n' "$BASE_DIR" + else + zet_env_regel "$env_bestand" BASE_DIR "$BASE_DIR" + fi + waarschuw "BASE_DIR gewijzigd; bestaande data verhuist hier niet mee." + log "Verplaatsen doe je vanuit Instellingen → Paden." + gewijzigd=1 + fi if [ "$gewijzigd" -eq 1 ]; then goed "Bestaande .env bijgewerkt (BIND=$BIND, PORT=$POORT)" else @@ -602,8 +684,8 @@ schrijf_env() { fi if [ "$DROOG" -eq 1 ]; then - printf ' [droog] .env aanmaken met BIND=%s PORT=%s, draaiend als %s\n' \ - "$BIND" "$POORT" "${SU_NAAM:-root}" + printf ' [droog] .env aanmaken met BIND=%s PORT=%s BASE_DIR=%s, draaiend als %s\n' \ + "$BIND" "$POORT" "${BASE_DIR:-/opt/serverup}" "${SU_NAAM:-root}" return 0 fi @@ -618,6 +700,7 @@ schrijf_env() { zet_env_regel "$env_bestand" SU_UID "$SU_UID" zet_env_regel "$env_bestand" SU_GID "$SU_GID" fi + [ -n "$BASE_DIR" ] && zet_env_regel "$env_bestand" BASE_DIR "$BASE_DIR" goed "Aangemaakt: $env_bestand (BIND=$BIND, PORT=$POORT)" } @@ -861,6 +944,152 @@ afsluiting() { printf ' Documentatie: %s/README.md\n\n' "$DOEL" } +# ── 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 +# tegengif — het controleert de aannames in plaats van ze te doen. +DOK_FOUTEN=0 +DOK_WAARSCHUWINGEN=0 + +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 +} + +doorlichten() { + stap "Installatie" + if [ -d "$DOEL" ] && [ -f "$DOEL/docker-compose.yml" ]; then + dok_ok "Gevonden in $DOEL" + else + dok_fout "Geen installatie in $DOEL (geen docker-compose.yml)" + printf '\n' + return 1 + fi + if [ -f "$DOEL/.env" ]; then + _rechten="$(stat -c '%a' "$DOEL/.env" 2>/dev/null || echo '?')" + case "$_rechten" in + 600|400) dok_ok ".env staat op $_rechten" ;; + '?') dok_waar "Rechten van .env niet te bepalen" ;; + *) dok_waar ".env staat op $_rechten; er staan tokens en wachtwoorden in. Zet hem op 600." ;; + esac + else + dok_waar "Geen .env in $DOEL; alles draait op de standaardwaarden" + fi + [ -f "$DOEL/VERSION" ] && log "Versie in de map: $(cat "$DOEL/VERSION" 2>/dev/null)" + + stap "Docker" + if $SUDO docker info >/dev/null 2>&1; then + dok_ok "Daemon reageert ($($SUDO docker version --format '{{.Server.Version}}' 2>/dev/null || echo '?'))" + else + dok_fout "De Docker-daemon reageert niet" + printf '\n' + return 1 + fi + + stap "Container" + _staat="$(_dok_inspect '{{.State.Status}}')" + if [ -z "$_staat" ]; then + dok_fout "Er is geen container 'server-up'. Start hem met: $SUDO docker compose -f $DOEL/docker-compose.yml up -d" + elif [ "$_staat" = "running" ]; then + _gezond="$(_dok_inspect '{{if .State.Health}}{{.State.Health.Status}}{{end}}')" + dok_ok "Draait${_gezond:+ (}${_gezond:-}${_gezond:+)}" + [ "${_gezond:-healthy}" = "healthy" ] || dok_waar "De healthcheck staat op '$_gezond'" + else + dok_fout "De container staat op '$_staat'" + fi + _img="$(_dok_inspect '{{.Config.Image}}')" + [ -n "$_img" ] && log "Image: $_img" + + stap "Bereikbaarheid" + _poort="$(_dok_env PORT)"; _poort="${_poort:-$POORT}" + _bind="$(_dok_env BIND)" + [ -n "$_bind" ] && geldig_bind "$_bind" && BIND="$_bind" + POORT="$_poort" + _adres="http://$(probe_adres):${POORT}" + if curl -fsS --connect-timeout 3 --max-time 8 "${_adres}/healthz" >/dev/null 2>&1; then + dok_ok "Antwoordt op $_adres" + else + dok_fout "Geen antwoord op $_adres" + log "Dit is het adres waarop compose publiceert (BIND=$BIND)." + fi + + stap "Account" + _uid_env="$(_dok_env 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" + elif [ "$_uid_echt" = "0" ]; then + if [ -n "$_uid_env" ] && [ "$_uid_env" != "0" ]; then + dok_fout "SU_UID=$_uid_env staat in .env maar hij draait als root. Hercreëer de container: $SUDO docker compose -f $DOEL/docker-compose.yml up -d" + else + dok_waar "Draait als root. Zie docs/beveiliging.md; sh $DOEL/install.sh --update laat je kiezen." + fi + else + dok_ok "Draait als uid $_uid_echt" + [ -n "$_uid_env" ] && [ "$_uid_env" != "$_uid_echt" ] \ + && dok_waar "In .env staat SU_UID=$_uid_env; hercreëer de container om dat door te voeren." + fi + + stap "Mappen" + _basis="$(_dok_env 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 + # overlay en zie je op de host niets terug. + if $SUDO docker exec server-up sh -c "grep -q ' $_basis ' /proc/self/mountinfo" 2>/dev/null; then + dok_ok "$_basis is in de container gekoppeld" + else + dok_fout "$_basis bestaat in de container maar is niet gekoppeld; wat hij daar schrijft komt niet op de host terecht" + fi + else + dok_waar "Kon $_basis niet in de container nakijken" + fi + for _sub in stacks appdata backups; do + if [ -d "$_basis/$_sub" ]; then + log " $_sub: $(find "$_basis/$_sub" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l) items, eigenaar $(stat -c '%U:%G' "$_basis/$_sub" 2>/dev/null || echo '?')" + else + dok_waar " $_sub ontbreekt nog ($_basis/$_sub)" + fi + done + if heeft df; then + _vrij="$(df -h "$_basis" 2>/dev/null | awk 'NR>1 {print $4; exit}')" + [ -n "$_vrij" ] && log "Vrije ruimte: $_vrij" + fi + + stap "Bijwerken vanuit de interface" + _werkmap="$(_dok_inspect '{{index .Config.Labels "com.docker.compose.project.working_dir"}}')" + if [ -z "$_werkmap" ]; then + dok_waar "Geen compose-labels; bijwerken vanuit de interface kan niet" + else + log "Werkmap volgens de container: $_werkmap" + case "$_img" in + */*) dok_ok "Registry-image, dus de knop in de interface werkt" ;; + *) log "Zelfgebouwd image; bijwerken loopt via $DOEL/install.sh --update" ;; + esac + fi + + printf '\n%s────────────────────────────────────────────────────────%s\n' "$C_KOP" "$C_UIT" + if [ "$DOK_FOUTEN" -gt 0 ]; then + printf '%s %s fout(en), %s waarschuwing(en)%s\n' "$C_FOUT" "$DOK_FOUTEN" "$DOK_WAARSCHUWINGEN" "$C_UIT" + elif [ "$DOK_WAARSCHUWINGEN" -gt 0 ]; then + printf '%s Geen fouten, %s waarschuwing(en)%s\n' "$C_WAARSCHUW" "$DOK_WAARSCHUWINGEN" "$C_UIT" + else + printf '%s Alles in orde%s\n' "$C_OK" "$C_UIT" + fi + printf '%s────────────────────────────────────────────────────────%s\n\n' "$C_KOP" "$C_UIT" + [ "$DOK_FOUTEN" -eq 0 ] +} + verwijderen() { stap "Server Up verwijderen" [ -d "$DOEL" ] || fout "Niets gevonden in $DOEL" @@ -895,6 +1124,14 @@ case "$ACTIE" in bepaal_rechten verwijderen ;; + doctor) + # Alleen kijken, nooit wijzigen. Daarom geen bepaal_rechten: die kan + # afbreken omdat je niet kunt schrijven, terwijl lezen prima gaat. + if [ "$(id -u)" -ne 0 ] && ! docker info >/dev/null 2>&1 && heeft sudo; then + SUDO="sudo" + fi + doorlichten + ;; update) [ -d "$DOEL" ] || fout "Geen installatie gevonden in $DOEL. Installeer eerst zonder --update." controleer_systeem diff --git a/server-up/core/selfupdate.py b/server-up/core/selfupdate.py index 6c0f42b..c4ac9a3 100644 --- a/server-up/core/selfupdate.py +++ b/server-up/core/selfupdate.py @@ -179,25 +179,35 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]: # ── Toepassen ──────────────────────────────────────────────────────────────── -def _write_tag(working_dir: str, tag: str, log_fn=None): - """Zet SU_TAG in het .env van de deploy-map, zodat compose het nieuwe image - gebruikt en de wijziging een herstart overleeft.""" - env_file = Path(working_dir) / ".env" - regels: list[str] = [] - if env_file.exists(): - regels = env_file.read_text(encoding="utf-8").splitlines() - nieuw, gezet = [], False - for r in regels: - if r.strip().startswith("SU_TAG="): - nieuw.append(f"SU_TAG={tag}") - gezet = True - else: - nieuw.append(r) - if not gezet: - nieuw.append(f"SU_TAG={tag}") - env_file.write_text("\n".join(nieuw) + "\n", encoding="utf-8") - if log_fn: - log_fn(f"SU_TAG={tag} vastgelegd in {env_file}") +def _tag_script(tag: str) -> str: + """Sh-regels die SU_TAG in het .env van de werkmap zetten. + + Dit gebeurt in de helper-container en niet hier, en dat is geen detail: de + installatiemap (`/opt/server-up`) is nérgens in deze container gemount. Er + zijn maar drie mounts — de docker-socket, het `su-data`-volume en `BASE_DIR` + — en de installatiemap zit daar niet bij. Zelf schrijven belandde dus in de + overlay van de container of, waarschijnlijker, mislukte omdat de map hier + niet bestaat. Bijwerken vanuit de interface kon daardoor nooit werken. + + De helper mount `working_dir` wél, op hetzelfde pad, en draait als root. + """ + q = shlex.quote(f"SU_TAG={tag}") + return ( + "touch .env; " + "sed -i '/^SU_TAG=/d' .env; " + f"printf '%s\\n' {q} >> .env; " + ) + + +def _tag_uit_env(working_dir: str) -> str: + """Wat staat er nú als SU_TAG in de werkmap? Leeg als we er niet bij kunnen.""" + try: + for regel in (Path(working_dir) / ".env").read_text("utf-8").splitlines(): + if regel.strip().startswith("SU_TAG="): + return regel.split("=", 1)[1].strip() + except OSError: + pass + return "" def apply(tag: str, log_fn=None) -> tuple[bool, str]: @@ -227,31 +237,30 @@ def apply(tag: str, log_fn=None) -> tuple[bool, str]: "UPDATE_LAST_APPLIED": {"tag": tag, "ts": time.time(), "from": vorige_tag}}) - try: - _write_tag(werkmap, tag, log_fn) - except Exception as e: - return False, f"Kan {werkmap}/.env niet schrijven: {e}" - if log_fn: log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)") + log_fn(f"SU_TAG={tag} wordt door de helper in {werkmap}/.env gezet") log_fn("Helper starten die de container hercreëert…") - ok, melding = _spawn_helper(image, werkmap, service, log_fn) + ok, melding = _spawn_helper(image, werkmap, service, tag, log_fn) if not ok: return False, melding return True, (f"Bijwerken naar {tag} gestart. Server Up herstart nu; " "de interface komt over enkele seconden terug.") -def _spawn_helper(image: str, working_dir: str, service: str, +def _spawn_helper(image: str, working_dir: str, service: str, tag: str, log_fn=None) -> tuple[bool, str]: """Start een korte container die `docker compose up -d ` uitvoert. Draait op hetzelfde (nieuwe) image, dat de docker- en compose-CLI al bevat. `sleep` geeft dit proces de tijd om zijn HTTP-antwoord af te maken voordat het onder zijn eigen voeten wordt weggehaald. + + De helper zet ook SU_TAG: hij mount de werkmap, deze container niet. """ script = (f"sleep 5; " + f"{_tag_script(tag)}" f"docker compose up -d --remove-orphans {shlex.quote(service)}") cmd = [docker._bin(), "run", "--rm", "--detach", "--name", "server-up-updater", diff --git a/tests/test_install_script.py b/tests/test_install_script.py index f7f039c..f57eafb 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -40,7 +40,10 @@ def test_geen_bashismen(): (r"\[\[", "[[ ]] is bash-only"), (r"^\s*declare ", "declare is bash-only"), (r"\$\{[A-Za-z_]+\[", "arrays zijn bash-only"), - (r"==", "== in test is bash-only"), + # Botweg overal verboden, ook in een awk-programma waar het prima mag. + # Dat is één regel die je omschrijft, tegenover een controle die precies + # moet weten wanneer je binnen [ ] zit. + (r"==", "== in test is bash-only (ook in awk: schrijf het anders)"), ]: treffers = [l for l in tekst.splitlines() if re.search(patroon, l) and not l.strip().startswith("#")] @@ -787,3 +790,220 @@ def test_zonder_terminal_valt_het_script_niet_om(tmp_path): 'else echo "UITKOMST=geen-tty"; fi\necho "EINDE"\n', tmp_path) assert "UITKOMST=geen-tty" in uit, uit assert "EINDE" in uit, "het script stopte halverwege" + + +# ── Bijwerken van een vastgepinde versie ───────────────────────────────────── + +def _repo_met_tag(tmp_path): + """Een lokale repo met een tag, en een kloon daarvan zoals install.sh die maakt.""" + import subprocess as sp + bron = tmp_path / "bron" + bron.mkdir() + def g(*a, cwd=bron): + return sp.run(["git", *a], cwd=str(cwd), capture_output=True, text=True, check=True) + g("init", "-q", ".") + g("config", "user.email", "t@t"); g("config", "user.name", "t") + (bron / "f").write_text("eerst") + g("add", "f"); g("commit", "-qm", "eerst"); g("tag", "v1.0.0") + (bron / "f").write_text("later") + g("commit", "-qam", "later"); g("tag", "v1.1.0") + return bron + + +@pytest.mark.parametrize("naar_tag", [True, False]) +def test_bijwerken_werkt_voor_een_tag_en_voor_een_tak(naar_tag, tmp_path): + """`git reset --hard origin/` bestaat niet: tags krijgen geen + origin/-ref. Installeren op een vaste versie was daardoor een + eenrichtingsstraat — bijwerken faalde met "ambiguous argument". + + Toetsen op de exitcode zou niets zeggen: `haal_broncode && …` zet errexit + uit, dus de functie geeft ook na een mislukte git-opdracht 0 terug. Daarom + kijken we naar wat er in de werkmap staat. + """ + import subprocess as sp + if not shutil.which("git"): + pytest.skip("git niet aanwezig") + bron = _repo_met_tag(tmp_path) + doel = tmp_path / "installatie" + tak = sp.run(["git", "-C", str(bron), "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True).stdout.strip() + # Installeren op de oude versie, daarna bijwerken naar de nieuwe. + sp.run(["git", "clone", "-q", "--depth", "1", "--branch", "v1.0.0", + f"file://{bron}", str(doel)], check=True, capture_output=True) + assert (doel / "f").read_text() == "eerst" + + doel_ref = "v1.1.0" if naar_tag else tak + uit = _sh(f'DROOG=0; SUDO=""; DOEL="{doel}"; BRANCH="{doel_ref}"\n' + 'haal_broncode\n', tmp_path) + + assert "ambiguous argument" not in uit, uit + assert (doel / "f").read_text() == "later", \ + f"de installatie is niet bijgewerkt naar {doel_ref}: {uit}" + + +# ── De token mag nergens blijven hangen ────────────────────────────────────── +# In de URL schrijft git hem verbatim in .git/config, waar hij blijft staan. Als +# argument staat hij in /proc//cmdline en leest elke gebruiker op de server +# hem met 'ps'. Dat laatste is precies waarom dit script --admin-password +# weigert; voor --token gold hetzelfde. + +def test_de_bron_url_bevat_geen_token(tmp_path): + uit = _sh('TOKEN="geheimtoken123"\nbron_url\nprintf "\\n"\n', tmp_path) + assert "geheimtoken123" not in uit, uit + assert uit.strip().endswith(".git") + + +def test_de_token_staat_niet_in_de_git_argumenten(tmp_path): + """Het pad naar het bestand mag op de opdrachtregel, de inhoud niet.""" + uit = _sh('TOKEN="geheimtoken123"; SUDO=""; DROOG=1\n' + 'zet_token_klaar\n' + 'git_doe clone --depth 1 https://voorbeeld/x.git /tmp/x\n', tmp_path) + assert "geheimtoken123" not in uit, f"de token staat in het commando: {uit}" + assert "credential.helper=store" in uit, uit + + +def test_het_tokenbestand_is_afgeschermd_en_verdwijnt(tmp_path): + uit = _sh('TOKEN="geheimtoken123"\n' + 'zet_token_klaar\n' + 'bewaard="$TOKEN_BESTAND"\n' + 'ls -l "$bewaard" | cut -c1-10\n' + 'grep -q geheimtoken123 "$bewaard" && echo "TOKEN-STAAT-ERIN"\n' + 'opruimen\n' + '[ -e "$bewaard" ] && echo "BLIJFT-STAAN" || echo "OPGERUIMD"\n', + tmp_path) + assert "-rw-------" in uit, f"het tokenbestand is te ruim: {uit}" + assert "TOKEN-STAAT-ERIN" in uit, uit + assert "OPGERUIMD" in uit, f"het tokenbestand blijft achter: {uit}" + + +def test_de_token_komt_daadwerkelijk_bij_git_aan(tmp_path): + """Niet lekken is de helft; hij moet ook nog wérken. + + Een git-server die 401 antwoordt tot er inloggegevens komen. Lukt het + uitlezen van de refs, dan heeft de credential-helper de token geleverd — + zonder dat hij in de opdrachtregel of in .git/config stond. + """ + import http.server + import subprocess as sp + import threading + if not shutil.which("git"): + pytest.skip("git niet aanwezig") + + bron = _repo_met_tag(tmp_path) + sp.run(["git", "-C", str(bron), "update-server-info"], check=True, + capture_output=True) + gezien = [] + + class H(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(bron / ".git"), **kw) + + def do_GET(self): + kop = self.headers.get("Authorization") + gezien.append(kop) + if not kop: + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="prive"') + self.send_header("Content-Length", "0") + self.end_headers() + return + super().do_GET() + + def log_message(self, *a): + pass + + srv = http.server.HTTPServer(("127.0.0.1", 0), H) + poort = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + uit = _sh(f'TOKEN="geheimtoken123"; SUDO=""; DROOG=0\n' + f'bron_url() {{ printf "http://127.0.0.1:{poort}/"; }}\n' + 'zet_token_klaar\n' + f'git_doe ls-remote "http://127.0.0.1:{poort}/"\n', tmp_path) + finally: + srv.shutdown() + + assert "geheimtoken123" not in uit, f"de token staat in de uitvoer: {uit}" + assert any(a and a.startswith("Basic") for a in gezien), \ + f"de server kreeg nooit inloggegevens: {gezien}" + assert "refs/tags/v1.0.0" in uit, f"de refs zijn niet opgehaald: {uit}" + + +# ── --base-dir ─────────────────────────────────────────────────────────────── +# BASE_DIR is sinds v0.9 de hoofdknop voor je paden, maar je kon hem alleen +# achteraf in .env zetten — en dan moet je de data alsnog verhuizen. + +def test_base_dir_belandt_in_env_en_in_de_mappen(tmp_path): + r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes", + "--base-dir", "/srv/serverup", "--user", "root") + assert r.returncode == 0, r.stderr + assert "BASE_DIR=/srv/serverup" in r.stdout, r.stdout + + +@pytest.mark.parametrize("pad", ["relatief/pad", "srv", "."]) +def test_base_dir_moet_een_volledig_pad_zijn(pad): + """Die map wordt in de container op zichzelf gemount; een relatief pad komt + daar ergens anders uit dan op de host.""" + r = _draai("--base-dir", pad, "--dry-run", "--yes") + assert r.returncode != 0 + assert "volledig pad" in r.stderr + + +@pytest.mark.parametrize("pad", ["/", "/etc", "/usr", "/var", "/root"]) +def test_base_dir_weigert_systeemmappen(pad): + r = _draai("--base-dir", pad, "--dry-run", "--yes") + assert r.returncode != 0 + assert "mag niet" in r.stderr + + +def test_afsluitende_schuine_streep_verdwijnt(tmp_path): + r = _draai("--dry-run", "--dir", str(tmp_path / "su"), "--yes", + "--base-dir", "/srv/serverup/", "--user", "root") + assert "BASE_DIR=/srv/serverup," in r.stdout or "BASE_DIR=/srv/serverup " in r.stdout, r.stdout + assert "/srv/serverup//" not in r.stdout + + +# ── --doctor ───────────────────────────────────────────────────────────────── +# De fouten die dit script had waren allemaal van dezelfde soort: iets klopte +# niet en niemand keek na. Dit is het tegengif. + +def test_doctor_meldt_een_ontbrekende_installatie(tmp_path): + r = _draai("--doctor", "--dir", str(tmp_path / "bestaatniet")) + assert r.returncode != 0, "een ontbrekende installatie is geen succes" + assert "Geen installatie" in r.stdout + + +def test_doctor_kijkt_naar_de_rechten_van_env(tmp_path): + """In .env staan tokens en wachtwoordhashes.""" + doel = tmp_path / "su" + doel.mkdir() + (doel / "docker-compose.yml").write_text("services: {}\n") + env = doel / ".env" + env.write_text("BIND=127.0.0.1\nPORT=5000\n") + env.chmod(0o644) + r = _draai("--doctor", "--dir", str(doel)) + assert "644" in r.stdout and "600" in r.stdout, r.stdout + + env.chmod(0o600) + r = _draai("--doctor", "--dir", str(doel)) + assert ".env staat op 600" in r.stdout, r.stdout + + +def test_doctor_wijzigt_niets(tmp_path): + """Doorlichten is kijken. Draait iemand dit op een productieserver, dan mag + er niets veranderen — ook geen map aangemaakt.""" + doel = tmp_path / "su" + doel.mkdir() + (doel / "docker-compose.yml").write_text("services: {}\n") + (doel / ".env").write_text("BASE_DIR=/tmp/su-doctor-mag-dit-niet-maken\n") + (doel / ".env").chmod(0o600) + voor = sorted(p.name for p in doel.iterdir()) + _draai("--doctor", "--dir", str(doel)) + assert sorted(p.name for p in doel.iterdir()) == voor + assert not Path("/tmp/su-doctor-mag-dit-niet-maken").exists() + + +def test_doctor_staat_in_de_hulptekst(): + r = _draai("--help") + assert "--doctor" in r.stdout + assert "--base-dir" in r.stdout diff --git a/tests/test_selfupdate.py b/tests/test_selfupdate.py index 0214b6b..7123a20 100644 --- a/tests/test_selfupdate.py +++ b/tests/test_selfupdate.py @@ -106,27 +106,66 @@ def test_pull_weigert_ongeldige_tag(su, monkeypatch, tag): assert "Ongeldige tag" in msg -def test_write_tag_maakt_env_aan(su, tmp_path): - su._write_tag(str(tmp_path), "0.5.10") - assert (tmp_path / ".env").read_text().strip() == "SU_TAG=0.5.10" +# ── SU_TAG wegschrijven ────────────────────────────────────────────────────── +# Dit gebeurde eerder in Python, hier in de container. Maar de installatiemap is +# hier nergens gemount — er zijn maar drie mounts: de docker-socket, su-data en +# BASE_DIR. Bijwerken vanuit de interface kon daardoor nooit werken. Het gebeurt +# nu in de helper, die de werkmap wél mount, dus toetsen we het shellfragment. + +def _draai_tagscript(su, map_: Path, tag: str): + import subprocess + r = subprocess.run(["sh", "-c", su._tag_script(tag)], cwd=str(map_), + capture_output=True, text=True, timeout=30) + assert r.returncode == 0, r.stderr + return (map_ / ".env").read_text(encoding="utf-8") -def test_write_tag_vervangt_bestaande_regel(su, tmp_path): +def test_tagscript_maakt_env_aan(su, tmp_path): + assert _draai_tagscript(su, tmp_path, "0.5.10").strip() == "SU_TAG=0.5.10" + + +def test_tagscript_vervangt_bestaande_regel(su, tmp_path): (tmp_path / ".env").write_text("BIND=0.0.0.0\nSU_TAG=0.5.00\nPORT=5000\n") - su._write_tag(str(tmp_path), "0.5.10") - regels = (tmp_path / ".env").read_text().splitlines() - assert regels == ["BIND=0.0.0.0", "SU_TAG=0.5.10", "PORT=5000"] + regels = _draai_tagscript(su, tmp_path, "0.5.10").splitlines() + assert regels == ["BIND=0.0.0.0", "PORT=5000", "SU_TAG=0.5.10"] assert sum(1 for r in regels if r.startswith("SU_TAG=")) == 1 -def test_write_tag_behoudt_andere_instellingen(su, tmp_path): +def test_tagscript_behoudt_andere_instellingen(su, tmp_path): (tmp_path / ".env").write_text("SU_IMAGE=git.example.com/bes-r/server-up\n") - su._write_tag(str(tmp_path), "0.5.10") - inhoud = (tmp_path / ".env").read_text() + inhoud = _draai_tagscript(su, tmp_path, "0.5.10") assert "SU_IMAGE=git.example.com/bes-r/server-up" in inhoud assert "SU_TAG=0.5.10" in inhoud +def test_tagscript_laat_zich_niet_uitbreiden(su, tmp_path): + """De tag wordt al door _TAG_RE gefilterd, maar dit fragment belandt in een + `sh -c` in een container met de docker-socket. Twee sloten op de deur.""" + inhoud = _draai_tagscript(su, tmp_path, "1.0.0'; touch /tmp/su-inbraak; echo '") + assert not Path("/tmp/su-inbraak").exists(), "de tag brak uit het commando" + assert inhoud.count("SU_TAG=") == 1 + + +def test_de_helper_zet_de_tag_en_mount_de_werkmap(su, monkeypatch): + """Zonder de mount schrijft de helper in zijn eigen overlay, en zonder het + tag-fragment start compose gewoon het oude image opnieuw.""" + aanroepen = [] + + def nep_run(cmd, **kw): + aanroepen.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(su.docker, "_run", nep_run) + su._spawn_helper("git.example.com/bes-r/server-up:0.5.10", + "/opt/server-up", "server-up", "0.5.10") + # De eerste aanroep ruimt een blijven hangen helper op; de tweede is de start. + cmd = next(c for c in aanroepen if "run" in c) + assert "-v" in cmd and "/opt/server-up:/opt/server-up" in cmd + script = cmd[-1] + assert "SU_TAG=0.5.10" in script, "de helper zet de tag niet" + assert "docker compose up -d" in script + + def test_rollback_zonder_vorige_versie(su): ok, msg = su.rollback() assert ok is False