server-up/tests/test_stackupdates.py
Ramon ae99df2fc7
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 6s
v0.5.20-beta - complete backups en updates per app
Backups (core/backups.py, core/scheduler.py):
- Terugzetten met een klik: stack stoppen, huidige map opzij, uitpakken,
  starten. Mislukt het uitpakken, dan wordt de oude situatie teruggeplaatst.
- Bewaarbeleid: aantal per stack en/of maximale leeftijd; de nieuwste backup
  van een stack blijft altijd staan.
- Geplande backups (dagelijks/wekelijks) via een eigen planner in de app, geen
  cron. Een gemiste ronde loopt bij de eerstvolgende gelegenheid alsnog.
- Automatisch een backup voor het bijwerken of verwijderen van een stack.
  Mislukt die, dan gaat de actie door - anders kun je een kapotte stack niet
  meer opruimen.
- Uitpakken met tarfile + filter="data": absolute paden en ..-ingangen worden
  geweigerd. Met een kaal `tar xzf` kon een geprepareerd archief buiten de
  stackmap schrijven.
- Twee bugs in de oude implementatie: de returncode van tar werd genegeerd
  (mislukte backup gold als succes) en backups binnen dezelfde seconde
  overschreven elkaar.

Updates per app (core/stackupdates.py, core/registry.py):
- Badge op de stackkaart als er een nieuwer image is; bijwerken doet de
  bestaande update-knop.
- Vergelijking via de Registry API v2 (Docker-Content-Digest) in plaats van
  `docker manifest inspect`: dat laatste geeft per platform een aparte digest
  terwijl RepoDigests de manifest-list-digest bevat, wat bij elk multi-arch
  image permanent "update beschikbaar" zou opleveren.
- Drie statussen: update / current / unknown. Lokaal gebouwd, nog niet gepulld
  of registry onbereikbaar geeft unknown en dus geen badge.
- Dagelijkse achtergrondcheck, resultaten 6 uur gecached.

Verder:
- docs/backups.md, met nadruk op wat er niet in een backup zit: de
  Docker-volumes met de eigenlijke appdata.
- Backup-instellingen onder Instellingen; backup-geschiedenis per stack.
- 197 tests groen (40 nieuwe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 19:54:51 +02:00

173 lines
6.7 KiB
Python

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