The gateway does two things — reverse-proxy services and serve static
frontends — but the dashboard/API route table only ever showed the proxy
routes, so 'serving a frontend' and 'proxying a service' looked like unrelated
features and castle-app/power-graph-app were invisible in the route view.
Now there's one concept: a route maps an address (path '/foo' or host 'foo.lan')
to a target of one kind — static (a built dist served by file_server), proxy
(a local service port), or remote (a service on another node).
- core: caddyfile.py gains compute_routes() — the single source of truth for
the route list; generate_caddyfile_from_registry renders it (output byte-for-
byte identical, verified against the live Caddyfile). Adds a GatewayRoute
dataclass.
- api: GatewayRoute model → {address, kind, target, name, node}; GET /gateway
builds from compute_routes (incl. config for static frontends + mesh for
remote), so the table matches what Caddy actually does.
- app: Gateway panel shows Address · Kind · Target for every route (static
frontends + host routes now appear); program detail shows 'Reachable at
/foo/ · served (static)' for static frontends.
- cli: 'castle gateway status' prints the full route table.
- docs: registry.md/design.md/CLAUDE.md describe routes as one concept with
three target kinds.
core 94 / cli 24 / api 52 green; ruff + app build clean; Caddyfile unchanged.
271 lines
9.9 KiB
Python
271 lines
9.9 KiB
Python
"""Tests for castle-api health endpoint."""
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
class TestHealth:
|
|
"""Health endpoint tests."""
|
|
|
|
def test_health(self, client: TestClient) -> None:
|
|
"""Health endpoint returns ok."""
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
class TestComponents:
|
|
"""Component list endpoint tests."""
|
|
|
|
def test_list_components(self, client: TestClient) -> None:
|
|
"""Returns all registered components."""
|
|
response = client.get("/deployments")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
names = [c["id"] for c in data]
|
|
assert "test-svc" in names
|
|
assert "test-tool" in names
|
|
|
|
def test_service_has_port(self, client: TestClient) -> None:
|
|
"""Service component includes port info."""
|
|
response = client.get("/deployments")
|
|
data = response.json()
|
|
svc = next(c for c in data if c["id"] == "test-svc")
|
|
assert svc["port"] == 19000
|
|
assert svc["health_path"] == "/health"
|
|
assert svc["proxy_path"] == "/test-svc"
|
|
assert svc["managed"] is True
|
|
assert svc["behavior"] == "daemon"
|
|
|
|
def test_tool_has_no_port(self, client: TestClient) -> None:
|
|
"""Tool component has no port."""
|
|
response = client.get("/deployments")
|
|
data = response.json()
|
|
tool = next(c for c in data if c["id"] == "test-tool")
|
|
assert tool["port"] is None
|
|
assert tool["behavior"] == "tool"
|
|
|
|
def test_job_has_schedule(self, client: TestClient) -> None:
|
|
"""Job component has schedule."""
|
|
response = client.get("/deployments")
|
|
data = response.json()
|
|
job = next(c for c in data if c["id"] == "test-job")
|
|
assert job["behavior"] == "tool"
|
|
assert job["schedule"] == "0 2 * * *"
|
|
|
|
|
|
class TestDeploymentDetail:
|
|
"""Component detail endpoint tests."""
|
|
|
|
def test_get_component(self, client: TestClient) -> None:
|
|
"""Returns detailed info for a component."""
|
|
response = client.get("/deployments/test-svc")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == "test-svc"
|
|
assert "manifest" in data
|
|
assert data["manifest"]["runner"] == "python"
|
|
|
|
def test_not_found(self, client: TestClient) -> None:
|
|
"""Returns 404 for unknown component."""
|
|
response = client.get("/deployments/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestServicesList:
|
|
"""GET /services endpoint tests."""
|
|
|
|
def test_returns_deployed_services(self, client: TestClient) -> None:
|
|
"""Returns deployed services from registry."""
|
|
response = client.get("/services")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
names = [s["id"] for s in data]
|
|
assert "test-svc" in names
|
|
|
|
def test_service_has_port_and_health(self, client: TestClient) -> None:
|
|
"""Service summary includes port and health info."""
|
|
response = client.get("/services")
|
|
data = response.json()
|
|
svc = next(s for s in data if s["id"] == "test-svc")
|
|
assert svc["port"] == 19000
|
|
assert svc["health_path"] == "/health"
|
|
assert svc["proxy_path"] == "/test-svc"
|
|
assert svc["managed"] is True
|
|
|
|
def test_no_schedule_field(self, client: TestClient) -> None:
|
|
"""ServiceSummary does not have schedule field."""
|
|
response = client.get("/services")
|
|
data = response.json()
|
|
svc = next(s for s in data if s["id"] == "test-svc")
|
|
assert "schedule" not in svc
|
|
|
|
def test_no_installed_field(self, client: TestClient) -> None:
|
|
"""ServiceSummary does not have installed field."""
|
|
response = client.get("/services")
|
|
data = response.json()
|
|
svc = next(s for s in data if s["id"] == "test-svc")
|
|
assert "installed" not in svc
|
|
|
|
def test_excludes_jobs(self, client: TestClient) -> None:
|
|
"""Jobs (scheduled items) are not in the services list."""
|
|
response = client.get("/services")
|
|
data = response.json()
|
|
names = [s["id"] for s in data]
|
|
assert "test-job" not in names
|
|
|
|
|
|
class TestServiceDetail:
|
|
"""GET /services/{name} endpoint tests."""
|
|
|
|
def test_get_service(self, client: TestClient) -> None:
|
|
"""Returns detailed info for a service."""
|
|
response = client.get("/services/test-svc")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == "test-svc"
|
|
assert "manifest" in data
|
|
# manifest is the editable castle.yaml ServiceSpec (nested run spec)
|
|
assert data["manifest"]["run"]["runner"] == "python"
|
|
assert data["run_target"] == "test-svc"
|
|
|
|
def test_not_found(self, client: TestClient) -> None:
|
|
"""Returns 404 for unknown service."""
|
|
response = client.get("/services/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestJobsList:
|
|
"""GET /jobs endpoint tests."""
|
|
|
|
def test_returns_jobs(self, client: TestClient) -> None:
|
|
"""Returns jobs from castle.yaml."""
|
|
response = client.get("/jobs")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
names = [j["id"] for j in data]
|
|
assert "test-job" in names
|
|
|
|
def test_job_has_schedule(self, client: TestClient) -> None:
|
|
"""Job summary includes schedule."""
|
|
response = client.get("/jobs")
|
|
data = response.json()
|
|
job = next(j for j in data if j["id"] == "test-job")
|
|
assert job["schedule"] == "0 2 * * *"
|
|
|
|
def test_no_port_field(self, client: TestClient) -> None:
|
|
"""JobSummary does not have port field."""
|
|
response = client.get("/jobs")
|
|
data = response.json()
|
|
job = next(j for j in data if j["id"] == "test-job")
|
|
assert "port" not in job
|
|
|
|
def test_excludes_services(self, client: TestClient) -> None:
|
|
"""Services (non-scheduled) are not in the jobs list."""
|
|
response = client.get("/jobs")
|
|
data = response.json()
|
|
names = [j["id"] for j in data]
|
|
assert "test-svc" not in names
|
|
|
|
|
|
class TestJobDetail:
|
|
"""GET /jobs/{name} endpoint tests."""
|
|
|
|
def test_get_job(self, client: TestClient) -> None:
|
|
"""Returns detailed info for a job."""
|
|
response = client.get("/jobs/test-job")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == "test-job"
|
|
assert "manifest" in data
|
|
assert data["schedule"] == "0 2 * * *"
|
|
|
|
def test_not_found(self, client: TestClient) -> None:
|
|
"""Returns 404 for unknown job."""
|
|
response = client.get("/jobs/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestProgramsList:
|
|
"""GET /programs endpoint tests."""
|
|
|
|
def test_returns_programs(self, client: TestClient) -> None:
|
|
"""Returns programs from castle.yaml."""
|
|
response = client.get("/programs")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
names = [p["id"] for p in data]
|
|
assert "test-tool" in names
|
|
|
|
def test_program_has_behavior(self, client: TestClient) -> None:
|
|
"""Program summary includes behavior."""
|
|
response = client.get("/programs")
|
|
data = response.json()
|
|
tool = next(p for p in data if p["id"] == "test-tool")
|
|
assert tool["behavior"] == "tool"
|
|
|
|
def test_no_port_field(self, client: TestClient) -> None:
|
|
"""ProgramSummary does not have port field."""
|
|
response = client.get("/programs")
|
|
data = response.json()
|
|
tool = next(p for p in data if p["id"] == "test-tool")
|
|
assert "port" not in tool
|
|
|
|
def test_no_schedule_field(self, client: TestClient) -> None:
|
|
"""ProgramSummary does not have schedule field."""
|
|
response = client.get("/programs")
|
|
data = response.json()
|
|
tool = next(p for p in data if p["id"] == "test-tool")
|
|
assert "schedule" not in tool
|
|
|
|
|
|
class TestProgramDetail:
|
|
"""GET /programs/{name} endpoint tests."""
|
|
|
|
def test_get_program(self, client: TestClient) -> None:
|
|
"""Returns detailed info for a program."""
|
|
response = client.get("/programs/test-tool")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == "test-tool"
|
|
assert "manifest" in data
|
|
assert data["behavior"] == "tool"
|
|
|
|
def test_not_found(self, client: TestClient) -> None:
|
|
"""Returns 404 for unknown program."""
|
|
response = client.get("/programs/nonexistent")
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestGateway:
|
|
"""Gateway info endpoint tests."""
|
|
|
|
def test_gateway_info(self, client: TestClient) -> None:
|
|
"""Returns gateway configuration from registry."""
|
|
response = client.get("/gateway")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["port"] == 9000
|
|
assert data["hostname"] == "test-node"
|
|
# Registry has 1 deployed component (test-svc)
|
|
assert data["deployment_count"] == 1
|
|
assert data["service_count"] == 1
|
|
assert data["managed_count"] == 1
|
|
|
|
def test_gateway_routes(self, client: TestClient) -> None:
|
|
"""Returns the full route table, tagged with kind + target."""
|
|
response = client.get("/gateway")
|
|
data = response.json()
|
|
route = next(r for r in data["routes"] if r["address"] == "/test-svc")
|
|
assert route["kind"] == "proxy"
|
|
assert route["target"] == "localhost:19000"
|
|
assert route["name"] == "test-svc"
|
|
assert route["node"] == "test-node"
|
|
|
|
def test_gateway_route_kinds_valid(self, client: TestClient) -> None:
|
|
"""Every route declares a known kind."""
|
|
response = client.get("/gateway")
|
|
data = response.json()
|
|
assert data["routes"]
|
|
for r in data["routes"]:
|
|
assert r["kind"] in ("static", "proxy", "remote")
|