"""Node registry — per-machine deployment state.""" from __future__ import annotations import socket from dataclasses import dataclass, field from pathlib import Path import yaml from wildpc_core.config import CONTENT_DIR, SPECS_DIR REGISTRY_PATH = SPECS_DIR / "registry.yaml" STATIC_DIR = CONTENT_DIR # backwards-compat alias @dataclass class NodeConfig: """Per-node identity and settings.""" hostname: str = "" wildpc_root: str | None = None # repo path, for dev commands gateway_port: int = 9000 # None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS; "acme" → Let's # Encrypt wildcard (*.gateway_domain) via DNS-01. gateway_tls: str | None = None gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains acme_email: str | None = None acme_dns_provider: str = "cloudflare" # Cloudflare tunnel: public services publish at .. public_domain: str | None = None tunnel_id: str | None = None # Emit the cert_obtained → `wildpc tls reconcile` hook (needs events-exec plugin). cert_hook: bool = False # Routable host peers use to reach this node's services (LAN IP/hostname). # Defaults to the hostname; set explicitly when the hostname isn't resolvable # cross-node. Used to build `remote` gateway routes to this node. address: str | None = None # Fleet role: "authority" may write shared config/secrets to the mesh; # "follower" reconciles from it. Static (no election) — the authority is # pinned in wildpc.yaml. When the authority is down, shared state is # read-only and every node keeps serving its own deployments from cache. role: str = "follower" def __post_init__(self) -> None: if not self.hostname: self.hostname = socket.gethostname() @dataclass class Deployment: """A component deployed on this node with resolved runtime config.""" # Who supervises/realizes this deployment: systemd | caddy | path | none. manager: str run_cmd: list[str] # The systemd launch mechanism (python|command|container|compose|node), or # None for the non-process managers (caddy/path/none). launcher: str | None = None # Optional teardown command emitted as systemd ``ExecStop=`` (e.g. compose # ``down``). Empty for launchers whose stop is just SIGTERM to the ExecStart pid. stop_cmd: list[str] = field(default_factory=list) env: dict[str, str] = field(default_factory=dict) # Absolute dirs prepended to the unit's default PATH — a resolved toolchain the # default tool PATH omits (e.g. a program's pinned nvm node bin). Ignored when # the deployment sets its own PATH in env (an explicit PATH is a full override). path_prepend: list[str] = field(default_factory=list) # Names (never values) of secret-bearing env vars. Their resolved values live # only in the mode-0600 env file, never in env/run_cmd/registry — this is for # visibility (which secrets a deployment expects). secret_env_keys: list[str] = field(default_factory=list) description: str | None = None # Deployment identity is (name, kind) — a name may be shared across kinds. name: str = "" # Kind: service | job | tool | static | reference (structural — its store). kind: str = "service" stack: str | None = None port: int | None = None health_path: str | None = None # Exposed at . (the subdomain is the service name), # or None when the service is reachable only at its host:port. subdomain: str | None = None # Also projected to the public internet via the tunnel at # .. Requires subdomain. public: bool = False # Optional public hostname override (exact FQDN, apex allowed). When set it is # the public-facing name instead of the derived .. public_host: str | None = None # Raw-TCP exposure port (postgres, redis, …). Set → reachable at # .: via bind + wildcard DNS (no Caddy route). tcp_port: int | None = None # For `static` runner services: the absolute dir the gateway file_servers. # Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy). static_root: str | None = None # For `static` routes: SPA fallback (True, default) vs content-site serving # (False, Hugo/multi-page). See CaddyDeployment.spa. spa: bool = True base_url: str | None = None schedule: str | None = None managed: bool = False # Declared desired state (from the deployment's `enabled:`). `wildpc apply` # activates enabled deployments and deactivates disabled ones. Default True. enabled: bool = True # Deployment `requires` (list of {kind, ref, bind}) — carried so the mesh can # draw cross-node consumption. A remote consumer's ref resolves against the # provider set across nodes. requires: list[dict] = field(default_factory=list) @dataclass class NodeRegistry: """What's deployed on this node. `deployed` is keyed by the composite identity ``"/"`` so a service and a job (or tool) can share a bare name. Prefer the helpers over indexing `deployed` directly: `all()` (iterate), `get(kind, name)`, `named(name)`, `put()`. """ node: NodeConfig deployed: dict[str, Deployment] = field(default_factory=dict) @staticmethod def key(kind: str, name: str) -> str: return f"{kind}/{name}" def all(self) -> list[tuple[str, str, Deployment]]: """Every deployed unit as ``(kind, name, Deployment)``.""" return [(d.kind, d.name, d) for d in self.deployed.values()] def get(self, kind: str, name: str) -> Deployment | None: return self.deployed.get(self.key(kind, name)) def named(self, name: str) -> list[Deployment]: """Every deployed unit sharing a bare name (across kinds).""" return [d for d in self.deployed.values() if d.name == name] def put(self, deployed: Deployment) -> None: self.deployed[self.key(deployed.kind, deployed.name)] = deployed def load_registry(path: Path | None = None) -> NodeRegistry: """Load the node registry from ~/.wildpc/registry.yaml.""" if path is None: path = REGISTRY_PATH if not path.exists(): raise FileNotFoundError( f"Registry not found: {path}\n" "Run 'wildpc deploy' to generate it from wildpc.yaml." ) with open(path) as f: data = yaml.safe_load(f) if not data: raise ValueError(f"Empty registry: {path}") node_data = data.get("node", {}) node = NodeConfig( hostname=node_data.get("hostname", ""), wildpc_root=node_data.get("wildpc_root"), gateway_port=node_data.get("gateway_port", 9000), gateway_tls=node_data.get("gateway_tls"), gateway_domain=node_data.get("gateway_domain"), acme_email=node_data.get("acme_email"), acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"), public_domain=node_data.get("public_domain"), tunnel_id=node_data.get("tunnel_id"), cert_hook=node_data.get("cert_hook", False), role=node_data.get("role", "follower"), address=node_data.get("address"), ) deployed: dict[str, Deployment] = {} for key, comp_data in data.get("deployed", {}).items(): # Key is the composite "/"; manager/kind are always present # (save_registry writes them). registry.yaml is regenerated every apply. kind, name = key.split("/", 1) deployed[NodeRegistry.key(kind, name)] = Deployment( manager=comp_data["manager"], launcher=comp_data.get("launcher"), run_cmd=comp_data.get("run_cmd", []), stop_cmd=comp_data.get("stop_cmd", []), env=comp_data.get("env", {}), path_prepend=comp_data.get("path_prepend", []), secret_env_keys=comp_data.get("secret_env_keys", []), description=comp_data.get("description"), name=name, kind=kind, stack=comp_data.get("stack"), port=comp_data.get("port"), health_path=comp_data.get("health_path"), subdomain=comp_data.get("subdomain"), public=comp_data.get("public", False), public_host=comp_data.get("public_host"), tcp_port=comp_data.get("tcp_port"), static_root=comp_data.get("static_root"), spa=comp_data.get("spa", True), base_url=comp_data.get("base_url"), schedule=comp_data.get("schedule"), managed=comp_data.get("managed", False), enabled=comp_data.get("enabled", True), requires=comp_data.get("requires", []), ) return NodeRegistry(node=node, deployed=deployed) def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: """Write the node registry to ~/.wildpc/registry.yaml.""" if path is None: path = REGISTRY_PATH path.parent.mkdir(parents=True, exist_ok=True) data: dict = { "node": { "hostname": registry.node.hostname, "gateway_port": registry.node.gateway_port, }, "deployed": {}, } if registry.node.wildpc_root: data["node"]["wildpc_root"] = registry.node.wildpc_root if registry.node.gateway_tls: data["node"]["gateway_tls"] = registry.node.gateway_tls if registry.node.gateway_domain: data["node"]["gateway_domain"] = registry.node.gateway_domain if registry.node.acme_email: data["node"]["acme_email"] = registry.node.acme_email if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare": data["node"]["acme_dns_provider"] = registry.node.acme_dns_provider if registry.node.public_domain: data["node"]["public_domain"] = registry.node.public_domain if registry.node.tunnel_id: data["node"]["tunnel_id"] = registry.node.tunnel_id if registry.node.cert_hook: data["node"]["cert_hook"] = registry.node.cert_hook if registry.node.role and registry.node.role != "follower": data["node"]["role"] = registry.node.role if registry.node.address: data["node"]["address"] = registry.node.address for key, comp in registry.deployed.items(): entry: dict = { "manager": comp.manager, "run_cmd": comp.run_cmd, } if comp.launcher: entry["launcher"] = comp.launcher if comp.stop_cmd: entry["stop_cmd"] = comp.stop_cmd if comp.env: entry["env"] = comp.env if comp.path_prepend: entry["path_prepend"] = comp.path_prepend if comp.secret_env_keys: entry["secret_env_keys"] = comp.secret_env_keys if comp.description: entry["description"] = comp.description entry["kind"] = comp.kind if comp.stack: entry["stack"] = comp.stack if comp.port is not None: entry["port"] = comp.port if comp.health_path: entry["health_path"] = comp.health_path if comp.subdomain: entry["subdomain"] = comp.subdomain if comp.public: entry["public"] = comp.public if comp.public_host: entry["public_host"] = comp.public_host if comp.tcp_port is not None: entry["tcp_port"] = comp.tcp_port if comp.static_root: entry["static_root"] = comp.static_root # Only emit when non-default (content-site) — default-True omission keeps # existing registries byte-identical and matches the load-side default. if not comp.spa: entry["spa"] = comp.spa if comp.base_url: entry["base_url"] = comp.base_url if comp.schedule: entry["schedule"] = comp.schedule if comp.managed: entry["managed"] = comp.managed # Only emit when disabled — default-True omission keeps existing # registries byte-identical and matches the load-side default. if not comp.enabled: entry["enabled"] = comp.enabled if comp.requires: entry["requires"] = comp.requires data["deployed"][key] = entry with open(path, "w") as f: yaml.dump(data, f, default_flow_style=False, sort_keys=False)