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:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

View File

@@ -0,0 +1,303 @@
"""Test fixtures for wildpc-api."""
import os as _os
# Tests must not read the host's real secret backend (wildpc.yaml may point
# at OpenBao); force the file backend unless CI explicitly overrides.
_os.environ.setdefault("WILDPC_SECRET_BACKEND", "file")
import socket
import subprocess
import time
import urllib.request
from collections.abc import Generator
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
import wildpc_api.config as api_config
from wildpc_api.main import app
from wildpc_core.registry import (
Deployment,
NodeConfig,
NodeRegistry,
save_registry,
)
def _free_port() -> int:
s = socket.socket()
s.bind(("", 0))
port = s.getsockname()[1]
s.close()
return port
def _docker_available() -> bool:
try:
return subprocess.run(
["docker", "info"], capture_output=True, timeout=5
).returncode == 0
except Exception:
return False
@pytest.fixture
def nats_url() -> Generator[str, None, None]:
"""A throwaway NATS+JetStream broker in docker (fresh per test for clean
buckets). Skips if docker is unavailable."""
if not _docker_available():
pytest.skip("docker unavailable — skipping NATS integration tests")
cport, mport = _free_port(), _free_port()
name = f"wildpc-test-nats-{cport}"
subprocess.run(
["docker", "run", "-d", "--rm", "--name", name,
"-p", f"{cport}:4222", "-p", f"{mport}:8222", "nats:2", "-js", "-m", "8222"],
check=True, capture_output=True,
)
try:
for _ in range(50): # wait for readiness
try:
urllib.request.urlopen(f"http://127.0.0.1:{mport}/healthz", timeout=1)
break
except Exception:
time.sleep(0.2)
yield f"nats://127.0.0.1:{cport}"
finally:
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
def _modernize_deployment(spec: dict) -> dict:
"""Translate a test's terse legacy deployment dict to the current
manager-discriminated shape (production dropped this read-compat post-migration).
``proxy``/``public`` → ``reach``; ``run.runner`` → ``manager`` (+ ``run.launcher``)."""
d = dict(spec)
proxy = bool(d.pop("proxy", False))
public = bool(d.pop("public", False))
if "reach" not in d:
if public:
d["reach"] = "public"
elif proxy:
d["reach"] = "internal"
if "manager" not in d:
run = dict(d.pop("run", None) or {})
runner = run.get("runner")
if runner == "static":
d["manager"] = "caddy"
if run.get("root"):
d["root"] = run["root"]
elif runner == "path":
d["manager"] = "path"
elif runner == "remote":
d["manager"] = "none"
for k in ("base_url", "health_url"):
if run.get(k):
d[k] = run[k]
else:
launch = {k: v for k, v in run.items() if k != "runner"}
launch["launcher"] = runner
d["manager"] = "systemd"
d["run"] = launch
return d
def _store_for(spec: dict) -> str:
if spec.get("schedule"):
return "jobs"
return {"systemd": "services", "caddy": "statics", "path": "tools", "none": "references"}[
spec["manager"]
]
def _write_wildpc_config(root: Path, config: dict) -> None:
"""Scatter a nested wildpc config dict into the on-disk layout: wildpc.yaml globals,
programs/<name>.yaml, and deployments/<kind>/<name>.yaml (fields modernized)."""
globals_data = {k: v for k, v in config.items() if k in ("gateway", "repo")}
(root / "wildpc.yaml").write_text(yaml.dump(globals_data, default_flow_style=False))
programs = config.get("programs") or {}
if programs:
(root / "programs").mkdir(parents=True, exist_ok=True)
for name, spec in programs.items():
(root / "programs" / f"{name}.yaml").write_text(
yaml.dump(spec, default_flow_style=False)
)
for section in ("services", "jobs", "deployments"):
for name, spec in (config.get(section) or {}).items():
modern = _modernize_deployment(spec)
store_dir = root / "deployments" / _store_for(modern)
store_dir.mkdir(parents=True, exist_ok=True)
(store_dir / f"{name}.yaml").write_text(
yaml.dump(modern, default_flow_style=False)
)
@pytest.fixture
def wildpc_root(tmp_path: Path) -> Generator[Path, None, None]:
"""Create a temporary wildpc root with directory-per-resource config."""
config = {
"gateway": {"port": 9000},
"programs": {
"test-tool": {
"description": "Test tool",
"source": "test-tool",
"system_dependencies": ["pandoc"],
},
"test-tool-2": {
"description": "Another test tool",
"source": "test-tool-2",
"version": "2.0.0",
},
"wired-in": {
"description": "Adopted repo, no stack",
"source": "wired-in",
"repo": "https://github.com/someone/wired-in.git",
"commands": {
"lint": [["make", "lint"]],
"test": [["make", "test"]],
"run": [["./bin/wired-in"]],
},
},
},
"services": {
# Path deployments — behavior "tool" derives from the `path` runner
# (behavior is derived from deployments, never stored).
"test-tool": {"program": "test-tool", "run": {"runner": "path"}},
"test-tool-2": {"program": "test-tool-2", "run": {"runner": "path"}},
"wired-in": {"program": "wired-in", "run": {"runner": "path"}},
"test-svc": {
"program": "test-svc-comp",
"description": "Test service",
"run": {
"runner": "python",
"program": "test-svc",
},
"expose": {
"http": {
"internal": {"port": 19000},
"health_path": "/health",
}
},
"proxy": True,
"manage": {"systemd": {}},
},
},
"jobs": {
"test-job": {
"description": "Test job",
"run": {
"runner": "command",
"argv": ["test-job"],
},
"schedule": "0 2 * * *",
},
},
}
_write_wildpc_config(tmp_path, config)
yield tmp_path
@pytest.fixture
def registry_path(tmp_path: Path, wildpc_root: Path) -> Generator[Path, None, None]:
"""Create a temporary registry.yaml and patch the module to use it."""
reg_path = tmp_path / "registry.yaml"
registry = NodeRegistry(
node=NodeConfig(
hostname="test-node",
wildpc_root=str(wildpc_root),
gateway_port=9000,
),
deployed={
NodeRegistry.key("service", "test-svc"): Deployment(
manager="systemd",
launcher="python",
run_cmd=["uv", "run", "test-svc"],
env={
"TEST_SVC_PORT": "19000",
"TEST_SVC_DATA_DIR": "/home/user/.wildpc/data/test-svc",
},
description="Test service",
name="test-svc",
kind="service",
port=19000,
health_path="/health",
subdomain="test-svc",
managed=True,
),
# A deployed tool (path) — must NOT leak into the /services list.
NodeRegistry.key("tool", "test-tool"): Deployment(
manager="path",
run_cmd=[],
description="Test tool",
name="test-tool",
kind="tool",
),
},
)
save_registry(registry, reg_path)
# Patch the registry path and helper functions
import wildpc_core.registry as reg_mod
import wildpc_api.routes as routes_mod
import wildpc_api.services as services_mod
import wildpc_api.nodes as nodes_mod
import wildpc_api.stream as stream_mod
import wildpc_api.config_editor as config_editor_mod
original_path = reg_mod.REGISTRY_PATH
reg_mod.REGISTRY_PATH = reg_path
def _get_registry() -> NodeRegistry:
from wildpc_core.registry import load_registry
return load_registry(reg_path)
def _get_wildpc_root() -> Path | None:
return wildpc_root
# Save originals and patch everywhere these are imported
originals = {
"api_config.get_registry": api_config.get_registry,
"api_config.get_wildpc_root": api_config.get_wildpc_root,
"routes.get_registry": routes_mod.get_registry,
"routes.get_wildpc_root": routes_mod.get_wildpc_root,
"services.get_registry": services_mod.get_registry,
"services.get_wildpc_root": services_mod.get_wildpc_root,
"nodes.get_registry": nodes_mod.get_registry,
"stream.get_registry": stream_mod.get_registry,
"config_editor.get_wildpc_root": config_editor_mod.get_wildpc_root,
}
for mod in [
api_config,
routes_mod,
services_mod,
nodes_mod,
stream_mod,
config_editor_mod,
]:
if hasattr(mod, "get_registry"):
mod.get_registry = _get_registry
if hasattr(mod, "get_wildpc_root"):
mod.get_wildpc_root = _get_wildpc_root
yield reg_path
reg_mod.REGISTRY_PATH = original_path
api_config.get_registry = originals["api_config.get_registry"]
api_config.get_wildpc_root = originals["api_config.get_wildpc_root"]
routes_mod.get_registry = originals["routes.get_registry"]
routes_mod.get_wildpc_root = originals["routes.get_wildpc_root"]
services_mod.get_registry = originals["services.get_registry"]
services_mod.get_wildpc_root = originals["services.get_wildpc_root"]
nodes_mod.get_registry = originals["nodes.get_registry"]
stream_mod.get_registry = originals["stream.get_registry"]
config_editor_mod.get_wildpc_root = originals["config_editor.get_wildpc_root"]
@pytest.fixture
def client(registry_path: Path) -> Generator[TestClient, None, None]:
"""Create a test client with temporary registry."""
with TestClient(app) as client:
yield client

