All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
Vier verbeteringen die elk pas opvielen als je ze nodig had. - Een gewijzigd databasewachtwoord wordt vóór de herstart omgezet in de nog draaiende database (ALTER USER / updateUser). Lukt dat niet, dan breekt de wijziging af en gaat de backup terug — 64 sjablonen hebben zo'n veld, en POSTGRES_PASSWORD werkt alleen bij een lege datamap. Wachtwoorden gaan via de omgeving en via stdin, niet via argumenten. - jobs.met_slot serialiseert taken per app; de geplande backup slaat een bezette app over in plaats van een half herschreven map in te pakken. Een taak die klapt laat het slot los en wordt als mislukt afgesloten. - api_stacks en api_connectable gebruiken één docker ps met het projectlabel in plaats van een compose ps per stackmap. - Per service een versietag te kiezen, bewaard in .serverup.json en opnieuw toegepast na het renderen; het containerpaneel toont het draaiende image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
432 lines
19 KiB
Python
432 lines
19 KiB
Python
"""Instellingen wijzigen na installatie, koppelen, en het audit-filter."""
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
WORTEL = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(WORTEL / "server-up"))
|
|
|
|
from conftest import login
|
|
|
|
|
|
def _wacht_op_job(client, jid, tijdslimiet=10):
|
|
"""Installeren gebeurt in een achtergrondthread; wachten tot hij klaar is."""
|
|
einde = time.time() + tijdslimiet
|
|
while time.time() < einde:
|
|
d = client.get(f"/api/jobs/{jid}").get_json() or {}
|
|
if d.get("status") in ("done", "error"):
|
|
return d
|
|
time.sleep(0.05)
|
|
return {}
|
|
|
|
|
|
def _stack(env, naam="web", meta=None, compose=None):
|
|
d = env["lib"] / naam
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "docker-compose.yml").write_text(
|
|
compose or "services:\n web:\n image: nginx\n ports:\n - \"8080:80\"\n")
|
|
if meta is not None:
|
|
(d / ".serverup.json").write_text(json.dumps(meta), encoding="utf-8")
|
|
return d
|
|
|
|
|
|
# ── Herconfigureren ──────────────────────────────────────────────────────────
|
|
|
|
def test_config_zonder_opgeslagen_waarden(client, env):
|
|
"""Stacks van vóór deze versie hebben geen keuzes bewaard; dan moet de
|
|
interface uitleggen waarom de knop niet werkt in plaats van te falen."""
|
|
login(client)
|
|
_stack(env, "oud", {"source": "oud"})
|
|
d = client.get("/api/stacks/oud/config").get_json()
|
|
assert d["ok"] is False and d["editable"] is False
|
|
assert "compose bewerken" in d["msg"]
|
|
|
|
|
|
def test_config_van_onbekende_stack(client):
|
|
login(client)
|
|
assert client.get("/api/stacks/bestaatniet/config").status_code == 404
|
|
|
|
|
|
def test_stacklijst_meldt_of_wijzigen_kan(client, env):
|
|
login(client)
|
|
_stack(env, "nieuw", {"source": "x", "values": {"port": 80}})
|
|
_stack(env, "oud", {"source": "x"})
|
|
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
|
|
assert per_naam["nieuw"]["editable"] is True
|
|
assert per_naam["oud"]["editable"] is False
|
|
|
|
|
|
def test_reconfigure_zonder_bronsjabloon(client, env):
|
|
csrf = login(client)
|
|
_stack(env, "web", {"source": "bestaatniet", "repo_id": "x", "values": {"a": 1}})
|
|
r = client.post("/api/stacks/web/reconfigure", json={"values": {"a": 2}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
assert "Bronsjabloon" in r.get_json()["msg"]
|
|
|
|
|
|
def test_reconfigure_van_onbekende_stack(client):
|
|
csrf = login(client)
|
|
r = client.post("/api/stacks/bestaatniet/reconfigure", json={"values": {}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_installatie_bewaart_de_gemaakte_keuzes(client, env, monkeypatch):
|
|
"""Zonder opgeslagen waarden valt het formulier later niet te heropenen.
|
|
|
|
Was een controle op een letterlijke regel broncode; die brak zodra de regel
|
|
verplaatste terwijl het gedrag klopte.
|
|
"""
|
|
import json as _json
|
|
from core import git
|
|
csrf = login(client)
|
|
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
|
|
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
|
(wortel / "template.json").write_text(_json.dumps({
|
|
"kind": "compose", "metadata": {"name": "keuzeapp"},
|
|
"variables": [{"title": "Algemeen", "items": [
|
|
{"name": "service_name", "type": "str", "title": "Naam",
|
|
"default": "keuzeapp", "required": True},
|
|
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
|
|
]}]}), encoding="utf-8")
|
|
(wortel / "files" / "compose.yaml").write_text(
|
|
'services:\n << service_name >>:\n image: nginx\n'
|
|
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
|
|
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "keuzeapp", "repo_id": "server-up",
|
|
"instance": "kz", "values": {"port": 9123}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
_wacht_op_job(client, r.get_json()["job_id"])
|
|
|
|
meta = _json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
|
|
assert meta["values"]["port"] == 9123
|
|
assert meta["source"] == "keuzeapp"
|
|
|
|
|
|
def _installeer_keuzeapp(client, csrf, poort=9123):
|
|
"""Zet een sjabloon in de git-cache en installeer het als stack 'kz'."""
|
|
import json as _json
|
|
from core import git
|
|
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
|
|
(wortel / "files").mkdir(parents=True, exist_ok=True)
|
|
(wortel / "template.json").write_text(_json.dumps({
|
|
"kind": "compose", "metadata": {"name": "keuzeapp"},
|
|
"variables": [{"title": "Algemeen", "items": [
|
|
{"name": "service_name", "type": "str", "title": "Naam",
|
|
"default": "keuzeapp", "required": True},
|
|
{"name": "port", "type": "int", "title": "Poort", "default": 8099},
|
|
]}]}), encoding="utf-8")
|
|
(wortel / "files" / "compose.yaml").write_text(
|
|
'services:\n << service_name >>:\n image: nginx\n'
|
|
' ports:\n - "<< port >>:80"\n', encoding="utf-8")
|
|
|
|
r = client.post("/api/store/install",
|
|
json={"stack": "keuzeapp", "repo_id": "server-up",
|
|
"instance": "kz", "values": {"port": poort}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
_wacht_op_job(client, r.get_json()["job_id"])
|
|
|
|
|
|
def test_geslaagde_wijziging_wordt_niet_teruggedraaid(client, env, monkeypatch):
|
|
"""De job draait in een eigen thread, waar `request` niet meer bestaat.
|
|
|
|
Het adres voor het audit-logboek werd daar alsnog uitgelezen: een
|
|
RuntimeError precies nádat de stack met de nieuwe instellingen al draaide.
|
|
De foutafhandeling zette vervolgens de backup terug, dus een geslaagde
|
|
wijziging werd alsnog ongedaan gemaakt.
|
|
"""
|
|
import app as app_module
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
|
|
monkeypatch.setattr(app_module.docker, "validate_compose",
|
|
lambda *a, **k: (True, ""))
|
|
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.backups, "create",
|
|
lambda *a, **k: {"file": "kz_test.tar.gz"})
|
|
monkeypatch.setattr(app_module.backups, "restore",
|
|
lambda *a, **k: pytest.fail("backup werd teruggezet"))
|
|
|
|
r = client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": 9124},
|
|
"connect_apps": False},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
d = _wacht_op_job(client, r.get_json()["job_id"])
|
|
|
|
tekst = "\n".join(r["text"] for r in d.get("lines", []))
|
|
assert "RuntimeError" not in tekst, tekst
|
|
assert d.get("status") == "done", tekst
|
|
compose = next((p for p in (env["lib"] / "kz").iterdir()
|
|
if p.name in ("compose.yaml", "compose.yml",
|
|
"docker-compose.yml", "docker-compose.yaml")))
|
|
assert '"9124:80"' in compose.read_text()
|
|
|
|
|
|
def test_wijziging_wordt_vastgelegd_met_het_adres_van_de_aanvrager(
|
|
client, env, monkeypatch):
|
|
"""Het IP-adres hoort in het audit-logboek te belanden, niet verloren te
|
|
gaan doordat we het pas in de achtergrondthread opvragen."""
|
|
import app as app_module
|
|
from core import audit
|
|
audit.init()
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
|
|
monkeypatch.setattr(app_module.docker, "validate_compose",
|
|
lambda *a, **k: (True, ""))
|
|
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.backups, "create",
|
|
lambda *a, **k: {"file": "kz_test.tar.gz"})
|
|
|
|
r = client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": 9125},
|
|
"connect_apps": False},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht_op_job(client, r.get_json()["job_id"])
|
|
|
|
regels = [x for x in audit.query(50, src="stacks")
|
|
if x["action"] == "reconfigure"]
|
|
assert regels and regels[0]["status"] == "ok"
|
|
assert regels[0]["ip"]
|
|
|
|
|
|
# ── Koppelen ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_connect_van_onbekende_stack(client):
|
|
csrf = login(client)
|
|
r = client.post("/api/stacks/bestaatniet/connect", json={"connect": True},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_connect_start_een_job(client, env):
|
|
csrf = login(client)
|
|
_stack(env, "web", {"source": "x"})
|
|
d = client.post("/api/stacks/web/connect", json={"connect": True},
|
|
headers={"X-CSRF-Token": csrf}).get_json()
|
|
assert d["ok"] and d["job_id"]
|
|
|
|
|
|
def test_viewer_mag_niet_herconfigureren(client, env):
|
|
csrf = login(client)
|
|
_stack(env, "web", {"source": "x", "values": {}})
|
|
client.post("/api/auth/users",
|
|
json={"username": "kijker", "password": "eenlangwachtwoord",
|
|
"role": "viewer"}, headers={"X-CSRF-Token": csrf})
|
|
kijker = client.post("/api/auth/login",
|
|
json={"username": "kijker", "password": "eenlangwachtwoord"}
|
|
).get_json()["csrf_token"]
|
|
assert client.post("/api/stacks/web/reconfigure", json={"values": {}},
|
|
headers={"X-CSRF-Token": kijker}).status_code == 403
|
|
|
|
|
|
# ── Audit-filter ─────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def gevuld(env):
|
|
from core import audit
|
|
audit.init()
|
|
audit.log("auth", "login", "ok", ref="ramon", ip="10.0.0.1")
|
|
audit.log("auth", "login", "error", ref="indringer", ip="10.9.9.9")
|
|
audit.log("stacks", "start", "ok", ref="jellyfin")
|
|
audit.log("backups", "create", "ok", ref="jellyfin")
|
|
return audit
|
|
|
|
|
|
def test_filter_op_bron(gevuld):
|
|
assert len(gevuld.query(100, src="auth")) == 2
|
|
assert gevuld.count(src="auth") == 2
|
|
|
|
|
|
def test_filter_op_actie_en_status(gevuld):
|
|
r = gevuld.query(100, src="auth", action="login", status="error")
|
|
assert len(r) == 1 and r[0]["ref"] == "indringer"
|
|
|
|
|
|
def test_zoeken_in_referentie_en_ip(gevuld):
|
|
assert len(gevuld.query(100, zoek="jellyfin")) == 2
|
|
assert len(gevuld.query(100, zoek="10.9.9.9")) == 1
|
|
|
|
|
|
def test_filter_op_periode(gevuld):
|
|
assert gevuld.count(sinds=time.time() - 60) == 4
|
|
assert gevuld.count(sinds=time.time() + 60) == 0
|
|
|
|
|
|
def test_doorbladeren(gevuld):
|
|
eerste = gevuld.query(2, 0)
|
|
tweede = gevuld.query(2, 2)
|
|
assert len(eerste) == 2 and len(tweede) == 2
|
|
assert {r["id"] for r in eerste}.isdisjoint({r["id"] for r in tweede})
|
|
|
|
|
|
def test_facetten_voor_de_keuzelijsten(gevuld):
|
|
f = gevuld.facets()
|
|
assert set(f["src"]) == {"auth", "stacks", "backups"}
|
|
assert "login" in f["action"]
|
|
assert set(f["status"]) == {"ok", "error"}
|
|
|
|
|
|
def test_audit_endpoint_filtert(client, env, gevuld):
|
|
login(client)
|
|
d = client.get("/api/audit?src=auth&status=error").get_json()
|
|
assert d["total"] == 1
|
|
assert d["entries"][0]["ref"] == "indringer"
|
|
assert "auth" in d["facets"]["src"]
|
|
assert d["grand_total"] >= d["total"]
|
|
|
|
|
|
def test_audit_endpoint_limiet_wordt_begrensd(client, env, gevuld):
|
|
login(client)
|
|
d = client.get("/api/audit?limit=99999").get_json()
|
|
assert d["limit"] <= 500
|
|
|
|
|
|
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
|
|
|
|
def _wijzig(client, csrf, extras, poort=9200):
|
|
return client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": poort},
|
|
"connect_apps": False, "extras": extras},
|
|
headers={"X-CSRF-Token": csrf})
|
|
|
|
|
|
def test_datamap_en_apparaat_belanden_in_de_compose(client, env, monkeypatch):
|
|
"""Een mediamap of USB-stick toevoegen kon alleen via 'compose bewerken',
|
|
en dat werd bij de eerstvolgende wijziging overschreven."""
|
|
import yaml as _yaml
|
|
import app as app_module
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
|
|
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
|
|
|
|
r = _wijzig(client, csrf, {
|
|
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
|
|
"container": "/media", "ro": True}],
|
|
"devices": [{"service": "keuzeapp", "host": "/dev/ttyUSB0"}]})
|
|
assert r.status_code == 200, r.get_json()
|
|
d = _wacht_op_job(client, r.get_json()["job_id"])
|
|
assert d.get("status") == "done", d.get("lines")
|
|
|
|
compose = next(p for p in (env["lib"] / "kz").iterdir()
|
|
if p.name.startswith(("compose", "docker-compose")))
|
|
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
|
|
assert "/srv/media:/media:ro" in svc["volumes"]
|
|
assert "/dev/ttyUSB0:/dev/ttyUSB0" in svc["devices"]
|
|
|
|
# En ze staan in de metadata, dus een volgende wijziging behoudt ze.
|
|
meta = json.loads((env["lib"] / "kz" / ".serverup.json").read_text())
|
|
assert meta["extras"]["volumes"][0]["host"] == "/srv/media"
|
|
|
|
|
|
def test_extras_overleven_een_volgende_wijziging(client, env, monkeypatch):
|
|
"""Het renderen begint elke keer opnieuw uit het sjabloon; zonder bewaarde
|
|
extra's zou je map er na één instellingswijziging weer af zijn."""
|
|
import yaml as _yaml
|
|
import app as app_module
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
for naam in ("validate_compose",):
|
|
monkeypatch.setattr(app_module.docker, naam, lambda *a, **k: (True, ""))
|
|
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
|
|
|
|
_wacht_op_job(client, _wijzig(client, csrf, {
|
|
"volumes": [{"service": "keuzeapp", "host": "/srv/media",
|
|
"container": "/media"}]}).get_json()["job_id"])
|
|
# Nu een wijziging zonder extras mee te sturen: de bewaarde keuze telt.
|
|
r = client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": 9201},
|
|
"connect_apps": False},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht_op_job(client, r.get_json()["job_id"])
|
|
|
|
compose = next(p for p in (env["lib"] / "kz").iterdir()
|
|
if p.name.startswith(("compose", "docker-compose")))
|
|
svc = _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]
|
|
assert "/srv/media:/media" in svc["volumes"]
|
|
|
|
|
|
def test_gevaarlijk_pad_wordt_geweigerd_met_uitleg(client, env):
|
|
"""Niet stil corrigeren: wie /etc intikt hoort te horen waarom dat niet gaat."""
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
r = _wijzig(client, csrf, {
|
|
"volumes": [{"service": "keuzeapp", "host": "/var/run/docker.sock",
|
|
"container": "/var/run/docker.sock"}]})
|
|
assert r.status_code == 400
|
|
assert "Docker-socket" in r.get_json()["msg"]
|
|
|
|
r = _wijzig(client, csrf, {
|
|
"devices": [{"service": "keuzeapp", "host": "/etc/shadow"}]})
|
|
assert r.status_code == 400
|
|
assert "/dev/" in r.get_json()["msg"]
|
|
|
|
|
|
def test_apparatenlijst_is_op_te_vragen(client, env):
|
|
login(client)
|
|
d = client.get("/api/devices").get_json()
|
|
assert "devices" in d and isinstance(d["devices"], list)
|
|
for dev in d["devices"]:
|
|
assert dev["path"].startswith("/dev/")
|
|
|
|
|
|
# ── Versietags ───────────────────────────────────────────────────────────────
|
|
|
|
def test_versietag_wordt_vastgehouden_over_wijzigingen_heen(client, env, monkeypatch):
|
|
"""Sjablonen staan meestal op `latest`; wie een versie kiest wil niet dat
|
|
de eerstvolgende instellingswijziging hem daar weer af haalt."""
|
|
import yaml as _yaml
|
|
import app as app_module
|
|
csrf = login(client)
|
|
_installeer_keuzeapp(client, csrf)
|
|
monkeypatch.setattr(app_module.docker, "validate_compose", lambda *a, **k: (True, ""))
|
|
monkeypatch.setattr(app_module.docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(app_module.backups, "create", lambda *a, **k: {"file": "x.tar.gz"})
|
|
|
|
r = client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": 9300},
|
|
"connect_apps": False,
|
|
"image_tags": {"keuzeapp": "1.25-alpine"}},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert _wacht_op_job(client, r.get_json()["job_id"]).get("status") == "done"
|
|
|
|
def _image():
|
|
compose = next(p for p in (env["lib"] / "kz").iterdir()
|
|
if p.name.startswith(("compose", "docker-compose")))
|
|
return _yaml.safe_load(compose.read_text())["services"]["keuzeapp"]["image"]
|
|
|
|
assert _image() == "nginx:1.25-alpine"
|
|
|
|
# Een volgende wijziging zonder tags: de keuze staat in de metadata en
|
|
# hoort te blijven staan, ook al rendert het sjabloon weer `nginx`.
|
|
r = client.post("/api/stacks/kz/reconfigure",
|
|
json={"values": {"service_name": "keuzeapp", "port": 9301},
|
|
"connect_apps": False},
|
|
headers={"X-CSRF-Token": csrf})
|
|
_wacht_op_job(client, r.get_json()["job_id"])
|
|
assert _image() == "nginx:1.25-alpine"
|
|
|
|
|
|
def test_onzinnige_versietag_wordt_genegeerd(client, env, monkeypatch):
|
|
"""De tag belandt in het compose-bestand; een waarde met een spatie of een
|
|
dubbele punt erin zou dat bestand stukmaken."""
|
|
import app as app_module
|
|
assert app_module._gekozen_image_tags({"image_tags": {"a": "1.2.3"}}) == {"a": "1.2.3"}
|
|
for onzin in ("mijn tag", "tag:met:dubbelepunt", "-begintmetstreepje", "", " "):
|
|
assert app_module._gekozen_image_tags({"image_tags": {"a": onzin}}) == {}
|