api+ui: kind-scoped save/delete endpoints and links

API: /config/{services,jobs,tools,static}/{name} now pin the twin they
target — _save_deployment/_delete_deployment take an explicit kind so a
patch to a 'backup' service can't bleed into a 'backup' job/tool. Add
/tools and /static endpoints; keep /deployments/{name} kind-agnostic.
New test_kind_twins proves per-kind save/delete isolation on disk.

UI: ConfigPanel and CreateDeploymentForm write to the kind-scoped
resource; GatewayPanel/NodeDetail/DeploymentsSection link via a shared
detailPath(name, kind) helper instead of the ambiguous /deployment/:name.

Includes incidental ruff-format reflow of untouched api files.
This commit is contained in:
2026-07-06 02:51:08 -07:00
parent 0cb41851cf
commit 20bf78caf1
24 changed files with 353 additions and 98 deletions

View File

@@ -18,9 +18,9 @@ class TestDeploymentEditSafety:
the shape the edit form consumes (launcher nested under `run`, plus
reach/expose) — not the flat runtime view (`run_cmd`, top-level launcher)."""
m = client.get("/deployments/test-svc").json()["manifest"]
assert m["run"]["launcher"] == "python" # spec shape (nested)
assert "run_cmd" not in m # runtime-only key absent
assert m.get("reach") == "internal" # normalized from proxy:true
assert m["run"]["launcher"] == "python" # spec shape (nested)
assert "run_cmd" not in m # runtime-only key absent
assert m.get("reach") == "internal" # normalized from proxy:true
assert m["expose"]["http"]["internal"]["port"] == 19000
def test_save_roundtrip_preserves_spec_fields(self, client: TestClient) -> None:
@@ -39,11 +39,13 @@ class TestDeploymentEditSafety:
This is what makes the astro-class regression structurally impossible —
even a client that sends a lossy payload can't nuke fields it omitted."""
before = client.get("/deployments/test-svc").json()["manifest"]
resp = client.put("/config/deployments/test-svc", json={"config": {"reach": "off"}})
resp = client.put(
"/config/deployments/test-svc", json={"config": {"reach": "off"}}
)
assert resp.status_code == 200, resp.text
after = client.get("/deployments/test-svc").json()["manifest"]
assert after["reach"] == "off" # the change applied
assert after["program"] == before["program"] # untouched → preserved
assert after["reach"] == "off" # the change applied
assert after["program"] == before["program"] # untouched → preserved
assert after["run"] == before["run"]
assert after["expose"] == before["expose"]
@@ -58,9 +60,9 @@ class TestDeploymentEditSafety:
)
assert resp.status_code == 200, resp.text
after = client.get("/deployments/test-svc").json()["manifest"]
assert after.get("expose") is None # cleared
assert after.get("expose") is None # cleared
assert after["reach"] == "off"
assert after["program"] == "test-svc-comp" # rest preserved
assert after["program"] == "test-svc-comp" # rest preserved
class TestProgramEditSafety:
@@ -73,6 +75,6 @@ class TestProgramEditSafety:
)
assert resp.status_code == 200, resp.text
m = client.get("/programs/wired-in").json()["manifest"]
assert m["description"] == "renamed" # change applied
assert m["source"].endswith("wired-in") # source preserved
assert m["commands"]["lint"] == [["make", "lint"]] # commands preserved
assert m["description"] == "renamed" # change applied
assert m["source"].endswith("wired-in") # source preserved
assert m["commands"]["lint"] == [["make", "lint"]] # commands preserved

View File