View File

@@ -0,0 +1,38 @@
"""Tests for cascade program deletion via /config/programs/{name}."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
class TestCascadeDelete:
def test_blocked_without_cascade(self, client: TestClient) -> None:
"""A program with a referencing deployment refuses a plain delete (409)."""
# test-tool (program) is referenced by test-tool (a path deployment).
r = client.delete("/config/programs/test-tool")
assert r.status_code == 409
assert "cascade" in r.json()["detail"]
def test_cascade_removes_program_and_deployment(
self, client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""cascade=true tears down + removes the deployments and the program."""
# Stub the runtime teardown so the test has no systemctl/deploy side effects.
import wildpc_core.deploy as dp
import wildpc_core.lifecycle as lc
async def _noop(*_a: object, **_k: object) -> None:
return None
monkeypatch.setattr(lc, "deactivate", _noop)
monkeypatch.setattr(dp, "deploy", lambda *_a, **_k: None)
r = client.delete("/config/programs/test-tool?cascade=true")
assert r.status_code == 200
body = r.json()
assert body["action"] == "deleted"
assert "test-tool" in body["removed_deployments"]
# The program is gone from the catalog.
assert client.get("/programs/test-tool").status_code == 404

View File

@@ -0,0 +1,111 @@
"""Edit-safety tests for the deployment detail + save endpoints.
These guard the regression that broke astro (and postgres): the detail endpoint
served the *runtime view* (no reach/program/root/expose) for a deployed deployment,
so the dashboard edit form round-tripped a lossy manifest and stripped spec-only
fields on save. The fixtures already have `test-svc` deployed AND defined in
wildpc.yaml (with legacy `proxy: true` → `reach: internal`) — exactly that case.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
class TestDeploymentEditSafety:
def test_detail_serves_editable_spec_not_runtime(self, client: TestClient) -> None:
"""A deployed deployment that's in wildpc.yaml serves its EDITABLE SPEC —
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["expose"]["http"]["internal"]["port"] == 19000
def test_save_roundtrip_preserves_spec_fields(self, client: TestClient) -> None:
"""GET a deployment's manifest → PUT it back unchanged → GET again: no
spec field may be lost. This is the exact round-trip the dashboard does on
an edit, and the exact thing that dropped astro's program/root."""
before = client.get("/deployments/test-svc").json()["manifest"]
resp = client.put("/config/deployments/test-svc", json={"config": before})
assert resp.status_code == 200, resp.text
after = client.get("/deployments/test-svc").json()["manifest"]
for key in ("reach", "expose", "run", "program"):
assert after.get(key) == before.get(key), f"{key} lost on save round-trip"
def test_partial_patch_preserves_untouched_fields(self, client: TestClient) -> None:
"""PATCH semantics: sending ONLY the changed field must not drop the rest.
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"}}
)
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["run"] == before["run"]
assert after["expose"] == before["expose"]
def test_explicit_null_clears_a_field(self, client: TestClient) -> None:
"""An explicit null clears a field — so a form can still *remove* exposure
under merge semantics (omit = preserve, null = clear). Removing the port
goes with reach: off (a port-only process), which is what ServiceFields
sends when the port is cleared."""
resp = client.put(
"/config/deployments/test-svc",
json={"config": {"expose": None, "reach": "off"}},
)
assert resp.status_code == 200, resp.text
after = client.get("/deployments/test-svc").json()["manifest"]
assert after.get("expose") is None # cleared
assert after["reach"] == "off"
assert after["program"] == "test-svc-comp" # rest preserved
class TestProgramEditSafety:
def test_program_partial_patch_preserves(self, client: TestClient) -> None:
"""save_program is the same footgun: a partial edit must not drop
source/commands. Send only a description; the rest survives."""
resp = client.put(
"/config/programs/wired-in",
json={"config": {"description": "renamed"}},
)
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
def test_deployment_edit_leaves_wildpc_yaml_globals_untouched(client, wildpc_root):
"""The regression that bit us: a config-editor deployment edit must NOT rewrite
wildpc.yaml globals (role/secrets). Scoped writes guarantee this."""
import yaml
cy = wildpc_root / "wildpc.yaml"
data = yaml.safe_load(cy.read_text())
data["role"] = "authority"
data["secrets"] = {"backend": "openbao", "addr": "https://v:8200"}
cy.write_text(yaml.safe_dump(data))
before = cy.read_text()
resp = client.put("/config/deployments/test-svc", json={"config": {"reach": "off"}})
assert resp.status_code == 200
assert cy.read_text() == before, "deployment edit rewrote wildpc.yaml globals"
def test_program_edit_leaves_wildpc_yaml_globals_untouched(client, wildpc_root):
import yaml
cy = wildpc_root / "wildpc.yaml"
data = yaml.safe_load(cy.read_text())
data["role"] = "authority"
cy.write_text(yaml.safe_dump(data))
before = cy.read_text()
resp = client.put("/config/programs/wired-in", json={"config": {"version": "9.9.9"}})
assert resp.status_code == 200
assert cy.read_text() == before, "program edit rewrote wildpc.yaml globals"

View 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))

View File

@@ -0,0 +1,71 @@
"""Tests for the /graph diagnostic and /repos (repo-scoped sync) endpoints."""
import os
import subprocess
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",
}
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},
)
def _commit(cwd: Path, fname: str) -> None:
(cwd / fname).write_text(fname)
_git(cwd, "add", fname)
_git(cwd, "commit", "-m", f"add {fname}")
def _setup(work: Path) -> None:
upstream = work.parent / f"{work.name}-upstream"
upstream.mkdir()
_git(upstream, "init", "-q", "-b", "main")
_commit(upstream, "a.txt")
_git(work.parent, "clone", "-q", str(upstream), str(work))
class TestGraph:
def test_graph_shape(self, client: TestClient) -> None:
resp = client.get("/graph")
assert resp.status_code == 200
body = resp.json()
assert {"repos", "nodes", "edges"} <= body.keys()
# every node carries the derived predicates
assert all("functional" in n for n in body["nodes"])
class TestRepos:
def test_list_and_sync(self, client: TestClient, wildpc_root: Path) -> None:
_setup(wildpc_root / "wired-in") # program `wired-in` source → <root>/wired-in
_commit(wildpc_root / "wired-in-upstream", "b.txt") # advance remote
repos = {r["key"]: r for r in client.get("/repos").json()}
assert "wired-in" in repos
assert "wired-in" in repos["wired-in"]["programs"]
gitinfo = client.get("/repos/wired-in/git").json()
assert gitinfo["is_repo"] is True and gitinfo["behind"] == 1
synced = client.post("/repos/wired-in/sync").json()
assert synced["pulled"] is True and "wired-in" in synced["deployments"]
assert (wildpc_root / "wired-in" / "b.txt").exists()
def test_unknown_repo_404(self, client: TestClient) -> None:
assert client.get("/repos/nope/git").status_code == 404
assert client.post("/repos/nope/sync").status_code == 404

View File

@@ -0,0 +1,329 @@
"""Tests for wildpc-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["subdomain"] == "test-svc"
assert svc["managed"] is True
assert svc["kind"] == "service"
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["kind"] == "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["kind"] == "job"
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
# A deployed deployment that's defined in wildpc.yaml serves its editable
# source spec (launcher nested under `run`), not the runtime view — so the
# dashboard edit form gets reach/expose/defaults and can't strip them.
assert data["manifest"]["run"]["launcher"] == "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["subdomain"] == "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_enabled_flows_through(self, client: TestClient) -> None:
"""ServiceSummary surfaces the declared `enabled` state (default True)."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["enabled"] is True
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
def test_excludes_tools(self, client: TestClient) -> None:
"""Tools (path deployments) are not in the services list."""
response = client.get("/services")
data = response.json()
names = [s["id"] for s in data]
assert "test-tool" 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 wildpc.yaml ServiceSpec (nested run spec)
assert data["manifest"]["run"]["launcher"] == "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 wildpc.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 wildpc.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_lists_deployments(self, client: TestClient) -> None:
"""Program summary lists its deployments (name + kind), not a single kind."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert "kind" not in tool
kinds = {d["kind"] for d in tool["deployments"]}
assert "tool" in kinds
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 {d["kind"] for d in data["deployments"]} == {"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 2 deployed components (test-svc + test-tool); only the
# service is a managed systemd deployment.
assert data["deployment_count"] == 2
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()
# Address is the subdomain label (the service name), not a path.
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")
def test_gateway_tls_off_by_default(self, client: TestClient) -> None:
"""No TLS configured → tls is null (HTTP-only gateway)."""
data = client.get("/gateway").json()
assert data["tls"] is None
class TestConfigEditor:
"""Virtual wildpc.yaml aggregation/scatter endpoints."""
def test_get_aggregates_resources(self, client: TestClient) -> None:
"""GET /config returns a unified YAML aggregating all resource files."""
import yaml
response = client.get("/config")
assert response.status_code == 200
data = yaml.safe_load(response.json()["yaml_content"])
assert "test-tool" in data["programs"]
# service, job, and tool all live under the single deployments section now.
assert "test-svc" in data["deployments"]
assert "test-job" in data["deployments"]
def test_put_scatters_and_prunes(self, client: TestClient, wildpc_root) -> None:
"""PUT /config writes resource files and prunes removed ones."""
import yaml
current = yaml.safe_load(client.get("/config").json()["yaml_content"])
current["deployments"].pop("test-svc")
current["programs"]["new-tool"] = {
"description": "Brand new",
}
resp = client.put("/config", json={"yaml_content": yaml.dump(current)})
assert resp.status_code == 200, resp.text
assert not (wildpc_root / "deployments" / "test-svc.yaml").exists()
assert (wildpc_root / "programs" / "new-tool.yaml").exists()
after = yaml.safe_load(client.get("/config").json()["yaml_content"])
assert "new-tool" in after["programs"]
assert "test-svc" not in (after.get("deployments") or {})

View File

@@ -0,0 +1,102 @@
"""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 wildpc_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",
"manager": "systemd",
"run": {"launcher": "python", "program": "backup"},
"manage": {"systemd": {}},
}
_JOB = {
"program": "backup",
"manager": "systemd",
"run": {"launcher": "command", "argv": ["backup"]},
"schedule": "0 3 * * *",
}
_TOOL = {"program": "backup", "manager": "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, wildpc_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(wildpc_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, wildpc_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, wildpc_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(wildpc_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, wildpc_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(wildpc_root)
assert "backup" not in cfg.tools # tool gone
assert "backup" in cfg.services # twins survive
assert "backup" in cfg.jobs

View File

@@ -0,0 +1,104 @@
"""Tests for MeshStateManager."""
import time
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
def _make_registry(hostname: str, deployed: dict | None = None) -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname=hostname, gateway_port=9000),
deployed=deployed or {},
)
class TestRemoteNode:
"""RemoteNode staleness tracking."""
def test_fresh_node_not_stale(self) -> None:
node = RemoteNode(registry=_make_registry("a"))
assert not node.is_stale
def test_old_node_is_stale(self) -> None:
node = RemoteNode(
registry=_make_registry("a"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
assert node.is_stale
class TestMeshStateManager:
"""MeshStateManager add/remove/stale operations."""
def test_update_and_get(self) -> None:
mgr = MeshStateManager()
reg = _make_registry("devbox")
mgr.update_node("devbox", reg)
node = mgr.get_node("devbox")
assert node is not None
assert node.registry.node.hostname == "devbox"
assert node.online is True
def test_set_offline(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.set_offline("devbox")
node = mgr.get_node("devbox")
assert node is not None
assert node.online is False
def test_remove_node(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.remove_node("devbox")
assert mgr.get_node("devbox") is None
def test_remove_nonexistent_is_safe(self) -> None:
mgr = MeshStateManager()
mgr.remove_node("nope") # should not raise
def test_all_nodes_excludes_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes()
assert "fresh" in result
assert "stale" not in result
def test_all_nodes_includes_stale_when_requested(self) -> None:
mgr = MeshStateManager()
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes(include_stale=True)
assert "stale" in result
def test_prune_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
pruned = mgr.prune_stale()
assert pruned == ["stale"]
assert mgr.get_node("stale") is None
assert mgr.get_node("fresh") is not None
def test_update_replaces_existing(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry(
"devbox",
{"svc": Deployment(manager="systemd", launcher="python", run_cmd=["svc"])},
)
mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox")
assert node is not None
assert "svc" in node.registry.deployed

View File

@@ -0,0 +1,26 @@
"""HTTP-layer tests for the /mesh/config endpoints (mesh-disabled paths).
The write/read-through-the-client behavior is covered by test_nats_integration;
here we pin the endpoint wiring when no mesh client is attached.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_list_config_reports_role_when_mesh_disabled(client: TestClient) -> None:
r = client.get("/mesh/config")
assert r.status_code == 200
body = r.json()
assert body["keys"] == []
assert body["role"] == "follower" # test-node has no explicit role
def test_get_missing_config_is_404(client: TestClient) -> None:
assert client.get("/mesh/config/does/not/exist").status_code == 404
def test_write_without_mesh_is_503(client: TestClient) -> None:
r = client.put("/mesh/config/fleet/motd", json={"value": "x"})
assert r.status_code == 503

View File

@@ -0,0 +1,122 @@
"""Tests for the transport-agnostic mesh wire format (registry (de)serialization)."""
import json
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_api.mesh_wire import json_to_registry, registry_to_json
def _make_registry() -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(
hostname="tower", wildpc_root="/data/repos/wildpc", gateway_port=9000
),
deployed={
"my-svc": Deployment(
manager="systemd",
launcher="python",
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service",
name="my-svc",
kind="service",
stack="python-fastapi",
port=9001,
health_path="/health",
subdomain="my-svc",
managed=True,
),
"my-job": Deployment(
manager="systemd",
launcher="command",
run_cmd=["my-job"],
name="my-job",
kind="job",
stack="python-cli",
schedule="0 2 * * *",
),
},
)
class TestRegistrySerialization:
"""Round-trip serialization of NodeRegistry to/from JSON."""
def test_round_trip(self) -> None:
original = _make_registry()
json_str = registry_to_json(original)
restored = json_to_registry(json_str)
assert restored.node.hostname == "tower"
assert restored.node.gateway_port == 9000
def test_deployed_components_preserved(self) -> None:
original = _make_registry()
restored = json_to_registry(registry_to_json(original))
svc = restored.get("service", "my-svc")
assert svc is not None
assert svc.manager == "systemd"
assert svc.launcher == "python"
assert svc.port == 9001
assert svc.health_path == "/health"
assert svc.subdomain == "my-svc"
assert svc.managed is True
assert svc.kind == "service"
assert svc.stack == "python-fastapi"
def test_job_fields_preserved(self) -> None:
original = _make_registry()
restored = json_to_registry(registry_to_json(original))
job = restored.get("job", "my-job")
assert job is not None
assert job.launcher == "command"
assert job.schedule == "0 2 * * *"
assert job.kind == "job"
assert job.stack == "python-cli"
def test_optional_fields_omitted(self) -> None:
"""Fields like port, health_path are None when not set."""
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": Deployment(
manager="systemd", launcher="command", run_cmd=["bare"], name="bare"
),
},
)
restored = json_to_registry(registry_to_json(reg))
bare = restored.get("service", "bare")
assert bare.port is None
assert bare.health_path is None
assert bare.subdomain is None
assert bare.schedule is None
assert bare.managed is False
def test_node_role_preserved(self) -> None:
reg = NodeRegistry(
node=NodeConfig(hostname="civil", role="authority"), deployed={}
)
restored = json_to_registry(registry_to_json(reg))
assert restored.node.role == "authority"
def test_node_role_defaults_follower(self) -> None:
reg = NodeRegistry(node=NodeConfig(hostname="n"), deployed={})
restored = json_to_registry(registry_to_json(reg))
assert restored.node.role == "follower"
def test_no_secrets_in_payload(self) -> None:
"""env vars, run_cmd, and wildpc_root must never appear on the wire."""
original = _make_registry()
json_str = registry_to_json(original)
data = json.loads(json_str)
# No wildpc_root in node
assert "wildpc_root" not in data["node"]
# No env or run_cmd in any component
for name, comp in data["deployed"].items():
assert "env" not in comp, f"{name} has env in payload"
assert "run_cmd" not in comp, f"{name} has run_cmd in payload"

