From fc83ce40c1a9f58639a581262db9273649576374 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 07:01:37 -0700 Subject: [PATCH] Public toggle composes for static frontends; document the deployment model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- core/src/castle_core/manifest.py | 7 +++++-- core/tests/test_tunnel.py | 12 +++++++++++ docs/registry.md | 35 ++++++++++++++++++++++++-------- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 68c0cc6..b8bdbbb 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -324,8 +324,11 @@ class ServiceSpec(BaseModel): if self.manage and self.manage.systemd and self.manage.systemd.enable: if self.run.runner == "remote": raise ValueError("manage.systemd cannot be enabled for runner=remote.") - if self.public and not self.proxy: - raise ValueError("public requires proxy (a service must be routed to be exposed).") + # A static service is inherently exposed (that's its purpose); other + # runners need the proxy checkbox to be routed. Public requires exposure. + exposed = self.proxy or self.run.runner == "static" + if self.public and not exposed: + raise ValueError("public requires the service to be exposed (proxy or static).") return self diff --git a/core/tests/test_tunnel.py b/core/tests/test_tunnel.py index b9f91ca..6ed9b32 100644 --- a/core/tests/test_tunnel.py +++ b/core/tests/test_tunnel.py @@ -68,3 +68,15 @@ def test_none_when_no_public_services() -> None: def test_none_when_tunnel_unconfigured() -> None: assert generate_tunnel_config(_registry(tunnel_id=None)) is None assert generate_tunnel_config(_registry(public_domain=None)) is None + + +def test_public_static_frontend_gets_ingress() -> None: + # A `static` (frontend) service can be public too — the toggle composes for + # any exposed deployment, process-backed or not. + reg = _registry(deployed={ + "guestbook": Deployment(runner="static", run_cmd=[], subdomain="guestbook", + static_root="/data/repos/guestbook/public", public=True), + }) + hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"] + if "hostname" in r} + assert hosts == {"guestbook.pub.payne.io"} diff --git a/docs/registry.md b/docs/registry.md index c9cc5fd..5332f54 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -216,16 +216,35 @@ Services define **how long-running daemons are deployed**. ### `run` — How to start it (required) +A deployment (service/job) is a *managed materialization* of a program. Its +**runner** determines the **manager** — who makes it available and supervises its +lifecycle: + +| Manager | Makes available as | Runners | start/stop | +|---------|--------------------|---------|------------| +| **systemd** | a running process (or a `.timer` for jobs) | `python`, `command`, `container`, `compose`, `node` | `systemctl` | +| **caddy** | a gateway route | `static` (file_server) — and any exposed process (reverse_proxy) | add/remove route + reload | +| **path** | an installed CLI on `PATH` | `path` | `uv tool install` / `uninstall` | +| **none** | an external reference | `remote` | *(nothing — not ours)* | + +`manager_for(runner)` (in `manifest.py`) is the single source of truth; lifecycle, +deploy, and status all dispatch on it. `behavior` (`tool`/`daemon`/`frontend`) is a +**derived display label only** — it never drives logic. A program is a *tool* when +it has a `path` service, a *frontend* when it has a `static` service, a *daemon* +when it has a process service. + Discriminated union on `runner`: -| Runner | Sync | Deploy | Key fields | -|--------|------|--------|------------| -| `python` | *(none — `uv run` self-syncs)* | `uv run --project --no-dev ` | `program`, `args` | -| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | -| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | -| `compose` | *(none)* | `docker compose -p -f up` (+ `ExecStop=down`) | `file`, `project_name` | -| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | -| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | +| Runner | Manager | Deploy | Key fields | +|--------|---------|--------|------------| +| `python` | systemd | `uv run --project --no-dev ` | `program`, `args` | +| `command` | systemd | `which(argv[0])` → resolved path | `argv` | +| `container` | systemd | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | +| `compose` | systemd | `docker compose -p -f up` (+ `ExecStop=down`) | `file`, `project_name` | +| `node` | systemd | `package_manager run script` | `script`, `package_manager` | +| `static` | caddy | *(no process — `file_server` from `/`)* | `root` | +| `path` | path | *(no process — `uv tool install` at enable time)* | *(the referenced program)* | +| `remote` | none | *(none — no local process)* | `base_url`, `health_url` | A `python` service runs **in place from its own project venv** via `uv run`, which syncs the env to the project's lockfile before launching. There is no separate