v0.5.20-beta - complete backups en updates per app
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 6s
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:
parent
fe7abce95b
commit
ae99df2fc7
15 changed files with 1623 additions and 32 deletions
57
CHANGELOG.md
57
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.5.10-beta
|
||||
0.5.20-beta
|
||||
|
|
|
|||
138
docs/backups.md
Normal file
138
docs/backups.md
Normal 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.
|
||||
187
server-up/app.py
187
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/<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")
|
||||
|
|
|
|||
|
|
@ -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
267
server-up/core/backups.py
Normal 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
|
||||
|
|
@ -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
127
server-up/core/registry.py
Normal 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
110
server-up/core/scheduler.py
Normal 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
|
||||
135
server-up/core/stackupdates.py
Normal file
135
server-up/core/stackupdates.py
Normal 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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
186
tests/test_backups.py
Normal 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
173
tests/test_stackupdates.py
Normal 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
|
||||
Loading…
Reference in a new issue