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

@@ -0,0 +1,74 @@
"""Consumption audit — *suggests* undeclared ``requires`` by matching a
deployment's env endpoint values against known provider sockets.
This is the one place castle looks at env → dependency, and it does so **only to
propose a declaration the user confirms** — never to write, and never to feed the
graph or ``functional?``. The relationship graph stays strictly declaration-derived
(see docs/relationships.md, "Env is derived *from* requires, never scraped *into*
it"); this module is an explicit, opt-in lint that sits *on top* of it. A suggestion
is accepted by writing a real ``requires`` (a declaration), at which point it stops
being a suggestion.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from castle_core.config import CastleConfig
from castle_core.relations import build_model
# Hosts that mean "a provider on this node" — a port match against them is a strong
# signal (ports are unique per host). 172.17.0.1 is the docker bridge to the host.
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", "172.17.0.1"}
# scheme://host:port OR host:port — anywhere inside an env value.
_HOSTPORT = re.compile(r"(?:(?P<scheme>[a-z][\w+.-]*)://)?(?P<host>[\w.-]+):(?P<port>\d{2,5})")
@dataclass
class Suggestion:
consumer: str # the deployment whose env references the endpoint
provider: str # the provider deployment the port resolves to
env_var: str # the env var that revealed it
endpoint: str # host:port as seen in the value
protocol: str # the provider's socket protocol
def suggest_consumption(config: CastleConfig) -> list[Suggestion]:
"""Undeclared consumption suggestions, derived from env endpoint values.
For each deployment, scan its ``defaults.env`` values for ``host:port`` (or a
URL). When the port resolves to a *local* provider's socket and the consumer
doesn't already declare it, propose the edge. Deduped per (consumer, provider)."""
model = build_model(config, check=False)
# port -> (provider name, protocol); ports are unique per host, so a port match
# against a local host is a confident resolution.
port_provider: dict[int, tuple[str, str]] = {}
for n in model.nodes:
for ep in n.endpoints:
port_provider.setdefault(ep.port, (n.name, ep.protocol))
out: list[Suggestion] = []
for _kind, name, dep in config.all_deployments():
env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
declared = {r.ref for r in getattr(dep, "requires", [])}
proposed: set[str] = set()
for var, val in env.items():
for m in _HOSTPORT.finditer(str(val)):
host = m.group("host")
port = int(m.group("port"))
prov = port_provider.get(port)
if not prov:
continue
pname, proto = prov
# Only resolve when the value points at a local host (this node's
# providers) or names the provider directly — avoids matching a
# coincidental external host that happens to share a port number.
if host not in _LOCAL_HOSTS and host != pname:
continue
if pname == name or pname in declared or pname in proposed:
continue
proposed.add(pname)
out.append(Suggestion(name, pname, var, f"{host}:{port}", proto))
return out

View File

@@ -24,7 +24,7 @@ from pathlib import Path
from castle_core import git
from castle_core.config import CastleConfig
from castle_core.manifest import Requirement
from castle_core.manifest import Requirement, SystemdDeployment
@dataclass
@@ -53,6 +53,16 @@ class Edge:
bind: str | None = None # env var to project the target URL into (deployment)
@dataclass
class Endpoint:
"""A socket a deployment exposes. ``protocol`` is a display heuristic — the
manifest has no protocol field (expose is http XOR tcp), so raw TCP is refined
by well-known port (else ``tcp``). A real protocol field is future work."""
protocol: str # "http" | "tcp" | "pg" | "bolt" | "mqtt" | "redis"
port: int
@dataclass
class Node:
name: str # deployment name
@@ -64,6 +74,11 @@ class Node:
functional: bool = True # derived: all requirements satisfied
fresh: bool | None = None # derived: its repo is at latest + clean
deployed: bool | None = None # derived: active in the registry (None = unknown)
reach: str | None = None # off|internal|public (systemd/caddy), else None
endpoints: list[Endpoint] = field(default_factory=list) # sockets it exposes
base_url: str | None = None # the target URL, for kind=="reference" (external)
provides: list[str] = field(default_factory=list) # capability types (from program)
consumes: list[str] = field(default_factory=list) # capability types (from program)
@dataclass
@@ -163,6 +178,25 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
return True
# Well-known TCP ports → a friendlier protocol label (display heuristic only).
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
def _endpoints_of(dep: object) -> list[Endpoint]:
"""The sockets a deployment exposes, derived from its manifest — reusing the
``http_exposed`` / ``tcp_port`` accessors (SystemdDeployment only). Tools,
statics, and references have no expose block and yield ``[]``."""
eps: list[Endpoint] = []
if not isinstance(dep, SystemdDeployment):
return eps # only systemd services/jobs carry an expose block
exp = dep.expose
if dep.http_exposed and exp and exp.http:
eps.append(Endpoint("http", exp.http.internal.port))
if dep.tcp_port is not None:
eps.append(Endpoint(_TCP_PROTOCOL.get(dep.tcp_port, "tcp"), dep.tcp_port))
return eps
def build_model(
config: CastleConfig,
check: bool = True,
@@ -206,11 +240,13 @@ def build_model(
if check
else []
)
repo_key = repo_of.get(_program_of(name, dep))
prog_name = _program_of(name, dep)
repo_key = repo_of.get(prog_name)
prog = config.programs.get(prog_name) if prog_name else None
nodes.append(
Node(
name=name,
program=_program_of(name, dep),
program=prog_name,
kind=_nk,
repo=repo_key,
depended_on_by=fan_in.get(name, 0),
@@ -218,6 +254,11 @@ def build_model(
functional=not unmet,
fresh=fresh_of.get(repo_key) if (freshness and repo_key) else None,
deployed=(name in active) if active is not None else None,
reach=getattr(getattr(dep, "reach", None), "value", None),
endpoints=_endpoints_of(dep),
base_url=getattr(dep, "base_url", None),
provides=[c.type for c in prog.provides] if prog else [],
consumes=[c.type for c in prog.consumes] if prog else [],
)
)
return Model(repos=list(repos.values()), nodes=nodes, edges=edges)