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:
0
wildpc-api/tests/__init__.py
Normal file
0
wildpc-api/tests/__init__.py
Normal file
303
wildpc-api/tests/conftest.py
Normal file
303
wildpc-api/tests/conftest.py
Normal 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
|
||||
38
wildpc-api/tests/test_config_delete.py
Normal file
38
wildpc-api/tests/test_config_delete.py
Normal 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
|
||||
111
wildpc-api/tests/test_deployment_roundtrip.py
Normal file
111
wildpc-api/tests/test_deployment_roundtrip.py
Normal 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"
|
||||
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))
|
||||
71
wildpc-api/tests/test_graph_repos.py
Normal file
71
wildpc-api/tests/test_graph_repos.py
Normal 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
|
||||
329
wildpc-api/tests/test_health.py
Normal file
329
wildpc-api/tests/test_health.py
Normal 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 {})
|
||||
102
wildpc-api/tests/test_kind_twins.py
Normal file
102
wildpc-api/tests/test_kind_twins.py
Normal 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
|
||||
104
wildpc-api/tests/test_mesh.py
Normal file
104
wildpc-api/tests/test_mesh.py
Normal 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
|
||||
26
wildpc-api/tests/test_mesh_config_api.py
Normal file
26
wildpc-api/tests/test_mesh_config_api.py
Normal 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
|
||||
122
wildpc-api/tests/test_mesh_wire.py
Normal file
122
wildpc-api/tests/test_mesh_wire.py
Normal 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"
|
||||
30
wildpc-api/tests/test_nats_client.py
Normal file
30
wildpc-api/tests/test_nats_client.py
Normal 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"))
|
||||
117
wildpc-api/tests/test_nats_integration.py
Normal file
117
wildpc-api/tests/test_nats_integration.py
Normal 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())
|
||||
94
wildpc-api/tests/test_nodes.py
Normal file
94
wildpc-api/tests/test_nodes.py
Normal 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
|
||||
34
wildpc-api/tests/test_programs_commands.py
Normal file
34
wildpc-api/tests/test_programs_commands.py
Normal 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
|
||||
79
wildpc-api/tests/test_programs_git.py
Normal file
79
wildpc-api/tests/test_programs_git.py
Normal 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
|
||||
33
wildpc-api/tests/test_secrets_overrides.py
Normal file
33
wildpc-api/tests/test_secrets_overrides.py
Normal 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
|
||||
29
wildpc-api/tests/test_stacks_api.py
Normal file
29
wildpc-api/tests/test_stacks_api.py
Normal 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
|
||||
199
wildpc-api/tests/test_tool_schema_api.py
Normal file
199
wildpc-api/tests/test_tool_schema_api.py
Normal 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
|
||||
Reference in New Issue
Block a user