v0.10.20-beta - self-update, tags, tokens, --base-dir en --doctor
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 17m46s

- bijwerken vanuit de interface kon nooit werken: selfupdate schreef SU_TAG naar
  <werkmap>/.env, maar die map is nergens in de container gemount. De
  helper-container mount hem wel en zet de tag nu
- een vastgepinde versie kon niet bijgewerkt worden: `git reset --hard
  origin/<tag>` bestaat niet. Resetten gaat nu naar FETCH_HEAD, wat voor takken
  en tags allebei klopt
- de token stond in .git/config en in ps; hij gaat nu via een bestand met modus
  0600 dat na afloop weer weg is (git via een credential-helper, curl via
  --config). Schema en host komen uit bron_url, want git zoekt op exact die
  combinatie
- nieuwe optie --base-dir PAD: de hoofdmap bij de installatie zetten in plaats
  van achteraf in .env, waarna je de data alsnog moet verhuizen. Relatieve
  paden en systeemmappen worden geweigerd
- nieuwe actie --doctor: een installatie doorlichten zonder iets te wijzigen —
  container, bereikbaarheid op het ingestelde adres, het account versus SU_UID
  in .env, of BASE_DIR echt gekoppeld is, de rechten van .env en de vrije
  ruimte. Exitcode 1 bij fouten
- de CI draait --doctor na elke deploy tegen de echte installatie

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-03 00:26:52 +02:00
parent 15213708a4
commit f8274ebeff
9 changed files with 656 additions and 51 deletions

View file

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

View file

@ -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 `<werkmap>/.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/<pid>/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

View file

@ -1 +1 @@
0.10.10-beta
0.10.20-beta

View file

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

View file

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

View file

@ -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/<pid>/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 <argumenten> — 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

View file

@ -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 <service>` 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",

View file

@ -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/<tag>` 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/<pid>/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

View file

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