v0.8.11-beta - overnemen en verwijderen
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 18m31s
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:
parent
030d644753
commit
930dce351b
9 changed files with 270 additions and 6 deletions
27
CHANGELOG.md
27
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/<id>` las `r["id"]` zonder controle. Staat er één regel
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.10-beta
|
||||
0.8.11-beta
|
||||
|
|
|
|||
|
|
@ -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,6 +795,16 @@ 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():
|
||||
# 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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue