diff --git a/CHANGELOG.md b/CHANGELOG.md index d85de3a..4c7e313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +# v0.8.27-beta — Wachtwoorden, wachtrijen, snelheid en versies + +Vier verbeteringen die alle vier dezelfde vorm hebben: iets dat er goed uitzag +totdat je het nodig had. + +- **Een databasewachtwoord wijzigen breekt je app niet meer.** + `POSTGRES_PASSWORD` en verwanten worden door de officiële images alleen bij + een lege datamap gebruikt. Wie dat veld wijzigde kreeg een app met een nieuw + wachtwoord en een database met het oude — 64 van de sjablonen hebben zo'n + veld. Server Up zet het wachtwoord nu eerst om in de nog draaiende database + (`ALTER USER`, `updateUser`) en breekt de wijziging af als dat niet lukt. + Het oude en nieuwe wachtwoord gaan daarbij via de omgeving, niet via de + argumenten: die zijn op de host zichtbaar in de procestabel. +- **Eén taak tegelijk per app.** De nachtelijke backup kon starten midden in + een instellingswijziging en zo een half herschreven map inpakken. Taken op + dezelfde app wachten nu netjes op elkaar; de geplande backup slaat een + bezette app over en meldt dat. Een taak die klapt laat het slot los en komt + niet meer voor eeuwig op 'bezig' te staan. +- **Het dashboard doet één docker-aanroep in plaats van één per app.** Bij + vijftien apps waren dat vijftien processen per keer verversen, en nog eens + zoveel bij elk openen van de installatiemodal. +- **Versie per app te kiezen.** De meeste sjablonen staan op `latest`, waardoor + Bijwerken je ongemerkt over een majorversie kan tillen. In de stap 'Extra's' + zet je per onderdeel een versie vast; die overleeft elke volgende wijziging. + In het containerpaneel staat welke versie er nu draait. + # v0.8.26-beta — Eigen mappen en apparaten per app Een sjabloon kan niet elke situatie voorzien: een mediamap die ergens anders diff --git a/VERSION b/VERSION index 035af51..b8cefcc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.26-beta +0.8.27-beta diff --git a/server-up/app.py b/server-up/app.py index aa8b2f4..314f97a 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, send_file, import core as cfg from core import audit, jobs, i18n from core import auth, docker, git, boilerplates, updater -from core import backups, categories, compose_transform, diskspace, networks +from core import backups, categories, compose_transform, dbwachtwoord +from core import diskspace, networks from core import notify, paden, pangolin from core import scheduler, selfupdate from core import stackupdates @@ -699,6 +700,10 @@ def api_stacks(): except Exception: return "" + # Eén `docker ps` voor alle apps samen. Per stackmap een `docker compose ps` + # starten betekende bij vijftien apps vijftien processen per keer verversen. + per_project = docker.containers_per_project() + out = [] for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith("."): @@ -712,7 +717,7 @@ def api_stacks(): # plaats van een status, geen knoppen — die door dit `continue` nooit te # zien kon zijn. Tonen mét die waarschuwing is beter dan verzwijgen. heeft_compose = docker.has_compose(d) - ct = docker.compose_ps(d, name=d.name) if heeft_compose else [] + ct = per_project.get(d.name, []) if heeft_compose else [] logo_url, icon = _stack_logo(d) meta = _stack_meta(d) out.append({ @@ -880,7 +885,9 @@ def api_stack_action(name, action): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(name, _run), jid) return jsonify(ok=True, job_id=jid) @@ -1115,6 +1122,7 @@ def api_stack_config(name): app_networks=meta.get("app_networks") or [], app_services=meta.get("app_services") or [], extras=meta.get("extras") or {"volumes": [], "devices": []}, + image_tags=meta.get("image_tags") or {}, source=meta.get("source", ""), repo_id=meta.get("repo_id", ""), name=meta.get("name", name), # Waar dit sjabloon vandaan komt. Opslaan rendert de app @@ -1181,6 +1189,9 @@ def api_stack_reconfigure(name): extras, extra_fout = _gekozen_extras(body, meta) if extra_fout: return jsonify(ok=False, msg=extra_fout), 400 + # Welke versietag per onderdeel. Zonder keuze blijft staan wat het sjabloon + # zelf zegt; met een keuze overleeft die elke volgende wijziging. + image_tags = _gekozen_image_tags(body, meta) # Ook hier: een veld leegmaken mag niet stilzwijgend een kapotte stack # opleveren — en er ligt al een werkende versie die we niet moeten slopen. @@ -1209,6 +1220,12 @@ def api_stack_reconfigure(name): jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}") jobs.finish(jid, "error"); jobs.done(qq); return + # De databases zoals ze nú draaien, mét hun huidige wachtwoorden. + # Na het renderen staan die nergens meer, en zonder het oude + # wachtwoord valt een nieuw wachtwoord niet meer te zetten. + db_voor = backups.diensten_uit_compose( + docker.read_compose(d), backups._env_waarden(d)) + boilerplates.render_to_dir(bron, d, nieuwe_waarden) jobs.log(qq, "ok", "Opnieuw gerenderd") @@ -1265,6 +1282,19 @@ def api_stack_reconfigure(name): + ", ".join(f"'{n}'" for n in klaar) + " (" + ", ".join(diensten_nu) + ")") + # De gekozen versietags terugzetten: het renderen haalt ze weg, + # want het sjabloon schrijft zijn eigen image-regel. + if cf and image_tags: + try: + nieuw_txt = compose_transform.zet_image_tags( + cf.read_text(encoding="utf-8"), image_tags) + if nieuw_txt != cf.read_text(encoding="utf-8"): + cf.write_text(nieuw_txt, encoding="utf-8") + for svc, tag in image_tags.items(): + jobs.log(qq, "ok", f"Versie vastgezet: {svc} → {tag}") + except compose_transform.TransformError as e: + jobs.log(qq, "warn", f"Versietag niet toegepast: {e}") + # Eigen mappen en apparaten als laatste: die horen ook op een # service die net een eigen IP of netwerk kreeg. if cf and (extras.get("volumes") or extras.get("devices")): @@ -1287,6 +1317,25 @@ def api_stack_reconfigure(name): backups.restore(name, backup_naam, log_fn=lf) jobs.finish(jid, "error"); jobs.done(qq); return + # Een gewijzigd databasewachtwoord staat nu in het compose-bestand, + # maar de database zelf weet daar niets van: die leest zijn + # wachtwoordvariabele alleen bij een lege datamap. Omzetten moet dus + # n\u00fa, terwijl de oude container nog draait met het oude wachtwoord. + db_na = backups.diensten_uit_compose( + docker.read_compose(d), backups._env_waarden(d)) + for dienst in dbwachtwoord.wijzigingen(db_voor, db_na): + ok_ww, fout_ww = dbwachtwoord.omzetten( + dienst, dienst["wachtwoord"], + log_fn=lambda m: jobs.log(qq, "ok", m)) + if not ok_ww: + jobs.log(qq, "error", fout_ww) + jobs.log(qq, "error", + "Zonder die wijziging kan de app straks niet meer " + "bij zijn database. De wijziging is afgebroken.") + jobs.log(qq, "dim", "Backup wordt teruggezet\u2026") + backups.restore(name, backup_naam, log_fn=lf) + jobs.finish(jid, "error"); jobs.done(qq); return + zichtbaar, geheime_namen = _splits_geheimen(velden_bron, nieuwe_waarden) _stack_meta_schrijven(d, { "values": zichtbaar, "secret_fields": geheime_namen, @@ -1295,6 +1344,7 @@ def api_stack_reconfigure(name): "app_networks": app_netten, "app_services": app_diensten, "extras": extras, + "image_tags": image_tags, "service_ports": poorten_meta, "web": boilerplates.metadata(bron).get("web") or {}, "connected": bool(app_netten)}) @@ -1318,7 +1368,9 @@ def api_stack_reconfigure(name): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(name, _run), jid) return jsonify(ok=True, job_id=jid) @@ -1382,7 +1434,9 @@ def api_stack_connect(name): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(name, _run), jid) return jsonify(ok=True, job_id=jid) @@ -1668,6 +1722,24 @@ def _gekozen_app_netwerken(body: dict, standaard: str) -> list[str]: return [standaard] if verbinden else [] +_TAG_TEKENS = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +def _gekozen_image_tags(body: dict, meta: dict | None = None) -> dict[str, str]: + """Per service de gekozen versietag. + + Zonder keuze blijft staan wat het sjabloon zelf zegt. Tags worden getoetst + aan wat Docker toestaat: ze belanden in het compose-bestand, dus een waarde + met een spatie of een dubbele punt erin zou dat bestand stukmaken. + """ + ruw = body.get("image_tags") + if not isinstance(ruw, dict): + return {s: t for s, t in ((meta or {}).get("image_tags") or {}).items() + if isinstance(t, str) and _TAG_TEKENS.match(t)} + return {str(s): str(t).strip() for s, t in ruw.items() + if str(t).strip() and _TAG_TEKENS.match(str(t).strip())} + + def _gekozen_extras(body: dict, meta: dict | None = None) -> tuple[dict, str]: """Extra mappen en apparaten uit de request, gecontroleerd. @@ -1919,8 +1991,15 @@ def api_store_preview(): doc = yaml.safe_load(compose_tekst) or {} diensten = [n for n in (doc.get("services") or {})] # Mét image en de vraag of het een database is: daarmee laat de - # interface zien wat er wel en niet op het gedeelde netwerk komt. + # interface zien wat er wel en niet op het gedeelde netwerk komt, + # en welke versie er per onderdeel te kiezen valt. dienst_info = compose_transform.diensten(compose_tekst) + per_image = {i["service"]: i + for i in compose_transform.images_per_service(compose_tekst)} + for info in dienst_info: + bron = per_image.get(info["name"]) or {} + info["repo"] = bron.get("repo", "") + info["tag"] = bron.get("tag", "") except Exception: diensten = [] return jsonify( @@ -2045,6 +2124,7 @@ def api_store_install(): extras, extra_fout = _gekozen_extras(d) if extra_fout: return jsonify(ok=False, msg=extra_fout), 400 + bp_image_tags = _gekozen_image_tags(d) verbinden = bool(app_netten) if not stack: return jsonify(ok=False, msg="stack vereist") @@ -2319,6 +2399,18 @@ def api_store_install(): except compose_transform.TransformError as e: jobs.log(qq, "warn", f"Koppelen mislukt: {e}") + # Versietags uit het invulmenu (boilerplate-apps). + if bp_image_tags: + cf = docker.find_compose(dest) + try: + cf.write_text(compose_transform.zet_image_tags( + cf.read_text(encoding="utf-8"), bp_image_tags), + encoding="utf-8") + for svc, tag in bp_image_tags.items(): + jobs.log(qq, "ok", f"Versie vastgezet: {svc} → {tag}") + except compose_transform.TransformError as e: + jobs.log(qq, "warn", f"Versietag niet toegepast: {e}") + # Eigen mappen en apparaten uit het invulmenu. if extras.get("volumes") or extras.get("devices"): cf = docker.find_compose(dest) @@ -2373,6 +2465,7 @@ def api_store_install(): "app_networks": app_netten, "app_services": app_diensten, "extras": extras, + "image_tags": bp_image_tags, "service_ports": poorten_meta, "categories": meta.get("categories") or [], "tags": meta.get("tags") or [], @@ -2404,7 +2497,9 @@ def api_store_install(): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(inst, _run), jid) return jsonify(ok=True, job_id=jid) @@ -2475,7 +2570,9 @@ def api_backup_create(stack): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(stack, _run), jid) return jsonify(ok=True, job_id=jid) @@ -2506,7 +2603,9 @@ def api_backup_restore(stack): jobs.finish(jid, "error") jobs.done(qq) - jobs.run(_run, jid) + # Eén taak tegelijk per app: anders kan een geplande backup midden + # in deze wijziging beginnen en een halve stack inpakken. + jobs.run(jobs.met_slot(stack, _run), jid) return jsonify(ok=True, job_id=jid) @@ -2931,6 +3030,9 @@ def api_connectable(): lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() uit = [] if lib.is_dir(): + # Eén docker-aanroep voor alle apps samen; dit scherm opent bij elke + # installatie en deed er eerst één per stack. + per_project = docker.containers_per_project() for d in sorted(lib.iterdir()): if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): continue @@ -2943,7 +3045,7 @@ def api_connectable(): meta = {} containers = [{"name": c["name"], "service": c.get("service", ""), "ports": c.get("ports", "")} - for c in docker.compose_ps(d, name=d.name) if c.get("name")] + for c in per_project.get(d.name, []) if c.get("name")] uit.append({ "stack": d.name, "source": meta.get("source", ""), # uit welke app-template @@ -4131,7 +4233,18 @@ def _geplande_backups() -> bool: if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): continue 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']}") audit.log("backups", "scheduled", "ok", ref=d.name) except Exception as e: diff --git a/server-up/core/backups.py b/server-up/core/backups.py index 1b66207..b147096 100644 --- a/server-up/core/backups.py +++ b/server-up/core/backups.py @@ -195,13 +195,24 @@ def _vul_in(waarde: str, env: dict[str, str]) -> str: def databasediensten(stack: str) -> list[dict]: """De databasecontainers van deze stack, met wat er nodig is voor een dump.""" - import yaml d = _stack_dir(stack) try: - doc = yaml.safe_load(docker.read_compose(d)) or {} + return diensten_uit_compose(docker.read_compose(d), _env_waarden(d)) + except Exception: + return [] + + +def diensten_uit_compose(compose_text: str, env: dict[str, str]) -> list[dict]: + """Dezelfde lijst, maar uit losse tekst. + + Bij het wijzigen van instellingen moet de situatie vóór het renderen naast + die erna gelegd worden — en dan bestaan de oude bestanden niet meer. + """ + import yaml + try: + doc = yaml.safe_load(compose_text) or {} except Exception: return [] - env = _env_waarden(d) uit = [] for snaam, svc in (doc.get("services") or {}).items(): if not isinstance(svc, dict): diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index 090ad9d..a9813df 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -539,3 +539,68 @@ def add_extras(compose_text: str, extras: dict) -> str: return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False, allow_unicode=True) + + +# ── Versietags ─────────────────────────────────────────────────────────────── +# De meeste sjablonen gebruiken `:latest`. Eén druk op Bijwerken kan je dan over +# een majorversie tillen — met een databasemigratie die je niet terugdraait. +# Deze twee maken het mogelijk om per app een versie te kiezen en vast te +# houden, ook nadat de app opnieuw uit zijn sjabloon gerenderd is. + +def images_per_service(compose_text: str) -> list[dict]: + """Per service het image, opgesplitst in repo en tag.""" + try: + doc = yaml.safe_load(compose_text) or {} + except yaml.YAMLError: + return [] + uit = [] + for naam, svc in (doc.get("services") or {}).items(): + if not isinstance(svc, dict): + continue + image = str(svc.get("image") or "") + if not image or "${" in image: + continue # variabele: daar gaan we niet over + # Een dubbele punt kan ook een poort in de registryhost zijn + # (registry.local:5000/app), dus alleen ná de laatste schuine streep. + laatste = image.rsplit("/", 1)[-1] + if ":" in laatste: + repo, tag = image.rsplit(":", 1) + else: + repo, tag = image, "latest" + uit.append({"service": naam, "image": image, "repo": repo, "tag": tag}) + return uit + + +def zet_image_tags(compose_text: str, tags: dict[str, str]) -> str: + """Zet per service een andere versietag op het image. + + Een lege of onveranderde tag laat de regel met rust; een service die niet + bestaat wordt overgeslagen, zodat een hernoemd sjabloon geen kapot + compose-bestand oplevert. + """ + gewenst = {s: str(t).strip() for s, t in (tags or {}).items() if str(t).strip()} + if not gewenst: + return compose_text + doc = _laad(compose_text) + diensten = doc.get("services") + if not isinstance(diensten, dict): + raise TransformError("compose bevat geen services") + + veranderd = False + for naam, tag in gewenst.items(): + svc = diensten.get(naam) + if not isinstance(svc, dict): + continue + image = str(svc.get("image") or "") + if not image or "${" in image: + continue + laatste = image.rsplit("/", 1)[-1] + repo = image.rsplit(":", 1)[0] if ":" in laatste else image + nieuw = f"{repo}:{tag}" + if nieuw != image: + svc["image"] = nieuw + veranderd = True + if not veranderd: + return compose_text + return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False, + allow_unicode=True, width=120) diff --git a/server-up/core/dbwachtwoord.py b/server-up/core/dbwachtwoord.py new file mode 100644 index 0000000..1a811a9 --- /dev/null +++ b/server-up/core/dbwachtwoord.py @@ -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, "" diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 5133cf7..82df4a4 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -147,7 +147,8 @@ def list_containers(alle: bool = True) -> list[dict]: """ fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" "\t{{.Label \"com.docker.compose.project\"}}" - "\t{{.Label \"com.docker.compose.project.working_dir\"}}") + "\t{{.Label \"com.docker.compose.project.working_dir\"}}" + "\t{{.Label \"com.docker.compose.service\"}}") cmd = [_bin(), "ps", "--format", fmt] if alle: cmd.insert(2, "-a") @@ -165,16 +166,31 @@ def list_containers(alle: bool = True) -> list[dict]: d = regel.split("\t") if len(d) < 4: continue - d += [""] * (8 - len(d)) + d += [""] * (9 - len(d)) uit.append({ "id": d[0], "name": d[1], "image": d[2], "state": d[3], "status": d[4], "ports": d[5], - "project": d[6], "working_dir": d[7], + "project": d[6], "working_dir": d[7], "service": d[8], "running": d[3].lower() in ("running", "up"), }) return sorted(uit, key=lambda c: c["name"]) +def containers_per_project(alle: bool = True) -> dict[str, list[dict]]: + """Alle containers, gegroepeerd op compose-project — in één aanroep. + + De stacklijst deed hiervoor een `docker compose ps` per stackmap: bij + vijftien apps vijftien processen per keer verversen, en nog eens zoveel elk + kwartier vanuit de achtergrondcontrole. Eén `docker ps` levert dezelfde + gegevens, want compose zet het projectlabel op elke container die het maakt. + """ + uit: dict[str, list[dict]] = {} + for c in list_containers(alle): + if c.get("project"): + uit.setdefault(c["project"], []).append(c) + return uit + + def container_action(name: str, action: str) -> tuple[bool, str]: """Start, stop of herstart één container. @@ -267,6 +283,9 @@ def compose_ps(d: Path, name=None) -> list[dict]: out.append({ "name": c.get("Name", ""), "service": c.get("Service", ""), + # Welke versie er nu daadwerkelijk draait; bij een sjabloon dat + # `latest` gebruikt is dit het enige wat dat nog verraadt. + "image": c.get("Image", ""), "state": c.get("State", ""), "status": c.get("Status", ""), "ports": _published_ports(c), @@ -397,6 +416,18 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]: pass +def container_draait(naam: str) -> bool: + """Draait deze container op dit moment? + + Nodig voordat er iets in een container uitgevoerd wordt: `docker exec` op + een gestopte container geeft een foutmelding die niets uitlegt. + """ + if not naam: + return False + r = _run([_bin(), "inspect", "-f", "{{.State.Running}}", naam], timeout=15) + return r.returncode == 0 and r.stdout.strip() == "true" + + def image_digest_local(image: str) -> str: """Digest van het lokaal aanwezige image, of "" als het er niet is. diff --git a/server-up/core/jobs.py b/server-up/core/jobs.py index e6660a2..70bd601 100644 --- a/server-up/core/jobs.py +++ b/server-up/core/jobs.py @@ -1,5 +1,5 @@ """Background job runner with log streaming.""" -import json, os, threading, time, uuid, queue +import contextlib, json, os, threading, time, uuid, queue from pathlib import Path _jobs: dict[str, dict] = {} @@ -193,3 +193,65 @@ def stream(jid: str, offset=0) -> dict: def run(fn, *args): threading.Thread(target=fn, args=args, daemon=True).start() + + +# ── Eén taak tegelijk per app ──────────────────────────────────────────────── +# Zonder dit kan de nachtelijke backup starten terwijl een instellingswijziging +# net `compose down` heeft gedaan en de bestanden herschrijft. Het archief bevat +# dan een halve stack, terwijl de melding erna 'Backup klaar' is. Twee keer op +# Bijwerken klikken levert hetzelfde op. + +_stack_sloten: dict[str, threading.Lock] = {} +_sloten_lock = threading.Lock() + + +def _stackslot(naam: str) -> threading.Lock: + with _sloten_lock: + return _stack_sloten.setdefault(naam, threading.Lock()) + + +def met_slot(naam: str, fn, wacht: float = 900.0): + """Laat `fn` pas draaien als er geen andere taak op deze app bezig is. + + Wikkelt de jobfunctie in plaats van hem te veranderen, zodat elke bestaande + taak dit met één regel krijgt. + """ + def _wikkel(jid, *args): + qq = get_queue(jid) + slot = _stackslot(naam) + if not slot.acquire(blocking=False): + log(qq, "warn", f"Er loopt al een taak voor '{naam}' — " + "deze wacht tot die klaar is.") + if not slot.acquire(timeout=wacht): + log(qq, "error", f"Er liep te lang een andere taak voor '{naam}'. " + "Deze taak is niet uitgevoerd.") + finish(jid, "error") + done(qq) + return + try: + fn(jid, *args) + except Exception as e: + # Een taak die klapt zonder eigen afhandeling bleef anders voor + # altijd op 'running' staan, en de interface bleef erop pollen. + log(qq, "error", f"{type(e).__name__}: {e}") + finish(jid, "error") + done(qq) + finally: + slot.release() + return _wikkel + + +@contextlib.contextmanager +def stack_slot(naam: str, wacht: float = 0.0): + """Hetzelfde slot, voor code die geen job is (de geplande backup). + + Levert False op als de app bezet is; de aanroeper beslist dan zelf of hij + overslaat of wacht. + """ + slot = _stackslot(naam) + gekregen = slot.acquire(timeout=wacht) if wacht else slot.acquire(blocking=False) + try: + yield gekregen + finally: + if gekregen: + slot.release() diff --git a/server-up/templates/index.html b/server-up/templates/index.html index e5b188b..8ec93f6 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -381,6 +381,9 @@ tailwind.config = { MEM NET + +
@@ -2018,6 +2021,24 @@ tailwind.config = { overschreven. -->