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:
196
docs/design.md
196
docs/design.md
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
202
docs/registry.md
202
docs/registry.md
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user