v0.9.00-beta - één hoofdmap, paden kloppend en data verhuizen
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m40s
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:
parent
b6319e8fdd
commit
90e4160727
14 changed files with 1027 additions and 18 deletions
11
.env.example
11
.env.example
|
|
@ -31,8 +31,15 @@ PORT=5000
|
||||||
|
|
||||||
# ── Overig ───────────────────────────────────────────────────────────────────
|
# ── Overig ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Waar de stacks, appdata en backups komen te staan. Dit pad wordt in de
|
# De hoofdmap voor stacks, appdata en backups. Dit pad wordt in de container op
|
||||||
# container op hetzelfde pad gemount, zodat compose-bestanden kloppen.
|
# 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
|
#BASE_DIR=/opt/serverup
|
||||||
|
|
||||||
# Zet op 1 zodra alles via HTTPS loopt: de sessiecookie krijgt dan de
|
# Zet op 1 zodra alles via HTTPS loopt: de sessiecookie krijgt dan de
|
||||||
|
|
|
||||||
38
CHANGELOG.md
38
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
|
# v0.8.82-beta — "Poort {port} is vrij" stond er letterlijk
|
||||||
|
|
||||||
De poortcontrole werd aangesloten op vertaalsleutels die er al waren, en twee
|
De poortcontrole werd aangesloten op vertaalsleutels die er al waren, en twee
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.8.82-beta
|
0.9.00-beta
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ services:
|
||||||
- SU_AUDIT=/data/audit.db
|
- SU_AUDIT=/data/audit.db
|
||||||
- SU_GIT_CACHE=/data/git
|
- SU_GIT_CACHE=/data/git
|
||||||
- SU_CONTAINER=server-up
|
- 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).
|
# Versie die de UI toont (overschrijft de ingebakken default).
|
||||||
- SU_VERSION=${SU_VERSION:-dev}
|
- SU_VERSION=${SU_VERSION:-dev}
|
||||||
# Voeg eigen app-repos toe zonder wizard. Eenmalig opgeslagen in config.json.
|
# Voeg eigen app-repos toe zonder wizard. Eenmalig opgeslagen in config.json.
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Het beheerdersaccount
|
||||||
|
|
||||||
Server Up beheert de Docker-daemon, en wie containers kan starten kan
|
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
|
Stopt en verwijdert de container. **Je gegevens blijven staan**: het
|
||||||
docker-volume `su-data` (instellingen, accounts, auditlog) en je stackmappen
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
159
server-up/app.py
159
server-up/app.py
|
|
@ -16,7 +16,7 @@ import core as cfg
|
||||||
from core import audit, jobs, i18n
|
from core import audit, jobs, i18n
|
||||||
from core import auth, docker, git, boilerplates, updater
|
from core import auth, docker, git, boilerplates, updater
|
||||||
from core import backups, categories, compose_transform, diskspace, networks
|
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 scheduler, selfupdate
|
||||||
from core import stackupdates
|
from core import stackupdates
|
||||||
from core.modules import Module, CORE, discover
|
from core.modules import Module, CORE, discover
|
||||||
|
|
@ -162,7 +162,7 @@ ADMIN_ONLY_PREFIXES = (
|
||||||
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
"/api/repos", "/api/modrepos", "/api/modules", "/api/wizard",
|
||||||
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
"/api/update/", "/api/audit/clear", "/api/docker/restart",
|
||||||
"/api/docker/images", "/api/notify/", "/api/appnetworks",
|
"/api/docker/images", "/api/notify/", "/api/appnetworks",
|
||||||
"/api/pangolin",
|
"/api/pangolin", "/api/paths",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
# Uitzondering: je eigen wachtwoord wijzigen mag iedereen.
|
||||||
|
|
@ -1456,6 +1456,7 @@ def _annotate_fields(fields: list[dict]) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
bezet = _bezette_poorten()
|
bezet = _bezette_poorten()
|
||||||
tz = cfg.tijdzone()
|
tz = cfg.tijdzone()
|
||||||
|
appdata = cfg.load().get("DATA_DIR") or cfg.DEFAULTS["DATA_DIR"]
|
||||||
uitgedeeld: set[int] = set()
|
uitgedeeld: set[int] = set()
|
||||||
for f in fields:
|
for f in fields:
|
||||||
if _is_secret_field(f):
|
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.
|
# alleen toevallig. De tijdzone van deze server is een beter voorstel.
|
||||||
if naam in ("timezone", "tz") and f.get("type") != "int":
|
if naam in ("timezone", "tz") and f.get("type") != "int":
|
||||||
f["default"] = tz
|
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:
|
if f.get("type") == "int" and "port" in naam:
|
||||||
try:
|
try:
|
||||||
gewenst = int(f.get("default") or 0)
|
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])
|
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>")
|
@app.route("/api/jobs/<jid>")
|
||||||
def api_job(jid):
|
def api_job(jid):
|
||||||
off = int(request.args.get("offset", 0))
|
off = int(request.args.get("offset", 0))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
_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]+$")
|
_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 = {
|
DEFAULTS = {
|
||||||
"LIBRARY_DIR": "/opt/serverup/stacks",
|
"BASE_DIR": BASE_DIR,
|
||||||
"DATA_DIR": "/opt/serverup/appdata",
|
**afgeleide_paden(BASE_DIR),
|
||||||
"BACKUP_DIR": "/opt/serverup/backups",
|
|
||||||
"APP_REPOS": [
|
"APP_REPOS": [
|
||||||
{"id":"server-up","name":"server-up",
|
{"id":"server-up","name":"server-up",
|
||||||
"url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"apps"},
|
"url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"apps"},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ def create(tag: str) -> tuple[str, queue.Queue]:
|
||||||
q = queue.Queue()
|
q = queue.Queue()
|
||||||
with _lock:
|
with _lock:
|
||||||
_jobs[jid] = {"id": jid, "tag": tag, "status": "running",
|
_jobs[jid] = {"id": jid, "tag": tag, "status": "running",
|
||||||
"lines": [], "ts": time.time()}
|
"lines": [], "ts": time.time(), "progress": None}
|
||||||
_queues[jid] = q
|
_queues[jid] = q
|
||||||
_stream_locks[jid] = threading.Lock()
|
_stream_locks[jid] = threading.Lock()
|
||||||
return jid, q
|
return jid, q
|
||||||
|
|
@ -45,6 +45,22 @@ def log(q, level: str, text: str):
|
||||||
q.put({"level": level, "text": text})
|
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"):
|
def finish(jid: str, status="done"):
|
||||||
with _lock:
|
with _lock:
|
||||||
if jid in _jobs:
|
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"}]
|
"text": f"… {dropped + job.get('dropped', 0)} eerdere regels weggelaten"}]
|
||||||
+ job["lines"][-_MAX_LINES:])
|
+ job["lines"][-_MAX_LINES:])
|
||||||
job["dropped"] = dropped + job.get("dropped", 0)
|
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):
|
def run(fn, *args):
|
||||||
|
|
|
||||||
261
server-up/core/paden.py
Normal file
261
server-up/core/paden.py
Normal 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 {}
|
||||||
|
|
@ -1270,10 +1270,66 @@ tailwind.config = {
|
||||||
|
|
||||||
<details class="card" open>
|
<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>
|
<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 class="px-5 pb-5 space-y-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>
|
<!-- Eén hoofdmap waar de rest zich naar voegt. De drie velden
|
||||||
<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>
|
blijven los aanpasbaar; dit is een voorstel, geen dwangbuis. -->
|
||||||
<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>
|
||||||
|
<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>
|
<div>
|
||||||
<label class="form-label" x-text="t('timezone')">Tijdzone</label>
|
<label class="form-label" x-text="t('timezone')">Tijdzone</label>
|
||||||
<input class="form-input" x-model="settings.TIMEZONE" @change="autoSave()"
|
<input class="form-input" x-model="settings.TIMEZONE" @change="autoSave()"
|
||||||
|
|
@ -1328,7 +1384,18 @@ 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="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>
|
<span class="mdi text-slate-400 text-lg" :class="termOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'"></span>
|
||||||
</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">
|
<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">
|
<template x-for="(line, i) in job.lines" :key="i">
|
||||||
<div class="flex gap-2" :class="'log-'+line.level">
|
<div class="flex gap-2" :class="'log-'+line.level">
|
||||||
<span class="w-3 text-center shrink-0" x-text="({ok:'✔',error:'✖',warn:'⚠',info:'ℹ',dim:'·',section:'›'})[line.level] || '·'"></span>
|
<span class="w-3 text-center shrink-0" x-text="({ok:'✔',error:'✖',warn:'⚠',info:'ℹ',dim:'·',section:'›'})[line.level] || '·'"></span>
|
||||||
|
|
@ -2446,6 +2513,12 @@ function app() {
|
||||||
stackQuery: '', stackCat: '',
|
stackQuery: '', stackCat: '',
|
||||||
storeQuery: '', storeTag: '', storeCat: '',
|
storeQuery: '', storeTag: '', storeCat: '',
|
||||||
filterOpen: false, filterTagQuery: '', stackMenu: '', stackFilterOpen: false,
|
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: {},
|
poortCheck: {},
|
||||||
// Niet reactief nodig: puur een geheugensteun voor filteredStore().
|
// Niet reactief nodig: puur een geheugensteun voor filteredStore().
|
||||||
_storeCache: { sleutel: null, lijsten: {} },
|
_storeCache: { sleutel: null, lijsten: {} },
|
||||||
|
|
@ -3247,6 +3320,56 @@ function app() {
|
||||||
this.install.connect_apps = d.connect_default !== false;
|
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 ──────────────────────────────────────────────────────────
|
// ── Poorten ──────────────────────────────────────────────────────────
|
||||||
// Alleen een int-veld dat ook echt een poort ís krijgt de stapknoppen en de
|
// Alleen een int-veld dat ook echt een poort ís krijgt de stapknoppen en de
|
||||||
// controle; puid en pgid zijn ook getallen.
|
// controle; puid en pgid zijn ook getallen.
|
||||||
|
|
@ -3805,6 +3928,14 @@ function app() {
|
||||||
},
|
},
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
const d = await this.rpc('/api/settings'); this.settings = d || {};
|
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.loadAppStore();
|
||||||
await this.loadUsers();
|
await this.loadUsers();
|
||||||
await this.loadNetworks();
|
await this.loadNetworks();
|
||||||
|
|
|
||||||
|
|
@ -455,5 +455,14 @@
|
||||||
"raise": "Increase",
|
"raise": "Increase",
|
||||||
"port_free_docker_only": "Free according to Docker — what listens directly on the host could not be checked",
|
"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}",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -455,5 +455,14 @@
|
||||||
"raise": "Omhoog",
|
"raise": "Omhoog",
|
||||||
"port_free_docker_only": "Vrij volgens Docker — wat rechtstreeks op de host luistert kon niet gecontroleerd worden",
|
"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}",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,9 +319,84 @@ c.install.ips['arr-sonarr'] = '192.168.1.240';
|
||||||
c.zetContainerIp('arr-sonarr', false);
|
c.zetContainerIp('arr-sonarr', false);
|
||||||
check('en weer uitgezet', !('arr-sonarr' in c.install.ips));
|
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.
|
// De poortcontrole praat met de backend, dus die checks zijn asynchroon.
|
||||||
// Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting.
|
// Top-level await kan niet in een CommonJS-script; vandaar deze afsluiting.
|
||||||
(async () => {
|
(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.
|
// Een leeg of onzinnig veld hoort geen verzoek te sturen.
|
||||||
c.install.values.port = 0;
|
c.install.values.port = 0;
|
||||||
gevraagd = [];
|
gevraagd = [];
|
||||||
|
|
|
||||||
240
tests/test_paden.py
Normal file
240
tests/test_paden.py
Normal 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"
|
||||||
Loading…
Reference in a new issue