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
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""Poorten → eigen IP-adres (macvlan/ipvlan)."""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
from core import compose_transform as ct
|
|
|
|
BASIS = """\
|
|
services:
|
|
vaultwarden:
|
|
image: vaultwarden/server:latest
|
|
ports:
|
|
- "8222:80"
|
|
volumes:
|
|
- /data:/data
|
|
"""
|
|
|
|
|
|
def test_poorten_verdwijnen_en_ip_wordt_toegekend():
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
|
svc = doc["services"]["vaultwarden"]
|
|
assert "ports" not in svc
|
|
assert svc["networks"]["lan"] == {"ipv4_address": "192.168.1.240"}
|
|
assert doc["networks"] == {"lan": {"external": True}}
|
|
|
|
|
|
def test_eigen_adres_verbreekt_de_stack_niet():
|
|
"""Een `networks`-blok haalt een service uit het impliciete default-netwerk.
|
|
Zonder `default` erbij vindt de container zijn eigen database niet meer."""
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
|
assert "default" in doc["services"]["vaultwarden"]["networks"]
|
|
|
|
|
|
def test_overige_instellingen_blijven_intact():
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(BASIS, "lan", "192.168.1.240"))
|
|
svc = doc["services"]["vaultwarden"]
|
|
assert svc["image"] == "vaultwarden/server:latest"
|
|
assert svc["volumes"] == ["/data:/data"]
|
|
|
|
|
|
def test_alleen_de_publicerende_service_krijgt_het_ip():
|
|
tekst = """\
|
|
services:
|
|
db:
|
|
image: postgres
|
|
web:
|
|
image: nginx
|
|
ports:
|
|
- "8080:80"
|
|
"""
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.5"))
|
|
assert doc["services"]["web"]["networks"]["lan"] == {"ipv4_address": "10.0.0.5"}
|
|
# De database blijft ongemoeid en is via het default-netwerk bereikbaar.
|
|
assert "networks" not in doc["services"]["db"]
|
|
|
|
|
|
def test_expliciete_service_kan_gekozen_worden():
|
|
tekst = "services:\n a:\n image: x\n b:\n image: y\n ports: ['1:1']\n"
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(tekst, "lan", "10.0.0.9", only_service="a"))
|
|
assert doc["services"]["a"]["networks"]["lan"] == {"ipv4_address": "10.0.0.9"}
|
|
# b krijgt geen adres en houdt dus zijn poortmapping.
|
|
assert doc["services"]["b"]["ports"] == ["1:1"]
|
|
|
|
|
|
# ── Meerdere containers, elk een eigen adres ─────────────────────────────────
|
|
|
|
MEERDERE = """\
|
|
services:
|
|
sonarr:
|
|
image: sonarr
|
|
ports: ["8989:8989"]
|
|
radarr:
|
|
image: radarr
|
|
ports: ["7878:7878"]
|
|
bazarr:
|
|
image: bazarr
|
|
ports: ["6767:6767"]
|
|
qbittorrent:
|
|
image: qbittorrent
|
|
network_mode: "service:gluetun"
|
|
"""
|
|
|
|
|
|
def test_meerdere_adressen_naast_elkaar():
|
|
doc = yaml.safe_load(ct.to_dedicated_ips(
|
|
MEERDERE, "lan", {"sonarr": "10.0.0.5", "radarr": "10.0.0.6"}))
|
|
assert doc["services"]["sonarr"]["networks"]["lan"] == {"ipv4_address": "10.0.0.5"}
|
|
assert doc["services"]["radarr"]["networks"]["lan"] == {"ipv4_address": "10.0.0.6"}
|
|
assert "ports" not in doc["services"]["sonarr"]
|
|
assert "ports" not in doc["services"]["radarr"]
|
|
|
|
|
|
def test_service_zonder_adres_houdt_zijn_poorten():
|
|
"""De bug waar dit voor gemaakt is: bij een stack met twintig webinterfaces
|
|
verloor élke container zijn poorten zodra er één een eigen adres kreeg."""
|
|
doc = yaml.safe_load(ct.to_dedicated_ips(MEERDERE, "lan", {"sonarr": "10.0.0.5"}))
|
|
assert doc["services"]["bazarr"]["ports"] == ["6767:6767"]
|
|
assert doc["services"]["radarr"]["ports"] == ["7878:7878"]
|
|
|
|
|
|
def test_geen_enkele_service_blijft_onbereikbaar_achter():
|
|
"""Poorten kwijt én geen adres is de combinatie die nergens meer te bereiken
|
|
is. Voor elke service moet minstens één van de twee overblijven."""
|
|
doc = yaml.safe_load(ct.to_dedicated_ips(MEERDERE, "lan", {"radarr": "10.0.0.6"}))
|
|
for naam, svc in doc["services"].items():
|
|
if svc.get("network_mode"):
|
|
continue
|
|
heeft_adres = isinstance(svc.get("networks"), dict) and "lan" in svc["networks"]
|
|
assert svc.get("ports") or heeft_adres, f"{naam} is nergens bereikbaar"
|
|
|
|
|
|
def test_network_mode_krijgt_geen_adres():
|
|
"""Compose weigert network_mode en networks samen."""
|
|
doc = yaml.safe_load(ct.to_dedicated_ips(
|
|
MEERDERE, "lan", {"sonarr": "10.0.0.5", "qbittorrent": "10.0.0.7"}))
|
|
assert "networks" not in doc["services"]["qbittorrent"]
|
|
assert doc["services"]["qbittorrent"]["network_mode"] == "service:gluetun"
|
|
|
|
|
|
def test_onbekende_service_in_de_mapping_geeft_fout():
|
|
with pytest.raises(ct.TransformError) as e:
|
|
ct.to_dedicated_ips(MEERDERE, "lan", {"bestaat-niet": "10.0.0.5"})
|
|
assert "bestaat-niet" in str(e.value)
|
|
|
|
|
|
def test_lege_mapping_laat_alles_staan():
|
|
doc = yaml.safe_load(ct.to_dedicated_ips(MEERDERE, "lan", {}))
|
|
assert doc["services"]["sonarr"]["ports"] == ["8989:8989"]
|
|
assert "networks" not in doc
|
|
|
|
|
|
def test_zonder_poorten_krijgt_de_eerste_service_het_ip():
|
|
doc = yaml.safe_load(ct.to_dedicated_ip(
|
|
"services:\n solo:\n image: x\n", "lan", "10.0.0.2"))
|
|
assert doc["services"]["solo"]["networks"]["lan"] == {"ipv4_address": "10.0.0.2"}
|
|
|
|
|
|
@pytest.mark.parametrize("tekst,fout", [
|
|
("dit: [is geen: geldige yaml", "geldige YAML"),
|
|
("version: '3'\n", "geen services"),
|
|
("- een\n- lijst\n", "mapping"),
|
|
])
|
|
def test_foutmeldingen(tekst, fout):
|
|
with pytest.raises(ct.TransformError) as e:
|
|
ct.to_dedicated_ip(tekst, "lan", "10.0.0.1")
|
|
assert fout in str(e.value)
|
|
|
|
|
|
def test_onbekende_service_geeft_fout():
|
|
with pytest.raises(ct.TransformError):
|
|
ct.to_dedicated_ip(BASIS, "lan", "10.0.0.1", only_service="bestaat-niet")
|
|
|
|
|
|
def test_published_ports():
|
|
assert ct.published_ports(BASIS) == ["8222:80"]
|
|
assert ct.published_ports("services:\n a:\n image: x\n") == []
|
|
|
|
|
|
# ── Eigen mappen en apparaten ────────────────────────────────────────────────
|
|
|
|
EXTRA_BASIS = """\
|
|
services:
|
|
app:
|
|
image: nginx
|
|
volumes:
|
|
- /opt/serverup/appdata/app:/config
|
|
db:
|
|
image: postgres:16-alpine
|
|
"""
|
|
|
|
|
|
def test_datamap_wordt_toegevoegd():
|
|
"""Een mediamap die ergens anders staat kon alleen via het compose-bestand,
|
|
en dat wordt bij de eerstvolgende wijziging overschreven."""
|
|
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
|
|
{"service": "app", "host": "/srv/media", "container": "/media"}]})
|
|
doc = yaml.safe_load(uit)
|
|
assert "/srv/media:/media" in doc["services"]["app"]["volumes"]
|
|
# Wat er al stond blijft staan.
|
|
assert "/opt/serverup/appdata/app:/config" in doc["services"]["app"]["volumes"]
|
|
|
|
|
|
def test_datamap_alleen_lezen():
|
|
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
|
|
{"service": "app", "host": "/srv/foto", "container": "/foto", "ro": True}]})
|
|
assert "/srv/foto:/foto:ro" in yaml.safe_load(uit)["services"]["app"]["volumes"]
|
|
|
|
|
|
def test_apparaat_wordt_toegevoegd():
|
|
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
|
|
{"service": "app", "host": "/dev/ttyUSB0", "container": "/dev/ttyACM0"}]})
|
|
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
|
|
["/dev/ttyUSB0:/dev/ttyACM0"]
|
|
|
|
|
|
def test_apparaat_zonder_pad_binnen_de_container():
|
|
uit = ct.add_extras(EXTRA_BASIS, {"devices": [
|
|
{"service": "app", "host": "/dev/ttyUSB0"}]})
|
|
assert yaml.safe_load(uit)["services"]["app"]["devices"] == \
|
|
["/dev/ttyUSB0:/dev/ttyUSB0"]
|
|
|
|
|
|
def test_extras_zijn_idempotent():
|
|
"""Elke wijziging rendert opnieuw en past dit opnieuw toe; twee mounts op
|
|
hetzelfde bronpad laat compose stuklopen."""
|
|
extras = {"volumes": [{"service": "app", "host": "/srv/media",
|
|
"container": "/media"}]}
|
|
een = ct.add_extras(EXTRA_BASIS, extras)
|
|
twee = ct.add_extras(een, extras)
|
|
volumes = yaml.safe_load(twee)["services"]["app"]["volumes"]
|
|
assert volumes.count("/srv/media:/media") == 1
|
|
|
|
|
|
def test_onbekende_service_wordt_overgeslagen():
|
|
"""Een hernoemd onderdeel mag geen kapot compose-bestand opleveren."""
|
|
uit = ct.add_extras(EXTRA_BASIS, {"volumes": [
|
|
{"service": "bestaatniet", "host": "/srv/x", "container": "/x"}]})
|
|
assert yaml.safe_load(uit)["services"].keys() == {"app", "db"}
|
|
|
|
|
|
def test_gevaarlijke_mounts_worden_geweigerd():
|
|
"""Een container die /etc of de Docker-socket mag lezen, is de host."""
|
|
for pad in ("/", "/etc", "/etc/ssh", "/var/run/docker.sock", "/proc/self",
|
|
"/boot", "relatief/pad", "/srv/../etc"):
|
|
assert ct.mount_bezwaar(pad), pad
|
|
for pad in ("/srv/media", "/mnt/schijf/films", "/opt/data"):
|
|
assert ct.mount_bezwaar(pad) == "", pad
|
|
|
|
|
|
def test_alleen_echte_apparaten():
|
|
for pad in ("/dev", "/srv/media", "/dev/../etc", "ttyUSB0", ""):
|
|
assert ct.apparaat_bezwaar(pad), pad
|
|
for pad in ("/dev/ttyUSB0", "/dev/serial/by-id/usb-abc", "/dev/dri/renderD128"):
|
|
assert ct.apparaat_bezwaar(pad) == "", pad
|
|
|
|
|
|
def test_geweigerd_pad_belandt_niet_in_compose():
|
|
"""De controle in de API is de eerste zeef; deze is de laatste."""
|
|
uit = ct.add_extras(EXTRA_BASIS, {
|
|
"volumes": [{"service": "app", "host": "/var/run/docker.sock",
|
|
"container": "/var/run/docker.sock"}],
|
|
"devices": [{"service": "app", "host": "/etc/shadow"}]})
|
|
doc = yaml.safe_load(uit)
|
|
assert "docker.sock" not in str(doc["services"]["app"].get("volumes"))
|
|
assert "devices" not in doc["services"]["app"]
|
|
|
|
|
|
def test_zonder_extras_verandert_er_niets():
|
|
assert ct.add_extras(EXTRA_BASIS, {}) == EXTRA_BASIS
|
|
assert ct.add_extras(EXTRA_BASIS, {"volumes": [], "devices": []}) == EXTRA_BASIS
|
|
|
|
|
|
# ── Versietags ───────────────────────────────────────────────────────────────
|
|
|
|
TAG_BASIS = """\
|
|
services:
|
|
app:
|
|
image: lscr.io/linuxserver/unifi-network-application:latest
|
|
db:
|
|
image: registry.local:5000/mongo
|
|
var:
|
|
image: ${IMAGE}
|
|
"""
|
|
|
|
|
|
def test_images_per_service_splitst_repo_en_tag():
|
|
per_naam = {i["service"]: i for i in ct.images_per_service(TAG_BASIS)}
|
|
assert per_naam["app"]["repo"] == "lscr.io/linuxserver/unifi-network-application"
|
|
assert per_naam["app"]["tag"] == "latest"
|
|
# De dubbele punt in registry.local:5000 is een poort, geen tag.
|
|
assert per_naam["db"]["repo"] == "registry.local:5000/mongo"
|
|
assert per_naam["db"]["tag"] == "latest"
|
|
# Een image uit een variabele gaat over de .env, niet over dit scherm.
|
|
assert "var" not in per_naam
|
|
|
|
|
|
def test_versietag_zetten():
|
|
uit = ct.zet_image_tags(TAG_BASIS, {"app": "10.4.57", "db": "7.0"})
|
|
doc = yaml.safe_load(uit)
|
|
assert doc["services"]["app"]["image"].endswith(":10.4.57")
|
|
assert doc["services"]["db"]["image"] == "registry.local:5000/mongo:7.0"
|
|
assert doc["services"]["var"]["image"] == "${IMAGE}"
|
|
|
|
|
|
def test_lege_of_onbekende_tag_verandert_niets():
|
|
assert ct.zet_image_tags(TAG_BASIS, {}) == TAG_BASIS
|
|
assert ct.zet_image_tags(TAG_BASIS, {"app": " "}) == TAG_BASIS
|
|
assert ct.zet_image_tags(TAG_BASIS, {"bestaatniet": "1"}) == TAG_BASIS
|
|
# Dezelfde tag nog eens zetten laat het bestand ongemoeid.
|
|
assert ct.zet_image_tags(TAG_BASIS, {"app": "latest"}) == TAG_BASIS
|