From ae99df2fc7de2f466856daf1809f65271a5d7a59 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 26 Jul 2026 19:54:51 +0200 Subject: [PATCH] v0.5.20-beta - complete backups en updates per app Backups (core/backups.py, core/scheduler.py): - Terugzetten met een klik: stack stoppen, huidige map opzij, uitpakken, starten. Mislukt het uitpakken, dan wordt de oude situatie teruggeplaatst. - Bewaarbeleid: aantal per stack en/of maximale leeftijd; de nieuwste backup van een stack blijft altijd staan. - Geplande backups (dagelijks/wekelijks) via een eigen planner in de app, geen cron. Een gemiste ronde loopt bij de eerstvolgende gelegenheid alsnog. - Automatisch een backup voor het bijwerken of verwijderen van een stack. Mislukt die, dan gaat de actie door - anders kun je een kapotte stack niet meer opruimen. - Uitpakken met tarfile + filter="data": absolute paden en ..-ingangen worden geweigerd. Met een kaal `tar xzf` kon een geprepareerd archief buiten de stackmap schrijven. - Twee bugs in de oude implementatie: de returncode van tar werd genegeerd (mislukte backup gold als succes) en backups binnen dezelfde seconde overschreven elkaar. Updates per app (core/stackupdates.py, core/registry.py): - Badge op de stackkaart als er een nieuwer image is; bijwerken doet de bestaande update-knop. - Vergelijking via de Registry API v2 (Docker-Content-Digest) in plaats van `docker manifest inspect`: dat laatste geeft per platform een aparte digest terwijl RepoDigests de manifest-list-digest bevat, wat bij elk multi-arch image permanent "update beschikbaar" zou opleveren. - Drie statussen: update / current / unknown. Lokaal gebouwd, nog niet gepulld of registry onbereikbaar geeft unknown en dus geen badge. - Dagelijkse achtergrondcheck, resultaten 6 uur gecached. Verder: - docs/backups.md, met nadruk op wat er niet in een backup zit: de Docker-volumes met de eigenlijke appdata. - Backup-instellingen onder Instellingen; backup-geschiedenis per stack. - 197 tests groen (40 nieuwe). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- CHANGELOG.md | 57 +++++++ VERSION | 2 +- docs/backups.md | 138 +++++++++++++++++ server-up/app.py | 187 +++++++++++++++++++++-- server-up/core/__init__.py | 12 ++ server-up/core/backups.py | 267 +++++++++++++++++++++++++++++++++ server-up/core/docker.py | 31 ++++ server-up/core/registry.py | 127 ++++++++++++++++ server-up/core/scheduler.py | 110 ++++++++++++++ server-up/core/stackupdates.py | 135 +++++++++++++++++ server-up/templates/index.html | 162 +++++++++++++++++++- server-up/translations/en.json | 34 +++-- server-up/translations/nl.json | 34 +++-- tests/test_backups.py | 186 +++++++++++++++++++++++ tests/test_stackupdates.py | 173 +++++++++++++++++++++ 15 files changed, 1623 insertions(+), 32 deletions(-) create mode 100644 docs/backups.md create mode 100644 server-up/core/backups.py create mode 100644 server-up/core/registry.py create mode 100644 server-up/core/scheduler.py create mode 100644 server-up/core/stackupdates.py create mode 100644 tests/test_backups.py create mode 100644 tests/test_stackupdates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9114399..175b7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,60 @@ +# v0.5.20-beta β€” Vertalingen, SSO-scherm, complete backups en app-updates + +## 🌍 Vertalingen en SSO + +- De schermen uit v0.5.x (login, Beveiliging, Netwerken, Updates, + containerpaneel) gebruikten harde Nederlandse teksten terwijl de rest van de + interface via `t()` loopt. Op Engels gaf dat een mengelmoes. Nu volledig + vertaald: 208 sleutels in `nl` en `en`. +- `t()` ondersteunt plaatshouders: `t('port_taken', {port: 8080})`. Zo blijven + zinnen heel in plaats van in losse stukjes geknipt. +- Nieuw instelscherm voor **reverse-proxy-SSO** onder Instellingen β†’ + Beveiliging: modus lokaal/proxy/beide, identiteitsheader en de vertrouwde + proxy-adressen. De backend bestond al maar was alleen met `curl` te bereiken. +- Nieuwe test bewaakt dat `nl` en `en` dezelfde sleutels houden, dat de + plaatshouders in beide talen gelijk zijn, en dat de interface geen sleutels + gebruikt die nergens gedefinieerd staan. + +## πŸ’Ύ Backups compleet + +Backup was één `tar`-commando waarvan de returncode genegeerd werd β€” een +mislukte backup gold als succes, en terugzetten kon alleen met de hand. + +- **Terugzetten** met één klik: stack stoppen, huidige map opzij, uitpakken, + starten. Mislukt het uitpakken, dan komt de oude situatie terug. +- **Bewaarbeleid**: aantal per stack en/of maximale leeftijd. De nieuwste + backup van een stack blijft altijd staan. +- **Geplande backups**, dagelijks of wekelijks, via een eigen planner in de + applicatie (geen cron). Een gemiste ronde loopt bij de eerstvolgende + gelegenheid alsnog. +- **Automatisch een backup vΓ³Γ³r het bijwerken of verwijderen** van een stack. + Mislukt die, dan gaat de actie door β€” anders kun je een kapotte stack niet + meer opruimen. +- Uitpakken gebeurt met `tarfile` en `filter="data"`: absolute paden en + `..`-ingangen worden geweigerd. Met een kaal `tar xzf` kon een geprepareerd + archief buiten de stackmap schrijven. +- Backups binnen dezelfde seconde overschreven elkaar; namen krijgen nu een + teller. +- `docs/backups.md` legt uit wat er wΓ©l en niet in zit β€” de compose- en + `.env`-bestanden, **niet** de Docker-volumes met de eigenlijke appdata. + +## πŸ“¦ Updates per app + +- Server Up controleert nu ook of er nieuwere images zijn voor de + geΓ―nstalleerde stacks, met een badge op de stackkaart en een knop om te + controleren. Bijwerken doet de bestaande update-knop. +- De vergelijking loopt via de Registry API v2 (`Docker-Content-Digest`), niet + via `docker manifest inspect`: dat laatste geeft per platform een aparte + digest terug terwijl `RepoDigests` de digest van de manifest-list bevat, wat + bij elk multi-arch image permanent "update beschikbaar" zou opleveren. +- Drie statussen per service β€” `update`, `current` en `unknown`. Een lokaal + gebouwd image, een nog niet gepulld image of een onbereikbare registry levert + `unknown` op en dus géén badge, zodat er nooit ten onrechte een update wordt + gemeld. +- Dagelijkse achtergrondcheck via dezelfde planner; resultaten 6 uur gecached. + +--- + # v0.5.10-beta β€” Update-systeem met kanalen en één-klik bijwerken ## πŸ”„ Kanalen diff --git a/VERSION b/VERSION index ce5c3c6..fc18b1a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.10-beta +0.5.20-beta diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..fc3ec95 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,138 @@ +# Backups + +Server Up maakt backups van je stacks: handmatig, automatisch vΓ³Γ³r risicovolle +acties, of op een schema. Terugzetten kan met één klik. + +--- + +## ⚠️ Lees dit eerst: wat zit er wΓ©l en niet in + +Een backup is een `.tar.gz` van de **stackmap** in je library. Daar zitten in: + +- het compose-bestand +- het `.env`-bestand (met je wachtwoorden en tokens) +- eventuele configuratiebestanden die naast de compose staan +- `.serverup.json` met de metadata van de installatie + +Wat er **niet** in zit: de Docker-volumes met de eigenlijke gegevens van de app. + +| Voorbeeld | In de backup? | +|---|---| +| De compose van Vaultwarden | βœ… | +| Je `ADMIN_TOKEN` uit `.env` | βœ… | +| De wachtwoordkluis van Vaultwarden zelf | ❌ | +| De database van Paperless | ❌ | +| Je foto's in Immich | ❌ | + +Je kunt hiermee dus een stack terugzetten zoals hij geconfigureerd stond, maar +niet de inhoud ervan herstellen. Wil je dat ook, dan zijn er twee routes: + +1. **Bind mounts in plaats van named volumes.** Staat je data in + `/opt/serverup/appdata/` en zit die map binnen je stackmap, dan gaat hij + wΓ©l mee. De meeste templates in `apps/` gebruiken `data_dir` daarvoor. +2. **Een aparte volume-backup** met bijvoorbeeld `restic` of `borg` naast Server + Up. Voor grote datasets is dat sowieso beter dan een tar per keer. + +> Omdat een backup je `.env` bevat, staan er wachtwoorden in. `BACKUP_DIR` hoort +> dus dezelfde bescherming te hebben als de rest van `/data`. + +--- + +## Een backup maken + +**Per stack** β€” de archiefknop op de stackkaart maakt er direct een. De +geschiedenisknop ernaast toont alle backups van die stack, met de mogelijkheid +om terug te zetten of te verwijderen. + +**Automatisch vΓ³Γ³r risicovolle acties** β€” standaard aan voor: + +- **bijwerken** van een stack (`compose pull` + `up`), want een nieuwe + image-versie kan een compose-wijziging nodig hebben; +- **verwijderen** van een stack, maar alleen als je "verwijder ook de bestanden" + kiest β€” bij het enkel stoppen van containers valt er niets te verliezen. + +Uit te zetten onder **Instellingen β†’ Backups**. Mislukt zo'n automatische +backup, dan gaat de actie alsnog door: anders zou je een kapotte stack niet meer +kunnen opruimen. De melding komt wel in het joblog. + +**Gepland** β€” dagelijks of wekelijks, rond een uur dat je zelf kiest. De +planner draait in de applicatie zelf (geen cron nodig) en onthoudt in +`config.json` wanneer hij voor het laatst gelopen heeft. Stond de server uit op +het geplande moment, dan loopt de backup bij de eerstvolgende gelegenheid alsnog. + +--- + +## Bewaarbeleid + +Twee regels, die allebei tegelijk gelden: + +| Instelling | Betekenis | +|---|---| +| `BACKUP_KEEP` | Hoeveel backups per stack je bewaart. `0` = onbeperkt. | +| `BACKUP_MAX_AGE_DAYS` | Alles ouder dan dit aantal dagen gaat weg. `0` = geen limiet. | + +**De nieuwste backup van een stack blijft altijd staan**, ook als hij ouder is +dan de maximale leeftijd. Anders sta je na een lange vakantie met lege handen. + +Opruimen gebeurt na elke nieuwe backup van die stack, en na een geplande ronde. + +--- + +## Terugzetten + +De geschiedenisknop op de stackkaart β†’ **Terugzetten**. Wat er dan gebeurt: + +``` +1. docker compose down stack stoppen +2. huidige map opzij zetten als .restore- +3. archief uitpakken +4. opzijgezette map weggooien +5. docker compose up -d stack starten +``` + +Gaat stap 3 mis β€” een beschadigd archief, of een archief dat buiten de stackmap +probeert te schrijven β€” dan wordt de opzijgezette map teruggeplaatst. Je raakt +je stack dus niet kwijt aan een kapotte backup. + +Een backup van stack A kun je niet over stack B heen zetten; dat wordt geweigerd +op basis van de metadata. + +### Veiligheid + +Archieven worden uitgepakt met Pythons `tarfile` en `filter="data"`. Dat weigert +absolute paden, `..`-ingangen, symlinks die buiten de map wijzen en +apparaatbestanden. Met een kaal `tar xzf` zou een geprepareerd archief bestanden +elders op de host kunnen neerzetten. + +--- + +## Instellingen + +| Sleutel | Standaard | Betekenis | +|---|---|---| +| `BACKUP_DIR` | `/opt/serverup/backups` | Waar de archieven komen | +| `BACKUP_KEEP` | `5` | Aantal per stack | +| `BACKUP_MAX_AGE_DAYS` | `0` | Maximale leeftijd in dagen | +| `BACKUP_SCHEDULE` | `off` | `off` / `daily` / `weekly` | +| `BACKUP_SCHEDULE_HOUR` | `3` | Rond welk uur de geplande ronde draait | +| `BACKUP_BEFORE_UPDATE` | `true` | Backup vΓ³Γ³r het bijwerken van een stack | +| `BACKUP_BEFORE_REMOVE` | `true` | Backup vΓ³Γ³r het verwijderen van bestanden | + +--- + +## Handmatig + +De archieven zijn gewone tarballs β€” je hebt Server Up niet nodig om erbij te +kunnen: + +```bash +# Bekijken wat erin zit +tar tzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz + +# Ergens anders uitpakken +tar xzf /opt/serverup/backups/vaultwarden_20260726_030000.tar.gz -C /tmp/herstel +``` + +Naast elk archief staat een `.json` met de stacknaam, het tijdstip, de grootte +en de reden (handmatig, gepland, voor-update, voor-remove). Ontbreekt dat +bestand, dan leidt Server Up de gegevens af uit de bestandsnaam. diff --git a/server-up/app.py b/server-up/app.py index 544be23..ef995b1 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -15,7 +15,8 @@ from flask import Flask, Response, jsonify, render_template, request, session import core as cfg from core import audit, jobs, i18n from core import auth, docker, git, boilerplates, updater -from core import compose_transform, networks, selfupdate +from core import backups, compose_transform, networks, scheduler, selfupdate +from core import stackupdates from core.modules import Module, CORE, discover app = Flask(__name__, static_folder="static", template_folder="templates") @@ -507,6 +508,23 @@ def api_stacks(): return jsonify(out) +def _auto_backup(qq, stack: str, config_key: str, reden: str): + """Backup vΓ³Γ³r een actie die bestanden overschrijft of weggooit. + + Faalt de backup, dan gaat de actie wΓ©l door β€” anders kun je een kapotte + stack niet meer opruimen. De melding komt wel in het joblog. + """ + if not cfg.load().get(config_key): + return + try: + meta = backups.create(stack, reden=reden, + log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok", f"Backup vooraf: {meta['file']}") + backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)) + except Exception as e: + jobs.log(qq, "warn", f"Backup vooraf mislukt ({e}) β€” actie gaat door") + + def _stack_dir(name: str) -> tuple[Path | None, Path | None]: """Resolve een stacknaam naar een map binnen LIBRARY_DIR. @@ -551,10 +569,13 @@ def api_stack_action(name, action): docker.compose_down(pdir, log_fn=lf, name=name) rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "update": + _auto_backup(qq, name, "BACKUP_BEFORE_UPDATE", "voor-update") docker.compose_pull(pdir, log_fn=lf) rc = docker.compose_up(pdir, log_fn=lf, name=name) elif action == "remove": mode = body.get("mode", "stack") + if mode in ("full", "all"): + _auto_backup(qq, name, "BACKUP_BEFORE_REMOVE", "voor-remove") if docker.has_compose(pdir): docker.compose_down(pdir, log_fn=lf, volumes=True, name=name) if mode in ("full", "all") and pdir.is_dir(): @@ -566,14 +587,12 @@ def api_stack_action(name, action): shutil.rmtree(dd) jobs.log(qq, "ok", "Data verwijderd") elif action == "backup": - bd = Path(cfg.load()["BACKUP_DIR"]).resolve() - bd.mkdir(parents=True, exist_ok=True) - ts = time.strftime("%Y%m%d_%H%M%S") - safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) - subprocess.run(["tar", "czf", str(bd / f"{safe_name}_{ts}.tar.gz"), - "-C", str(pdir.parent), name], - capture_output=True, timeout=300) - jobs.log(qq, "ok", f"Backup β†’ {bd / f'{safe_name}_{ts}.tar.gz'}") + # Ging eerder via een tar-subprocess waarvan de returncode + # genegeerd werd β€” een mislukte backup gold dan als succes. + meta = backups.create(name, reden="handmatig", + log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok", f"Backup β†’ {meta['file']} ({meta['size']} bytes)") + backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if rc == 0 else "error", f"{action} {'klaar' if rc == 0 else 'mislukt'}") audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name) @@ -1170,6 +1189,117 @@ def api_store_install(): return jsonify(ok=True, job_id=jid) +# ── Updates van geΓ―nstalleerde stacks ──────────────────────────────────────── + +@app.route("/api/stacks/updates") +def api_stack_updates(): + """Bekende updatestatus per stack. `?force=1` doet een verse check.""" + force = request.args.get("force") in ("1", "true", "yes") + if force: + return jsonify(ok=True, stacks=stackupdates.check_all(force=True)) + return jsonify(ok=True, stacks=stackupdates.cached()) + + +@app.route("/api/stacks/updates/check", methods=["POST"]) +def api_stack_updates_check(): + """Verse check als job, want dit doet een netwerkverzoek per image.""" + jid, q = jobs.create("stack-updates") + + def _run(jid): + qq = jobs.get_queue(jid) + jobs.log(qq, "section", "Updates controleren") + res = stackupdates.check_all(force=True, + log_fn=lambda m: jobs.log(qq, "dim", m)) + met_update = [n for n, r in res.items() if r.get("update_available")] + jobs.log(qq, "ok", f"{len(met_update)} van {len(res)} stacks heeft een update" + + (f": {', '.join(met_update)}" if met_update else "")) + jobs.finish(jid, "done") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + +# ── Backups ─────────────────────────────────────────────────────────────────── + +@app.route("/api/backups") +def api_backups(): + stack = request.args.get("stack", "").strip() + lijst = backups.list_for(stack) if stack else backups.list_all() + c = cfg.load() + return jsonify(backups=lijst, settings={ + k: c.get(k) for k in ("BACKUP_KEEP", "BACKUP_MAX_AGE_DAYS", + "BACKUP_SCHEDULE", "BACKUP_SCHEDULE_HOUR", + "BACKUP_BEFORE_UPDATE", "BACKUP_BEFORE_REMOVE")}) + + +@app.route("/api/backups//create", methods=["POST"]) +def api_backup_create(stack): + jid, q = jobs.create(f"backup:{stack}") + + def _run(jid): + qq = jobs.get_queue(jid) + try: + jobs.log(qq, "section", f"Backup: {stack}") + meta = backups.create(stack, reden="handmatig", + log_fn=lambda m: jobs.log(qq, "dim", m)) + for weg in backups.prune(stack, log_fn=lambda m: jobs.log(qq, "dim", m)): + pass + jobs.log(qq, "ok", f"Backup klaar ({meta['file']})") + audit.log("backups", "create", "ok", ref=stack, ip=request.remote_addr) + jobs.finish(jid, "done") + except backups.BackupError as e: + jobs.log(qq, "error", str(e)) + audit.log("backups", "create", "error", ref=stack, detail=str(e)) + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + +@app.route("/api/backups//restore", methods=["POST"]) +def api_backup_restore(stack): + bestand = ((request.json or {}).get("file") or "").strip() + if not bestand: + return jsonify(ok=False, msg="Geen backup opgegeven"), 400 + try: + backups._archive_path(bestand) # vroege validatie + except backups.BackupError as e: + return jsonify(ok=False, msg=str(e)), 400 + + jid, q = jobs.create(f"restore:{stack}") + + def _run(jid): + qq = jobs.get_queue(jid) + try: + jobs.log(qq, "section", f"Terugzetten: {stack}") + ok, msg = backups.restore(stack, bestand, + log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok" if ok else "error", msg) + audit.log("backups", "restore", "ok" if ok else "error", ref=stack, + detail=bestand) + jobs.finish(jid, "done" if ok else "error") + except backups.BackupError as e: + jobs.log(qq, "error", str(e)) + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + +@app.route("/api/backups/", methods=["DELETE"]) +def api_backup_delete(bestand): + try: + ok, msg = backups.delete(bestand) + except backups.BackupError as e: + return jsonify(ok=False, msg=str(e)), 400 + if ok: + audit.log("backups", "delete", "ok", ref=bestand, ip=request.remote_addr) + return jsonify(ok=ok, msg=msg), (200 if ok else 404) + + # ── Netwerken (eigen IP-adres per stack) ───────────────────────────────────── def _network_by_name(name: str) -> dict | None: @@ -1981,6 +2111,41 @@ def _apply_boot_repos(): audit.log("core", "boot_repo", "ok", ref=r.get("id"), detail=r.get("url")) +def _geplande_backups(): + """Backup van alle stacks, aangestuurd door de scheduler.""" + c = cfg.load() + plan = (c.get("BACKUP_SCHEDULE") or "off").lower() + if plan not in ("daily", "weekly"): + return + if not scheduler.binnen_venster(int(c.get("BACKUP_SCHEDULE_HOUR") or 3)): + return + lib = Path(c["LIBRARY_DIR"]).resolve() + if not lib.is_dir(): + return + for d in sorted(lib.iterdir()): + 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}") + print(f" [backup] {meta['file']}") + audit.log("backups", "scheduled", "ok", ref=d.name) + except Exception as e: + print(f" [backup] {d.name} mislukt: {e}") + audit.log("backups", "scheduled", "error", ref=d.name, detail=str(e)) + backups.prune() + + +def _registreer_taken(): + """Periodieke taken aanmelden bij de scheduler.""" + c = cfg.load() + # Dagelijks/wekelijks; de venstercheck in _geplande_backups bepaalt het uur. + uren = 24.0 if (c.get("BACKUP_SCHEDULE") or "off") != "weekly" else 24.0 * 7 + scheduler.register("backups", _geplande_backups, interval_uren=uren) + scheduler.register("stack_updates", + lambda: stackupdates.check_all(force=True), + interval_uren=24.0, enabled_key="STACK_UPDATE_CHECK") + + def _boot_sync(): """Sync alle geconfigureerde repos bij elke start (achtergrond-thread). @@ -2033,6 +2198,10 @@ if __name__ == "__main__": if auth.needs_setup(): print("\n⚠ Nog geen account β€” open de webinterface en maak er één aan.") print(" Tot dat moment kan iedereen die deze poort bereikt het account claimen.\n") + print("Geplande taken:") + _registreer_taken() + scheduler.start() + print(" backups + updatecheck geregistreerd") print("Boot sync (achtergrond):") jobs.run(_boot_sync) print(f"\nβ†’ http://localhost:{port}\n") diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index 76d16e0..a9c8a90 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -56,6 +56,18 @@ DEFAULTS = { "AUTO_SYNC_ON_BOOT": False, # Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen. "NETWORKS": [], + # Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup + # van een stack wordt nooit opgeruimd. + "BACKUP_KEEP": 5, + "BACKUP_MAX_AGE_DAYS": 0, + "BACKUP_SCHEDULE": "off", # off | daily | weekly + "BACKUP_SCHEDULE_HOUR": 3, + "BACKUP_BEFORE_UPDATE": True, + "BACKUP_BEFORE_REMOVE": True, + # Laatste uitvoering van geplande taken (beheerd door core.scheduler). + "SCHEDULER_LAST": {}, + # Updatecheck voor de geΓ―nstalleerde stacks (image-digests). + "STACK_UPDATE_CHECK": True, } # Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat diff --git a/server-up/core/backups.py b/server-up/core/backups.py new file mode 100644 index 0000000..35f68b1 --- /dev/null +++ b/server-up/core/backups.py @@ -0,0 +1,267 @@ +"""Backups van stacks: maken, tonen, terugzetten en opruimen. + +Een backup is een `.tar.gz` van de stackmap plus een `.json` met metadata +ernaast. Dat betekent: **de compose- en `.env`-bestanden zitten erin, de +Docker-volumes met de eigenlijke appdata niet.** Zie docs/backups.md β€” dat +verschil is het belangrijkste dat een gebruiker moet weten. + +Terugzetten gebeurt met Pythons `tarfile` en `filter="data"` (PEP 706): dat +weigert absolute paden en `..` in archiefingangen. Met een kaal `tar xzf` kan +een geprepareerd archief buiten de stackmap schrijven. +""" +from __future__ import annotations + +import json +import re +import shutil +import tarfile +import time +from pathlib import Path + +import core as cfg +from core import docker + +SUFFIX = ".tar.gz" +META_SUFFIX = ".json" + +# __[-n].tar.gz β€” ruimer dan cfg.safe_name() omdat de +# tijdstempel er nog achter komt, maar even streng op padtekens. +_BACKUP_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}_\d{8}_\d{6}(-\d+)?$") + + +class BackupError(Exception): + pass + + +# ── Paden ──────────────────────────────────────────────────────────────────── + +def backup_dir() -> Path: + return Path(cfg.load()["BACKUP_DIR"]).resolve() + + +def _archive_path(bestandsnaam: str) -> Path: + """Valideer een backupnaam en geef het volledige pad. + + De naam komt uit een request, dus hij mag nooit uit BACKUP_DIR wijzen. + """ + naam = (bestandsnaam or "").strip() + if not naam.endswith(SUFFIX): + raise BackupError("Geen geldige backupnaam.") + kern = naam[: -len(SUFFIX)] + if not _BACKUP_RE.match(kern): + raise BackupError("Geen geldige backupnaam.") + bd = backup_dir() + pad = (bd / naam).resolve() + if not pad.is_relative_to(bd): + raise BackupError("Geen geldige backupnaam.") + return pad + + +def _stack_dir(stack: str) -> Path: + if not cfg.safe_name(stack): + raise BackupError("Ongeldige stacknaam.") + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + d = (lib / stack).resolve() + if not d.is_relative_to(lib) or d == lib: + raise BackupError("Ongeldige stacknaam.") + return d + + +# ── Maken ──────────────────────────────────────────────────────────────────── + +def create(stack: str, reden: str = "handmatig", log_fn=None) -> dict: + """Maak een backup van de stackmap. Retourneert de metadata.""" + bron = _stack_dir(stack) + if not bron.is_dir(): + raise BackupError(f"Stack '{stack}' bestaat niet.") + bd = backup_dir() + bd.mkdir(parents=True, exist_ok=True) + + ts = time.strftime("%Y%m%d_%H%M%S") + naam = f"{stack}_{ts}{SUFFIX}" + doel = bd / naam + # Twee backups binnen dezelfde seconde (bv. handmatig vlak na een + # automatische backup vΓ³Γ³r een update) zouden elkaar overschrijven. + teller = 2 + while doel.exists(): + naam = f"{stack}_{ts}-{teller}{SUFFIX}" + doel = bd / naam + teller += 1 + if log_fn: + log_fn(f"Backup maken van {stack}…") + + try: + # Python's tarfile in plaats van het tar-commando: de returncode van dat + # commando werd eerder genegeerd, zodat een mislukte backup als succes + # gold. + with tarfile.open(doel, "w:gz") as tar: + tar.add(bron, arcname=stack) + except Exception as e: + doel.unlink(missing_ok=True) + raise BackupError(f"Backup mislukt: {e}") from e + + meta = {"stack": stack, "file": naam, "ts": time.time(), + "size": doel.stat().st_size, "reason": reden} + doel.with_suffix("").with_suffix(META_SUFFIX).write_text( + json.dumps(meta, indent=2), encoding="utf-8") + if log_fn: + log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})") + return meta + + +def _leesbaar(bytes_: int) -> str: + eenheid = ["B", "KB", "MB", "GB"] + n = float(bytes_) + for e in eenheid: + if n < 1024 or e == eenheid[-1]: + return f"{n:.0f} {e}" if e == "B" else f"{n:.1f} {e}" + n /= 1024 + return f"{n:.1f} GB" + + +# ── Tonen ──────────────────────────────────────────────────────────────────── + +def _meta_voor(archief: Path) -> dict: + mf = archief.with_suffix("").with_suffix(META_SUFFIX) + meta = {} + if mf.exists(): + try: + meta = json.loads(mf.read_text(encoding="utf-8")) + except Exception: + meta = {} + st = archief.stat() + # Ontbreekt de metadata (backup van vΓ³Γ³r deze versie), leid af uit de naam. + meta.setdefault("file", archief.name) + meta.setdefault("stack", archief.name.rsplit("_", 2)[0]) + meta.setdefault("ts", st.st_mtime) + meta.setdefault("reason", "") + meta["size"] = st.st_size + meta["size_human"] = _leesbaar(st.st_size) + return meta + + +def list_all() -> list[dict]: + bd = backup_dir() + if not bd.is_dir(): + return [] + uit = [] + for p in bd.iterdir(): + if p.is_file() and p.name.endswith(SUFFIX): + try: + uit.append(_meta_voor(p)) + except Exception: + continue + return sorted(uit, key=lambda m: m["ts"], reverse=True) + + +def list_for(stack: str) -> list[dict]: + return [m for m in list_all() if m.get("stack") == stack] + + +# ── Terugzetten ────────────────────────────────────────────────────────────── + +def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]: + """Zet een backup terug: stack stoppen, uitpakken, weer starten. + + De huidige map wordt eerst opzijgezet. Mislukt het uitpakken, dan komt de + oude situatie terug β€” je raakt dus nooit alles kwijt door een kapot archief. + """ + archief = _archive_path(bestandsnaam) + if not archief.is_file(): + return False, "Backup niet gevonden." + doel = _stack_dir(stack) + + meta = _meta_voor(archief) + if meta.get("stack") and meta["stack"] != stack: + return False, (f"Deze backup hoort bij '{meta['stack']}', niet bij " + f"'{stack}'.") + + if doel.is_dir() and docker.has_compose(doel): + if log_fn: + log_fn("Stack stoppen…") + docker.compose_down(doel, log_fn=log_fn, name=stack) + + opzij = doel.with_name(f"{doel.name}.restore-{int(time.time())}") + verplaatst = False + if doel.exists(): + doel.rename(opzij) + verplaatst = True + if log_fn: + log_fn(f"Huidige map opzijgezet als {opzij.name}") + + try: + with tarfile.open(archief, "r:gz") as tar: + # filter="data" weigert absolute paden, '..' en apparaatbestanden. + tar.extractall(doel.parent, filter="data") + if not doel.is_dir(): + raise BackupError( + "Het archief bevatte geen map met de naam van de stack.") + except Exception as e: + # Terug naar de oude situatie. + if doel.exists(): + shutil.rmtree(doel, ignore_errors=True) + if verplaatst: + opzij.rename(doel) + if log_fn: + log_fn("Oude situatie teruggezet.") + return False, f"Terugzetten mislukt: {e}" + + if verplaatst: + shutil.rmtree(opzij, ignore_errors=True) + + if log_fn: + log_fn("Bestanden teruggezet β€” stack starten…") + rc = docker.compose_up(doel, log_fn=log_fn, name=stack) + if rc != 0: + return False, ("Bestanden zijn teruggezet, maar de stack startte niet. " + "Bekijk de logs.") + return True, f"'{stack}' teruggezet uit {bestandsnaam}." + + +# ── Opruimen ───────────────────────────────────────────────────────────────── + +def delete(bestandsnaam: str) -> tuple[bool, str]: + archief = _archive_path(bestandsnaam) + if not archief.is_file(): + return False, "Backup niet gevonden." + archief.unlink() + archief.with_suffix("").with_suffix(META_SUFFIX).unlink(missing_ok=True) + return True, "Backup verwijderd." + + +def prune(stack: str | None = None, log_fn=None) -> list[str]: + """Pas het retentiebeleid toe. Retourneert de verwijderde bestandsnamen. + + Twee regels, die allebei mogen gelden: hoogstens BACKUP_KEEP backups per + stack, en niets ouder dan BACKUP_MAX_AGE_DAYS. + """ + c = cfg.load() + keep = int(c.get("BACKUP_KEEP") or 0) + max_age = int(c.get("BACKUP_MAX_AGE_DAYS") or 0) + if keep <= 0 and max_age <= 0: + return [] + + verwijderd: list[str] = [] + per_stack: dict[str, list[dict]] = {} + for m in list_all(): + if stack and m.get("stack") != stack: + continue + per_stack.setdefault(m.get("stack", "?"), []).append(m) + + grens = time.time() - max_age * 86400 if max_age > 0 else None + for naam, lijst in per_stack.items(): + lijst.sort(key=lambda m: m["ts"], reverse=True) # nieuwste eerst + for i, m in enumerate(lijst): + te_veel = keep > 0 and i >= keep + te_oud = grens is not None and m["ts"] < grens + # De nieuwste backup van een stack blijft altijd staan, ook als hij + # te oud is β€” anders houd je na een vakantie niets over. + if (te_veel or te_oud) and i > 0: + try: + delete(m["file"]) + verwijderd.append(m["file"]) + if log_fn: + log_fn(f"Opgeruimd: {m['file']}") + except Exception: + continue + return verwijderd diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 588353d..4c87416 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -325,6 +325,37 @@ def validate_compose_text(content: str, d: Path) -> tuple[bool, str]: pass +def image_digest_local(image: str) -> str: + """Digest van het lokaal aanwezige image, of "" als het er niet is. + + Een lokaal gebouwd image heeft geen RepoDigest β€” dan valt er niets met een + registry te vergelijken. + """ + r = _run([_bin(), "image", "inspect", image, + "--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}"], + timeout=15) + if r.returncode != 0: + return "" + uit = r.stdout.strip() + return uit.split("@", 1)[1] if "@" in uit else "" + + +def image_digest_remote(image: str) -> str: + """Digest van het image in de registry, of "" als dat niet te bepalen is. + + Vraagt de registry rechtstreeks (Registry API v2) en leest de + `Docker-Content-Digest`-header. Dat is precies de waarde die ook in + `RepoDigests` van een gepulld image staat, dus de twee zijn vergelijkbaar. + + `docker manifest inspect --verbose` is hiervoor nΓ­Γ©t bruikbaar: dat geeft + per platform een aparte descriptor-digest terug, terwijl RepoDigests de + digest van de manifest-list bevat. Die vergelijken zou bij elk multi-arch + image permanent "update beschikbaar" opleveren. + """ + from core import registry + return registry.manifest_digest(image) + + def used_ports() -> set[int]: r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10) ports = set() diff --git a/server-up/core/registry.py b/server-up/core/registry.py new file mode 100644 index 0000000..4a25fd3 --- /dev/null +++ b/server-up/core/registry.py @@ -0,0 +1,127 @@ +"""Registry API v2 β€” de manifest-digest van een image opvragen (stdlib only). + +Wordt gebruikt om te bepalen of er een nieuwer image is voor een geΓ―nstalleerde +stack. De `Docker-Content-Digest`-header die de registry teruggeeft is dezelfde +waarde die Docker in `RepoDigests` zet na een pull, dus die twee zijn direct +vergelijkbaar. + +Ondersteunt anonieme toegang (Docker Hub, ghcr, publieke Forgejo-registries) en +Bearer-token-auth via de `WWW-Authenticate`-uitdaging. +""" +from __future__ import annotations + +import base64 +import json +import re +import urllib.error +import urllib.parse +import urllib.request + +DOCKER_HUB = "registry-1.docker.io" + +# Accept-header met alle manifest-types, zodat de registry de manifest-list +# teruggeeft in plaats van een enkel platform-manifest. +_ACCEPT = ", ".join([ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +]) + +_TIMEOUT = 10 + + +def parse_image(image: str) -> tuple[str, str, str]: + """Splits een image-referentie in (host, repository, tag). + + nginx β†’ registry-1.docker.io, library/nginx, latest + bes-r/app:1.2 β†’ registry-1.docker.io, bes-r/app, 1.2 + git.example.com:3000/a/b:dev β†’ git.example.com:3000, a/b, dev + """ + ref = (image or "").strip() + if not ref: + return "", "", "" + # Digest-referenties hebben geen tag om op te vragen. + if "@" in ref: + ref = ref.split("@", 1)[0] + + tag = "latest" + delen = ref.split("/") + eerste = delen[0] + # Een host herken je aan een punt, een dubbele punt of 'localhost'. + heeft_host = len(delen) > 1 and ("." in eerste or ":" in eerste or eerste == "localhost") + host = eerste if heeft_host else DOCKER_HUB + rest = "/".join(delen[1:]) if heeft_host else ref + + if ":" in rest.split("/")[-1]: + rest, tag = rest.rsplit(":", 1) + if host == DOCKER_HUB and "/" not in rest: + rest = f"library/{rest}" + return host, rest, tag + + +def _scheme(host: str) -> str: + """Lokale registries draaien vaak zonder TLS.""" + if host.startswith("localhost") or host.startswith("127."): + return "http" + return "https" + + +def _auth_header(creds: tuple[str, str] | None) -> dict: + if not creds: + return {} + blob = base64.b64encode(f"{creds[0]}:{creds[1]}".encode()).decode() + return {"Authorization": f"Basic {blob}"} + + +def _bearer_token(uitdaging: str, creds: tuple[str, str] | None) -> str: + """Haal een token op met de gegevens uit een WWW-Authenticate-header.""" + velden = dict(re.findall(r'(\w+)="([^"]*)"', uitdaging or "")) + realm = velden.get("realm") + if not realm: + return "" + params = {k: v for k, v in velden.items() if k in ("service", "scope") and v} + url = f"{realm}?{urllib.parse.urlencode(params)}" if params else realm + req = urllib.request.Request(url, headers=_auth_header(creds)) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: + data = json.loads(r.read().decode("utf-8")) + except Exception: + return "" + return data.get("token") or data.get("access_token") or "" + + +def manifest_digest(image: str, creds: tuple[str, str] | None = None) -> str: + """Digest van de manifest(-list) voor deze image-tag, of "" bij twijfel. + + Retourneert bewust "" in plaats van te gokken: de aanroeper toont dan + "onbekend" en niet ten onrechte "update beschikbaar". + """ + host, repo, tag = parse_image(image) + if not repo: + return "" + url = f"{_scheme(host)}://{host}/v2/{repo}/manifests/{urllib.parse.quote(tag)}" + + def _vraag(extra: dict) -> tuple[str, str]: + """Retourneert (digest, www_authenticate).""" + req = urllib.request.Request(url, method="HEAD", + headers={"Accept": _ACCEPT, **extra}) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: + return r.headers.get("Docker-Content-Digest", ""), "" + except urllib.error.HTTPError as e: + if e.code == 401: + return "", e.headers.get("WWW-Authenticate", "") + return "", "" + except Exception: + return "", "" + + digest, uitdaging = _vraag(_auth_header(creds)) + if digest: + return digest + if uitdaging: + token = _bearer_token(uitdaging, creds) + if token: + digest, _ = _vraag({"Authorization": f"Bearer {token}"}) + return digest + return "" diff --git a/server-up/core/scheduler.py b/server-up/core/scheduler.py new file mode 100644 index 0000000..33167d0 --- /dev/null +++ b/server-up/core/scheduler.py @@ -0,0 +1,110 @@ +"""Periodieke taken in één achtergrondthread. + +Geen cron en geen extra dependency: één daemon-thread die elke paar minuten +kijkt of er iets te doen is. De laatste uitvoering staat in `config.json`, dus +een herstart laat de planning niet opnieuw beginnen β€” en een taak die door een +herstart gemist is, loopt bij de eerstvolgende tick alsnog. + +Taken registreren zich met `register()`; `start()` zet de lus in gang. +""" +from __future__ import annotations + +import threading +import time +import traceback +from datetime import datetime + +import core as cfg + +TICK_SECONDS = 300 # elke 5 minuten kijken of er iets moet +_TAKEN: dict[str, dict] = {} +_LOCK = threading.Lock() +_gestart = False + + +def register(naam: str, fn, interval_uren: float = 24.0, enabled_key: str = ""): + """Registreer een periodieke taak. + + `enabled_key` verwijst naar een config-sleutel; staat die op iets falsy, dan + wordt de taak overgeslagen (maar blijft hij geregistreerd). + """ + with _LOCK: + _TAKEN[naam] = {"fn": fn, "interval": max(0.05, float(interval_uren)), + "enabled_key": enabled_key} + + +def _laatste() -> dict: + d = cfg.load().get("SCHEDULER_LAST") or {} + return d if isinstance(d, dict) else {} + + +def _markeer(naam: str): + d = dict(_laatste()) + d[naam] = time.time() + cfg.patch({"SCHEDULER_LAST": d}) + + +def due(naam: str, taak: dict, nu: float | None = None) -> bool: + """Moet deze taak nu draaien?""" + nu = nu if nu is not None else time.time() + if taak.get("enabled_key") and not cfg.load().get(taak["enabled_key"]): + return False + laatste = _laatste().get(naam) + if not laatste: + return True + return (nu - float(laatste)) >= taak["interval"] * 3600 + + +def run_due(log_fn=None) -> list[str]: + """Draai alle taken die aan de beurt zijn. Retourneert de namen.""" + gedraaid = [] + with _LOCK: + items = list(_TAKEN.items()) + for naam, taak in items: + try: + if not due(naam, taak): + continue + if log_fn: + log_fn(f"[scheduler] {naam}") + taak["fn"]() + _markeer(naam) + gedraaid.append(naam) + except Exception: + # EΓ©n kapotte taak mag de lus niet stoppen. + print(f" [scheduler] {naam} faalde:\n{traceback.format_exc()}") + _markeer(naam) # niet blijven herhalen in een lus + return gedraaid + + +def _lus(): + # Even wachten tot de app volledig op is. + time.sleep(30) + while True: + try: + run_due() + except Exception: + print(f" [scheduler] tick faalde:\n{traceback.format_exc()}") + time.sleep(TICK_SECONDS) + + +def start(): + """Start de achtergrondlus (eenmalig).""" + global _gestart + if _gestart: + return + _gestart = True + threading.Thread(target=_lus, daemon=True, name="su-scheduler").start() + + +# ── Hulp voor dagelijkse taken op een vast tijdstip ────────────────────────── + +def binnen_venster(uur: int, marge_minuten: int = 30, nu: datetime | None = None) -> bool: + """True als de huidige tijd binnen `marge` minuten nΓ‘ `uur`:00 ligt. + + Voor 'draai dit rond 03:00'. De tick is 5 minuten, dus een venster van een + half uur vangt ruim af β€” combineer met de interval-check zodat een taak niet + meerdere keren in hetzelfde venster draait. + """ + nu = nu or datetime.now() + minuten_na = (nu.hour - uur) * 60 + nu.minute + return 0 <= minuten_na < marge_minuten diff --git a/server-up/core/stackupdates.py b/server-up/core/stackupdates.py new file mode 100644 index 0000000..ecc5864 --- /dev/null +++ b/server-up/core/stackupdates.py @@ -0,0 +1,135 @@ +"""Updatecheck voor de geΓ―nstalleerde stacks. + +Per stack worden de images uit het compose-bestand gehaald en vergeleken: +de lokaal aanwezige digest (`RepoDigests`) tegen die in de registry +(`Docker-Content-Digest`). Zijn ze verschillend, dan is er een nieuwer image. + +Drie statussen per service, zodat de UI nooit ten onrechte een update meldt: + +* `update` β€” beide digests bekend en verschillend +* `current` β€” beide bekend en gelijk +* `unknown` β€” lokaal gebouwd image, registry onbereikbaar, of image nog niet + gepulld. Wordt niet als update getoond. +""" +from __future__ import annotations + +import threading +import time + +import yaml + +import core as cfg +from core import docker + +_CACHE: dict[str, dict] = {} +_CACHE_TS: float = 0.0 +_CACHE_TTL = 6 * 3600 +_LOCK = threading.Lock() + + +def _images_uit_compose(stack_dir) -> list[tuple[str, str]]: + """[(servicenaam, image), …] uit het compose-bestand van deze stack.""" + cf = docker.find_compose(stack_dir) + if not cf: + return [] + try: + doc = yaml.safe_load(cf.read_text(encoding="utf-8")) or {} + except Exception: + return [] + uit = [] + for naam, svc in (doc.get("services") or {}).items(): + if not isinstance(svc, dict): + continue + img = svc.get("image") + # Niet-ingevulde variabelen (${TAG}) kunnen we niet opzoeken. + if isinstance(img, str) and img and "${" not in img: + uit.append((naam, img)) + return uit + + +def _registry_creds(image: str) -> tuple[str, str] | None: + """Inloggegevens hergebruiken als het image uit dezelfde registry komt als + waar Server Up zelf vandaan wordt bijgewerkt.""" + c = cfg.load() + gebruiker = (c.get("UPDATE_REGISTRY_USER") or "").strip() + token = (c.get("UPDATE_REGISTRY_TOKEN") or "").strip() + if not gebruiker or not token: + return None + eigen = (c.get("UPDATE_IMAGE") or "").strip() + if not eigen: + return None + from core import registry + host_eigen, _, _ = registry.parse_image(eigen) + host_img, _, _ = registry.parse_image(image) + return (gebruiker, token) if host_eigen and host_eigen == host_img else None + + +def check_stack(stack: str, stack_dir) -> dict: + """Vergelijk alle images van één stack. Doet netwerkverzoeken.""" + diensten = [] + for service, image in _images_uit_compose(stack_dir): + lokaal = docker.image_digest_local(image) + if not lokaal: + # Nooit gepulld of lokaal gebouwd β€” niets om mee te vergelijken. + diensten.append({"service": service, "image": image, + "status": "unknown", "reason": "geen lokale digest"}) + continue + from core import registry + extern = registry.manifest_digest(image, _registry_creds(image)) + if not extern: + diensten.append({"service": service, "image": image, + "status": "unknown", "reason": "registry onbereikbaar"}) + continue + diensten.append({"service": service, "image": image, + "status": "update" if extern != lokaal else "current", + "local": lokaal, "remote": extern}) + return { + "stack": stack, + "services": diensten, + "update_available": any(d["status"] == "update" for d in diensten), + "checked_at": time.time(), + } + + +def check_all(force: bool = False, log_fn=None) -> dict[str, dict]: + """Alle stacks langs. Resultaat wordt gecached (TTL 6 uur).""" + global _CACHE, _CACHE_TS + with _LOCK: + if not force and _CACHE and (time.time() - _CACHE_TS) < _CACHE_TTL: + return dict(_CACHE) + + from pathlib import Path + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + resultaat: dict[str, dict] = {} + if lib.is_dir(): + for d in sorted(lib.iterdir()): + if not d.is_dir() or d.name.startswith(".") or not docker.has_compose(d): + continue + if log_fn: + log_fn(f"Controleren: {d.name}") + try: + resultaat[d.name] = check_stack(d.name, d) + except Exception as e: + # EΓ©n onbereikbare registry mag de rest niet blokkeren. + resultaat[d.name] = {"stack": d.name, "services": [], + "update_available": False, + "error": str(e)[:200], + "checked_at": time.time()} + + with _LOCK: + _CACHE = dict(resultaat) + _CACHE_TS = time.time() + return resultaat + + +def cached() -> dict[str, dict]: + """Wat we weten zonder netwerkverzoeken te doen.""" + with _LOCK: + return dict(_CACHE) + + +def invalidate(): + global _CACHE, _CACHE_TS + with _LOCK: + _CACHE = {} + _CACHE_TS = 0.0 diff --git a/server-up/templates/index.html b/server-up/templates/index.html index ca0a179..1ae3679 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -243,6 +243,10 @@ tailwind.config = { +