59 Commits

Author SHA1 Message Date
Paul Payne
05b28cb584 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.
2026-07-18 22:55:08 -07:00
Paul Payne
afc623ec58 Add castle secret CLI and fail-loud unresolved-secret apply gate
Writing a secret by hand meant knowing the active backend and its layout;
getting it wrong (a file write on an OpenBao fleet) left the value where the
resolver never reads it, so ${secret:NAME} silently degraded to the literal
<MISSING_SECRET:NAME> placeholder that a service then used as its credential.

- `castle secret {list|set|get|rm}` reads/writes the ACTIVE backend, so there's
  no wrong store to pick. `set NAME` with no value reads a hidden prompt / stdin.
- `castle apply` (and --plan) now refuses to converge any deployment whose
  ${secret:...} refs don't resolve in the active backend: exits non-zero, writes
  nothing, and names each deployment + secret + the `castle secret set` fix.
- New helpers in core/config.py: active_secret_backend(), active_backend_name(),
  secret_refs(). Gate impl: deploy._unresolved_secrets() + ApplyResult.blocked.
- Tests: test_deploy_secret_gate.py, TestSecretRefs, test_secret.py. Docs: AGENTS.md.
2026-07-14 07:17:17 -07:00
Paul Payne
1767a0a304 Adds custom domains. 2026-07-12 17:29:34 -07:00
Paul Payne
964226d671 Add stack dependency management
Stacks declare host toolchains (uv/pnpm/node/hugo/deno/psql) that can drift
from what's installed and, worse, from what's on a running service's PATH.
Make those dependencies first-class and visible.

Model (core):
- stacks.ToolRequirement + StackHandler.tools declare each stack's toolchains
  (command, purpose, phase, install_hint); tools_for() is the single source.
