diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx index 442ef27..d891b02 100644 --- a/app/src/components/GatewayPanel.tsx +++ b/app/src/components/GatewayPanel.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom" import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react" import type { GatewayInfo, HealthStatus } from "@/types" import { useApply, useCaddyfile } from "@/services/api/hooks" -import { subdomainUrl } from "@/lib/labels" +import { subdomainUrl, detailPath } from "@/lib/labels" import { HealthBadge } from "./HealthBadge" import { GatewaySettings } from "./GatewaySettings" @@ -118,7 +118,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { {route.name ? ( - + {route.kind === "static" ? shortDir(route.target) : route.target} ) : ( diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx index e455899..0602e03 100644 --- a/app/src/components/detail/ConfigPanel.tsx +++ b/app/src/components/detail/ConfigPanel.tsx @@ -31,9 +31,10 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane const [pendingApply, setPendingApply] = useState(false) const [applying, setApplying] = useState(false) const isDeployment = configSection !== "programs" - // Programs are their own catalog; every deployment kind (service/job/tool/ - // static) lives in the single deployments/ collection. - const writeSection = isDeployment ? "deployments" : "programs" + // Save/delete against the kind-scoped resource (services/jobs/tools/static) so + // a save can't hit a same-named twin of another kind. `configSection` already + // carries the kind; programs write to their own catalog. + const writeSection = configSection const handleSave = async (compName: string, config: Record) => { setMessage(null) diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index 1e03abc..8d74fb0 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -112,7 +112,10 @@ export function CreateDeploymentForm({ setError("") try { setBusy("Saving…") - await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() }) + // Post to the kind-scoped resource so creating a twin (a `backup` job when + // a `backup` service exists) targets the right collection, not a guess. + const section = kind === "static" ? "static" : `${kind}s` + await apiClient.put(`/config/${section}/${name}`, { config: buildConfig() }) // Converge: render + activate the new deployment in one step. setBusy("Applying…") await apiClient.post(`/apply`, { name }) diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx index 453792d..5cbc23b 100644 --- a/app/src/components/detail/DeploymentsSection.tsx +++ b/app/src/components/detail/DeploymentsSection.tsx @@ -4,6 +4,7 @@ import { Plus, ChevronRight } from "lucide-react" import type { ProgramDetail } from "@/types" import { useServices, useJobs } from "@/services/api/hooks" import { KindBadge } from "@/components/KindBadge" +import { detailPath } from "@/lib/labels" import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm" /** How a program is deployed. A program → 0-N deployments; each row links to its @@ -27,9 +28,6 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) { launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command", } - const detailPath = (name: string, kind: string) => - kind === "tool" ? `/tools/${name}` : kind === "job" ? `/jobs/${name}` : `/services/${name}` - return (
diff --git a/app/src/components/detail/SystemdPanel.tsx b/app/src/components/detail/SystemdPanel.tsx index 1b412e1..05f3f5c 100644 --- a/app/src/components/detail/SystemdPanel.tsx +++ b/app/src/components/detail/SystemdPanel.tsx @@ -27,6 +27,14 @@ export function SystemdPanel({ name, systemd }: SystemdPanelProps) {
Unit {systemd.unit_name} + {systemd.timer && ( + <> + Timer unit + + {systemd.unit_name.replace(".service", ".timer")} + + + )} Path {systemd.unit_path} {systemd.timer && ( diff --git a/app/src/lib/labels.ts b/app/src/lib/labels.ts index f30fdd1..dc74666 100644 --- a/app/src/lib/labels.ts +++ b/app/src/lib/labels.ts @@ -48,6 +48,17 @@ export function stackLabel(stack: string): string { return STACK_LABELS[stack] ?? stack } +/** + * The kind-scoped detail route for a deployment. A name can be shared across + * kinds (a `backup` service + job + tool), so links must carry the kind to + * reach the right twin. Statics share the service detail page. + */ +export function detailPath(name: string, kind: string): string { + if (kind === "tool") return `/tools/${name}` + if (kind === "job") return `/jobs/${name}` + return `/services/${name}` +} + /** * Full URL for a service exposed at .. The domain is * derived from the dashboard's own host (it is served at castle.), so diff --git a/app/src/pages/NodeDetail.tsx b/app/src/pages/NodeDetail.tsx index d63819d..542e424 100644 --- a/app/src/pages/NodeDetail.tsx +++ b/app/src/pages/NodeDetail.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Server } from "lucide-react" import { useNode } from "@/services/api/hooks" import { KindBadge } from "@/components/KindBadge" import { StackBadge } from "@/components/StackBadge" +import { detailPath } from "@/lib/labels" import { cn } from "@/lib/utils" export function NodeDetailPage() { @@ -77,7 +78,7 @@ export function NodeDetailPage() { > {comp.id} diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index c38a007..a6cb944 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -50,24 +50,16 @@ export function ScheduledDetailPage() { {deployment.schedule} - {deployment.systemd && ( - <> - Timer unit - - {deployment.systemd.unit_name.replace(".service", ".timer")} - - - )}
)} + + {deployment.systemd && ( )} - - {deployment.managed && (

Logs

diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 74882ce..6507ecb 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -146,10 +146,6 @@ export function ServiceDetailPage() {
- {deployment.systemd && ( - - )} - {isGateway && caddyfile?.content && (

@@ -170,6 +166,10 @@ export function ServiceDetailPage() { onRefetch={refetch} /> + {deployment.systemd && ( + + )} + {deployment.managed && (

Logs

diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index e45273a..d92b3e9 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -279,10 +279,15 @@ async def delete_program(name: str, cascade: bool = False) -> dict: except Exception: pass - return {"ok": True, "program": name, "action": "deleted", "removed_deployments": removed} + return { + "ok": True, + "program": name, + "action": "deleted", + "removed_deployments": removed, + } -def _save_deployment(name: str, config_dict: dict) -> dict: +def _save_deployment(name: str, config_dict: dict, kind: str | None = None) -> dict: """Create/update a deployment (any manager) with PATCH semantics. The incoming config is shallow-merged over the existing spec, so a save can @@ -290,18 +295,26 @@ def _save_deployment(name: str, config_dict: dict) -> dict: a present key replaces wholesale, an **omitted** key is preserved, and an explicit ``null`` clears the key (back to its default). On CREATE there's no base, so the incoming config stands alone. + + ``kind`` pins the twin this save targets — a kind-scoped endpoint + (``/services|/jobs|/tools|/static``) passes it so a partial patch to a + ``backup`` service can never bleed into a ``backup`` job/tool sharing the + name. The kind-agnostic ``/deployments/{name}`` leaves it None and infers. """ _require_repo() config = get_config() incoming = dict(config_dict) - # Resolve the (name, kind) this save targets. A partial patch (e.g. just + # Resolve the (name, kind) this save targets. An explicit kind is + # authoritative (kind-scoped endpoint). Otherwise: a partial patch (e.g. just # {reach: off}) has no manager, so we can't derive kind from it — prefer the - # existing same-named deployment when there's exactly one; otherwise derive the + # existing same-named deployment when there's exactly one; else derive the # kind from the incoming spec (a create, or disambiguating a shared name). named = config.deployments_named(name) existing = None - if len(named) == 1: + if kind is not None: + existing = config.deployment(kind, name) + elif len(named) == 1: existing = named[0][1] else: try: @@ -332,17 +345,25 @@ def _save_deployment(name: str, config_dict: dict) -> dict: status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid deployment config: {e}", ) - config.store_for(kind_for(dep))[name] = dep + target_kind = kind_for(dep) + # A field edit that changes the derived kind (e.g. adds a schedule) moves the + # spec to the new store; drop the stale entry under the requested kind. + if kind is not None and target_kind != kind: + config.store_for(kind).pop(name, None) + config.store_for(target_kind)[name] = dep save_config(config) return {"ok": True, "deployment": name} -def _delete_deployment(name: str) -> dict: +def _delete_deployment(name: str, kind: str | None = None) -> dict: + """Remove a deployment. A kind-scoped delete drops only that twin; the + kind-agnostic path removes every kind sharing the name.""" config = get_config() removed = False - for kind in KINDS: - if name in config.store_for(kind): - del config.store_for(kind)[name] + kinds = (kind,) if kind is not None else KINDS + for k in kinds: + if name in config.store_for(k): + del config.store_for(k)[name] removed = True if not removed: raise HTTPException( @@ -391,26 +412,50 @@ def set_deployment_enabled(name: str, request: EnabledRequest) -> dict: return {"ok": True, "deployment": name, "enabled": request.enabled} +# Kind-scoped endpoints — pin the twin so a save/delete can't hit a same-named +# deployment of another kind (a `backup` service vs job vs tool). @router.put("/services/{name}") def save_service(name: str, request: ServiceConfigRequest) -> dict: - """Alias of PUT /deployments/{name} (kept for the existing dashboard).""" - return _save_deployment(name, request.config) + """Create/update the *service* named `name`.""" + return _save_deployment(name, request.config, kind="service") @router.delete("/services/{name}") def delete_service(name: str) -> dict: - return _delete_deployment(name) + return _delete_deployment(name, kind="service") @router.put("/jobs/{name}") def save_job(name: str, request: JobConfigRequest) -> dict: - """Alias of PUT /deployments/{name} (kept for the existing dashboard).""" - return _save_deployment(name, request.config) + """Create/update the *job* named `name`.""" + return _save_deployment(name, request.config, kind="job") @router.delete("/jobs/{name}") def delete_job(name: str) -> dict: - return _delete_deployment(name) + return _delete_deployment(name, kind="job") + + +@router.put("/tools/{name}") +def save_tool(name: str, request: ServiceConfigRequest) -> dict: + """Create/update the *tool* named `name`.""" + return _save_deployment(name, request.config, kind="tool") + + +@router.delete("/tools/{name}") +def delete_tool(name: str) -> dict: + return _delete_deployment(name, kind="tool") + + +@router.put("/static/{name}") +def save_static(name: str, request: ServiceConfigRequest) -> dict: + """Create/update the *static* frontend named `name`.""" + return _save_deployment(name, request.config, kind="static") + + +@router.delete("/static/{name}") +def delete_static(name: str) -> dict: + return _delete_deployment(name, kind="static") @router.post("/apply", response_model=ApplyResponse) diff --git a/castle-api/src/castle_api/health.py b/castle-api/src/castle_api/health.py index 17ee034..691a276 100644 --- a/castle-api/src/castle_api/health.py +++ b/castle-api/src/castle_api/health.py @@ -60,7 +60,10 @@ async def _check_systemd(name: str) -> HealthStatus: """Check a managed service's health via its systemd unit state.""" unit = f"castle-{name}.service" proc = await asyncio.create_subprocess_exec( - "systemctl", "--user", "is-active", unit, + "systemctl", + "--user", + "is-active", + unit, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) diff --git a/castle-api/src/castle_api/logs.py b/castle-api/src/castle_api/logs.py index dc5a45e..14423a8 100644 --- a/castle-api/src/castle_api/logs.py +++ b/castle-api/src/castle_api/logs.py @@ -31,7 +31,11 @@ async def get_logs( config = load_config(root) # A name may span kinds — the managed (systemd) one owns the journal. dep_kind = next( - ((k, s) for k, s in config.deployments_named(name) if getattr(s, "manage", None)), + ( + (k, s) + for k, s in config.deployments_named(name) + if getattr(s, "manage", None) + ), None, ) if dep_kind is None: diff --git a/castle-api/src/castle_api/mdns.py b/castle-api/src/castle_api/mdns.py index 6e569bc..6205315 100644 --- a/castle-api/src/castle_api/mdns.py +++ b/castle-api/src/castle_api/mdns.py @@ -35,7 +35,9 @@ class CastleMDNS: self._service_info: ServiceInfo | None = None # Discovered state - self.peers: dict[str, dict] = {} # hostname -> {gateway_port, api_port, addresses} + self.peers: dict[ + str, dict + ] = {} # hostname -> {gateway_port, api_port, addresses} self.mqtt_broker: dict | None = None # {host, port} or None def _on_service_state_change( @@ -67,14 +69,18 @@ class CastleMDNS: def _handle_castle_peer(self, info: ServiceInfo) -> None: """Process a discovered castle peer.""" props = { - k.decode() if isinstance(k, bytes) else k: v.decode() if isinstance(v, bytes) else v + k.decode() if isinstance(k, bytes) else k: v.decode() + if isinstance(v, bytes) + else v for k, v in info.properties.items() } peer_hostname = props.get("hostname", "") if not peer_hostname or peer_hostname == self._hostname: return - addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4] + addresses = [ + socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4 + ] self.peers[peer_hostname] = { "gateway_port": int(props.get("gateway_port", 9000)), @@ -85,13 +91,17 @@ class CastleMDNS: def _handle_mqtt_broker(self, info: ServiceInfo) -> None: """Process a discovered MQTT broker.""" - addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4] + addresses = [ + socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4 + ] if addresses: self.mqtt_broker = { "host": addresses[0], "port": info.port, } - logger.info("mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port) + logger.info( + "mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port + ) def start(self) -> None: """Start advertising and browsing.""" @@ -109,14 +119,24 @@ class CastleMDNS: }, ) self._zeroconf.register_service(self._service_info) - logger.info("mDNS: advertising %s on port %d", self._hostname, self._gateway_port) + logger.info( + "mDNS: advertising %s on port %d", self._hostname, self._gateway_port + ) # Browse for peers and MQTT broker self._browsers.append( - ServiceBrowser(self._zeroconf, CASTLE_SERVICE_TYPE, handlers=[self._on_service_state_change]) + ServiceBrowser( + self._zeroconf, + CASTLE_SERVICE_TYPE, + handlers=[self._on_service_state_change], + ) ) self._browsers.append( - ServiceBrowser(self._zeroconf, MQTT_SERVICE_TYPE, handlers=[self._on_service_state_change]) + ServiceBrowser( + self._zeroconf, + MQTT_SERVICE_TYPE, + handlers=[self._on_service_state_change], + ) ) def stop(self) -> None: diff --git a/castle-api/src/castle_api/mesh.py b/castle-api/src/castle_api/mesh.py index e66bf86..0735646 100644 --- a/castle-api/src/castle_api/mesh.py +++ b/castle-api/src/castle_api/mesh.py @@ -40,7 +40,9 @@ class MeshStateManager: def update_node(self, hostname: str, registry: NodeRegistry) -> None: """Add or update a remote node's registry.""" self._nodes[hostname] = RemoteNode(registry=registry) - logger.info("Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed)) + logger.info( + "Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed) + ) def set_offline(self, hostname: str) -> None: """Mark a node as offline (LWT received).""" diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index ed686c4..94d3492 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -171,7 +171,9 @@ class CastleMQTTClient: return self._connected = True - logger.info("Connected to MQTT broker at %s:%d", self._broker_host, self._broker_port) + logger.info( + "Connected to MQTT broker at %s:%d", self._broker_host, self._broker_port + ) # Publish our status as online (retained) client.publish( @@ -222,7 +224,9 @@ class CastleMQTTClient: if payload == "offline": mesh_state.set_offline(hostname) asyncio.run_coroutine_threadsafe( - broadcast("mesh", {"event": "node_offline", "hostname": hostname}), + broadcast( + "mesh", {"event": "node_offline", "hostname": hostname} + ), self._loop, ) diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index f8f6bfe..ec1eed4 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -154,7 +154,9 @@ def _summary_from_service( ) -def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> DeploymentSummary: +def _summary_from_job( + name: str, job: SystemdDeployment, config: object +) -> DeploymentSummary: """Build a DeploymentSummary from a systemd deployment (job, non-deployed).""" managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable) @@ -280,7 +282,9 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: ) -def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> ServiceSummary: +def _service_from_spec( + name: str, svc: SystemdDeployment, config: object +) -> ServiceSummary: """Build a ServiceSummary from a systemd deployment.""" port = None health_path = None @@ -911,7 +915,8 @@ def get_gateway() -> GatewayInfo: # is not a service, so filtering to services dropped its public_url (calculator). public_domain = registry.node.public_domain public_names = { - name for _k, name, dep in (config.all_deployments() if config else []) + name + for _k, name, dep in (config.all_deployments() if config else []) if getattr(dep, "public", False) } @@ -937,7 +942,8 @@ def get_gateway() -> GatewayInfo: tunnel_connected = ( subprocess.run( ["systemctl", "--user", "is-active", "castle-castle-tunnel.service"], - capture_output=True, text=True, + capture_output=True, + text=True, ).stdout.strip() == "active" ) @@ -970,7 +976,7 @@ def save_gateway_config(request: GatewayConfigRequest) -> dict[str, str]: from castle_core.config import load_config, save_config config = load_config(root) - norm = lambda v: (v or None) # noqa: E731 — empty string clears + norm = lambda v: v or None # noqa: E731 — empty string clears config.gateway.tls = norm(request.tls) config.gateway.domain = norm(request.domain) config.gateway.public_domain = norm(request.public_domain) diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index d659718..d5c87fb 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -107,7 +107,9 @@ async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> Non async def _do_action(name: str, action: str) -> JSONResponse: """Execute a systemctl action and broadcast updated health.""" deployed = _managed(name) - unit = unit_name(name, deployed.kind) if deployed else f"{UNIT_PREFIX}{name}.service" + unit = ( + unit_name(name, deployed.kind) if deployed else f"{UNIT_PREFIX}{name}.service" + ) # Self-restart: defer the systemctl call so the response can be sent first if name == SELF_NAME and action in ("restart", "stop"): diff --git a/castle-api/tests/test_deployment_roundtrip.py b/castle-api/tests/test_deployment_roundtrip.py index cec054a..8e4bbb2 100644 --- a/castle-api/tests/test_deployment_roundtrip.py +++ b/castle-api/tests/test_deployment_roundtrip.py @@ -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 diff --git a/castle-api/tests/test_exposure_derivation.py b/castle-api/tests/test_exposure_derivation.py index e61f27c..37821ae 100644 --- a/castle-api/tests/test_exposure_derivation.py +++ b/castle-api/tests/test_exposure_derivation.py @@ -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( diff --git a/castle-api/tests/test_graph_repos.py b/castle-api/tests/test_graph_repos.py index 7dd5d29..862d559 100644 --- a/castle-api/tests/test_graph_repos.py +++ b/castle-api/tests/test_graph_repos.py @@ -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: diff --git a/castle-api/tests/test_kind_twins.py b/castle-api/tests/test_kind_twins.py new file mode 100644 index 0000000..468b91d --- /dev/null +++ b/castle-api/tests/test_kind_twins.py @@ -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 diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py index 00ebd2d..541c8c7 100644 --- a/castle-api/tests/test_mqtt.py +++ b/castle-api/tests/test_mqtt.py @@ -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)) diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py index adb8f99..dd902ce 100644 --- a/castle-api/tests/test_nodes.py +++ b/castle-api/tests/test_nodes.py @@ -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", diff --git a/castle-api/tests/test_programs_commands.py b/castle-api/tests/test_programs_commands.py index def2512..f8057cb 100644 --- a/castle-api/tests/test_programs_commands.py +++ b/castle-api/tests/test_programs_commands.py @@ -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