@@ -37,12 +37,21 @@ def public_client(
)
)
(root / "programs").mkdir()
(root / "programs" / "calc.yaml").write_text(yaml.dump({"source": str(root / "calc")}))
(root / "calc" / "public").mkdir(parents=True) # static build dir must exist to route
(root / "programs" / "calc.yaml").write_text(
yaml.dump({"source": str(root / "calc")})
)
(root / "calc" / "public").mkdir(
parents=True
) # static build dir must exist to route
deps = {
# public STATIC (the calculator case)
"calc": {"program": "calc", "manager": "caddy", "root": "public", "reach": "public"},
"calc": {
"program": "calc",
"manager": "caddy",
"root": "public",
"reach": "public",
},
# public systemd service
"web": {
"manager": "systemd",
@@ -80,20 +89,42 @@ def public_client(
),
deployed={
"calc": Deployment(
manager="caddy", run_cmd=[], kind="static", subdomain="calc",
public=True, static_root=str(root / "calc" / "public"),
manager="caddy",
run_cmd=[],
kind="static",
subdomain="calc",
public=True,
static_root=str(root / "calc" / "public"),
),
"web": Deployment(
manager="systemd", launcher="python", run_cmd=["x"], kind="service",
port=9001, subdomain="web", public=True, managed=True,
manager="systemd",
launcher="python",
run_cmd=["x"],
kind="service",
port=9001,
subdomain="web",
public=True,
managed=True,
),
"intern": Deployment(
manager="systemd", launcher="python", run_cmd=["x"], kind="service",
port=9002, subdomain="intern", public=False, managed=True,
manager="systemd",
launcher="python",
run_cmd=["x"],
kind="service",
port=9002,
subdomain="intern",
public=False,
managed=True,
),
"pg": Deployment(
manager="systemd", launcher="container", run_cmd=["x"], kind="service",
port=None, subdomain=None, tcp_port=5432, managed=True,
manager="systemd",
launcher="container",
run_cmd=["x"],
kind="service",
port=None,
subdomain=None,
tcp_port=5432,
managed=True,
),
},
)
@@ -125,7 +156,9 @@ class TestGatewayPublicUrl:
def test_public_service_has_public_url(self, public_client: TestClient) -> None:
assert _routes(public_client)["web"]["public_url"] == "https://web.pub.test"
def test_internal_service_has_no_public_url(self, public_client: TestClient) -> None:
def test_internal_service_has_no_public_url(
self, public_client: TestClient
) -> None:
assert _routes(public_client)["intern"]["public_url"] is None
@@ -152,10 +185,10 @@ class TestDetailEndpointInvariant:
@pytest.mark.parametrize(
"endpoint,expected_reach",
[
("/deployments/calc", "public"), # static, unified endpoint
("/services/calc", "public"), # static, /services endpoint (broke here)
("/deployments/web", "public"), # service, unified endpoint
("/services/web", "public"), # service, /services endpoint
("/deployments/calc", "public"), # static, unified endpoint
("/services/calc", "public"), # static, /services endpoint (broke here)
("/deployments/web", "public"), # service, unified endpoint
("/services/web", "public"), # service, /services endpoint
("/deployments/intern", "internal"),
("/services/intern", "internal"),
],
@@ -164,8 +197,8 @@ class TestDetailEndpointInvariant:
self, public_client: TestClient, endpoint: str, expected_reach: str
) -> None:
m = public_client.get(endpoint).json()["manifest"]
assert m.get("reach") == expected_reach # spec field present
assert "run_cmd" not in m # not the runtime view
assert m.get("reach") == expected_reach # spec field present
assert "run_cmd" not in m # not the runtime view
@pytest.mark.parametrize("name", ["calc", "web", "intern"])
def test_deployments_and_services_endpoints_agree(

View File

@@ -7,15 +7,23 @@ from pathlib import Path
from fastapi.testclient import TestClient
_ENV = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_CONFIG_SYSTEM": "/dev/null",
}
def _git(cwd: Path, *args: str) -> None:
subprocess.run(["git", "-C", str(cwd), *args], check=True,
capture_output=True, text=True, env={**os.environ, **_ENV})
subprocess.run(
["git", "-C", str(cwd), *args],
check=True,
capture_output=True,
text=True,
env={**os.environ, **_ENV},
)
def _commit(cwd: Path, fname: str) -> None:

View File

@@ -0,0 +1,100 @@
"""A tool, a service, and a job may share a name — kind-scoped endpoints must
address (and mutate) exactly one twin.
These guard the collision case the per-kind identity refactor enables: a
`backup` service + job + tool coexisting. The risk is a save/delete against one
kind bleeding into a same-named twin of another kind. We assert against the
on-disk config (load_config) so we're testing the persisted invariant, not a
response echo.
"""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from castle_core.config import load_config
# Minimal, valid specs for each kind — all named "backup", all referencing the
# same program. Only the service is HTTP-exposed (none claims a subdomain here),
# so the trio passes subdomain-uniqueness validation.
_SVC = {
"program": "backup",
"run": {"runner": "python", "program": "backup"},
"manage": {"systemd": {}},
}
_JOB = {
"program": "backup",
"run": {"runner": "command", "argv": ["backup"]},
"schedule": "0 3 * * *",
}
_TOOL = {"program": "backup", "run": {"runner": "path"}}
def _put(client: TestClient, section: str, name: str, cfg: dict) -> None:
r = client.put(f"/config/{section}/{name}", json={"config": cfg})
assert r.status_code == 200, r.text
class TestKindScopedTwins:
def test_same_name_across_kinds_coexist(
self, client: TestClient, castle_root: Path
) -> None:
"""Creating a service, job, and tool all named `backup` yields three
distinct deployments — one per kind, none overwriting another."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "tools", "backup", _TOOL)
cfg = load_config(castle_root)
assert "backup" in cfg.services
assert "backup" in cfg.jobs
assert "backup" in cfg.tools
def test_detail_endpoints_resolve_the_right_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""`/services/backup` and `/jobs/backup` each return their own kind, not
whichever twin happens to sort first."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
svc = client.get("/services/backup").json()
job = client.get("/jobs/backup").json()
assert svc["kind"] == "service"
# Each endpoint returns its own twin: the job carries the schedule, the
# service does not — so neither resolved to the other.
assert job["manifest"].get("schedule") == "0 3 * * *"
assert "schedule" not in svc["manifest"]
def test_kind_scoped_save_does_not_touch_the_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""A partial patch to the *service* backup must leave the *job* backup
(and its schedule) untouched — the wrong-twin bleed this refactor closes."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "services", "backup", {"reach": "off"})
cfg = load_config(castle_root)
assert cfg.jobs["backup"].schedule == "0 3 * * *" # job untouched
assert "backup" in cfg.services # service still there
def test_kind_scoped_delete_removes_only_that_twin(
self, client: TestClient, castle_root: Path
) -> None:
"""Deleting `/config/tools/backup` drops only the tool; the service and
job twins survive."""
_put(client, "services", "backup", _SVC)
_put(client, "jobs", "backup", _JOB)
_put(client, "tools", "backup", _TOOL)
r = client.delete("/config/tools/backup")
assert r.status_code == 200, r.text
cfg = load_config(castle_root)
assert "backup" not in cfg.tools # tool gone
assert "backup" in cfg.services # twins survive
assert "backup" in cfg.jobs

View File

@@ -9,7 +9,9 @@ from castle_api.mqtt_client import _json_to_registry, _registry_to_json
def _make_registry() -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
node=NodeConfig(
hostname="tower", castle_root="/data/repos/castle", gateway_port=9000
),
deployed={
"my-svc": Deployment(
manager="systemd",
@@ -80,7 +82,9 @@ class TestRegistrySerialization:
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": Deployment(manager="systemd", launcher="command", run_cmd=["bare"], name="bare"),
"bare": Deployment(
manager="systemd", launcher="command", run_cmd=["bare"], name="bare"
),
},
)
restored = _json_to_registry(_registry_to_json(reg))

View File

@@ -31,7 +31,9 @@ class TestNodesList:
assert local["deployed_count"] == 2 # test-svc + test-tool
assert local["service_count"] == 1 # only test-svc is a service
def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None:
def test_includes_remote_nodes(
self, client: TestClient, registry_path: Path
) -> None:
"""Remote nodes from mesh state are included."""
import castle_api.mesh as mesh_mod
@@ -42,7 +44,8 @@ class TestNodesList:
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": Deployment(
manager="systemd", launcher="python",
manager="systemd",
launcher="python",
run_cmd=["svc"],
port=9050,
kind="service",

View File

@@ -4,7 +4,9 @@ from fastapi.testclient import TestClient
class TestProgramCommands:
def test_wired_in_program_surfaces_commands_and_repo(self, client: TestClient) -> None:
def test_wired_in_program_surfaces_commands_and_repo(
self, client: TestClient
) -> None:
"""A stack-less adopted program exposes its declared commands + repo."""
resp = client.get("/programs")
assert resp.status_code == 200