server-up/tests/test_stackslot.py
Ramon d3ebf40165
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
feat: wachtwoordsync, slot per app, sneller dashboard en versietags
Vier verbeteringen die elk pas opvielen als je ze nodig had.

- Een gewijzigd databasewachtwoord wordt vóór de herstart omgezet in de nog
  draaiende database (ALTER USER / updateUser). Lukt dat niet, dan breekt de
  wijziging af en gaat de backup terug — 64 sjablonen hebben zo'n veld, en
  POSTGRES_PASSWORD werkt alleen bij een lege datamap. Wachtwoorden gaan via
  de omgeving en via stdin, niet via argumenten.
- jobs.met_slot serialiseert taken per app; de geplande backup slaat een
  bezette app over in plaats van een half herschreven map in te pakken. Een
  taak die klapt laat het slot los en wordt als mislukt afgesloten.
- api_stacks en api_connectable gebruiken één docker ps met het projectlabel
  in plaats van een compose ps per stackmap.
- Per service een versietag te kiezen, bewaard in .serverup.json en opnieuw
  toegepast na het renderen; het containerpaneel toont het draaiende image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 00:14:26 +02:00

140 lines
4.6 KiB
Python

"""Eén taak tegelijk per app.
Zonder vergrendeling kan de nachtelijke backup starten terwijl een
instellingswijziging net `compose down` heeft gedaan en de bestanden
herschrijft. Het archief bevat dan een halve stack, terwijl de melding erna
'Backup klaar' is.
"""
import sys
import threading
import time
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
def test_tweede_taak_wacht_op_de_eerste(env):
from core import jobs
volgorde = []
eerste_bezig = threading.Event()
laat_los = threading.Event()
def taak_een(jid):
volgorde.append("een-start")
eerste_bezig.set()
laat_los.wait(5)
volgorde.append("een-klaar")
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
def taak_twee(jid):
volgorde.append("twee-start")
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
jid1, _ = jobs.create("update:web")
jid2, _ = jobs.create("backup:web")
jobs.run(jobs.met_slot("web", taak_een), jid1)
assert eerste_bezig.wait(5)
jobs.run(jobs.met_slot("web", taak_twee), jid2)
time.sleep(0.2)
assert volgorde == ["een-start"], "de tweede taak begon terwijl de eerste liep"
laat_los.set()
einde = time.time() + 5
while time.time() < einde and "twee-start" not in volgorde:
time.sleep(0.02)
assert volgorde == ["een-start", "een-klaar", "twee-start"]
# En de wachtende taak meldt dat ook in zijn eigen log.
tekst = " ".join(r["text"] for r in jobs.stream(jid2)["lines"])
assert "wacht" in tekst
def test_andere_app_hoeft_niet_te_wachten(env):
"""Twee verschillende apps mogen wél tegelijk."""
from core import jobs
bezig = threading.Event()
los = threading.Event()
gedraaid = []
def blokkeer(jid):
bezig.set()
los.wait(5)
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
def snel(jid):
gedraaid.append(True)
jobs.finish(jid, "done")
jobs.done(jobs.get_queue(jid))
jid1, _ = jobs.create("update:web")
jid2, _ = jobs.create("update:andere")
jobs.run(jobs.met_slot("web", blokkeer), jid1)
assert bezig.wait(5)
jobs.run(jobs.met_slot("andere", snel), jid2)
einde = time.time() + 5
while time.time() < einde and not gedraaid:
time.sleep(0.02)
assert gedraaid, "een taak op een andere app werd onnodig geblokkeerd"
los.set()
def test_slot_komt_vrij_na_een_fout(env):
"""Een taak die klapt mag de app niet voor altijd op slot zetten, en mag
ook niet eeuwig op 'running' blijven staan met een interface die erop
blijft pollen."""
from core import jobs
def klapt(jid):
raise RuntimeError("boem")
jid, _ = jobs.create("update:web")
t = threading.Thread(target=jobs.met_slot("web", klapt), args=(jid,))
t.start(); t.join(5)
with jobs.stack_slot("web") as vrij:
assert vrij, "het slot bleef hangen na een mislukte taak"
d = jobs.stream(jid)
assert d["status"] == "error"
assert "boem" in " ".join(r["text"] for r in d["lines"])
def test_geplande_backup_slaat_een_bezette_app_over(env, monkeypatch):
"""Wachten heeft geen zin als er een lange update loopt; dan is overslaan
met een melding beter dan een archief van een halve stack."""
import app as app_module
from core import jobs
(env["lib"] / "web").mkdir(parents=True, exist_ok=True)
(env["lib"] / "web" / "docker-compose.yml").write_text(
"services:\n web:\n image: nginx\n")
env["core"].patch({"BACKUP_SCHEDULE": "daily"})
# Het tijdvenster zelf is elders getest; hier gaat het om de vergrendeling.
monkeypatch.setattr(app_module.scheduler, "binnen_venster",
lambda *a, **k: True)
gemaakt = []
monkeypatch.setattr(app_module.backups, "create",
lambda *a, **k: gemaakt.append(a) or {"file": "x.tar.gz"})
# Niet twee minuten wachten in een test; het gaat om de keuze die daarna
# gemaakt wordt. De originele functie eerst vastleggen, anders roept de
# vervanger zichzelf aan.
origineel = jobs.stack_slot
monkeypatch.setattr(app_module.jobs, "stack_slot",
lambda naam, wacht=0.0: origineel(naam))
slot = jobs._stackslot("web")
slot.acquire()
try:
app_module._geplande_backups()
finally:
slot.release()
assert gemaakt == [], "er werd een backup gemaakt van een bezette app"
# Zonder slot loopt hij gewoon.
app_module._geplande_backups()
assert gemaakt, "zonder bezetting hoort de backup gewoon te draaien"