diff --git a/.env.example b/.env.example index 09f48d4..131d343 100644 --- a/.env.example +++ b/.env.example @@ -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: +# /stacks /appdata /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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 552f69f..009ab89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 1c866df..66e6063 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.82-beta +0.9.00-beta diff --git a/docker-compose.yml b/docker-compose.yml index 9e647c9..be5d431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/docs/installeren.md b/docs/installeren.md index b841e68..ab76c47 100644 --- a/docs/installeren.md +++ b/docs/installeren.md @@ -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. --- diff --git a/server-up/app.py b/server-up/app.py index 7eb2a7e..406c3b0 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -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/") def api_job(jid): off = int(request.args.get("offset", 0)) diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index 312afcb..16dfad8 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -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"}, diff --git a/server-up/core/jobs.py b/server-up/core/jobs.py index e0a134f..5fa8e3f 100644 --- a/server-up/core/jobs.py +++ b/server-up/core/jobs.py @@ -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): diff --git a/server-up/core/paden.py b/server-up/core/paden.py new file mode 100644 index 0000000..88c9817 --- /dev/null +++ b/server-up/core/paden.py @@ -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 {} diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 86c8fcb..b6236fb 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -1270,10 +1270,66 @@ tailwind.config = {
Paden -
-
-
-
+
+ +
+ +
+ + +
+ +

+ + +

+
+ +
+ +
+ + +
+
+ + De mappen zijn gewijzigd +
+

+ + +
-
+
+
+ + +
+
+
+
+
+