diff --git a/CHANGELOG.md b/CHANGELOG.md index 020b1b7..46bd1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +# v0.8.00-beta — Een kapotte cache herstelt zichzelf + +`fatal: shallow file has changed since we read it` liet een repo achter die zich +niet meer liet bijwerken — en daarmee een app store die leeg bleef. + +Die melding komt van twee `git fetch` in dezelfde map. Bij het opstarten +synchroniseert Server Up alles, en ondertussen kan je op **Synchroniseren** +drukken of een app installeren die de repo ook aanraakt. Er zat geen slot op: +elke synchronisatie kon over een andere heen lopen en de cache halverwege +achterlaten, met een `.git/shallow` waar git niets meer mee kan. + +Twee dingen erbij: + +- **Eén git tegelijk per repo.** Botsen kan nu niet meer. +- **Zelfherstel.** Lukt het bijwerken niet — om welke reden dan ook — dan wordt + de cache weggegooid en opnieuw opgehaald. Er staat niets in dat bewaard hoeft + te blijven, dus dat is het juiste antwoord op élke fout hier, en niet alleen + op deze. Een repo die zich niet meer laat bijwerken kan daarmee niet meer + bestaan. + +Dat repareert ook een cache die al stuk is: de eerstvolgende synchronisatie +haalt hem gewoon opnieuw op. + # v0.7.99-beta — "Gekoppeld" zei niets `--doctor` meldde vrolijk dat `/opt/serverup` in de container gekoppeld was, diff --git a/VERSION b/VERSION index 0c6793f..752fcfd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.99-beta +0.8.00-beta diff --git a/server-up/core/git.py b/server-up/core/git.py index 5a64241..8ef348e 100644 --- a/server-up/core/git.py +++ b/server-up/core/git.py @@ -1,5 +1,5 @@ """Git operations — clone, pull, scan for stacks and modules.""" -import json, os, re, subprocess, shutil +import json, os, re, subprocess, shutil, threading from pathlib import Path from core.docker import COMPOSE_NAMES from core import boilerplates as bp @@ -15,6 +15,18 @@ def guess_logo_url(name: str) -> str: CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git")) +# Eén git tegelijk per repo. Bij het opstarten wordt alles gesynchroniseerd, en +# ondertussen kan iemand op "Synchroniseren" drukken of een app installeren. +# Twee `git fetch` in dezelfde map leveren "fatal: shallow file has changed +# since we read it" op — en laten de cache halverwege achter. +_repo_sloten: dict[str, threading.Lock] = {} +_sloten_lock = threading.Lock() + + +def _slot(rid: str) -> threading.Lock: + with _sloten_lock: + return _repo_sloten.setdefault(rid, threading.Lock()) + # Fix git "dubious ownership" — container draait als root, cache kan door andere uid zijn _git_safe_set = False @@ -60,7 +72,9 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]: env["GIT_TERMINAL_PROMPT"] = "0" try: - if dest.exists() and (dest / ".git").exists(): + with _slot(rid): + vers_ophalen = not (dest.exists() and (dest / ".git").exists()) + if not vers_ophalen: if log_fn: log_fn(f"Bijwerken {rid} ({branch})…") # Geen `git pull --rebase`. Dat commando leunt op de @@ -84,12 +98,25 @@ def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]: r = subprocess.run(["git", "reset", "--hard", "FETCH_HEAD"], cwd=str(dest), capture_output=True, text=True, env=env, timeout=60) - else: + if r.returncode != 0: + # Wat er ook misging — een half afgebroken fetch die een kapot + # `.git/shallow` achterliet, een verdwenen tak, een lege map — + # de cache bevat niets dat bewaard hoeft te blijven. Opnieuw + # ophalen is daarom het antwoord op élke fout hier, in plaats + # van een repo die zich niet meer laat bijwerken en een app + # store die leeg blijft. + kort = (r.stderr or r.stdout or "").strip().splitlines() + if log_fn: + log_fn(f"Bijwerken mislukte ({kort[-1][:90] if kort else '?'});" + f" ik haal {rid} opnieuw op") + vers_ophalen = True + + if vers_ophalen: if log_fn: log_fn(f"Clone {url}…") dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): - shutil.rmtree(dest) + shutil.rmtree(dest, ignore_errors=True) r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1", clone_url, str(dest)], capture_output=True, text=True, env=env, timeout=120) diff --git a/tests/test_repo_urls.py b/tests/test_repo_urls.py index 5a3c928..5e58ee6 100644 --- a/tests/test_repo_urls.py +++ b/tests/test_repo_urls.py @@ -173,3 +173,73 @@ def test_lokale_rommel_in_de_cache_blokkeert_niets(env, tmp_path, monkeypatch): ok, msg = git.clone_or_pull(repo) assert ok, msg assert (git.cache_dir("proef") / "app.txt").read_text() == "van main" + + +def test_een_kapotte_cache_haalt_zichzelf_opnieuw_op(env, tmp_path, monkeypatch): + """"fatal: shallow file has changed since we read it" liet een repo achter + die zich niet meer liet bijwerken, en daarmee een lege app store. + + De cache bevat niets dat bewaard hoeft te blijven, dus opnieuw ophalen is + het antwoord op elke fout — niet alleen op deze.""" + import shutil as _shutil + from core import git + if not _shutil.which("git"): + pytest.skip("git niet aanwezig") + bron = _repo_met_twee_takken(tmp_path) + monkeypatch.setattr("core.valid_repo_url", lambda u: True) + repo = {"id": "proef", "url": f"file://{bron}", "branch": "main"} + assert git.clone_or_pull(repo)[0] + + cache = git.cache_dir("proef") + # Een `.git/shallow` met onzin erin: precies wat een halverwege afgebroken + # fetch achterlaat. + (cache / ".git" / "shallow").write_text("dit is geen commit\n", encoding="utf-8") + (cache / "app.txt").unlink() + + regels = [] + ok, msg = git.clone_or_pull(repo, log_fn=regels.append) + assert ok, f"de cache herstelt zichzelf niet: {msg}" + assert (cache / "app.txt").read_text() == "van main" + assert any("opnieuw op" in r for r in regels), regels + + +def test_twee_synchronisaties_tegelijk_botsen_niet(env, tmp_path, monkeypatch): + """Bij het opstarten wordt alles gesynchroniseerd; ondertussen kan iemand + op Synchroniseren drukken. Twee `git fetch` in dezelfde map leveren + "shallow file has changed" op en laten de cache halverwege achter.""" + import shutil as _shutil + import threading + from core import git + if not _shutil.which("git"): + pytest.skip("git niet aanwezig") + bron = _repo_met_twee_takken(tmp_path) + monkeypatch.setattr("core.valid_repo_url", lambda u: True) + repo = {"id": "proef", "url": f"file://{bron}", "branch": "main"} + assert git.clone_or_pull(repo)[0] + + tegelijk = [] + bezig = threading.Lock() + + echt = git.subprocess.run + + def merk_op(cmd, **kw): + if cmd[:2] == ["git", "fetch"]: + if not bezig.acquire(blocking=False): + tegelijk.append(cmd) + else: + try: + return echt(cmd, **kw) + finally: + bezig.release() + return echt(cmd, **kw) + + monkeypatch.setattr(git.subprocess, "run", merk_op) + draden = [threading.Thread(target=git.clone_or_pull, args=(dict(repo),)) + for _ in range(4)] + for d in draden: + d.start() + for d in draden: + d.join(timeout=60) + + assert not tegelijk, f"{len(tegelijk)} fetches liepen door elkaar heen" + assert (cache_na := (git.cache_dir("proef") / "app.txt")).read_text() == "van main", cache_na