server-up/tests/test_containers.py
Ramon d3ebf40165
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m37s
feat: wachtwoordsync, slot per app, sneller dashboard en versietags
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
2026-08-07 00:14:26 +02:00

310 lines
13 KiB
Python

"""Containers op de host: wat is beheerd en wat niet.
Het dashboard telt alles wat de Docker-daemon kent, terwijl de stacklijst alleen
toont wat in LIBRARY_DIR staat. Containers die daarbuiten vallen waren zichtbaar
in de cijfers maar nergens terug te vinden.
"""
import pytest
from conftest import login
PS_UITVOER = "\n".join([
"abc123\tvaultwarden\tvaultwarden/server:latest\trunning\tUp 2 hours\t0.0.0.0:8222->80/tcp\tvaultwarden\t/opt/serverup/stacks/vaultwarden",
"def456\tforgejo\tcodeberg.org/forgejo/forgejo:1.21\trunning\tUp 3 days\t0.0.0.0:3000->3000/tcp\tforgejo\t/opt/docker/forgejo",
"ghi789\tact_runner\tgitea/act_runner:latest\trunning\tUp 3 days\t\t\t",
"jkl012\tserver-up\tserver-up:0.5.46-beta\trunning\tUp 5 minutes\t127.0.0.1:5000->5000/tcp\tserver-up\t/opt/docker/server-up",
"mno345\toud-testje\tnginx:latest\texited\tExited (0) 4 days ago\t\t\t",
])
@pytest.fixture
def host(env, monkeypatch):
from core import docker
monkeypatch.setattr(docker, "_run", lambda *a, **k: type(
"R", (), {"returncode": 0, "stdout": PS_UITVOER, "stderr": ""})())
return docker
def test_list_containers_leest_alle_velden(host):
c = host.list_containers()
assert len(c) == 5
vw = next(x for x in c if x["name"] == "vaultwarden")
assert vw["image"] == "vaultwarden/server:latest"
assert vw["running"] is True
assert vw["project"] == "vaultwarden"
assert vw["working_dir"] == "/opt/serverup/stacks/vaultwarden"
def test_list_containers_zonder_labels(host):
"""Een container zonder compose-labels heeft lege project-/werkmapvelden."""
c = next(x for x in host.list_containers() if x["name"] == "act_runner")
assert c["project"] == "" and c["working_dir"] == ""
assert c["running"] is True
def test_gestopte_container_wordt_herkend(host):
c = next(x for x in host.list_containers() if x["name"] == "oud-testje")
assert c["running"] is False
def test_endpoint_scheidt_beheerd_van_de_rest(client, env, monkeypatch):
from core import docker
login(client)
# Eén stack in de library: vaultwarden.
stack = env["lib"] / "vaultwarden"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text("services:\n app:\n image: nginx\n")
monkeypatch.setattr(docker, "_run", lambda *a, **k: type(
"R", (), {"returncode": 0,
"stdout": PS_UITVOER.replace("/opt/serverup/stacks/vaultwarden",
str(stack)),
"stderr": ""})())
d = client.get("/api/containers").get_json()
beheerd = [c["name"] for c in d["containers"] if c["managed"]]
overig = [c["name"] for c in d["containers"] if not c["managed"]]
assert beheerd == ["vaultwarden"]
assert set(overig) == {"forgejo", "act_runner", "server-up", "oud-testje"}
assert d["managed"] == 1
assert d["total"] == 5
assert d["running"] == 4
def test_eigen_container_wordt_gemarkeerd(client, env, monkeypatch):
from core import docker
import app as app_module
login(client)
monkeypatch.setattr(app_module, "CONTAINER_NAME", "server-up")
monkeypatch.setattr(docker, "_run", lambda *a, **k: type(
"R", (), {"returncode": 0, "stdout": PS_UITVOER, "stderr": ""})())
d = client.get("/api/containers").get_json()
assert next(c for c in d["containers"] if c["name"] == "server-up")["self"] is True
def test_eigen_container_kan_niet_gestopt_worden(client, monkeypatch):
"""Zichzelf stoppen midden in een verzoek levert een halve actie op; daar
is de aparte herstartknop voor."""
import app as app_module
csrf = login(client)
monkeypatch.setattr(app_module, "CONTAINER_NAME", "server-up")
r = client.post("/api/containers/server-up/stop", headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "herstarten" in r.get_json()["msg"]
def test_onbekende_container(client, host):
csrf = login(client)
r = client.post("/api/containers/bestaatniet/stop", headers={"X-CSRF-Token": csrf})
assert r.status_code == 404
def test_onbekende_actie(client, host):
csrf = login(client)
r = client.post("/api/containers/forgejo/verwijder", headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_viewer_mag_geen_containers_bedienen(client, host):
csrf = login(client)
client.post("/api/auth/users",
json={"username": "kijker", "password": "eenlangwachtwoord",
"role": "viewer"},
headers={"X-CSRF-Token": csrf})
r = client.post("/api/auth/login",
json={"username": "kijker", "password": "eenlangwachtwoord"})
kijker_csrf = r.get_json()["csrf_token"]
assert client.get("/api/containers").status_code == 200
assert client.post("/api/containers/forgejo/stop",
headers={"X-CSRF-Token": kijker_csrf}).status_code == 403
# ── Een map zonder compose-bestand ───────────────────────────────────────────
# Die werd stilzwijgend overgeslagen: niet in de lijst, en de containers die
# erbij hoorden belandden onder "handmatig gestart". Je zag een leeg scherm en
# nergens stond waarom.
def test_map_zonder_compose_verdwijnt_niet_uit_de_lijst(client, env):
from conftest import login
login(client)
(env["lib"] / "kapotte-app").mkdir(parents=True, exist_ok=True)
d = client.get("/api/stacks").get_json()
namen = [s["name"] for s in d]
assert "kapotte-app" in namen, f"de map is niet zichtbaar: {namen}"
s = next(s for s in d if s["name"] == "kapotte-app")
assert s["has_compose"] is False, "hij doet alsof er compose is"
assert s["containers"] == []
assert s["running"] is False
def test_een_gewone_app_blijft_gewoon_staan(client, env, monkeypatch):
from conftest import login
from core import docker
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [])
login(client)
d = env["lib"] / "echte-app"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text("services:\n web:\n image: nginx\n")
uit = client.get("/api/stacks").get_json()
s = next(s for s in uit if s["name"] == "echte-app")
assert s["has_compose"] is True
# ── Overnemen ────────────────────────────────────────────────────────────────
# Verwijderen laat een handmatig gestarte compose-app niet verdwijnen: hij staat
# er weer zodra dat project ergens anders start. Overnemen wél.
def _nep_container(monkeypatch, **velden):
from core import docker
c = {"id": "abc", "name": "losse-app", "image": "nginx", "state": "running",
"status": "Up", "ports": "", "project": "losse-app",
"working_dir": "", "running": True}
c.update(velden)
monkeypatch.setattr(docker, "list_containers", lambda *a, **k: [c])
return c
def test_overnemen_maakt_een_verwijzing_en_verplaatst_niets(client, env, monkeypatch, tmp_path):
from conftest import login
csrf = login(client)
bron = tmp_path / "eigen-map"
bron.mkdir()
(bron / "docker-compose.yml").write_text("services:\n web:\n image: nginx\n")
_nep_container(monkeypatch, working_dir=str(bron))
r = client.post("/api/containers/losse-app/adopt", headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
doel = env["lib"] / "losse-app"
assert doel.is_symlink(), "er is geen verwijzing gemaakt"
assert doel.resolve() == bron.resolve()
assert (bron / "docker-compose.yml").is_file(), "de bron is aangeraakt"
# En hij duikt op in de appslijst.
from core import docker
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [])
namen = [s["name"] for s in client.get("/api/stacks").get_json()]
assert "losse-app" in namen, namen
def test_overnemen_van_een_map_waar_we_niet_bij_kunnen(client, env, monkeypatch):
"""De container draait op de host; wij zien alleen wat gekoppeld is."""
from conftest import login
csrf = login(client)
_nep_container(monkeypatch, working_dir="/ergens/op/de/host")
r = client.post("/api/containers/losse-app/adopt", headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "niet in de container gekoppeld" in r.get_json()["msg"]
def test_overnemen_zonder_compose_labels(client, env, monkeypatch):
from conftest import login
csrf = login(client)
_nep_container(monkeypatch, project="", working_dir="")
r = client.post("/api/containers/losse-app/adopt", headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
assert "niet met docker compose gestart" in r.get_json()["msg"]
def test_overnemen_botst_niet_met_een_bestaande_app(client, env, monkeypatch, tmp_path):
from conftest import login
csrf = login(client)
(env["lib"] / "losse-app").mkdir(parents=True)
bron = tmp_path / "eigen-map"
bron.mkdir()
(bron / "docker-compose.yml").write_text("services: {}\n")
_nep_container(monkeypatch, working_dir=str(bron))
r = client.post("/api/containers/losse-app/adopt", headers={"X-CSRF-Token": csrf})
assert r.status_code == 409
assert "al een app" in r.get_json()["msg"]
def test_een_losse_container_mag_verwijderd_worden(client, env, monkeypatch):
from conftest import login
from core import docker
csrf = login(client)
_nep_container(monkeypatch)
gedaan = []
monkeypatch.setattr(docker, "container_action",
lambda n, a: (gedaan.append((n, a)), (True, "weg"))[1])
r = client.post("/api/containers/losse-app/remove", headers={"X-CSRF-Token": csrf})
assert r.status_code == 200, r.get_json()
assert gedaan == [("losse-app", "remove")]
def test_server_up_verwijdert_zichzelf_niet(client, env, monkeypatch):
from conftest import login
import app as app_mod
csrf = login(client)
_nep_container(monkeypatch, name=app_mod.CONTAINER_NAME)
r = client.post(f"/api/containers/{app_mod.CONTAINER_NAME}/remove",
headers={"X-CSRF-Token": csrf})
assert r.status_code == 400
def test_compose_ps_toont_alleen_werkelijk_gepubliceerde_poorten(host):
container = {
"Ports": "8443/tcp, 8843/tcp",
"Publishers": [
{"TargetPort": 8443, "PublishedPort": 8443, "Protocol": "tcp"},
{"TargetPort": 8443, "PublishedPort": 8443, "Protocol": "tcp"},
{"TargetPort": 8843, "PublishedPort": 0, "Protocol": "tcp"},
],
}
assert host._published_ports(container) == "8443->8443/tcp"
def test_compose_ps_valt_bij_oude_compose_terug_op_ports(host):
assert host._published_ports(
{"Ports": "0.0.0.0:8080->80/tcp"}) == "0.0.0.0:8080->80/tcp"
# ── Eén docker-aanroep voor de hele lijst ────────────────────────────────────
def test_stacklijst_doet_geen_aanroep_per_app(client, env, monkeypatch):
"""De lijst deed een `docker compose ps` per stackmap: bij vijftien apps
vijftien processen per keer verversen, en nog eens zoveel bij elk openen
van de installatiemodal."""
from core import docker
for naam in ("een", "twee", "drie"):
d = env["lib"] / naam
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text(f"services:\n {naam}:\n image: nginx\n")
aanroepen = {"ps": 0}
def _lijst(alle=True):
aanroepen["ps"] += 1
return [
{"id": "a", "name": "een-app-1", "image": "nginx", "state": "running",
"status": "Up", "ports": "0.0.0.0:8080->80/tcp", "project": "een",
"working_dir": str(env["lib"] / "een"), "service": "app",
"running": True},
{"id": "b", "name": "twee-app-1", "image": "nginx", "state": "exited",
"status": "Exited (0)", "ports": "", "project": "twee",
"working_dir": str(env["lib"] / "twee"), "service": "app",
"running": False},
]
monkeypatch.setattr(docker, "list_containers", _lijst)
monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: pytest.fail(
"er werd alsnog een aanroep per app gedaan"))
login(client)
per_naam = {s["name"]: s for s in client.get("/api/stacks").get_json()}
assert aanroepen["ps"] == 1, "meer dan één docker-aanroep voor de hele lijst"
assert per_naam["een"]["running"] is True
assert per_naam["een"]["containers"][0]["service"] == "app"
assert per_naam["twee"]["running"] is False
# Een app zonder containers blijft gewoon in de lijst staan.
assert per_naam["drie"]["running"] is False
assert per_naam["drie"]["containers"] == []
def test_containers_worden_op_project_gegroepeerd(host):
per_project = host.containers_per_project()
assert set(per_project) == {"vaultwarden", "forgejo", "server-up"}
assert per_project["vaultwarden"][0]["name"] == "vaultwarden"
# Containers zonder compose-label horen bij geen enkele app.
assert all("act_runner" not in [c["name"] for c in v]
for v in per_project.values())