View File

@@ -0,0 +1,30 @@
"""WildpcNATSClient role gating — hermetic (no live NATS server needed).
The write-gate is checked before touching the KV bucket, so a follower is denied
without any connection.
"""
from __future__ import annotations
import asyncio
import pytest
from wildpc_core.registry import NodeConfig, NodeRegistry
from wildpc_api.nats_client import WildpcNATSClient
def _client(role: str) -> WildpcNATSClient:
reg = NodeRegistry(node=NodeConfig(hostname="n", role=role), deployed={})
return WildpcNATSClient("n", reg, servers="nats://localhost:4222")
def test_role_property() -> None:
assert _client("authority").role == "authority"
assert _client("follower").role == "follower"
def test_follower_cannot_write_shared_config() -> None:
client = _client("follower")
with pytest.raises(PermissionError):
asyncio.run(client.put_shared_config("fleet/key", "value"))

View File

@@ -0,0 +1,117 @@
"""Integration tests for WildpcNATSClient against a real NATS/JetStream broker.
Exercises the runtime the unit tests can't: connect, KV publish, peer discovery
via watch, presence, graceful-offline, and shared-config. Requires docker (the
`nats_url` fixture); skipped otherwise.
"""
from __future__ import annotations
import asyncio
import pytest
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
import wildpc_api.nats_client as ncmod
from wildpc_api.mesh import mesh_state
from wildpc_api.nats_client import WildpcNATSClient
@pytest.fixture(autouse=True)
def _isolate(monkeypatch):
"""Stub the gateway-regen side effect (so tests never touch the host's real
Caddyfile) and clear the shared mesh_state singleton around each test."""
async def _stub(*_a, **_k):
return False
monkeypatch.setattr(ncmod, "refresh_remote_routes", _stub)
mesh_state._nodes.clear()
yield
mesh_state._nodes.clear()
def _reg(host: str, deployed=None, role: str = "follower") -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname=host, role=role), deployed=deployed or {}
)
def _widget_reg(host: str) -> NodeRegistry:
w = Deployment(
manager="systemd", launcher="python", run_cmd=[], name="widget",
kind="service", port=9099, subdomain="widget",
)
return _reg(host, {NodeRegistry.key("service", "widget"): w})
def test_publish_peer_discovery_and_offline(nats_url: str) -> None:
async def run() -> None:
a = WildpcNATSClient("node-a", _reg("node-a"), servers=nats_url)
b = WildpcNATSClient("node-b", _widget_reg("node-b"), servers=nats_url)
await a.start()
await b.start()
await asyncio.sleep(1.0) # let watches propagate
nodes = mesh_state.all_nodes(include_stale=True)
assert "node-b" in nodes, "node-a should discover node-b via the KV watch"
assert nodes["node-b"].registry.get("service", "widget") is not None
# Graceful stop deletes b's key -> the DELETE watch marks it offline.
await b.stop()
await asyncio.sleep(1.0)
nb = mesh_state.get_node("node-b")
assert nb is None or not nb.online
await a.stop()
asyncio.run(run())
def test_presence_key_written(nats_url: str) -> None:
async def run() -> None:
c = WildpcNATSClient("solo", _reg("solo"), servers=nats_url)
await c.start()
keys = await c._presence_kv.keys()
assert "solo" in keys
await c.stop()
asyncio.run(run())
def test_shared_config_authority_write_read(nats_url: str) -> None:
async def run() -> None:
auth = WildpcNATSClient("auth", _reg("auth", role="authority"), servers=nats_url)
await auth.start()
await auth.put_shared_config("fleet/motd", "hello")
assert await auth.get_shared_config("fleet/motd") == "hello"
assert "fleet/motd" in await auth.list_shared_config()
await auth.stop()
asyncio.run(run())
def test_secrets_never_on_the_wire(nats_url: str) -> None:
"""The registry a peer receives must carry no env/run_cmd."""
async def run() -> None:
secretful = Deployment(
manager="systemd", launcher="python",
run_cmd=["uv", "run", "svc"], env={"API_KEY": "s3cr3t"},
name="svc", kind="service", port=9001, subdomain="svc",
)
a = WildpcNATSClient("wa", _reg("wa"), servers=nats_url)
b = WildpcNATSClient(
"wb", _reg("wb", {NodeRegistry.key("service", "svc"): secretful}),
servers=nats_url,
)
await a.start()
await b.start()
await asyncio.sleep(1.0)
svc = mesh_state.get_node("wb").registry.get("service", "svc")
assert svc is not None
assert not svc.env, "env must not cross the wire"
assert not svc.run_cmd, "run_cmd must not cross the wire"
await a.stop()
await b.stop()
asyncio.run(run())

