From fa8890ac458f326145223d2af78cdf043adfd79d Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 11:42:48 -0700 Subject: [PATCH] Delete program cascades to its deployments (no more dead-end gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program and its 1:1 tool/static deployment are one thing to the user, so 'Delete program' now just works instead of refusing. DELETE /config/programs/ {name}?cascade=true tears down each referencing deployment (manager-aware: uninstall a tool from PATH, stop+disable a service/job, drop a static route), removes them and the program, then converges the runtime (prune orphan units, regenerate the Caddyfile, reload the gateway). Without cascade it still 409s (safe API default). The dashboard drops the 'can't remove while deployed' block, labels the action 'Delete program', and the confirm names exactly what will go. Verified live: throwaway tool → cascade delete removes program + path deployment, real config untouched; plain delete of a referenced program still 409s. castle-api 57 passed. --- app/src/components/detail/ConfigPanel.tsx | 10 ++++- app/src/components/detail/ProgramFields.tsx | 25 ++++++++---- castle-api/src/castle_api/config_editor.py | 44 +++++++++++++++++---- castle-api/tests/test_config_delete.py | 38 ++++++++++++++++++ 4 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 castle-api/tests/test_config_delete.py 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