server-up/tests/test_eigen_ip.py
Ramon 58d9be1f48
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m1s
v0.7.90-beta - ARR-stack opgesplitst, /data-mount, netwerkopties per container
- Elk *arr-onderdeel is nu ook een losse app in de store (46 nieuwe sjablonen);
  los en gebundeld komen uit één bron (tools/arr_sjablonen.py) met een test die
  bewaakt dat ze niet uiteen lopen
- Alles deelt één /data-map, zodat hardlinks werken; losse /tv-, /movies- en
  /downloads-mounts zijn eruit, UMASK=002 erbij
- Nieuwe eerste stap "Onderdelen" in het invulmenu, met kopjes per soort; de
  dubbele groepsschakelaar en de lege kaarten in Instellingen zijn weg
- Eigen IP-adres per container in plaats van per stack; containers zonder adres
  houden hun poortmapping. Ook Pangolin publiceert nu per container
- Huntarr vervangen door NeutArr (project offline na lekken, image bestaat niet
  meer), Maintainerr naar zijn nieuwe organisatie
- 31 apps toegevoegd uit het *arr-ecosysteem, alle images tegen hun registry
  gecontroleerd
- data_dir hernoemd naar appdata_dir in de hele catalogus, naast de nieuwe
  data_root; qBittorrent van poort 8080 naar 8097

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-07-31 22:21:04 +02:00

194 lines
7.5 KiB
Python

