v0.9.00-beta - één hoofdmap, paden kloppend en data verhuizen
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m40s

- LIBRARY_DIR, DATA_DIR en BACKUP_DIR worden afgeleid van BASE_DIR in plaats
  van hard op /opt/serverup te staan
- docker-compose geeft BASE_DIR nu ook aan de container door; die stond alleen
  in de mount, dus de app bleef /opt/serverup voorstellen bij een /srv-mount
- het installatieformulier vult appdata_dir met de ingestelde DATA_DIR, zodat
  alle 286 sjablonen de juiste map voorstellen
- Instellingen → Paden heeft een hoofdmap met afgeleide submappen, een
  kopieerknop per pad en "Alles overnemen"
- nieuw GET /api/paths/check: is het pad schrijfbaar en binnen de gemounte
  BASE_DIR, met de .env-regel als dat niet zo is
- nieuw POST /api/paths/move: stacks stoppen, kopiëren, controleren, paden in
  compose/.env/metadata omschrijven, stacks starten en pas dán het oude
  opruimen
- jobs.voortgang() plus een voortgangsbalk boven het terminallog
- core/paden.py met meten, bereikbaarheid, verhuizen en omschrijven
- docs/installeren.md en .env.example beschrijven BASE_DIR als de manier om
  alles te verplaatsen

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-02 14:06:17 +02:00
parent b6319e8fdd
commit 90e4160727
14 changed files with 1027 additions and 18 deletions

View file

@ -31,8 +31,15 @@ PORT=5000
# ── Overig ───────────────────────────────────────────────────────────────────
# Waar de stacks, appdata en backups komen te staan. Dit pad wordt in de
# container op hetzelfde pad gemount, zodat compose-bestanden kloppen.
# De hoofdmap voor stacks, appdata en backups. Dit pad wordt in de container op
# hetzelfde pad gemount, zodat compose-bestanden kloppen, én meegegeven aan de
# app, zodat de standaarden eronder vallen:
# <BASE_DIR>/stacks <BASE_DIR>/appdata <BASE_DIR>/backups
#
# Wijzig je dit ná de installatie, dan moet je ook de bestaande data verplaatsen.
# Doe dat via Instellingen → Paden; die knop kopieert eerst, controleert, en
# ruimt het oude pas daarna op. Alleen deze regel aanpassen laat je data staan
# waar hij stond.
#BASE_DIR=/opt/serverup
# Zet op 1 zodra alles via HTTPS loopt: de sessiecookie krijgt dan de

View file

@ -1,3 +1,41 @@
# v0.9.00-beta — Eén hoofdmap, en je data mag mee verhuizen
**Server Up stelde `/opt/serverup` voor, wat je ook instelde.** Dat was geen
weergavefout maar drie fouten tegelijk:
- `LIBRARY_DIR`, `DATA_DIR` en `BACKUP_DIR` stonden hard op `/opt/serverup/*` en
trokken zich niets aan van de `BASE_DIR` die `docker-compose.yml` wél kent. Ze
worden nu afgeleid van `BASE_DIR`, en compose geeft die variabele voortaan ook
aan de container door — die stond alleen in de mount, dus je kon `/srv`
koppelen terwijl de app `/opt/serverup` bleef voorstellen.
- 266 van de 286 sjablonen hebben `/opt/serverup/appdata` als standaard voor
`appdata_dir`. Het installatieformulier overschreef wel de tijdzone met die
van de server, maar niet de appdata-map. Wat je ook had ingesteld, je data
belandde in `/opt`. Dat is nu één plek in `_annotate_fields()`, en daarmee
meteen goed voor alle sjablonen.
- `.env.example` en de documentatie noemden `/opt/serverup` als vast gegeven.
**Instellingen → Paden werkt nu met één hoofdmap.** Typ je er een, dan verschijnen
de drie submappen eronder als voorstel, elk met een knop om hem over te nemen, en
één knop **Alles overnemen**. De velden blijven los aanpasbaar. `/api/paths/check`
controleert onderweg of Server Up er überhaupt bij kán: de container ziet alleen
wat via `BASE_DIR` gekoppeld is, en daarbuiten schrijft hij naar zijn eigen
overlay — dat lijkt te lukken en levert daarna lege apps op. Ligt je map erbuiten,
dan noemt de melding de regel voor `.env` en het commando, in plaats van "kon niet
opslaan".
**Een gewijzigd pad biedt aan de data mee te verhuizen.** Alleen de instelling
aanpassen liet je data staan waar hij stond, en elke stack wees nog naar zijn oude
appdata-map. De verhuizing stopt de draaiende stacks, kopieert met een
voortgangsbalk, controleert aantal en omvang, schrijft de paden om in
`docker-compose.yml`, `.env` en de metadata van elke stack, start de stacks weer
op de nieuwe plek — **en ruimt pas daarna het oude op**. Klopt de controle niet,
dan blijft het origineel staan en is de melding de fout. Het omschrijven kijkt
naar hele pad-segmenten, zodat `/opt/serverup` vervangen `/opt/serverup-oud` met
rust laat.
Jobs kregen daarvoor een `voortgang()`; de terminal toont de balk boven het log.
# v0.8.82-beta — "Poort {port} is vrij" stond er letterlijk
De poortcontrole werd aangesloten op vertaalsleutels die er al waren, en twee

View file

@ -1 +1 @@
0.8.82-beta
0.9.00-beta

View file

@ -36,6 +36,10 @@ services:
- SU_AUDIT=/data/audit.db
- SU_GIT_CACHE=/data/git
- SU_CONTAINER=server-up
# Dezelfde waarde als de mount hierboven, zodat de app zijn standaard
# paden (stacks, appdata, backups) onder de juiste map zoekt. Zonder dit
# mount je /srv terwijl de app /opt/serverup blijft voorstellen.
- BASE_DIR=${BASE_DIR:-/opt/serverup}
# Versie die de UI toont (overschrijft de ingebakken default).
- SU_VERSION=${SU_VERSION:-dev}
# Voeg eigen app-repos toe zonder wizard. Eenmalig opgeslagen in config.json.

View file

