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:
2026-07-18 22:55:08 -07:00
parent 25d7a522cc
commit 05b28cb584
190 changed files with 2428 additions and 3337 deletions

View File

@@ -0,0 +1,574 @@
"""Wild PC manifest models — program specs, service specs, job specs."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
class RestartPolicy(str, Enum):
NO = "no"
ON_FAILURE = "on-failure"
ALWAYS = "always"
class Reach(str, Enum):
"""How far a deployment is exposed — a protocol-agnostic ladder.
``off`` → reachable only at its own host:port (no gateway route).
``internal`` → reachable at ``<name>.<domain>`` (HTTP via the gateway, or TCP
via bind + wildcard DNS).
``public`` → *also* projected to the internet (HTTP via the tunnel origin;
TCP via ``cloudflared access tcp``). Implies ``internal``.
``proxy``/``public`` survive as derived read-only accessors on the deployment.
"""
OFF = "off"
INTERNAL = "internal"
PUBLIC = "public"
def _validate_public_host(host: str | None, reach: Reach) -> None:
"""Validate an optional ``public_host`` override on an exposable deployment.
A ``public_host`` only makes sense for a publicly-projected deployment, and
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
tunnel ingress ``hostname`` and a Caddy site address verbatim.
"""
if host is None:
return
if reach != Reach.PUBLIC:
raise ValueError(
f"public_host is only valid with reach: public (got reach: {reach.value})"
)
bad = any(c.isspace() for c in host) or any(
tok in host for tok in ("://", "/", ":")
)
if not host or host != host.strip(".") or bad:
raise ValueError(
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
)
# ---------------------
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
# ---------------------
#
# A *launcher* is the process-launch mechanism for a systemd-managed deployment.
# It is orthogonal to the deployment's `manager`: only systemd deployments have a
# launcher, and it says only how the process starts — not how it's supervised.
# The non-process managers (caddy/path/none) have no launcher; their fields live
# on the deployment variant itself.
class LaunchBase(BaseModel):
launcher: str
class RunCommand(LaunchBase):
launcher: Literal["command"]
argv: list[str] = Field(min_length=1)
class RunPython(LaunchBase):
launcher: Literal["python"]
program: str
args: list[str] = Field(default_factory=list)
class RunContainer(LaunchBase):
launcher: Literal["container"]
image: str
command: list[str] | None = None
args: list[str] = Field(default_factory=list)
ports: dict[int, int] = Field(default_factory=dict)
volumes: list[str] = Field(default_factory=list)
env: EnvMap = Field(default_factory=dict)
workdir: str | None = None
# Run the container as this uid[:gid] (e.g. "${uid}:${gid}"). Running as the
# invoking user makes bind-mounted data/secrets/certs readable with no chown —
# see docs/tcp-exposure.md §4. None → the image's own default user.
user: str | None = None
# tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must
# be writable when the container runs as a non-default uid.
tmpfs: list[str] = Field(default_factory=list)
class RunNode(LaunchBase):
launcher: Literal["node"]
script: str
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
args: list[str] = Field(default_factory=list)
class RunCompose(LaunchBase):
"""A multi-container stack supervised as one unit via ``docker compose``.
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
networking, startup ordering, and per-service health — Wild PC delegates rather
than reinventing orchestration. The unit runs ``compose up`` attached
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
``defaults.env``); compose interpolates them from the process environment.
"""
launcher: Literal["compose"]
file: str = "docker-compose.yml" # resolved relative to the program source
project_name: str | None = None # ``-p``; defaults to ``wildpc-<name>``
LaunchSpec = Annotated[
Union[
RunCommand,
RunPython,
RunContainer,
RunNode,
RunCompose,
],
Field(discriminator="launcher"),
]
# ---------------------
# Systemd management
# ---------------------
class ReadinessHttpGet(BaseModel):
http_get: str
timeout_seconds: int = 2
interval_seconds: int = 2
success_codes: list[int] = Field(default_factory=lambda: [200])
class SystemdSpec(BaseModel):
enable: bool = True
user: bool = True
description: str | None = None
after: list[str] = Field(default_factory=list)
requires: list[str] = Field(default_factory=list)
wanted_by: list[str] = Field(default_factory=lambda: ["default.target"])
restart: RestartPolicy = RestartPolicy.ON_FAILURE
restart_sec: int = 2
no_new_privileges: bool = True
readiness: ReadinessHttpGet | None = None
exec_reload: str | None = None
# Commands run after the main process starts (systemd ``ExecStartPost=``), one
# line each — e.g. an OpenBao auto-unseal step. Failures don't fail the unit.
exec_start_post: list[str] = Field(default_factory=list)
class ManageSpec(BaseModel):
systemd: SystemdSpec | None = None
# ---------------------
# Exposure — HTTP (via the gateway) or raw TCP (bind + DNS)
# ---------------------
class HttpInternal(BaseModel):
host: str = "127.0.0.1"
port: int = Field(ge=1, le=65535)
unix_socket: str | None = None
class HttpExposeSpec(BaseModel):
internal: HttpInternal
health_path: str | None = None
class TlsMaterial(str, Enum):
"""What cert files wildpc materializes onto a service from the wildcard cert.
``off`` → the service does its own TLS (or none); wildpc stays out of it.
``pair`` → cert.pem + key.pem (postgres, redis, most daemons).
``combined`` → one file: key+cert concatenated (mongodb, haproxy, …).
"""
OFF = "off"
PAIR = "pair"
COMBINED = "combined"
class TlsSpec(BaseModel):
"""Wild PC-managed TLS material for a raw-TCP service, cut from the gateway's
ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal.
The service consumes the materialized files via the ``${tls_*}`` placeholders.
"""
material: TlsMaterial = TlsMaterial.OFF
# Optional zero-downtime reload argv (a single command) run after the cert is
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "wildpc-postgres"].
# Default (None): wildpc restarts the deployment (fine at a ~60-day cadence).
reload: list[str] | None = None
class TcpExposeSpec(BaseModel):
"""A raw-TCP service (postgres, redis, …). It doesn't ride the HTTP gateway:
with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the
wildcard DNS record + the bound port (no Caddy route). Publishing the port on
the LAN is the deployment's own job (a container's ``run.ports``, or a native
service binding ``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host
field here to imply otherwise. ``tls`` (optional) has wildpc drop the wildcard
cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
"""
port: int = Field(ge=1, le=65535)
tls: TlsSpec | None = None
class ExposeSpec(BaseModel):
http: HttpExposeSpec | None = None
tcp: TcpExposeSpec | None = None
@model_validator(mode="after")
def _one_protocol(self) -> ExposeSpec:
if self.http and self.tcp:
raise ValueError("a deployment exposes http OR tcp, not both")
return self
# ---------------------
# Build spec
# ---------------------
class BuildSpec(BaseModel):
commands: list[list[str]] = Field(default_factory=list)
outputs: list[str] = Field(default_factory=list)
# ---------------------
# Commands spec — per-program dev verb overrides
# ---------------------
class CommandsSpec(BaseModel):
"""Per-program dev verb commands. Each verb is a list of argv lists run in
sequence. A declared verb overrides the stack default; an absent verb falls
back to the program's stack handler (if any), else the verb is unavailable.
This generalizes BuildSpec.commands to the rest of the verb contract, which
is what lets a wired-in repo with no `stack` declare how it is linted/tested/run.
"""
model_config = ConfigDict(populate_by_name=True)
lint: list[list[str]] | None = None
test: list[list[str]] | None = None
type_check: list[list[str]] | None = Field(default=None, alias="type-check")
check: list[list[str]] | None = None
run: list[list[str]] | None = None
install: list[list[str]] | None = None
uninstall: list[list[str]] | None = None
def for_verb(self, verb: str) -> list[list[str]] | None:
"""Return the declared commands for a verb name (accepts 'type-check')."""
return getattr(self, verb.replace("-", "_"), None)
# ---------------------
# Capabilities
# ---------------------
class Requirement(BaseModel):
"""A precondition — another **deployment** that must exist for this one to be
*functional* (``ref`` = the target deployment's name). ``bind`` names the env
var wildpc projects the target's URL into — env is derived *from* the
requirement, never scraped back into it.
A deployment declares these in its ``requires`` list; ``kind`` defaults to
``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
not written here — a program's host-package preconditions live in
``system_dependencies`` (synthesized as ``kind: system``), and its stack's
toolchains (``uv``/``pnpm``/``hugo``/…) are synthesized as ``kind: tool`` — both by
the relationship model for the ``functional?`` check. See docs/relationships.md.
"""
kind: Literal["system", "deployment", "tool"] = "deployment"
ref: str
bind: str | None = None
# ---------------------
# Defaults
# ---------------------
class DefaultsSpec(BaseModel):
env: EnvMap = Field(default_factory=dict)
# ---------------------
# Agent spec — a launchable agent CLI (assistant-agnostic)
# ---------------------
class SessionsSpec(BaseModel):
"""Declarative session-history capability for an agent (optional).
Lets the dashboard show a unified picker of an agent's *own* past sessions
without wildpc knowing anything agent-specific in code: it runs
``list_command`` (which must print a JSON array of session objects), reads
three named fields off each object, and launches ``command`` + ``resume``
(with ``{id}`` substituted) to reopen one. Field names default to opencode's
shape and are overridable per agent (dotted paths allowed, e.g.
``config_summary.session_id``).
"""
list_command: list[str] # argv → JSON array of session objects
resume: list[str] # appended to the agent command; "{id}" is substituted
id_field: str = "id"
title_field: str = "title"
time_field: str = "updated"
class AgentSpec(BaseModel):
"""A launchable agent CLI for the dashboard's terminal UX.
Wild PC just runs ``command args`` inside a pty at ``cwd``; it never parses
the agent's output, so any interactive CLI works. This block only names the
launch — live sessions (list/resume/kill) are a runtime concern, not config.
"""
command: str
args: list[str] = Field(default_factory=list)
description: str | None = None
cwd: str | None = None # defaults to the wildpc repo root when unset
env: EnvMap = Field(default_factory=dict)
# Extra args that open the agent's own session browser / continue its last
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and
# agent-specific — wildpc just passes them through. Empty = no such affordance.
resume_args: list[str] = Field(default_factory=list)
# Optional: declares how to list + resume the agent's own sessions, so the
# dashboard can render a unified session picker (see SessionsSpec).
sessions: SessionsSpec | None = None
# ---------------------
# Program spec — software identity
# ---------------------
class ProgramSpec(BaseModel):
"""Software catalog entry — what exists."""
id: str = ""
description: str | None = None
# A program has NO kind of its own — kind is a *deployment* property. A program
# is a catalog entry that has 0..N deployments, each with its own kind (see
# kind_for and WildpcConfig.deployments_of).
source: str | None = None
stack: str | None = None
# Wiring in existing repos: clone from `repo` (git URL) at optional `ref`;
# `source` (when set) is the local working copy and takes precedence.
repo: str | None = None
ref: str | None = None
# Per-program dev verb overrides (declared verbs override the stack default).
commands: CommandsSpec | None = None
# Host-package preconditions (apt packages / binaries) intrinsic to this
# software. The relationship model checks these (`which`/`dpkg`) to derive the
# `functional?` light. Deployment-to-deployment dependencies are NOT here — they
# live on the deployment's `requires` (see DeploymentBase). See docs/relationships.md.
system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list)
version: str | None = None
build: BuildSpec | None = None
tags: list[str] = Field(default_factory=list)
@property
def source_dir(self) -> str | None:
"""Relative directory for this component's source, or None."""
if self.source:
return self.source.rstrip("/")
return None
# ---------------------
# Deployment specs — a program materialized into the runtime
# ---------------------
#
# A deployment is discriminated on its `manager` — who supervises/realizes it:
# systemd → a process (or, with a `schedule`, a `.timer`)
# caddy → a gateway route serving a static dir (file_server)
# path → a CLI installed on PATH (uv tool install)
# none → an external reference we only point at (remote)
# The human "kind" (service/job/tool/static/reference) is *derived* from the
# manager (+ schedule presence), never stored — see `kind_for`.
class DeploymentBase(BaseModel):
"""Fields common to every deployment, regardless of manager."""
model_config = ConfigDict(populate_by_name=True)
id: str = ""
# The program this deployment materializes.
program: str | None = None
description: str | None = None
defaults: DefaultsSpec | None = None
# Deployment-to-deployment preconditions: other deployments this one needs
# (e.g. a frontend that requires its API + the supabase substrate). Each entry
# is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's
# URL into env). Drives the relationship graph's edges. See docs/relationships.md.
requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `wildpc apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired
# state*, not a runtime toggle — the only way to durably stop something.
enabled: bool = True
class SystemdDeployment(DeploymentBase):
"""A process supervised by systemd — a *service*, or a *job* when scheduled."""
manager: Literal["systemd"]
run: LaunchSpec
# Present → a `.timer` (job); absent → a continuous `.service` (service).
schedule: str | None = None
timezone: str = "America/Los_Angeles"
expose: ExposeSpec | None = None
# How far this process is exposed (off | internal | public). See `Reach`.
reach: Reach = Reach.OFF
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
public_host: str | None = None
manage: ManageSpec | None = None
@model_validator(mode="after")
def _validate_reach(self) -> SystemdDeployment:
# An exposed reach needs a port to expose. Without an `expose` block the
# reach silently no-ops — no route, no subdomain, no tunnel entry — so a
# typo'd/omitted `expose` reads as success while the service is unreachable.
# Reject it at load so the mistake surfaces (replaces the old
# "public requires proxy" guard, now that reach is the canonical field).
# Static frontends (manager: caddy) are inherently exposed and validated
# elsewhere; this is a supervised process, which needs an explicit port.
if self.reach != Reach.OFF and not self.expose:
raise ValueError(
f"reach: {self.reach.value} requires an `expose` block "
"(expose.http or expose.tcp); a port-only process uses reach: off"
)
# Public raw-TCP (tunnel + Access) is a later step; guard it explicitly
# rather than silently no-op'ing when a TCP service asks for it.
if (
self.reach == Reach.PUBLIC
and self.expose
and self.expose.tcp
and not self.expose.http
):
raise ValueError(
"reach: public for a raw-TCP service isn't supported yet "
"(see docs/tcp-exposure.md step 5); use reach: internal"
)
_validate_public_host(self.public_host, self.reach)
return self
# Derived, read-only back-compat accessors (not serialized) so existing
# readers keep working while the stored/authored field is `reach`.
@property
def proxy(self) -> bool:
return self.reach != Reach.OFF
@property
def public(self) -> bool:
return self.reach == Reach.PUBLIC
@property
def http_exposed(self) -> bool:
"""Exposed through the HTTP gateway at ``<name>.<domain>`` — the predicate
for a Caddy route / subdomain. Requires ``reach != off`` *and* an HTTP
port; a raw-TCP service (``expose.tcp``) is never HTTP-exposed."""
return self.reach != Reach.OFF and bool(self.expose and self.expose.http)
@property
def tcp_port(self) -> int | None:
"""The raw-TCP port this service is exposed on, or None. Reachable at
``<name>.<domain>:<port>`` when ``reach != off`` (bind + wildcard DNS)."""
if self.reach != Reach.OFF and self.expose and self.expose.tcp:
return self.expose.tcp.port
return None
class CaddyDeployment(DeploymentBase):
"""A static site served by the gateway (Caddy ``file_server``) — no process.
The gateway *is* its runtime; it's inherently exposed at its subdomain. ``root``
is the built dir to serve, relative to the program source (e.g. ``dist``/``public``).
"""
manager: Literal["caddy"]
root: str = "dist"
# Serving strategy. `True` (default) → SPA fallback: any unmatched path serves
# the root `index.html` so a client-side router (React/Vite) takes over. `False`
# → content-site serving: the gateway resolves directory indexes (`/posts/` →
# `/posts/index.html`) and 404s genuinely-missing paths — correct for Hugo and
# other multi-page static sites, where the SPA fallback would swallow every
# in-site link back to the homepage.
spa: bool = True
# A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
# with `reach: public`; see SystemdDeployment.public_host.
public_host: str | None = None
@model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF:
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
_validate_public_host(self.public_host, self.reach)
return self
@property
def public(self) -> bool:
return self.reach == Reach.PUBLIC
class PathDeployment(DeploymentBase):
"""A CLI installed on PATH via ``uv tool install`` — no process, no route.
Lifecycle is install/uninstall (what start/stop/enable/disable map to).
"""
manager: Literal["path"]
# An Anthropic Messages-API tool definition ({name, description, input_schema})
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
# ``wildpc_core.tool_schema``) but editable/overridable — a stored draft the
# completion-context builder reads. None → not yet generated.
tool_schema: dict | None = None
class RemoteDeployment(DeploymentBase):
"""An external service (another node) — we only reference/route it, never run it."""
manager: Literal["none"]
base_url: str
health_url: str | None = None
DeploymentSpec = Annotated[
Union[SystemdDeployment, CaddyDeployment, PathDeployment, RemoteDeployment],
Field(discriminator="manager"),
]
def kind_for(spec: DeploymentSpec) -> str:
"""The derived kind of a deployment: service|job|tool|static|reference."""
if spec.manager == "systemd":
return "job" if getattr(spec, "schedule", None) else "service"
return {"caddy": "static", "path": "tool", "none": "reference"}[spec.manager]