feat: wachtwoordsync, slot per app, sneller dashboard en versietags
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
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
This commit is contained in:
parent
07d5effcec
commit
d3ebf40165
16 changed files with 1052 additions and 19 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,3 +1,29 @@
|
|||
# v0.8.27-beta — Wachtwoorden, wachtrijen, snelheid en versies
|
||||
|
||||
Vier verbeteringen die alle vier dezelfde vorm hebben: iets dat er goed uitzag
|
||||
totdat je het nodig had.
|
||||
|
||||
- **Een databasewachtwoord wijzigen breekt je app niet meer.**
|
||||
`POSTGRES_PASSWORD` en verwanten worden door de officiële images alleen bij
|
||||
een lege datamap gebruikt. Wie dat veld wijzigde kreeg een app met een nieuw
|
||||
wachtwoord en een database met het oude — 64 van de sjablonen hebben zo'n
|
||||
veld. Server Up zet het wachtwoord nu eerst om in de nog draaiende database
|
||||
(`ALTER USER`, `updateUser`) en breekt de wijziging af als dat niet lukt.
|
||||
Het oude en nieuwe wachtwoord gaan daarbij via de omgeving, niet via de
|
||||
argumenten: die zijn op de host zichtbaar in de procestabel.
|
||||
- **Eén taak tegelijk per app.** De nachtelijke backup kon starten midden in
|
||||
een instellingswijziging en zo een half herschreven map inpakken. Taken op
|
||||
dezelfde app wachten nu netjes op elkaar; de geplande backup slaat een
|
||||
bezette app over en meldt dat. Een taak die klapt laat het slot los en komt
|
||||
niet meer voor eeuwig op 'bezig' te staan.
|
||||
- **Het dashboard doet één docker-aanroep in plaats van één per app.** Bij
|
||||
vijftien apps waren dat vijftien processen per keer verversen, en nog eens
|
||||
zoveel bij elk openen van de installatiemodal.
|
||||
- **Versie per app te kiezen.** De meeste sjablonen staan op `latest`, waardoor
|
||||
Bijwerken je ongemerkt over een majorversie kan tillen. In de stap 'Extra's'
|
||||
zet je per onderdeel een versie vast; die overleeft elke volgende wijziging.
|
||||
In het containerpaneel staat welke versie er nu draait.
|
||||
|
||||
# v0.8.26-beta — Eigen mappen en apparaten per app
|
||||
|
||||
Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.26-beta
|
||||
0.8.27-beta
|
||||
|
|
|
|||
133
server-up/app.py
133
server-up/app.py
|
|
@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, send_file,
|
|||
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 backups, categories, compose_transform, dbwachtwoord
|
||||
from core import diskspace, networks
|
||||
from core import notify, paden, pangolin
|
||||
from core import scheduler, selfupdate
|
||||
from core import stackupdates
|
||||
|
|
@ -699,6 +700,10 @@ def api_stacks():
|
|||
except Exception:
|
||||
return ""
|
||||
|
||||
# Eén `docker ps` voor alle apps samen. Per stackmap een `docker compose ps`
|
||||
# starten betekende bij vijftien apps vijftien processen per keer verversen.
|
||||
per_project = docker.containers_per_project()
|
||||
|
||||
out = []
|
||||
for d in sorted(lib.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("."):
|
||||
|
|
@ -712,7 +717,7 @@ def api_stacks():
|
|||
# plaats van een status, geen knoppen — die door dit `continue` nooit te
|
||||
# zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen.
|
||||
heeft_compose = docker.has_compose(d)
|
||||
ct = docker.compose_ps(d, name=d.name) if heeft_compose else []
|
||||
ct = per_project.get(d.name, []) if heeft_compose else []
|
||||
logo_url, icon = _stack_logo(d)
|
||||
meta = _stack_meta(d)
|
||||
out.append({
|
||||
|
|
@ -880,7 +885,9 @@ def api_stack_action(name, action):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(name, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -1115,6 +1122,7 @@ def api_stack_config(name):
|
|||
app_networks=meta.get("app_networks") or [],
|
||||
app_services=meta.get("app_services") or [],
|
||||
extras=meta.get("extras") or {"volumes": [], "devices": []},
|
||||
image_tags=meta.get("image_tags") or {},
|
||||
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
|
||||
name=meta.get("name", name),
|
||||
# Waar dit sjabloon vandaan komt. Opslaan rendert de app
|
||||
|
|
@ -1181,6 +1189,9 @@ def api_stack_reconfigure(name):
|
|||
extras, extra_fout = _gekozen_extras(body, meta)
|
||||
if extra_fout:
|
||||
return jsonify(ok=False, msg=extra_fout), 400
|
||||
# Welke versietag per onderdeel. Zonder keuze blijft staan wat het sjabloon
|
||||
# zelf zegt; met een keuze overleeft die elke volgende wijziging.
|
||||
image_tags = _gekozen_image_tags(body, meta)
|
||||
|
||||
# Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
|
||||
# opleveren — en er ligt al een werkende versie die we niet moeten slopen.
|
||||
|
|
@ -1209,6 +1220,12 @@ def api_stack_reconfigure(name):
|
|||
jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}")
|
||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||
|
||||
# De databases zoals ze nú draaien, mét hun huidige wachtwoorden.
|
||||
# Na het renderen staan die nergens meer, en zonder het oude
|
||||
# wachtwoord valt een nieuw wachtwoord niet meer te zetten.
|
||||
db_voor = backups.diensten_uit_compose(
|
||||
docker.read_compose(d), backups._env_waarden(d))
|
||||
|
||||
boilerplates.render_to_dir(bron, d, nieuwe_waarden)
|
||||
jobs.log(qq, "ok", "Opnieuw gerenderd")
|
||||
|
||||
|
|
@ -1265,6 +1282,19 @@ def api_stack_reconfigure(name):
|
|||
+ ", ".join(f"'{n}'" for n in klaar)
|
||||
+ " (" + ", ".join(diensten_nu) + ")")
|
||||
|
||||
# De gekozen versietags terugzetten: het renderen haalt ze weg,
|
||||
# want het sjabloon schrijft zijn eigen image-regel.
|
||||
if cf and image_tags:
|
||||
try:
|
||||
nieuw_txt = compose_transform.zet_image_tags(
|
||||
cf.read_text(encoding="utf-8"), image_tags)
|
||||
if nieuw_txt != cf.read_text(encoding="utf-8"):
|
||||
cf.write_text(nieuw_txt, encoding="utf-8")
|
||||
for svc, tag in image_tags.items():
|
||||
jobs.log(qq, "ok", f"Versie vastgezet: {svc} → {tag}")
|
||||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Versietag niet toegepast: {e}")
|
||||
|
||||
# Eigen mappen en apparaten als laatste: die horen ook op een
|
||||
# service die net een eigen IP of netwerk kreeg.
|
||||
if cf and (extras.get("volumes") or extras.get("devices")):
|
||||
|
|
@ -1287,6 +1317,25 @@ def api_stack_reconfigure(name):
|
|||
backups.restore(name, backup_naam, log_fn=lf)
|
||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||
|
||||
# Een gewijzigd databasewachtwoord staat nu in het compose-bestand,
|
||||
# maar de database zelf weet daar niets van: die leest zijn
|
||||
# wachtwoordvariabele alleen bij een lege datamap. Omzetten moet dus
|
||||
# n\u00fa, terwijl de oude container nog draait met het oude wachtwoord.
|
||||
db_na = backups.diensten_uit_compose(
|
||||
docker.read_compose(d), backups._env_waarden(d))
|
||||
for dienst in dbwachtwoord.wijzigingen(db_voor, db_na):
|
||||
ok_ww, fout_ww = dbwachtwoord.omzetten(
|
||||
dienst, dienst["wachtwoord"],
|
||||
log_fn=lambda m: jobs.log(qq, "ok", m))
|
||||
if not ok_ww:
|
||||
jobs.log(qq, "error", fout_ww)
|
||||
jobs.log(qq, "error",
|
||||
"Zonder die wijziging kan de app straks niet meer "
|
||||
"bij zijn database. De wijziging is afgebroken.")
|
||||
jobs.log(qq, "dim", "Backup wordt teruggezet\u2026")
|
||||
backups.restore(name, backup_naam, log_fn=lf)
|
||||
jobs.finish(jid, "error"); jobs.done(qq); return
|
||||
|
||||
zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
|
||||
_stack_meta_schrijven(d, {
|
||||
"values": zichtbaar, "secret_fields": geheime_namen,
|
||||
|
|
@ -1295,6 +1344,7 @@ def api_stack_reconfigure(name):
|
|||
"app_networks": app_netten,
|
||||
"app_services": app_diensten,
|
||||
"extras": extras,
|
||||
"image_tags": image_tags,
|
||||
"service_ports": poorten_meta,
|
||||
"web": boilerplates.metadata(bron).get("web") or {},
|
||||
"connected": bool(app_netten)})
|
||||
|
|
@ -1318,7 +1368,9 @@ def api_stack_reconfigure(name):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(name, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -1382,7 +1434,9 @@ def api_stack_connect(name):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(name, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -1668,6 +1722,24 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]:
|
|||
return [standaard] if verbinden else []
|
||||
|
||||
|
||||
_TAG_TEKENS = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
def _gekozen_image_tags(body: dict, meta: dict | None = None) -> dict[str, str]:
|
||||
"""Per service de gekozen versietag.
|
||||
|
||||
Zonder keuze blijft staan wat het sjabloon zelf zegt. Tags worden getoetst
|
||||
aan wat Docker toestaat: ze belanden in het compose-bestand, dus een waarde
|
||||
met een spatie of een dubbele punt erin zou dat bestand stukmaken.
|
||||
"""
|
||||
ruw = body.get("image_tags")
|
||||
if not isinstance(ruw, dict):
|
||||
return {s: t for s, t in ((meta or {}).get("image_tags") or {}).items()
|
||||
if isinstance(t, str) and _TAG_TEKENS.match(t)}
|
||||
return {str(s): str(t).strip() for s, t in ruw.items()
|
||||
if str(t).strip() and _TAG_TEKENS.match(str(t).strip())}
|
||||
|
||||
|
||||
def _gekozen_extras(body: dict, meta: dict | None = None) -> tuple[dict, str]:
|
||||
"""Extra mappen en apparaten uit de request, gecontroleerd.
|
||||
|
||||
|
|
@ -1919,8 +1991,15 @@ def api_store_preview():
|
|||
doc = yaml.safe_load(compose_tekst) or {}
|
||||
diensten = [n for n in (doc.get("services") or {})]
|
||||
# Mét image en de vraag of het een database is: daarmee laat de
|
||||
# interface zien wat er wel en niet op het gedeelde netwerk komt.
|
||||
# interface zien wat er wel en niet op het gedeelde netwerk komt,
|
||||
# en welke versie er per onderdeel te kiezen valt.
|
||||
dienst_info = compose_transform.diensten(compose_tekst)
|
||||
per_image = {i["service"]: i
|
||||
for i in compose_transform.images_per_service(compose_tekst)}
|
||||
for info in dienst_info:
|
||||
bron = per_image.get(info["name"]) or {}
|
||||
info["repo"] = bron.get("repo", "")
|
||||
info["tag"] = bron.get("tag", "")
|
||||
except Exception:
|
||||
diensten = []
|
||||
return jsonify(
|
||||
|
|
@ -2045,6 +2124,7 @@ def api_store_install():
|
|||
extras, extra_fout = _gekozen_extras(d)
|
||||
if extra_fout:
|
||||
return jsonify(ok=False, msg=extra_fout), 400
|
||||
bp_image_tags = _gekozen_image_tags(d)
|
||||
verbinden = bool(app_netten)
|
||||
if not stack:
|
||||
return jsonify(ok=False, msg="stack vereist")
|
||||
|
|
@ -2319,6 +2399,18 @@ def api_store_install():
|
|||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Koppelen mislukt: {e}")
|
||||
|
||||
# Versietags uit het invulmenu (boilerplate-apps).
|
||||
if bp_image_tags:
|
||||
cf = docker.find_compose(dest)
|
||||
try:
|
||||
cf.write_text(compose_transform.zet_image_tags(
|
||||
cf.read_text(encoding="utf-8"), bp_image_tags),
|
||||
encoding="utf-8")
|
||||
for svc, tag in bp_image_tags.items():
|
||||
jobs.log(qq, "ok", f"Versie vastgezet: {svc} → {tag}")
|
||||
except compose_transform.TransformError as e:
|
||||
jobs.log(qq, "warn", f"Versietag niet toegepast: {e}")
|
||||
|
||||
# Eigen mappen en apparaten uit het invulmenu.
|
||||
if extras.get("volumes") or extras.get("devices"):
|
||||
cf = docker.find_compose(dest)
|
||||
|
|
@ -2373,6 +2465,7 @@ def api_store_install():
|
|||
"app_networks": app_netten,
|
||||
"app_services": app_diensten,
|
||||
"extras": extras,
|
||||
"image_tags": bp_image_tags,
|
||||
"service_ports": poorten_meta,
|
||||
"categories": meta.get("categories") or [],
|
||||
"tags": meta.get("tags") or [],
|
||||
|
|
@ -2404,7 +2497,9 @@ def api_store_install():
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(inst, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -2475,7 +2570,9 @@ def api_backup_create(stack):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(stack, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -2506,7 +2603,9 @@ def api_backup_restore(stack):
|
|||
jobs.finish(jid, "error")
|
||||
jobs.done(qq)
|
||||
|
||||
jobs.run(_run, jid)
|
||||
# Eén taak tegelijk per app: anders kan een geplande backup midden
|
||||
# in deze wijziging beginnen en een halve stack inpakken.
|
||||
jobs.run(jobs.met_slot(stack, _run), jid)
|
||||
return jsonify(ok=True, job_id=jid)
|
||||
|
||||
|
||||
|
|
@ -2931,6 +3030,9 @@ def api_connectable():
|
|||
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
|
||||
uit = []
|
||||
if lib.is_dir():
|
||||
# Eén docker-aanroep voor alle apps samen; dit scherm opent bij elke
|
||||
# installatie en deed er eerst één per stack.
|
||||
per_project = docker.containers_per_project()
|
||||
for d in sorted(lib.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||
continue
|
||||
|
|
@ -2943,7 +3045,7 @@ def api_connectable():
|
|||
meta = {}
|
||||
containers = [{"name": c["name"], "service": c.get("service", ""),
|
||||
"ports": c.get("ports", "")}
|
||||
for c in docker.compose_ps(d, name=d.name) if c.get("name")]
|
||||
for c in per_project.get(d.name, []) if c.get("name")]
|
||||
uit.append({
|
||||
"stack": d.name,
|
||||
"source": meta.get("source", ""), # uit welke app-template
|
||||
|
|
@ -4131,6 +4233,17 @@ def _geplande_backups() -> bool:
|
|||
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
|
||||
continue
|
||||
try:
|
||||
# Loopt er net een wijziging of update op deze app, dan is de
|
||||
# stackmap halverwege herschreven. Even wachten; blijft hij bezet,
|
||||
# dan slaan we hem over — een archief van een halve stack is erger
|
||||
# dan een backup die een nacht overslaat.
|
||||
with jobs.stack_slot(d.name, wacht=120.0) as vrij:
|
||||
if not vrij:
|
||||
print(f" [backup] {d.name} overgeslagen: er loopt een taak")
|
||||
audit.log("backups", "scheduled", "error", ref=d.name,
|
||||
detail="app was bezet")
|
||||
mislukt.append(f"{d.name}: er liep een andere taak")
|
||||
continue
|
||||
meta = backups.create(d.name, reden=f"gepland-{plan}")
|
||||
print(f" [backup] {meta['file']}")
|
||||
audit.log("backups", "scheduled", "ok", ref=d.name)
|
||||
|
|
|
|||
|
|
@ -195,13 +195,24 @@ def _vul_in(waarde: str, env: dict[str, str]) -> str:
|
|||
|
||||
def databasediensten(stack: str) -> list[dict]:
|
||||
"""De databasecontainers van deze stack, met wat er nodig is voor een dump."""
|
||||
import yaml
|
||||
d = _stack_dir(stack)
|
||||
try:
|
||||
doc = yaml.safe_load(docker.read_compose(d)) or {}
|
||||
return diensten_uit_compose(docker.read_compose(d), _env_waarden(d))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def diensten_uit_compose(compose_text: str, env: dict[str, str]) -> list[dict]:
|
||||
"""Dezelfde lijst, maar uit losse tekst.
|
||||
|
||||
Bij het wijzigen van instellingen moet de situatie vóór het renderen naast
|
||||
die erna gelegd worden — en dan bestaan de oude bestanden niet meer.
|
||||
"""
|
||||
import yaml
|
||||
try:
|
||||
doc = yaml.safe_load(compose_text) or {}
|
||||
except Exception:
|
||||
return []
|
||||
env = _env_waarden(d)
|
||||
uit = []
|
||||
for snaam, svc in (doc.get("services") or {}).items():
|
||||
if not isinstance(svc, dict):
|
||||
|
|
|
|||
|
|
@ -539,3 +539,68 @@ def add_extras(compose_text: str, extras: dict) -> str:
|
|||
|
||||
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||
allow_unicode=True)
|
||||
|
||||
|
||||
# ── Versietags ───────────────────────────────────────────────────────────────
|
||||
# De meeste sjablonen gebruiken `:latest`. Eén druk op Bijwerken kan je dan over
|
||||
# een majorversie tillen — met een databasemigratie die je niet terugdraait.
|
||||
# Deze twee maken het mogelijk om per app een versie te kiezen en vast te
|
||||
# houden, ook nadat de app opnieuw uit zijn sjabloon gerenderd is.
|
||||
|
||||
def images_per_service(compose_text: str) -> list[dict]:
|
||||
"""Per service het image, opgesplitst in repo en tag."""
|
||||
try:
|
||||
doc = yaml.safe_load(compose_text) or {}
|
||||
except yaml.YAMLError:
|
||||
return []
|
||||
uit = []
|
||||
for naam, svc in (doc.get("services") or {}).items():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
image = str(svc.get("image") or "")
|
||||
if not image or "${" in image:
|
||||
continue # variabele: daar gaan we niet over
|
||||
# Een dubbele punt kan ook een poort in de registryhost zijn
|
||||
# (registry.local:5000/app), dus alleen ná de laatste schuine streep.
|
||||
laatste = image.rsplit("/", 1)[-1]
|
||||
if ":" in laatste:
|
||||
repo, tag = image.rsplit(":", 1)
|
||||
else:
|
||||
repo, tag = image, "latest"
|
||||
uit.append({"service": naam, "image": image, "repo": repo, "tag": tag})
|
||||
return uit
|
||||
|
||||
|
||||
def zet_image_tags(compose_text: str, tags: dict[str, str]) -> str:
|
||||
"""Zet per service een andere versietag op het image.
|
||||
|
||||
Een lege of onveranderde tag laat de regel met rust; een service die niet
|
||||
bestaat wordt overgeslagen, zodat een hernoemd sjabloon geen kapot
|
||||
compose-bestand oplevert.
|
||||
"""
|
||||
gewenst = {s: str(t).strip() for s, t in (tags or {}).items() if str(t).strip()}
|
||||
if not gewenst:
|
||||
return compose_text
|
||||
doc = _laad(compose_text)
|
||||
diensten = doc.get("services")
|
||||
if not isinstance(diensten, dict):
|
||||
raise TransformError("compose bevat geen services")
|
||||
|
||||
veranderd = False
|
||||
for naam, tag in gewenst.items():
|
||||
svc = diensten.get(naam)
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
image = str(svc.get("image") or "")
|
||||
if not image or "${" in image:
|
||||
continue
|
||||
laatste = image.rsplit("/", 1)[-1]
|
||||
repo = image.rsplit(":", 1)[0] if ":" in laatste else image
|
||||
nieuw = f"{repo}:{tag}"
|
||||
if nieuw != image:
|
||||
svc["image"] = nieuw
|
||||
veranderd = True
|
||||
if not veranderd:
|
||||
return compose_text
|
||||
return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
|
||||
allow_unicode=True, width=120)
|
||||
|
|
|
|||
130
server-up/core/dbwachtwoord.py
Normal file
130
server-up/core/dbwachtwoord.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Een gewijzigd databasewachtwoord ook echt in de database zetten.
|
||||
|
||||
`POSTGRES_PASSWORD` en zijn verwanten worden door de officiële images alleen
|
||||
gebruikt bij een lege datamap. Wijzig je zo'n veld in de instellingen van een
|
||||
app, dan krijgt de applicatie het nieuwe wachtwoord en de database niet — en na
|
||||
de herstart kan de app niet meer inloggen. Er staat dan een stack stil met een
|
||||
foutmelding die nergens naar het wachtwoord verwijst.
|
||||
|
||||
Daarom draait Server Up het wachtwoord vóór de herstart om in de nog draaiende
|
||||
database, met de oude inloggegevens. Lukt dat niet, dan hoort de wijziging af te
|
||||
breken: een app die niet meer bij zijn gegevens kan is erger dan een instelling
|
||||
die niet doorgevoerd is.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from core import docker
|
||||
|
||||
|
||||
class WachtwoordFout(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def wijzigingen(oud: list[dict], nieuw: list[dict]) -> list[dict]:
|
||||
"""Welke databases krijgen een ander wachtwoord?
|
||||
|
||||
Vergelijkt per service. Een service die verdwijnt of bijkomt telt niet mee:
|
||||
daar valt niets om te zetten.
|
||||
"""
|
||||
per_naam = {d["service"]: d for d in oud}
|
||||
uit = []
|
||||
for d in nieuw:
|
||||
vorige = per_naam.get(d["service"])
|
||||
if not vorige or not vorige.get("wachtwoord") or not d.get("wachtwoord"):
|
||||
continue
|
||||
if vorige["wachtwoord"] != d["wachtwoord"]:
|
||||
uit.append({**d, "oud_wachtwoord": vorige["wachtwoord"]})
|
||||
return uit
|
||||
|
||||
|
||||
def _pg_letterlijk(waarde: str) -> str:
|
||||
"""Een tekenreeks voor PostgreSQL.
|
||||
|
||||
Met `standard_conforming_strings` (standaard sinds 9.1) is een backslash
|
||||
daar een gewoon teken; die verdubbelen zou het wachtwoord juist veranderen.
|
||||
"""
|
||||
return waarde.replace("'", "''")
|
||||
|
||||
|
||||
def _mysql_letterlijk(waarde: str) -> str:
|
||||
"""Een tekenreeks voor MySQL/MariaDB, waar de backslash wél escapet."""
|
||||
return waarde.replace("\\", "\\\\").replace("'", "''")
|
||||
|
||||
|
||||
def _js_letterlijk(waarde: str) -> str:
|
||||
return waarde.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def commando(dienst: dict, nieuw: str) -> tuple[list[str], dict]:
|
||||
"""Het commando binnen de container, plus de omgeving ervoor.
|
||||
|
||||
Het oude wachtwoord gaat via de omgeving mee en niet via de argumenten:
|
||||
argumenten zijn op de host zichtbaar in de procestabel.
|
||||
"""
|
||||
soort = dienst["soort"]
|
||||
gebruiker = dienst.get("gebruiker") or ""
|
||||
oud = dienst["oud_wachtwoord"]
|
||||
|
||||
if soort in ("postgres", "postgis"):
|
||||
gb = gebruiker or "postgres"
|
||||
# Het statement gaat via stdin naar binnen: dan staat het nieuwe
|
||||
# wachtwoord niet in de procestabel van de host, en hoeven we niet te
|
||||
# vertrouwen op variabele-interpolatie die `psql -c` niet altijd doet.
|
||||
return (["sh", "-c",
|
||||
f'printf "%s" "$SU_SQL" | psql -v ON_ERROR_STOP=1 -U {gb} '
|
||||
f'-d {dienst.get("database") or gb}'],
|
||||
{"PGPASSWORD": oud,
|
||||
"SU_SQL": f"ALTER USER {gb} WITH PASSWORD "
|
||||
f"'{_pg_letterlijk(nieuw)}';"})
|
||||
|
||||
if soort in ("mariadb", "mysql"):
|
||||
client = "mariadb" if soort == "mariadb" else "mysql"
|
||||
gb = gebruiker or "root"
|
||||
# USER() is de gebruiker waarmee we net zijn ingelogd; zo hoeven we geen
|
||||
# rootrechten te veronderstellen die dit sjabloon misschien niet geeft.
|
||||
# Het statement gaat via stdin naar binnen in plaats van via -e, zodat
|
||||
# het nieuwe wachtwoord niet in de procestabel van de host verschijnt.
|
||||
return (["sh", "-c",
|
||||
f'printf "%s" "$SU_SQL" | {client} -u {gb} -p"$SU_OUD"'],
|
||||
{"SU_OUD": oud,
|
||||
"SU_SQL": f"ALTER USER USER() IDENTIFIED BY "
|
||||
f"'{_mysql_letterlijk(nieuw)}'; FLUSH PRIVILEGES;"})
|
||||
|
||||
if soort == "mongo":
|
||||
gb = gebruiker or "root"
|
||||
js = (f'db.getSiblingDB("admin").auth("{_js_letterlijk(gb)}", "{_js_letterlijk(oud)}");'
|
||||
f'db.getSiblingDB("admin").updateUser("{_js_letterlijk(gb)}",'
|
||||
f' {{pwd: "{_js_letterlijk(nieuw)}"}});')
|
||||
# Ook hier via stdin: de mongo-shell leest een script net zo goed van
|
||||
# de standaardinvoer als uit --eval.
|
||||
return (["sh", "-c",
|
||||
"if which mongosh > /dev/null 2>&1; then bin=mongosh; else bin=mongo; fi; "
|
||||
'printf "%s" "$SU_JS" | "$bin" --quiet'],
|
||||
{"SU_JS": js})
|
||||
|
||||
raise WachtwoordFout(f"Onbekend databasesoort: {soort}")
|
||||
|
||||
|
||||
def omzetten(dienst: dict, nieuw: str, log_fn=None) -> tuple[bool, str]:
|
||||
"""Zet het wachtwoord om in de draaiende container."""
|
||||
naam = dienst["container"]
|
||||
if not docker.container_draait(naam):
|
||||
return False, (f"{naam} draait niet, dus het wachtwoord kan daar niet "
|
||||
"gewijzigd worden.")
|
||||
cmd, extra_env = commando(dienst, nieuw)
|
||||
volledig = [docker._bin(), "exec"]
|
||||
for k, v in extra_env.items():
|
||||
volledig += ["-e", f"{k}={v}"]
|
||||
volledig += [naam] + cmd
|
||||
r = docker._run(volledig, capture_output=True, timeout=60)
|
||||
if r.returncode != 0:
|
||||
fout = (r.stderr or r.stdout or "").strip().splitlines()
|
||||
# Het wachtwoord zelf mag niet in het joblog belanden.
|
||||
laatste = (fout[-1] if fout else "onbekende fout")[:200]
|
||||
for geheim in (dienst["oud_wachtwoord"], nieuw):
|
||||
if geheim:
|
||||
laatste = laatste.replace(geheim, "***")
|
||||
return False, f"Wachtwoord van {naam} niet gewijzigd: {laatste}"
|
||||
if log_fn:
|
||||
log_fn(f"Wachtwoord van {naam} omgezet in de database zelf")
|
||||
return True, ""
|
||||
|
|
@ -147,7 +147,8 @@ def list_containers(alle: bool = True) -> list[dict]:
|
|||
"""
|
||||
fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}"
|
||||
"\t{{.Label \"com.docker.compose.project\"}}"
|
||||
"\t{{.Label \"com.docker.compose.project.working_dir\"}}")
|
||||
"\t{{.Label \"com.docker.compose.project.working_dir\"}}"
|
||||
"\t{{.Label \"com.docker.compose.service\"}}")
|
||||
cmd = [_bin(), "ps", "--format", fmt]
|
||||
if alle:
|
||||
cmd.insert(2, "-a")
|
||||
|
|
@ -165,16 +166,31 @@ def list_containers(alle: bool = True) -> list[dict]:
|
|||
d = regel.split("\t")
|
||||
if len(d) < 4:
|
||||
continue
|
||||
d += [""] * (8 - len(d))
|
||||
d += [""] * (9 - len(d))
|
||||
uit.append({
|
||||
"id": d[0], "name": d[1], "image": d[2],
|
||||
"state": d[3], "status": d[4], "ports": d[5],
|
||||
"project": d[6], "working_dir": d[7],
|
||||
"project": d[6], "working_dir": d[7], "service": d[8],
|
||||
"running": d[3].lower() in ("running", "up"),
|
||||
})
|
||||
return sorted(uit, key=lambda c: c["name"])
|
||||
|
||||
|
||||
def containers_per_project(alle: bool = True) -> dict[str, list[dict]]:
|
||||
"""Alle containers, gegroepeerd op compose-project — in één aanroep.
|
||||
|
||||
De stacklijst deed hiervoor een `docker compose ps` per stackmap: bij
|
||||
vijftien apps vijftien processen per keer verversen, en nog eens zoveel elk
|
||||
kwartier vanuit de achtergrondcontrole. Eén `docker ps` levert dezelfde
|
||||
gegevens, want compose zet het projectlabel op elke container die het maakt.
|
||||
"""
|
||||
uit: dict[str, list[dict]] = {}
|
||||
for c in list_containers(alle):
|
||||
if c.get("project"):
|
||||
uit.setdefault(c["project"], []).append(c)
|
||||
return uit
|
||||
|
||||
|
||||
def container_action(name: str, action: str) -> tuple[bool, str]:
|
||||
"""Start, stop of herstart één container.
|
||||
|
||||
|
|
@ -267,6 +283,9 @@ def compose_ps(d: Path, name=None) -> list[dict]:
|
|||
out.append({
|
||||
"name": c.get("Name", ""),
|
||||
"service": c.get("Service", ""),
|
||||
# Welke versie er nu daadwerkelijk draait; bij een sjabloon dat
|
||||
# `latest` gebruikt is dit het enige wat dat nog verraadt.
|
||||
"image": c.get("Image", ""),
|
||||
"state": c.get("State", ""),
|
||||
"status": c.get("Status", ""),
|
||||
"ports": _published_ports(c),
|
||||
|
|
@ -397,6 +416,18 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
|
|||
pass
|
||||
|
||||
|
||||
def container_draait(naam: str) -> bool:
|
||||
"""Draait deze container op dit moment?
|
||||
|
||||
Nodig voordat er iets in een container uitgevoerd wordt: `docker exec` op
|
||||
een gestopte container geeft een foutmelding die niets uitlegt.
|
||||
"""
|
||||
if not naam:
|
||||
return False
|
||||
r = _run([_bin(), "inspect", "-f", "{{.State.Running}}", naam], timeout=15)
|
||||
return r.returncode == 0 and r.stdout.strip() == "true"
|
||||
|
||||
|
||||
def image_digest_local(image: str) -> str:
|
||||
"""Digest van het lokaal aanwezige image, of "" als het er niet is.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""Background job runner with log streaming."""
|
||||
import json, os, threading, time, uuid, queue
|
||||
import contextlib, json, os, threading, time, uuid, queue
|
||||
from pathlib import Path
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
|
|
@ -193,3 +193,65 @@ def stream(jid: str, offset=0) -> dict:
|
|||
|
||||
def run(fn, *args):
|
||||
threading.Thread(target=fn, args=args, daemon=True).start()
|
||||
|
||||
|
||||
# ── Eén taak tegelijk per app ────────────────────────────────────────────────
|
||||
# Zonder dit 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. Twee keer op
|
||||
# Bijwerken klikken levert hetzelfde op.
|
||||
|
||||
_stack_sloten: dict[str, threading.Lock] = {}
|
||||
_sloten_lock = threading.Lock()
|
||||
|
||||
|
||||
def _stackslot(naam: str) -> threading.Lock:
|
||||
with _sloten_lock:
|
||||
return _stack_sloten.setdefault(naam, threading.Lock())
|
||||
|
||||
|
||||
def met_slot(naam: str, fn, wacht: float = 900.0):
|
||||
"""Laat `fn` pas draaien als er geen andere taak op deze app bezig is.
|
||||
|
||||
Wikkelt de jobfunctie in plaats van hem te veranderen, zodat elke bestaande
|
||||
taak dit met één regel krijgt.
|
||||
"""
|
||||
def _wikkel(jid, *args):
|
||||
qq = get_queue(jid)
|
||||
slot = _stackslot(naam)
|
||||
if not slot.acquire(blocking=False):
|
||||
log(qq, "warn", f"Er loopt al een taak voor '{naam}' — "
|
||||
"deze wacht tot die klaar is.")
|
||||
if not slot.acquire(timeout=wacht):
|
||||
log(qq, "error", f"Er liep te lang een andere taak voor '{naam}'. "
|
||||
"Deze taak is niet uitgevoerd.")
|
||||
finish(jid, "error")
|
||||
done(qq)
|
||||
return
|
||||
try:
|
||||
fn(jid, *args)
|
||||
except Exception as e:
|
||||
# Een taak die klapt zonder eigen afhandeling bleef anders voor
|
||||
# altijd op 'running' staan, en de interface bleef erop pollen.
|
||||
log(qq, "error", f"{type(e).__name__}: {e}")
|
||||
finish(jid, "error")
|
||||
done(qq)
|
||||
finally:
|
||||
slot.release()
|
||||
return _wikkel
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def stack_slot(naam: str, wacht: float = 0.0):
|
||||
"""Hetzelfde slot, voor code die geen job is (de geplande backup).
|
||||
|
||||
Levert False op als de app bezet is; de aanroeper beslist dan zelf of hij
|
||||
overslaat of wacht.
|
||||
"""
|
||||
slot = _stackslot(naam)
|
||||
gekregen = slot.acquire(timeout=wacht) if wacht else slot.acquire(blocking=False)
|
||||
try:
|
||||
yield gekregen
|
||||
finally:
|
||||
if gekregen:
|
||||
slot.release()
|
||||
|
|
|
|||
|
|
@ -381,6 +381,9 @@ tailwind.config = {
|
|||
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span>
|
||||
<span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
|
||||
</div>
|
||||
<!-- Welke versie hier nu draait. Met `latest` in het
|
||||
sjabloon is dat het enige dat het je nog vertelt. -->
|
||||
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.image" x-text="c.image"></div>
|
||||
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -2018,6 +2021,24 @@ tailwind.config = {
|
|||
overschreven. -->
|
||||
<template x-if="s.id === 'extras'">
|
||||
<div class="space-y-4">
|
||||
<!-- Versie per onderdeel. De meeste sjablonen staan op
|
||||
`latest`, en dan kan Bijwerken je ongemerkt over een
|
||||
majorversie tillen. -->
|
||||
<div x-show="(install.service_info||[]).length">
|
||||
<div class="form-label" x-text="t('image_versions')">Versies</div>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('image_versions_hint')"></p>
|
||||
<template x-for="svc in (install.service_info||[])" :key="'t'+svc.name">
|
||||
<div class="flex items-center gap-2 mb-1.5" x-show="svc.repo">
|
||||
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate"
|
||||
:title="svc.image" x-text="svc.name + ': ' + svc.repo"></span>
|
||||
<input class="form-input !h-9 !text-xs font-mono !w-40"
|
||||
:placeholder="svc.tag"
|
||||
:value="(install.image_tags||{})[svc.name] || ''"
|
||||
@input="install.image_tags[svc.name] = $event.target.value.trim()">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="form-label" x-text="t('extra_volumes')">Mappen van deze host</div>
|
||||
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('extra_volumes_hint')"></p>
|
||||
|
|
@ -3991,6 +4012,7 @@ function app() {
|
|||
network: '', ip: '', ips: {}, services: [], service_info: [],
|
||||
app_services: [], depends_on: [],
|
||||
extras: { volumes: [], devices: [] },
|
||||
image_tags: {},
|
||||
compatibility: { known: false, supported: true, architecture: '', reason: '' },
|
||||
architecture_note: ''
|
||||
};
|
||||
|
|
@ -4248,6 +4270,7 @@ function app() {
|
|||
source_repo: d.source_repo || {},
|
||||
extras: { volumes: (d.extras||{}).volumes || [],
|
||||
devices: (d.extras||{}).devices || [] },
|
||||
image_tags: { ...(d.image_tags || {}) },
|
||||
};
|
||||
await this.loadAppNets(stack);
|
||||
this.fieldInfo = null;
|
||||
|
|
@ -4271,6 +4294,7 @@ function app() {
|
|||
app_networks: this.install.app_networks || [],
|
||||
app_services: this.install.app_services || [],
|
||||
extras: this.install.extras || {},
|
||||
image_tags: this.install.image_tags || {},
|
||||
});
|
||||
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||||
this.modal = null;
|
||||
|
|
@ -4306,6 +4330,10 @@ function app() {
|
|||
body.app_networks = this.install.app_networks || [];
|
||||
body.app_services = this.install.app_services || [];
|
||||
body.extras = this.install.extras || {};
|
||||
// Boilerplate-apps sturen hun versiekeuze hier mee; het klassieke
|
||||
// formaat gebruikt hiervoor het imageTags-blok verderop.
|
||||
if (this.install.format === 'boilerplate')
|
||||
body.image_tags = this.install.image_tags || {};
|
||||
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
||||
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
|
||||
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@
|
|||
"device_stable": "stable",
|
||||
"no_devices": "No devices found on this host. You can type the path yourself, for example /dev/ttyUSB0.",
|
||||
"path_unreachable": "Server Up cannot reach this path; check that the folder is mounted.",
|
||||
"image_versions": "Versions",
|
||||
"image_versions_hint": "Leave empty to follow the template (usually 'latest'). Enter a version to pin it, so updating cannot silently carry you across a major release.",
|
||||
"add_repo": "Add repository",
|
||||
"repo_name": "Name",
|
||||
"repo_url": "Git URL",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@
|
|||
"device_stable": "vast",
|
||||
"no_devices": "Geen apparaten gevonden op deze host. Je kunt het pad zelf intikken, bijvoorbeeld /dev/ttyUSB0.",
|
||||
"path_unreachable": "Server Up kan hier niet bij; controleer of deze map gemount is.",
|
||||
"image_versions": "Versies",
|
||||
"image_versions_hint": "Leeg laten volgt het sjabloon (meestal 'latest'). Vul een versie in om die vast te houden, zodat Bijwerken je niet ongemerkt over een grote versiesprong tilt.",
|
||||
"add_repo": "Repository toevoegen",
|
||||
"repo_name": "Naam",
|
||||
"repo_url": "Git URL",
|
||||
|
|
|
|||
|
|
@ -252,3 +252,43 @@ def test_geweigerd_pad_belandt_niet_in_compose():
|
|||
def test_zonder_extras_verandert_er_niets():
|
||||
assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
|
||||
assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == EXTRA_BASIS
|
||||
|
||||
|
||||
# ── Versietags ───────────────────────────────────────────────────────────────
|
||||
|
||||
TAG_BASIS = """\
|
||||
services:
|
||||
app:
|
||||
image: lscr.io/linuxserver/unifi-network-application:latest
|
||||
db:
|
||||
image: registry.local:5000/mongo
|
||||
var:
|
||||
image: ${IMAGE}
|
||||
"""
|
||||
|
||||
|
||||
def test_images_per_service_splitst_repo_en_tag():
|
||||
per_naam = {i["service"]: i for i in ct.images_per_service(TAG_BASIS)}
|
||||
assert per_naam["app"]["repo"] == "lscr.io/linuxserver/unifi-network-application"
|
||||
assert per_naam["app"]["tag"] == "latest"
|
||||
# De dubbele punt in registry.local:5000 is een poort, geen tag.
|
||||
assert per_naam["db"]["repo"] == "registry.local:5000/mongo"
|
||||
assert per_naam["db"]["tag"] == "latest"
|
||||
# Een image uit een variabele gaat over de .env, niet over dit scherm.
|
||||
assert "var" not in per_naam
|
||||
|
||||
|
||||
def test_versietag_zetten():
|
||||
uit = ct.zet_image_tags(TAG_BASIS, {"app": "10.4.57", "db": "7.0"})
|
||||
doc = yaml.safe_load(uit)
|
||||
assert doc["services"]["app"]["image"].endswith(":10.4.57")
|
||||
assert doc["services"]["db"]["image"] == "registry.local:5000/mongo:7.0"
|
||||
assert doc["services"]["var"]["image"] == "${IMAGE}"
|
||||
|
||||
|
||||
def test_lege_of_onbekende_tag_verandert_niets():
|
||||
assert ct.zet_image_tags(TAG_BASIS, {}) == TAG_BASIS
|
||||
assert ct.zet_image_tags(TAG_BASIS, {"app": " "}) == TAG_BASIS
|
||||
assert ct.zet_image_tags(TAG_BASIS, {"bestaatniet": "1"}) == TAG_BASIS
|
||||
# Dezelfde tag nog eens zetten laat het bestand ongemoeid.
|
||||
assert ct.zet_image_tags(TAG_BASIS, {"app": "latest"}) == TAG_BASIS
|
||||
|
|
|
|||
|
|
@ -257,3 +257,54 @@ def test_compose_ps_toont_alleen_werkelijk_gepubliceerde_poorten(host):
|
|||
def test_compose_ps_valt_bij_oude_compose_terug_op_ports(host):
|
||||
assert host._published_ports(
|
||||
{"Ports": "0.0.0.0:8080->80/tcp"}) == "0.0.0.0:8080->80/tcp"
|
||||
|
||||
|
||||
# ── Eén docker-aanroep voor de hele lijst ────────────────────────────────────
|
||||
|
||||
def test_stacklijst_doet_geen_aanroep_per_app(client, env, monkeypatch):
|
||||
"""De lijst deed een `docker compose ps` per stackmap: bij vijftien apps
|
||||
vijftien processen per keer verversen, en nog eens zoveel bij elk openen
|
||||
van de installatiemodal."""
|
||||
from core import docker
|
||||
for naam in ("een", "twee", "drie"):
|
||||
d = env["lib"] / naam
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "docker-compose.yml").write_text(f"services:\n {naam}:\n image: nginx\n")
|
||||
|
||||
aanroepen = {"ps": 0}
|
||||
|
||||
def _lijst(alle=True):
|
||||
aanroepen["ps"] += 1
|
||||
return [
|
||||
{"id": "a", "name": "een-app-1", "image": "nginx", "state": "running",
|
||||
"status": "Up", "ports": "0.0.0.0:8080->80/tcp", "project": "een",
|
||||
"working_dir": str(env["lib"] / "een"), "service": "app",
|
||||
"running": True},
|
||||
{"id": "b", "name": "twee-app-1", "image": "nginx", "state": "exited",
|
||||
"status": "Exited (0)", "ports": "", "project": "twee",
|
||||
"working_dir": str(env["lib"] / "twee"), "service": "app",
|
||||
"running": False},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(docker, "list_containers", _lijst)
|
||||
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: pytest.fail(
|
||||
"er werd alsnog een aanroep per app gedaan"))
|
||||
login(client)
|
||||
|
||||
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
|
||||
assert aanroepen["ps"] == 1, "meer dan één docker-aanroep voor de hele lijst"
|
||||
assert per_naam["een"]["running"] is True
|
||||
assert per_naam["een"]["containers"][0]["service"] == "app"
|
||||
assert per_naam["twee"]["running"] is False
|
||||
# Een app zonder containers blijft gewoon in de lijst staan.
|
||||
assert per_naam["drie"]["running"] is False
|
||||
assert per_naam["drie"]["containers"] == []
|
||||
|
||||
|
||||
def test_containers_worden_op_project_gegroepeerd(host):
|
||||
per_project = host.containers_per_project()
|
||||
assert set(per_project) == {"vaultwarden", "forgejo", "server-up"}
|
||||
assert per_project["vaultwarden"][0]["name"] == "vaultwarden"
|
||||
# Containers zonder compose-label horen bij geen enkele app.
|
||||
assert all("act_runner" not in [c["name"] for c in v]
|
||||
for v in per_project.values())
|
||||
|
|
|
|||
285
tests/test_dbwachtwoord.py
Normal file
285
tests/test_dbwachtwoord.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""Een gewijzigd databasewachtwoord moet ook in de database terechtkomen.
|
||||
|
||||
`POSTGRES_PASSWORD` en verwanten worden door de officiële images alleen bij een
|
||||
lege datamap gebruikt. Wie dat veld in de instellingen wijzigde, kreeg een app
|
||||
met een nieuw wachtwoord en een database met het oude — en dus een stack die
|
||||
niet meer kon inloggen, met een foutmelding die nergens naar het wachtwoord
|
||||
verwees.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
WORTEL = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(WORTEL / "server-up"))
|
||||
|
||||
from conftest import login
|
||||
|
||||
COMPOSE = """\
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
environment:
|
||||
- DB_PASS=${DB_PASSWORD}
|
||||
web-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: web-db
|
||||
environment:
|
||||
- POSTGRES_USER=app
|
||||
- POSTGRES_DB=app
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
"""
|
||||
|
||||
|
||||
def _diensten(env_waarden):
|
||||
from core import backups
|
||||
return backups.diensten_uit_compose(COMPOSE, env_waarden)
|
||||
|
||||
|
||||
# ── Herkennen ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_gewijzigd_wachtwoord_wordt_herkend(env):
|
||||
from core import dbwachtwoord
|
||||
voor = _diensten({"DB_PASSWORD": "oud"})
|
||||
na = _diensten({"DB_PASSWORD": "nieuw"})
|
||||
veranderd = dbwachtwoord.wijzigingen(voor, na)
|
||||
assert len(veranderd) == 1
|
||||
assert veranderd[0]["service"] == "web-db"
|
||||
assert veranderd[0]["oud_wachtwoord"] == "oud"
|
||||
assert veranderd[0]["wachtwoord"] == "nieuw"
|
||||
|
||||
|
||||
def test_gelijk_wachtwoord_levert_geen_werk_op(env):
|
||||
from core import dbwachtwoord
|
||||
zelfde = _diensten({"DB_PASSWORD": "geheim"})
|
||||
assert dbwachtwoord.wijzigingen(zelfde, zelfde) == []
|
||||
|
||||
|
||||
def test_nieuwe_database_telt_niet_mee(env):
|
||||
"""Een dienst die er nog niet was heeft geen oud wachtwoord om mee in te
|
||||
loggen; die krijgt zijn wachtwoord gewoon bij de eerste start."""
|
||||
from core import dbwachtwoord
|
||||
assert dbwachtwoord.wijzigingen([], _diensten({"DB_PASSWORD": "x"})) == []
|
||||
|
||||
|
||||
# ── De commando's ────────────────────────────────────────────────────────────
|
||||
|
||||
def _cmd(soort, oud="oud", nieuw="nieuw", gebruiker="app"):
|
||||
from core import dbwachtwoord
|
||||
dienst = {"soort": soort, "gebruiker": gebruiker, "database": "app",
|
||||
"container": f"web-{soort}", "wachtwoord": nieuw,
|
||||
"oud_wachtwoord": oud}
|
||||
return dbwachtwoord.commando(dienst, nieuw)
|
||||
|
||||
|
||||
def test_postgres_zet_het_wachtwoord_om():
|
||||
cmd, omgeving = _cmd("postgres", oud="Ou6-oudgeheim", nieuw="Zg3-nieuwgeheim")
|
||||
assert "ALTER USER app WITH PASSWORD" in omgeving["SU_SQL"]
|
||||
assert omgeving["PGPASSWORD"] == "Ou6-oudgeheim"
|
||||
assert "| psql -v ON_ERROR_STOP=1 -U app -d app" in cmd[-1]
|
||||
# Geen van beide wachtwoorden staat in het commando zelf: argumenten zijn
|
||||
# op de host zichtbaar in de procestabel.
|
||||
assert "Zg3-nieuwgeheim" not in cmd[-1]
|
||||
assert "Ou6-oudgeheim" not in cmd[-1]
|
||||
|
||||
|
||||
def test_postgres_laat_een_backslash_met_rust():
|
||||
"""Postgres neemt een backslash letterlijk; verdubbelen zou het wachtwoord
|
||||
veranderen en de app alsnog buitensluiten."""
|
||||
_, omgeving = _cmd("postgres", nieuw="pas\\woord")
|
||||
assert omgeving["SU_SQL"] == "ALTER USER app WITH PASSWORD 'pas\\woord';"
|
||||
_, mysql_omgeving = _cmd("mysql", nieuw="pas\\woord")
|
||||
assert "pas\\\\woord" in mysql_omgeving["SU_SQL"]
|
||||
|
||||
|
||||
def test_mysql_en_mariadb():
|
||||
for soort, client in (("mysql", "mysql"), ("mariadb", "mariadb")):
|
||||
cmd, omgeving = _cmd(soort, oud="Ou6-oudgeheim", nieuw="Zg3-nieuwgeheim")
|
||||
assert f"| {client} -u app" in cmd[-1]
|
||||
assert "ALTER USER USER() IDENTIFIED BY" in omgeving["SU_SQL"]
|
||||
assert omgeving["SU_OUD"] == "Ou6-oudgeheim"
|
||||
# Via stdin, dus geen wachtwoord in de procestabel van de host.
|
||||
assert "Zg3-nieuwgeheim" not in cmd[-1]
|
||||
assert "Ou6-oudgeheim" not in cmd[-1]
|
||||
|
||||
|
||||
def test_mongo_werkt_met_mongo_en_mongosh():
|
||||
cmd, omgeving = _cmd("mongo", gebruiker="root", oud="Ou6-oud",
|
||||
nieuw="Zg3-nieuw")
|
||||
assert "updateUser" in omgeving["SU_JS"]
|
||||
assert "mongosh" in cmd[-1] and "bin=mongo" in cmd[-1]
|
||||
assert "Zg3-nieuw" not in cmd[-1] and "Ou6-oud" not in cmd[-1]
|
||||
|
||||
|
||||
def test_aanhalingstekens_breken_het_statement_niet():
|
||||
"""Een wachtwoord met een quote erin mag geen SQL of JavaScript openbreken."""
|
||||
_, omgeving = _cmd("mysql", nieuw="pas'woord\\raar")
|
||||
assert "pas''woord" in omgeving["SU_SQL"]
|
||||
assert "\\\\raar" in omgeving["SU_SQL"]
|
||||
|
||||
_, mongo_omgeving = _cmd("mongo", nieuw='pas"woord\\raar')
|
||||
assert '\\"woord' in mongo_omgeving["SU_JS"]
|
||||
|
||||
|
||||
def test_onbekend_soort_geeft_een_duidelijke_fout():
|
||||
from core import dbwachtwoord
|
||||
with pytest.raises(dbwachtwoord.WachtwoordFout):
|
||||
dbwachtwoord.commando({"soort": "sqlite", "gebruiker": "",
|
||||
"oud_wachtwoord": "x", "container": "c"}, "y")
|
||||
|
||||
|
||||
# ── Omzetten in de container ─────────────────────────────────────────────────
|
||||
|
||||
def test_gestopte_database_wordt_gemeld(env, monkeypatch):
|
||||
from core import dbwachtwoord, docker
|
||||
monkeypatch.setattr(docker, "container_draait", lambda n: False)
|
||||
ok, msg = dbwachtwoord.omzetten(
|
||||
{"soort": "postgres", "gebruiker": "app", "database": "app",
|
||||
"container": "web-db", "oud_wachtwoord": "oud"}, "nieuw")
|
||||
assert ok is False
|
||||
assert "draait niet" in msg
|
||||
|
||||
|
||||
def test_wachtwoord_lekt_niet_in_de_foutmelding(env, monkeypatch):
|
||||
"""Een joblog is zichtbaar voor iedereen die de app mag bedienen."""
|
||||
from core import dbwachtwoord, docker
|
||||
|
||||
class Mislukt:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = 'FATAL: password authentication failed voor "supergeheim123"'
|
||||
|
||||
monkeypatch.setattr(docker, "container_draait", lambda n: True)
|
||||
monkeypatch.setattr(docker, "_run", lambda *a, **k: Mislukt())
|
||||
ok, msg = dbwachtwoord.omzetten(
|
||||
{"soort": "postgres", "gebruiker": "app", "database": "app",
|
||||
"container": "web-db", "oud_wachtwoord": "supergeheim123"}, "nieuw")
|
||||
assert ok is False
|
||||
assert "supergeheim123" not in msg
|
||||
assert "***" in msg
|
||||
|
||||
|
||||
# ── De hele weg door de wijziging heen ───────────────────────────────────────
|
||||
|
||||
def _sjabloon(client, csrf):
|
||||
from core import git
|
||||
wortel = git.cache_dir("server-up") / "apps" / "dbapp"
|
||||
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
||||
(wortel / "template.json").write_text(json.dumps({
|
||||
"kind": "compose", "metadata": {"name": "dbapp"},
|
||||
"variables": [{"title": "Algemeen", "items": [
|
||||
{"name": "service_name", "type": "str", "title": "Naam",
|
||||
"default": "dbapp", "required": True},
|
||||
{"name": "db_password", "type": "str", "title": "Wachtwoord",
|
||||
"default": "", "required": True, "secret": True,
|
||||
"generate": "auto"},
|
||||
]}]}), encoding="utf-8")
|
||||
(wortel / "files" / "compose.yaml").write_text(
|
||||
'services:\n'
|
||||
' << service_name >>:\n image: nginx\n'
|
||||
' << service_name >>-db:\n'
|
||||
' image: postgres:16-alpine\n'
|
||||
' container_name: << service_name >>-db\n'
|
||||
' environment:\n'
|
||||
' - POSTGRES_USER=app\n'
|
||||
' - POSTGRES_DB=app\n'
|
||||
' - POSTGRES_PASSWORD=<< db_password >>\n', encoding="utf-8")
|
||||
|
||||
r = client.post("/api/store/install",
|
||||
json={"stack": "dbapp", "repo_id": "server-up",
|
||||
"instance": "dbapp", "connect_apps": False,
|
||||
"values": {"service_name": "dbapp",
|
||||
"db_password": "eerstewachtwoord"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
|
||||
|
||||
def _wacht(client, jid, tijdslimiet=10):
|
||||
einde = time.time() + tijdslimiet
|
||||
while time.time() < einde:
|
||||
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
||||
if d.get("status") in ("done", "error"):
|
||||
return d
|
||||
time.sleep(0.05)
|
||||
return {}
|
||||
|
||||
|
||||
def _stub_docker(monkeypatch, app_module, omzetten_lukt=True):
|
||||
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
|
||||
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
|
||||
gedaan = []
|
||||
|
||||
def _omzetten(dienst, nieuw, log_fn=None):
|
||||
gedaan.append((dienst["container"], dienst["oud_wachtwoord"], nieuw))
|
||||
if omzetten_lukt:
|
||||
if log_fn:
|
||||
log_fn("Wachtwoord omgezet")
|
||||
return True, ""
|
||||
return False, "Wachtwoord van web-db niet gewijzigd: verbinding geweigerd"
|
||||
|
||||
monkeypatch.setattr(app_module.dbwachtwoord, "omzetten", _omzetten)
|
||||
return gedaan
|
||||
|
||||
|
||||
def test_wijziging_zet_het_wachtwoord_ook_in_de_database(client, env, monkeypatch):
|
||||
import app as app_module
|
||||
csrf = login(client)
|
||||
_sjabloon(client, csrf)
|
||||
gedaan = _stub_docker(monkeypatch, app_module)
|
||||
|
||||
r = client.post("/api/stacks/dbapp/reconfigure",
|
||||
json={"values": {"service_name": "dbapp",
|
||||
"db_password": "tweedewachtwoord"},
|
||||
"connect_apps": False},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert r.status_code == 200, r.get_json()
|
||||
d = _wacht(client, r.get_json()["job_id"])
|
||||
assert d.get("status") == "done", d.get("lines")
|
||||
|
||||
assert gedaan == [("dbapp-db", "eerstewachtwoord", "tweedewachtwoord")]
|
||||
|
||||
|
||||
def test_zonder_wachtwoordwijziging_gebeurt_er_niets_met_de_database(
|
||||
client, env, monkeypatch):
|
||||
import app as app_module
|
||||
csrf = login(client)
|
||||
_sjabloon(client, csrf)
|
||||
gedaan = _stub_docker(monkeypatch, app_module)
|
||||
|
||||
r = client.post("/api/stacks/dbapp/reconfigure",
|
||||
json={"values": {"service_name": "dbapp",
|
||||
"db_password": "eerstewachtwoord"},
|
||||
"connect_apps": False},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht(client, r.get_json()["job_id"])
|
||||
assert gedaan == []
|
||||
|
||||
|
||||
def test_mislukte_omzetting_breekt_de_wijziging_af(client, env, monkeypatch):
|
||||
"""Een instelling die niet doorgaat is beter dan een app die niet meer bij
|
||||
zijn gegevens kan."""
|
||||
import app as app_module
|
||||
csrf = login(client)
|
||||
_sjabloon(client, csrf)
|
||||
_stub_docker(monkeypatch, app_module, omzetten_lukt=False)
|
||||
hersteld = []
|
||||
monkeypatch.setattr(app_module.backups, "restore",
|
||||
lambda *a, **k: hersteld.append(a) or (True, "ok"))
|
||||
|
||||
r = client.post("/api/stacks/dbapp/reconfigure",
|
||||
json={"values": {"service_name": "dbapp",
|
||||
"db_password": "derdewachtwoord"},
|
||||
"connect_apps": False},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
d = _wacht(client, r.get_json()["job_id"])
|
||||
assert d.get("status") == "error"
|
||||
tekst = "\n".join(x["text"] for x in d.get("lines", []))
|
||||
assert "niet meer bij zijn database" in tekst
|
||||
assert hersteld, "de backup hoort teruggezet te worden"
|
||||
|
|
@ -383,3 +383,50 @@ def test_apparatenlijst_is_op_te_vragen(client, env):
|
|||
assert "devices" in d and isinstance(d["devices"], list)
|
||||
for dev in d["devices"]:
|
||||
assert dev["path"].startswith("/dev/")
|
||||
|
||||
|
||||
# ── Versietags ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_versietag_wordt_vastgehouden_over_wijzigingen_heen(client, env, monkeypatch):
|
||||
"""Sjablonen staan meestal op `latest`; wie een versie kiest wil niet dat
|
||||
de eerstvolgende instellingswijziging hem daar weer af haalt."""
|
||||
import yaml as _yaml
|
||||
import app as app_module
|
||||
csrf = login(client)
|
||||
_installeer_keuzeapp(client, csrf)
|
||||
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
|
||||
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
|
||||
|
||||
r = client.post("/api/stacks/kz/reconfigure",
|
||||
json={"values": {"service_name": "keuzeapp", "port": 9300},
|
||||
"connect_apps": False,
|
||||
"image_tags": {"keuzeapp": "1.25-alpine"}},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
assert _wacht_op_job(client, r.get_json()["job_id"]).get("status") == "done"
|
||||
|
||||
def _image():
|
||||
compose = next(p for p in (env["lib"] / "kz").iterdir()
|
||||
if p.name.startswith(("compose", "docker-compose")))
|
||||
return _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]["image"]
|
||||
|
||||
assert _image() == "nginx:1.25-alpine"
|
||||
|
||||
# Een volgende wijziging zonder tags: de keuze staat in de metadata en
|
||||
# hoort te blijven staan, ook al rendert het sjabloon weer `nginx`.
|
||||
r = client.post("/api/stacks/kz/reconfigure",
|
||||
json={"values": {"service_name": "keuzeapp", "port": 9301},
|
||||
"connect_apps": False},
|
||||
headers={"X-CSRF-Token": csrf})
|
||||
_wacht_op_job(client, r.get_json()["job_id"])
|
||||
assert _image() == "nginx:1.25-alpine"
|
||||
|
||||
|
||||
def test_onzinnige_versietag_wordt_genegeerd(client, env, monkeypatch):
|
||||
"""De tag belandt in het compose-bestand; een waarde met een spatie of een
|
||||
dubbele punt erin zou dat bestand stukmaken."""
|
||||
import app as app_module
|
||||
assert app_module._gekozen_image_tags({"image_tags": {"a": "1.2.3"}}) == {"a": "1.2.3"}
|
||||
for onzin in ("mijn tag", "tag:met:dubbelepunt", "-begintmetstreepje", "", " "):
|
||||
assert app_module._gekozen_image_tags({"image_tags": {"a": onzin}}) == {}
|
||||
|
|
|
|||
140
tests/test_stackslot.py
Normal file
140
tests/test_stackslot.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""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"
|
||||
Loading…
Reference in a new issue