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.
This commit is contained in:
2026-07-01 07:01:37 -07:00
parent 7eca3cc217
commit fc83ce40c1
3 changed files with 44 additions and 10 deletions

View File

@@ -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

View File

@@ -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"}

View File

@@ -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 <source> --no-dev <program>` | `program`, `args` |
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `compose` | *(none)* | `docker compose -p <project> -f <file> 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 <source> --no-dev <program>` | `program`, `args` |
| `command` | systemd | `which(argv[0])` → resolved path | `argv` |
| `container` | systemd | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `compose` | systemd | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
| `node` | systemd | `package_manager run script` | `script`, `package_manager` |
| `static` | caddy | *(no process — `file_server` from `<source>/<root>`)* | `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