For services with no authentication of their own -- a trace viewer, an admin
UI, an OTLP receiver -- which must not be open to the LAN just because they run
here.
- `expose.http.basic_auth: {user, password_hash}` has the gateway ask for HTTP
Basic auth (Caddy's `basic_auth`, bcrypt from `caddy hash-password`) before
proxying, on the internal name and any public one. It is recorded in the
registry too, so a Caddyfile generated from the registry alone still asks
rather than serving the route open. Checked against caddy 2.11: 401 without
credentials, through with them.
- A container's `ports` may give the host side an address, `4318:
127.0.0.1:4318` -> `-p 127.0.0.1:4318:4318`. A bare port still publishes on
every interface, as before, and the docs now say that this means the LAN.
627 lines
24 KiB
Python
627 lines
24 KiB
Python
"""Wild PC manifest models — program specs, service specs, job specs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from enum import Enum
|
|
from typing import Annotated, Literal, Union
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
EnvMap = dict[str, str]
|
|
|
|
|
|
class RestartPolicy(str, Enum):
|
|
NO = "no"
|
|
ON_FAILURE = "on-failure"
|
|
ALWAYS = "always"
|
|
|
|
|
|
class Reach(str, Enum):
|
|
"""How far a deployment is exposed — a protocol-agnostic ladder.
|
|
|
|
``off`` → reachable only at its own host:port (no gateway route).
|
|
``internal`` → reachable at ``<name>.<domain>`` (HTTP via the gateway, or TCP
|
|
via bind + wildcard DNS).
|
|
``public`` → *also* projected to the internet (HTTP via the tunnel origin;
|
|
TCP via ``cloudflared access tcp``). Implies ``internal``.
|
|
|
|
``proxy``/``public`` survive as derived read-only accessors on the deployment.
|
|
"""
|
|
|
|
OFF = "off"
|
|
INTERNAL = "internal"
|
|
PUBLIC = "public"
|
|
|
|
|
|
def _validate_public_host(host: str | None, reach: Reach) -> None:
|
|
"""Validate an optional ``public_host`` override on an exposable deployment.
|
|
|
|
A ``public_host`` only makes sense for a publicly-projected deployment, and
|
|
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
|
|
tunnel ingress ``hostname`` and a Caddy site address verbatim.
|
|
"""
|
|
if host is None:
|
|
return
|
|
if reach != Reach.PUBLIC:
|
|
raise ValueError(
|
|
f"public_host is only valid with reach: public (got reach: {reach.value})"
|
|
)
|
|
bad = any(c.isspace() for c in host) or any(
|
|
tok in host for tok in ("://", "/", ":")
|
|
)
|
|
if not host or host != host.strip(".") or bad:
|
|
raise ValueError(
|
|
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
|
|
)
|
|
|
|
|
|
# ---------------------
|
|
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
|
# ---------------------
|
|
#
|
|
# A *launcher* is the process-launch mechanism for a systemd-managed deployment.
|
|
# It is orthogonal to the deployment's `manager`: only systemd deployments have a
|
|
# launcher, and it says only how the process starts — not how it's supervised.
|
|
# The non-process managers (caddy/path/none) have no launcher; their fields live
|
|
# on the deployment variant itself.
|
|
|
|
|
|
class LaunchBase(BaseModel):
|
|
launcher: str
|
|
|
|
|
|
class RunCommand(LaunchBase):
|
|
launcher: Literal["command"]
|
|
argv: list[str] = Field(min_length=1)
|
|
|
|
|
|
class RunPython(LaunchBase):
|
|
launcher: Literal["python"]
|
|
program: str
|
|
args: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RunContainer(LaunchBase):
|
|
launcher: Literal["container"]
|
|
image: str
|
|
command: list[str] | None = None
|
|
args: list[str] = Field(default_factory=list)
|
|
# container port → host port. The host side may name the address to publish
|
|
# on, "127.0.0.1:4318": a bare port publishes on every interface (docker's
|
|
# default), which puts it on the LAN whatever `reach:` says.
|
|
ports: dict[int, int | str] = Field(default_factory=dict)
|
|
volumes: list[str] = Field(default_factory=list)
|
|
env: EnvMap = Field(default_factory=dict)
|
|
workdir: str | None = None
|
|
# Run the container as this uid[:gid] (e.g. "${uid}:${gid}"). Running as the
|
|
# invoking user makes bind-mounted data/secrets/certs readable with no chown —
|
|
# see docs/tcp-exposure.md §4. None → the image's own default user.
|
|
user: str | None = None
|
|
# tmpfs mounts (e.g. ["/var/run/postgresql"]) for image runtime dirs that must
|
|
# be writable when the container runs as a non-default uid.
|
|
tmpfs: list[str] = Field(default_factory=list)
|
|
# GPUs to hand the container (docker `--gpus`), e.g. "all" or "device=0".
|
|
# None → no GPU access.
|
|
gpus: str | None = None
|
|
|
|
|
|
@field_validator("ports")
|
|
@classmethod
|
|
def _host_binding(cls, ports: dict[int, int | str]) -> dict[int, int | str]:
|
|
for container_port, host in ports.items():
|
|
if isinstance(host, str) and not _HOST_BINDING.match(host):
|
|
raise ValueError(
|
|
f"ports[{container_port}]: {host!r} is not a host port or "
|
|
"an <ipv4>:<port> binding (e.g. 127.0.0.1:4318)"
|
|
)
|
|
return ports
|
|
|
|
|
|
class RunNode(LaunchBase):
|
|
launcher: Literal["node"]
|
|
script: str
|
|
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
|
args: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RunCompose(LaunchBase):
|
|
"""A multi-container stack supervised as one unit via ``docker compose``.
|
|
|
|
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
|
|
networking, startup ordering, and per-service health — Wild PC delegates rather
|
|
than reinventing orchestration. The unit runs ``compose up`` attached
|
|
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
|
|
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
|
|
``defaults.env``); compose interpolates them from the process environment.
|
|
"""
|
|
|
|
launcher: Literal["compose"]
|
|
file: str = "docker-compose.yml" # resolved relative to the program source
|
|
project_name: str | None = None # ``-p``; defaults to ``wildpc-<name>``
|
|
|
|
|
|
LaunchSpec = Annotated[
|
|
Union[
|
|
RunCommand,
|
|
RunPython,
|
|
RunContainer,
|
|
RunNode,
|
|
RunCompose,
|
|
],
|
|
Field(discriminator="launcher"),
|
|
]
|
|
|
|
|
|
# ---------------------
|
|
# Systemd management
|
|
# ---------------------
|
|
|
|
|
|
class SystemdSpec(BaseModel):
|
|
enable: bool = True
|
|
user: bool = True
|
|
description: str | None = None
|
|
# Unit ordering (systemd ``After=``). There is deliberately no ``requires:``
|
|
# partner emitting ``Requires=``: that binds lifecycles, so stopping postgres
|
|
# would stop everything downstream, and ordering is what callers actually
|
|
# want. A deployment's own ``requires:`` list is the relationship model (see
|
|
# docs/relationships.md), not systemd wiring.
|
|
after: list[str] = Field(default_factory=list)
|
|
wanted_by: list[str] = Field(default_factory=lambda: ["default.target"])
|
|
restart: RestartPolicy = RestartPolicy.ON_FAILURE
|
|
restart_sec: int = 2
|
|
# Exponential restart backoff: the delay grows from ``restart_sec`` to
|
|
# ``restart_max_delay_sec`` over ``restart_steps`` restarts, so a service that
|
|
# can't start stops hammering the box (systemd >= 254; older systemd ignores
|
|
# both keys with a warning and keeps the flat ``restart_sec``).
|
|
restart_steps: int = 8
|
|
restart_max_delay_sec: int = 300
|
|
# Give up eventually: ``start_limit_burst`` starts inside
|
|
# ``start_limit_interval_sec`` puts the unit in `failed` instead of looping
|
|
# forever. Sized against the backoff above — with the defaults, ~10 restarts
|
|
# span ~17min, long enough to ride out a boot-time dependency race and short
|
|
# enough that a genuinely broken service surfaces as failed the same hour.
|
|
start_limit_burst: int = 10
|
|
start_limit_interval_sec: int = 3600
|
|
no_new_privileges: bool = True
|
|
exec_reload: str | None = None
|
|
# Commands run after the main process starts (systemd ``ExecStartPost=``), one
|
|
# line each — e.g. an OpenBao auto-unseal step. Failures don't fail the unit.
|
|
exec_start_post: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class ManageSpec(BaseModel):
|
|
systemd: SystemdSpec | None = None
|
|
|
|
|
|
# ---------------------
|
|
# Exposure — HTTP (via the gateway) or raw TCP (bind + DNS)
|
|
# ---------------------
|
|
|
|
|
|
#: "127.0.0.1:4318" — an IPv4 address and a port, as docker's `-p` takes them.
|
|
_HOST_BINDING = re.compile(r"^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$")
|
|
|
|
|
|
class BasicAuthSpec(BaseModel):
|
|
"""HTTP Basic auth at the gateway, for a service with no login of its own
|
|
(a trace viewer, an admin UI). Caddy's ``basic_auth``: one user, and a bcrypt
|
|
hash of the password — ``caddy hash-password`` makes one. The hash is not the
|
|
password, which is why it may live in the manifest; the gateway is HTTPS, so
|
|
the credential never crosses the LAN in the clear."""
|
|
|
|
user: str = Field(min_length=1, pattern=r"^[^\s:]+$")
|
|
password_hash: str = Field(pattern=r"^\$2[aby]\$\d\d\$[./A-Za-z0-9]{53}$")
|
|
|
|
|
|
class HttpInternal(BaseModel):
|
|
host: str = "127.0.0.1"
|
|
port: int = Field(ge=1, le=65535)
|
|
unix_socket: str | None = None
|
|
|
|
|
|
class HttpExposeSpec(BaseModel):
|
|
internal: HttpInternal
|
|
health_path: str | None = None
|
|
# Require a login at the gateway before proxying. Applies wherever the
|
|
# gateway routes this service (internal and public names alike).
|
|
basic_auth: BasicAuthSpec | None = None
|
|
|
|
|
|
class TlsMaterial(str, Enum):
|
|
"""What cert files wildpc materializes onto a service from the wildcard cert.
|
|
|
|
``off`` → the service does its own TLS (or none); wildpc stays out of it.
|
|
``pair`` → cert.pem + key.pem (postgres, redis, most daemons).
|
|
``combined`` → one file: key+cert concatenated (mongodb, haproxy, …).
|
|
"""
|
|
|
|
OFF = "off"
|
|
PAIR = "pair"
|
|
COMBINED = "combined"
|
|
|
|
|
|
class TlsSpec(BaseModel):
|
|
"""Wild PC-managed TLS material for a raw-TCP service, cut from the gateway's
|
|
ACME wildcard cert (valid for ``<name>.<domain>``) and refreshed on renewal.
|
|
The service consumes the materialized files via the ``${tls_*}`` placeholders.
|
|
"""
|
|
|
|
material: TlsMaterial = TlsMaterial.OFF
|
|
# Optional zero-downtime reload argv (a single command) run after the cert is
|
|
# re-materialized on renewal — e.g. ["systemctl", "--user", "reload", "wildpc-postgres"].
|
|
# Default (None): wildpc restarts the deployment (fine at a ~60-day cadence).
|
|
reload: list[str] | None = None
|
|
|
|
|
|
class TcpExposeSpec(BaseModel):
|
|
"""A raw-TCP service (postgres, redis, …). It doesn't ride the HTTP gateway:
|
|
with ``reach: internal`` it's reachable at ``<name>.<domain>:<port>`` via the
|
|
wildcard DNS record + the bound port (no Caddy route). Publishing the port on
|
|
the LAN is the deployment's own job (a container's ``run.ports`` — whose host
|
|
side may name an address, ``127.0.0.1:4000`` — or a native service binding
|
|
``0.0.0.0``); wildpc doesn't rebind it, so there's no bind-host field here to
|
|
imply otherwise. ``tls`` (optional) has wildpc drop the wildcard
|
|
cert onto the service so it presents a trusted cert for ``<name>.<domain>``.
|
|
"""
|
|
|
|
port: int = Field(ge=1, le=65535)
|
|
tls: TlsSpec | None = None
|
|
|
|
|
|
class ExposeSpec(BaseModel):
|
|
http: HttpExposeSpec | None = None
|
|
tcp: TcpExposeSpec | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _one_protocol(self) -> ExposeSpec:
|
|
if self.http and self.tcp:
|
|
raise ValueError("a deployment exposes http OR tcp, not both")
|
|
return self
|
|
|
|
|
|
# ---------------------
|
|
# Build spec
|
|
# ---------------------
|
|
|
|
|
|
class BuildSpec(BaseModel):
|
|
commands: list[list[str]] = Field(default_factory=list)
|
|
outputs: list[str] = Field(default_factory=list)
|
|
|
|
|
|
# ---------------------
|
|
# Commands spec — per-program dev verb overrides
|
|
# ---------------------
|
|
|
|
|
|
class CommandsSpec(BaseModel):
|
|
"""Per-program dev verb commands. Each verb is a list of argv lists run in
|
|
sequence. A declared verb overrides the stack default; an absent verb falls
|
|
back to the program's stack handler (if any), else the verb is unavailable.
|
|
|
|
This generalizes BuildSpec.commands to the rest of the verb contract, which
|
|
is what lets a wired-in repo with no `stack` declare how it is linted/tested/run.
|
|
"""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
lint: list[list[str]] | None = None
|
|
test: list[list[str]] | None = None
|
|
type_check: list[list[str]] | None = Field(default=None, alias="type-check")
|
|
check: list[list[str]] | None = None
|
|
run: list[list[str]] | None = None
|
|
install: list[list[str]] | None = None
|
|
uninstall: list[list[str]] | None = None
|
|
|
|
def for_verb(self, verb: str) -> list[list[str]] | None:
|
|
"""Return the declared commands for a verb name (accepts 'type-check')."""
|
|
return getattr(self, verb.replace("-", "_"), None)
|
|
|
|
|
|
# ---------------------
|
|
# Capabilities
|
|
# ---------------------
|
|
|
|
|
|
class Requirement(BaseModel):
|
|
"""A precondition — another **deployment** that must exist for this one to be
|
|
*functional* (``ref`` = the target deployment's name). ``bind`` names the env
|
|
var wildpc projects the target's URL into — env is derived *from* the
|
|
requirement, never scraped back into it.
|
|
|
|
A deployment declares these in its ``requires`` list; ``kind`` defaults to
|
|
``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
|
|
not written here — a program's host-package preconditions live in
|
|
``system_dependencies`` (synthesized as ``kind: system``), and its stack's
|
|
toolchains (``uv``/``pnpm``/``hugo``/…) are synthesized as ``kind: tool`` — both by
|
|
the relationship model for the ``functional?`` check. See docs/relationships.md.
|
|
"""
|
|
|
|
kind: Literal["system", "deployment", "tool"] = "deployment"
|
|
ref: str
|
|
bind: str | None = None
|
|
# Appended to the target's URL when binding — for a consumer that wants a
|
|
# sub-path of it (e.g. an OpenAI-compatible server's "/v1") as its base URL.
|
|
path: str | None = None
|
|
|
|
|
|
# ---------------------
|
|
# Defaults
|
|
# ---------------------
|
|
|
|
|
|
class DefaultsSpec(BaseModel):
|
|
env: EnvMap = Field(default_factory=dict)
|
|
|
|
|
|
# ---------------------
|
|
# Agent spec — a launchable agent CLI (assistant-agnostic)
|
|
# ---------------------
|
|
|
|
|
|
class SessionsSpec(BaseModel):
|
|
"""Declarative session-history capability for an agent (optional).
|
|
|
|
Lets the dashboard show a unified picker of an agent's *own* past sessions
|
|
without wildpc knowing anything agent-specific in code: it runs
|
|
``list_command`` (which must print a JSON array of session objects), reads
|
|
three named fields off each object, and launches ``command`` + ``resume``
|
|
(with ``{id}`` substituted) to reopen one. Field names default to opencode's
|
|
shape and are overridable per agent (dotted paths allowed, e.g.
|
|
``config_summary.session_id``).
|
|
"""
|
|
|
|
list_command: list[str] # argv → JSON array of session objects
|
|
resume: list[str] # appended to the agent command; "{id}" is substituted
|
|
id_field: str = "id"
|
|
title_field: str = "title"
|
|
time_field: str = "updated"
|
|
|
|
|
|
class AgentSpec(BaseModel):
|
|
"""A launchable agent CLI for the dashboard's terminal UX.
|
|
|
|
Wild PC just runs ``command args`` inside a pty at ``cwd``; it never parses
|
|
the agent's output, so any interactive CLI works. This block only names the
|
|
launch — live sessions (list/resume/kill) are a runtime concern, not config.
|
|
"""
|
|
|
|
command: str
|
|
args: list[str] = Field(default_factory=list)
|
|
description: str | None = None
|
|
cwd: str | None = None # defaults to the wildpc repo root when unset
|
|
env: EnvMap = Field(default_factory=dict)
|
|
# Extra args that open the agent's own session browser / continue its last
|
|
# conversation (e.g. ["--resume"] or ["--continue"]). Optional and
|
|
# agent-specific — wildpc just passes them through. Empty = no such affordance.
|
|
resume_args: list[str] = Field(default_factory=list)
|
|
# Optional: declares how to list + resume the agent's own sessions, so the
|
|
# dashboard can render a unified session picker (see SessionsSpec).
|
|
sessions: SessionsSpec | None = None
|
|
|
|
|
|
# ---------------------
|
|
# Program spec — software identity
|
|
# ---------------------
|
|
|
|
|
|
class ProgramSpec(BaseModel):
|
|
"""Software catalog entry — what exists."""
|
|
|
|
id: str = ""
|
|
description: str | None = None
|
|
# A program has NO kind of its own — kind is a *deployment* property. A program
|
|
# is a catalog entry that has 0..N deployments, each with its own kind (see
|
|
# kind_for and WildpcConfig.deployments_of).
|
|
|
|
source: str | None = None
|
|
stack: str | None = None
|
|
|
|
# Wiring in existing repos: clone from `repo` (git URL) at optional `ref`;
|
|
# `source` (when set) is the local working copy and takes precedence.
|
|
repo: str | None = None
|
|
ref: str | None = None
|
|
|
|
# Per-program dev verb overrides (declared verbs override the stack default).
|
|
commands: CommandsSpec | None = None
|
|
|
|
# Host-package preconditions (apt packages / binaries) intrinsic to this
|
|
# software. The relationship model checks these (`which`/`dpkg`) to derive the
|
|
# `functional?` light. Deployment-to-deployment dependencies are NOT here — they
|
|
# live on the deployment's `requires` (see DeploymentBase). See docs/relationships.md.
|
|
system_dependencies: list[str] = Field(default_factory=list)
|
|
install_extras: list[str] = Field(default_factory=list)
|
|
version: str | None = None
|
|
build: BuildSpec | None = None
|
|
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
@property
|
|
def source_dir(self) -> str | None:
|
|
"""Relative directory for this component's source, or None."""
|
|
if self.source:
|
|
return self.source.rstrip("/")
|
|
return None
|
|
|
|
|
|
# ---------------------
|
|
# Deployment specs — a program materialized into the runtime
|
|
# ---------------------
|
|
#
|
|
# A deployment is discriminated on its `manager` — who supervises/realizes it:
|
|
# systemd → a process (or, with a `schedule`, a `.timer`)
|
|
# caddy → a gateway route serving a static dir (file_server)
|
|
# path → a CLI installed on PATH (uv tool install)
|
|
# none → an external reference we only point at (remote)
|
|
# The human "kind" (service/job/tool/static/reference) is *derived* from the
|
|
# manager (+ schedule presence), never stored — see `kind_for`.
|
|
|
|
|
|
class DeploymentBase(BaseModel):
|
|
"""Fields common to every deployment, regardless of manager."""
|
|
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
id: str = ""
|
|
# The program this deployment materializes.
|
|
program: str | None = None
|
|
description: str | None = None
|
|
defaults: DefaultsSpec | None = None
|
|
# Deployment-to-deployment preconditions: other deployments this one needs
|
|
# (e.g. a frontend that requires its API + the supabase substrate). Each entry
|
|
# is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's
|
|
# URL into env). Drives the relationship graph's edges. See docs/relationships.md.
|
|
requires: list[Requirement] = Field(default_factory=list)
|
|
# Declared on/off state. `wildpc apply` converges reality to this: enabled
|
|
# deployments are activated (service started, tool installed, route served),
|
|
# disabled ones are deactivated but kept in the catalog. This is *desired
|
|
# state*, not a runtime toggle — the only way to durably stop something.
|
|
enabled: bool = True
|
|
|
|
|
|
class SystemdDeployment(DeploymentBase):
|
|
"""A process supervised by systemd — a *service*, or a *job* when scheduled."""
|
|
|
|
manager: Literal["systemd"]
|
|
run: LaunchSpec
|
|
# Present → a `.timer` (job); absent → a continuous `.service` (service).
|
|
schedule: str | None = None
|
|
timezone: str = "America/Los_Angeles"
|
|
expose: ExposeSpec | None = None
|
|
# How far this process is exposed (off | internal | public). See `Reach`.
|
|
reach: Reach = Reach.OFF
|
|
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
|
|
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
|
|
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
|
|
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
|
|
public_host: str | None = None
|
|
manage: ManageSpec | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_reach(self) -> SystemdDeployment:
|
|
# An exposed reach needs a port to expose. Without an `expose` block the
|
|
# reach silently no-ops — no route, no subdomain, no tunnel entry — so a
|
|
# typo'd/omitted `expose` reads as success while the service is unreachable.
|
|
# Reject it at load so the mistake surfaces (replaces the old
|
|
# "public requires proxy" guard, now that reach is the canonical field).
|
|
# Static frontends (manager: caddy) are inherently exposed and validated
|
|
# elsewhere; this is a supervised process, which needs an explicit port.
|
|
if self.reach != Reach.OFF and not self.expose:
|
|
raise ValueError(
|
|
f"reach: {self.reach.value} requires an `expose` block "
|
|
"(expose.http or expose.tcp); a port-only process uses reach: off"
|
|
)
|
|
# Public raw-TCP (tunnel + Access) is a later step; guard it explicitly
|
|
# rather than silently no-op'ing when a TCP service asks for it.
|
|
if (
|
|
self.reach == Reach.PUBLIC
|
|
and self.expose
|
|
and self.expose.tcp
|
|
and not self.expose.http
|
|
):
|
|
raise ValueError(
|
|
"reach: public for a raw-TCP service isn't supported yet "
|
|
"(see docs/tcp-exposure.md step 5); use reach: internal"
|
|
)
|
|
_validate_public_host(self.public_host, self.reach)
|
|
return self
|
|
|
|
# Derived, read-only back-compat accessors (not serialized) so existing
|
|
# readers keep working while the stored/authored field is `reach`.
|
|
@property
|
|
def proxy(self) -> bool:
|
|
return self.reach != Reach.OFF
|
|
|
|
@property
|
|
def public(self) -> bool:
|
|
return self.reach == Reach.PUBLIC
|
|
|
|
@property
|
|
def http_exposed(self) -> bool:
|
|
"""Exposed through the HTTP gateway at ``<name>.<domain>`` — the predicate
|
|
for a Caddy route / subdomain. Requires ``reach != off`` *and* an HTTP
|
|
port; a raw-TCP service (``expose.tcp``) is never HTTP-exposed."""
|
|
return self.reach != Reach.OFF and bool(self.expose and self.expose.http)
|
|
|
|
@property
|
|
def tcp_port(self) -> int | None:
|
|
"""The raw-TCP port this service is exposed on, or None. Reachable at
|
|
``<name>.<domain>:<port>`` when ``reach != off`` (bind + wildcard DNS)."""
|
|
if self.reach != Reach.OFF and self.expose and self.expose.tcp:
|
|
return self.expose.tcp.port
|
|
return None
|
|
|
|
|
|
class CaddyDeployment(DeploymentBase):
|
|
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
|
|
|
The gateway *is* its runtime; it's inherently exposed at its subdomain. ``root``
|
|
is the built dir to serve, relative to the program source (e.g. ``dist``/``public``).
|
|
"""
|
|
|
|
manager: Literal["caddy"]
|
|
root: str = "dist"
|
|
# Serving strategy. `True` (default) → SPA fallback: any unmatched path serves
|
|
# the root `index.html` so a client-side router (React/Vite) takes over. `False`
|
|
# → content-site serving: the gateway resolves directory indexes (`/posts/` →
|
|
# `/posts/index.html`) and 404s genuinely-missing paths — correct for Hugo and
|
|
# other multi-page static sites, where the SPA fallback would swallow every
|
|
# in-site link back to the homepage.
|
|
spa: bool = True
|
|
# A static site is inherently served at its subdomain, so `reach` is
|
|
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
|
|
reach: Reach = Reach.INTERNAL
|
|
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
|
|
# with `reach: public`; see SystemdDeployment.public_host.
|
|
public_host: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_reach(self) -> CaddyDeployment:
|
|
if self.reach == Reach.OFF:
|
|
raise ValueError(
|
|
"a static (caddy) deployment is always served; reach must be internal|public"
|
|
)
|
|
_validate_public_host(self.public_host, self.reach)
|
|
return self
|
|
|
|
@property
|
|
def public(self) -> bool:
|
|
return self.reach == Reach.PUBLIC
|
|
|
|
|
|
class PathDeployment(DeploymentBase):
|
|
"""A CLI installed on PATH via ``uv tool install`` — no process, no route.
|
|
|
|
Lifecycle is install/uninstall (what start/stop/enable/disable map to).
|
|
"""
|
|
|
|
manager: Literal["path"]
|
|
# An Anthropic Messages-API tool definition ({name, description, input_schema})
|
|
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
|
|
# ``wildpc_core.tool_schema``) but editable/overridable — a stored draft the
|
|
# completion-context builder reads. None → not yet generated.
|
|
tool_schema: dict | None = None
|
|
|
|
|
|
class RemoteDeployment(DeploymentBase):
|
|
"""An external service (another node) — we only reference/route it, never run it."""
|
|
|
|
manager: Literal["none"]
|
|
base_url: str
|
|
health_url: str | None = None
|
|
|
|
|
|
DeploymentSpec = Annotated[
|
|
Union[SystemdDeployment, CaddyDeployment, PathDeployment, RemoteDeployment],
|
|
Field(discriminator="manager"),
|
|
]
|
|
|
|
|
|
def kind_for(spec: DeploymentSpec) -> str:
|
|
"""The derived kind of a deployment: service|job|tool|static|reference."""
|
|
if spec.manager == "systemd":
|
|
return "job" if getattr(spec, "schedule", None) else "service"
|
|
return {"caddy": "static", "path": "tool", "none": "reference"}[spec.manager]
|