@ -84,6 +84,50 @@ sh install.sh --token jouw-forgejo-token
---
## Waar je gegevens staan
`--dir` bepaalt waar Server Up zélf staat. Waar je *stacks, appdata en backups*
komen is een aparte keuze: dat is `BASE_DIR` in `.env`, standaard
`/opt/serverup`. Alles valt daaronder:
```
/opt/serverup/stacks de compose-bestanden van je apps
/opt/serverup/appdata de gegevens van je apps
/opt/serverup/backups de backups
```
Wil je alles onder `/srv`, zet dat dan vóór de eerste installatie in `.env`:
```bash
echo 'BASE_DIR=/srv/serverup' >> .env
docker compose up -d
```
Die map wordt in de container op hetzelfde pad gemount, zodat de paden in je
compose-bestanden op de host kloppen. Server Up kan daardoor **alleen bij paden
onder `BASE_DIR`**. Vul je in de interface een map buiten die boom in, dan zegt
hij welke regel je in `.env` moet zetten in plaats van stilletjes naar een map
binnen de container te schrijven — die je op de host nooit terugziet.
### Later verhuizen
Staat er al data, dan is `.env` aanpassen niet genoeg: je stacks blijven naar
hun oude appdata-map wijzen. Ga naar **Instellingen → Paden**, vul de nieuwe
hoofdmap in en klik op **Alles overnemen**. Server Up:
1. stopt de draaiende stacks
2. kopieert alles naar de nieuwe plek, met een voortgangsbalk
3. controleert of aantal en omvang kloppen
4. schrijft de paden in elke stack om (`docker-compose.yml`, `.env`, metadata)
5. start de stacks weer op de nieuwe plek
6. **en ruimt pas daarna het oude op**
Klopt de controle niet, dan blijft het origineel staan en krijg je de fout te
zien. Ligt de nieuwe map buiten de huidige `BASE_DIR`, zet die dan eerst in
`.env` en draai `docker compose up -d`; daarna kun je verhuizen.
---
## Het beheerdersaccount
Server Up beheert de Docker-daemon, en wie containers kan starten kan
@ -169,7 +213,8 @@ 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 `/opt/serverup`. Het script vertelt daarna hoe je die alsnog opruimt.
onder `BASE_DIR` (standaard `/opt/serverup`). Het script vertelt daarna hoe je
die alsnog opruimt.
---

View file