- relations synthesizes them as `kind: tool` requirements so functional?/graph
  account for them; checked runtime-env-aware (run-phase tools of a systemd
  service are probed against the service's PATH, not the shell) via a shared
  generators.systemd.runtime_path helper the unit generator also uses, so the
  checker can't drift from the generator. hint_for() makes every unmet
  requirement actionable.
- stack_status: the derived per-stack health the CLI/API/UI all render.
- config: add ~/.deno/bin to USER_TOOL_PATH_DIRS so deno (supabase edge fns)
  is found by services and the check, same as ~/.local/bin and the pnpm dirs.

Surfaces:
- castle stack list|info (new resource) + GET /stacks/status, /stacks/{name}
  (GET /stacks stays a bare name list for the create-form select).
- castle doctor gains a "Stacks & dependencies" section (FAIL for an enabled
  deployment's missing tool, WARN otherwise, unused stacks skipped).
- castle apply preflight warns (advisory, like _acme_preflight) when a tool is
  missing where a service runs; ConvergePanel renders those warnings.
- Dashboard Stacks page: per-stack tool checklist with versions + copyable
  install hints, program links, and verb chips.

Tests: relations (drift + hints), doctor (ok/fail/skip), /stacks endpoints.
2026-07-12 16:04:48 -07:00
Paul Payne
a3ff03fc74 app: enhance gateway change detection for Caddyfile routing 2026-07-12 14:09:07 -07:00
Paul Payne
9a8e16143b For real. Tools schema generation and ux tweaks. 2026-07-07 17:51:00 -07:00
Paul Payne
5beebfd739 core: drop pre-migration read-compat now both nodes are current-format
Both machines (civil, primer) are on the current on-disk layout, so the
read-compat for older formats is dead weight. Remove it:

- config loader: only deployments/<kind>/<name>.yaml (drop flat deployments/*.yaml
  and the services/+jobs/ split); drop the run.runner→manager normalizer.
- registry loader: require composite <kind>/<name> keys + manager/kind (registry.yaml
  is regenerated every apply, so it's always current).
- manifest: drop the proxy/public→reach legacy input and the component→program alias
  (the derived proxy/public read accessors stay). reach field defaults already match.
- config_editor: parse incoming specs directly (frontend sends the current shape).
- save_config: drop the flat-file migration cleanup.

Bootstrap seeds move to bootstrap/deployments/<kind>/ and install.sh seeds that
per-kind tree directly. The legacy→current translation for terse test fixtures
moves into each test conftest (out of production). All suites green (362).
2026-07-07 09:16:03 -07:00
Paul Payne
d6f5678948 test: cover the config-editor globals regression + secrets surfaces
- endpoint-level: a deployment/program edit via /config/* leaves castle.yaml
  globals byte-identical (the exact regression that shipped — the endpoints were
  tested but nobody asserted globals survived)
- write_program_file leaves globals untouched (core)
- /secrets/info reports the backend

Still uncovered (tracked): castle mesh CLI, direct-read refactor (dns/stacks),
backend-aware doctor, and all frontend (no test runner configured).
2026-07-07 08:46:38 -07:00
Paul Payne
faa9a99a5a fix(config): scoped (PATCH) writes for deployment/program edits
Root-cause hardening for the dropped-globals bug: config edits went through the
full-document save_config, which rewrote castle.yaml globals (+ every resource
file) on every deployment/program edit — so an unmodeled global (role, secrets)
was dropped.

- add write_deployment_file / write_program_file: persist ONE resource file,
  never touching globals or other resources
- config_editor deployment/program save/delete/enable now use those instead of
  save_config, so a deployment edit can't rewrite castle.yaml
- save_config now preserves ANY unmanaged top-level global (not just secrets)
- _aggregate_yaml surfaces role + secrets (raw-yaml round-trip completeness)
- tests: scoped write leaves globals byte-identical; unmanaged global survives

Verified live: editing lakehouse via the endpoint leaves castle.yaml unchanged.
2026-07-07 08:42:29 -07:00
Paul Payne
1bc41ddd2d fix(config): save_config must round-trip role + secrets block
save_config rewrites castle.yaml from scratch and only re-emitted gateway/repo/
roots — so any config save (dashboard deployment edit, config_editor) silently
STRIPPED top-level 'role' and the 'secrets:' backend block. Effect: the node
reverted to a file-backend follower, the vault stopped resolving, and a
subsequent apply baked <MISSING_SECRET> into service env (hit castle-lakehouse).

Now re-emit role (from config) + preserve the secrets block (read from the
existing file, since it's not modeled on CastleConfig). Regression test added.
2026-07-07 08:22:28 -07:00
Paul Payne
5949543ace feat(app): Secrets management page + backend-aware API
- new Secrets page (/secrets, nav entry): lists vault secrets, reveal/copy,
  add/edit/delete, shows active backend + addr + role; read-only on followers
- GET /secrets/info (backend/addr/role/writable); fix /secrets CRUD to use the
  castle.yaml-selected backend (was defaulting to the now-empty file backend)
- OpenBaoBackend.list_names drops folder entries (node-prefix groups)
- tests: conftest forces file backend so host castle.yaml can't leak into tests

app builds clean (tsc 0); endpoints verified live against the vault.
2026-07-07 06:53:16 -07:00
Paul Payne
836b1437ab feat(secrets): OpenBao-only backend via castle.yaml + per-node prefix
- backend selected by castle.yaml 'secrets:' block (env overrides); no file
  fallback in openbao mode (bootstrap token still read from file)
- route direct-read secrets (public DNS token, supabase pw) through read_secret
- OpenBaoBackend node_prefix: read <prefix>/<name> then shared <name>, so a
  shared vault serves per-node overrides (e.g. each node's postgres password)
- tests updated (fallback removed, settings + node_prefix selection)
2026-07-07 06:34:50 -07:00
Paul Payne
b437f71300 feat(secrets): OpenBao write path + auto-unseal on boot (Phase 4 hardening)
- SecretBackend gains write/delete/list_names; FileSecretBackend + OpenBaoBackend
  implement them (vault KV-v2 POST/DELETE/LIST); castle-api/secrets.py routes all
  CRUD through the active backend (dashboard writes to the vault in openbao mode)
- add systemd exec_start_post (general; '-' prefix so a hook failure can't fail
  the unit); castle-openbao runs an unseal.sh on boot using OPENBAO_UNSEAL_KEY
- tests: file write/read/list/delete round-trip

Verified live: write→vault→read→list→delete against OpenBao; seal→restart→
auto-unsealed. TLS hardening remains (deferred; gates cross-network/real secrets).
2026-07-07 05:44:23 -07:00
Paul Payne
526736f778 feat(mesh): cross-node routing + presence breaker (Phase 3)
- NodeConfig.address: routable host peers proxy to (wired registry + wire)
- compute_routes emits 'remote' routes for consumed peer services
  (local requires ref satisfied by an online peer -> <address>:<port>)
- _host_remote_block: fail-fast reverse_proxy (2s dial, passive health);
  presence expiry removes the route entirely = the primary circuit-breaker
- mesh_gateway.py: API re-renders + reloads the Caddyfile on mesh change,
  iff content changed (no-op until a cross-node service is consumed)
- tests: route emit / address fallback / breaker-absent / unconsumed

Logic verified hermetically + against primer's real registry (castle-api ->
primer:9020); live integration proven a no-op on civil.
2026-07-07 05:38:15 -07:00
Paul Payne
f94517887e feat(secrets): pluggable secret backend with OpenBao read (Phase 4)
- secret_backends.py: SecretBackend protocol, FileSecretBackend (historical),
  OpenBaoBackend (KV-v2 read + file fallback), build_backend() env-selected
- _read_secret delegates to the active backend; default is file, so production
  is unchanged until CASTLE_SECRET_BACKEND=openbao
- OpenBao token bootstraps from the file backend; missing/unreachable falls back
- tests: file hit/miss, backend selection, unreachable + empty-token fallback

Read path verified live against castle-openbao. Write path, auto-unseal-on-boot,
and TLS hardening documented as remaining for full OpenBao production use.
2026-07-07 05:08:16 -07:00
Paul Payne
c67c06d8e6 feat(mesh): fleet role + shared config/presence KV (Phase 2)
- add static 'role' (authority|follower) to NodeConfig/CastleConfig, wired from
  castle.yaml through the registry to the mesh wire; civil pinned authority
- castle-presence: TTL KV bucket each node renews (churn signal); delete-on-stop
- castle-config: authority-gated get/put + change-watch SSE (follower reconcile
  hook hangs off it)
- bound the NATS drain on stop() so shutdown can't hang
- tests: role config/registry round-trip, wire round-trip, role write-gating

Single-node verified; follower reconcile pending the second node.
2026-07-07 05:03:07 -07:00
payneio
340f469b3d feat(core): single-source data_dir/repos_dir via castle.yaml
The `castle` CLI and the `castle-api` service are two independent in-process
drivers of `castle_core`. Each resolved DATA_DIR/REPOS_DIR at import time from
its own process env (default /data/castle), persisted nowhere — so they silently
diverged, and `apply`/dashboard-apply crashed on a non-existent /data.

Make the loaded CastleConfig the single source of truth:
- Resolve data_dir/repos_dir only in load_config (env > castle.yaml > default),
  anchored to the config root; drop the DATA_DIR/REPOS_DIR module globals and the
  import-time file read entirely — no global twin that can disagree with the file.
- Thread config.data_dir/repos_dir through ensure_dirs, _env_context, tls_dir_for
  (now unified — deploy no longer inlines the tls path), and create/add/clone.
- ensure_dirs raises an actionable CastleDirError instead of a bare PermissionError;
  the api surfaces it as 422.
- doctor: "data dir writable" check + WARN when CASTLE_DATA_DIR/REPOS_DIR env
  overrides the file (the one remaining cross-process divergence vector).
- install.sh persists data_dir/repos_dir into castle.yaml (idempotent, non-default).
- Docs: registry.md globals + AGENTS.md roots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 23:02:33 -07:00
Paul Payne
bbd5590742 fix(core): audit matches split *_HOST/*_PORT env pairs
The consumption audit only matched host:port inside a single value (a URL,
DATABASE_URL), missing the very common split-var shape (X_HOST=localhost +
X_PORT=1883, e.g. castle-api's mqtt config). Resolve those pairs too — factored
into a shared _resolve helper. A bare *_PORT with no *_HOST is ignored so a
deployment's own listen port isn't mistaken for a dependency. Adds test_audit.py.
2026-07-06 16:57:29 -07:00
Paul Payne
e438f45b54 feat: deployment-scoped requires + UI editors, gateway nav, services filter
Model:
- Drop `requires` from ProgramSpec; requires is now deployment-only.
  Collapse Requirement to {ref, bind} with kind defaulting to "deployment"
  (drop the unused `version`). System-package preconditions stay on the
  program as `system_dependencies`, synthesized into {kind: system} for the
  functional check. relations.py/deploy.py read only the deployment's requires.
- create.py seeds a stack's substrate dependency onto the deployment, not the
  program. Docs + tests updated.

UI:
- Add a `requires` editor (useRequires) on service, job, and static detail
  pages — edit service→service deps (ref + optional bind env var).
- Restore the gateway route table "Open" column: a kind-aware in-app link to
  each route's deployment.
- Reformat the reach control: name baked into each option
  (localhost:PORT (local) / <name>.<domain> (internal) / <name>.<pubdomain> (public)).
- Services page: sort statics in with systemd services by name, add a
  search + kind-filter bar mirroring the programs page.
2026-07-06 04:00:24 -07:00
Paul Payne
c5cf3a1561 feat(core): kind-scoped deployment identity (name, kind)
A deployment's identity becomes (name, kind), so a tool, service, and job can
share a name (a `backup` trio). Kind is structural — which per-kind store / dir —
not a derived discriminator threaded around.

- CastleConfig holds per-kind stores (services/jobs/tools/statics/references);
  all_deployments()/deployments_named()/deployment(kind,name) replace the single
  flat `deployments` dict. A construction-only `deployments` InitVar routes a flat
  map into the stores by kind_for.
- Storage: deployments/<store>/<name>.yaml (per-kind dirs), with read-compat for
  the old flat + services/+jobs/ layouts.
- Registry keyed by composite "<kind>/<name>" + name field; get()/all()/named()/put().
- Job units carry a -job marker (castle-<name>-job.{service,timer}); services and
  everything else unchanged — so a service and a job can coexist.
- deploy/apply/lifecycle thread kind through unit naming, activate/deactivate,
  is_active; caddyfile/tunnel/relations iterate via all_deployments/registry.all.
- Subdomain uniqueness validated across kinds (a service and a static can't share one).

182 core tests pass, incl. a new same-name-trio suite (round-trip, distinct units,
subdomain guard). API/CLI/UI updated in follow-up phases.
2026-07-06 02:25:05 -07:00
Paul Payne
07c02aa151 fix(api): editable-spec detail + PATCH-merge saves + shared gateway parser
Deployment/program edits could silently drop spec fields:

- GET /deployments/{name} served the runtime view (no reach/program/root/expose)
  for deployed deployments, so the dashboard round-tripped a lossy manifest and
  stripped fields on save (broke astro). Now serves the editable castle.yaml spec
  when the deployment is in config.
- _save_deployment and save_program replaced the whole object with client input.
  Now shallow-merge over the existing spec (omit = preserve, null = clear), so a
  partial/lossy save can't drop untouched fields.
- save_yaml rebuilt GatewayConfig from `port` only, wiping tls/domain/tunnel/
  cert_hook on a whole-file save. Now uses a shared parse_gateway() (also used by
  load_config) so gateway fields can't drift between the two.

Dashboard forms (ServiceFields/StaticFields) send explicit null to clear, per the
merge contract; adds exposure host-label helpers. Coverage: detail-serves-spec,
save round-trip, partial-patch-preserves, null-clears, program partial-patch,
parse_gateway, and config save/load round-trip.
2026-07-05 23:23:59 -07:00
payneio
eeaa65f7ce feat: resolve a program's pinned node version for build + runtime
Frontend builds triggered from the castle web app run inside the castle-api
systemd service, whose PATH omits nvm's versioned node dir — so `pnpm build`
died with `node: not found` even though it worked from an interactive shell.

Introduce a per-program node convention: a program declares its version the
ecosystem-standard way (.node-version / .nvmrc / package.json engines.node),
and castle_core.toolchains.resolve_node_bin() maps it to a concrete nvm bin dir
(CASTLE_NODE_VERSIONS_DIR, default ~/.nvm/versions/node; newest match wins).
The same resolver feeds both sites that run a program's node:

- build time: stacks._build_env() prepends the pinned node for the dev-verb
  subprocess (keyed on the source dir), so `castle program build` uses the
  program's node regardless of caller.
- run time: deploy._build_deployed() stores it in Deployment.path_prepend,
  which the systemd generator puts ahead of the default unit PATH — so a
  `launcher: node` service runs its program's node.

A pinned-but-uninstalled version fails loud with an `nvm install` hint instead
of a cryptic `node: not found`. Unpinned → no injection (no guessing).

Also: the systemd generator now honors an explicit PATH in defaults.env as a
full override instead of clobbering it with a trailing Environment=PATH line
(systemd's last-assignment-wins rule had silently defeated the documented
escape hatch).

Pins app/.node-version and documents the convention in the react-vite stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:17:23 -07:00
Paul Payne
b9d38be707 fix: system requirement check verifies the package, not a PATH command
`system_dependencies` holds apt PACKAGE names; checking `which <name>` only
coincides with "installed" when the name equals its command (pandoc, rsync). Names
like poppler-utils / texlive-latex-base / docker-compose-plugin have no binary of
that name, so the functional? predicate wrongly reported them unmet. Fall back to
`dpkg -s` (the real "installed" check) after the PATH fast-path.
2026-07-05 15:17:03 -07:00
Paul Payne
add356dcf2 feat: relationship model (requires/repos/predicates) + git sync
A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.

Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
  precondition relation; `system_dependencies` is its `{kind: system}` alias.
  kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
  predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
  consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.

Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
  a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
  monorepo banner on Programs.

Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
2026-07-05 10:55:42 -07:00
Paul Payne
3c566540aa feat: raw-TCP exposure with castle-managed TLS + reach model
Expose raw-TCP services (postgres) by name at <name>.<domain>:<port> and
cut the gateway's ACME wildcard cert onto them so they present a trusted
cert. Replaces the proxy/public booleans with a single `reach` enum
(off|internal|public); legacy input still parses via derived accessors.

Core:
- expose.tcp{port,tls} + TlsSpec(material: pair|combined|off, reload)
- tls.py: materialize cert files from Caddy's wildcard, reconcile on
  renewal; `castle tls` CLI; optional cert_obtained events-exec hook
  (gateway.cert_hook, gated so a plugin-less Caddy still parses)
- apply waits (bounded) for the wildcard to issue before materializing so
  a fresh-node TLS service starts with its cert in place, then scopes
  materialization to the deployments being applied
- reach: internal|public requires an expose block (no silent no-op);
  public raw-TCP guarded until tunnel support lands
- chain.pem (${tls_ca}) is the issuer chain (leaf stripped), a real CA
  bundle distinct from cert.pem
- one shared ${...} resolver (resolve_placeholders) for env and container
  run fields; run.env now expands like volumes/args; $$ escapes a literal
- validate the generated Caddyfile before reloading the gateway so an
  invalid config never degrades routing

Docs: docs/tcp-exposure.md. Tests cover reach/expose validation,
placeholder expansion + escape, issuer-chain material, TLS materialize.
2026-07-04 23:04:26 -07:00
Paul Payne
d0a206f7d6 core+cli: castle apply — one converge verb replaces deploy/start/enable/…
Reconcile the running system to declared config in a single operation,
replacing the old deploy && start plus the per-kind enable/disable/
install/uninstall verbs. The mechanism varies by manager (systemd unit /
PATH install / gateway route); the verb never does.

Core (castle_core.deploy.apply):
- render (units, Caddyfile, tunnel) then reconcile: activate every
  enabled deployment that's down, restart any whose unit bytes changed,
  deactivate the disabled ones. Returns ApplyResult (the enacted diff).
- --plan computes the diff and touches nothing.
- restart-on-change is exact: _render_unit_files is the single source of
  truth for unit bytes, used both to write units and to predict restarts,
  so the plan can't drift from what deploy writes.

Desired state:
- DeploymentBase.enabled (default True) — the declarative on/off. apply
  converges to it; a disabled deployment is defined but not running and
  gets no gateway route (else it would 502). Registry omits enabled when
  True, keeping existing registries byte-identical.

CLI:
- add `castle apply [name] [--plan]`; keep `restart` as the one
  imperative bounce, plus status/doctor/logs/gateway/program.
- retire deploy, start, stop (top-level); service/job deploy|enable|
  disable|start|stop; tool install|uninstall; program install|uninstall.
- next-step strings + doctor hints now say `castle apply`.

Verified: core 129 + cli 31 green; live `castle apply` on a converged
node reports "nothing to do" with no spurious restarts; --plan shows an
empty diff; gateway + api stay up.
2026-07-02 11:35:34 -07:00
Paul Payne
10a86d0b6f kind is a deployment property, not a program property
A program has no single kind — it HAS deployments, each with its own kind (a
program can be a tool AND a job, e.g. protonmail). Remove program-level kind:

- core: drop ProgramSpec.kind; CastleConfig.kind_of → deployments_of(name) →
  [(deployment-name, kind)]; tools property derives from a tool deployment.
- api: ProgramSummary drops kind/services/jobs → deployments: [{name, kind}];
  /programs?kind= filters by deployment-kind membership; a program's legacy
  DeploymentSummary carries kind=None.
- cli: list/info show a program's set of deployment kinds; --kind filters by
  membership.

Also: Services page now covers statics — /services returns kind in
{service, static} (both are exposed, URL-reachable 'services', caddy vs systemd),
and ServiceSummary gains kind + manager to distinguish them.

Suites: core 124, cli 25, castle-api 58.
2026-07-01 12:25:54 -07:00
Paul Payne
317232ca6a Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
2026-07-01 10:23:03 -07:00
Paul Payne
fc83ce40c1 Public toggle composes for static frontends; document the deployment model
- A static service is inherently exposed, so `public: true` no longer trips the
  "requires proxy" validator — frontends can be public via the tunnel for free
  (the original friction that started this refactor, now resolved).
- docs/registry.md: the manager model (systemd/caddy/path/none), the runner→manager
  table incl. static/path, and behavior as a derived display label.
2026-07-01 07:01:37 -07:00
Paul Payne
7eca3cc217 Tools as PATH deployments; demote behavior from lifecycle logic
- manifest: RunPath (runner: path) — a CLI on PATH via `uv tool install`, manager
  = path, no unit/route (like remote/static).
- lifecycle/service: a path deployment's start/stop = install/uninstall (routed
  through activate/deactivate, which skip the reinstall when already on PATH).
  `castle service start/stop <tool>` and status now dispatch by manager; status
  shows the manager tag [systemd|caddy|path|none].
- config.tools() now derives from `runner: path` services, not the behavior label.
- castle-api: the "installed" flag derives from manager==path, not behavior==tool.
- create: a python-cli stack emits a `runner: path` service.

`behavior` no longer drives any runtime logic — it survives only as a display
label + `list --behavior` filter. Verified live: html2text uninstall→reinstall
round-trip via `castle service stop/start`.
2026-07-01 06:59:40 -07:00
Paul Payne
57861e58c1 Manager abstraction + manager-dispatched lifecycle; frontends create static services
- manifest: `manager_for(runner)` — the single source of truth mapping a runner to
  its manager (systemd | caddy | path | none). deploy's `managed` now derives from it.
- lifecycle: is_active/activate/deactivate dispatch by manager instead of behavior
  (removed the stale `_is_static_frontend`, which broke once frontends became
  services). caddy → gateway reload; path → install/uninstall; none → no-op.
- cli/service: start/stop/restart and enable/disable route through the manager
  (systemctl only for systemd; static reloads the gateway; remote is a no-op);
  platform restart skips non-systemd runners.
- create: a frontend stack (react-vite/supabase) now emits a `runner: static`
  service, not a bare frontend program.
2026-07-01 06:51:31 -07:00
Paul Payne
5f6afbf007 Checkpoint: Cloudflare tunnel + public toggle, and static-runner frontends
Two features built this session, committed together as a checkpoint before the
deployment-model refactor (they share files, so kept as one rollback point):

- Public exposure via Cloudflare tunnel: a `public` toggle on services (requires
  proxy), gateway.public_domain/tunnel_id config, a cloudflared ingress generator
  (generators/tunnel.py) that maps <name>.<public_domain> to the gateway's
  internal host, deploy wiring, and docs/tunnel-setup.md.
- Frontends as `static`-runner services: a RunStatic runner served by Caddy
  (file_server), no systemd unit (like `remote`); the Caddyfile generator now
  derives static routes from services instead of a behavior==frontend branch.
2026-07-01 06:44:23 -07:00
Paul Payne
770574f575 Flatten proxy config to a bool (drop the caddy key)
CaddySpec had only `enable` left (path_prefix/host removed; extra_snippets was
unused), so `proxy: { caddy: {} }` was three levels of nesting for a checkbox —
and it invited drift (supabase silently reverted to proxy.caddy.host). Replace it
with a plain `ServiceSpec.proxy: bool`.

- Model: remove ProxySpec/CaddySpec; `proxy: bool = False`. Readers simplified to
  `bool(svc.proxy)` (generator, castle-api summaries, CLI info).
- CLI create / dashboard editor + create form: write `proxy: true` / a checkbox.
- Configs migrated to `proxy: true` (and supabase's stale proxy.caddy.host block
  removed).
- Docs (registry, dns-and-tls, design, stack guides) + tests/fixtures updated;
  also fixed lingering path-model staleness in the stack guides.
2026-06-30 21:23:14 -07:00
Paul Payne
4ff6af8f4d Rename the dashboard program castle-app -> castle
The dashboard is now served at castle.<domain> (was castle-app.<domain>). Since
the subdomain is the program name, this is just a program rename plus the one
special-case constant.

- Generator: _DASHBOARD = "castle" (the :9000 redirect target and off-mode root).
- Program file renamed (runtime): programs/castle-app.yaml -> programs/castle.yaml
  (source unchanged: repo:app).
- Dashboard UI: ProgramDetail shows a frontend's subdomain (via subdomainUrl),
  dropping the old castle-app-at-root special-case; comment refs updated.
- Docs: registry.md/design.md/react-vite.md updated to the subdomain model and the
  `castle` name (react-vite.md also corrected — frontends serve at their subdomain
  root with VITE_BASE=/, and builds are manual via `castle program build`, not
  deploy).
2026-06-30 21:04:18 -07:00
Paul Payne
43ef1cc588 Subdomain-only gateway routing; drop path prefixes
Collapse the two proxy shapes (path_prefix + host) to a single checkbox: a
service is either port-only, or exposed at <service-name>.<gateway.domain>. Path
routes and their prefix-stripping foot-guns are gone; the subdomain is always the
service name (rename the service to change it).

- Schema: CaddySpec is just `enable` (presence = expose). service_proxy_targets
  returns (expose, port, base_url); Deployment carries `subdomain` (was
  proxy_path/proxy_host). Registry/deploy/mesh/CLI updated in lockstep.
- Generator: compute_routes emits one host route per exposed service/frontend
  (address = name). acme mode = one *.<domain> site with a reverse_proxy matcher
  per service + a file_server matcher per frontend; the :<port> site redirects to
  the dashboard. off mode = a HTTP control plane on :<port> (dashboard at / +
  /api → castle-api); other services are port-only.
- Dashboard/API: castle-app and castle-api are ordinary subdomains. The dashboard
  derives the API base at runtime (castle-api.<domain> on a subdomain, else /api)
  and calls it cross-origin — castle-api already allows CORS *. Frontends build
  with VITE_BASE=/ (served at their subdomain root).
- CLI: `service create` drops --path/--host; --no-proxy makes it port-only.
- Docs/tests reworked for the model; docs use generic placeholders only (no
  personal host/domain/IP details).
2026-06-30 20:47:03 -07:00
Paul Payne
a022a136fe Retire gateway.tls=internal; host routes use a bare subdomain label
acme mode fully replaced the internal-CA path (which required installing a private
CA on every device — the exact pain, esp. on Android, that acme avoids). Remove
`internal` entirely and simplify how services declare host routes.

- Generator (caddyfile.py): drop the `tls internal` branch — modes are now
  `off` | `acme`. In acme mode the published subdomain is the **first DNS label**
  of `proxy.caddy.host` (a bare `claw`, or a legacy `claw.civil.lan`, both →
  `claw.<domain>`), so services stay domain-agnostic and the declared value is
  authoritative again (no more silent service-name override). Shared
  `_host_matcher_block` reused by off-mode and the acme wildcard site.
- castle-api: delete `GET /gateway/ca.crt`, `_gateway_ca_pem`, `_ca_fingerprint`
  and the now-unused imports; drop `ca_fingerprint` from `GatewayInfo` (keep
  `tls`).
- Dashboard: remove the CA-cert download button + unused imports; drop
  `ca_fingerprint` from the `GatewayInfo` type.
- Tests: replace TestCaddyfileTlsInternal with an off-mode class (keeps the
  runner-agnostic host-route coverage); acme tests assert first-label derivation
  incl. label-wins-over-service-name; drop the castle-api CA-endpoint test.
- Docs: registry.md + dns-and-tls.md — two-mode tables (off|acme), remove the
  internal sections/CA-download, document the bare-label host convention; note a
  domain-less node stays on `off`.
2026-06-30 19:55:17 -07:00
Paul Payne
121f970f14 Add gateway.tls=acme: Let's Encrypt wildcard cert via DNS-01
internal-CA HTTPS forces every device to trust a private root, which breaks down
on some clients (Android browsers). acme mode instead obtains a real Let's Encrypt
wildcard cert (*.<domain>) via a DNS-01 challenge, so browsers trust it with zero
CA install — while services stay internal-only (no inbound exposure, no public A
records; only a transient _acme-challenge TXT touches the public zone, and LAN DNS
resolves the names to a private IP).

- Config: GatewayConfig/NodeConfig gain domain, acme_email, acme_dns_provider
  (plumbed through load/save + registry + deploy, omitted-when-empty for
  round-trip stability). tls stays the discriminator: off | internal | acme.
- Caddyfile generator: acme branch emits a global {email, acme_dns <provider>
  {env.TOKEN}} block + one *.<domain> wildcard site with host matchers derived
  from the service name (<name>.<domain>); path/static stay on :<port>. Shared
  _host_matcher_block helper (reused by off-mode). CASTLE_ACME_STAGING=1 toggles
  the LE staging CA; acme-without-domain falls back to HTTP with a warning.
- deploy(): _acme_preflight warns (never writes) when domain, the gateway-service
  token env, or the CLOUDFLARE_API_TOKEN secret is missing.
- install.sh: opt-in --with-dns-plugin[=provider] builds a DNS-plugin Caddy via
  xcaddy (pinned) to /usr/local/bin/caddy, which the gateway picks up on deploy.
- Tests: TestCaddyfileTlsAcme (global block, wildcard site, derived host matcher,
  path routes on :port, staging toggle, no-domain fallback). Docs: gateway.tls
  modes table + full acme/DNS-01 section (setup, wild-central LAN DNS, staging).
2026-06-30 18:45:13 -07:00
Paul Payne
b726e79c23 Add supabase stack + general compose runner
Adds "a stack whose default is a substrate": a shared self-hosted Supabase
backend plus a `--stack supabase` that scaffolds per-app projects (migrations +
edge functions + static UI) which deploy against it. Apps own their code and
stay repo-durable; only their rows/blobs live on the shared substrate.

- compose runner: new RunCompose supervises a multi-container stack as one
  systemd unit (ExecStart=`compose up`, generated ExecStop=`compose down`);
  secrets via EnvironmentFile. Reusable beyond Supabase. Deployment.stop_cmd
  carries the teardown command through the registry.
- supabase stack: CLI --stack choice, STACK_DEFAULTS→frontend with
  build.outputs=[public], _scaffold_supabase() (migrations/RLS/functions/
  static UI/app manifest), and SupabaseHandler with a forward-only idempotent
  migration runner (plan_migrations + psql build) and deno dev-verbs.
- docs: docs/stacks/supabase.md, registered in CLAUDE.md; compose runner
  documented in registry.md.
- tests: compose run/stop cmd, systemd ExecStop, compose host-route TLS,
  migration planner, supabase verb resolution, create --stack supabase.
2026-06-30 18:18:47 -07:00
Paul Payne
0d9c1ae6c2 Add gateway.tls=internal: HTTPS for host routes via Caddy's local CA
The gateway was HTTP-only by design, but a plain-HTTP origin is not a browser
"secure context", so apps that need WebCrypto (device identity, E2E crypto)
can't run when proxied — only localhost or HTTPS qualifies.

Add an opt-in `gateway.tls: internal`. When set, each host route
(proxy.caddy.host) is emitted as its own `<host> { tls internal; reverse_proxy }`
site, served over HTTPS by Caddy's local CA (Caddy binds :443 and redirects :80).
Path-prefix/static routes stay on the HTTP :<port> site — give a service a host
route to put it on HTTPS. Default (unset/off) keeps the existing HTTP-only
Caddyfile (single :<port> block, auto_https off, host matchers inline).

Plumbing mirrors gateway_port: GatewayConfig.tls (castle.yaml load/save) →
NodeConfig.gateway_tls (registry load/save, set in deploy) → the Caddyfile
generator. Tests cover both modes; docs/registry.md documents the option plus
the two operational requirements (binding 443/80 via the unprivileged-port
sysctl, and trusting Caddy's root CA on clients) and the host-route-vs-prefix
guidance that motivates it.
2026-06-29 08:13:17 -07:00
Paul Payne
b105487790 Fix gateway-route drift when a service is edited
Editing a service's routing (port, path_prefix, host) and then hitting
"apply" or "gateway reload" — rather than a full deploy — left the running
Caddyfile reflecting the old routing. The Caddyfile was generated from the
derived registry.yaml snapshot, which only `deploy()` rebuilds from
castle.yaml; apply/reload regenerated from that stale snapshot.

Make castle.yaml the single source of truth for local routes:

- caddyfile.py: new shared `service_proxy_targets()` derives a service's
  (proxy_path, proxy_host, port, base_url) from its spec. `compute_routes`
  now builds local routes from `config.services` when castle.yaml is
  loadable, falling back to `registry.deployed` only when it isn't. Since
  deploy/apply/reload all funnel through `compute_routes`, every regeneration
  path now tracks the spec. Mesh/remote and static-frontend routes unchanged.
- deploy.py: `_build_deployed_service` uses the same shared deriver, so the
  written registry and the computed routes can't diverge.
- config_editor.py: `/config/apply` now runs a full `deploy()` (rebuilding
  units + Caddyfile and reloading the gateway) before restarting services, so
  changed unit ExecStarts actually take effect. Scheduled jobs are no longer
  restarted on apply. Drops the now-unused direct caddy-reload path.
- Regression tests: a stale registry's old port/path loses to castle.yaml;
  registry fallback still works when config is unavailable.
2026-06-29 07:04:06 -07:00
Paul Payne
5987f01749 Refactor configuration handling to use directory-per-resource layout
- Introduced `_write_castle_config` function to scatter nested config into separate YAML files for programs, services, and jobs.
- Updated `castle_root` fixture to utilize the new config writing method.
- Added tests for configuration aggregation and scattering in `test_health.py`.
- Removed legacy unit handling from `manifest.py` and refactored related code in `config.py`.
- Updated documentation to reflect new configuration directory structure.
- Removed obsolete unit tests related to unit expansion.
2026-06-28 14:01:18 -07:00
Paul Payne
48c3915e4d feat: Enhance node runner to execute scripts in source directory and update tests 2026-06-28 13:18:28 -07:00
Paul Payne
3f1b2e6f56 feat: Implement secret management for environment variables and enhance systemd unit generation 2026-06-27 12:47:05 -07:00
Paul Payne
26a00c4073 refactor: Run python services in place via uv run
A python-runner service ran from a tool venv (uv tool install --editable),
separate from the project venv that uv sync manages. The editable link kept
source live, but dependencies were resolved independently in each venv, so a
new dependency landed in the project venv and never reached the running
service — leaving no clean rollout for it.

Deploy now emits ExecStart=uv run --project <source> --no-dev <program>,
running the service in place from its own project venv. uv run syncs the env
to the lockfile before launching, so a single restart picks up both code and
dependency changes; the ExecStart is deterministic from source and never goes
stale. The tool-venv install is gated to the command runner only; uv tool
install is reserved for tool-behavior programs, where PATH is the point. A
python service with no resolvable source falls back to a PATH lookup.
2026-06-17 11:03:11 -07:00
Paul Payne
4840cbe72a feat: castle UX fixes from the lakehouse adoption test
Driven by adopting lakehouse (an existing daemon that bundles its own SPA) —
see docs/findings-lakehouse.md.

#3 deploy reloads the gateway. castle deploy regenerated the Caddyfile but
   left the running Caddy on the old config, so new proxy routes were silently
   dead. Deploy now reloads the gateway when it's running.

#1 castle expose <program>. Turns an adopted program into a service
   (run/port/health/proxy/systemd) in one command — the missing
   daemon-to-service step. Flags: --port --health --path --run --port-env
   --host --no-proxy.

#2 port_env mapping. A service can declare expose.http.internal.port_env so
   castle sets the env var the program actually reads (e.g. lakehoused reads
   LAKEHOUSED_DAEMON_PORT, not castle's convention LAKEHOUSE_PORT). Castle now
   genuinely drives an adopted daemon's bind port.

#4a auto-base for react-vite. The build passes VITE_BASE = the gateway serve
   prefix (/<name>/, or / for castle-app); vite.config reads it. A castle-built
   frontend now works at its subpath with no hand-tuned base. castle-app's
   vite.config updated as the reference.

#4b host-based routing. proxy.caddy.host routes a whole hostname to the backend
   root via a host matcher inside the :9000 site, so a root-based SPA (base="/")
   serves unchanged — the fix for proxying an app castle can't rebuild. Caddyfile
   now emits 'auto_https off' (HTTP-only gateway on a non-standard port).

Nit: activate skips the editable reinstall when the tool is already on PATH.

Tests: core 94, cli 24, api 52; ruff + app build clean. Verified live: lakehouse
runs under systemd, API at /lakehouse, full UI (SPA boots) via host routing.
2026-06-14 13:16:34 -07:00
Paul Payne
3f3b88f17b refactor: Align vocabulary — component → program / deployment
Canonical terms (see docs/registry.md glossary):
- program: any catalog entry (tool/daemon/frontend) — the software
- service / job: how a program is deployed (systemd .service / .timer)
- deployment: umbrella for the unified service+job+program view

Changes:
- core: ServiceSpec/JobSpec component: → program: (component accepted as
  back-compat validation_alias); DeployedComponent → Deployment.
- api: ComponentSummary/Detail → DeploymentSummary/Detail; GET /components
  → /deployments; GatewayRoute.component → program; GatewayInfo
  .component_count → deployment_count; ServiceActionResponse/action keys
  component → program.
- app: matching type renames, /components → /deployments hook, route
  /component/:name → /deployment/:name, GatewayRoute.program.
- docs: component-registry.md → registry.md (+ canonical glossary);
  CLAUDE.md endpoint list refreshed (drop removed /tools, add typed
  program/service/job + config routes).

Tests: core 92, cli 24, api 52 green; app build clean; ruff clean.
2026-06-14 11:05:22 -07:00
Paul Payne
482524bfe5 Phase 2b: serve static frontends in place from repo dist
The Caddyfile generator now emits static-frontend routes that root directly at
<source>/<build.outputs[0]> (e.g. /data/repos/castle/app/dist) instead of a copy
under ~/.castle/artifacts/content. Removes _copy_app_static and the central
content-dir staging entirely; rebuilds are live without a copy step.

- caddyfile.py: manifest-driven static routes (castle-app at /, others at /<name>)
- ReactViteHandler.install just builds in place (no copy); uninstall is a no-op
- is_active(static frontend) = repo dist exists
- test isolation updated for the generator's config use

Dashboard + power-graph-app verified serving from repo dist; 168 tests pass.
2026-06-13 18:16:09 -07:00
Paul Payne
2953aea548 Runtime half: convergent deploy + unified active lifecycle
Convergent deploy (prefix-based prune): castle deploy now removes orphaned
castle-* systemd units/timers no longer in the registry. Full deploy only;
partial (--name) deploys preserve siblings. Fixes the orphaned-timer / stale-
path drift class.

Container runner: derive --name from the service name (castle-<name>) instead
of the image name; skip the <PREFIX>_DATA_DIR env injection for containers
(avoids colliding with image env namespaces like NEO4J_*).

Unified active lifecycle (core/lifecycle.py): install/uninstall keep their
names but become activate/deactivate, dispatching by behavior — tool→PATH,
daemon/self-serving-frontend/job→systemd, static frontend→served. is_active
reports a uniform state (PATH-independent for tools). The CLI install/uninstall
and service enable/disable route through the extracted core.

API + dashboard: surface a uniform `active` state on programs alongside
`installed`; ProgramDetail shows active/inactive.

Tests: test_deploy_prune (6), test_lifecycle (5). 168 tests pass; lint clean.
2026-06-13 18:08:54 -07:00
Paul Payne
400e0b253b Less stack-centric and location-centric model. 2026-06-13 17:26:49 -07:00
Paul Payne
01a09d6a61 feat: Implement unit specification and expansion logic in configuration 2026-04-08 16:57:19 -07:00