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

@@ -1,12 +1,12 @@
# Castle Design
# Wild PC Design
Castle is a personal software platform. It manages independent services,
Wild PC is a personal software platform. It manages independent services,
tools, and frontends on a Linux machine using standard Unix primitives —
systemd for process supervision, Caddy for HTTP routing, the filesystem
for storage, and env vars for configuration. The `castle` CLI and API
for storage, and env vars for configuration. The `wildpc` CLI and API
provide a registry and coordination layer on top.
The long-term goal: multiple Castle nodes (machines) that discover each
The long-term goal: multiple Wild PC nodes (machines) that discover each
other and coordinate, forming a personal infrastructure mesh. Each node
is self-sufficient. The mesh is optional.
@@ -15,9 +15,9 @@ is self-sufficient. The mesh is optional.
1. **Unix-native.** Use the OS. systemd, journald, filesystem, signals,
env vars, DNS. Don't reimplement what Linux already provides.
2. **Independence.** Components never depend on Castle. They accept
2. **Independence.** Components never depend on Wild PC. They accept
standard configuration (ports, data dirs, URLs) via env vars. A
Castle service is just a well-behaved Unix daemon that happens to
Wild PC service is just a well-behaved Unix daemon that happens to
be registered in a manifest.
3. **Stack and kind.** Each program has an optional *stack* (development
@@ -29,12 +29,12 @@ is self-sufficient. The mesh is optional.
4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it,
everything is just processes, ports, files, and signals. Castle
everything is just processes, ports, files, and signals. Wild PC
operates above the line.
5. **Separate source from runtime.** The repo is for development. The
runtime lives in standard Unix locations (`$CASTLE_HOME`, default
`~/.castle/`, plus systemd units). Nothing running should point into the
runtime lives in standard Unix locations (`$WILDPC_HOME`, default
`~/.wildpc/`, plus systemd units). Nothing running should point into the
source tree.
6. **AI-manageable.** The CLI and API exist so that AI assistants can
@@ -70,7 +70,7 @@ responds to SIGTERM.
### Build Layer
Transforms source code into runnable artifacts. Castle does not abstract
Transforms source code into runnable artifacts. Wild PC does not abstract
over language toolchains — it just records the build commands and their
outputs.
@@ -81,11 +81,11 @@ outputs.
| Rust | cargo | Binary |
| Go | go build | Binary |
Castle's `build` spec is intentionally minimal: a list of shell commands
and a list of output paths. This works for any language without Castle
Wild PC's `build` spec is intentionally minimal: a list of shell commands
and a list of output paths. This works for any language without Wild PC
needing to understand the toolchain.
For interpreted languages (Python, Node), Castle also needs to know the
For interpreted languages (Python, Node), Wild PC also needs to know the
runtime wrapper — how to invoke the artifact. For a `manager: systemd`
deployment this is the nested `run:` block's **launcher** variants:
@@ -99,7 +99,7 @@ deployment this is the nested `run:` block's **launcher** variants:
`manager: path` installs a CLI, `manager: none` is an external reference.)
Compiled languages (Rust, Go) use the `command` launcher — once built, they're
just binaries. No Castle-specific launcher needed.
just binaries. No Wild PC-specific launcher needed.
### Runtime Layer
@@ -120,12 +120,12 @@ Manages running processes using standard Linux infrastructure.
- TLS termination
**Filesystem** handles storage:
- Service data: `$CASTLE_DATA_DIR/<name>/` (default `/data/castle/`, on a
- Service data: `$WILDPC_DATA_DIR/<name>/` (default `/data/wildpc/`, on a
dedicated volume)
- Secrets: `$CASTLE_HOME/secrets/` (default `~/.castle/secrets/`)
- Generated config: `$CASTLE_HOME/artifacts/specs/` (Caddyfile, registry.yaml)
- Secrets: `$WILDPC_HOME/secrets/` (default `~/.wildpc/secrets/`)
- Generated config: `$WILDPC_HOME/artifacts/specs/` (Caddyfile, registry.yaml)
Castle generates systemd unit files and Caddyfile entries from the
Wild PC generates systemd unit files and Caddyfile entries from the
registry. It doesn't run a daemon itself — it configures OS-level
infrastructure and gets out of the way.
@@ -136,7 +136,7 @@ than stage a copy, Caddy serves their built assets **in place** from the repo
### Registry Layer
The registry is the central concept in Castle. It tracks what programs
The registry is the central concept in Wild PC. It tracks what programs
exist, what they can do, and how they're configured. But it's not a
single thing — it's three distinct concepts:
@@ -146,7 +146,7 @@ information, version-controlled in the repo. It answers: "what programs
could exist?"
**2. Node config** — what's *deployed on this machine*, with what concrete
ports, data paths, and env vars. This is per-machine. Two Castle nodes
ports, data paths, and env vars. This is per-machine. Two Wild PC nodes
might run different subsets of programs with different parameters. It
answers: "what's running here, and how?"
@@ -156,7 +156,7 @@ answers: "is it working?"
#### Source vs. runtime split
These map to the config root (`castle.yaml` globals plus per-resource files
These map to the config root (`wildpc.yaml` globals plus per-resource files
under `programs/` and `deployments/`), version-controlled in the repo:
```yaml
@@ -198,19 +198,19 @@ reference) captures whether it's an always-on daemon, a scheduled task, a CLI on
PATH, a served frontend, or an external reference.
A deployment can reference a program via `program:` for description fallthrough
and source code linking. It can also exist independently (e.g., `castle-gateway`
and source code linking. It can also exist independently (e.g., `wildpc-gateway`
runs Caddy — not our software).
A service's env is exactly its `defaults.env`castle injects nothing
A service's env is exactly its `defaults.env`wildpc injects nothing
implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
placeholders, which deploy resolves into the registry's flat `env`.
**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle apply`) — Node config:
**`$WILDPC_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `wildpc apply`) — Node config:
```yaml
node:
hostname: tower
castle_root: /data/repos/castle
wildpc_root: /data/repos/wildpc
gateway_port: 9000
deployed:
central-context:
@@ -218,7 +218,7 @@ deployed:
launcher: python
run_cmd: [/home/user/.local/bin/central-context]
env:
CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context
CENTRAL_CONTEXT_DATA_DIR: /home/user/.wildpc/data/central-context
CENTRAL_CONTEXT_PORT: "9001"
kind: service
stack: python-fastapi
@@ -229,7 +229,7 @@ deployed:
```
The node config says what's deployed *here* and with what concrete
values. `castle apply` reads the spec from the repo, resolves the
values. `wildpc apply` reads the spec from the repo, resolves the
`defaults.env` placeholders and secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly.
@@ -244,31 +244,31 @@ This separation means:
Three interfaces expose the registry:
- **CLI** (`castle`) — For AI agents and terminal users. Structured
- **CLI** (`wildpc`) — For AI agents and terminal users. Structured
output via `--json`. Commands for listing, inspecting, creating,
and managing programs.
- **API** (`castle-api`) — For programmatic access over HTTP. Used by
- **API** (`wildpc-api`) — For programmatic access over HTTP. Used by
the dashboard, other nodes, and remote agents.
- **Dashboard** (`castle`) — For human discoverability. Visual
- **Dashboard** (`wildpc`) — For human discoverability. Visual
overview of what's running, health status, logs.
### Coordination Layer
Coordination handles discovery and communication — both between
programs on a single node and across multiple Castle nodes.
programs on a single node and across multiple Wild PC nodes.
**Intra-node coordination:**
- Programs find each other through the gateway or direct port access via env
vars. A gateway route maps a host address (`<name>.<domain>`) to a target of one
kind: **proxy** (a local service port), **remote** (a service on another
node), or **static** (a built frontend's `dist/`, served as files). The same
computed route list drives the Caddyfile, `castle gateway status`, and the
computed route list drives the Caddyfile, `wildpc gateway status`, and the
dashboard, so they always agree.
- The registry (CLI/API) provides discoverability.
- No service mesh or message broker required for basic operation.
**Inter-node coordination:**
- Each Castle node runs the API, which exposes its program registry.
- Each Wild PC node runs the API, which exposes its program registry.
- Nodes discover each other via MQTT retained messages and mDNS/DNS-SD
(python-zeroconf) for LAN environments.
- The gateway on each node can proxy to services on other nodes via
@@ -277,21 +277,21 @@ programs on a single node and across multiple Castle nodes.
they're talking to.
- MQTT provides pub/sub messaging for events, status, and coordination
across nodes.
- All mesh features are opt-in: `CASTLE_API_MQTT_ENABLED=true` and
`CASTLE_API_MDNS_ENABLED=true`. Single-node works without them.
- All mesh features are opt-in: `WILDPC_API_MQTT_ENABLED=true` and
`WILDPC_API_MDNS_ENABLED=true`. Single-node works without them.
**MQTT topics:**
- `castle/{hostname}/registry` — retained JSON, full NodeRegistry.
Published on connect and after `castle apply`.
- `castle/{hostname}/status``"online"` (retained) / `"offline"` (LWT).
- `wildpc/{hostname}/registry` — retained JSON, full NodeRegistry.
Published on connect and after `wildpc apply`.
- `wildpc/{hostname}/status``"online"` (retained) / `"offline"` (LWT).
LWT ensures nodes are marked offline if they disconnect unexpectedly.
**MeshStateManager** (`castle_api.mesh`) holds remote NodeRegistry
**MeshStateManager** (`wildpc_api.mesh`) holds remote NodeRegistry
instances in memory, indexed by hostname. 5-minute staleness TTL.
Updated by the MQTT client on incoming messages. Read by API endpoints
to serve cross-node data.
**mDNS** (`castle_api.mdns`) advertises `_castle._tcp` and browses
**mDNS** (`wildpc_api.mdns`) advertises `_wildpc._tcp` and browses
for peers and `_mqtt._tcp` broker. Uses python-zeroconf. Properties
include hostname, gateway_port, api_port.
@@ -302,27 +302,27 @@ Local paths always take precedence.
**Why MQTT over custom gossip:**
- Standard protocol, every language has a client library.
- Retained messages give new nodes an immediate view of the network.
- Topic-based routing maps naturally to `castle/{node}/{program}`.
- Topic-based routing maps naturally to `wildpc/{node}/{program}`.
- Works across networks (not just LAN like mDNS).
- Mosquitto is a single binary, simple to run as a Castle program.
- Mosquitto is a single binary, simple to run as a Wild PC program.
**Why mDNS/DNS-SD as a complement:**
- Zero-config LAN discovery via python-zeroconf.
- Each node advertises `_castle._tcp` — standard tooling works
- Each node advertises `_wildpc._tcp` — standard tooling works
(`avahi-browse`, `dns-sd`).
- Good for bootstrapping: find the MQTT broker without hardcoding
its address.
### Dashboard
The web dashboard (`castle`) is a React SPA served by Caddy in place from
The web dashboard (`wildpc`) is a React SPA served by Caddy in place from
its repo build output (`<source>/dist/`) at the root `/`. It talks to
`castle-api` via the gateway proxy at `/api`.
`wildpc-api` via the gateway proxy at `/api`.
**Layout:**
```
Castle
Wild PC
Personal software platform
[tower] [devbox (3)] ← NodeBar (hidden in single-node)
@@ -331,7 +331,7 @@ Personal software platform
│ Gateway · tower · port 9000 · 4 routes [Reload] [Caddyfile] │
│ │
│ Path Component Port Node Health│
│ /api castle-api 9020 tower ● up │
│ /api wildpc-api 9020 tower ● up │
│ /central-context central-context 9001 tower ● up │
│ /notifications notification-bridge 9002 tower ● up │
│ /devbox-api devbox-api 9020 devbox ● up │
@@ -343,7 +343,7 @@ Personal software platform
Daemons · Long-running processes that expose ports
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
castle-api │ │ central-ctx │ │ notif-bridge │
wildpc-api │ │ central-ctx │ │ notif-bridge │
│ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │
│ :9020 │ │ :9001 │ │ :9002 │
└──────────────┘ └──────────────┘ └──────────────┘
@@ -352,7 +352,7 @@ Components · Software catalog
Name Stack Kind Schedule Status
pdf2md Python / CLI tool — installed
protonmail Python / CLI tool */5 * * * * installed
castle React / Vite static — —
wildpc React / Vite static — —
backup-collect Python / CLI job 0 2 * * * —
```
@@ -381,7 +381,7 @@ Components · Software catalog
## Component Contract
Every Castle program, regardless of language, must satisfy a minimal
Every Wild PC program, regardless of language, must satisfy a minimal
contract. This is what makes the system uniform above the build line.
### Services (long-running daemons)
@@ -394,8 +394,8 @@ contract. This is what makes the system uniform above the build line.
| Data storage | Read `*_DATA_DIR` env var, write there |
| Logging | stdout for output, stderr for errors |
| Graceful shutdown | Handle SIGTERM, exit cleanly |
| Secrets | Read from env vars (Castle resolves `${secret:NAME}`) |
| No Castle dependency | Must run standalone with just env vars set |
| Secrets | Read from env vars (Wild PC resolves `${secret:NAME}`) |
| No Wild PC dependency | Must run standalone with just env vars set |
### Tools (CLI utilities)
@@ -419,73 +419,73 @@ Same contract as tools, plus:
## Component Lifecycle
The path from source to managed process is two moves — **develop** (against the
program) and **converge** (`castle apply`):
program) and **converge** (`wildpc apply`):
```
source → [dev verbs: build/test/…] → config (programs/ + deployments/) → [castle apply] → running
source → [dev verbs: build/test/…] → config (programs/ + deployments/) → [wildpc apply] → running
```
- **Develop** — language-specific dev verbs (`build`, `test`, …) that Castle
- **Develop** — language-specific dev verbs (`build`, `test`, …) that Wild PC
records but never runs implicitly. Frontends build in place under the repo
(`<source>/<dist>/`), served from there — no copy step.
- **Converge** — `castle apply` reads the config, generates systemd units +
- **Converge** — `wildpc apply` reads the config, generates systemd units +
Caddyfile entries, then reconciles reality to the desired state: activate what's
enabled, restart what changed, deactivate what's disabled. Activation is
*polymorphic over the manager* — systemd `enable --now`, `uv tool install` for a
tool on PATH, a gateway route for a static — so there is no separate install or
start step, and no per-kind verb. `castle apply --plan` shows the diff first.
start step, and no per-kind verb. `wildpc apply --plan` shows the diff first.
Desired on/off is `enabled` on the deployment; the only way to durably stop
something is `enabled: false` + apply. `castle restart` is the one imperative
something is `enabled: false` + apply. `wildpc restart` is the one imperative
bounce that re-actualizes current state without changing it.
## Runtime Filesystem Layout
Two roots, each overridable by an env var: `$CASTLE_HOME` (config, artifacts,
secrets; default `~/.castle`) and `$CASTLE_DATA_DIR` (bulk program data; default
`/data/castle`, on a dedicated volume). Program source lives separately under
`/data/repos/<name>/` (`$CASTLE_REPOS_DIR`).
Two roots, each overridable by an env var: `$WILDPC_HOME` (config, artifacts,
secrets; default `~/.wildpc`) and `$WILDPC_DATA_DIR` (bulk program data; default
`/data/wildpc`, on a dedicated volume). Program source lives separately under
`/data/repos/<name>/` (`$WILDPC_REPOS_DIR`).
```
$CASTLE_HOME/ ← Config & artifacts (default ~/.castle)
├── castle.yaml ← Global settings (gateway, repo, agents)
$WILDPC_HOME/ ← Config & artifacts (default ~/.wildpc)
├── wildpc.yaml ← Global settings (gateway, repo, agents)
├── programs/ deployments/ ← One YAML file per program / deployment
├── infra.conf ← Infrastructure install choices
├── artifacts/specs/ ← Generated by `castle apply`
├── artifacts/specs/ ← Generated by `wildpc apply`
│ ├── Caddyfile
│ └── registry.yaml ← Node config (what's deployed here)
└── secrets/ ← Secret files (NAME → value)
/data/repos/<name>/ ← Program source (your programs; absolute source:)
$CASTLE_DATA_DIR/<name>/ ← Persistent service data (default /data/castle)
~/.config/systemd/user/ ← Systemd units + timers (castle-*.service/.timer)
$WILDPC_DATA_DIR/<name>/ ← Persistent service data (default /data/wildpc)
~/.config/systemd/user/ ← Systemd units + timers (wildpc-*.service/.timer)
```
Compiled-language tools (Rust, Go — planned) install their binaries to the
standard `~/.local/bin/`. Source is referenced only by dev verbs and while a
deployment is materialized; everything the runtime touches lives under
`$CASTLE_HOME`, `$CASTLE_DATA_DIR`, or standard systemd paths.
`$WILDPC_HOME`, `$WILDPC_DATA_DIR`, or standard systemd paths.
## OTP as Design Guide
Castle's architecture parallels Erlang/OTP, mapped onto Unix:
Wild PC's architecture parallels Erlang/OTP, mapped onto Unix:
| OTP Concept | Castle Equivalent |
| OTP Concept | Wild PC Equivalent |
|-------------|------------------|
| Application | Component (independent, self-contained) |
| Application resource file | Component spec in `castle.yaml` |
| Release config (sys.config) | Node config in `$CASTLE_HOME/artifacts/specs/registry.yaml` |
| Release assembly | `castle apply` (spec + node config → runtime) |
| Application resource file | Component spec in `wildpc.yaml` |
| Release config (sys.config) | Node config in `$WILDPC_HOME/artifacts/specs/registry.yaml` |
| Release assembly | `wildpc apply` (spec + node config → runtime) |
| Supervisor | systemd (restart policies, ordering) |
| Process | Running service/worker/job |
| Application env | Env vars |
| Node | A machine running Castle |
| Node | A machine running Wild PC |
| epmd | mDNS / MQTT discovery |
| Distribution | Inter-node coordination via MQTT + gateway proxying |
| "Let it crash" | `restart: on-failure` in systemd |
| Global registry | Merged node registries via MQTT retained messages |
The mapping is conceptual, not literal. Castle doesn't implement OTP
The mapping is conceptual, not literal. Wild PC doesn't implement OTP
semantics — it uses OTP's *thinking* to guide which Unix primitives
to compose and how.
@@ -501,41 +501,41 @@ Key OTP ideas that apply:
can remap these across nodes.
- **Spec vs. config.** In OTP, an application defines its structure
(the `.app` file) and a release provides the deployment config
(`sys.config`). Castle mirrors this: the program spec defines
(`sys.config`). Wild PC mirrors this: the program spec defines
structure, the node config provides deployment values.
## Current State
What exists today:
- **CLI** — `castle` command, installed via `uv tool install --editable cli/`
- **Three packages** — `castle-core` (models, config, generators),
`castle-cli` (commands), `castle-api` (HTTP API)
- **Source/runtime split** — `castle.yaml` (spec) → `castle apply`
`$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
- **CLI** — `wildpc` command, installed via `uv tool install --editable cli/`
- **Three packages** — `wildpc-core` (models, config, generators),
`wildpc-cli` (commands), `wildpc-api` (HTTP API)
- **Source/runtime split** — `wildpc.yaml` (spec) → `wildpc apply`
`$WILDPC_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
Caddyfile generated from registry with fully resolved paths. No repo references
in runtime artifacts.
- **Explicit env with placeholders** — a deployment's env is exactly its
`defaults.env`; `castle apply` resolves `${port}`/`${data_dir}`/`${name}`/
`defaults.env`; `wildpc apply` resolves `${port}`/`${data_dir}`/`${name}`/
`${secret:…}` into concrete values. No hidden convention injection.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional
castle.yaml fallback for non-deployed programs)
- **Dashboard** — `castle` React/Vite frontend, static assets
- **API** — `wildpc-api` on port 9020, reads from registry (optional
wildpc.yaml fallback for non-deployed programs)
- **Dashboard** — `wildpc` React/Vite frontend, static assets
served in place from its repo build output (`<source>/dist/`)
- **Services** — central-context (content storage), notification-bridge
(desktop notification forwarder)
- **Jobs** — protonmail (email sync every 5 min), backup-collect (nightly),
backup-data (nightly restic backup)
- **Tools** — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.)
- **Manifest** — `castle.yaml` with typed Pydantic models
- **Manifest** — `wildpc.yaml` with typed Pydantic models
- **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery
(python-zeroconf), MeshStateManager, all wired into API lifespan.
Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`.
- **MQTT broker** — Mosquitto running as `castle-mqtt` Docker container
Opt-in via `WILDPC_API_MQTT_ENABLED` / `WILDPC_API_MDNS_ENABLED`.
- **MQTT broker** — Mosquitto running as `wildpc-mqtt` Docker container
on port 1883, managed by systemd. Config and data in
`$CASTLE_DATA_DIR/castle-mqtt/`.
`$WILDPC_DATA_DIR/wildpc-mqtt/`.
- **Node API** — `GET /mesh/status`, `GET /nodes`, `GET /nodes/{hostname}`.
`GET /deployments?include_remote=true` for cross-node program listing.
- **Gateway panel** — Dedicated UI showing route table, health per route,
@@ -551,10 +551,10 @@ What doesn't exist yet:
- **Multi-language support** — Rust and Go programs (the abstractions
support them via the `command` launcher, but no examples exist yet)
- **Build automation** — Castle records build specs but doesn't
- **Build automation** — Wild PC records build specs but doesn't
orchestrate builds (each project builds independently)
- **Multi-machine testing** — Mesh infrastructure is built and running
on one node, but not yet tested with a second Castle node
on one node, but not yet tested with a second Wild PC node
## Technology Map
@@ -562,18 +562,18 @@ What doesn't exist yet:
|---------|-----------|--------|
| Process supervision | systemd (user units) | Active |
| HTTP routing | Caddy (port 9000) | Active |
| Component specs | castle.yaml + Pydantic models | Active |
| Node config | `$CASTLE_HOME/artifacts/specs/registry.yaml` | Active |
| CLI | castle (Python, uv) | Active |
| API | castle-api (FastAPI) | Active |
| Dashboard | castle (React, Vite, shadcn/ui) | Active |
| Component specs | wildpc.yaml + Pydantic models | Active |
| Node config | `$WILDPC_HOME/artifacts/specs/registry.yaml` | Active |
| CLI | wildpc (Python, uv) | Active |
| API | wildpc-api (FastAPI) | Active |
| Dashboard | wildpc (React, Vite, shadcn/ui) | Active |
| Python packaging | uv | Active |
| Node packaging | pnpm | Active |
| Linting | ruff (Python), ESLint (TS) | Active |
| Type checking | pyright (Python), tsc (TS) | Active |
| Testing | pytest (Python), Vitest (TS) | Active |
| Secrets | `~/.castle/secrets/` file-based | Active |
| Data storage | Filesystem (`$CASTLE_DATA_DIR/`, default `/data/castle/`) | Active |
| Secrets | `~/.wildpc/secrets/` file-based | Active |
| Data storage | Filesystem (`$WILDPC_DATA_DIR/`, default `/data/wildpc/`) | Active |
| Messaging | MQTT (paho-mqtt client, Mosquitto broker) | Active (opt-in) |
| Node discovery | mDNS (python-zeroconf) + MQTT | Active (opt-in) |
| Rust packaging | cargo | Planned |

View File

@@ -1,31 +1,31 @@
# Developing Castle
# Developing Wild PC
How to work on **Castle's own code** — the CLI, core library, control-plane API,
and dashboard. For *using* Castle to manage software (create/deploy/expose
How to work on **Wild PC's own code** — the CLI, core library, control-plane API,
and dashboard. For *using* Wild PC to manage software (create/deploy/expose
programs), see the operator guide in [`AGENTS.md`](../AGENTS.md).
## The Castle monorepo
## The Wild PC monorepo
Castle's own programs live in **this git repo** (`source: repo:<name>`) — distinct
Wild PC's own programs live in **this git repo** (`source: repo:<name>`) — distinct
from the programs you manage, which live under `/data/repos/<name>/`:
- `cli/` — the `castle` CLI (installed via `uv tool install --editable cli/`)
- `core/``castle_core`: manifest models, config loader, generators
- `castle-api/` — the FastAPI control-plane service (port 9020)
- `app/` — the dashboard frontend (React/Vite; program name `castle`, served at
`castle.<gateway.domain>`)
- `cli/` — the `wildpc` CLI (installed via `uv tool install --editable cli/`)
- `core/``wildpc_core`: manifest models, config loader, generators
- `wildpc-api/` — the FastAPI control-plane service (port 9020)
- `app/` — the dashboard frontend (React/Vite; program name `wildpc`, served at
`wildpc.<gateway.domain>`)
The root `pyproject.toml` is the **uv workspace** (core, cli, castle-api).
The root `pyproject.toml` is the **uv workspace** (core, cli, wildpc-api).
## Key files
- `core/src/castle_core/manifest.py` — Pydantic models: `ProgramSpec`,
- `core/src/wildpc_core/manifest.py` — Pydantic models: `ProgramSpec`,
`DeploymentSpec` (discriminated union on `manager`), `LaunchSpec`, `AgentSpec`, …
- `core/src/castle_core/config.py` — config loader (`castle.yaml` + `programs/` +
`deployments/``CastleConfig`), the two roots, `source:` resolution
- `core/src/castle_core/generators/` — systemd unit/timer + Caddyfile generation
- `cli/src/castle_cli/` — resource-first CLI commands; `templates/scaffold.py`
- `castle-api/src/castle_api/` — routes, health polling, SSE `stream.py`, mesh,
- `core/src/wildpc_core/config.py` — config loader (`wildpc.yaml` + `programs/` +
`deployments/``WildpcConfig`), the two roots, `source:` resolution
- `core/src/wildpc_core/generators/` — systemd unit/timer + Caddyfile generation
- `cli/src/wildpc_cli/` — resource-first CLI commands; `templates/scaffold.py`
- `wildpc-api/src/wildpc_api/` — routes, health polling, SSE `stream.py`, mesh,
and the agent terminal UX (`agents.py`, `pty_session.py`, `agent_sessions.py`,
`agent_registry.py`)
- `ruff.toml` / `pyrightconfig.json` — shared lint/type config
@@ -44,7 +44,7 @@ from the repo root to restore all members. Code style: **ruff** (100-char lines)
**pyright**, **pytest** + **pytest-asyncio**; Python **3.13** for services, **3.11+**
for tools/libraries.
## castle-api endpoints (port 9020)
## wildpc-api endpoints (port 9020)
- Core: `GET /health`, `GET /stream` (SSE: health, service-action, mesh)
- Converge: `POST /apply` (`{name?, plan?}`) — the one lifecycle endpoint
@@ -61,12 +61,12 @@ for tools/libraries.
## Infrastructure internals
- Generated Caddyfile at `~/.castle/artifacts/specs/Caddyfile`; a **plugin Caddy**
- Generated Caddyfile at `~/.wildpc/artifacts/specs/Caddyfile`; a **plugin Caddy**
at `/usr/local/bin/caddy` when `tls: acme`.
- Systemd user units at `~/.config/systemd/user/castle-*.service` (+ `.timer`);
the unit for program `X` is `castle-X.service`. Use drop-in `*.service.d/*.conf`
for extra env `castle apply` shouldn't overwrite.
- Systemd user units at `~/.config/systemd/user/wildpc-*.service` (+ `.timer`);
the unit for program `X` is `wildpc-X.service`. Use drop-in `*.service.d/*.conf`
for extra env `wildpc apply` shouldn't overwrite.
- The `container` launcher resolves docker via `shutil.which("docker")` (preferred
over rootless podman on this box).
- Service data at `$CASTLE_DATA_DIR/<name>/`; secrets at `~/.castle/secrets/`
- Service data at `$WILDPC_DATA_DIR/<name>/`; secrets at `~/.wildpc/secrets/`
(mode 700) — never in project directories.

View File

@@ -1,6 +1,6 @@
# DNS & TLS in Castle
# DNS & TLS in Wild PC
How services on a Castle node become reachable by name and trusted over HTTPS —
How services on a Wild PC node become reachable by name and trusted over HTTPS —
while staying **internal-only** (no external exposure). This is the conceptual
companion to the field-level gateway reference in
[registry.md](registry.md#proxy--how-the-gateway-routes-to-it).
@@ -8,10 +8,10 @@ companion to the field-level gateway reference in
Two independent questions decide whether `https://foo.example/` works from a
browser on your LAN:
1. **Resolve** — does the name `foo.example` point at the Castle node? *(DNS)*
1. **Resolve** — does the name `foo.example` point at the Wild PC node? *(DNS)*
2. **Trust** — is the certificate the node serves one the browser accepts? *(TLS)*
Castle answers #1 by leaning on your LAN's own DNS, and #2 with a per-node choice
Wild PC answers #1 by leaning on your LAN's own DNS, and #2 with a per-node choice
of two TLS modes. They're orthogonal: you pick a resolution strategy and a trust
strategy, and any working combination is fine.
@@ -34,7 +34,7 @@ making those subdomains **resolve** (DNS) and be **trusted** (TLS).
## DNS: making a name resolve to the node
A subdomain does nothing until it resolves **to this node** on the clients that
browse it. Castle does **not** run DNS; you add one record to your **LAN's DNS
browse it. Wild PC does **not** run DNS; you add one record to your **LAN's DNS
server** — usually the router. A single **wildcard** covers every subdomain, so new
services need no further DNS edits:
@@ -54,7 +54,7 @@ trusted (real cert) yet reachable only from the LAN, never the internet.
## TLS: two trust modes
`gateway.tls` (in `castle.yaml`) picks how host routes are served. It's a per-node
`gateway.tls` (in `wildpc.yaml`) picks how host routes are served. It's a per-node
choice.
| `gateway.tls` | What the browser gets | Client setup | Use when |
@@ -122,7 +122,7 @@ origin — moving a service onto HTTPS changes its origin.
(§DNS). Verify: `dig +short <name>.<domain>` → the node's IP.
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
prerequisites (below).
4. **Deploy & reload:** `castle apply` regenerates the Caddyfile and reloads Caddy.
4. **Deploy & reload:** `wildpc apply` regenerates the Caddyfile and reloads Caddy.
5. **Update the app's origin allowlist** if it has one (§secure context).
## Operational prerequisites
@@ -137,14 +137,14 @@ a DNS token.
- **`acme` only — a DNS-plugin Caddy.** Stock Caddy has no DNS-provider modules.
Build one: `./install.sh --with-dns-plugin=<provider>` (uses `xcaddy`, installs
to `/usr/local/bin/caddy`, which precedes the apt binary on `PATH`, so the
gateway picks it up on the next deploy). Castle now owns updates to that binary.
gateway picks it up on the next deploy). Wild PC now owns updates to that binary.
- **`acme` only — a provider API token.** Store it as a secret
(`~/.castle/secrets/<TOKEN_NAME>`, scope: the DNS provider's "edit DNS records"
(`~/.wildpc/secrets/<TOKEN_NAME>`, scope: the DNS provider's "edit DNS records"
permission for your zone) and map it into the gateway service env in
`deployments/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as
`{env.<TOKEN_NAME>}`. `castle apply` warns if the domain, env var, or secret is
`deployments/wildpc-gateway.yaml` (`defaults.env`), so Caddy reads it as
`{env.<TOKEN_NAME>}`. `wildpc apply` warns if the domain, env var, or secret is
missing.
- **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's
- **`acme` — stage first.** Set `WILDPC_ACME_STAGING=1` at deploy to use Let's
Encrypt's staging CA (generous rate limits) while verifying issuance, then unset
it and redeploy for a browser-trusted production cert.

View File

@@ -7,7 +7,7 @@ consumed service) — every underlying piece is verified.
## Context
Castle's mesh today is a deliberately minimal, read-only gossip: each node
Wild PC's mesh today is a deliberately minimal, read-only gossip: each node
publishes a secret-stripped `NodeRegistry` to Mosquitto MQTT (retained) + an
online/offline LWT, and peers aggregate it into `MeshStateManager`. It is
LAN-only, unauthenticated, carries no secrets, and does nothing with what it
@@ -26,7 +26,7 @@ Two adopted, genuinely-open components (see licensing analysis in chat history):
**TTL keys as presence** (a provider's key vanishes on death → the breaker
trip signal, no separate health plumbing).
- **OpenBao** (Linux Foundation / OpenSSF, MPL-2.0, Vault fork) — the secret
authority behind castle's existing `${secret:...}` mechanism.
authority behind wildpc's existing `${secret:...}` mechanism.
Design stance: **static single-writer `role` authority, no consensus**
availability comes from followers running cached local state, not failover.
@@ -36,11 +36,11 @@ NATS single-node matches that topology. Consensus stays explicitly out of scope.
| Plane | Component | Primary code seam (verified) |
|-------|-----------|------------------------------|
| Transport + KV + presence | NATS (`castle-nats`) | `castle-api/.../mqtt_client.py` (whole file), `main.py` lifespan 62-77, `config.py` Settings |
| Transport + KV + presence | NATS (`wildpc-nats`) | `wildpc-api/.../mqtt_client.py` (whole file), `main.py` lifespan 62-77, `config.py` Settings |
| Shared config + registry + presence | NATS JetStream KV | `mesh.py` `MeshStateManager`; new KV buckets |
| Secret authority | OpenBao (`castle-openbao`) | `core/.../config.py:307` `_read_secret` (single chokepoint), `castle-api/.../secrets.py` CRUD |
| Secret authority | OpenBao (`wildpc-openbao`) | `core/.../config.py:307` `_read_secret` (single chokepoint), `wildpc-api/.../secrets.py` CRUD |
| Binding-by-purpose + breaker | Caddy gateway | `generators/caddyfile.py` `compute_routes` (`remote` kind + ignored `remote_registries` are pre-stubbed), `deploy.py:509-540` `_target_url`/`_requires_env` (local-only today) |
| Authority role | `NodeConfig.role` | `castle_core/registry.py` / `manifest.py` |
| Authority role | `NodeConfig.role` | `wildpc_core/registry.py` / `manifest.py` |
## Phased plan
@@ -48,47 +48,47 @@ Each phase is independently useful; the risky secret-transport work is last.
### Phase 0 — Stand up the two services (no behavior change)
- **`castle-nats`**: a `SystemdDeployment` + `RunContainer` (`nats:2` with
JetStream `-js`), data volume `/data/castle/castle-nats`, `reach: internal`
- **`wildpc-nats`**: a `SystemdDeployment` + `RunContainer` (`nats:2` with
JetStream `-js`), data volume `/data/wildpc/wildpc-nats`, `reach: internal`
(`nats.<domain>`). Follow the container YAML shape in `docs/tcp-exposure.md`
(postgres example). Replace the Mosquitto provisioning in `install.sh`
(`setup_mqtt`, `seed_mosquitto_config`, image/port consts) with NATS — or
better, promote it to a bootstrap deployment YAML (today the broker is only a
shell-provisioned container, not a declared deployment).
- **`castle-openbao`**: `RunContainer` (`openbao/openbao`), data volume, a file
- **`wildpc-openbao`**: `RunContainer` (`openbao/openbao`), data volume, a file
storage backend, `reach: internal`, **auto-unseal** (local transit/key) so the
node boots unattended (Decision 2).
### Phase 1 — NATS as the mesh transport (swap Mosquitto, behavior-preserving)
- New `nats_client.py` (async-native `nats-py`) replacing `mqtt_client.py`.
Registry → a **JetStream KV bucket `castle-registry`** keyed by hostname
(KV last-value replaces MQTT retained). Presence → a **`castle-presence`**
Registry → a **JetStream KV bucket `wildpc-registry`** keyed by hostname
(KV last-value replaces MQTT retained). Presence → a **`wildpc-presence`**
bucket with a per-node **TTL key** the node renews (replaces LWT + the 300s
`STALE_TTL` poll; note `prune_stale` is currently uncalled).
- Wire in `main.py` lifespan; rename `mqtt_*``nats_*` in `config.py` (keep
`CASTLE_API_` prefix). `MeshStateManager` logic is reused; the KV **watch**
`WILDPC_API_` prefix). `MeshStateManager` logic is reused; the KV **watch**
callback drives `update_node`/`set_offline` — and because `nats-py` is
asyncio-native, the `run_coroutine_threadsafe` cross-thread hop for
`broadcast("mesh", …)` **goes away**.
- **Preserve the secret-stripping invariant** from `_registry_to_json` (env /
run_cmd / castle_root never on the wire).
run_cmd / wildpc_root never on the wire).
- Rename MQTT-named fields: `models.py` `MeshStatus` (217-226) + frontend
`types/index.ts` (326-330), `MeshPanel.tsx`. Drop `paho-mqtt`; add `nats-py`.
Keep zeroconf peer-advert for LAN discovery **and** support explicit NATS seed
URLs for cross-network (Decision 1); drop the dangling unused `_mqtt._tcp`
browse. Rewrite `test_mqtt.py``test_nats.py`.
- Fixes a latent gap: today the registry is published **on-connect only** (no
periodic/on-change republish). KV writes on `castle apply` fix this naturally.
periodic/on-change republish). KV writes on `wildpc apply` fix this naturally.
### Phase 2 — Shared config + presence via JetStream KV
- Buckets: `castle-config` (shared LAN config, **authority-written only**),
`castle-registry` (per-node), `castle-presence` (TTL liveness).
- Add static **`role: authority | follower`** to `NodeConfig` (castle.yaml).
- Buckets: `wildpc-config` (shared LAN config, **authority-written only**),
`wildpc-registry` (per-node), `wildpc-presence` (TTL liveness).
- Add static **`role: authority | follower`** to `NodeConfig` (wildpc.yaml).
Pin **`civil` = authority** (Decision 3); only the authority may write
`castle-config`. Authority-down ⇒ shared state read-only, nodes serve cached.
- Followers **watch** `castle-config` and reconcile via `castle apply`. Presence
`wildpc-config`. Authority-down ⇒ shared state read-only, nodes serve cached.
- Followers **watch** `wildpc-config` and reconcile via `wildpc apply`. Presence
key renewal is the churn signal for Phase 3.
### Phase 3 — Cross-node `requires` resolution + gateway binding + breaker (keystone)
@@ -102,7 +102,7 @@ Each phase is independently useful; the risky secret-transport work is last.
write at `deploy.py:157` (currently passed nothing). Binding = a **stable local
subdomain route** the gateway re-targets to the remote node's URL — the program
sees one unchanging URL.
- **Circuit-breaker:** gate the remote route on the `castle-presence` key
- **Circuit-breaker:** gate the remote route on the `wildpc-presence` key
(regenerate + `_reload_gateway` when a provider appears/vanishes), backed by
Caddy passive health (`fail_duration` / `lb_try_duration`) on the
`reverse_proxy` line in `_host_matcher_block`. Consumer degraded-mode =
@@ -112,9 +112,9 @@ Each phase is independently useful; the risky secret-transport work is last.
### Phase 4 — OpenBao as the secret backend (last; needs the security foundation)
- Introduce a **`SecretBackend` seam** at the single read chokepoint
`_read_secret` (`config.py:307`) + the `castle-api/.../secrets.py` writer:
`_read_secret` (`config.py:307`) + the `wildpc-api/.../secrets.py` writer:
`FileSecretBackend` (default, unchanged) and `OpenBaoBackend`. `${secret:NAME}`
syntax is untouched; backend selected in castle.yaml or by env.
syntax is untouched; backend selected in wildpc.yaml or by env.
- Followers auth to OpenBao (token / AppRole); **need-to-know scoping** via
per-deployment policies. Keep the file backend as fallback; migrate secrets in.
@@ -129,8 +129,8 @@ Each phase is independently useful; the risky secret-transport work is last.
- **P1:** bring up two nodes; confirm registry + presence propagate over NATS and
the mesh view (`/mesh/status`, System Map) is identical to the MQTT behavior.
- **P2:** write a key to `castle-config` on the authority; observe a follower
watch fire and `castle apply` reconcile.
- **P2:** write a key to `wildpc-config` on the authority; observe a follower
watch fire and `wildpc apply` reconcile.
- **P3:** define a service on node A that `requires` a ref provided on node B;
confirm the gateway routes cross-node, then **kill node B** and confirm the
consumer fails fast (curl returns the degraded 503, not a hang) and **recovers**
@@ -145,17 +145,17 @@ Each phase is independently useful; the risky secret-transport work is last.
Both services stood up **alongside** the live MQTT mesh (no cutover yet):
- `castle-nats` (`nats:2`, JetStream) — deployment
`~/.castle/deployments/services/castle-nats.yaml`, config
`/data/castle/castle-nats/config/nats-server.conf`. Verified: container active,
- `wildpc-nats` (`nats:2`, JetStream) — deployment
`~/.wildpc/deployments/services/wildpc-nats.yaml`, config
`/data/wildpc/wildpc-nats/config/nats-server.conf`. Verified: container active,
`/healthz` ok, JetStream enabled (`/jsz` shows store dir + limits), and a real
**KV round-trip** — created `castle-registry` (put/get) and a TTL
`castle-presence` bucket (the presence primitive). Test buckets cleaned up.
- `castle-openbao` (`openbao/openbao:latest`, v2.5.5) — deployment
`~/.castle/deployments/services/castle-openbao.yaml`, config
`/data/castle/castle-openbao/config/openbao.hcl`. Verified: initialized (1
**KV round-trip** — created `wildpc-registry` (put/get) and a TTL
`wildpc-presence` bucket (the presence primitive). Test buckets cleaned up.
- `wildpc-openbao` (`openbao/openbao:latest`, v2.5.5) — deployment
`~/.wildpc/deployments/services/wildpc-openbao.yaml`, config
`/data/wildpc/wildpc-openbao/config/openbao.hcl`. Verified: initialized (1
share / threshold 1), unsealed, **KV-v2 secret put/get/delete** round-trip.
Unseal key + root token stored as castle secrets `OPENBAO_UNSEAL_KEY` /
Unseal key + root token stored as wildpc secrets `OPENBAO_UNSEAL_KEY` /
`OPENBAO_ROOT_TOKEN`.
**Consciously deferred (tracked):**
@@ -172,10 +172,10 @@ Both services stood up **alongside** the live MQTT mesh (no cutover yet):
MQTT/Mosquitto transport replaced by NATS JetStream KV.
- New `castle_api/mesh_wire.py` — transport-agnostic registry (de)serialization
- New `wildpc_api/mesh_wire.py` — transport-agnostic registry (de)serialization
(moved out of `mqtt_client.py`; preserves the secret-stripping invariant).
- New `castle_api/nats_client.py``CastleNATSClient`: connects, PUTs its
registry to the `castle-registry` KV bucket, seeds from existing keys, watches
- New `wildpc_api/nats_client.py``WildpcNATSClient`: connects, PUTs its
registry to the `wildpc-registry` KV bucket, seeds from existing keys, watches
for peer PUT/DELETE, heartbeats a re-PUT (crash liveness via the existing
stale-TTL), and DELETEs its key on graceful stop (immediate peer-offline).
Async-native → the paho cross-thread `run_coroutine_threadsafe` hop is gone.
@@ -184,54 +184,54 @@ MQTT/Mosquitto transport replaced by NATS JetStream KV.
- Frontend: `types/index.ts` `MeshStatus` + `MeshPanel.tsx` updated to the new
fields. `mqtt_client.py` + `test_mqtt.py` deleted; `test_mesh_wire.py` added.
`mdns.py` dead `_mqtt._tcp` broker-browse removed. `paho-mqtt``nats-py`.
- **Live cutover:** `castle-api.yaml` env → `CASTLE_API_NATS_*`, `requires:
castle-nats`; applied. castle-api healthy on NATS, `/mesh/status` connected,
- **Live cutover:** `wildpc-api.yaml` env → `WILDPC_API_NATS_*`, `requires:
wildpc-nats`; applied. wildpc-api healthy on NATS, `/mesh/status` connected,
registry (9.7 KiB, 48 deployments) published to KV, **no secrets on the wire**.
- Verification run: ruff clean; 89 api + 196 core tests pass; frontend `tsc`
clean; runtime KV round-trip confirmed against the live `castle-nats`.
clean; runtime KV round-trip confirmed against the live `wildpc-nats`.
**Pending second node:** two-node mesh-view parity (peer sees peer, offline on
departure) — needs the second LAN node running the NATS-enabled castle-api
pointed at civil's NATS (seed URL) or a clustered `castle-nats`. Revert path if
needed: restore `CASTLE_API_MQTT_*` env — but note `mqtt_client.py` is deleted,
so revert = `git checkout main -- castle-api` then re-apply. The old Mosquitto
departure) — needs the second LAN node running the NATS-enabled wildpc-api
pointed at civil's NATS (seed URL) or a clustered `wildpc-nats`. Revert path if
needed: restore `WILDPC_API_MQTT_*` env — but note `mqtt_client.py` is deleted,
so revert = `git checkout main -- wildpc-api` then re-apply. The old Mosquitto
`mqtt` service is left running (dormant) as a safety net; retire it once the
second node is proven on NATS.
### Phase 2 — DONE + single-node verified + tested (2026-07-07)
- **`role` field** on `NodeConfig` + `CastleConfig`, wired end-to-end:
castle.yaml top-level `role:` → config → `_node_config` → registry.yaml →
- **`role` field** on `NodeConfig` + `WildpcConfig`, wired end-to-end:
wildpc.yaml top-level `role:` → config → `_node_config` → registry.yaml →
mesh wire. **`civil` pinned `role: authority`**; default `follower`.
- **Presence** (`castle-presence`) — a TTL KV bucket each node renews on the
- **Presence** (`wildpc-presence`) — a TTL KV bucket each node renews on the
heartbeat; expiry = the node is gone. Delete-on-stop for immediate departure.
- **Shared config** (`castle-config`) — `get_shared_config` / `put_shared_config`
- **Shared config** (`wildpc-config`) — `get_shared_config` / `put_shared_config`
(authority-gated: followers raise `PermissionError`), plus a watch loop that
broadcasts a `config_changed` SSE (the follower `castle apply` reconcile hook
broadcasts a `config_changed` SSE (the follower `wildpc apply` reconcile hook
hangs off this — inert on one node).
- Hardened `stop()` to bound the NATS drain (can't hang systemd shutdown).
- Verified live: all three buckets present, `civil=online` presence,
`"role": "authority"` on the wire, authority write + follower-deny exercised
against the running `castle-nats`.
against the running `wildpc-nats`.
- **Tests added:** `core/tests/test_fleet_role.py` (role config load + registry
round-trip), `castle-api/tests/test_nats_client.py` (role gating, hermetic),
round-trip), `wildpc-api/tests/test_nats_client.py` (role gating, hermetic),
role round-trip in `test_mesh_wire.py`. Suites: **200 core + 93 api** pass.
**Pending second node:** the follower-side reconcile (watch `castle-config` →
`castle apply`) is wired as an SSE hook but only meaningful with a peer.
**Pending second node:** the follower-side reconcile (watch `wildpc-config` →
`wildpc apply`) is wired as an SSE hook but only meaningful with a peer.
### Phase 4 — secret-read backend DONE + verified + tested (2026-07-07)
- New `core/castle_core/secret_backends.py`: `SecretBackend` protocol,
- New `core/wildpc_core/secret_backends.py`: `SecretBackend` protocol,
`FileSecretBackend` (the historical behavior), `OpenBaoBackend` (KV-v2 read with
file fallback), and `build_backend()` selecting via `CASTLE_SECRET_BACKEND`
file fallback), and `build_backend()` selecting via `WILDPC_SECRET_BACKEND`
(default **file** — production is byte-for-byte unchanged until opted in).
- `_read_secret` (the single chokepoint, `config.py`) now delegates to the active
backend. `${secret:NAME}` syntax untouched.
- OpenBao token bootstraps from the file backend (it can't live in the vault it
unlocks); a missing key / auth failure / unreachable server all fall through to
file, so a partly-migrated vault keeps working.
- Verified live against the running `castle-openbao`: a secret stored in the vault
- Verified live against the running `wildpc-openbao`: a secret stored in the vault
resolves through `${secret:...}` in openbao mode; file-only secrets resolve via
fallback; missing → placeholder; **default file mode unchanged**.
- **Tests:** `core/tests/test_secret_backends.py` (file hit/miss, backend
@@ -239,12 +239,12 @@ second node is proven on NATS.
**Hardening:**
1. **Write path — DONE + verified live.** `SecretBackend` gained
`write`/`delete`/`list_names`; `castle-api/secrets.py` routes all CRUD through
`write`/`delete`/`list_names`; `wildpc-api/secrets.py` routes all CRUD through
the active backend, so in openbao mode the dashboard writes to the vault.
Verified: write→vault→read→list→delete round-trip against live OpenBao.
2. **Auto-unseal on boot — DONE + verified.** Added `exec_start_post` to the
systemd spec (general, `-`-prefixed so a hook failure never fails the unit);
`castle-openbao` runs `/data/castle/castle-openbao/unseal.sh` (polls up, unseals
`wildpc-openbao` runs `/data/wildpc/wildpc-openbao/unseal.sh` (polls up, unseals
with `OPENBAO_UNSEAL_KEY`). Verified: manual seal → restart → auto-unsealed.
3. **TLS hardening — DONE + verified across both nodes (2026-07-07).**
Reuses the existing ACME **wildcard cert** via `expose.tcp.tls` (the postgres
@@ -253,12 +253,12 @@ second node is proven on NATS.
custom CA to distribute**.
- **NATS:** `tls{}` (wildcard cert in `/tls`) + `authorization{token}`
(`$NATS_TOKEN` from the `NATS_TOKEN` secret). Clients connect to
`tls://castle-nats.civil.payne.io:4222` with the token; `CastleNATSClient`
`tls://wildpc-nats.civil.payne.io:4222` with the token; `WildpcNATSClient`
gained token support (a `tls://` URL → nats-py verifies via system CA).
**civil + primer both cut over**; plaintext now rejected (verified:
`certificate is valid for *.civil.payne.io, not localhost`).
- **OpenBao:** listener `tls_cert_file`/`tls_key_file` from `/tls`; reachable at
`https://castle-openbao.civil.payne.io:8200`. Auto-unseal + secret backend
`https://wildpc-openbao.civil.payne.io:8200`. Auto-unseal + secret backend
both use the HTTPS URL. Verified: HTTPS serves, **auto-unsealed over HTTPS**,
plaintext rejected, backend reads a secret over HTTPS.
- Coordinated cutover across the whole fleet (both nodes) with a brief,
@@ -267,7 +267,7 @@ second node is proven on NATS.
### Phase 3 — cross-node routing + breaker: logic DONE + verified against a real peer (2026-07-07)
**Real second node:** `primer` (192.168.8.129) migrated onto the NATS mesh
(branch checked out, castle-api pointed at `nats://civil:4222` via a systemd
(branch checked out, wildpc-api pointed at `nats://civil:4222` via a systemd
drop-in). civil ↔ primer mesh confirmed over the LAN — **Phase 1 two-node parity
done for real**, not simulated. (This also *restored* the civil↔primer mesh my
Phase 1 cutover had split — primer was still on MQTT→civil.)
@@ -280,12 +280,12 @@ Phase 1 cutover had split — primer was still on MQTT→civil.)
(2s dial timeout + passive health) for the there-but-wedged case; **presence
expiry removes the peer from the route set entirely** (gone → no route) — the
primary breaker.
- `castle_api/mesh_gateway.py` — on peer join/leave/change (+ startup) the API
- `wildpc_api/mesh_gateway.py` — on peer join/leave/change (+ startup) the API
re-renders the Caddyfile (same generator `apply` uses + remote routes for
online peers) and reloads the gateway **iff content changed**.
- **Verified:** 4 hermetic tests (route emitted / address fallback / breaker:
peer-absent → no route / unconsumed → no route); **resolution against primer's
real registry** pulled live from the mesh → `castle-api → primer:9020`; and the
real registry** pulled live from the mesh → `wildpc-api → primer:9020`; and the
live integration proven a **no-op** on civil (Caddyfile hash unchanged, gateway
healthy) since civil consumes nothing cross-node yet. Suites: **210 core + 93 api**.
@@ -309,6 +309,6 @@ primer is now a permanent mesh member on the branch (revert: remove the drop-in
as `role: authority`; all others are followers. **Confirmed:** when `civil` is
down, shared config/secrets go **read-only** fleet-wide and every node keeps
serving its own deployments from cached local state.
4. **NATS — single server now, cluster-ready.** Run one `castle-nats` today
4. **NATS — single server now, cluster-ready.** Run one `wildpc-nats` today
(matches the single-writer authority). Clustering is deferred but must be a
pure config addition (cluster/routes block + a few more nodes) — no re-architecture.

View File

@@ -1,14 +1,14 @@
# Registry
How castle tracks, configures, and manages programs and their deployments.
This is the central reference for `castle.yaml` structure and the registry
How wildpc tracks, configures, and manages programs and their deployments.
This is the central reference for `wildpc.yaml` structure and the registry
architecture.
## Vocabulary (canonical)
Use these terms consistently across code, CLI, API, and docs.
- **program** — any project castle manages, regardless of what it does. The
- **program** — any project wildpc manages, regardless of what it does. The
software catalog (`programs/`). Every program has an optional **stack**.
*("component" was the old name for program — don't use it.)*
- **stack** — a creation-time toolchain + scaffold template (`python-cli`,
@@ -37,11 +37,11 @@ gateway). A single `deployments/<name>.yaml` file carries the whole thing.
## Configuration Directory Layout
Castle splits its configuration across a root directory (`~/.castle/` or your config root) instead of a single file:
Wild PC splits its configuration across a root directory (`~/.wildpc/` or your config root) instead of a single file:
```
~/.castle/
├── castle.yaml # Global settings (gateway, repo, etc.)
~/.wildpc/
├── wildpc.yaml # Global settings (gateway, repo, etc.)
├── programs/ # Program configuration files (one file per program)
│ └── my-tool.yaml
└── deployments/ # Deployment configuration files (one file per deployment)
@@ -51,35 +51,35 @@ Castle splits its configuration across a root directory (`~/.castle/` or your co
└── my-app.yaml # manager: caddy → kind: static
```
### castle.yaml (Globals)
### wildpc.yaml (Globals)
The core `castle.yaml` contains configuration settings that apply globally to your Castle platform instance:
The core `wildpc.yaml` contains configuration settings that apply globally to your Wild PC platform instance:
```yaml
gateway:
port: 9000
repo: /data/repos/castle
data_dir: /data/castle # optional — where program/service data lives
repo: /data/repos/wildpc
data_dir: /data/wildpc # optional — where program/service data lives
repos_dir: /data/repos # optional — default home for new program source repos
```
**`data_dir` / `repos_dir` — the configurable roots.** Both are optional and omitted
by default (the built-ins `/data/castle` and `/data/repos` apply). Each resolves with
precedence **env var > `castle.yaml` > built-in default**:
by default (the built-ins `/data/wildpc` and `/data/repos` apply). Each resolves with
precedence **env var > `wildpc.yaml` > built-in default**:
| root | env override | castle.yaml key | default |
| root | env override | wildpc.yaml key | default |
|------|--------------|-----------------|---------|
| program data (`${data_dir}` base) | `CASTLE_DATA_DIR` | `data_dir:` | `/data/castle` |
| new-repo home (`castle create`/`add`/`clone`) | `CASTLE_REPOS_DIR` | `repos_dir:` | `/data/repos` |
| program data (`${data_dir}` base) | `WILDPC_DATA_DIR` | `data_dir:` | `/data/wildpc` |
| new-repo home (`wildpc create`/`add`/`clone`) | `WILDPC_REPOS_DIR` | `repos_dir:` | `/data/repos` |
Put the value in `castle.yaml`, not an env var. The `castle` CLI and the `castle-api`
Put the value in `wildpc.yaml`, not an env var. The `wildpc` CLI and the `wildpc-api`
service each resolve config independently in their own process; a per-shell env var is
seen by only one of them, so the two silently diverge (and `apply` crashes if the
resolved dir — e.g. a non-existent `/data/castle` — can't be created). Persisting the
choice in `castle.yaml` is the single source of truth both read. `install.sh` writes
these keys when you install with `CASTLE_DATA_DIR`/`CASTLE_REPOS_DIR` set; `castle
resolved dir — e.g. a non-existent `/data/wildpc` — can't be created). Persisting the
choice in `wildpc.yaml` is the single source of truth both read. `install.sh` writes
these keys when you install with `WILDPC_DATA_DIR`/`WILDPC_REPOS_DIR` set; `wildpc
doctor` flags a data dir that isn't writable, or an env var that's overriding the file.
(`CASTLE_HOME`, the dir that *contains* castle.yaml, stays env-or-default `~/.castle`
(`WILDPC_HOME`, the dir that *contains* wildpc.yaml, stays env-or-default `~/.wildpc`
it can't be defined inside the file it locates.)
### Resource Configuration Files (`programs/`, `deployments/`)
@@ -139,7 +139,7 @@ root: dist
| **deployments** | `deployments/*.yaml` | How a program is realized on this node | service, job, tool, static, reference |
A deployment can reference a program via `program:` for description fallthrough
and source code linking. It can also exist independently (e.g., `castle-gateway`
and source code linking. It can also exist independently (e.g., `wildpc-gateway`
runs Caddy — not our software). The **kind** is derived from `manager` (+
`schedule`), never stored.
@@ -148,28 +148,28 @@ runs Caddy — not our software). The **kind** is derived from `manager` (+
Programs define **what software exists** — identity, source, builds. How a
program is *used* is not a program property: it's decided by its deployment's
`manager` and surfaces as the derived **kind** (service/job/tool/static/reference).
A program with no deployment is just source castle knows how to develop.
A program with no deployment is just source wildpc knows how to develop.
### `source` — Where the source lives
```yaml
source: /data/repos/my-tool # your programs, under $CASTLE_REPOS_DIR
source: repo:castle-api # castle's own programs, inside the git repo
source: /data/repos/my-tool # your programs, under $WILDPC_REPOS_DIR
source: repo:wildpc-api # wildpc's own programs, inside the git repo
```
The `source` path is resolved one of three ways (`core/src/castle_core/config.py`):
The `source` path is resolved one of three ways (`core/src/wildpc_core/config.py`):
| `source:` value | Resolves to | Used for |
|-----------------|-------------|----------|
| `/data/repos/my-tool` *(absolute)* | as-is | Your own programs (the default) |
| `repo:castle-api` | `<repo>/castle-api` (via the top-level `repo:` field) | Castle's built-in programs |
| `code/my-tool` *(relative)* | `$CASTLE_HOME/code/my-tool` | Legacy — pre-`/data/repos` layout |
| `repo:wildpc-api` | `<repo>/wildpc-api` (via the top-level `repo:` field) | Wild PC's built-in programs |
| `code/my-tool` *(relative)* | `$WILDPC_HOME/code/my-tool` | Legacy — pre-`/data/repos` layout |
Programs you create or adopt live under **`$CASTLE_REPOS_DIR`** (default
`/data/repos`, override with `CASTLE_REPOS_DIR`) and are recorded with an
**absolute** `source:`. Castle's own programs (CLI, core, castle-api, app) live
Programs you create or adopt live under **`$WILDPC_REPOS_DIR`** (default
`/data/repos`, override with `WILDPC_REPOS_DIR`) and are recorded with an
**absolute** `source:`. Wild PC's own programs (CLI, core, wildpc-api, app) live
in the git repo and use the `repo:` prefix. A relative `source:` still resolves
against `$CASTLE_HOME` for back-compat, but new programs no longer use it.
against `$WILDPC_HOME` for back-compat, but new programs no longer use it.
### `stack` — Development toolchain (optional)
@@ -196,7 +196,7 @@ the stack default; an absent verb falls back to the stack handler (if any), else
the verb is unavailable. `build` is declared via `build:` (it also carries
`outputs:`); every other verb via `commands:`. This is what lets a wired-in repo
with no stack be linted/tested/run. Verb resolution lives in
`core/src/castle_core/stacks.py` (`run_action`, `available_actions`).
`core/src/wildpc_core/stacks.py` (`run_action`, `available_actions`).
### `repo` / `ref` — Wiring in an existing repo
@@ -205,9 +205,9 @@ repo: https://github.com/me/widget.git
ref: v2.1.0 # optional branch/tag/commit
```
`repo` records a git URL so `castle program clone` can provision the source on a fresh
`repo` records a git URL so `wildpc program clone` can provision the source on a fresh
machine. When `source:` points at an existing working copy, that takes
precedence. Use `castle program add <path|url>` to register an existing repo as a program.
precedence. Use `wildpc program add <path|url>` to register an existing repo as a program.
### `system_dependencies` — Required system packages
@@ -216,7 +216,7 @@ system_dependencies: [pandoc, poppler-utils]
```
System packages that must be installed for the program to work. Displayed
in `castle tool list` / `castle tool info` and the dashboard.
in `wildpc tool list` / `wildpc tool info` and the dashboard.
### `version` — Program version
@@ -246,7 +246,7 @@ declares a **`manager`** — who makes it available and supervises its lifecycle
### `manager` — Who realizes it (the discriminant)
A deployment is a *managed materialization* of a program. Its **`manager`** is
the stored discriminant — the single axis `castle apply` and status dispatch on
the stored discriminant — the single axis `wildpc apply` and status dispatch on
(it's what makes activation polymorphic: one verb, kind-specific mechanism):
| Manager | Makes available as | Launch mechanism | how `apply` activates | Kind |
@@ -298,7 +298,7 @@ A `compose` launcher supervises a **whole multi-container stack as one systemd
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
stop. Unlike the single-container `container` launcher, compose owns the stack's own
networking, startup ordering, and per-service health — Castle delegates rather
networking, startup ordering, and per-service health — Wild PC delegates rather
than reinventing orchestration. Secrets/env reach compose through the unit's
`Environment=`/`EnvironmentFile=` (from `defaults.env`), which compose interpolates
from the process environment. This is what runs the shared **Supabase substrate**
@@ -309,7 +309,7 @@ manager: systemd
run:
launcher: compose
file: docker-compose.yml # resolved under the program source
# project_name: castle-my-stack # optional; defaults to castle-<name>
# project_name: wildpc-my-stack # optional; defaults to wildpc-<name>
```
### `root` — Static frontend (caddy only)
@@ -354,9 +354,9 @@ proxy: true # expose at <service-name>.<gateway.domain>
`public: true` additionally projects a proxied service to the public internet via a
Cloudflare tunnel, at **`<service-name>.<gateway.public_domain>`** (a separate zone,
so internal subdomain names stay out of public DNS). Defaults to `false` — public is
explicit — and **requires `proxy: true`**. `castle apply` generates the cloudflared
explicit — and **requires `proxy: true`**. `wildpc apply` generates the cloudflared
ingress from the set of public services. Needs `gateway.public_domain` +
`gateway.tunnel_id` set and the `castle-tunnel` service running; see
`gateway.tunnel_id` set and the `wildpc-tunnel` service running; see
@docs/tunnel-setup.md for the one-time setup.
```yaml
@@ -401,15 +401,15 @@ prerequisites (tokens + LAN DNS for the apex): @docs/tunnel-setup.md.
"Serving a frontend" and "proxying a service" are the same thing — a subdomain
route — differing only in whether the target is files on disk or a live process.
The table is shown by `castle gateway status`, the dashboard Gateway panel, and
The table is shown by `wildpc gateway status`, the dashboard Gateway panel, and
`GET /gateway`; the Caddyfile is generated from it.
**The dashboard and its API.** `castle` (the dashboard frontend) and `castle-api`
are just two such subdomains (`castle.<domain>`, `castle-api.<domain>`); the
dashboard calls the API **cross-origin** (castle-api allows CORS `*`). The bare
**The dashboard and its API.** `wildpc` (the dashboard frontend) and `wildpc-api`
are just two such subdomains (`wildpc.<domain>`, `wildpc-api.<domain>`); the
dashboard calls the API **cross-origin** (wildpc-api allows CORS `*`). The bare
gateway port (`:9000`) redirects to the dashboard subdomain. On a node with **no
domain** (`gateway.tls: off`), there are no subdomains, so `:9000` serves just the
control plane — the dashboard at `/` plus a `/api` reverse-proxy to castle-api —
control plane — the dashboard at `/` plus a `/api` reverse-proxy to wildpc-api —
and other services stay port-only.
#### Host routes need DNS, and the gateway is HTTP-only
@@ -456,7 +456,7 @@ the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
A private-CA approach would force every client device to trust a custom root —
which some platforms (e.g. Android browsers, and Firefox, which uses its own
store) make painful — so Castle doesn't offer one. `acme` mode avoids it entirely:
store) make painful — so Wild PC doesn't offer one. `acme` mode avoids it entirely:
Caddy obtains a
**real Let's Encrypt wildcard cert** (`*.<domain>`) via a **DNS-01** challenge, so
every browser trusts it with **zero CA install** — while the services stay
@@ -496,25 +496,25 @@ is published at `<name>.<domain>`. Services stay domain-agnostic (switching
`gateway.domain` needs no service edits). One `*.<domain>` site means a single cert
covers every route — adding a service needs no new cert.
Setup (the parts castle can't do for you):
Setup (the parts wildpc can't do for you):
- **DNS-plugin Caddy.** Stock Caddy has no DNS modules; build one with the
provider plugin: `./install.sh --with-dns-plugin=cloudflare` (uses `xcaddy`,
installs to `/usr/local/bin/caddy`, which the gateway picks up on next deploy).
- **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN`
secret (Cloudflare scope: **Zone → DNS → Edit**), and map it into the gateway
service env — add to `deployments/castle-gateway.yaml`:
service env — add to `deployments/wildpc-gateway.yaml`:
```yaml
defaults:
env:
CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN}
```
`castle apply` warns if the domain, this env var, or the secret is missing.
`wildpc apply` warns if the domain, this env var, or the secret is missing.
- **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router)
pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>`
(dnsmasq) or the equivalent A record. The public zone gets no A records, so
services aren't externally reachable.
- **Staging first.** Set `CASTLE_ACME_STAGING=1` to use Let's Encrypt's staging CA
- **Staging first.** Set `WILDPC_ACME_STAGING=1` to use Let's Encrypt's staging CA
(its rate limits are generous) while verifying issuance, then unset it and
redeploy to get a browser-trusted production cert. Verify with
`openssl s_client -connect <ip>:443 -servername claw.<domain> | openssl x509 -noout -issuer`.
@@ -535,8 +535,8 @@ manage:
systemd: {}
```
Marks the deployment systemd-managed, so `castle apply` generates and reconciles
its unit (and `castle service logs` tails it). An empty `{}` uses defaults
Marks the deployment systemd-managed, so `wildpc apply` generates and reconciles
its unit (and `wildpc service logs` tails it). An empty `{}` uses defaults
(enable=true, restart=on-failure, restart_sec=2).
Full options:
@@ -547,7 +547,7 @@ manage:
restart: always # on-failure | always | no
restart_sec: 2
no_new_privileges: true
after: [network.target, castle-other.service]
after: [network.target, wildpc-other.service]
wanted_by: [default.target]
exec_reload: "caddy reload ..."
```
@@ -555,7 +555,7 @@ manage:
### `defaults` — Environment
`defaults.env` is the **single, explicit source** of the env a service/job runs
with — what you write here is exactly what lands in the systemd unit. Castle
with — what you write here is exactly what lands in the systemd unit. Wild PC
does **not** inject hidden convention vars; whatever env var your program reads
for its port, data dir, etc., you map here.
@@ -564,25 +564,25 @@ expose: { http: { internal: { port: 9001 }, health_path: /health } }
defaults:
env:
MY_SERVICE_PORT: ${port} # the program's own port var ← expose.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/<name>
MY_SERVICE_DATA_DIR: ${data_dir} # = $WILDPC_DATA_DIR/<name>
CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY}
```
Values may contain placeholders that castle resolves at deploy:
Values may contain placeholders that wildpc resolves at deploy:
| Placeholder | Expands to |
|-------------|------------|
| `${port}` | the service's `expose.http.internal.port` (so it can't drift) |
| `${data_dir}` | `$CASTLE_DATA_DIR/<program-or-name>` (the dedicated data volume) |
| `${data_dir}` | `$WILDPC_DATA_DIR/<program-or-name>` (the dedicated data volume) |
| `${name}` | the deployment name |
| `${public_url}` | the service's gateway-facing base URL — `https://<name>.<domain>` when exposed under `tls: acme`, else the node-local `http://localhost:<port>`. The origin an app allowlists (CORS/WebSocket/secure-context); tracks `gateway.domain`, so a domain change needs no app edit. |
| `${secret:NAME}` | the contents of `~/.castle/secrets/NAME` |
| `${secret:NAME}` | the contents of `~/.wildpc/secrets/NAME` |
Hardcode the values instead if you prefer; the placeholders just save you from
repeating castle's computed paths/ports. `castle program create` scaffolds the
repeating wildpc's computed paths/ports. `wildpc program create` scaffolds the
`${port}`/`${data_dir}` lines for new services. Never store secrets in
castle.yaml — use `${secret:…}`.
wildpc.yaml — use `${secret:…}`.
## Job fields
@@ -598,37 +598,37 @@ schedule: "*/5 * * * *"
timezone: America/Los_Angeles # default
```
Castle generates a systemd `.timer` file alongside the `.service` unit.
Wild PC generates a systemd `.timer` file alongside the `.service` unit.
## How programs get into `/data/repos/`
Every program's source lives under `$CASTLE_REPOS_DIR` (default `/data/repos/<name>/`).
Every program's source lives under `$WILDPC_REPOS_DIR` (default `/data/repos/<name>/`).
It can arrive there a few ways:
1. **Scaffold a new one** with `castle program create` — writes the project into
`/data/repos/<name>/` and registers it in `castle.yaml` with an absolute
1. **Scaffold a new one** with `wildpc program create` — writes the project into
`/data/repos/<name>/` and registers it in `wildpc.yaml` with an absolute
`source: /data/repos/<name>`.
2. **Adopt an existing repo** — `castle program add <path|git-url>` registers it
in place (or records its `repo:` URL for `castle program clone`).
2. **Adopt an existing repo** — `wildpc program add <path|git-url>` registers it
in place (or records its `repo:` URL for `wildpc program clone`).
3. **Drop files in directly** — a `/data/repos/<name>/` directory is just a
working tree; it doesn't have to be under version control to be run.
`/data/repos/` holds independent repos — each program directory manages its own
version control (or none); some are standalone git clones, others loose files.
Castle's own programs (CLI, core, castle-api, app) are the exception: they live
inside the castle git repo and are referenced with `source: repo:<name>`.
Wild PC's own programs (CLI, core, wildpc-api, app) are the exception: they live
inside the wildpc git repo and are referenced with `source: repo:<name>`.
## Registering a new program
### Via `castle program create` (recommended)
### Via `wildpc program create` (recommended)
```bash
# Service — scaffolds into /data/repos/, assigns port, registers in castle.yaml
castle program create my-service --stack python-fastapi --description "Does something"
# Service — scaffolds into /data/repos/, assigns port, registers in wildpc.yaml
wildpc program create my-service --stack python-fastapi --description "Does something"
# Tool — scaffolds into /data/repos/
castle program create my-tool --stack python-cli --description "Does something"
wildpc program create my-tool --stack python-cli --description "Does something"
```
### Manually
@@ -672,18 +672,18 @@ manage:
## Lifecycle
One flow for every kind — scaffold, implement, **`castle apply`**. Activation is
One flow for every kind — scaffold, implement, **`wildpc apply`**. Activation is
polymorphic over the `manager`, so the *verb* never changes; only what apply *does*
does:
```bash
castle program create my-thing --stack python-fastapi # scaffold source + deployment
wildpc program create my-thing --stack python-fastapi # scaffold source + deployment
cd /data/repos/my-thing && uv sync # implement
castle program test my-thing # dev verbs (build/test/lint/…)
castle apply my-thing # converge: render + activate
wildpc program test my-thing # dev verbs (build/test/lint/…)
wildpc apply my-thing # converge: render + activate
```
| kind (manager) | what `castle apply` does |
| kind (manager) | what `wildpc apply` does |
|----------------|--------------------------|
| **service** (systemd) | render the `.service` unit + gateway route, `enable --now` |
| **job** (systemd + schedule) | render a `.service` (Type=oneshot) **and** a `.timer` |
@@ -693,47 +693,47 @@ castle apply my-thing # converge: render + act
Manage a running deployment:
```bash
castle logs my-thing -f # Tail logs
castle program run my-thing # Run in foreground (for debugging)
castle restart my-thing # Imperative bounce — re-actualize current state
wildpc logs my-thing -f # Tail logs
wildpc program run my-thing # Run in foreground (for debugging)
wildpc restart my-thing # Imperative bounce — re-actualize current state
```
To durably turn something off, set `enabled: false` in its deployment and
`castle apply` — there is no start/stop/enable/disable/install verb.
`wildpc apply` — there is no start/stop/enable/disable/install verb.
## Infrastructure paths
Castle uses **two** independent roots, each overridable by an environment
Wild PC uses **two** independent roots, each overridable by an environment
variable (both expand `~` and resolve relative paths):
- **`CASTLE_HOME`** — config, code, artifacts, and secrets. Default `~/.castle`.
- **`CASTLE_DATA_DIR`** — program/service data I/O (potentially large; lives on a
dedicated volume). Default `/data/castle`. Decoupled from `CASTLE_HOME` on
- **`WILDPC_HOME`** — config, code, artifacts, and secrets. Default `~/.wildpc`.
- **`WILDPC_DATA_DIR`** — program/service data I/O (potentially large; lives on a
dedicated volume). Default `/data/wildpc`. Decoupled from `WILDPC_HOME` on
purpose so bulk data doesn't sit in the home directory.
| What | Where |
|------|-------|
| Castle home | `$CASTLE_HOME` (default `~/.castle`) |
| Config | `$CASTLE_HOME/castle.yaml` + `programs/` + `deployments/` |
| Program source (yours) | `/data/repos/<name>/` (`$CASTLE_REPOS_DIR`; absolute `source:`) |
| Program source (castle's) | `<repo>/<name>` (via `source: repo:<name>`) |
| Secrets | `$CASTLE_HOME/secrets/<NAME>` |
| Generated Caddyfile | `$CASTLE_HOME/artifacts/specs/Caddyfile` |
| Wild PC home | `$WILDPC_HOME` (default `~/.wildpc`) |
| Config | `$WILDPC_HOME/wildpc.yaml` + `programs/` + `deployments/` |
| Program source (yours) | `/data/repos/<name>/` (`$WILDPC_REPOS_DIR`; absolute `source:`) |
| Program source (wildpc's) | `<repo>/<name>` (via `source: repo:<name>`) |
| Secrets | `$WILDPC_HOME/secrets/<NAME>` |
| Generated Caddyfile | `$WILDPC_HOME/artifacts/specs/Caddyfile` |
| Built frontends | served in place from `<source>/<dist>/` (no copy) |
| **Service data** | **`$CASTLE_DATA_DIR/<name>/` (default `/data/castle/<name>/`)** |
| Systemd units | `~/.config/systemd/user/castle-*.service` |
| Systemd timers | `~/.config/systemd/user/castle-*.timer` |
| **Service data** | **`$WILDPC_DATA_DIR/<name>/` (default `/data/wildpc/<name>/`)** |
| Systemd units | `~/.config/systemd/user/wildpc-*.service` |
| Systemd timers | `~/.config/systemd/user/wildpc-*.timer` |
Defined in `core/src/castle_core/config.py`: `CASTLE_HOME` (with derived
Defined in `core/src/wildpc_core/config.py`: `WILDPC_HOME` (with derived
`CODE_DIR`, `SECRETS_DIR`, `SPECS_DIR`, `CONTENT_DIR`) and the independent
`DATA_DIR` (`CASTLE_DATA_DIR`). A service reaches its data path by mapping
`${data_dir}` (= `$CASTLE_DATA_DIR/<name>`) to the env var its program reads, in
`DATA_DIR` (`WILDPC_DATA_DIR`). A service reaches its data path by mapping
`${data_dir}` (= `$WILDPC_DATA_DIR/<name>`) to the env var its program reads, in
`defaults.env`. Systemd unit/timer paths are fixed by systemd's user-unit
convention.
## Manifest models
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
The Pydantic models live in `core/src/wildpc_core/manifest.py`. Key classes:
- `ProgramSpec` — software catalog entry (source, stack, build, system_dependencies)
- `DeploymentSpec` — a deployment, a discriminated union on `manager`:
@@ -745,8 +745,8 @@ The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py` — `load_config()` parses the
config root into `CastleConfig` with typed `programs` and `deployments` dicts.
Config loading: `core/src/wildpc_core/config.py` — `load_config()` parses the
config root into `WildpcConfig` with typed `programs` and `deployments` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
Infrastructure generators: `core/src/wildpc_core/generators/` — systemd unit/timer
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`).

View File

@@ -1,6 +1,6 @@
# Relationships: requires, repos, and derived predicates
How castle models the relationships between **programs**, **deployments**, and
How wildpc models the relationships between **programs**, **deployments**, and
**repos** — and answers questions like *"is this functional?"*, *"is it fresh?"*,
*"is it deployed?"* — with the smallest possible amount of stored state.
@@ -10,7 +10,7 @@ How castle models the relationships between **programs**, **deployments**, and
A *predicate* is a question we ask about a program or deployment: `functional?`,
`fresh?`, `deployed?`. None of these are ever stored — each is a **function** over
data castle already has (git, config, the registry). When a predicate can't be
data wildpc already has (git, config, the registry). When a predicate can't be
answered from derived data, find the one missing datum and ask: is it about the
**thing** (a node property) or about a **relationship** (an edge property)? Encode
*only* that datum. Everything else stays computed.
@@ -59,17 +59,17 @@ each precondition on its natural layer — a deployment-ref is node-level wiring
the program). There is no `requires` on the program, and no `kind: system` written
into a deployment's `requires`.
Only encode a `requires` edge that is **not derivable** and that **castle itself
Only encode a `requires` edge that is **not derivable** and that **wildpc itself
must traverse** for an operation (status, bring-up order, group ops). Do **not**
duplicate what another layer already owns — systemd `Requires=`/`After=` for unit
ordering, uv/pnpm for build graphs. This is *castle's* slice, uncoupled from any
ordering, uv/pnpm for build graphs. This is *wildpc's* slice, uncoupled from any
one package ecosystem.
### Env is derived *from* `requires`, never scraped *into* it
Reading dependencies out of env strings is unstable (formats vary; a static
frontend's API URL is baked into its bundle and invisible). The stable direction is
the reverse: from an encoded `{ref, bind}` deployment requirement castle **generates**
the reverse: from an encoded `{ref, bind}` deployment requirement wildpc **generates**
the wiring env — it knows the target's address (`<ref>.<domain>` / its port) and
projects it into the consumer's env, optionally under the var named by `bind`. Same
move as `${public_url}`, one step further. Dependency → env, never env → dependency.

View File

@@ -1,13 +1,13 @@
# Hugo static sites in Castle
# Hugo static sites in Wild PC
> **This is a stack — creation-time guidance for writing _new_ sites.**
> A stack is a template + conventions, not a runtime requirement. `castle program
> A stack is a template + conventions, not a runtime requirement. `wildpc program
> create --stack hugo` scaffolds from it (via Hugo's own `hugo new site`) and seeds
> the program's default build verb. An existing Hugo site adopted with `castle
> the program's default build verb. An existing Hugo site adopted with `wildpc
> program add` doesn't need this stack — it declares its own `commands:` /
> `build:`. See @docs/registry.md for `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle programs.
How to build, serve, and manage [Hugo](https://gohugo.io) sites as wildpc programs.
## Stack
@@ -18,27 +18,27 @@ How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle progra
`/posts/index.html`) and 404s missing paths. (The default `spa: true` is a
single-page-app fallback that serves the root `index.html` for every unmatched
path — correct for React/Vite, but it swallows a Hugo site's in-page links back
to the homepage. `castle program create --stack hugo` sets `spa: false` for you.)
to the homepage. `wildpc program create --stack hugo` sets `spa: false` for you.)
- Package manager (only if a theme needs an asset pipeline): pnpm
Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check,
so the stack advertises only `build` / `install` / `uninstall`; `castle check` and
so the stack advertises only `build` / `install` / `uninstall`; `wildpc check` and
friends aren't offered (a site can still declare its own, e.g. an HTML linter, under
`commands:` — a declared verb always wins over the stack).
## Create a new site
```bash
castle program create my-site --stack hugo --description "My site"
wildpc program create my-site --stack hugo --description "My site"
cd /data/repos/my-site
castle program build my-site # hugo --gc --minify -> public/
castle apply my-site # serve at my-site.<gateway.domain>
wildpc program build my-site # hugo --gc --minify -> public/
wildpc apply my-site # serve at my-site.<gateway.domain>
```
The scaffold delegates the canonical skeleton to `hugo new site` (archetypes/,
content/, layouts/, static/, themes/, hugo.toml) and overlays the pieces a bare
skeleton lacks: minimal `layouts/` so the site **builds and serves without a
theme**, an example `content/posts/hello.md`, a castle-flavored `hugo.toml`
theme**, an example `content/posts/hello.md`, a wildpc-flavored `hugo.toml`
(`baseURL = "/"`, so assets resolve at the root of the site's own subdomain), and a
`.gitignore` for the regenerated `public/` and `resources/`.
@@ -78,14 +78,14 @@ cd themes/<theme> && pnpm install
## Deployment shape
`castle program create --stack hugo` writes:
`wildpc program create --stack hugo` writes:
- **`programs/<name>.yaml`** — `source`, `stack: hugo`, `build.outputs: [public]`.
- **`deployments/statics/<name>.yaml`** — `manager: caddy`, `root: public`,
`reach: internal` (flip to `public` to also expose over the tunnel).
The gateway serves `<source>/public` in place — no copy, no Node/Hugo process at
runtime. `castle program build` regenerates `public/`; `castle apply` renders the
runtime. `wildpc program build` regenerates `public/`; `wildpc apply` renders the
route and reloads the gateway.
## Adopting an existing Hugo site
@@ -93,7 +93,7 @@ route and reloads the gateway.
No stack needed — adopt the repo and declare how it builds:
```bash
castle program add /path/to/site --name my-site
wildpc program add /path/to/site --name my-site
```
Then set `build.commands` (as above) and add a `manager: caddy` deployment. This is

View File

@@ -1,9 +1,9 @@
# Python Tools in Castle
# Python Tools in Wild PC
> **This is a stack — creation-time guidance for writing _new_ CLI tools.**
> A stack is a template + conventions, not a runtime requirement. `castle program create
> A stack is a template + conventions, not a runtime requirement. `wildpc program create
> --stack python-cli` scaffolds from it and seeds the program's default dev-verb
> commands. An existing CLI adopted with `castle program add` doesn't need this stack — it
> commands. An existing CLI adopted with `wildpc program add` doesn't need this stack — it
> declares its own `commands:`. See @docs/registry.md for `commands:`,
> `stack:` (optional), and `repo:`.
@@ -49,11 +49,11 @@ Examples: `code/pdf2md/`, `code/gpt/`, `code/protonmail/`
## Creating a new tool
```bash
castle program create my-tool --stack python-cli --description "Does something"
wildpc program create my-tool --stack python-cli --description "Does something"
cd /data/repos/my-tool && uv sync
```
This scaffolds the project and registers it in `castle.yaml`.
This scaffolds the project and registers it in `wildpc.yaml`.
## pyproject.toml

View File

@@ -1,15 +1,15 @@
# Web APIs in Castle
# Web APIs in Wild PC
> **This is a stack — creation-time guidance for writing _new_ FastAPI services.**
> A stack is a template + conventions, not a runtime requirement. `castle program create
> A stack is a template + conventions, not a runtime requirement. `wildpc program create
> --stack python-fastapi` scaffolds from it and seeds the program's default
> dev-verb commands. An existing service adopted with `castle program add` doesn't need
> dev-verb commands. An existing service adopted with `wildpc program add` doesn't need
> this stack — it declares its own `commands:`. See @docs/registry.md
> for `commands:`, `stack:` (optional), and `repo:`.
How to build Python web APIs as castle service components. Based on the
How to build Python web APIs as wildpc service components. Based on the
patterns used in [wild-cloud/api](https://github.com/civilsociety-dev/wild-cloud)
and existing castle services (central-context, notification-bridge, event-bus).
and existing wildpc services (central-context, notification-bridge, event-bus).
## Stack
@@ -103,7 +103,7 @@ class Settings(BaseSettings):
settings = Settings()
```
Castle passes config via env vars in the deployment's `defaults.env`:
Wild PC passes config via env vars in the deployment's `defaults.env`:
```yaml
# programs/my-service.yaml
@@ -127,9 +127,9 @@ manage:
systemd: {}
```
The env a service runs with is exactly what's in `defaults.env`castle injects
The env a service runs with is exactly what's in `defaults.env`wildpc injects
nothing implicitly. Map the vars your settings read (above, `env_prefix:
"MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed
"MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to wildpc's computed
values with the `${port}`/`${data_dir}` placeholders — add to
`deployments/my-service.yaml`:
@@ -137,11 +137,11 @@ values with the `${port}`/`${data_dir}` placeholders — add to
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
MY_SERVICE_DATA_DIR: ${data_dir} # = $WILDPC_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
```
`castle program create` scaffolds the `${port}`/`${data_dir}` lines for you.
`wildpc program create` scaffolds the `${port}`/`${data_dir}` lines for you.
## Application entry point
@@ -300,10 +300,10 @@ Mapping convention:
## Storage
Castle services use filesystem storage with JSON metadata sidecars:
Wild PC services use filesystem storage with JSON metadata sidecars:
```
$CASTLE_DATA_DIR/my-service/ # default /data/castle/my-service/
$WILDPC_DATA_DIR/my-service/ # default /data/wildpc/my-service/
└── bucket/
├── item-name
└── item-name.meta.json
@@ -447,11 +447,11 @@ uv run ruff format . # Format
## Scaffolding
`castle program create` generates all of this automatically:
`wildpc program create` generates all of this automatically:
```bash
castle program create my-service --stack python-fastapi --description "Does something useful"
wildpc program create my-service --stack python-fastapi --description "Does something useful"
```
See @docs/registry.md for manifest fields, castle.yaml structure,
and the full service lifecycle (`castle apply`, logs).
See @docs/registry.md for manifest fields, wildpc.yaml structure,
and the full service lifecycle (`wildpc apply`, logs).

View File

@@ -1,13 +1,13 @@
# Web Frontends in Castle
# Web Frontends in Wild PC
> **This is a stack — creation-time guidance for writing _new_ frontends.**
> A stack is a template + conventions, not a runtime requirement. `castle program create
> A stack is a template + conventions, not a runtime requirement. `wildpc program create
> --stack react-vite` scaffolds from it and seeds the program's default dev-verb
> commands. An existing frontend adopted with `castle program add` doesn't need this
> commands. An existing frontend adopted with `wildpc program add` doesn't need this
> stack — it declares its own `commands:`. See @docs/registry.md for
> `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage web frontends as castle programs.
How to build, serve, and manage web frontends as wildpc programs.
## Stack
@@ -56,7 +56,7 @@ import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
// Castle sets VITE_BASE=/ at build time (every frontend serves at its
// Wild PC sets VITE_BASE=/ at build time (every frontend serves at its
// subdomain root). Reading it here keeps the bundle.s absolute asset
// URLs resolving at the root — no hand-tuning.
base: process.env.VITE_BASE ?? "/",
@@ -70,7 +70,7 @@ export default defineConfig({
```
> **Serving behind the gateway.** A static frontend is served at its own subdomain
> — `<name>.<gateway.domain>` — rooted at `/`, so `VITE_BASE` is always `/`. Castle's
> — `<name>.<gateway.domain>` — rooted at `/`, so `VITE_BASE` is always `/`. Wild PC's
> `react-vite` build passes `VITE_BASE=/`, and a `vite.config` that reads it (above)
> works unchanged. (Frontends are no longer mounted under a `/<name>/` path.)
@@ -130,19 +130,19 @@ project root:
24.14.1
```
Castle reads this pin and puts the matching node on PATH whenever it runs the
program's node — at build time (`castle program build`, incl. builds triggered from
the castle web app, which run inside the `castle-api` service) and at run time (a
Wild PC reads this pin and puts the matching node on PATH whenever it runs the
program's node — at build time (`wildpc program build`, incl. builds triggered from
the wildpc web app, which run inside the `wildpc-api` service) and at run time (a
`launcher: node` service's systemd unit). This is why a build works from the web app
even though the service's default PATH deliberately omits nvm's versioned dirs.
The pin is standard, tool-agnostic config — the program stays castle-independent; nvm
(and editors/CI) honor the same file. Castle resolves it against
`~/.nvm/versions/node` (override with `CASTLE_NODE_VERSIONS_DIR`), newest match wins.
The pin is standard, tool-agnostic config — the program stays wildpc-independent; nvm
(and editors/CI) honor the same file. Wild PC resolves it against
`~/.nvm/versions/node` (override with `WILDPC_NODE_VERSIONS_DIR`), newest match wins.
An exact pin (`24.14.1`) matches exactly; a partial (`24`) or a range (`>=24`) matches
the newest installed major. If the pinned version isn't installed, the verb fails loud
with a `nvm install <version>` hint instead of a cryptic `node: not found`. Unpinned →
Castle injects no node (it uses whatever ambient node is on PATH, and does not guess).
Wild PC injects no node (it uses whatever ambient node is on PATH, and does not guess).
## Registering as a program
@@ -194,19 +194,19 @@ See @docs/registry.md for the full registry reference.
## Serving with Caddy
For production, the static build output is served by Caddy rather than a Node
process. You do **not** write this block by hand — `castle apply` generates it.
process. You do **not** write this block by hand — `wildpc apply` generates it.
The flow:
1. You build the frontend with `castle program build <name>` (deploy does **not**
1. You build the frontend with `wildpc program build <name>` (deploy does **not**
build — it only points Caddy at `dist/`).
2. The Caddyfile generator emits a route per `manager: caddy` deployment (kind
**static**), rooted **in place** at `<source>/<root>` — no copy. Each static
frontend is served at its own subdomain `<name>.<gateway.domain>` (the
dashboard, `castle`, is also the target of the `:9000` redirect).
dashboard, `wildpc`, is also the target of the `:9000` redirect).
The build is run with `VITE_BASE=/`, so a `vite.config` that reads it (see
[Vite config](#vite-config)) emits root-relative asset URLs. The generated block
(in `~/.castle/artifacts/specs/Caddyfile`) is a host matcher inside the
(in `~/.wildpc/artifacts/specs/Caddyfile`) is a host matcher inside the
`*.<domain>` site:
```caddyfile
@@ -225,7 +225,7 @@ not hand-configured.
## API integration
Frontends talk to castle services via environment variables injected at build
Frontends talk to wildpc services via environment variables injected at build
time. Vite exposes variables prefixed with `VITE_`:
```bash
@@ -256,7 +256,7 @@ class ApiClient {
export const apiClient = new ApiClient()
```
When served behind the castle gateway, the API base URL can use the gateway's
When served behind the wildpc gateway, the API base URL can use the gateway's
proxy paths (e.g., `/central-context/`) instead of direct ports, avoiding CORS.
## React Query setup

View File

@@ -1,13 +1,13 @@
# Supabase Apps in Castle
# Supabase Apps in Wild PC
> **This is a stack — creation-time guidance for writing _new_ database-backed
> web apps.** A stack is a template + conventions, not a runtime requirement.
> `castle program create --stack supabase` scaffolds from it and seeds the
> `wildpc program create --stack supabase` scaffolds from it and seeds the
> program's default dev-verb commands. See @docs/registry.md for `commands:`,
> `stack:` (optional), and the deployment `manager` (and derived `kind`).
How to build tiny, database-backed web apps as castle programs that target a
**shared Supabase substrate**. This is Castle's "a stack whose default is a
How to build tiny, database-backed web apps as wildpc programs that target a
**shared Supabase substrate**. This is Wild PC's "a stack whose default is a
substrate": the app owns its code (and stays repo-durable), and rents the boring
backend — Postgres + auth + storage + RLS — that an app can't reliably reinvent.
@@ -16,7 +16,7 @@ backend — Postgres + auth + storage + RLS — that an app can't reliably reinv
Unlike the other stacks (which scaffold a self-contained process), a supabase app
is **code + migrations that deploy against a shared backend**:
- **The substrate** is one castle service (`supabase`, the `supabase-substrate`
- **The substrate** is one wildpc service (`supabase`, the `supabase-substrate`
repo) running self-hosted Supabase via a `manager: systemd` deployment with the
`compose` launcher. It is shared by
every supabase app. Stand it up once (see that repo's README).
@@ -27,10 +27,10 @@ is **code + migrations that deploy against a shared backend**:
Apps are isolated on the shared instance by their **own Postgres schema + RLS**
(and Storage buckets), under **one identity pool** — correct for a single-operator
datalake. Each app owns a schema named after the program (`my-app` → schema
`my_app`); `castle program build` creates and grants it, tracks migrations in a
per-app `<schema>.schema_migrations`, and PostgREST exposes it (castle derives the
`my_app`); `wildpc program build` creates and grants it, tracks migrations in a
per-app `<schema>.schema_migrations`, and PostgREST exposes it (wildpc derives the
substrate's `PGRST_DB_SCHEMAS` from the registered apps). This gives a clean
teardown — `castle delete --purge-data` runs `drop schema <app> cascade` — and
teardown — `wildpc delete --purge-data` runs `drop schema <app> cascade` — and
means migration version tokens never collide across apps. Substrate-per-app is
deliberately not supported: ~14 containers per app doesn't scale to "lots of small
ideas," and a DB-backed app is a pet either way.
@@ -69,14 +69,14 @@ gateway serves `public/` in place at `<name>.<gateway.domain>` (its own subdomai
```yaml
name: my-app
substrate: supabase # the shared castle service this app deploys against
substrate: supabase # the shared wildpc service this app deploys against
auth: public # public | private | shared: [handles]
schema: my_app # this app's isolated Postgres schema (frontend: db.schema)
```
## Migrations
`migrations/*.sql` are **numbered, forward-only, and idempotent**. `castle program
`migrations/*.sql` are **numbered, forward-only, and idempotent**. `wildpc program
build my-app` runs the versioned migration runner: it creates + grants the app's
schema, ensures a per-app `<schema>.schema_migrations` table, reads applied
versions, and applies only the **unapplied** files (in filename order) with
@@ -112,14 +112,14 @@ const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: SCHEMA
### Teardown
An app's rows live only on the substrate, so an ordinary `castle delete my-app`
An app's rows live only on the substrate, so an ordinary `wildpc delete my-app`
leaves the schema intact (and says so). To destroy the data too:
```bash
castle delete my-app --purge-data # drop schema my_app cascade
wildpc delete my-app --purge-data # drop schema my_app cascade
```
`castle apply` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the
`wildpc apply` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the
`supabase` service** for PostgREST to pick up the added/removed schema list.
## Auth, RLS & the three privacy layers
@@ -158,17 +158,17 @@ service itself is likewise at `supabase.<gateway.domain>`.) See
## Commands
```bash
castle program create my-app --stack supabase --description "..." # scaffold + register
castle program build my-app # apply unapplied migrations to the substrate
castle program test my-app # deno test over functions/ (if deno present)
castle apply # serve the static UI at <name>.<gateway.domain>
wildpc program create my-app --stack supabase --description "..." # scaffold + register
wildpc program build my-app # apply unapplied migrations to the substrate
wildpc program test my-app # deno test over functions/ (if deno present)
wildpc apply # serve the static UI at <name>.<gateway.domain>
```
## Scaffolding
`castle program create --stack supabase` generates the full layout above and
`wildpc program create --stack supabase` generates the full layout above and
registers the program as a static frontend. Set the anon key in `public/config.js`
(`cat ~/.castle/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build.
(`cat ~/.wildpc/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build.
See @docs/registry.md for the `compose` launcher, the substrate deployment definition,
and the full registry reference. The substrate itself lives in the

View File

@@ -1,9 +1,9 @@
# TCP exposure & castle-managed TLS material
# TCP exposure & wildpc-managed TLS material
> Status: **design / not yet implemented.** This specs the `reach` exposure
> ladder, raw-TCP service exposure (postgres, redis, …), and how castle
> ladder, raw-TCP service exposure (postgres, redis, …), and how wildpc
> materializes its ACME wildcard cert onto a service so a raw port presents a
> *trusted* cert — generically, with **no protocol knowledge in castle's code**.
> *trusted* cert — generically, with **no protocol knowledge in wildpc's code**.
Read `docs/dns-and-tls.md` first — this builds on the acme wildcard model.
@@ -17,14 +17,14 @@ protocols either send a cleartext preamble before any ClientHello (postgres,
mysql) so there's no SNI to route on, or they simply aren't HTTP. But they don't
*need* the gateway: the wildcard DNS record already points **every** subdomain at
the node, and each service already has its own port. So "expose a TCP service
internally" reduces to two things castle can do without a proxy:
internally" reduces to two things wildpc can do without a proxy:
1. **bind the port** on the LAN, and
2. **put a trusted cert on the service** — the one wildcard cert we already own,
which is valid for `<name>.<domain>` (wildcards match).
The only per-service variation is *what file format the cert takes* and *how the
service is told to re-read it*. Both are pushed into the deployment yaml. Castle
service is told to re-read it*. Both are pushed into the deployment yaml. Wild PC
stays a cert-mover and a signal-sender.
Public raw-TCP is a separate, Cloudflare-edge concern — see §6.
@@ -47,8 +47,8 @@ says *what it speaks*.
### Legacy mapping (back-compat normalizer)
`castle` accepts the old fields and normalizes them so existing yamls keep
working; `castle apply` can rewrite them on next write:
`wildpc` accepts the old fields and normalizes them so existing yamls keep
working; `wildpc apply` can rewrite them on next write:
| old | new |
|-----|-----|
@@ -59,7 +59,7 @@ working; `castle apply` can rewrite them on next write:
---
## 3. Manifest models (`core/src/castle_core/manifest.py`)
## 3. Manifest models (`core/src/wildpc_core/manifest.py`)
```python
class Reach(str, Enum):
@@ -77,7 +77,7 @@ class TlsMaterial(str, Enum):
class TlsSpec(BaseModel):
material: TlsMaterial = TlsMaterial.OFF
# Optional zero-downtime reload argv run after re-materialize on renewal.
# Default: castle restarts the deployment (fine for a ~60-day cadence).
# Default: wildpc restarts the deployment (fine for a ~60-day cadence).
reload: list[str] | None = None
@@ -85,7 +85,7 @@ class TcpExposeSpec(BaseModel):
port: int = Field(ge=1, le=65535)
tls: TlsSpec | None = None # step 3; absent = service does its own TLS
# No bind-host field: 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). castle
# job (a container's run.ports, or a native service binding 0.0.0.0). wildpc
# doesn't rebind it, so a `host:` here would be an ignored (misleading) field.
@@ -121,13 +121,13 @@ old `public: bool` normalizes to `reach`.
> **acme prerequisite:** `tls.material != off` only makes sense when
> `gateway.tls == acme` with a domain — otherwise there's no wildcard cert to
> copy. `castle apply` / `castle doctor` warn if `material` is set without acme.
> copy. `wildpc apply` / `wildpc doctor` warn if `material` is set without acme.
---
## 4. Placeholders (`deploy.py` `_env_context`)
When a deployment declares `expose.tcp.tls.material != off`, castle adds these to
When a deployment declares `expose.tcp.tls.material != off`, wildpc adds these to
the placeholder context (alongside the existing `${port}`/`${data_dir}`/… set),
pointing at the materialized copies under `data_dir/tls/`:
@@ -143,7 +143,7 @@ They resolve through the one shared `${...}` resolver (`resolve_placeholders`,
which `resolve_env_split` also uses) — for a container these `${key}` refs are
expanded in `run.env`, `run.volumes`, `run.args`, `run.command`, `run.workdir`,
`run.user`, and `run.tmpfs` alike. To pass a **literal** `${key}` through to the
container's own shell/env (rather than have castle expand it), write `$${key}`
container's own shell/env (rather than have wildpc expand it), write `$${key}`
(docker-compose-style `$$` escape).
**Native service** (python/command launcher) references the host paths directly:
@@ -168,12 +168,12 @@ expose:
tcp: { port: 5432, tls: { material: pair } }
run:
launcher: container
user: ${uid}:${gid} # default for castle containers — see below
user: ${uid}:${gid} # default for wildpc containers — see below
image: postgres:17
ports: { '5432': 5432 }
tmpfs: [/var/run/postgresql] # image runtime dir, needs to be writable by our uid
volumes:
- /data/castle/postgres/data:/var/lib/postgresql/data
- /data/wildpc/postgres/data:/var/lib/postgresql/data
- ${tls_dir}:/tls:ro
args: ['-c','ssl=on','-c','ssl_cert_file=/tls/cert.pem','-c','ssl_key_file=/tls/key.pem']
```
@@ -181,10 +181,10 @@ run:
### Container key ownership — dissolved by uid uniformity (verified)
The naive problem: postgres refuses a key that isn't `0600` **and owned by the
process uid** (999 in the stock image), while castle writes files as the host
process uid** (999 in the stock image), while wildpc writes files as the host
user (1000), and bind mounts preserve host uid — so 999 can't read them, and
chowning across uids needs privilege. Rather than patch that with a privileged
chown, castle **runs containers as the invoking user** (`--user ${uid}:${gid}`,
chown, wildpc **runs containers as the invoking user** (`--user ${uid}:${gid}`,
the default on `RunContainer`, overridable per deployment). Then the process, its
data dir, its secrets *and* its certs are all one uid — nothing to chown, for any
service. Verified end-to-end against `postgres:17`:
@@ -196,8 +196,8 @@ docker run --user 1000:1000 --tmpfs /var/run/postgresql \
```
The only image-specific detail is a writable runtime dir (`--tmpfs
/var/run/postgresql`) — declared in the deployment yaml, not castle. `RunContainer`
gains a `user: str = "${uid}:${gid}"` field and a `tmpfs: list[str]`; castle stays
/var/run/postgresql`) — declared in the deployment yaml, not wildpc. `RunContainer`
gains a `user: str = "${uid}:${gid}"` field and a `tmpfs: list[str]`; wildpc stays
privilege-free. (One-time migration cost: an existing data dir owned by 999 gets
chowned to the runtime uid once when a service adopts this — not per-renewal.)
@@ -205,14 +205,14 @@ chowned to the runtime uid once when a service adopts this — not per-renewal.)
## 5. Cert materialization + the `cert_obtained` hook
### The materializer (`castle_core`, protocol-agnostic)
### The materializer (`wildpc_core`, protocol-agnostic)
`materialize_tls(config, name, dep) -> bool` — for one deployment with
`tls.material != off`:
1. Locate the source wildcard in Caddy's store by glob (prefer prod over staging):
`~/.local/share/caddy/certificates/acme-v02*-directory/wildcard_.domain/wildcard_.domain.{crt,key}`
(falls back to `acme-staging-v02*` when `CASTLE_ACME_STAGING=1`).
(falls back to `acme-staging-v02*` when `WILDPC_ACME_STAGING=1`).
2. Compare a hash of the source to the materialized copy; **return False if
unchanged** (idempotent — safe to call every apply and every renewal).
3. Write `data_dir/tls/` in the requested format:
@@ -226,12 +226,12 @@ chowned to the runtime uid once when a service adopts this — not per-renewal.)
5. Return True (changed).
`reconcile_tls(config)` — walk all deployments; `materialize_tls` each; for those
that changed, run `tls.reload` argv **or** `castle restart name`. Idempotent;
that changed, run `tls.reload` argv **or** `wildpc restart name`. Idempotent;
prints a summary.
### Wiring
- **`castle apply`** calls `materialize_tls` for each affected deployment before
- **`wildpc apply`** calls `materialize_tls` for each affected deployment before
(re)starting it — so first deploy has certs in place.
- **Event-driven (the hook you wanted) — VERIFIED.** Build Caddy with
`github.com/mholt/caddy-events-exec` (add the `--with` to `install.sh`'s xcaddy
@@ -241,12 +241,12 @@ prints a summary.
email …
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
events {
on cert_obtained exec castle tls reconcile
on cert_obtained exec wildpc tls reconcile
}
}
```
Caddy fires `cert_obtained` on every issuance **and renewal** (renewal reuses
the same event with `renewal: true`); the handler runs `castle tls reconcile`,
the same event with `renewal: true`); the handler runs `wildpc tls reconcile`,
which re-copies the rotated wildcard and reloads consumers. Immediate, no polling.
> **Proven end-to-end** (2026-07-03): built into our Caddy 2.11.4, an
> `on cert_obtained exec` handler fired on internal-CA issuance with event data
@@ -254,15 +254,15 @@ prints a summary.
> emit is issuer-agnostic (CertMagic `config.go`), acme renewal fires it identically.
> Two caveats from the test: (a) the module is **experimental** — pin the xcaddy
> build and re-verify on Caddy upgrades; (b) `exec` runs the command in the
> **background and swallows its exit code** — so `castle tls reconcile` must be
> **background and swallows its exit code** — so `wildpc tls reconcile` must be
> idempotent and log its *own* outcome (don't rely on Caddy surfacing failures).
- **Safety net:** a nightly `castle-tls-reconcile` job (`manager: systemd` +
`schedule`) also runs `castle tls reconcile` — catches a missed event, a Caddy
- **Safety net:** a nightly `wildpc-tls-reconcile` job (`manager: systemd` +
`schedule`) also runs `wildpc tls reconcile` — catches a missed event, a Caddy
restart, or a manual cert swap. Same idempotent call, so belt-and-suspenders is
free.
New CLI: `castle tls reconcile [--plan]` (thin wrapper over `reconcile_tls`), and
`castle tls status` to show, per deployment, source vs materialized cert fingerprint
New CLI: `wildpc tls reconcile [--plan]` (thin wrapper over `reconcile_tls`), and
`wildpc tls status` to show, per deployment, source vs materialized cert fingerprint
+ expiry.
---
@@ -275,8 +275,8 @@ Public raw-TCP can't reach an unmodified client below Cloudflare Enterprise
- `tunnel.py` emits an extra ingress entry per public TCP deployment:
`{ hostname: name.public_domain, service: tcp://localhost:port }`.
- castle ensures a self-hosted **Access** app + policy over that hostname.
- `castle` prints the client connect line:
- wildpc ensures a self-hosted **Access** app + policy over that hostname.
- `wildpc` prints the client connect line:
`cloudflared access tcp --hostname name.public_domain --url localhost:local`.
The client runs `cloudflared access tcp` (or WARP private-network) and points its
@@ -287,7 +287,7 @@ authenticated tunnel, never an open port. Build this only when wanted.
## 7. Generality check
Same castle code, different yaml — the postgres-ness is two env/arg mappings + a
Same wildpc code, different yaml — the postgres-ness is two env/arg mappings + a
format choice, nothing more:
| service | `material` | consumes | reload |
@@ -313,7 +313,7 @@ format choice, nothing more:
works, service does its own TLS.~~ **DONE (2026-07-03).** `TcpExposeSpec` +
`ExposeSpec` one-protocol validator; `http_exposed`/`tcp_port` predicates so a
TCP `reach: internal` yields **no** Caddy route (correctness); registry carries
`tcp_port`; `castle service info` shows the `<name>.<domain>:<port>` endpoint;
`tcp_port`; `wildpc service info` shows the `<name>.<domain>:<port>` endpoint;
public-TCP guarded (step 5). Applied to `postgres` — verified: `psql -h
postgres.civil.payne.io` connects by name, postgres absent from HTTP routes,
`apply --plan` still converged.
@@ -321,22 +321,22 @@ format choice, nothing more:
`TlsSpec`/`TlsMaterial`; `${tls_dir|cert|key|pem|ca}` + `${uid}/${gid}`
placeholders; `RunContainer.user`/`tmpfs` (uid-uniformity, no chown);
`core/tls.py` (`wildcard_cert`/`materialize_tls`/`materialize_all`); wired into
`castle apply`; unit-tested in isolation (pair/combined/idempotency/stale-clean).
4. **`cert_obtained` hook + `castle tls`** — **CODE DONE (2026-07-03).** Generator
emits the `events { on cert_obtained exec castle tls reconcile }` block, **gated
on `CASTLE_CADDY_CERT_HOOK=1`** so the current (no-plugin) gateway is unaffected;
`castle tls reconcile|status` CLI; `reconcile_tls` idempotent + self-logging.
`wildpc apply`; unit-tested in isolation (pair/combined/idempotency/stale-clean).
4. **`cert_obtained` hook + `wildpc tls`** — **CODE DONE (2026-07-03).** Generator
emits the `events { on cert_obtained exec wildpc tls reconcile }` block, **gated
on `WILDPC_CADDY_CERT_HOOK=1`** so the current (no-plugin) gateway is unaffected;
`wildpc tls reconcile|status` CLI; `reconcile_tls` idempotent + self-logging.
*(Nightly safety-net job: TODO.)*
→ **LIVE ROLLOUT DONE (2026-07-03).** (a) `/usr/local/bin/caddy` rebuilt with
cloudflare-dns + `caddy-events-exec` (old binary → `caddy.bak-pre-events`;
`install.sh` bakes both plugins). (b) Gate is a durable **`gateway.cert_hook`**
config flag (not an env var) → NodeConfig → generator; set true, `apply`'d; the
running gateway's admin API confirms the `cert_obtained → castle tls reconcile`
running gateway's admin API confirms the `cert_obtained → wildpc tls reconcile`
subscription is live. (c) postgres enabled: `user: ${uid}:${gid}` + `tmpfs` +
`tls.material: pair`; data dir chowned 999→1000 once (cold backup at
`/data/castle/postgres-data-backup-pre-tls.tar.gz`); WAL-recovered clean.
`/data/wildpc/postgres-data-backup-pre-tls.tar.gz`); WAL-recovered clean.
**Verified:** `psql -h postgres.civil.payne.io sslmode=verify-full sslrootcert=system`
→ "verify-full OK" against the trusted LE wildcard; `castle` + `litellm` DBs intact.
→ "verify-full OK" against the trusted LE wildcard; `wildpc` + `litellm` DBs intact.
Also landed: `${uid}/${gid}` in the placeholder context + placeholder expansion
in container run fields (`volumes`/`args`/`user`/`tmpfs`) so `${tls_dir}` mounts work.
5. **`reach: public` for TCP** (tunnel `tcp://` + Access + connect line) — when

View File

@@ -1,6 +1,6 @@
# Public exposure via Cloudflare Tunnel
Castle is LAN-only by default: services with `proxy: true` are reachable at
Wild PC is LAN-only by default: services with `proxy: true` are reachable at
`<name>.<gateway.domain>` (e.g. `<name>.civil.payne.io`) through split DNS, and
nothing is on the public internet. This document sets up a **per-service public
toggle** — flip `public: true` on a service and it's also reachable from the public
@@ -17,9 +17,9 @@ internet at `<name>.<gateway.public_domain>`, via a Cloudflare Tunnel.
Cloudflare (no inbound holes, no public IP needed) and forwards each public
hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the
*internal* name so Caddy routes it and its wildcard cert validates. The public
surface is exactly the set of `public: true` services — `castle apply` generates
surface is exactly the set of `public: true` services — `wildpc apply` generates
the ingress from the registry.
- **One kill switch.** Stop `castle-tunnel` → instantly nothing is public.
- **One kill switch.** Stop `wildpc-tunnel` → instantly nothing is public.
```
foo.pub.payne.io ──(Cloudflare edge, public cert)──▶ cloudflared (outbound)
@@ -39,22 +39,22 @@ sudo apt-get update && sudo apt-get install -y cloudflared
# 2. Authenticate and create the tunnel (opens a browser to pick the zone)
cloudflared tunnel login
cloudflared tunnel create castle # prints a tunnel UUID + writes creds JSON
cloudflared tunnel create wildpc # prints a tunnel UUID + writes creds JSON
# 3. Move the credentials into Castle's secret store (path the generator expects)
mkdir -p ~/.castle/secrets/cloudflared
# 3. Move the credentials into Wild PC's secret store (path the generator expects)
mkdir -p ~/.wildpc/secrets/cloudflared
TID=<the-uuid-from-step-2>
mv ~/.cloudflared/$TID.json ~/.castle/secrets/cloudflared/$TID.json
chmod 600 ~/.castle/secrets/cloudflared/$TID.json
mv ~/.cloudflared/$TID.json ~/.wildpc/secrets/cloudflared/$TID.json
chmod 600 ~/.wildpc/secrets/cloudflared/$TID.json
# 4. Tell Castle the public zone + tunnel id (in ~/.castle/castle.yaml)
# 4. Tell Wild PC the public zone + tunnel id (in ~/.wildpc/wildpc.yaml)
# gateway:
# ...
# public_domain: pub.payne.io
# tunnel_id: <the-uuid>
```
Then create the tunnel deployment at `~/.castle/deployments/castle-tunnel.yaml`
Then create the tunnel deployment at `~/.wildpc/deployments/wildpc-tunnel.yaml`
(`manager: systemd` → kind: service):
```yaml
@@ -67,7 +67,7 @@ run:
- tunnel
- --no-autoupdate
- --config
- /home/payne/.castle/artifacts/specs/cloudflared.yml
- /home/payne/.wildpc/artifacts/specs/cloudflared.yml
- run
manage:
systemd: {}
@@ -76,8 +76,8 @@ manage:
Bring it online:
```bash
castle apply # writes cloudflared.yml from public services
castle apply castle-tunnel # start the tunnel
wildpc apply # writes cloudflared.yml from public services
wildpc apply wildpc-tunnel # start the tunnel
```
## Using the toggle
@@ -92,18 +92,18 @@ public: true # also expose at <name>.pub.payne.io via the tunnel
Then just deploy:
```bash
castle apply
wildpc apply
```
`castle apply` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the
current set of public services and restarts `castle-tunnel`. Flip `public` back to
`wildpc apply` regenerates `~/.wildpc/artifacts/specs/cloudflared.yml` from the
current set of public services and restarts `wildpc-tunnel`. Flip `public` back to
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the
ingress immediately.
### DNS: automatic (with a token) or manual
Each public host needs a CNAME `<name>.pub.payne.io → <tunnel>.cfargotunnel.com`.
Castle can manage these for you. Create a Cloudflare API token with a single
Wild PC can manage these for you. Create a Cloudflare API token with a single
**`DNS:Edit`** permission scoped to the **public zone** — this is exactly
Cloudflare's built-in *"Edit zone DNS"* template. That one permission is
sufficient: it resolves the zone by name *and* creates/deletes records (no
@@ -112,18 +112,18 @@ token (`cfat_…`) works (that's what this was confirmed with). Drop it into the
secret store:
```bash
printf %s "<token>" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
printf %s "<token>" > ~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.wildpc/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
```
With it present, every `castle apply` **reconciles** the public CNAMEs against the
With it present, every `wildpc apply` **reconciles** the public CNAMEs against the
current public set — creating missing ones and deleting removed ones — and prints a
one-line summary. It only ever touches records pointing at *this* tunnel
(`<tunnel_id>.cfargotunnel.com`), so hand-managed records in the same zone are safe.
This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the
internal zone); the public zone is usually a different zone/account.
Without the token, `castle apply` instead prints the exact command to run per host:
Without the token, `wildpc apply` instead prints the exact command to run per host:
```bash
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io