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:
117
core/src/wildpc_core/audit.py
Normal file
117
core/src/wildpc_core/audit.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Consumption audit — *suggests* undeclared ``requires`` by matching a
|
||||
deployment's env endpoint values against known provider sockets.
|
||||
|
||||
This is the one place wildpc 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 wildpc_core.config import WildpcConfig
|
||||
from wildpc_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 _resolve(
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
consumer: str,
|
||||
port_provider: dict[int, tuple[str, str]],
|
||||
declared: set[str],
|
||||
proposed: set[str],
|
||||
env_var: str,
|
||||
out: list[Suggestion],
|
||||
) -> None:
|
||||
"""Resolve a (host, port) to a provider and record a suggestion if it's a new,
|
||||
undeclared, local match. Only resolve local hosts (this node's providers) or a
|
||||
host that names the provider — avoids matching a coincidental external host that
|
||||
happens to share a port number."""
|
||||
prov = port_provider.get(port)
|
||||
if not prov:
|
||||
return
|
||||
pname, proto = prov
|
||||
if host not in _LOCAL_HOSTS and host != pname:
|
||||
return
|
||||
if pname == consumer or pname in declared or pname in proposed:
|
||||
return
|
||||
proposed.add(pname)
|
||||
out.append(Suggestion(consumer, pname, env_var, f"{host}:{port}", proto))
|
||||
|
||||
|
||||
def suggest_consumption(config: WildpcConfig) -> list[Suggestion]:
|
||||
"""Undeclared consumption suggestions, derived from env endpoint values.
|
||||
|
||||
Two shapes are recognized in a deployment's ``defaults.env``:
|
||||
(a) ``host:port`` inside a single value (a URL, a ``DATABASE_URL``); and
|
||||
(b) a split ``X_HOST`` + ``X_PORT`` pair (e.g. ``WILDPC_API_MQTT_HOST`` +
|
||||
``WILDPC_API_MQTT_PORT``). 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). A bare ``*_PORT`` with no ``*_HOST`` is ignored: without an
|
||||
explicit host it can't be told apart from the deployment's own listen port."""
|
||||
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()
|
||||
|
||||
def resolve(host: str, port: int, env_var: str) -> None:
|
||||
_resolve(
|
||||
host,
|
||||
port,
|
||||
consumer=name,
|
||||
port_provider=port_provider,
|
||||
declared=declared,
|
||||
proposed=proposed,
|
||||
env_var=env_var,
|
||||
out=out,
|
||||
)
|
||||
|
||||
# (a) host:port inside a single value.
|
||||
for var, val in env.items():
|
||||
for m in _HOSTPORT.finditer(str(val)):
|
||||
resolve(m.group("host"), int(m.group("port")), var)
|
||||
# (b) split X_HOST + X_PORT pair.
|
||||
for var, val in env.items():
|
||||
if not var.endswith("_HOST"):
|
||||
continue
|
||||
pvar = var[:-5] + "_PORT" # X_HOST -> X_PORT
|
||||
if pvar not in env:
|
||||
continue
|
||||
try:
|
||||
port = int(str(env[pvar]).strip())
|
||||
except ValueError:
|
||||
continue
|
||||
resolve(str(val).strip(), port, f"{var}+{pvar}")
|
||||
return out
|
||||
Reference in New Issue
Block a user