Eigenaar meenemen bij het verhuizen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m32s

`shutil.copy2` neemt de rechten mee maar niet de eigenaar, en Server Up draait
als root. Verhuisde appdata werd daardoor root:root, waarna een container die
als PUID 1000 draait er niet meer in kon.

- elk bestand, elke map, elke symlink én de hoofdmap krijgen de uid/gid van het
  origineel
- lukt chownen niet (je draait niet als root), dan gaat de verhuizing door met
  een waarschuwing in plaats van te struikelen
- tests voor beide gevallen, en voor het feit dat de uid van de bron komt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-02 14:49:18 +02:00
parent 1530df277d
commit f4b652e182
3 changed files with 108 additions and 0 deletions

View file

@ -34,6 +34,14 @@ 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 naar hele pad-segmenten, zodat `/opt/serverup` vervangen `/opt/serverup-oud` met
rust laat. rust laat.
**Rechten én eigenaar gaan mee.** `shutil.copy2` neemt de rechten over maar niet
de eigenaar, en Server Up draait als root — verhuisde appdata werd dus root:root,
waarna een container die als PUID 1000 draait niet meer bij zijn eigen gegevens
kon. De linuxserver-images zetten `/config` bij het starten terug, maar postgres,
Nextcloud en Immich lopen er gewoon op vast. De verhuizing chownt nu elk bestand,
elke map, elke symlink én de hoofdmap zelf naar de eigenaar van het origineel.
Lukt dat niet, dan gaat de verhuizing door met een waarschuwing in het log.
Jobs kregen daarvoor een `voortgang()`; de terminal toont de balk boven het log. 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

View file