"""Een eigen IP-adres per container, en een publieke URL per container.
Beide gingen per stack. Bij een app met één container klopt dat, maar de
ARR-stack heeft er vijftig: één adres voor de hele stack betekende dat de eerste
container het kreeg en alle andere hun poortmappings kwijtraakten zonder er iets
voor terug te krijgen. Daarna waren ze nergens meer te bereiken.
"""
import json
import sys
import time
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from conftest import login
NETWERK = {"name": "lan", "parent": "eth0", "subnet": "192.168.1.0/24",
"gateway": "192.168.1.1", "range": "192.168.1.240/28",
"driver": "macvlan"}
def _maak_bron(env, naam="tweeling"):
"""Sjabloon met twee containers, elk met een eigen poort."""
from core import git
wortel = git.cache_dir("server-up") / "apps" / naam
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": naam, "description": "test"},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": naam, "required": True},
]}],
}), encoding="utf-8")
(wortel / "files" / "compose.yaml").write_text(
"services:\n"
" << service_name >>-een:\n"
" image: nginx\n"
" ports:\n"
' - "8801:80"\n'
" << service_name >>-twee:\n"
" image: nginx\n"
" ports:\n"
' - "8802:80"\n', encoding="utf-8")
return wortel
@pytest.fixture
def netwerk(env):
import core as cfg
cfg.patch({"NETWORKS": [NETWERK]})
def _installeer(client, csrf, monkeypatch, body):
"""Installeer zonder Docker: alleen het compose-bestand op schijf telt."""
from core import docker, networks
import app as app_module
monkeypatch.setattr(docker, "validate_compose", lambda *a, **k: (True, ""))
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
monkeypatch.setattr(networks, "used_ips", lambda *a, **k: set())
monkeypatch.setattr(networks, "ensure_shared", lambda *a, **k: (True, ""))
monkeypatch.setattr(app_module, "_reserved_ips", lambda: set())
r = client.post("/api/store/install", json=body,
headers={"X-CSRF-Token": csrf})
jid = (r.get_json() or {}).get("job_id")
if jid:
einde = time.time() + 15
while time.time() < einde:
d = client.get(f"/api/jobs/{jid}").get_json() or {}
if d.get("status") in ("done", "error"):
break
time.sleep(0.05)
return r
def _compose(env, naam):
import core as cfg
d = Path(cfg.load()["LIBRARY_DIR"]) / naam
return yaml.safe_load((d / "docker-compose.yml").read_text(encoding="utf-8"))
def test_adres_per_container(client, env, netwerk, monkeypatch):
csrf = login(client)
_maak_bron(env)
r = _installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"network": "lan", "ips": {"tweeling-een": "192.168.1.241"},
"app_networks": [],
})
assert r.get_json().get("job_id"), r.get_json()
doc = _compose(env, "tweeling")
een = doc["services"]["tweeling-een"]
twee = doc["services"]["tweeling-twee"]
assert een["networks"]["lan"] == {"ipv4_address": "192.168.1.241"}
assert "ports" not in een
# En dit is waar het om gaat: de andere container is níét stilzwijgend
# onbereikbaar geworden.
assert twee["ports"] == ["8802:80"]
def test_leeg_adres_wordt_aangevuld(client, env, netwerk, monkeypatch):
csrf = login(client)
_maak_bron(env)
_installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"network": "lan", "ips": {"tweeling-een": "", "tweeling-twee": ""},
"app_networks": [],
})
doc = _compose(env, "tweeling")
adressen = {n: s["networks"]["lan"]["ipv4_address"]
for n, s in doc["services"].items()}
assert len(set(adressen.values())) == 2, f"zelfde adres twee keer: {adressen}"
def test_adres_buiten_het_subnet_wordt_geweigerd(client, env, netwerk, monkeypatch):
csrf = login(client)
_maak_bron(env)
r = _installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"network": "lan", "ips": {"tweeling-een": "10.0.0.5"}, "app_networks": [],
})
assert r.status_code == 400
assert "subnet" in r.get_json()["msg"]
def test_adressen_staan_in_de_metadata(client, env, netwerk, monkeypatch):
"""Zonder dit weet het herconfigureerscherm niet meer welke container welk
adres had."""
csrf = login(client)
_maak_bron(env)
_installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"network": "lan", "ips": {"tweeling-een": "192.168.1.241"},
"app_networks": [],
})
import core as cfg
meta = json.loads((Path(cfg.load()["LIBRARY_DIR"]) / "tweeling"
/ ".serverup.json").read_text(encoding="utf-8"))
assert meta["ips"] == {"tweeling-een": "192.168.1.241"}
r = client.get("/api/stacks/tweeling/config")
assert r.get_json()["ips"] == {"tweeling-een": "192.168.1.241"}
def test_zonder_mapping_blijft_het_bij_een_adres(client, env, netwerk, monkeypatch):
"""Een app met één container hoort niets van deze uitbreiding te merken."""
csrf = login(client)
_maak_bron(env)
_installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"network": "lan", "ip": "192.168.1.245", "app_networks": [],
})
doc = _compose(env, "tweeling")
met_adres = [n for n, s in doc["services"].items()
if isinstance(s.get("networks"), dict) and "lan" in s["networks"]]
assert met_adres == ["tweeling-een"]
# ── Pangolin ─────────────────────────────────────────────────────────────────
def test_publieke_url_per_container(client, env, netwerk, monkeypatch):
"""Pangolin pakte de eerste poort in het bestand. Bij twintig webinterfaces
kreeg je dus twintig keer dezelfde."""
csrf = login(client)
_maak_bron(env)
_installeer(client, csrf, monkeypatch, {
"stack": "tweeling", "repo_id": "server-up", "instance": "tweeling",
"app_networks": [],
})
from core import pangolin
gezien = {}
def nep(naam, subdomein, ip, poort, site_id=None, domein_id=""):
gezien.update(naam=naam, ip=ip, poort=poort)
return {"url": f"https://{subdomein}.example.com", "resource_id": 1}
monkeypatch.setattr(pangolin, "ingesteld", lambda: True)
monkeypatch.setattr(pangolin, "publiceer", nep)
r = client.post("/api/stacks/tweeling/pangolin",
json={"service": "tweeling-twee", "host_ip": "10.1.2.3"},
headers={"X-CSRF-Token": csrf})
assert r.get_json()["ok"], r.get_json()
assert gezien["poort"] == 8802, gezien
assert gezien["naam"] == "tweeling-twee"
import core as cfg
meta = json.loads((Path(cfg.load()["LIBRARY_DIR"]) / "tweeling"
/ ".serverup.json").read_text(encoding="utf-8"))
assert meta["pangolin_urls"]["tweeling-twee"] == \
"https://tweeling-twee.example.com"