fix(api): serve editable spec on /services/{name} + public_url for statics
- /services/{name} served the runtime view for a STATIC (caddy) deployment
(it's not in config.services), so its manifest lacked reach/root/program and
the dashboard's reach dropdown defaulted to "internal". Now serves the editable
spec whenever the deployment is in config.deployments — the twin of the earlier
/deployments/{name} fix.
- gateway route public_url scanned only config.services, so public statics got
public_url=None. Now scans all deployments.
Coverage: parametrized invariant sweep (every kind × every detail endpoint serves
the editable spec) + a /deployments-vs-/services consistency test that fails the
instant the two paths diverge; public/internal/static public_url derivation and
raw-TCP route exclusion.
This commit is contained in:
@@ -506,19 +506,28 @@ def get_service(name: str) -> ServiceDetail:
|
|||||||
summary = _service_from_deployed(name, deployed)
|
summary = _service_from_deployed(name, deployed)
|
||||||
if config is not None and summary.source is None:
|
if config is not None and summary.source is None:
|
||||||
summary.source = _backfill_source(name, config)
|
summary.source = _backfill_source(name, config)
|
||||||
manifest = {
|
# Serve the editable spec whenever the deployment is in castle.yaml — a
|
||||||
"manager": deployed.manager,
|
# STATIC (caddy) is in config.deployments but NOT config.services, so it
|
||||||
"launcher": deployed.launcher,
|
# lands here; without this its manifest lacks reach/root/program and the
|
||||||
"run_cmd": deployed.run_cmd,
|
# edit form defaults them wrong (calculator showed reach as internal).
|
||||||
"env": deployed.env,
|
if config is not None and name in config.deployments:
|
||||||
"secret_env_keys": deployed.secret_env_keys,
|
manifest = config.deployments[name].model_dump(
|
||||||
"port": deployed.port,
|
mode="json", exclude_none=True
|
||||||
"health_path": deployed.health_path,
|
)
|
||||||
"subdomain": deployed.subdomain,
|
else:
|
||||||
"managed": deployed.managed,
|
manifest = {
|
||||||
"kind": deployed.kind,
|
"manager": deployed.manager,
|
||||||
"stack": deployed.stack,
|
"launcher": deployed.launcher,
|
||||||
}
|
"run_cmd": deployed.run_cmd,
|
||||||
|
"env": deployed.env,
|
||||||
|
"secret_env_keys": deployed.secret_env_keys,
|
||||||
|
"port": deployed.port,
|
||||||
|
"health_path": deployed.health_path,
|
||||||
|
"subdomain": deployed.subdomain,
|
||||||
|
"managed": deployed.managed,
|
||||||
|
"kind": deployed.kind,
|
||||||
|
"stack": deployed.stack,
|
||||||
|
}
|
||||||
return ServiceDetail(**summary.model_dump(), manifest=manifest)
|
return ServiceDetail(**summary.model_dump(), manifest=manifest)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -892,11 +901,13 @@ def get_gateway() -> GatewayInfo:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Which local services are public → their public URL (<name>.<public_domain>).
|
# Which local deployments are public → their public URL (<name>.<public_domain>).
|
||||||
|
# Scan ALL deployments, not just `config.services` — a public *static* (caddy)
|
||||||
|
# is not a service, so filtering to services dropped its public_url (calculator).
|
||||||
public_domain = registry.node.public_domain
|
public_domain = registry.node.public_domain
|
||||||
public_names = {
|
public_names = {
|
||||||
name for name, svc in (config.services.items() if config else [])
|
name for name, dep in (config.deployments.items() if config else [])
|
||||||
if getattr(svc, "public", False)
|
if getattr(dep, "public", False)
|
||||||
}
|
}
|
||||||
|
|
||||||
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
|
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
|
||||||
|
|||||||
192
castle-api/tests/test_exposure_derivation.py
Normal file
192
castle-api/tests/test_exposure_derivation.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Exposure-derivation coverage for the gateway route table.
|
||||||
|
|
||||||
|
Guards the calculator bug: a public STATIC (caddy) deployment got public_url=None
|
||||||
|
because `public_names` scanned only config.services (systemd), not statics. Also
|
||||||
|
guards that a raw-TCP service is excluded from the HTTP route table (the
|
||||||
|
http_exposed derivation that decides subdomain/route).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from castle_api.main import app
|
||||||
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry, save_registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def public_client(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> Generator[TestClient, None, None]:
|
||||||
|
root = tmp_path
|
||||||
|
(root / "castle.yaml").write_text(
|
||||||
|
yaml.dump(
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"port": 9000,
|
||||||
|
"tls": "acme",
|
||||||
|
"domain": "civil.test",
|
||||||
|
"public_domain": "pub.test",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(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
|
||||||
|
|
||||||
|
deps = {
|
||||||
|
# public STATIC (the calculator case)
|
||||||
|
"calc": {"program": "calc", "manager": "caddy", "root": "public", "reach": "public"},
|
||||||
|
# public systemd service
|
||||||
|
"web": {
|
||||||
|
"manager": "systemd",
|
||||||
|
"run": {"launcher": "python", "program": "web"},
|
||||||
|
"expose": {"http": {"internal": {"port": 9001}}},
|
||||||
|
"reach": "public",
|
||||||
|
},
|
||||||
|
# internal systemd service
|
||||||
|
"intern": {
|
||||||
|
"manager": "systemd",
|
||||||
|
"run": {"launcher": "python", "program": "intern"},
|
||||||
|
"expose": {"http": {"internal": {"port": 9002}}},
|
||||||
|
"reach": "internal",
|
||||||
|
},
|
||||||
|
# raw-TCP service (postgres-like) — must NOT become an HTTP route
|
||||||
|
"pg": {
|
||||||
|
"manager": "systemd",
|
||||||
|
"run": {"launcher": "container", "image": "postgres:17"},
|
||||||
|
"expose": {"tcp": {"port": 5432}},
|
||||||
|
"reach": "internal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(root / "deployments").mkdir()
|
||||||
|
for name, spec in deps.items():
|
||||||
|
(root / "deployments" / f"{name}.yaml").write_text(yaml.dump(spec))
|
||||||
|
|
||||||
|
reg = NodeRegistry(
|
||||||
|
node=NodeConfig(
|
||||||
|
hostname="n",
|
||||||
|
castle_root=str(root),
|
||||||
|
gateway_port=9000,
|
||||||
|
gateway_tls="acme",
|
||||||
|
gateway_domain="civil.test",
|
||||||
|
public_domain="pub.test",
|
||||||
|
),
|
||||||
|
deployed={
|
||||||
|
"calc": Deployment(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
"intern": Deployment(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
reg_path = tmp_path / "registry.yaml"
|
||||||
|
save_registry(reg, reg_path)
|
||||||
|
|
||||||
|
import castle_core.registry as reg_mod
|
||||||
|
import castle_api.routes as routes_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(reg_mod, "REGISTRY_PATH", reg_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes_mod, "get_registry", lambda: reg_mod.load_registry(reg_path)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(routes_mod, "get_castle_root", lambda: root)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def _routes(client: TestClient) -> dict:
|
||||||
|
return {r["name"]: r for r in client.get("/gateway").json()["routes"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatewayPublicUrl:
|
||||||
|
def test_public_static_has_public_url(self, public_client: TestClient) -> None:
|
||||||
|
"""The calculator bug: a public STATIC must get public_url, not None."""
|
||||||
|
assert _routes(public_client)["calc"]["public_url"] == "https://calc.pub.test"
|
||||||
|
|
||||||
|
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:
|
||||||
|
assert _routes(public_client)["intern"]["public_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceDetailServesSpec:
|
||||||
|
def test_services_endpoint_serves_static_editable_spec(
|
||||||
|
self, public_client: TestClient
|
||||||
|
) -> None:
|
||||||
|
"""/services/{name} for a STATIC must serve the editable spec (reach/root/
|
||||||
|
program), not the runtime view. This is the endpoint the dashboard's
|
||||||
|
/services/ detail page reads — serving the runtime shape made the reach
|
||||||
|
dropdown default to 'internal' for a public static (calculator)."""
|
||||||
|
m = public_client.get("/services/calc").json()["manifest"]
|
||||||
|
assert m.get("reach") == "public" # <-- was None (runtime view) before the fix
|
||||||
|
assert m.get("root") == "public"
|
||||||
|
assert m.get("program") == "calc"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetailEndpointInvariant:
|
||||||
|
"""The invariant, swept across the matrix — not one example. Every deployment
|
||||||
|
in castle.yaml must serve its EDITABLE SPEC (reach/program, no runtime-only
|
||||||
|
keys) on EVERY detail endpoint the dashboard calls, for EVERY kind. Testing a
|
||||||
|
single instance is what let the static x /services cell (calculator) rot."""
|
||||||
|
|
||||||
|
@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/intern", "internal"),
|
||||||
|
("/services/intern", "internal"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_detail_serves_editable_spec(
|
||||||
|
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
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["calc", "web", "intern"])
|
||||||
|
def test_deployments_and_services_endpoints_agree(
|
||||||
|
self, public_client: TestClient, name: str
|
||||||
|
) -> None:
|
||||||
|
"""The two detail endpoints must return the SAME editable manifest for the
|
||||||
|
same deployment. Fixing one and forgetting its twin (the calculator bug)
|
||||||
|
fails this immediately, whatever the kind."""
|
||||||
|
dep = public_client.get(f"/deployments/{name}").json()["manifest"]
|
||||||
|
svc = public_client.get(f"/services/{name}").json()["manifest"]
|
||||||
|
assert dep.get("reach") == svc.get("reach")
|
||||||
|
assert dep.get("program") == svc.get("program")
|
||||||
|
assert dep.get("root") == svc.get("root")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTcpNotHttpRouted:
|
||||||
|
def test_tcp_service_absent_from_http_route_table(
|
||||||
|
self, public_client: TestClient
|
||||||
|
) -> None:
|
||||||
|
"""A raw-TCP service (expose.tcp, no http) is reachable by name+port via
|
||||||
|
DNS, never an HTTP gateway route — so it must not appear in the table."""
|
||||||
|
assert "pg" not in _routes(public_client)
|
||||||
|
# sanity: the HTTP services are present
|
||||||
|
assert {"calc", "web", "intern"} <= set(_routes(public_client))
|
||||||
Reference in New Issue
Block a user