Rename Castle -> Wild PC across the repo
Repo-side rename only (Phases 1-3 of the migration plan); the live box (~/.castle, systemd units, /data/castle, domains) is a separate cutover. - Slug `castle` -> `wildpc`: CLI command, module names (wildpc_core/cli/api), dist names, entry point `wildpc = wildpc_cli.main:main`. - Identifiers: CastleConfig/NATSClient/DirError/MDNS -> Wildpc*. - Env/constants: CASTLE_* -> WILDPC_*; ~/.castle -> ~/.wildpc, castle.yaml -> wildpc.yaml, /data/castle -> /data/wildpc. - Systemd UNIT_PREFIX castle- -> wildpc-; own programs castle-api/gateway/etc. - Display prose "Castle" -> "Wild PC" in docs, agent-guide files, README, frontend. - Package dirs and bootstrap yaml renamed via git mv; lockfiles regenerated; redundant nested uv.lock files dropped (workspace root lock is authoritative). Tests: core 273, cli 47, wildpc-api 120 all pass. Frontend type-checks + builds. Fixed a stale test fixture (secret_env_path kind arg) broken pre-rename.
This commit is contained in:
232
wildpc-api/tests/test_exposure_derivation.py
Normal file
232
wildpc-api/tests/test_exposure_derivation.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""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 wildpc_api.main import app
|
||||
from wildpc_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 / "wildpc.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",
|
||||
},
|
||||
}
|
||||
_store = {"caddy": "statics", "path": "tools", "none": "references"}
|
||||
for name, spec in deps.items():
|
||||
store = "jobs" if spec.get("schedule") else _store.get(spec["manager"], "services")
|
||||
store_dir = root / "deployments" / store
|
||||
store_dir.mkdir(parents=True, exist_ok=True)
|
||||
(store_dir / f"{name}.yaml").write_text(yaml.dump(spec))
|
||||
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(
|
||||
hostname="n",
|
||||
wildpc_root=str(root),
|
||||
gateway_port=9000,
|
||||
gateway_tls="acme",
|
||||
gateway_domain="civil.test",
|
||||
public_domain="pub.test",
|
||||
),
|
||||
deployed={
|
||||
NodeRegistry.key("static", "calc"): Deployment(
|
||||
manager="caddy",
|
||||
run_cmd=[],
|
||||
name="calc",
|
||||
kind="static",
|
||||
subdomain="calc",
|
||||
public=True,
|
||||
static_root=str(root / "calc" / "public"),
|
||||
),
|
||||
NodeRegistry.key("service", "web"): Deployment(
|
||||
manager="systemd",
|
||||
launcher="python",
|
||||
run_cmd=["x"],
|
||||
name="web",
|
||||
kind="service",
|
||||
port=9001,
|
||||
subdomain="web",
|
||||
public=True,
|
||||
managed=True,
|
||||
),
|
||||
NodeRegistry.key("service", "intern"): Deployment(
|
||||
manager="systemd",
|
||||
launcher="python",
|
||||
run_cmd=["x"],
|
||||
name="intern",
|
||||
kind="service",
|
||||
port=9002,
|
||||
subdomain="intern",
|
||||
public=False,
|
||||
managed=True,
|
||||
),
|
||||
NodeRegistry.key("service", "pg"): Deployment(
|
||||
manager="systemd",
|
||||
launcher="container",
|
||||
run_cmd=["x"],
|
||||
name="pg",
|
||||
kind="service",
|
||||
port=None,
|
||||
subdomain=None,
|
||||
tcp_port=5432,
|
||||
managed=True,
|
||||
),
|
||||
},
|
||||
)
|
||||
reg_path = tmp_path / "registry.yaml"
|
||||
save_registry(reg, reg_path)
|
||||
|
||||
import wildpc_core.registry as reg_mod
|
||||
import wildpc_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_wildpc_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 wildpc.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