From 930dce351b9f4ab367a2ec6190a80f5ff83dc6d9 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 4 Aug 2026 00:16:10 +0200 Subject: [PATCH] v0.8.11-beta - overnemen en verwijderen - overnemen: een compose-app die buiten Server Up om draait, met een knop erbij trekken. Er wordt niets verplaatst of gekopieerd; de stackmap wordt een verwijzing naar de map die de gebruiker al heeft. Kopieren zou de relatieve paden in zijn compose-bestand breken - kan Server Up niet bij die map (hij ziet alleen wat via BASE_DIR gekoppeld is), dan zegt hij dat met waar de map moet staan - verwijderen van een container die niet bij een app hoort, met in de vraag dat alleen de container weggaat en niet zijn gegevens of image - hoort hij bij een compose-project, dan staat erbij dat hij terugkomt zodra dat project ergens anders start en dat overnemen waarschijnlijk is wat je zoekt. Daarom staat overnemen links van verwijderen - een overgenomen app verwijderen haalt alleen de verwijzing weg; rmtree zou daar bovendien op weigeren en halverwege stranden Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy --- CHANGELOG.md | 27 ++++++++++ VERSION | 2 +- server-up/app.py | 78 +++++++++++++++++++++++++++-- server-up/core/docker.py | 5 ++ server-up/templates/index.html | 28 +++++++++++ server-up/translations/en.json | 7 ++- server-up/translations/nl.json | 7 ++- tests/js/menu.test.js | 31 ++++++++++++ tests/test_containers.py | 91 ++++++++++++++++++++++++++++++++++ 9 files changed, 270 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8594e3a..8e51d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,30 @@ +# v0.8.11-beta — Overnemen en verwijderen + +Handmatig gestarte apps stonden in de lijst, maar je kon er alleen mee starten, +stoppen en logs bekijken. Nu ook: + +**Overnemen.** Draait er een compose-app die je buiten Server Up om hebt +gestart, dan trek je hem er met één knop bij. Er wordt **niets verplaatst of +gekopieerd**: de stackmap wordt een verwijzing naar de map die je al hebt. +Kopiëren zou de relatieve paden in je compose-bestand breken — die worden +opgezocht vanaf de map waar dat bestand staat, en dat zou dan een andere zijn. + +Kan Server Up niet bij die map — hij ziet alleen wat via `BASE_DIR` gekoppeld +is — dan zegt hij dat, met waar je hem neer moet zetten. Overnemen zonder erbij +te kunnen heeft geen zin. + +**Verwijderen.** Voor een container die niet bij een app hoort. De vraag +vermeldt dat alleen de container weggaat, niet zijn gegevens of zijn image. + +Hoort hij bij een compose-project, dan staat er bovendien bij dat hij **terugkomt +zodra dat project ergens anders start**, en dat overnemen waarschijnlijk is wat +je zoekt. Dat is de reden dat de overnemen-knop links van de verwijderknop +staat: verwijderen lost dat geval niet op. + +Een overgenomen app verwijderen haalt alleen de verwijzing weg. Jouw map blijft +staan — `shutil.rmtree` zou daar trouwens sowieso op weigeren ("Cannot call +rmtree on a symbolic link") en de verwijdering halverwege laten stranden. + # v0.8.10-beta — Een repo verwijderen mag niet struikelen `DELETE /api/repos/` las `r["id"]` zonder controle. Staat er één regel diff --git a/VERSION b/VERSION index 6ec662d..6b5ca58 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.10-beta +0.8.11-beta diff --git a/server-up/app.py b/server-up/app.py index 1d70812..4dee607 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -507,7 +507,7 @@ def api_any_container_action(name, action): herstart netjes afhandelt in plaats van zichzelf midden in een verzoek te stoppen. """ - if action not in ("start", "stop", "restart"): + if action not in ("start", "stop", "restart", "remove"): return jsonify(ok=False, msg="onbekende actie"), 400 if name == CONTAINER_NAME: return jsonify(ok=False, @@ -522,6 +522,68 @@ def api_any_container_action(name, action): return jsonify(ok=ok, msg=msg or ("gelukt" if ok else "mislukt")) +@app.route("/api/containers//adopt", methods=["POST"]) +def api_container_adopt(name): + """Neem een handmatig gestarte compose-app over als app van Server Up. + + Verwijderen laat zo'n app niet verdwijnen: hoort de container bij een + compose-project, dan staat hij er weer zodra dat project ergens anders + start. Overnemen wél — daarna beheert Server Up hem gewoon mee. + + Er wordt niets gekopieerd of verplaatst. De stackmap wordt een symlink naar + de map die de gebruiker al heeft. Kopiëren zou de relatieve paden in zijn + compose-bestand breken: die worden opgezocht vanaf de map waar het bestand + staat, en dat is dan opeens een andere. + """ + container = next((c for c in docker.list_containers() if c["name"] == name), None) + if container is None: + return jsonify(ok=False, msg="container niet gevonden"), 404 + + project = (container.get("project") or "").strip() + werkmap = (container.get("working_dir") or "").strip() + if not project or not werkmap: + return jsonify(ok=False, msg=( + "Deze container is niet met docker compose gestart, dus er is geen " + "map om over te nemen.")), 400 + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", project): + return jsonify(ok=False, msg=f"Ongeldige projectnaam: {project[:40]}"), 400 + + bron = Path(werkmap) + if not bron.is_dir(): + # De container draait op de host; wij zien alleen wat via BASE_DIR + # gekoppeld is. Kopiëren zou hier niet helpen — we kunnen er niet bij. + basis = cfg.load().get("BASE_DIR") or cfg.DEFAULTS["BASE_DIR"] + return jsonify(ok=False, msg=( + f"Server Up kan niet bij {werkmap}: die map is niet in de container " + f"gekoppeld. Zet hem onder {basis}, of koppel hem mee in " + f"docker-compose.yml.")), 400 + if not docker.has_compose(bron): + return jsonify(ok=False, msg=( + f"Geen compose-bestand in {werkmap}.")), 400 + + lib = Path(cfg.load()["LIBRARY_DIR"]).resolve() + lib.mkdir(parents=True, exist_ok=True) + doel = lib / project + if doel.exists() or doel.is_symlink(): + return jsonify(ok=False, msg=( + f"Er is al een app met de naam '{project}'.")), 409 + if bron.resolve() == doel.resolve().parent or bron.resolve().parent == lib: + return jsonify(ok=False, msg=( + "Deze map staat al in je appsmap; hij hoort dus al bij Server Up. " + "Ontbreekt hij in de lijst, kijk dan of er een compose-bestand in " + "staat.")), 400 + + try: + doel.symlink_to(bron.resolve()) + except OSError as e: + return jsonify(ok=False, msg=f"Overnemen mislukte: {e}"), 500 + + audit.log("stacks", "adopt", "ok", ref=project, ip=request.remote_addr) + return jsonify(ok=True, name=project, msg=( + f"'{project}' hoort nu bij Server Up. Er is niets verplaatst: de map " + f"{werkmap} blijft waar hij staat.")) + + @app.route("/api/containers//logs") def api_any_container_logs(name): bestaand = {c["name"] for c in docker.list_containers()} @@ -733,8 +795,18 @@ def api_stack_action(name, action): if docker.has_compose(pdir): docker.compose_down(pdir, log_fn=lf, volumes=True, name=name) if mode in ("full", "all") and pdir.is_dir(): - shutil.rmtree(pdir) - jobs.log(qq, "ok", "Bestanden verwijderd") + # Een overgenomen app is een symlink naar een map van de + # gebruiker zelf. Die map is niet van ons; alleen de + # verwijzing halen we weg. `shutil.rmtree` zou hier + # bovendien weigeren ("Cannot call rmtree on a symbolic + # link") en de verwijdering halverwege laten stranden. + if pdir.is_symlink(): + doelmap = os.path.realpath(pdir) + pdir.unlink() + jobs.log(qq, "ok", f"Losgekoppeld; {doelmap} blijft staan") + else: + shutil.rmtree(pdir) + jobs.log(qq, "ok", "Bestanden verwijderd") if mode == "all": dd = Path(cfg.load()["DATA_DIR"]).resolve() / name if dd.is_dir(): diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 8bcca72..3a82081 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -181,6 +181,11 @@ def container_action(name: str, action: str) -> tuple[bool, str]: De aanroeper is verantwoordelijk voor het controleren dát deze container bij een door Server Up beheerde stack hoort (zie app.api_container_action). """ + if action == "remove": + # -f: een draaiende container hoeft niet eerst apart gestopt te worden. + # De aanroeper heeft er al een bevestiging voor gevraagd. + r = _run([_bin(), "rm", "-f", name], timeout=120) + return r.returncode == 0, (r.stderr or r.stdout).strip() if action not in ("start", "stop", "restart"): return False, "onbekende actie" r = _run([_bin(), action, name], timeout=60) diff --git a/server-up/templates/index.html b/server-up/templates/index.html index fe12332..f9c93ae 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -474,6 +474,14 @@ tailwind.config = { @click="hostContainerAction(c.name, 'restart')"> + + + @@ -3535,6 +3543,26 @@ function app() { get unmanaged() { return (this.hostContainers.containers || []).filter(c => !c.managed); }, + async overnemen(c) { + if (!confirm(this.t('adopt_confirm', { naam: c.name }))) return; + const d = await this.req(`/api/containers/${encodeURIComponent(c.name)}/adopt`, + { method: 'POST' }); + this.toast(d.msg || (d.ok ? 'ok' : this.t('error')), d.ok ? 'ok' : 'er'); + if (d.ok) { await this.loadStacks(); await this.loadHostContainers(); } + }, + async verwijderContainer(c) { + // Hoort hij bij een compose-project, dan komt hij terug zodra dat project + // ergens anders start. Dat hoort in de vraag te staan, niet achteraf. + let vraag = this.t('remove_container_confirm', { naam: c.name }); + if (c.project) { + vraag += '\n\n' + this.t('remove_container_project', { project: c.project }); + } + if (!confirm(vraag)) return; + const d = await this.req(`/api/containers/${encodeURIComponent(c.name)}/remove`, + { method: 'POST' }); + this.toast(d.msg || (d.ok ? 'ok' : this.t('error')), d.ok ? 'ok' : 'er'); + if (d.ok) await this.loadHostContainers(); + }, 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'); diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 0dd93b3..8014ef0 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -466,5 +466,10 @@ "manual_started": "Started manually", "manual_hint": "These apps run on this host but were not created by Server Up — started manually, or from before you began using Server Up. You can start, stop and view their logs here.", "containers_n": "{n} containers", - "no_apps_found": "No apps found" + "no_apps_found": "No apps found", + "adopt": "Adopt", + "adopt_confirm": "Adopt '{naam}' as a Server Up app?\n\nNothing is moved or copied — the folder stays where it is. Server Up will simply manage it too.", + "remove_container_confirm": "Remove container '{naam}'?\n\nThis removes only the container, not its data or its image.", + "remove_container_project": "Note: this container belongs to compose project '{project}'. It will come back as soon as that project is started elsewhere. Adopting is probably what you want.", + "audit_act_adopt": "Adopted" } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 5c707d2..630ae30 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -466,5 +466,10 @@ "manual_started": "Handmatig gestart", "manual_hint": "Deze apps draaien op deze host maar zijn niet door Server Up aangemaakt — handmatig gestart, of van vóór je Server Up ging gebruiken. Je kunt ze hier starten, stoppen en hun logs bekijken.", "containers_n": "{n} containers", - "no_apps_found": "Geen apps gevonden" + "no_apps_found": "Geen apps gevonden", + "adopt": "Overnemen", + "adopt_confirm": "'{naam}' overnemen als app van Server Up?\n\nEr wordt niets verplaatst of gekopieerd — de map blijft staan waar hij staat. Server Up gaat hem alleen mee beheren.", + "remove_container_confirm": "Container '{naam}' verwijderen?\n\nDit verwijdert alleen de container, niet zijn gegevens of zijn image.", + "remove_container_project": "Let op: deze container hoort bij het compose-project '{project}'. Hij komt terug zodra dat project ergens anders wordt gestart. Overnemen is dan waarschijnlijk wat je zoekt.", + "audit_act_adopt": "Overgenomen" } diff --git a/tests/js/menu.test.js b/tests/js/menu.test.js index 18464f5..195672e 100644 --- a/tests/js/menu.test.js +++ b/tests/js/menu.test.js @@ -451,6 +451,37 @@ check('balk staat halverwege', c.job.progress.pct === 50); check('nog een keer opslaan zonder padwijziging lukt', (await c.saveSettings()) === true); check('en herlaadt dan niet opnieuw', herladen === 0); + // ── Overnemen en verwijderen ────────────────────────────────────────────── + // Een container van een compose-project komt na het verwijderen gewoon terug. + // Dat hoort in de vraag te staan, niet achteraf te blijken. + let gevraagd2 = '', verstuurd2 = null; + global.confirm = (v) => { gevraagd2 = v; return true; }; + c.req = async (url, opt) => { verstuurd2 = url + ' ' + (opt || {}).method; return {ok: true, msg: 'gelukt'}; }; + c.loadStacks = async () => {}; c.loadHostContainers = async () => {}; + c.toast = () => {}; + + await c.verwijderContainer({name: 'oude-nginx', project: 'nginx-proj'}); + check('de waarschuwing over het project staat in de vraag', + gevraagd2.includes('remove_container_project')); + check('en de container wordt verwijderd', + verstuurd2 === '/api/containers/oude-nginx/remove POST'); + + gevraagd2 = ''; + await c.verwijderContainer({name: 'los-ding', project: ''}); + check('zonder project geen projectwaarschuwing', + !gevraagd2.includes('remove_container_project')); + + await c.overnemen({name: 'homepage', project: 'homepage'}); + check('overnemen roept het juiste eindpunt aan', + verstuurd2 === '/api/containers/homepage/adopt POST'); + + // Afbreken bij de bevestiging hoort niets te doen. + verstuurd2 = null; + global.confirm = () => false; + await c.verwijderContainer({name: 'oude-nginx', project: ''}); + await c.overnemen({name: 'homepage', project: 'homepage'}); + check('afbreken doet niets', verstuurd2 === null); + c.rpc = poortRpc; // ── Eén lijst voor beheerde en handmatig gestarte apps ──────────────────── diff --git a/tests/test_containers.py b/tests/test_containers.py index 4562ca4..38b1de7 100644 --- a/tests/test_containers.py +++ b/tests/test_containers.py @@ -149,3 +149,94 @@ def test_een_gewone_app_blijft_gewoon_staan(client, env, monkeypatch): 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