v0.8.11-beta - overnemen en verwijderen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 18m31s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-04 00:16:10 +02:00
parent 030d644753
commit 930dce351b
9 changed files with 270 additions and 6 deletions

View file

@ -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/<id>` las `r["id"]` zonder controle. Staat er één regel

View file

@ -1 +1 @@
0.8.10-beta
0.8.11-beta

View file

@ -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/<name>/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/<name>/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():

View file

@ -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)

View file

@ -474,6 +474,14 @@ tailwind.config = {
@click="hostContainerAction(c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" :title="t('logs')"
@click="openHostLogs(c.name)"><span class="mdi mdi-text-box-outline"></span></button>
<!-- Overnemen staat vóór verwijderen, en niet zonder reden:
een container van een compose-project komt na het
verwijderen gewoon terug. Overnemen is meestal wat je
eigenlijk zoekt. -->
<button class="btn-sm" x-show="c.project" :title="t('adopt')"
@click="overnemen(c)"><span class="mdi mdi-import"></span></button>
<button class="btn-sm btn-danger ml-auto" :title="t('remove')"
@click="verwijderContainer(c)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
</div>
@ -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');

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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 ────────────────────

View file

@ -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