v0.5.20-beta - complete backups en updates per app
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 6s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-26 19:54:51 +02:00
parent fe7abce95b
commit ae99df2fc7
15 changed files with 1623 additions and 32 deletions

View file

@ -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

View file

@ -1 +1 @@
0.5.10-beta
0.5.20-beta

138
docs/backups.md Normal file
View file

@ -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/<app>` 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 <stack>.restore-<tijdstempel>
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.

View file

@ -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/<stack>/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/<stack>/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/<path:bestand>", 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")

View file

@ -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

267
server-up/core/backups.py Normal file
View file

@ -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"
# <stack>_<JJJJMMDD>_<UUMMSS>[-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

View file

@ -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()

127
server-up/core/registry.py Normal file
View file

@ -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 ""

110
server-up/core/scheduler.py Normal file
View file

@ -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

View file

@ -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

View file

@ -243,6 +243,10 @@ tailwind.config = {
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
</div>
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
<button @click="checkStackUpdates()" class="btn" :disabled="checkingUpd">
<span class="mdi" :class="checkingUpd ? 'mdi-loading mdi-spin' : 'mdi-package-up'"></span>
<span x-text="t('check_updates')">Updates controleren</span>
</button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
<template x-for="s in filteredStacks" :key="s.name">
@ -254,7 +258,10 @@ tailwind.config = {
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
:title="'Eigen IP op netwerk ' + s.network" x-text="s.ip"></span>
:title="t('own_ip') + ': ' + s.network" x-text="s.ip"></span>
<span x-show="stackUpd[s.name] && stackUpd[s.name].update_available"
class="badge badge-ok" :title="stackUpdTitle(s.name)"
x-text="t('update_available')">update</span>
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
</div>
<div class="p-4 flex flex-col gap-3 flex-1">
@ -311,6 +318,7 @@ tailwind.config = {
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
<button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</div>
@ -529,6 +537,54 @@ tailwind.config = {
</div>
</div>
<!-- Backups: bewaarbeleid en planning -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-archive-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('backups')">Backups</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('backup_keep')">Aantal per stack</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_KEEP" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_max_age')">Maximale leeftijd</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_MAX_AGE_DAYS" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_schedule')">Geplande backups</label>
<select class="form-input" x-model="settings.BACKUP_SCHEDULE" @change="autoSave()">
<option value="off" x-text="t('backup_schedule_off')">Uit</option>
<option value="daily" x-text="t('backup_schedule_daily')">Dagelijks</option>
<option value="weekly" x-text="t('backup_schedule_weekly')">Wekelijks</option>
</select>
</div>
<div x-show="settings.BACKUP_SCHEDULE && settings.BACKUP_SCHEDULE !== 'off'">
<label class="form-label" x-text="t('backup_schedule_hour')">Rond welk uur</label>
<input type="number" min="0" max="23" class="form-input" x-model.number="settings.BACKUP_SCHEDULE_HOUR" @change="autoSave()">
</div>
</div>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_UPDATE" @change="settings.BACKUP_BEFORE_UPDATE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_update')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_REMOVE" @change="settings.BACKUP_BEFORE_REMOVE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_remove')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.STACK_UPDATE_CHECK" @change="settings.STACK_UPDATE_CHECK=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
</label>
</div>
</div>
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
@ -758,6 +814,50 @@ tailwind.config = {
</div>
</div>
<!-- Backups van één stack -->
<div x-show="modal==='backups'" class="modal max-w-xl flex flex-col max-h-[85vh]">
<div class="modal-head">
<span class="mdi mdi-history text-ac dark:text-ac-dark"></span>
<span x-text="t('backups')">Backups</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="bkStack"></span>
<button class="btn-sm ml-auto" @click="createBackup(bkStack)">
<span class="mdi mdi-archive-arrow-down-outline"></span> <span x-text="t('backup')">Backup</span>
</button>
</div>
<div class="modal-body overflow-y-auto">
<p class="text-[11px] text-wr mb-3 flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="space-y-2">
<template x-for="b in bkList" :key="b.file">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
<div class="min-w-0 flex-1">
<div class="text-xs font-mono text-tx dark:text-tx-dark truncate" x-text="b.file"></div>
<div class="text-[10px] text-dm dark:text-dm-dark">
<span x-text="fmtTime(b.ts)"></span> · <span x-text="b.size_human"></span>
<span x-show="b.reason"> · <span x-text="b.reason"></span></span>
</div>
</div>
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
</button>
<button class="btn-sm !h-8 !px-2 btn-danger" @click="deleteBackup(b.file)">
<span class="mdi mdi-delete-outline"></span>
</button>
</div>
</template>
</div>
<div x-show="bkList.length === 0" class="empty !py-8">
<span class="mdi mdi-archive-outline"></span><p x-text="t('no_backups')">Nog geen backups</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
</div>
</div>
<!-- Netwerk toevoegen -->
<div x-show="modal==='add-network'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
@ -1093,6 +1193,10 @@ function app() {
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
cpanel: {},
// Backups en updatestatus per stack
bkStack: '', bkList: [], bkSettings: {},
stackUpd: {}, checkingUpd: false,
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
netList: [], netIfaces: [],
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
@ -1166,7 +1270,7 @@ function app() {
this.loadUpdate();
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
this.$watch('page', async (p) => {
if (p === 'stacks') this.loadStacks();
if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); }
if (p === 'app_store') this.loadAppStore();
if (p === 'docker') this.loadImages();
if (p === 'audit') this.loadAudit();
@ -1421,6 +1525,60 @@ function app() {
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast(d.msg || 'Fout bij opslaan', 'er');
},
// ── Backups ──────────────────────────────────────────────────────────────
async openBackups(stack) {
this.bkStack = stack;
this.bkList = [];
this.modal = 'backups';
await this.loadBackups(stack);
},
async loadBackups(stack) {
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
this.bkList = d.backups || [];
this.bkSettings = d.settings || {};
},
async createBackup(stack) {
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.pollJob(d.job_id, () => this.loadBackups(stack));
},
async restoreBackup(stack, file) {
if (!confirm(this.t('restore_confirm', {file}))) return;
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/restore`, {file});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
this.termOpen = true;
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast(this.t('restore'), 'ok'); });
},
async deleteBackup(file) {
if (!confirm(this.t('delete_backup_confirm'))) return;
const d = await this.req('/api/backups/' + encodeURIComponent(file), { method:'DELETE' });
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadBackups(this.bkStack);
},
// ── Updates van geïnstalleerde stacks ────────────────────────────────────
async loadStackUpdates() {
const d = await this.rpc('/api/stacks/updates');
this.stackUpd = d.stacks || {};
},
async checkStackUpdates() {
this.checkingUpd = true;
const d = await this.rpc('/api/stacks/updates/check', {});
if (!d.job_id) { this.checkingUpd = false; this.toast(d.msg || this.t('error'), 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, async () => {
this.checkingUpd = false;
await this.loadStackUpdates();
});
},
stackUpdTitle(naam) {
const r = this.stackUpd[naam];
if (!r) return '';
return (r.services || []).filter(s => s.status === 'update')
.map(s => s.service + ': ' + s.image).join('\n');
},
// ── Netwerken ────────────────────────────────────────────────────────────
async loadNetworks() {
const d = await this.rpc('/api/networks');

View file

@ -1,5 +1,9 @@
{
"_meta": {"code": "en", "name": "English", "flag": "🇬🇧"},
"_meta": {
"code": "en",
"name": "English",
"flag": "🇬🇧"
},
"dashboard": "Dashboard",
"stacks": "Stacks",
"app_store": "App Store",
@ -116,7 +120,6 @@
"update_api_url": "Releases API URL",
"update_api_hint": "Forgejo/GitHub Releases API returning the latest release. Empty = disabled.",
"include_prerelease": "Include pre-releases (beta)",
"login": "Sign in",
"login_failed": "Sign-in failed",
"logout": "Sign out",
@ -129,7 +132,6 @@
"password_min": "Password (min. 10 characters)",
"password_repeat": "Repeat password",
"passwords_differ": "The passwords do not match.",
"security": "Security",
"logged_in_as": "signed in as {user}",
"users": "Users",
@ -140,7 +142,6 @@
"new_password": "New password",
"new_password_repeat": "Repeat new password",
"change": "Change",
"auth_mode": "How do people sign in?",
"auth_mode_local": "Local login",
"auth_mode_proxy": "Reverse proxy",
@ -151,8 +152,6 @@
"trusted_proxies": "Trusted proxy addresses",
"trusted_proxies_hint": "One per line, IP or CIDR. The header is only trusted from these addresses — without this list anyone could impersonate an administrator.",
"proxy_lockout_confirm": "In 'Reverse proxy' mode the local login is disabled. Are you sure your proxy works?",
"save": "Save",
"networks": "Networks",
"networks_hint": "A macvlan or ipvlan network gives a stack its own IP address on your LAN instead of ports on the host. No more port conflicts, and you can firewall per app. Note: the Docker host itself cannot reach macvlan containers without a shim interface.",
"add_network": "Add network",
@ -171,11 +170,8 @@
"ip_address": "IP address",
"own_ip_hint": "The stack gets this address on your LAN. Port mappings are dropped — the app is reachable on its own port. The Docker host itself cannot reach a macvlan container without a shim interface (see docs/netwerken.md).",
"no_networks_hint": "Want to give stacks their own IP? Add a network first under Settings → Networks.",
"loading": "Loading…",
"generate_value": "Generate a random value",
"port_taken": "Port {port} is already in use — {suggested} suggested.",
"channel": "Channel",
"channel_stable_hint": "Stable releases only.",
"channel_beta_hint": "Also pre-releases (e.g. v0.5.10-beta1). Useful for testing, less predictable.",
@ -197,5 +193,23 @@
"rollback_confirm": "Roll back to {tag}?",
"restarting": "Server Up is restarting…",
"updated_to": "Updated to v{version}",
"restart_slow": "Restart is taking a while — please refresh the page"
"restart_slow": "Restart is taking a while — please refresh the page",
"backups": "Backups",
"no_backups": "No backups yet",
"restore": "Restore",
"restore_confirm": "Restore backup {file}?\n\nThe current files of this stack will be replaced and the stack restarted.",
"delete_backup_confirm": "Delete this backup?",
"backup_scope_warning": "A backup contains the stack's compose and .env files, not the Docker volumes holding the actual app data. See docs/backups.md.",
"backup_retention": "Retention",
"backup_keep": "Backups to keep per stack",
"backup_max_age": "Maximum age (days, 0 = no limit)",
"backup_schedule": "Scheduled backups",
"backup_schedule_off": "Off",
"backup_schedule_daily": "Daily",
"backup_schedule_weekly": "Weekly",
"backup_schedule_hour": "Around which hour",
"backup_before_update": "Automatically back up before updating a stack",
"backup_before_remove": "Automatically back up before removing a stack",
"check_updates": "Check for updates",
"stack_update_check": "Check daily whether newer images are available"
}

View file

@ -1,5 +1,9 @@
{
"_meta": {"code": "nl", "name": "Nederlands", "flag": "🇳🇱"},
"_meta": {
"code": "nl",
"name": "Nederlands",
"flag": "🇳🇱"
},
"dashboard": "Dashboard",
"stacks": "Stacks",
"app_store": "App Store",
@ -116,7 +120,6 @@
"update_api_url": "Releases-API URL",
"update_api_hint": "Forgejo/GitHub Releases-API die de laatste release teruggeeft. Leeg = uitgeschakeld.",
"include_prerelease": "Pre-releases (beta) meenemen",
"login": "Inloggen",
"login_failed": "Inloggen mislukt",
"logout": "Uitloggen",
@ -129,7 +132,6 @@
"password_min": "Wachtwoord (min. 10 tekens)",
"password_repeat": "Wachtwoord herhalen",
"passwords_differ": "De wachtwoorden komen niet overeen.",
"security": "Beveiliging",
"logged_in_as": "ingelogd als {user}",
"users": "Gebruikers",
@ -140,7 +142,6 @@
"new_password": "Nieuw wachtwoord",
"new_password_repeat": "Nieuw wachtwoord herhalen",
"change": "Wijzigen",
"auth_mode": "Hoe wordt er ingelogd?",
"auth_mode_local": "Lokale login",
"auth_mode_proxy": "Reverse proxy",
@ -151,8 +152,6 @@
"trusted_proxies": "Vertrouwde proxy-adressen",
"trusted_proxies_hint": "Eén per regel, IP of CIDR. Alleen vanaf deze adressen wordt de header vertrouwd — zonder deze lijst kan iedereen zich voordoen als beheerder.",
"proxy_lockout_confirm": "In modus 'Reverse proxy' vervalt de lokale login. Weet je zeker dat je proxy werkt?",
"save": "Opslaan",
"networks": "Netwerken",
"networks_hint": "Met een macvlan- of ipvlan-netwerk krijgt een stack een eigen IP-adres in je LAN in plaats van poorten op de host. Geen poortconflicten meer, en je kunt per app firewallen. Let op: de Docker-host zelf bereikt macvlan-containers niet zonder shim-interface.",
"add_network": "Netwerk toevoegen",
@ -171,11 +170,8 @@
"ip_address": "IP-adres",
"own_ip_hint": "De stack krijgt dit adres in het LAN. Poortmappings vervallen — de app is bereikbaar op zijn eigen poort. De Docker-host zelf kan een macvlan-container niet bereiken zonder shim-interface (zie docs/netwerken.md).",
"no_networks_hint": "Wil je stacks een eigen IP geven? Voeg eerst een netwerk toe bij Instellingen → Netwerken.",
"loading": "Laden…",
"generate_value": "Genereer een willekeurige waarde",
"port_taken": "Poort {port} is al in gebruik — {suggested} voorgesteld.",
"channel": "Kanaal",
"channel_stable_hint": "Alleen stabiele releases.",
"channel_beta_hint": "Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.",
@ -197,5 +193,23 @@
"rollback_confirm": "Terugrollen naar {tag}?",
"restarting": "Server Up herstart…",
"updated_to": "Bijgewerkt naar v{version}",
"restart_slow": "Herstart duurt lang — ververs de pagina zelf"
"restart_slow": "Herstart duurt lang — ververs de pagina zelf",
"backups": "Backups",
"no_backups": "Nog geen backups",
"restore": "Terugzetten",
"restore_confirm": "Backup {file} terugzetten?\n\nDe huidige bestanden van deze stack worden vervangen en de stack wordt herstart.",
"delete_backup_confirm": "Deze backup verwijderen?",
"backup_scope_warning": "Een backup bevat de compose- en .env-bestanden van de stack, niet de Docker-volumes met de eigenlijke appdata. Zie docs/backups.md.",
"backup_retention": "Bewaarbeleid",
"backup_keep": "Aantal backups per stack",
"backup_max_age": "Maximale leeftijd (dagen, 0 = geen limiet)",
"backup_schedule": "Geplande backups",
"backup_schedule_off": "Uit",
"backup_schedule_daily": "Dagelijks",
"backup_schedule_weekly": "Wekelijks",
"backup_schedule_hour": "Rond welk uur",
"backup_before_update": "Automatisch een backup vóór het bijwerken van een stack",
"backup_before_remove": "Automatisch een backup vóór het verwijderen van een stack",
"check_updates": "Updates controleren",
"stack_update_check": "Dagelijks controleren of er nieuwere images zijn"
}

186
tests/test_backups.py Normal file
View file

@ -0,0 +1,186 @@
"""Backups: maken, tonen, terugzetten, retentie — en de veiligheid daarvan."""
import io
import json
import tarfile
import time
from pathlib import Path
import pytest
from conftest import login
@pytest.fixture
def bk(env, monkeypatch):
from core import backups, docker
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
# Geen echte docker in de tests: stack stoppen/starten is een no-op.
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
return backups
def _maak_stack(env, naam="vaultwarden", inhoud="services:\n app:\n image: nginx\n"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(inhoud, encoding="utf-8")
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
return d
def test_maken_en_tonen(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
assert meta["stack"] == "vaultwarden"
assert meta["size"] > 0
lijst = bk.list_for("vaultwarden")
assert len(lijst) == 1
assert lijst[0]["file"] == meta["file"]
assert lijst[0]["size_human"]
def test_maken_van_onbekende_stack(bk):
with pytest.raises(bk.BackupError):
bk.create("bestaatniet")
def test_terugzetten_herstelt_gewijzigd_bestand(bk, env):
d = _maak_stack(env)
meta = bk.create("vaultwarden")
(d / "docker-compose.yml").write_text("KAPOT", encoding="utf-8")
ok, msg = bk.restore("vaultwarden", meta["file"])
assert ok, msg
assert "image: nginx" in (d / "docker-compose.yml").read_text()
assert (d / ".env").read_text() == "PORT=8222\n"
def test_terugzetten_van_backup_van_andere_stack(bk, env):
_maak_stack(env, "app-a")
_maak_stack(env, "app-b")
meta = bk.create("app-a")
ok, msg = bk.restore("app-b", meta["file"])
assert ok is False
assert "hoort bij 'app-a'" in msg
@pytest.mark.parametrize("naam", [
"../../etc/passwd.tar.gz", "/etc/shadow.tar.gz", "geen-tijdstempel.tar.gz",
"vaultwarden_20260101_120000.txt", "", "..tar.gz",
])
def test_ongeldige_backupnaam(bk, naam):
with pytest.raises(bk.BackupError):
bk._archive_path(naam)
def test_terugzetten_weigert_archief_dat_buiten_de_map_schrijft(bk, env):
"""Een geprepareerd archief met '..' mag niets buiten de stackmap raken.
Met een kaal `tar xzf` zou dit bestand op de host belanden; tarfile met
filter='data' weigert de ingang.
"""
_maak_stack(env)
bd = Path(bk.backup_dir())
bd.mkdir(parents=True, exist_ok=True)
boos = bd / "vaultwarden_20260101_120000.tar.gz"
with tarfile.open(boos, "w:gz") as tar:
data = b"ontsnapt"
info = tarfile.TarInfo("../../ontsnapt.txt")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
ok, msg = bk.restore("vaultwarden", boos.name)
assert ok is False
assert not (env["tmp"] / "ontsnapt.txt").exists()
assert not (env["lib"].parent / "ontsnapt.txt").exists()
def test_mislukte_restore_zet_de_oude_map_terug(bk, env):
"""Een kapot archief mag je niet zonder stack achterlaten."""
d = _maak_stack(env)
bd = Path(bk.backup_dir())
bd.mkdir(parents=True, exist_ok=True)
stuk = bd / "vaultwarden_20260101_120000.tar.gz"
stuk.write_bytes(b"dit is geen gzip")
ok, _ = bk.restore("vaultwarden", stuk.name)
assert ok is False
assert d.is_dir()
assert "image: nginx" in (d / "docker-compose.yml").read_text()
def test_verwijderen(bk, env):
_maak_stack(env)
meta = bk.create("vaultwarden")
ok, _ = bk.delete(meta["file"])
assert ok
assert bk.list_for("vaultwarden") == []
def test_retentie_houdt_er_hoogstens_keep(bk, env):
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 3})
metas = []
for i in range(5):
m = bk.create("vaultwarden")
# Tijdstempels uit elkaar trekken; de naam heeft secondeprecisie.
m["ts"] = time.time() - (5 - i)
_herschrijf_ts(bk, m)
metas.append(m)
bk.prune("vaultwarden")
assert len(bk.list_for("vaultwarden")) == 3
def test_retentie_bewaart_altijd_de_nieuwste(bk, env):
"""Ook als alles te oud is, blijft er één over."""
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 1})
for i in range(3):
m = bk.create("vaultwarden")
m["ts"] = time.time() - 30 * 86400
_herschrijf_ts(bk, m)
bk.prune("vaultwarden")
assert len(bk.list_for("vaultwarden")) == 1
def test_retentie_uit_doet_niets(bk, env):
_maak_stack(env)
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 0})
for _ in range(3):
bk.create("vaultwarden")
assert bk.prune("vaultwarden") == []
def _herschrijf_ts(bk, meta):
"""Zet de tijdstempel in het metadata-bestand terug in de tijd."""
p = Path(bk.backup_dir()) / meta["file"]
mf = p.with_suffix("").with_suffix(".json")
d = json.loads(mf.read_text())
d["ts"] = meta["ts"]
mf.write_text(json.dumps(d))
# ── API ──────────────────────────────────────────────────────────────────────
def test_endpoint_lijst(client, env, bk):
login(client)
_maak_stack(env)
bk.create("vaultwarden")
d = client.get("/api/backups").get_json()
assert len(d["backups"]) == 1
assert "BACKUP_KEEP" in d["settings"]
def test_endpoint_restore_weigert_ongeldige_naam(client, bk):
csrf = login(client)
r = client.post("/api/backups/vaultwarden/restore",
json={"file": "../../etc/passwd.tar.gz"},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_endpoint_restore_zonder_bestand(client, bk):
csrf = login(client)
r = client.post("/api/backups/vaultwarden/restore", json={},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400

173
tests/test_stackupdates.py Normal file
View file

@ -0,0 +1,173 @@
"""Updates per app: image-parsing, digest-vergelijking en randgevallen."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import registry
# ── Image-referenties ────────────────────────────────────────────────────────
@pytest.mark.parametrize("ref,verwacht", [
("nginx", ("registry-1.docker.io", "library/nginx", "latest")),
("nginx:1.27", ("registry-1.docker.io", "library/nginx", "1.27")),
("bes-r/app:2.0", ("registry-1.docker.io", "bes-r/app", "2.0")),
("ghcr.io/org/app", ("ghcr.io", "org/app", "latest")),
("ghcr.io/org/app:dev", ("ghcr.io", "org/app", "dev")),
("git.example.com:3000/bes-r/server-up:0.5.10",
("git.example.com:3000", "bes-r/server-up", "0.5.10")),
("localhost:5000/mijn/app", ("localhost:5000", "mijn/app", "latest")),
("vaultwarden/server:latest", ("registry-1.docker.io", "vaultwarden/server", "latest")),
])
def test_parse_image(ref, verwacht):
assert registry.parse_image(ref) == verwacht
def test_parse_image_negeert_digest():
host, repo, tag = registry.parse_image("nginx@sha256:" + "a" * 64)
assert (host, repo) == ("registry-1.docker.io", "library/nginx")
def test_parse_image_leeg():
assert registry.parse_image("") == ("", "", "")
def test_lokale_registry_gebruikt_http():
assert registry._scheme("localhost:5000") == "http"
assert registry._scheme("127.0.0.1:5000") == "http"
assert registry._scheme("git.example.com") == "https"
# ── Digest-vergelijking ──────────────────────────────────────────────────────
@pytest.fixture
def su(env):
from core import stackupdates
stackupdates.invalidate()
return stackupdates
@pytest.fixture
def reg(env):
"""Het registry-module-object ná de herimport door de env-fixture."""
from core import registry as verse_registry
return verse_registry
def _stack(env, naam="web", compose="services:\n app:\n image: nginx:1.27\n"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(compose, encoding="utf-8")
return d
def test_update_gedetecteerd(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:nieuw")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is True
assert res["services"][0]["status"] == "update"
def test_geen_update_bij_gelijke_digest(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:zelfde")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:zelfde")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "current"
def test_lokaal_gebouwd_image_is_onbekend_geen_update(su, reg, env, monkeypatch):
"""Zonder RepoDigest valt er niets te vergelijken — dat mag geen update
melden, anders staat er permanent een badge."""
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "unknown"
def test_onbereikbare_registry_is_onbekend_geen_update(su, reg, env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "")
res = su.check_stack("web", _stack(env))
assert res["update_available"] is False
assert res["services"][0]["status"] == "unknown"
assert "registry" in res["services"][0]["reason"]
def test_variabele_in_image_wordt_overgeslagen(su, env):
d = _stack(env, compose="services:\n app:\n image: nginx:${TAG}\n")
assert su._images_uit_compose(d) == []
def test_meerdere_services(su, reg, env, monkeypatch):
from core import docker
compose = ("services:\n"
" web:\n image: nginx:1.27\n"
" db:\n image: postgres:16\n")
monkeypatch.setattr(docker, "image_digest_local", lambda i: "sha256:oud")
monkeypatch.setattr(reg, "manifest_digest",
lambda i, c=None: "sha256:nieuw" if "nginx" in i else "sha256:oud")
res = su.check_stack("web", _stack(env, compose=compose))
statussen = {d["service"]: d["status"] for d in res["services"]}
assert statussen == {"web": "update", "db": "current"}
assert res["update_available"] is True
def test_check_all_cachet(su, reg, env, monkeypatch):
from core import docker
_stack(env)
tellers = {"n": 0}
def _tel(image):
tellers["n"] += 1
return "sha256:oud"
monkeypatch.setattr(docker, "image_digest_local", _tel)
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:oud")
su.check_all(force=True)
n_na_eerste = tellers["n"]
su.check_all()
assert tellers["n"] == n_na_eerste
def test_kapotte_stack_blokkeert_de_rest_niet(su, reg, env, monkeypatch):
from core import docker
_stack(env, "goed")
_stack(env, "stuk")
def _ontploft(image):
raise RuntimeError("registry weg")
monkeypatch.setattr(docker, "image_digest_local",
lambda i: (_ontploft(i) if False else "sha256:x"))
monkeypatch.setattr(reg, "manifest_digest", lambda i, c=None: "sha256:x")
res = su.check_all(force=True)
assert set(res) == {"goed", "stuk"}
# ── Endpoints ────────────────────────────────────────────────────────────────
def test_endpoint_updates_botst_niet_met_stack_routes(client, su):
"""/api/stacks/updates mag niet als stacknaam 'updates' gelezen worden."""
from conftest import login
login(client)
r = client.get("/api/stacks/updates")
assert r.status_code == 200
assert "stacks" in r.get_json()
def test_endpoint_credentials_alleen_bij_zelfde_registry(su, env):
env["core"].patch({"UPDATE_REGISTRY_USER": "bes-r",
"UPDATE_REGISTRY_TOKEN": "geheim",
"UPDATE_IMAGE": "git.example.com/bes-r/server-up"})
assert su._registry_creds("git.example.com/bes-r/iets") == ("bes-r", "geheim")
assert su._registry_creds("nginx:1.27") is None