@ -127,6 +127,25 @@ def past_het(bron: Path, doel: Path) -> tuple[bool, str]:
# ── Verhuizen ──────────────────────────────────────────────────────────────── # ── Verhuizen ────────────────────────────────────────────────────────────────
def _neem_eigenaar_over(bron: Path, doel: Path, mislukt: list) -> None:
"""Geef het gekopieerde dezelfde eigenaar en rechten als het origineel.
`shutil.copy2` neemt de rechten mee maar níét de eigenaar. Server Up draait
als root, dus zonder dit wordt alles root:root en een container die als
PUID 1000 draait kan dan niet meer bij zijn eigen appdata. Bij de
linuxserver-images valt dat nog mee (die zetten `/config` bij het starten
terug), maar postgres, Nextcloud en Immich lopen er gewoon op vast.
"""
try:
st = os.stat(bron, follow_symlinks=False)
os.chown(doel, st.st_uid, st.st_gid, follow_symlinks=False)
except (OSError, NotImplementedError) as e:
# Draai je niet als root, dan is alles toch al van jou; dan is dit geen
# fout maar een no-op. Alleen echte gevallen verzamelen we.
if not mislukt:
mislukt.append(str(e))
def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None, def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None,
verwijder_bron: bool = True) -> int: verwijder_bron: bool = True) -> int:
"""Kopieer alles van bron naar doel; verwijder de bron pas als het klopt. """Kopieer alles van bron naar doel; verwijder de bron pas als het klopt.
@ -134,6 +153,9 @@ def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None,
`voortgang_fn(gedaan_bytes, totaal_bytes, label)` wordt tijdens het kopiëren `voortgang_fn(gedaan_bytes, totaal_bytes, label)` wordt tijdens het kopiëren
aangeroepen. Retourneert het aantal gekopieerde bestanden. aangeroepen. Retourneert het aantal gekopieerde bestanden.
Rechten én eigenaar gaan mee: een verhuisde appdata-map die opeens van root
is, breekt elke container die als een gewone gebruiker draait.
Verwijderen gebeurt alleen als aantal én omvang overeenkomen. Klopt er iets 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 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 verhuizing waarbij het origineel al weg is, is het ergste dat hier kan
@ -153,6 +175,12 @@ def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None,
f"{diskspace.leesbaar(totaal)}") f"{diskspace.leesbaar(totaal)}")
doel.mkdir(parents=True, exist_ok=True) doel.mkdir(parents=True, exist_ok=True)
# Ook de hoofdmap zelf: staat die op root, dan kan een container er niet in
# schrijven al klopt alles eronder.
chown_mislukt: list = []
shutil.copystat(bron, doel)
_neem_eigenaar_over(bron, doel, chown_mislukt)
gedaan = kopieerd = 0 gedaan = kopieerd = 0
for p in sorted(bron.rglob("*")): for p in sorted(bron.rglob("*")):
rel = p.relative_to(bron) rel = p.relative_to(bron)
@ -175,9 +203,15 @@ def verhuis(bron: Path, doel: Path, log_fn=None, voortgang_fn=None,
kopieerd += 1 kopieerd += 1
if voortgang_fn: if voortgang_fn:
voortgang_fn(gedaan, totaal, str(rel)) voortgang_fn(gedaan, totaal, str(rel))
_neem_eigenaar_over(p, uit, chown_mislukt)
except OSError as e: except OSError as e:
raise PadFout(f"Kopiëren van {rel} mislukte: {e}") from e raise PadFout(f"Kopiëren van {rel} mislukte: {e}") from e
if chown_mislukt and log_fn:
log_fn(f"Let op: eigenaar kon niet worden overgenomen "
f"({chown_mislukt[0]}). Controleer de rechten als een app niet "
f"meer bij zijn gegevens kan.")
# Controleren vóór we iets weggooien. # Controleren vóór we iets weggooien.
n_doel, b_doel = meet(doel) n_doel, b_doel = meet(doel)
if n_doel < aantal or b_doel < totaal: if n_doel < aantal or b_doel < totaal:

View file

@ -113,6 +113,72 @@ def test_zelfde_map_doet_niets(env, tmp_path):
assert (bron / "a.txt").exists() assert (bron / "a.txt").exists()
def test_eigenaar_en_rechten_gaan_mee(env, tmp_path, monkeypatch):
"""`shutil.copy2` neemt de rechten mee maar niet de eigenaar.
Server Up draait als root, dus zonder chown werd verhuisde appdata root:root
en kwam een container die als PUID 1000 draait er niet meer in. De test kan
zelf niet chownen (dat mag alleen root), dus we leggen vast wát er gevraagd
wordt.
"""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo", "sub/b.txt": "wereld"})
os.chmod(bron / "a.txt", 0o640)
doel = tmp_path / "nieuw"
gevraagd = {}
monkeypatch.setattr(paden.os, "chown",
lambda p, u, g, **kw: gevraagd.__setitem__(str(p), (u, g)))
paden.verhuis(bron, doel)
eigen = (os.getuid(), os.getgid())
assert gevraagd.get(str(doel)) == eigen, "de hoofdmap zelf blijft van root"
assert gevraagd.get(str(doel / "a.txt")) == eigen
assert gevraagd.get(str(doel / "sub")) == eigen
assert gevraagd.get(str(doel / "sub" / "b.txt")) == eigen
# De rechten komen van copy2/copystat en horen ook te kloppen.
assert os.stat(doel / "a.txt").st_mode & 0o777 == 0o640
def test_de_eigenaar_komt_van_de_bron(env, tmp_path, monkeypatch):
"""De uid/gid moet van het origineel komen, niet van wie er toevallig
kopieert. Chownen kan alleen root, dus we leggen de vraag vast."""
from core import paden
bron = _vul(tmp_path, {"a.txt": "x"}) / "a.txt"
doel = tmp_path / "b.txt"
doel.write_text("x")
st = os.stat(bron)
# Een echte stat_result met een andere eigenaar, zodat copystat blijft werken.
nep = os.stat_result((st.st_mode, st.st_ino, st.st_dev, st.st_nlink,
1000, 1000, st.st_size,
int(st.st_atime), int(st.st_mtime), int(st.st_ctime)))
monkeypatch.setattr(paden.os, "stat", lambda p, **kw: nep)
gevraagd = []
monkeypatch.setattr(paden.os, "chown",
lambda p, u, g, **kw: gevraagd.append((u, g)))
paden._neem_eigenaar_over(bron, doel, [])
assert gevraagd == [(1000, 1000)]
def test_chown_die_niet_mag_stopt_de_verhuizing_niet(env, tmp_path, monkeypatch):
"""Draai je niet als root, dan is alles toch al van jou. Dat mag geen fout
zijn wel een melding, want dan kán het misgaan."""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo"})
doel = tmp_path / "nieuw"
monkeypatch.setattr(paden.os, "chown",
lambda *a, **k: (_ for _ in ()).throw(PermissionError("mag niet")))
regels = []
n = paden.verhuis(bron, doel, log_fn=regels.append)
assert n == 1 and (doel / "a.txt").read_text() == "hallo"
assert any("eigenaar" in r for r in regels), "geen waarschuwing over de rechten"
def test_symlinks_blijven_symlinks(env, tmp_path): def test_symlinks_blijven_symlinks(env, tmp_path):
"""Een link volgen zou de inhoud dupliceren of buiten de boom kunnen wijzen.""" """Een link volgen zou de inhoud dupliceren of buiten de boom kunnen wijzen."""
from core import paden from core import paden