diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx index 219acfa..19ab21a 100644 --- a/app/src/components/detail/ConfigPanel.tsx +++ b/app/src/components/detail/ConfigPanel.tsx @@ -73,9 +73,17 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane } const handleDelete = async (compName: string) => { + // Deleting a program cascades: its deployments are torn down and removed too + // (a program and its 1:1 tool/static deployment are one thing). Deleting a + // service/job deployment is a plain removal (keeps the program). + const url = + configSection === "programs" + ? `/config/programs/${compName}?cascade=true` + : `/config/${writeSection}/${compName}` try { - await apiClient.delete(`/config/${writeSection}/${compName}`) + await apiClient.delete(url) qc.invalidateQueries({ queryKey: [configSection] }) + qc.invalidateQueries({ queryKey: ["programs"] }) navigate("/") } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e) diff --git a/app/src/components/detail/ProgramFields.tsx b/app/src/components/detail/ProgramFields.tsx index d42aa3a..f787685 100644 --- a/app/src/components/detail/ProgramFields.tsx +++ b/app/src/components/detail/ProgramFields.tsx @@ -161,14 +161,25 @@ export function ProgramFields({ program, onSave, onDelete }: Props) { saved={saved} onSave={handleSave} onDelete={onDelete ? () => onDelete(program.id) : undefined} - deleteLabel="Remove program" - confirmMessage={`Remove program "${program.id}" from castle.yaml? (Source on disk is untouched.)`} - deleteBlocked={ - program.services.length + program.jobs.length > 0 - ? "Programs with active jobs or services cannot be removed — delete those first." - : undefined - } + deleteLabel="Delete program" + confirmMessage={deleteConfirm(program)} /> ) } + +/** Deleting a program cascades to its deployments (tear down + remove), so the + * confirm names exactly what will go. Source on disk is always left untouched. */ +function deleteConfirm(program: ProgramDetail): string { + const deps = [...program.services, ...program.jobs] + const kind = program.kind + const own = + kind === "tool" + ? " This uninstalls it from PATH." + : kind === "static" + ? " This stops serving it." + : "" + const also = + deps.length > 0 ? ` Its deployments (${deps.join(", ")}) are torn down and removed too.` : "" + return `Delete "${program.id}"?${own}${also} (Source on disk is untouched.)` +} diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 491d84d..8dc0d7c 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -214,11 +214,15 @@ def save_program(name: str, request: ProgramConfigRequest) -> dict: @router.delete("/programs/{name}") -def delete_program(name: str) -> dict: +async def delete_program(name: str, cascade: bool = False) -> dict: """Remove a program from castle.yaml. - Refuses if any service or job still references the program — those - deployments must be removed first so no dangling `program:` ref is left. + Without ``cascade``, refuses (409) if any deployment still references the + program. With ``cascade=true`` it first tears down and removes those + deployments — dispatched by manager: uninstall a tool from PATH, stop+disable + a service or job, drop a static route — so nothing is left running, installed, + or served, then removes the program. A program and its 1:1 tool/static + deployment are one thing to the user, so this makes "Delete" just work. """ config = get_config() if name not in config.programs: @@ -227,17 +231,43 @@ def delete_program(name: str) -> dict: detail=f"Program '{name}' not found", ) refs = [d for d, spec in config.deployments.items() if spec.program == name] - if refs: + if refs and not cascade: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( - "Programs with active jobs or services cannot be removed. " - f"Delete these first: {', '.join(refs)}" + f"'{name}' still has deployments ({', '.join(refs)}). " + "Delete them first, or pass cascade=true to remove them too." ), ) + + removed: list[str] = [] + if refs: + from castle_core.lifecycle import deactivate + + for ref in refs: + # Best-effort teardown (uninstall/stop/disable); still remove the config + # even if the runtime is already gone. + try: + await deactivate(ref, config, config.root) + except Exception: + pass + del config.deployments[ref] + removed.append(ref) + del config.programs[name] save_config(config) - return {"ok": True, "program": name, "action": "deleted"} + + if removed: + # Converge the runtime: prune any orphan units and regenerate the Caddyfile + # (dropping static routes), then reload the gateway. + from castle_core.deploy import deploy + + try: + deploy() + except Exception: + pass + + return {"ok": True, "program": name, "action": "deleted", "removed_deployments": removed} def _save_deployment(name: str, config_dict: dict) -> dict: diff --git a/castle-api/tests/test_config_delete.py b/castle-api/tests/test_config_delete.py new file mode 100644 index 0000000..7af3b63 --- /dev/null +++ b/castle-api/tests/test_config_delete.py @@ -0,0 +1,38 @@ +"""Tests for cascade program deletion via /config/programs/{name}.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +class TestCascadeDelete: + def test_blocked_without_cascade(self, client: TestClient) -> None: + """A program with a referencing deployment refuses a plain delete (409).""" + # test-tool (program) is referenced by test-tool (a path deployment). + r = client.delete("/config/programs/test-tool") + assert r.status_code == 409 + assert "cascade" in r.json()["detail"] + + def test_cascade_removes_program_and_deployment( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + """cascade=true tears down + removes the deployments and the program.""" + # Stub the runtime teardown so the test has no systemctl/deploy side effects. + import castle_core.deploy as dp + import castle_core.lifecycle as lc + + async def _noop(*_a: object, **_k: object) -> None: + return None + + monkeypatch.setattr(lc, "deactivate", _noop) + monkeypatch.setattr(dp, "deploy", lambda *_a, **_k: None) + + r = client.delete("/config/programs/test-tool?cascade=true") + assert r.status_code == 200 + body = r.json() + assert body["action"] == "deleted" + assert "test-tool" in body["removed_deployments"] + + # The program is gone from the catalog. + assert client.get("/programs/test-tool").status_code == 404