diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cca2d..fba81fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +# v0.5.50-beta — Containers die nergens te vinden waren + +Het dashboard meldde 13 actieve containers en 17 images, terwijl je die nergens +terugzag. Dat kwam doordat de cijfers en de lijst uit twee verschillende bronnen +komen: + +* de **tellers** komen van `docker info` en gaan over de hele Docker-daemon +* de **stacklijst** toont alleen mappen in `LIBRARY_DIR` met een compose-bestand + +Alles wat je buiten Server Up om had gestart — je Forgejo, de act_runner, +containers van vóór je Server Up ging gebruiken — telde dus wel mee maar was +onzichtbaar en onbedienbaar. Een beheertool die dingen telt die hij niet toont, +is verwarrend. + +## Wat er nu is + +- **Overzicht van alle containers op de host.** Onder de stacklijst staat een + blok "Overige containers" met alles wat niet bij een beheerde stack hoort, + inclusief image, poorten en compose-project. Starten, stoppen, herstarten en + logs bekijken kan direct. +- **De dashboardteller vertelt nu het hele verhaal**: onder "actief" staat + hoeveel daarvan via Server Up loopt. +- De eigen container is gemarkeerd als "deze app" en kan niet via dit blok + gestopt worden — daarvoor is de herstartknop in de instellingen, die dat + netjes afhandelt in plaats van zichzelf halverwege een verzoek te stoppen. +- Een viewer ziet de lijst wel maar kan niets bedienen. + +## Verholpen tijdens het bouwen + +Containers zonder poorten én zonder compose-labels vielen weg uit de lijst als +ze toevallig als laatste stonden: `strip()` haalde de lege velden aan het eind +van de uitvoer weg, waarna die regel te weinig kolommen leek te hebben. Gevonden +door de test die precies zo'n container als laatste zet. + +--- + # v0.5.43-beta — Van-tot omrekenen naar een CIDR-blok "Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat diff --git a/VERSION b/VERSION index 6bbadc1..287a4da 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.46-beta +0.5.50-beta diff --git a/server-up/app.py b/server-up/app.py index e03b2fe..2b043cc 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -459,6 +459,75 @@ def api_update_rollback(): return jsonify(ok=True, job_id=jid, tag=st["previous_tag"]) +@app.route("/api/containers") +def api_containers(): + """Alle containers op deze host, met of ze bij een beheerde stack horen. + + Het dashboard telde alles wat de Docker-daemon kent, terwijl de stacklijst + alleen toont wat in LIBRARY_DIR staat. Containers die je buiten Server Up om + hebt gestart — of die er al stonden — waren daardoor wel zichtbaar in de + tellers maar nergens terug te vinden. + """ + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + beheerd = set() + if lib.is_dir(): + beheerd = {d.name for d in lib.iterdir() + if d.is_dir() and not d.name.startswith(".") and docker.has_compose(d)} + + containers = docker.list_containers() + for c in containers: + # Een container hoort bij ons als zijn compose-project overeenkomt met + # een map in de library, of als zijn projectmap daar onder valt. + stack = c.get("project") or "" + in_lib = False + if c.get("working_dir"): + try: + in_lib = Path(c["working_dir"]).resolve().parent == lib + except Exception: + in_lib = False + c["managed"] = bool(stack and stack in beheerd) or in_lib + c["stack"] = stack if c["managed"] else "" + c["self"] = c["name"] == CONTAINER_NAME + + return jsonify(containers=containers, + managed=sum(1 for c in containers if c["managed"]), + total=len(containers), + running=sum(1 for c in containers if c["running"])) + + +@app.route("/api/containers//", methods=["POST"]) +def api_any_container_action(name, action): + """Start/stop/herstart een willekeurige container op deze host. + + Bedoeld voor containers die niet bij een beheerde stack horen. De eigen + container is uitgezonderd: daarvoor is /api/docker/restart, dat de + herstart netjes afhandelt in plaats van zichzelf midden in een verzoek te + stoppen. + """ + if action not in ("start", "stop", "restart"): + return jsonify(ok=False, msg="onbekende actie"), 400 + if name == CONTAINER_NAME: + return jsonify(ok=False, + msg="Gebruik 'Container herstarten' in de instellingen " + "om Server Up zelf te herstarten."), 400 + bestaand = {c["name"] for c in docker.list_containers()} + if name not in bestaand: + return jsonify(ok=False, msg="container niet gevonden"), 404 + ok, msg = docker.container_action(name, action) + audit.log("containers", action, "ok" if ok else "error", ref=name, + ip=request.remote_addr) + return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt")) + + +@app.route("/api/containers//logs") +def api_any_container_logs(name): + bestaand = {c["name"] for c in docker.list_containers()} + if name not in bestaand: + return jsonify(ok=False, msg="container niet gevonden"), 404 + tail = min(max(int(request.args.get("tail", 200)), 1), 2000) + return jsonify(ok=True, logs=docker.container_logs(name, tail)) + + @app.route("/api/docker/images") def api_images(): return jsonify(images=docker.images()) diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 4c87416..5493074 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -137,6 +137,44 @@ def restart_container(name: str) -> tuple[bool, str]: return r.returncode == 0, (r.stderr or r.stdout).strip() +def list_containers(alle: bool = True) -> list[dict]: + """Alle containers op deze host, met hun compose-herkomst. + + De dashboardtellers komen van `docker info` en gaan over de hele daemon, + terwijl de stacklijst alleen toont wat in LIBRARY_DIR staat. Met deze lijst + kunnen we die twee met elkaar rijmen en ook containers tonen die Server Up + niet zelf heeft aangemaakt. + """ + fmt = ("{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Ports}}" + "\t{{.Label \"com.docker.compose.project\"}}" + "\t{{.Label \"com.docker.compose.project.working_dir\"}}") + cmd = [_bin(), "ps", "--format", fmt] + if alle: + cmd.insert(2, "-a") + r = _run(cmd, timeout=20) + if r.returncode != 0: + return [] + uit = [] + # Bewust geen .strip() op de hele uitvoer: een container zonder poorten en + # zonder compose-labels eindigt op lege velden, en die tabs zou strip() + # weghalen — waarna de laatste regel te weinig kolommen leek te hebben en + # stilzwijgend werd overgeslagen. + for regel in r.stdout.splitlines(): + if not regel.strip(): + continue + d = regel.split("\t") + if len(d) < 4: + continue + d += [""] * (8 - len(d)) + uit.append({ + "id": d[0], "name": d[1], "image": d[2], + "state": d[3], "status": d[4], "ports": d[5], + "project": d[6], "working_dir": d[7], + "running": d[3].lower() in ("running", "up"), + }) + return sorted(uit, key=lambda c: c["name"]) + + def container_action(name: str, action: str) -> tuple[bool, str]: """Start, stop of herstart één container. diff --git a/server-up/templates/index.html b/server-up/templates/index.html index a516a33..18e8e6f 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -199,10 +199,18 @@ tailwind.config = {
-
+
+
+
+
Running
+ +
+
+
Stopped
@@ -326,6 +334,46 @@ tailwind.config = {

Geen stacks gevonden

+ + +
+
+ +

Overige containers

+ + +
+

+
+ +
+
@@ -1362,6 +1410,9 @@ function app() { // Uitklapbaar containerpaneel per stack: { : {open, loading, items} } cpanel: {}, + // Containers op deze host, ook die Server Up niet zelf beheert + hostContainers: { containers: [], managed: 0, total: 0, running: 0 }, + // Backups en updatestatus per stack bkStack: '', bkList: [], bkSettings: {}, stackUpd: {}, checkingUpd: false, @@ -1439,10 +1490,11 @@ function app() { await this.loadDocker(); await this.loadModules(); await this.loadDashboard(); + this.loadHostContainers(); this.loadUpdate(); setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000); this.$watch('page', async (p) => { - if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); } + if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); this.loadHostContainers(); } if (p === 'app_store') this.loadAppStore(); if (p === 'docker') this.loadImages(); if (p === 'audit') this.loadAudit(); @@ -1884,6 +1936,26 @@ function app() { .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }, + // ── Containers op deze host ────────────────────────────────────────────── + async loadHostContainers() { + const d = await this.rpc('/api/containers'); + this.hostContainers = d && d.containers ? d : { containers: [], managed: 0, total: 0, running: 0 }; + }, + get unmanaged() { + return (this.hostContainers.containers || []).filter(c => !c.managed); + }, + async hostContainerAction(naam, actie) { + const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/${actie}`, {}); + this.toast(d.ok ? this.t(actie) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er'); + await this.loadHostContainers(); + this.loadDocker(); + }, + async openHostLogs(naam) { + const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/logs`); + this.logs = { title: naam + ' · logs', content: d.logs || '(geen output)' }; + this.modal = 'logs'; + }, + // ── Containers binnen een stack ────────────────────────────────────────── async toggleContainers(stack) { const cur = this.cpanel[stack] || { open: false, loading: false, items: [] }; diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 2101f7c..f6db6f5 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -242,5 +242,9 @@ "range_count": "{count} addresses", "already_added": "already added", "detect_empty": "No network with a default route was found on this server. Fill in the details below manually.", - "wizard_hint": "Run the wizard again to adjust paths and repositories. Your existing stacks are left untouched." + "wizard_hint": "Run the wizard again to adjust paths and repositories. Your existing stacks are left untouched.", + "of_which_managed": "of which {n} via Server Up", + "other_containers": "Other containers", + "other_containers_hint": "These containers run on this host but do not belong to a stack managed by Server Up — started manually, for example, or from before you began using Server Up. They do count towards the dashboard figures.", + "this_app": "this app" } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 2076161..c27b43f 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -242,5 +242,9 @@ "range_count": "{count} adressen", "already_added": "al toegevoegd", "detect_empty": "Er is geen netwerk met een default route gevonden op deze server. Vul de gegevens hieronder handmatig in.", - "wizard_hint": "Doorloop de wizard opnieuw om paden en repositories aan te passen. Je bestaande stacks blijven staan." + "wizard_hint": "Doorloop de wizard opnieuw om paden en repositories aan te passen. Je bestaande stacks blijven staan.", + "of_which_managed": "waarvan {n} via Server Up", + "other_containers": "Overige containers", + "other_containers_hint": "Deze containers draaien op deze host maar horen niet bij een stack die Server Up beheert — bijvoorbeeld handmatig gestart of van vóór je Server Up ging gebruiken. Ze tellen wel mee in de cijfers op het dashboard.", + "this_app": "deze app" } diff --git a/tests/test_containers.py b/tests/test_containers.py new file mode 100644 index 0000000..a7ed0ec --- /dev/null +++ b/tests/test_containers.py @@ -0,0 +1,118 @@ +"""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