server-up/tests/test_connect.py
Ramon c658418b10
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m16s
fix(netwerk): koppel niet langer de database aan het gedeelde netwerk
add_shared_network hing elke service van een stack aan het gedeelde netwerk.
Eén druk op 'koppelen' maakte daarmee de database van die app bereikbaar voor
elke andere gekoppelde app — en niet elk sjabloon zet daar een wachtwoord op.

- Standaard gaan alleen services mee die geen database, cache of zoekindex
  zijn; brokers als mosquitto en rabbitmq blijven wel deelbaar.
- Per onderdeel te kiezen in de stap 'Verbinden'; de keuze staat in
  .serverup.json en geldt ook bij een volgende wijziging.
- mongodump/mongorestore sturen geen inloggegevens meer mee bij een database
  zonder authenticatie; die dump mislukte altijd.
- Joblogs worden bewaard in SU_JOBS (standaard /data/jobs) en teruggelezen na
  een herstart; een afgekapte taak meldt dat in plaats van 'unknown'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-06 23:14:24 +02:00

372 lines
15 KiB
Python

"""Apps onderling koppelen, geavanceerde velden en de uitleg per veld."""
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from core import boilerplates as bp
from core import compose_transform as ct
from conftest import login
BASIS = """\
services:
web:
image: nginx
ports:
- "8080:80"
db:
image: postgres
"""
# ── Gedeeld netwerk ──────────────────────────────────────────────────────────
def test_gedeeld_netwerk_komt_bij_de_app_maar_niet_bij_de_database():
"""Zonder gedeeld netwerk zit elke stack in zijn eigen compose-netwerk en
kan geen enkele app een andere op naam bereiken.
De database van een stack heeft daarbuiten niets te zoeken: die stond met
één druk op 'koppelen' open voor elke andere app op de host, en lang niet
elk sjabloon zet er een wachtwoord op.
"""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup"))
assert "serverup" in doc["services"]["web"]["networks"]
assert "networks" not in doc["services"]["db"]
assert doc["networks"]["serverup"] == {"external": True}
def test_database_kan_er_bewust_wel_bij():
"""Eén database delen tussen twee stacks is een geldige keuze — hij mag
alleen niet stilzwijgend gebeuren."""
doc = yaml.safe_load(ct.add_shared_network(BASIS, "serverup",
services=["web", "db"]))
assert "serverup" in doc["services"]["db"]["networks"]
def test_diensten_worden_herkend():
lijst = {d["name"]: d for d in ct.diensten(BASIS)}
assert lijst["db"]["helper"] is True
assert lijst["web"]["helper"] is False
assert ct.deelbare_diensten(BASIS) == ["web"]
# Een broker delen is juist de reden dat het gedeelde netwerk bestaat.
assert not ct.is_hulpdienst("eclipse-mosquitto:2")
assert not ct.is_hulpdienst("rabbitmq:3-management")
assert ct.is_hulpdienst("docker.io/library/postgres:16-alpine")
assert ct.is_hulpdienst("opensearchproject/opensearch:2")
def test_gedeeld_netwerk_naast_een_eigen_ip():
"""Een stack met een macvlan-adres moet het gedeelde netwerk erbij krijgen,
niet in plaats daarvan."""
met_ip = ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240")
doc = yaml.safe_load(ct.add_shared_network(met_ip, "serverup"))
web = doc["services"]["web"]["networks"]
assert "lan" in web and "serverup" in web
assert web["lan"] == {"ipv4_address": "192.168.1.240"}
assert doc["networks"]["lan"]["external"] is True
assert doc["networks"]["serverup"]["external"] is True
def test_gedeeld_netwerk_is_idempotent():
een = ct.add_shared_network(BASIS, "serverup")
twee = ct.add_shared_network(een, "serverup")
doc = yaml.safe_load(twee)
assert doc["services"]["web"]["networks"].count("serverup") == 1
def test_gedeeld_netwerk_bij_ongeldige_yaml():
with pytest.raises(ct.TransformError):
ct.add_shared_network("dit: [is geen: yaml", "serverup")
# ── Veldopties ───────────────────────────────────────────────────────────────
def _veld(app: str, naam: str) -> dict:
return next(f for f in bp.fields(WORTEL / "apps" / app) if f["name"] == naam)
def test_koppelveld_wordt_doorgegeven():
f = _veld("zigbee2mqtt", "mqtt_server")
assert f["connect"] == {"app": "mosquitto", "scheme": "mqtt", "port": 1883}
assert f["help"], "koppelveld hoort uitleg te hebben"
def test_open_webui_wijst_naar_ollama():
f = _veld("open-webui", "ollama_url")
assert f["connect"]["app"] == "ollama"
assert f["connect"]["port"] == 11434
def test_geavanceerd_veld_wordt_gemarkeerd():
assert _veld("zigbee2mqtt", "usb_device")["advanced"] is True
assert _veld("zigbee2mqtt", "mqtt_server")["advanced"] is False
def test_gewone_velden_blijven_zichtbaar():
"""De basisvelden mogen niet per ongeluk achter het uitklapmenu verdwijnen."""
for app in ("zigbee2mqtt", "immich", "frigate", "ollama"):
velden = {f["name"]: f for f in bp.fields(WORTEL / "apps" / app)}
assert velden["service_name"]["advanced"] is False
if "port" in velden:
assert velden["port"]["advanced"] is False
def test_elke_app_houdt_zichtbare_velden_over():
"""Een app waarvan alles geavanceerd is, zou een leeg formulier tonen."""
for d in sorted((WORTEL / "apps").iterdir()):
if not d.is_dir():
continue
velden = [f for f in bp.fields(d) if not f.get("is_group_toggle")]
zichtbaar = [f for f in velden if not f["advanced"]]
assert zichtbaar, f"{d.name}: alle velden staan als geavanceerd gemarkeerd"
def test_koppelvelden_verwijzen_naar_bestaande_apps():
"""Een connect-veld dat naar een app verwijst die niet bestaat, levert een
lege keuzelijst op zonder dat iemand snapt waarom."""
apps = {d.name for d in (WORTEL / "apps").iterdir() if d.is_dir()}
for d in sorted((WORTEL / "apps").iterdir()):
if not d.is_dir():
continue
for f in bp.fields(d):
doel = (f.get("connect") or {}).get("app")
if doel:
assert doel in apps, f"{d.name}/{f['name']} verwijst naar '{doel}'"
# ── Afhankelijkheden tussen apps ─────────────────────────────────────────────
# Een `connect`-veld wijst naar een andere app, maar zegt niet dát je die nodig
# hebt. Zigbee2MQTT zonder MQTT-broker start prima en doet niets; `depends_on`
# maakt dat zichtbaar vóór het installeren.
def _apps():
return sorted(d for d in (WORTEL / "apps").iterdir() if d.is_dir())
def _deps(d):
return bp.metadata(d).get("depends_on") or []
def test_afhankelijkheden_verwijzen_naar_bestaande_apps():
namen = {d.name for d in _apps()}
for d in _apps():
for dep in _deps(d):
assert dep["app"] in namen, f"{d.name} hangt af van '{dep['app']}'"
def test_elke_afhankelijkheid_heeft_een_reden():
"""Een waarschuwing zonder uitleg is een waarschuwing die je wegklikt."""
for d in _apps():
for dep in _deps(d):
assert dep["reason"], f"{d.name}{dep['app']}: geen reden opgegeven"
def test_geen_app_hangt_van_zichzelf_af():
for d in _apps():
assert d.name not in [x["app"] for x in _deps(d)], d.name
def test_geen_kringetjes():
"""A heeft B nodig en B heeft A nodig levert een melding op die je nooit
kunt wegwerken."""
graaf = {d.name: [x["app"] for x in _deps(d)] for d in _apps()}
bezocht: set[str] = set()
def loop(naam, pad):
if naam in pad:
raise AssertionError(f"kringetje: {''.join(pad + [naam])}")
if naam in bezocht:
return
bezocht.add(naam)
for volgende in graaf.get(naam, []):
loop(volgende, pad + [naam])
for naam in graaf:
loop(naam, [])
def test_korte_schrijfwijze_wordt_uitgeklapt(tmp_path):
"""Externe repo's hoeven niets van deze sleutel te weten; `["mosquitto"]`
moet net zo goed werken als de volledige vorm."""
import json as _json
d = tmp_path / "app"
(d / "files").mkdir(parents=True)
(d / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "x", "description": "y", "tags": ["z"],
"depends_on": ["mosquitto", {"app": "loki", "reason": "omdat"}]},
"variables": [],
}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
assert bp.metadata(d)["depends_on"] == [
{"app": "mosquitto", "reason": ""},
{"app": "loki", "reason": "omdat"},
]
def test_zonder_depends_on_een_lege_lijst():
assert bp.metadata(WORTEL / "apps" / "vaultwarden")["depends_on"] == []
# ── Endpoint ─────────────────────────────────────────────────────────────────
def test_connectable_endpoint(client, env, monkeypatch):
from core import docker
import json as _json
login(client)
stack = env["lib"] / "mosquitto"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(stack / ".serverup.json").write_text(_json.dumps(
{"source": "mosquitto", "name": "Mosquitto MQTT"}))
monkeypatch.setattr(docker, "compose_ps",
lambda d, name=None: [{"name": "mosquitto", "service": "a",
"ports": "1883/tcp", "running": True}])
d = client.get("/api/apps/connectable").get_json()
assert d["shared_network"] == "serverup"
app = next(a for a in d["apps"] if a["stack"] == "mosquitto")
assert app["source"] == "mosquitto"
assert app["containers"][0]["name"] == "mosquitto"
def test_connectable_zonder_stacks(client):
login(client)
assert client.get("/api/apps/connectable").get_json()["apps"] == []
def test_preview_meldt_wat_er_nog_ontbreekt(client, env, monkeypatch):
"""De installatiemodal moet vóór het installeren kunnen tonen wat er mist."""
import json as _json
from core import git
csrf = login(client)
bron = git.cache_dir("server-up") / "apps" / "z2m"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Zigbee2MQTT", "description": "test", "tags": ["mqtt"],
"depends_on": [{"app": "mosquitto", "reason": "broker nodig"},
{"app": "loki", "reason": "logs"}]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "z2m", "required": True}]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n", encoding="utf-8")
# Alleen Mosquitto draait al.
stack = env["lib"] / "mqtt"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n a:\n image: x\n")
(stack / ".serverup.json").write_text(_json.dumps({"source": "mosquitto"}))
d = client.post("/api/store/preview", json={"stack": "z2m", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
deps = {x["app"]: x for x in d["depends_on"]}
assert deps["mosquitto"]["installed"] is True
assert deps["mosquitto"]["stacks"] == ["mqtt"]
assert deps["loki"]["installed"] is False
assert deps["loki"]["reason"] == "logs"
def test_store_herkent_een_installatie_aan_zijn_bron(env, tmp_path):
"""De store keek naar de mapnaam: een stack telde als installatie van
`sonarr` zodra hij `sonarr` of `sonarr-…` heette. Wie zijn stack `media-tv`
noemde, kreeg te zien dat Sonarr nog niet geïnstalleerd was — terwijl hij
draaide. `.serverup.json` weet wél uit welk sjabloon hij komt.
"""
import json as _json
from core import git
sjablonen = tmp_path / "sjablonen"
(sjablonen / "sonarr" / "files").mkdir(parents=True)
(sjablonen / "sonarr" / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Sonarr", "description": "d", "tags": ["media"]},
"variables": [],
}), encoding="utf-8")
(sjablonen / "sonarr" / "files" / "compose.yaml").write_text(
"services: {}\n", encoding="utf-8")
lib = env["lib"]
# Een installatie met een eigen naam …
eigen = lib / "media-tv"
eigen.mkdir(parents=True)
(eigen / ".serverup.json").write_text(_json.dumps({"source": "sonarr"}))
# … en eentje die toevallig wel zo heet, zonder metadata.
(lib / "sonarr-2").mkdir()
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["sonarr"]["instances"] == ["media-tv", "sonarr-2"]
def test_store_ziet_geen_installatie_van_een_andere_app(env, tmp_path):
import json as _json
from core import git
sjablonen = tmp_path / "sjablonen"
(sjablonen / "radarr" / "files").mkdir(parents=True)
(sjablonen / "radarr" / "template.json").write_text(_json.dumps({
"kind": "compose",
"metadata": {"name": "Radarr", "description": "d", "tags": ["media"]},
"variables": [],
}), encoding="utf-8")
(sjablonen / "radarr" / "files" / "compose.yaml").write_text(
"services: {}\n", encoding="utf-8")
lib = env["lib"]
d = lib / "media-tv"
d.mkdir(parents=True)
(d / ".serverup.json").write_text(_json.dumps({"source": "sonarr"}))
stacks = {s["dir"]: s for s in git._scan_compose_dirs(sjablonen, lib)}
assert stacks["radarr"]["instances"] == []
def test_installeren_koppelt_de_database_niet_mee(client, env, monkeypatch):
"""Het echte pad: installeren met koppeling aan, en dan hoort de database
in zijn eigen netwerk te blijven."""
import json as _json
import time as _time
from core import git
csrf = login(client)
wortel = git.cache_dir("server-up") / "apps" / "metdb"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "metdb"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "metdb", "required": True}]}]}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
'services:\n << service_name >>:\n image: nginx\n'
' << service_name >>-db:\n image: postgres:16-alpine\n', encoding="utf-8")
import app as app_module
monkeypatch.setattr(app_module.networks, "ensure_shared", lambda n: (True, ""))
r = client.post("/api/store/install",
json={"stack": "metdb", "repo_id": "server-up",
"instance": "mdb", "connect_apps": True},
headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
jid = r.get_json()["job_id"]
einde = _time.time() + 10
while _time.time() < einde:
if (client.get(f"/api/jobs/{jid}").get_json() or {}).get("status") in ("done", "error"):
break
_time.sleep(0.05)
compose = next(p for p in (env["lib"] / "mdb").iterdir()
if p.name.startswith(("compose", "docker-compose")))
doc = yaml.safe_load(compose.read_text())
app_svc = next(n for n in doc["services"] if not n.endswith("-db"))
db_svc = next(n for n in doc["services"] if n.endswith("-db"))
assert "serverup" in doc["services"][app_svc]["networks"]
assert "networks" not in doc["services"][db_svc], \
"de database hoort niet op het gedeelde netwerk"