@ -16,7 +16,7 @@ import core as cfg
from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater
from core import backups, categories, compose_transform, diskspace, networks
from core import notify, pangolin
from core import notify, paden, pangolin
from core import scheduler, selfupdate
from core import stackupdates
from core.modules import Module, CORE, discover
@ -162,7 +162,7 @@ ADMIN_ONLY_PREFIXES = (
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
"/api/update/", "/api/audit/clear", "/api/docker/restart",
"/api/docker/images", "/api/notify/", "/api/appnetworks",
"/api/pangolin",
"/api/pangolin", "/api/paths",
)
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
@ -1456,6 +1456,7 @@ def _annotate_fields(fields: list[dict]) -> list[dict]:
"""
bezet = _bezette_poorten()
tz = cfg.tijdzone()
appdata = cfg.load().get("DATA_DIR") or cfg.DEFAULTS["DATA_DIR"]
uitgedeeld: set[int] = set()
for f in fields:
if _is_secret_field(f):
@ -1465,6 +1466,11 @@ def _annotate_fields(fields: list[dict]) -> list[dict]:
# alleen toevallig. De tijdzone van deze server is een beter voorstel.
if naam in ("timezone", "tz") and f.get("type") != "int":
f["default"] = tz
# Hetzelfde verhaal voor de appdata-map: 266 sjablonen noemen
# /opt/serverup/appdata, ongeacht wat jij hebt ingesteld. Dan komt je
# data dus ergens anders terecht dan waar je hem verwacht.
if naam == "appdata_dir" and f.get("type") != "int":
f["default"] = appdata
if f.get("type") == "int" and "port" in naam:
try:
gewenst = int(f.get("default") or 0)
@ -3450,6 +3456,155 @@ def api_debug_startup():
return jsonify(log=STARTUP_LOG, loaded=[m.ID for m in MODULES])
@app.route("/api/paths/check")
def api_path_check():
"""Kan Server Up op dit pad schrijven, en komt dat op de host terecht?
Zonder deze controle schrijft de app naar een pad binnen de container dat
niet gemount is: het lijkt te lukken, maar op de host staat er niets en je
stacks vinden hun data niet meer.
"""
uitslag = paden.bereikbaar(request.args.get("path") or "")
basis = (request.args.get("base") or "").strip()
if basis:
uitslag["afgeleid"] = cfg.afgeleide_paden(basis)
if not uitslag.get("ok"):
uitslag["msg"] = _padfout_tekst(uitslag)
# `uitslag` bevat zelf al `ok`; die betekent hier "dit pad is bruikbaar".
return jsonify(**uitslag)
@app.route("/api/paths/move", methods=["POST"])
def api_paths_move():
"""Verplaats de mappen van Server Up, met de stacks erbij.
De volgorde is de hele truc: eerst kopiëren en controleren, dan de
verwijzingen in de stacks omschrijven, dan de instellingen bijwerken, dan de
stacks weer starten en pas als dat allemaal gelukt is, het oude weggooien.
"""
d = request.json or {}
huidig = cfg.load()
doelen = {}
for sleutel in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
nieuw = (d.get(sleutel) or "").strip().rstrip("/")
if nieuw and nieuw != (huidig.get(sleutel) or "").rstrip("/"):
doelen[sleutel] = nieuw
if not doelen:
return jsonify(ok=False, msg="Geen enkele map is gewijzigd.")
# Vóór er iets gebeurt: is elk doel bruikbaar, en past het?
for sleutel, nieuw in doelen.items():
controle = paden.bereikbaar(nieuw)
if not controle.get("ok"):
return jsonify(**{**controle, "msg": _padfout_tekst(controle)}), 400
bron = Path(huidig[sleutel])
if bron.is_dir():
past, waarom = paden.past_het(bron, Path(nieuw))
if not past:
return jsonify(ok=False, msg=waarom), 400
jid, q = jobs.create("paths:move")
# De job draait in een eigen thread; `request` bestaat daar niet meer.
ip = request.remote_addr
def _run(jid):
qq = jobs.get_queue(jid)
lf = lambda m: jobs.log(qq, "dim", m)
library = Path(huidig["LIBRARY_DIR"])
gestart = []
opgeruimd = False
try:
jobs.log(qq, "section", "Mappen verplaatsen")
# 1. Alles stoppen: een container die tijdens het kopiëren
# doorschrijft levert een halve kopie op.
for st in paden.stacks_metadata(library):
if any(c.get("running") for c in docker.compose_ps(st["pad"], name=st["naam"])):
gestart.append(st["naam"])
jobs.log(qq, "dim", f"Stoppen: {st['naam']}")
docker.compose_down(st["pad"], name=st["naam"])
# 2. Kopiëren, met voortgang over het geheel.
totaal_bytes = sum(paden.meet(Path(huidig[k]))[1] for k in doelen)
al_gedaan = [0]
for sleutel, nieuw in doelen.items():
bron = Path(huidig[sleutel])
if not bron.is_dir():
jobs.log(qq, "warn", f"{bron} bestaat niet — overgeslagen")
continue
basis = al_gedaan[0]
def voortgang(gedaan, _tot, label, _b=basis):
jobs.voortgang(jid, _b + gedaan, totaal_bytes, label)
paden.verhuis(bron, Path(nieuw), log_fn=lf,
voortgang_fn=voortgang, verwijder_bron=False)
al_gedaan[0] += paden.meet(bron)[1]
# 3. De stacks laten weten waar hun data nu staat.
nieuwe_library = Path(doelen.get("LIBRARY_DIR", huidig["LIBRARY_DIR"]))
vervangingen = {huidig[k]: v for k, v in doelen.items()}
n = paden.schrijf_paden_om(nieuwe_library, vervangingen, log_fn=lf)
jobs.log(qq, "ok", f"{n} bestand(en) met oude paden bijgewerkt")
# 4. Pas nu de instellingen.
cfg.patch(doelen)
jobs.log(qq, "ok", "Instellingen bijgewerkt")
# 5. Terugzetten wat draaide, op de nieuwe plek.
for naam in gestart:
st = nieuwe_library / naam
if docker.has_compose(st):
jobs.log(qq, "dim", f"Starten: {naam}")
docker.compose_up(st, log_fn=lf, name=naam)
# 6. Het oude verdwijnt als laatste — pas als de rest werkt.
for sleutel in doelen:
oud = Path(huidig[sleutel])
if oud.is_dir():
shutil.rmtree(oud, ignore_errors=True)
opgeruimd = True
jobs.log(qq, "ok", f"Oude map opgeruimd: {oud}")
audit.log("settings", "paths_move", "ok",
ref=", ".join(doelen.values()), ip=ip)
jobs.log(qq, "ok", "Verplaatsen voltooid")
jobs.finish(jid, "done")
except Exception as e:
jobs.log(qq, "error", str(e))
# Alleen geruststellen als het ook waar is: struikelt hij ná het
# opruimen, dan staat het oude er juist niet meer.
jobs.log(qq, "warn",
"De nieuwe mappen staan klaar, maar het oude is al "
"opgeruimd — controleer de instellingen."
if opgeruimd else
"De oude mappen staan er nog; er is niets weggegooid.")
audit.log("settings", "paths_move", "error", detail=str(e)[:200])
jobs.finish(jid, "error")
jobs.done(qq)
jobs.run(_run, jid)
return jsonify(ok=True, job_id=jid)
def _padfout_tekst(controle: dict) -> str:
"""Een melding die zegt wat je moet dóén, niet alleen dat het misging."""
reden = controle.get("reden")
if reden == "geen_absoluut_pad":
return "Geef een volledig pad op, beginnend met /."
if reden == "buiten_mount":
voorstel = controle.get("voorstel_base") or controle.get("pad")
return (f"Server Up kan niet bij {controle.get('pad')}: die map is niet "
f"in de container gekoppeld. Zet BASE_DIR={voorstel} in je .env "
f"naast docker-compose.yml en draai `docker compose up -d`; "
f"daarna kun je hier verder.")
if reden == "niet_schrijfbaar":
return (f"Kan niet schrijven in {controle.get('pad')}: "
f"{controle.get('detail', 'onbekende fout')}")
return "Dit pad is niet bruikbaar."
@app.route("/api/jobs/<jid>")
def api_job(jid):
off = int(request.args.get("offset", 0))

View file

@ -11,10 +11,28 @@ _SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
_URL_SCHEME_RE = re.compile(r"^(https?|ssh|git)://", re.IGNORECASE)
_SCP_SYNTAX_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$")
# De hoofdmap waar alles onder hangt. `docker-compose.yml` mount deze op
# zichzelf (`${BASE_DIR}:${BASE_DIR}`), dus dit is óók precies het stuk
# bestandssysteem dat Server Up kan zien. Stond hier eerder los van: de drie
# paden hieronder waren hard `/opt/serverup/*`, ook als je BASE_DIR verzette.
BASE_DIR = (os.environ.get("BASE_DIR") or "/opt/serverup").rstrip("/") or "/opt/serverup"
# Hoe de submappen heten. Eén plek, zodat de interface dezelfde afleiding kan
# tonen als de server gebruikt.
SUBMAPPEN = {"LIBRARY_DIR": "stacks", "DATA_DIR": "appdata", "BACKUP_DIR": "backups"}
def afgeleide_paden(basis: str) -> dict[str, str]:
"""De drie mappen zoals ze onder deze hoofdmap zouden heten."""
schoon = (basis or "").strip().rstrip("/")
if not schoon.startswith("/"):
return {}
return {k: f"{schoon}/{sub}" for k, sub in SUBMAPPEN.items()}
DEFAULTS = {
"LIBRARY_DIR": "/opt/serverup/stacks",
"DATA_DIR": "/opt/serverup/appdata",
"BACKUP_DIR": "/opt/serverup/backups",
"BASE_DIR": BASE_DIR,
**afgeleide_paden(BASE_DIR),
"APP_REPOS": [
{"id":"server-up","name":"server-up",
"url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"apps"},

View file

@ -30,7 +30,7 @@ def create(tag: str) -> tuple[str, queue.Queue]:
q = queue.Queue()
with _lock:
_jobs[jid] = {"id": jid, "tag": tag, "status": "running",
"lines": [], "ts": time.time()}
"lines": [], "ts": time.time(), "progress": None}
_queues[jid] = q
_stream_locks[jid] = threading.Lock()
return jid, q
@ -45,6 +45,22 @@ def log(q, level: str, text: str):
q.put({"level": level, "text": text})
def voortgang(jid: str, gedaan: int, totaal: int, label: str = ""):
"""Hoever is deze job? Voor een balk in plaats van alleen regels tekst.
Bij het verplaatsen van tientallen gigabytes zegt "bezig…" niets; hier komt
het percentage en het bestand waar hij mee bezig is vandaan.
"""
with _lock:
job = _jobs.get(jid)
if job is None:
return
job["progress"] = {
"done": gedaan, "total": totaal, "label": label,
"pct": round(gedaan / totaal * 100) if totaal else 0,
}
def finish(jid: str, status="done"):
with _lock:
if jid in _jobs:
@ -78,7 +94,8 @@ def stream(jid: str, offset=0) -> dict:
"text": f"{dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
+ job["lines"][-_MAX_LINES:])
job["dropped"] = dropped + job.get("dropped", 0)
return {"lines": job["lines"][offset:], "status": job["status"]}
return {"lines": job["lines"][offset:], "status": job["status"],
"progress": job.get("progress")}
def run(fn, *args):

261
server-up/core/paden.py Normal file
View file

@ -0,0 +1,261 @@
"""De mappen van Server Up verplaatsen, en controleren of dat kán.
Twee dingen maken dit lastiger dan `mv`:
**Server Up ziet niet je hele bestandssysteem.** `docker-compose.yml` mount
`${BASE_DIR}` op zichzelf. Alles daarbuiten bestaat wel als pad binnen de
container, maar wat je erheen schrijft belandt in de overlay van de container en
niet op de host. Een verhuizing naar zo'n pad lijkt te lukken en levert daarna
lege apps op. `bereikbaar()` vangt dat vóóraf af.
**Elke geïnstalleerde stack heeft het oude pad in zijn compose staan**
(`/opt/serverup/appdata/sonarr:/config`). Alleen de bestanden verplaatsen laat
die verwijzingen staan, en dan draait je app op een lege map. `schrijf_paden_om()`
werkt ze bij.
De verhuizing kopieert eerst, controleert dan, en verwijdert de bron pas als dat
klopt nooit andersom.
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
import core as cfg
from core import diskspace
class PadFout(Exception):
pass
# ── Waar kan Server Up bij? ──────────────────────────────────────────────────
def _mountpunten() -> list[str]:
"""De paden die vanaf de host in deze container gemount zijn."""
punten = []
try:
with open("/proc/self/mountinfo", encoding="utf-8") as f:
for regel in f:
delen = regel.split()
# Veld 5 is het pad binnen de container.
if len(delen) > 4 and delen[4].startswith("/"):
punten.append(delen[4].rstrip("/") or "/")
except OSError:
pass
return punten
def bereikbaar(pad: str) -> dict:
"""Kan Server Up hier schrijven, en komt dat ook op de host terecht?
Retourneert `ok` (schrijfbaar én zichtbaar op de host), plus waarom niet en
wat je eraan doet. Bewust geen kale bool: de gebruiker moet weten dat hij
`BASE_DIR` moet verzetten, niet dat "het niet lukte".
"""
schoon = (pad or "").strip().rstrip("/")
if not schoon.startswith("/"):
return {"ok": False, "reden": "geen_absoluut_pad", "pad": pad}
basis = (cfg.load().get("BASE_DIR") or cfg.DEFAULTS["BASE_DIR"]).rstrip("/")
# Buiten een container (bij het ontwikkelen) is alles bereikbaar.
in_container = Path("/.dockerenv").exists()
punten = _mountpunten() if in_container else []
binnen = any(schoon == m or schoon.startswith(m.rstrip("/") + "/")
for m in punten if m != "/")
if in_container and punten and not binnen:
return {"ok": False, "reden": "buiten_mount", "pad": schoon,
"base_dir": basis,
# Het pad dat je in .env moet zetten: de eerste twee niveaus,
# want dieper mounten dan nodig is onhandig bij een volgende map.
"voorstel_base": "/".join(schoon.split("/")[:3]) or schoon}
# Schrijfbaar? Alleen echt proberen zegt iets; os.access liegt bij
# read-only mounts en bij root.
doel = Path(schoon)
proef = doel / ".serverup-schrijftest"
try:
doel.mkdir(parents=True, exist_ok=True)
proef.write_text("", encoding="utf-8")
proef.unlink()
except OSError as e:
return {"ok": False, "reden": "niet_schrijfbaar", "pad": schoon,
"detail": str(e)}
return {"ok": True, "pad": schoon, "base_dir": basis}
# ── Meten ────────────────────────────────────────────────────────────────────
def meet(bron: Path) -> tuple[int, int]:
"""(aantal bestanden, aantal bytes) onder deze map."""
aantal = bytes_ = 0
if not bron.is_dir():
return 0, 0
for p in bron.rglob("*"):
try:
if p.is_symlink() or not p.is_file():
continue
bytes_ += p.stat().st_size
aantal += 1
except OSError:
continue
return aantal, bytes_
def past_het(bron: Path, doel: Path) -> tuple[bool, str]:
"""Is er genoeg ruimte op de bestemming?"""
_, nodig = meet(bron)
u = diskspace.usage(doel.parent if not doel.exists() else doel)
if not u.get("ok"):
return True, "" # niet te meten: niet tegenhouden
min_gb, _ = diskspace.drempels()
reserve = min_gb * diskspace.GB
if nodig + reserve > u["free"]:
return False, (f"Te weinig ruimte op de bestemming: "
f"{diskspace.leesbaar(u['free'])} vrij, "
f"{diskspace.leesbaar(nodig)} nodig")
return True, ""
# ── Verhuizen ────────────────────────────────────────────────────────────────
def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None,
verwijder_bron: bool = True) -> int:
"""Kopieer alles van bron naar doel; verwijder de bron pas als het klopt.
`voortgang_fn(gedaan_bytes, totaal_bytes, label)` wordt tijdens het kopiëren
aangeroepen. Retourneert het aantal gekopieerde bestanden.
Verwijderen gebeurt alleen als aantal én omvang overeenkomen. Klopt er iets
niet, dan blijft de bron staan en volgt er een fout een half gelukte
verhuizing waarbij het origineel al weg is, is het ergste dat hier kan
gebeuren.
"""
if not bron.is_dir():
return 0
bron, doel = bron.resolve(), Path(str(doel)).resolve()
if bron == doel:
return 0
if str(doel).startswith(str(bron) + os.sep):
raise PadFout(f"'{doel}' ligt binnen '{bron}' — dat kan niet.")
aantal, totaal = meet(bron)
if log_fn:
log_fn(f"{bron}{doel}: {aantal} bestanden, "
f"{diskspace.leesbaar(totaal)}")
doel.mkdir(parents=True, exist_ok=True)
gedaan = kopieerd = 0
for p in sorted(bron.rglob("*")):
rel = p.relative_to(bron)
uit = doel / rel
try:
if p.is_symlink():
# Symlinks overnemen zoals ze zijn; het doel volgen zou een map
# kunnen dupliceren of buiten de boom kunnen wijzen.
if uit.exists() or uit.is_symlink():
uit.unlink()
uit.parent.mkdir(parents=True, exist_ok=True)
os.symlink(os.readlink(p), uit)
elif p.is_dir():
uit.mkdir(parents=True, exist_ok=True)
shutil.copystat(p, uit, follow_symlinks=False)
else:
uit.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, uit)
gedaan += p.stat().st_size
kopieerd += 1
if voortgang_fn:
voortgang_fn(gedaan, totaal, str(rel))
except OSError as e:
raise PadFout(f"Kopiëren van {rel} mislukte: {e}") from e
# Controleren vóór we iets weggooien.
n_doel, b_doel = meet(doel)
if n_doel < aantal or b_doel < totaal:
raise PadFout(
f"Controle mislukt: {aantal} bestanden / "
f"{diskspace.leesbaar(totaal)} verwacht, "
f"{n_doel} / {diskspace.leesbaar(b_doel)} aangekomen. "
f"De oude map blijft staan.")
if log_fn:
log_fn(f"Gecontroleerd: {n_doel} bestanden aangekomen")
if verwijder_bron:
shutil.rmtree(bron, ignore_errors=True)
if log_fn:
log_fn(f"Oude map opgeruimd: {bron}")
return kopieerd
# ── Verwijzingen in de stacks bijwerken ──────────────────────────────────────
def schrijf_paden_om(library: Path, vervangingen: dict[str, str],
log_fn=None) -> int:
"""Vervang oude paden door nieuwe in elke geïnstalleerde stack.
Raakt `docker-compose.yml`, `.env` en `.serverup.json`. Zonder dit wijst een
verplaatste app nog steeds naar zijn oude appdata-map die dan leeg is.
Alleen hele pad-segmenten: `/opt/serverup` vervangen mag `/opt/serverup-oud`
niet aanraken.
"""
if not library.is_dir():
return 0
paren = [(oud.rstrip("/"), nieuw.rstrip("/"))
for oud, nieuw in vervangingen.items()
if oud and nieuw and oud.rstrip("/") != nieuw.rstrip("/")]
if not paren:
return 0
geraakt = 0
for d in sorted(library.iterdir()):
if not d.is_dir():
continue
for naam in ("docker-compose.yml", "compose.yml", "compose.yaml",
".env", ".serverup.json"):
bestand = d / naam
if not bestand.is_file():
continue
try:
tekst = origineel = bestand.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
for oud, nieuw in paren:
# Een pad eindigt op een scheidingsteken, een aanhalingsteken,
# een dubbele punt of het regeleinde — nooit midden in een naam.
for grens in ("/", '"', "'", ":", "\n", " "):
tekst = tekst.replace(oud + grens, nieuw + grens)
if tekst.endswith(oud):
tekst = tekst[: -len(oud)] + nieuw
if tekst != origineel:
bestand.write_text(tekst, encoding="utf-8")
geraakt += 1
if log_fn:
log_fn(f"Paden bijgewerkt in {d.name}/{naam}")
return geraakt
def stacks_metadata(library: Path) -> list[dict]:
"""Per stack of hij draaide, zodat we hem na de verhuizing terugzetten."""
uit = []
if not library.is_dir():
return uit
for d in sorted(library.iterdir()):
if d.is_dir() and any((d / n).exists() for n in
("docker-compose.yml", "compose.yml", "compose.yaml")):
uit.append({"naam": d.name, "pad": d})
return uit
def lees_meta(stack: Path) -> dict:
mf = stack / ".serverup.json"
if not mf.is_file():
return {}
try:
return json.loads(mf.read_text("utf-8")) or {}
except Exception:
return {}

