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
|
# 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
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.8.26-beta
|
0.8.27-beta
|
||||||
|
|
|
||||||
135
server-up/app.py
135
server-up/app.py
|
|
@ -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,7 +4233,18 @@ 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:
|
||||||
meta = backups.create(d.name, reden=f"gepland-{plan}")
|
# 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']}")
|
print(f" [backup] {meta['file']}")
|
||||||
audit.log("backups", "scheduled", "ok", ref=d.name)
|
audit.log("backups", "scheduled", "ok", ref=d.name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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}}"
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
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)
|
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
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