feat: wachtwoordsync, slot per app, sneller dashboard en versietags
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:
Ramon 2026-08-07 00:14:26 +02:00
parent 07d5effcec
commit d3ebf40165
16 changed files with 1052 additions and 19 deletions

View file

@ -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 # v0.8.26-beta — Eigen mappen en apparaten per app
Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders

View file

@ -1 +1 @@
0.8.26-beta 0.8.27-beta

View file

@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, send_file,
import core as cfg 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, dbwachtwoord
from core import diskspace, networks
from core import notify, paden, 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
@ -699,6 +700,10 @@ def api_stacks():
except Exception: except Exception:
return "" 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 = [] out = []
for d in sorted(lib.iterdir()): for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith("."): 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 # plaats van een status, geen knoppen — die door dit `continue` nooit te
# zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen. # zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen.
heeft_compose = docker.has_compose(d) 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) logo_url, icon = _stack_logo(d)
meta = _stack_meta(d) meta = _stack_meta(d)
out.append({ out.append({
@ -880,7 +885,9 @@ def api_stack_action(name, action):
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) return jsonify(ok=True, job_id=jid)
@ -1115,6 +1122,7 @@ def api_stack_config(name):
app_networks=meta.get("app_networks") or [], app_networks=meta.get("app_networks") or [],
app_services=meta.get("app_services") or [], app_services=meta.get("app_services") or [],
extras=meta.get("extras") or {"volumes": [], "devices": []}, extras=meta.get("extras") or {"volumes": [], "devices": []},
image_tags=meta.get("image_tags") or {},
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""), source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name), name=meta.get("name", name),
# Waar dit sjabloon vandaan komt. Opslaan rendert de app # 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) extras, extra_fout = _gekozen_extras(body, meta)
if extra_fout: if extra_fout:
return jsonify(ok=False, msg=extra_fout), 400 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 # Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack
# opleveren — en er ligt al een werkende versie die we niet moeten slopen. # 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.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}")
jobs.finish(jid, "error"); jobs.done(qq); return 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) boilerplates.render_to_dir(bron, d, nieuwe_waarden)
jobs.log(qq, "ok", "Opnieuw gerenderd") jobs.log(qq, "ok", "Opnieuw gerenderd")
@ -1265,6 +1282,19 @@ def api_stack_reconfigure(name):
+ ", ".join(f"'{n}'" for n in klaar) + ", ".join(f"'{n}'" for n in klaar)
+ " (" + ", ".join(diensten_nu) + ")") + " (" + ", ".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 # Eigen mappen en apparaten als laatste: die horen ook op een
# service die net een eigen IP of netwerk kreeg. # service die net een eigen IP of netwerk kreeg.
if cf and (extras.get("volumes") or extras.get("devices")): 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) backups.restore(name, backup_naam, log_fn=lf)
jobs.finish(jid, "error"); jobs.done(qq); return 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) zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden)
_stack_meta_schrijven(d, { _stack_meta_schrijven(d, {
"values": zichtbaar, "secret_fields": geheime_namen, "values": zichtbaar, "secret_fields": geheime_namen,
@ -1295,6 +1344,7 @@ def api_stack_reconfigure(name):
"app_networks": app_netten, "app_networks": app_netten,
"app_services": app_diensten, "app_services": app_diensten,
"extras": extras, "extras": extras,
"image_tags": image_tags,
"service_ports": poorten_meta, "service_ports": poorten_meta,
"web": boilerplates.metadata(bron).get("web") or {}, "web": boilerplates.metadata(bron).get("web") or {},
"connected": bool(app_netten)}) "connected": bool(app_netten)})
@ -1318,7 +1368,9 @@ def api_stack_reconfigure(name):
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) return jsonify(ok=True, job_id=jid)
@ -1382,7 +1434,9 @@ def api_stack_connect(name):
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) 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 [] 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]: def _gekozen_extras(body: dict, meta: dict | None = None) -> tuple[dict, str]:
"""Extra mappen en apparaten uit de request, gecontroleerd. """Extra mappen en apparaten uit de request, gecontroleerd.
@ -1919,8 +1991,15 @@ def api_store_preview():
doc = yaml.safe_load(compose_tekst) or {} doc = yaml.safe_load(compose_tekst) or {}
diensten = [n for n in (doc.get("services") or {})] diensten = [n for n in (doc.get("services") or {})]
# Mét image en de vraag of het een database is: daarmee laat de # 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) 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: except Exception:
diensten = [] diensten = []
return jsonify( return jsonify(
@ -2045,6 +2124,7 @@ def api_store_install():
extras, extra_fout = _gekozen_extras(d) extras, extra_fout = _gekozen_extras(d)
if extra_fout: if extra_fout:
return jsonify(ok=False, msg=extra_fout), 400 return jsonify(ok=False, msg=extra_fout), 400
bp_image_tags = _gekozen_image_tags(d)
verbinden = bool(app_netten) verbinden = bool(app_netten)
if not stack: if not stack:
return jsonify(ok=False, msg="stack vereist") return jsonify(ok=False, msg="stack vereist")
@ -2319,6 +2399,18 @@ def api_store_install():
except compose_transform.TransformError as e: except compose_transform.TransformError as e:
jobs.log(qq, "warn", f"Koppelen mislukt: {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. # Eigen mappen en apparaten uit het invulmenu.
if extras.get("volumes") or extras.get("devices"): if extras.get("volumes") or extras.get("devices"):
cf = docker.find_compose(dest) cf = docker.find_compose(dest)
@ -2373,6 +2465,7 @@ def api_store_install():
"app_networks": app_netten, "app_networks": app_netten,
"app_services": app_diensten, "app_services": app_diensten,
"extras": extras, "extras": extras,
"image_tags": bp_image_tags,
"service_ports": poorten_meta, "service_ports": poorten_meta,
"categories": meta.get("categories") or [], "categories": meta.get("categories") or [],
"tags": meta.get("tags") or [], "tags": meta.get("tags") or [],
@ -2404,7 +2497,9 @@ def api_store_install():
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) return jsonify(ok=True, job_id=jid)
@ -2475,7 +2570,9 @@ def api_backup_create(stack):
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) return jsonify(ok=True, job_id=jid)
@ -2506,7 +2603,9 @@ def api_backup_restore(stack):
jobs.finish(jid, "error") jobs.finish(jid, "error")
jobs.done(qq) 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) return jsonify(ok=True, job_id=jid)
@ -2931,6 +3030,9 @@ def api_connectable():
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
uit = [] uit = []
if lib.is_dir(): 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()): for d in sorted(lib.iterdir()):
if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue continue
@ -2943,7 +3045,7 @@ def api_connectable():
meta = {} meta = {}
containers = [{"name": c["name"], "service": c.get("service", ""), containers = [{"name": c["name"], "service": c.get("service", ""),
"ports": c.get("ports", "")} "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({ uit.append({
"stack": d.name, "stack": d.name,
"source": meta.get("source", ""), # uit welke app-template "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): if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d):
continue continue
try: 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}") meta = backups.create(d.name, reden=f"gepland-{plan}")
print(f" [backup] {meta['file']}") print(f" [backup] {meta['file']}")
audit.log("backups", "scheduled", "ok", ref=d.name) audit.log("backups", "scheduled", "ok", ref=d.name)

View file

@ -195,13 +195,24 @@ def _vul_in(waarde: str, env: dict[str, str]) -> str:
def databasediensten(stack: str) -> list[dict]: def databasediensten(stack: str) -> list[dict]:
"""De databasecontainers van deze stack, met wat er nodig is voor een dump.""" """De databasecontainers van deze stack, met wat er nodig is voor een dump."""
import yaml
d = _stack_dir(stack) d = _stack_dir(stack)
try: 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: except Exception:
return [] return []
env = _env_waarden(d)
uit = [] uit = []
for snaam, svc in (doc.get("services") or {}).items(): for snaam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict): if not isinstance(svc, dict):

View file

@ -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, return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False,
allow_unicode=True) 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)