View file

@ -1270,10 +1270,66 @@ tailwind.config = {
<details class="card" open>
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark text-lg"></span> <span x-text="t('paths')">Paden</span></summary>
<div class="px-5 pb-5 grid sm:grid-cols-3 gap-4">
<div><label class="form-label" x-text="t('library_dir')">Library map</label><input class="form-input" x-model="settings.LIBRARY_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('appdata_dir')">Appdata-map</label><input class="form-input" x-model="settings.DATA_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('backup_dir')">Backup map</label><input class="form-input" x-model="settings.BACKUP_DIR" @change="autoSave()"></div>
<div class="px-5 pb-5 space-y-4">
<!-- Eén hoofdmap waar de rest zich naar voegt. De drie velden
blijven los aanpasbaar; dit is een voorstel, geen dwangbuis. -->
<div>
<label class="form-label" x-text="t('base_dir')">Hoofdmap</label>
<div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="hoofdmap"
@input.debounce.300ms="checkHoofdmap()"
placeholder="/srv/serverup">
<button type="button" class="btn shrink-0" @click="neemAllesOver()"
:disabled="!Object.keys(afgeleidePaden).length">
<span class="mdi mdi-arrow-down"></span>
<span class="hidden sm:inline" x-text="t('apply_all')">Alles overnemen</span>
</button>
</div>
<!-- Server Up ziet alleen wat in de container gekoppeld is;
daarbuiten schrijft hij naar het niets. -->
<p x-show="padCheck.reden" class="text-[11px] text-wr mt-1.5 flex items-start gap-1.5">
<span class="mdi mdi-alert-circle-outline shrink-0"></span>
<span x-text="padCheck.msg"></span>
</p>
</div>
<div class="grid sm:grid-cols-3 gap-4">
<template x-for="v in padVelden" :key="v.sleutel">
<div>
<label class="form-label" x-text="t(v.label)"></label>
<input class="form-input font-mono !text-xs"
:value="settings[v.sleutel]"
@input="settings[v.sleutel] = $event.target.value"
@change="autoSave()">
<button type="button" x-show="afgeleidePaden[v.sleutel] && afgeleidePaden[v.sleutel] !== settings[v.sleutel]"
class="text-[11px] text-ac dark:text-ac-dark hover:underline mt-1 inline-flex items-center gap-1 text-left"
@click="settings[v.sleutel] = afgeleidePaden[v.sleutel]; autoSave()">
<span class="mdi mdi-content-copy text-[11px] shrink-0"></span>
<span class="font-mono break-all" x-text="afgeleidePaden[v.sleutel]"></span>
</button>
</div>
</template>
</div>
<!-- Wijzigen alleen is niet genoeg: je data staat nog op de oude
plek, en elke stack wijst er nog naartoe. -->
<div x-show="teVerplaatsen.length"
class="rounded-xl border border-wr/40 bg-wr/5 px-3.5 py-3 space-y-2">
<div class="flex items-center gap-2 text-xs font-bold text-wr">
<span class="mdi mdi-folder-move-outline"></span>
<span x-text="t('paths_changed')">De mappen zijn gewijzigd</span>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark" x-text="t('paths_move_hint')"></p>
<template x-for="v in teVerplaatsen" :key="v.sleutel">
<p class="text-[11px] font-mono text-t2 dark:text-t2-dark break-all">
<span x-text="v.oud"></span><span class="text-ac dark:text-ac-dark" x-text="v.nieuw"></span>
</p>
</template>
<button type="button" class="btn btn-primary !h-9 !text-xs" @click="verplaatsPaden()">
<span class="mdi mdi-folder-move-outline"></span>
<span x-text="t('move_data')">Data verplaatsen</span>
</button>
</div>
<div>
<label class="form-label" x-text="t('timezone')">Tijdzone</label>
<input class="form-input" x-model="settings.TIMEZONE" @change="autoSave()"
@ -1328,6 +1384,17 @@ tailwind.config = {
<span class="badge" :class="job.status==='running'?'badge-info':job.status==='error'?'badge-danger':'badge-dim'" x-text="job.status==='running' ? t('busy') : (job.status==='error' ? t('error') : (job.status==='done' ? t('done') : t('ready')))"></span>
<span class="mdi text-slate-400 text-lg" :class="termOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'"></span>
</div>
<div x-show="job.progress && job.status === 'running'"
class="px-4 pt-3 pb-1 shrink-0">
<div class="flex items-center gap-2 text-[11px] text-t2 dark:text-t2-dark mb-1">
<span class="font-mono truncate flex-1" x-text="(job.progress || {}).label || ''"></span>
<span class="tabular-nums" x-text="((job.progress || {}).pct || 0) + '%'"></span>
</div>
<div class="h-1.5 rounded-full bg-s2 dark:bg-s2-dark overflow-hidden">
<div class="h-full bg-gradient-to-r from-ac to-ac2 transition-all duration-300"
:style="`width:${(job.progress || {}).pct || 0}%`"></div>
</div>
</div>
<div x-ref="termOut" class="h-44 overflow-y-auto px-4 py-3 font-mono text-xs leading-relaxed text-slate-200">
<template x-for="(line, i) in job.lines" :key="i">
<div class="flex gap-2" :class="'log-'+line.level">
@ -2446,6 +2513,12 @@ function app() {
stackQuery: '', stackCat: '',
storeQuery: '', storeTag: '', storeCat: '',
filterOpen: false, filterTagQuery: '', stackMenu: '', stackFilterOpen: false,
hoofdmap: '', afgeleidePaden: {}, padCheck: {}, padenVoorWijziging: {},
padVelden: [
{ sleutel: 'LIBRARY_DIR', label: 'library_dir' },
{ sleutel: 'DATA_DIR', label: 'appdata_dir' },
{ sleutel: 'BACKUP_DIR', label: 'backup_dir' },
],
poortCheck: {},
// Niet reactief nodig: puur een geheugensteun voor filteredStore().
_storeCache: { sleutel: null, lijsten: {} },
@ -3247,6 +3320,56 @@ function app() {
this.install.connect_apps = d.connect_default !== false;
}
},
// ── Mappen ───────────────────────────────────────────────────────────
// De hoofdmap is een voorstel: hij rekent de drie submappen uit en
// controleert of Server Up daar überhaupt bij kan. Dat laatste is geen
// luxe — buiten de gemounte map schrijft de container naar zijn eigen
// overlay en staat er op de host niets.
async checkHoofdmap() {
const basis = (this.hoofdmap || '').trim();
if (!basis.startsWith('/')) {
this.afgeleidePaden = {}; this.padCheck = {};
return;
}
const d = await this.rpc(`/api/paths/check?path=${encodeURIComponent(basis)}`
+ `&base=${encodeURIComponent(basis)}`);
this.afgeleidePaden = (d && d.afgeleid) || {};
this.padCheck = (d && !d.ok) ? { reden: d.reden, msg: d.msg || this.t('path_unusable') } : {};
},
neemAllesOver() {
for (const [sleutel, pad] of Object.entries(this.afgeleidePaden)) {
this.settings[sleutel] = pad;
}
this.autoSave();
},
// Welke mappen zijn gewijzigd sinds het scherm openging? Dat zijn de
// mappen waar nog data op de oude plek staat.
get teVerplaatsen() {
const uit = [];
for (const v of this.padVelden) {
const oud = this.padenVoorWijziging[v.sleutel];
const nieuw = this.settings[v.sleutel];
if (oud && nieuw && oud !== nieuw) uit.push({ sleutel: v.sleutel, oud, nieuw });
}
return uit;
},
async verplaatsPaden() {
const lijst = this.teVerplaatsen;
if (!lijst.length) return;
if (!confirm(this.t('move_data_confirm', { n: lijst.length }))) return;
const body = {};
for (const v of lijst) body[v.sleutel] = v.nieuw;
const d = await this.rpc('/api/paths/move', body);
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.pollJob(d.job_id, async (status) => {
if (status === 'done') {
this.padenVoorWijziging = {};
await this.loadSettings();
this.toast(this.t('saved'), 'ok');
}
});
},
// ── Poorten ──────────────────────────────────────────────────────────
// Alleen een int-veld dat ook echt een poort ís krijgt de stapknoppen en de
// controle; puid en pgid zijn ook getallen.
@ -3805,6 +3928,14 @@ function app() {
},
async loadSettings() {
const d = await this.rpc('/api/settings'); this.settings = d || {};
// Onthouden waar de mappen stónden, zodat we na een wijziging kunnen
// aanbieden de data mee te verhuizen.
this.padenVoorWijziging = {
LIBRARY_DIR: this.settings.LIBRARY_DIR,
DATA_DIR: this.settings.DATA_DIR,
BACKUP_DIR: this.settings.BACKUP_DIR,
};
this.hoofdmap = this.settings.BASE_DIR || '';
await this.loadAppStore();
await this.loadUsers();
await this.loadNetworks();

View file

@ -455,5 +455,14 @@
"raise": "Increase",
"port_free_docker_only": "Free according to Docker — what listens directly on the host could not be checked",
"port_used_by": "Port {port} is in use by {container}",
"use_free_port": "use {port}"
"use_free_port": "use {port}",
"audit_act_paths_move": "Folders moved",
"audit_cat_settings": "Settings",
"base_dir": "Base folder",
"apply_all": "Apply to all",
"path_unusable": "This path cannot be used",
"paths_changed": "The folders have changed",
"paths_move_hint": "The setting is saved, but your data is still in the old place and every stack still points there. Moving copies everything first, updates the stacks, and only then clears out the old folders.",
"move_data": "Move data",
"move_data_confirm": "Move {n} folder(s)? Stacks are stopped, data is copied and verified, and only then is the old cleared out."
}

View file

@ -455,5 +455,14 @@
"raise": "Omhoog",
"port_free_docker_only": "Vrij volgens Docker — wat rechtstreeks op de host luistert kon niet gecontroleerd worden",
"port_used_by": "Poort {port} is in gebruik door {container}",
"use_free_port": "gebruik {port}"
"use_free_port": "gebruik {port}",
"audit_act_paths_move": "Mappen verplaatst",
"audit_cat_settings": "Instellingen",
"base_dir": "Hoofdmap",
"apply_all": "Alles overnemen",
"path_unusable": "Dit pad is niet bruikbaar",
"paths_changed": "De mappen zijn gewijzigd",
"paths_move_hint": "De instelling is opgeslagen, maar je data staat nog op de oude plek en elke stack wijst daar nog naartoe. Verplaatsen kopieert eerst alles, werkt de stacks bij, en ruimt de oude mappen pas daarna op.",
"move_data": "Data verplaatsen",
"move_data_confirm": "{n} map(pen) verplaatsen? De stacks worden gestopt, de data wordt gekopieerd en gecontroleerd, en pas daarna wordt het oude opgeruimd."
}

View file

@ -319,9 +319,84 @@ c.install.ips['arr-sonarr'] = '192.168.1.240';
c.zetContainerIp('arr-sonarr', false);
check('en weer uitgezet', !('arr-sonarr' in c.install.ips));
// ── Mappen ──────────────────────────────────────────────────────────────────
// De poortcontroles hieronder hebben hun eigen antwoord nodig; even bewaren.
const poortRpc = c.rpc;
c.settings = { LIBRARY_DIR: '/opt/serverup/stacks', DATA_DIR: '/opt/serverup/appdata',
BACKUP_DIR: '/opt/serverup/backups' };
c.padenVoorWijziging = Object.assign({}, c.settings);
c.autoSave = () => {};
check('niets gewijzigd, niets te verplaatsen', c.teVerplaatsen.length === 0);
c.afgeleidePaden = { LIBRARY_DIR: '/srv/serverup/stacks',
DATA_DIR: '/srv/serverup/appdata',
BACKUP_DIR: '/srv/serverup/backups' };
c.neemAllesOver();
check('alles overnemen vult de drie velden', c.settings.DATA_DIR === '/srv/serverup/appdata');
check('en meldt drie verhuizingen', c.teVerplaatsen.length === 3);
check('met oud én nieuw erbij',
c.teVerplaatsen[0].oud === '/opt/serverup/stacks' &&
c.teVerplaatsen[0].nieuw === '/srv/serverup/stacks');
// De voortgangsbalk leest rechtstreeks uit de job; zonder voortgang blijft hij weg.
c.job = { status: 'running', lines: [], progress: null };
check('geen balk zonder voortgang', !c.job.progress);
c.job.progress = { done: 512, total: 1024, label: 'sonarr/config.xml', pct: 50 };
check('balk staat halverwege', c.job.progress.pct === 50);
// De poortcontrole praat met de backend, dus die checks zijn asynchroon.
// Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting.
(async () => {
// ── De hoofdmap ───────────────────────────────────────────────────────────
let gevraagdPad = null;
c.rpc = async (url) => {
gevraagdPad = url;
return { ok: true, afgeleid: { LIBRARY_DIR: '/srv/su/stacks',
DATA_DIR: '/srv/su/appdata',
BACKUP_DIR: '/srv/su/backups' } };
};
c.hoofdmap = 'srv/su';
await c.checkHoofdmap();
check('een relatief pad gaat niet naar de server', gevraagdPad === null);
check('en levert geen voorstellen op', Object.keys(c.afgeleidePaden).length === 0);
c.hoofdmap = '/srv/su';
await c.checkHoofdmap();
check('een volledig pad levert drie submappen op',
c.afgeleidePaden.DATA_DIR === '/srv/su/appdata');
check('zonder klacht', !c.padCheck.reden);
// Buiten de gemounte map schrijft de container naar zijn eigen overlay; dat
// moet je zien staan vóór je verhuist, niet erna.
c.rpc = async () => ({ ok: false, reden: 'buiten_mount', afgeleid: {},
msg: 'Zet BASE_DIR=/srv in je .env' });
await c.checkHoofdmap();
check('een onbereikbaar pad wordt gemeld', c.padCheck.reden === 'buiten_mount');
check('met de regel voor .env erbij', c.padCheck.msg.includes('BASE_DIR=/srv'));
// Verhuizen zonder wijziging hoort geen job te starten.
let verstuurd = null;
global.confirm = () => true;
c.rpc = async (url, body) => { verstuurd = body; return { job_id: 'x1' }; };
c.pollJob = () => {};
c.padenVoorWijziging = Object.assign({}, c.settings);
await c.verplaatsPaden();
check('geen verhuizing zonder wijziging', verstuurd === null);
c.settings.DATA_DIR = '/srv/su/appdata';
await c.verplaatsPaden();
check('alleen de gewijzigde map wordt verstuurd',
verstuurd && Object.keys(verstuurd).join() === 'DATA_DIR');
check('met het nieuwe pad', verstuurd.DATA_DIR === '/srv/su/appdata');
// Afbreken bij de bevestiging hoort niets te doen: dit gooit data weg.
verstuurd = null;
global.confirm = () => false;
await c.verplaatsPaden();
check('afbreken bij de bevestiging start niets', verstuurd === null);
c.rpc = poortRpc;
// Een leeg of onzinnig veld hoort geen verzoek te sturen.
c.install.values.port = 0;
gevraagd = [];

240
tests/test_paden.py Normal file
View file

@ -0,0 +1,240 @@
"""De mappen van Server Up verplaatsen.
Dit is het enige onderdeel dat gegevens van de gebruiker weggooit, dus de
volgorde is de hele zaak: kopiëren, controleren, en pas daarna opruimen. Een
verhuizing die halverwege strandt met het origineel al verwijderd is het ergste
dat hier kan gebeuren.
"""
import json
import os
import sys
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
def _vul(d: Path, bestanden: dict[str, str]) -> Path:
for naam, inhoud in bestanden.items():
p = d / naam
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(inhoud, encoding="utf-8")
return d
# ── Hoofdmap en afgeleide paden ──────────────────────────────────────────────
def test_afgeleide_paden(env):
import core
assert core.afgeleide_paden("/srv/serverup") == {
"LIBRARY_DIR": "/srv/serverup/stacks",
"DATA_DIR": "/srv/serverup/appdata",
"BACKUP_DIR": "/srv/serverup/backups",
}
# Een afsluitende schuine streep hoort niet te verdubbelen.
assert core.afgeleide_paden("/srv/serverup/")["DATA_DIR"] == "/srv/serverup/appdata"
# Een relatief pad levert niets op in plaats van iets kapots.
assert core.afgeleide_paden("srv/data") == {}
assert core.afgeleide_paden("") == {}
def test_base_dir_stuurt_de_standaarden(monkeypatch, tmp_path):
"""De drie paden stonden hard op /opt/serverup/*, ook als je BASE_DIR
verzette precies waarom de interface /opt bleef voorstellen."""
import importlib
monkeypatch.setenv("BASE_DIR", "/srv/eigen")
monkeypatch.setenv("SU_CONFIG", str(tmp_path / "c.json"))
for naam in [n for n in sys.modules if n == "core" or n.startswith("core.")]:
del sys.modules[naam]
core = importlib.import_module("core")
assert core.DEFAULTS["LIBRARY_DIR"] == "/srv/eigen/stacks"
assert core.DEFAULTS["DATA_DIR"] == "/srv/eigen/appdata"
assert core.DEFAULTS["BACKUP_DIR"] == "/srv/eigen/backups"
# ── Meten en verplaatsen ─────────────────────────────────────────────────────
def test_meet_telt_bestanden_en_bytes(env, tmp_path):
from core import paden
d = _vul(tmp_path / "bron", {"a.txt": "12345", "sub/b.txt": "678"})
aantal, bytes_ = paden.meet(d)
assert (aantal, bytes_) == (2, 8)
assert paden.meet(tmp_path / "bestaat-niet") == (0, 0)
def test_verhuizen_kopieert_en_ruimt_daarna_op(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo", "sub/b.txt": "wereld"})
doel = tmp_path / "nieuw"
stappen = []
n = paden.verhuis(bron, doel, voortgang_fn=lambda g, t, l: stappen.append((g, t)))
assert n == 2
assert (doel / "a.txt").read_text() == "hallo"
assert (doel / "sub" / "b.txt").read_text() == "wereld"
assert not bron.exists(), "de bron hoort na een geslaagde verhuizing weg te zijn"
assert stappen and stappen[-1][0] == stappen[-1][1], "voortgang loopt niet vol"
def test_de_bron_blijft_staan_als_de_controle_faalt(env, tmp_path, monkeypatch):
"""Als er onderweg iets misgaat mag het origineel niet verdwijnen."""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo"})
doel = tmp_path / "nieuw"
# Doe alsof er minder is aangekomen dan verstuurd.
echt = paden.meet
def nep(p):
return (0, 0) if Path(p) == doel.resolve() else echt(p)
monkeypatch.setattr(paden, "meet", nep)
with pytest.raises(paden.PadFout, match="Controle mislukt"):
paden.verhuis(bron, doel)
assert (bron / "a.txt").exists(), "de bron is weg terwijl de controle faalde"
def test_doel_binnen_de_bron_wordt_geweigerd(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
with pytest.raises(paden.PadFout, match="binnen"):
paden.verhuis(bron, bron / "erin")
def test_zelfde_map_doet_niets(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
assert paden.verhuis(bron, bron) == 0
assert (bron / "a.txt").exists()
def test_symlinks_blijven_symlinks(env, tmp_path):
"""Een link volgen zou de inhoud dupliceren of buiten de boom kunnen wijzen."""
from core import paden
bron = _vul(tmp_path / "oud", {"echt.txt": "inhoud"})
os.symlink("echt.txt", bron / "link.txt")
doel = tmp_path / "nieuw"
paden.verhuis(bron, doel)
assert (doel / "link.txt").is_symlink()
assert os.readlink(doel / "link.txt") == "echt.txt"
# ── De stacks meeverhuizen ───────────────────────────────────────────────────
def test_paden_in_stacks_worden_omgeschreven(env, tmp_path):
"""Alleen bestanden verplaatsen laat elke stack naar zijn oude appdata
wijzen die dan leeg is."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "sonarr"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n sonarr:\n volumes:\n"
" - /opt/serverup/appdata/sonarr:/config\n", encoding="utf-8")
(stack / ".env").write_text("PAD=/opt/serverup/appdata/sonarr\n", encoding="utf-8")
(stack / ".serverup.json").write_text(json.dumps(
{"source": "sonarr", "values": {"appdata_dir": "/opt/serverup/appdata"}}))
n = paden.schrijf_paden_om(lib, {"/opt/serverup/appdata": "/srv/serverup/appdata"})
assert n == 3
assert "/srv/serverup/appdata/sonarr:/config" in (stack / "docker-compose.yml").read_text()
assert "/srv/serverup/appdata/sonarr" in (stack / ".env").read_text()
meta = json.loads((stack / ".serverup.json").read_text())
assert meta["values"]["appdata_dir"] == "/srv/serverup/appdata"
def test_een_pad_dat_er_op_lijkt_blijft_met_rust(env, tmp_path):
"""`/opt/serverup` vervangen mag `/opt/serverup-oud` niet aanraken."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "app"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n a:\n volumes:\n"
" - /opt/serverup-oud/data:/config\n"
" - /opt/serverup/data:/data\n", encoding="utf-8")
paden.schrijf_paden_om(lib, {"/opt/serverup": "/srv/serverup"})
tekst = (stack / "docker-compose.yml").read_text()
assert "/opt/serverup-oud/data:/config" in tekst, "de buurmap is meegenomen"
assert "/srv/serverup/data:/data" in tekst
# ── Bereikbaarheid ───────────────────────────────────────────────────────────
def test_relatief_pad_wordt_geweigerd(env):
from core import paden
uit = paden.bereikbaar("relatief/pad")
assert not uit["ok"] and uit["reden"] == "geen_absoluut_pad"
def test_onschrijfbaar_pad_wordt_gemeld(env, tmp_path, monkeypatch):
from core import paden
monkeypatch.setattr(Path, "mkdir",
lambda *a, **k: (_ for _ in ()).throw(OSError("alleen-lezen")))
uit = paden.bereikbaar(str(tmp_path / "kan-niet"))
assert not uit["ok"] and uit["reden"] == "niet_schrijfbaar"
assert "alleen-lezen" in uit["detail"]
# ── Voortgang ────────────────────────────────────────────────────────────────
def test_voortgang_komt_door_de_api(client, env):
"""Bij tientallen gigabytes zegt "bezig…" niets; de balk komt hiervandaan."""
from core import jobs
login(client)
jid, _q = jobs.create("test")
jobs.voortgang(jid, 256, 1024, "bestand.txt")
d = client.get(f"/api/jobs/{jid}").get_json()
assert d["progress"] == {"done": 256, "total": 1024,
"label": "bestand.txt", "pct": 25}
def test_voortgang_deelt_niet_door_nul(env):
from core import jobs
jid, _q = jobs.create("test")
jobs.voortgang(jid, 0, 0, "")
assert jobs.stream(jid)["progress"]["pct"] == 0
# ── Het formulier stelt de ingestelde map voor ───────────────────────────────
def test_installatieformulier_gebruikt_de_ingestelde_appdata(client, env, monkeypatch):
"""266 sjablonen noemen /opt/serverup/appdata. Wat je ook instelde, dáár
kwam je data terecht.
De testomgeving zet DATA_DIR als omgevingsvariabele, en die wint van het
configuratiebestand dus die verzetten we hier, niet de config.
"""
from core import git
csrf = login(client)
monkeypatch.setenv("DATA_DIR", "/srv/eigen/appdata")
bron = git.cache_dir("server-up") / "apps" / "testapp"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Test", "description": "d", "tags": ["t"]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "testapp", "required": True},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": True},
]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n"
" volumes:\n - << appdata_dir >>/<< service_name >>:/config\n",
encoding="utf-8")
d = client.post("/api/store/preview",
json={"stack": "testapp", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
veld = next(f for f in d["fields"] if f["name"] == "appdata_dir")
assert veld["default"] == "/srv/eigen/appdata"