View File

@@ -0,0 +1,94 @@
"""Tests for nodes endpoints."""
from pathlib import Path
from fastapi.testclient import TestClient
from wildpc_core.registry import Deployment, NodeConfig, NodeRegistry
from wildpc_api.mesh import MeshStateManager
class TestNodesList:
"""GET /nodes endpoint tests."""
def test_returns_local_node(self, client: TestClient) -> None:
"""Always returns the local node."""
response = client.get("/nodes")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
local = data[0]
assert local["hostname"] == "test-node"
assert local["is_local"] is True
assert local["online"] is True
def test_local_node_counts(self, client: TestClient) -> None:
"""Local node has correct deployment counts."""
response = client.get("/nodes")
data = response.json()
local = data[0]
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:
"""Remote nodes from mesh state are included."""
import wildpc_api.mesh as mesh_mod
original = mesh_mod.mesh_state
try:
mgr = MeshStateManager()
remote_reg = NodeRegistry(
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": Deployment(
manager="systemd",
launcher="python",
run_cmd=["svc"],
port=9050,
kind="service",
),
},
)
mgr.update_node("devbox", remote_reg)
mesh_mod.mesh_state = mgr
# Also patch the reference in the nodes module
import wildpc_api.nodes as nodes_mod
nodes_mod.mesh_state = mgr
response = client.get("/nodes")
data = response.json()
hostnames = [n["hostname"] for n in data]
assert "devbox" in hostnames
devbox = next(n for n in data if n["hostname"] == "devbox")
assert devbox["is_local"] is False
assert devbox["deployed_count"] == 1
finally:
mesh_mod.mesh_state = original
import wildpc_api.nodes as nodes_mod2
nodes_mod2.mesh_state = original
class TestNodeDetail:
"""GET /nodes/{hostname} endpoint tests."""
def test_local_node_detail(self, client: TestClient) -> None:
"""Returns local node detail with deployed components."""
response = client.get("/nodes/test-node")
assert response.status_code == 200
data = response.json()
assert data["hostname"] == "test-node"
assert data["is_local"] is True
assert len(data["deployed"]) == 2 # test-svc + test-tool
svc = next(d for d in data["deployed"] if d["id"] == "test-svc")
assert svc["node"] == "test-node"
def test_unknown_node_returns_404(self, client: TestClient) -> None:
"""Returns 404 for unknown hostname."""
response = client.get("/nodes/nonexistent")
assert response.status_code == 404