View 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, ""

View file

@ -147,7 +147,8 @@ def list_containers(alle: bool = True) -> list[dict]:
""" """
fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}"
"\t{{.Label \"com.docker.compose.project\"}}" "\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] cmd = [_bin(), "ps", "--format", fmt]
if alle: if alle:
cmd.insert(2, "-a") cmd.insert(2, "-a")
@ -165,16 +166,31 @@ def list_containers(alle: bool = True) -> list[dict]:
d = regel.split("\t") d = regel.split("\t")
if len(d) < 4: if len(d) < 4:
continue continue
d += [""] * (8 - len(d)) d += [""] * (9 - len(d))
uit.append({ uit.append({
"id": d[0], "name": d[1], "image": d[2], "id": d[0], "name": d[1], "image": d[2],
"state": d[3], "status": d[4], "ports": d[5], "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"), "running": d[3].lower() in ("running", "up"),
}) })
return sorted(uit, key=lambda c: c["name"]) 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]: def container_action(name: str, action: str) -> tuple[bool, str]:
"""Start, stop of herstart één container. """Start, stop of herstart één container.
@ -267,6 +283,9 @@ def compose_ps(d: Path, name=None) -> list[dict]:
out.append({ out.append({
"name": c.get("Name", ""), "name": c.get("Name", ""),
"service": c.get("Service", ""), "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", ""), "state": c.get("State", ""),
"status": c.get("Status", ""), "status": c.get("Status", ""),
"ports": _published_ports(c), "ports": _published_ports(c),
@ -397,6 +416,18 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]:
pass 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: def image_digest_local(image: str) -> str:
"""Digest van het lokaal aanwezige image, of "" als het er niet is. """Digest van het lokaal aanwezige image, of "" als het er niet is.

View file

@ -1,5 +1,5 @@
"""Background job runner with log streaming.""" """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 from pathlib import Path
_jobs: dict[str, dict] = {} _jobs: dict[str, dict] = {}
@ -193,3 +193,65 @@ def stream(jid: str, offset=0) -> dict:
def run(fn, *args): def run(fn, *args):
threading.Thread(target=fn, args=args, daemon=True).start() 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()

View file

@ -381,6 +381,9 @@ tailwind.config = {
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span> <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> <span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
</div> </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 class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
</div> </div>
</template> </template>
@ -2018,6 +2021,24 @@ tailwind.config = {
overschreven. --> overschreven. -->
<template x-if="s.id === 'extras'"> <template x-if="s.id === 'extras'">
<div class="space-y-4"> <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>
<div class="form-label" x-text="t('extra_volumes')">Mappen van deze host</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> <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: [], network: '', ip: '', ips: {}, services: [], service_info: [],
app_services: [], depends_on: [], app_services: [], depends_on: [],
extras: { volumes: [], devices: [] }, extras: { volumes: [], devices: [] },
image_tags: {},
compatibility: { known: false, supported: true, architecture: '', reason: '' }, compatibility: { known: false, supported: true, architecture: '', reason: '' },
architecture_note: '' architecture_note: ''
}; };
@ -4248,6 +4270,7 @@ function app() {
source_repo: d.source_repo || {}, source_repo: d.source_repo || {},
extras: { volumes: (d.extras||{}).volumes || [], extras: { volumes: (d.extras||{}).volumes || [],
devices: (d.extras||{}).devices || [] }, devices: (d.extras||{}).devices || [] },
image_tags: { ...(d.image_tags || {}) },
}; };
await this.loadAppNets(stack); await this.loadAppNets(stack);
this.fieldInfo = null; this.fieldInfo = null;
@ -4271,6 +4294,7 @@ function app() {
app_networks: this.install.app_networks || [], app_networks: this.install.app_networks || [],
app_services: this.install.app_services || [], app_services: this.install.app_services || [],
extras: this.install.extras || {}, extras: this.install.extras || {},
image_tags: this.install.image_tags || {},
}); });
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; } if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null; this.modal = null;
@ -4306,6 +4330,10 @@ function app() {
body.app_networks = this.install.app_networks || []; body.app_networks = this.install.app_networks || [];
body.app_services = this.install.app_services || []; body.app_services = this.install.app_services || [];
body.extras = this.install.extras || {}; 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; if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; } 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 // Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw

View file

@ -69,6 +69,8 @@
"device_stable": "stable", "device_stable": "stable",
"no_devices": "No devices found on this host. You can type the path yourself, for example /dev/ttyUSB0.", "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.", "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", "add_repo": "Add repository",
"repo_name": "Name", "repo_name": "Name",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -69,6 +69,8 @@
"device_stable": "vast", "device_stable": "vast",
"no_devices": "Geen apparaten gevonden op deze host. Je kunt het pad zelf intikken, bijvoorbeeld /dev/ttyUSB0.", "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.", "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", "add_repo": "Repository toevoegen",
"repo_name": "Naam", "repo_name": "Naam",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -252,3 +252,43 @@ def test_geweigerd_pad_belandt_niet_in_compose():
def test_zonder_extras_verandert_er_niets(): def test_zonder_extras_verandert_er_niets():
assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == 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

View file

@ -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): def test_compose_ps_valt_bij_oude_compose_terug_op_ports(host):
assert host._published_ports( assert host._published_ports(
{"Ports": "0.0.0.0:8080->80/tcp"}) == "0.0.0.0:8080->80/tcp" {"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
View 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"

View file

@ -383,3 +383,50 @@ def test_apparatenlijst_is_op_te_vragen(client, env):
assert "devices" in d and isinstance(d["devices"], list) assert "devices" in d and isinstance(d["devices"], list)
for dev in d["devices"]: for dev in d["devices"]:
assert dev["path"].startswith("/dev/") 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
View 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"