feat(core+api): sockets, consumption audit, capabilities, mesh endpoints

Graph model gains, all derived (GET /graph auto-exposes via asdict):
- endpoints[{protocol,port}] + reach per node — reusing http_exposed/tcp_port,
  so raw-TCP infra (postgres/neo4j/mqtt) is finally visible and edges can be
  protocol-typed by the target's socket.
- base_url for reference (external) nodes.
- provides/consumes capability types, activating the dormant ProgramSpec fields.

New surfaces:
- core/audit.py + GET /graph/suggestions: SUGGESTS undeclared `requires` by
  matching a deployment's env endpoint values against provider sockets. Never
  writes; the graph stays declaration-derived (docs/relationships.md).
- kind-scoped PUT/DELETE /config/references/{name} for external resources.
- GET /mesh/deployments (flattened remote deployments + derived endpoints), and
  the mesh MQTT payload now carries tcp_port + base_url (forward-compatible,
  still no secrets) so peers can resolve cross-node endpoints.
This commit is contained in:
2026-07-06 16:09:12 -07:00
parent 8086a09bf7
commit 800f539ef6
6 changed files with 185 additions and 3 deletions

View File

@@ -458,6 +458,18 @@ def delete_static(name: str) -> dict:
return _delete_deployment(name, kind="static")
@router.put("/references/{name}")
def save_reference(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update an external *reference* (manager: none, base_url) — an
endpoint castle doesn't run (a SaaS API, a remote/external service)."""
return _save_deployment(name, request.config, kind="reference")
@router.delete("/references/{name}")
def delete_reference(name: str) -> dict:
return _delete_deployment(name, kind="reference")
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Converge the running system to match castle.yaml (a thin wrapper on core

View File

@@ -11,6 +11,7 @@ import dataclasses
from fastapi import APIRouter
from castle_core.audit import suggest_consumption
from castle_core.relations import build_model
from castle_api.config import get_config
@@ -28,3 +29,11 @@ def get_graph() -> dict:
"nodes": [dataclasses.asdict(n) for n in model.nodes],
"edges": [dataclasses.asdict(e) for e in model.edges],
}
@graph_router.get("/graph/suggestions")
def get_suggestions() -> dict:
"""Undeclared-consumption *suggestions* — an opt-in advisory that matches each
deployment's env endpoint values against provider sockets. Never writes; the
graph itself stays declaration-derived. Accept one by declaring the `requires`."""
return {"suggestions": [dataclasses.asdict(s) for s in suggest_consumption(get_config())]}

View File

@@ -61,6 +61,12 @@ def _registry_to_json(registry: NodeRegistry) -> str:
entry["schedule"] = comp.schedule
if comp.managed:
entry["managed"] = comp.managed
# Socket surface + external target — so a peer can resolve cross-node
# consumption endpoints (still no secrets: only ports/URLs).
if getattr(comp, "tcp_port", None) is not None:
entry["tcp_port"] = comp.tcp_port
if getattr(comp, "base_url", None):
entry["base_url"] = comp.base_url
data["deployed"][NodeRegistry.key(comp.kind, name)] = entry
return json.dumps(data)
@@ -93,6 +99,8 @@ def _json_to_registry(payload: str) -> NodeRegistry:
subdomain=comp_data.get("subdomain"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
tcp_port=comp_data.get("tcp_port"),
base_url=comp_data.get("base_url"),
)
return NodeRegistry(node=node, deployed=deployed)

View File

@@ -93,6 +93,44 @@ def list_nodes() -> list[NodeSummary]:
return nodes
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
def _endpoints_of_registry(d: object) -> list[dict]:
"""Derive display endpoints from a registry deployment (mirrors relations)."""
eps: list[dict] = []
port = getattr(d, "port", None)
if port is not None:
eps.append({"protocol": "http", "port": port})
tcp = getattr(d, "tcp_port", None)
if tcp is not None:
eps.append({"protocol": _TCP_PROTOCOL.get(tcp, "tcp"), "port": tcp})
return eps
@router.get("/mesh/deployments")
def mesh_deployments() -> dict:
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
data the System Map needs to render other machines. Local node excluded (it's
already in /graph). Cross-node dependency *edges* need `requires` in the mesh
payload, which the registry doesn't carry yet."""
out: list[dict] = []
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
for _kind, name, d in remote.registry.all():
out.append(
{
"name": name,
"kind": d.kind,
"node": hostname,
"port": d.port,
"base_url": getattr(d, "base_url", None),
"subdomain": d.subdomain,
"endpoints": _endpoints_of_registry(d),
}
)
return {"deployments": out}
@router.get("/nodes/{hostname}", response_model=NodeDetail)
def get_node(hostname: str) -> NodeDetail:
"""Get detailed info for a specific node."""