View File

@@ -0,0 +1,34 @@
"""Tests for the new per-program commands/repo fields on the programs API."""
from fastapi.testclient import TestClient
class TestProgramCommands:
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
progs = {p["id"]: p for p in resp.json()}
assert "wired-in" in progs
w = progs["wired-in"]
assert w["stack"] is None
assert w["repo"] == "https://github.com/someone/wired-in.git"
assert w["commands"]["lint"] == [["make", "lint"]]
assert w["commands"]["run"] == [["./bin/wired-in"]]
def test_wired_in_actions_resolved_from_commands(self, client: TestClient) -> None:
"""available actions come from declared commands when there's no stack."""
resp = client.get("/programs")
w = next(p for p in resp.json() if p["id"] == "wired-in")
# declared lint/test/run + the composite check (lint/test available)
assert set(w["actions"]) >= {"lint", "test", "run"}
assert "build" not in w["actions"] # not declared, no stack
def test_tools_via_kind_filter(self, client: TestClient) -> None:
"""Tools are reached via /programs?kind=tool (no dedicated /tools)."""
resp = client.get("/programs", params={"kind": "tool"})
assert resp.status_code == 200
ids = [p["id"] for p in resp.json()]
assert "wired-in" in ids and "test-tool" in ids

View File

@@ -0,0 +1,79 @@
"""Tests for the program git-status / sync endpoints."""
import os
import subprocess
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",
}
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},
)
def _commit(cwd: Path, fname: str) -> None:
(cwd / fname).write_text(fname)
_git(cwd, "add", fname)
_git(cwd, "commit", "-m", f"add {fname}")
def _setup_git_program(work: Path) -> None:
"""Make `work` a git clone tracking an `-upstream` repo with one commit."""
upstream = work.parent / f"{work.name}-upstream"
upstream.mkdir()
_git(upstream, "init", "-q", "-b", "main")
_commit(upstream, "a.txt")
_git(work.parent, "clone", "-q", str(upstream), str(work))
class TestProgramGit:
def test_non_git_source_is_benign(self, client: TestClient) -> None:
"""A program whose source isn't a git repo returns is_repo:false, not 500."""
resp = client.get("/programs/test-tool/git")
assert resp.status_code == 200
assert resp.json()["is_repo"] is False
def test_status_behind_then_sync_pulls(
self, client: TestClient, wildpc_root: Path
) -> None:
work = wildpc_root / "wired-in" # source: "wired-in" → <root>/wired-in
_setup_git_program(work)
_commit(work.parent / "wired-in-upstream", "b.txt") # advance the remote
status = client.get("/programs/wired-in/git")
assert status.status_code == 200
body = status.json()
assert body["is_repo"] is True and body["branch"] == "main"
assert body["behind"] == 1 and body["ahead"] == 0
synced = client.post("/programs/wired-in/sync")
assert synced.status_code == 200
s = synced.json()
assert s["status"] == "ok" and s["pulled"] is True
assert "wired-in" in s["deployments"] # affected deployment surfaced
assert (work / "b.txt").exists()
# A second sync is a no-op: nothing pulled, no affected deployments.
again = client.post("/programs/wired-in/sync").json()
assert again["pulled"] is False and again["deployments"] == []
def test_sync_unknown_program_404(self, client: TestClient) -> None:
assert client.post("/programs/nope/sync").status_code == 404
def test_sync_non_git_source_400(self, client: TestClient) -> None:
assert client.post("/programs/test-tool/sync").status_code == 400

