All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m16s
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
372 lines
15 KiB
Python
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"
|