View File

@@ -0,0 +1,33 @@
"""HTTP-layer tests for the node-override endpoints (file-backend paths).
The live OpenBao override round-trip is verified manually; here we pin the
file-backend behavior (overrides are an OpenBao-only feature).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_list_overrides_empty_on_file_backend(client: TestClient) -> None:
r = client.get("/secrets/overrides")
assert r.status_code == 200
assert r.json() == {"overrides": {}}
def test_set_override_rejected_on_file_backend(client: TestClient) -> None:
r = client.put("/secrets/overrides/primer/POSTGRES_PASSWORD", json={"value": "x"})
assert r.status_code == 400
def test_get_missing_override_is_404(client: TestClient) -> None:
assert client.get("/secrets/overrides/primer/NOPE").status_code == 404
def test_secrets_info_reports_backend(client: TestClient) -> None:
r = client.get("/secrets/info")
assert r.status_code == 200
body = r.json()
assert body["backend"] == "file" # conftest forces file in tests
assert body["writable"] is True
assert "role" in body

View File

@@ -0,0 +1,29 @@
"""Tests for the stack-dependency endpoints (/stacks, /stacks/status, /stacks/{name})."""
from fastapi.testclient import TestClient
from wildpc_core.stacks import available_stacks
class TestStacks:
def test_names_endpoint_stays_a_bare_list(self, client: TestClient) -> None:
"""`GET /stacks` keeps its back-compat string[] shape (the create-form select
depends on it) even though the richer status lives at /stacks/status."""
resp = client.get("/stacks")
assert resp.status_code == 200
assert resp.json() == available_stacks()
def test_status_shape(self, client: TestClient) -> None:
resp = client.get("/stacks/status")
assert resp.status_code == 200
body = resp.json()
assert {s["name"] for s in body} == set(available_stacks())
st = next(s for s in body if s["name"] == "python-fastapi")
# python-fastapi declares uv; every tool carries its phase + fix.
assert {"in_use", "ok", "tools", "programs", "verbs"} <= st.keys()
uv = next(t for t in st["tools"] if t["command"] == "uv")
assert uv["phase"] == "both" and uv["install_hint"]
def test_detail_and_404(self, client: TestClient) -> None:
assert client.get("/stacks/python-cli").json()["name"] == "python-cli"
assert client.get("/stacks/nope").status_code == 404

View File

@@ -0,0 +1,199 @@
"""POST /config/tools/{name}/schema — derive an Anthropic tool schema from --help,
and the tool_schema field round-tripping through the tool save path."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
import json
from wildpc_core.config import load_config
from wildpc_api.config import settings
def _completion(args: dict) -> dict:
"""A minimal chat-completions response carrying an emit_tool_schema call."""
return {
"choices": [
{"message": {"tool_calls": [
{"function": {"name": "emit_tool_schema", "arguments": json.dumps(args)}}
]}}
]
}
_BAD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string", "enum": 3}}},
}
_GOOD_CORE = {
"name": "python3", "description": "d",
"parameters": {"type": "object", "properties": {"m": {"type": "string"}}},
}
# A tool whose program falls back to the tool name for its executable; `python3`
# is always on PATH, so derive succeeds deterministically.
_PY_TOOL = {"program": "python3", "manager": "path"}
class TestGenerateSchema:
def test_generate_returns_draft_not_saved(
self, client: TestClient, wildpc_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
r = client.post("/config/tools/python3/schema")
assert r.status_code == 200, r.text
body = r.json()
schema = body["schema"]
# The stored draft is the neutral core (name/description/parameters),
# rendered to a provider envelope only on read.
assert schema["name"] == "python3"
assert "parameters" in schema
assert schema["parameters"]["properties"]
# It's a draft: nothing persisted until the client saves it.
cfg = load_config(wildpc_root)
assert cfg.tools["python3"].tool_schema is None
def test_unknown_tool_404(self, client: TestClient) -> None:
r = client.post("/config/tools/nope-not-a-tool/schema")
assert r.status_code == 404
def test_validate_endpoint_valid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_GOOD_CORE)
assert r.status_code == 200
assert r.json() == {"valid": True, "errors": []}
def test_validate_endpoint_invalid(self, client: TestClient) -> None:
r = client.post("/config/tools/schema/validate", json=_BAD_CORE)
assert r.status_code == 200
body = r.json()
assert body["valid"] is False and body["errors"]
def test_uninstalled_executable_422(
self, client: TestClient, wildpc_root: Path
) -> None:
client.put(
"/config/tools/ghosttool",
json={"config": {"program": "ghosttool", "manager": "path"}},
)
r = client.post("/config/tools/ghosttool/schema")
assert r.status_code == 422
assert "PATH" in r.json()["detail"]
def test_assist_disabled_returns_503(
self, client: TestClient, wildpc_root: Path
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
# llm_enabled is False by default.
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 503
assert "disabled" in r.json()["detail"]
def test_assist_success_returns_draft(
self, client: TestClient, wildpc_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fake_llm(help_text: str, name: str) -> dict:
assert "python3" in help_text # the recursive help was gathered
return {
"name": name,
"description": "the python interpreter",
"parameters": {
"type": "object",
"properties": {"script": {"type": "string"}},
},
}
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("wildpc_api.llm.generate_tool_schema_llm", _fake_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
body = r.json()
assert body["assist"] == "llm"
assert body["schema"]["parameters"]["properties"] == {"script": {"type": "string"}}
def test_assist_upstream_error_returns_502(
self, client: TestClient, wildpc_root: Path, monkeypatch
) -> None:
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _fail_llm(help_text: str, name: str) -> dict:
from wildpc_api.llm import LLMAssistError
raise LLMAssistError("litellm returned 500")
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("wildpc_api.llm.generate_tool_schema_llm", _fail_llm)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "litellm" in r.json()["detail"]
def test_assist_repairs_invalid_then_valid(
self, client: TestClient, wildpc_root: Path, monkeypatch
) -> None:
"""A malformed first response (bad enum) is fed back and repaired."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
calls = {"n": 0}
async def _fake_complete(messages: list, key: str) -> dict:
calls["n"] += 1
return _completion(_BAD_CORE if calls["n"] == 1 else _GOOD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("wildpc_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("wildpc_api.llm._complete", _fake_complete)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 200, r.text
assert calls["n"] == 2 # repaired on the second attempt
props = r.json()["schema"]["parameters"]["properties"]
assert props == {"m": {"type": "string"}}
def test_assist_unrepairable_returns_502(
self, client: TestClient, wildpc_root: Path, monkeypatch
) -> None:
"""Persistently invalid output exhausts the repair budget → 502."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
async def _always_bad(messages: list, key: str) -> dict:
return _completion(_BAD_CORE)
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr("wildpc_api.llm.read_secret", lambda name: "k")
monkeypatch.setattr("wildpc_api.llm._complete", _always_bad)
r = client.post("/config/tools/python3/schema?assist=llm")
assert r.status_code == 502
assert "attempts" in r.json()["detail"]
def test_schema_round_trips_through_save(
self, client: TestClient, wildpc_root: Path
) -> None:
"""Saving a tool with tool_schema persists it (PATCH merge), and clearing
it with null drops the field."""
client.put("/config/tools/python3", json={"config": _PY_TOOL})
schema = client.post("/config/tools/python3/schema").json()["schema"]
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": schema}},
)
cfg = load_config(wildpc_root)
assert cfg.tools["python3"].tool_schema is not None
assert cfg.tools["python3"].tool_schema["name"] == "python3"
# Clearing with null removes it (default None).
client.put(
"/config/tools/python3",
json={"config": {"program": "python3", "tool_schema": None}},
)
cfg = load_config(wildpc_root)
assert cfg.tools["python3"].tool_schema is None