Files
wild-pc/core/src/castle_core/manifest.py
Paul Payne 7314b5cddb refactor(env): explicit defaults.env with placeholders; drop auto-injection
A service/job's env is now exactly its defaults.env — castle injects no hidden
convention vars. Values support ${port}/${data_dir}/${name} placeholders
(resolved at deploy, alongside ${secret:…}), so a program's own env var names
map to castle's computed values without hardcoding.

Why: the auto-injected <PREFIX>_PORT/<PREFIX>_DATA_DIR were a guess at the
program's env names — right for castle-scaffolded services, dead weight for
adopted ones (lakehouse carried two dead vars; notification-bridge/backup jobs
too). They also weren't visible in the config editor (computed at deploy), which
was the source of the 'four env vars but the UI shows none' mystery.

- core: resolve_env_vars gains a context (${port}/${data_dir}/${name});
  deploy builds env from defaults.env only — no <PREFIX>_* injection, no
  port_env. Removed the port_env field and the dead _env_prefix helper.
- cli: 'service/job create' gains repeatable --env KEY=VALUE (replaces
  --port-env); 'program create' scaffolds <PREFIX>_PORT/_DATA_DIR: ${…} for new
  daemons.
- app: removed the 'Port env' field; the Environment editor (defaults.env) is
  the single place, with a placeholder hint.
- live migration: central-context/castle-api/power-graph/protonmail mapped their
  real vars explicitly; lakehouse → just LAKEHOUSED_DAEMON_PORT: ${port}, data
  stays in ~/.lakehoused. Verified all services healthy on their ports, dead
  vars gone, zero failed units.
- docs: registry.md/design.md/stack guides + findings updated to the explicit
  model.

core 94 / cli 24 / api 52 green; ruff + app build clean.
2026-06-14 17:06:46 -07:00

356 lines
9.4 KiB
Python

"""Castle manifest models — program specs, service specs, job specs."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
class RestartPolicy(str, Enum):
NO = "no"
ON_FAILURE = "on-failure"
ALWAYS = "always"
class TLSMode(str, Enum):
OFF = "off"
INTERNAL = "internal"
LETSENCRYPT = "letsencrypt"
# ---------------------
# Run specs (discriminated union)
# ---------------------
class RunBase(BaseModel):
runner: str
class RunCommand(RunBase):
runner: Literal["command"]
argv: list[str] = Field(min_length=1)
class RunPython(RunBase):
runner: Literal["python"]
program: str
args: list[str] = Field(default_factory=list)
class RunContainer(RunBase):
runner: Literal["container"]
image: str
command: list[str] | None = None
args: list[str] = Field(default_factory=list)
ports: dict[int, int] = Field(default_factory=dict)
volumes: list[str] = Field(default_factory=list)
env: EnvMap = Field(default_factory=dict)
workdir: str | None = None
class RunNode(RunBase):
runner: Literal["node"]
script: str
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
args: list[str] = Field(default_factory=list)
class RunRemote(RunBase):
runner: Literal["remote"]
base_url: str
health_url: str | None = None
RunSpec = Annotated[
Union[RunCommand, RunPython, RunContainer, RunNode, RunRemote],
Field(discriminator="runner"),
]
# ---------------------
# Systemd management
# ---------------------
class ReadinessHttpGet(BaseModel):
http_get: str
timeout_seconds: int = 2
interval_seconds: int = 2
success_codes: list[int] = Field(default_factory=lambda: [200])
class SystemdSpec(BaseModel):
enable: bool = True
user: bool = True
description: str | None = None
after: list[str] = Field(default_factory=list)
requires: 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
no_new_privileges: bool = True
readiness: ReadinessHttpGet | None = None
exec_reload: str | None = None
class ManageSpec(BaseModel):
systemd: SystemdSpec | None = None
# ---------------------
# HTTP exposure + proxy
# ---------------------
class HttpInternal(BaseModel):
host: str = "127.0.0.1"
port: int = Field(ge=1, le=65535)
unix_socket: str | None = None
class HttpPublic(BaseModel):
hostnames: list[str] = Field(min_length=1)
path_prefix: str = "/"
tls: TLSMode = TLSMode.INTERNAL
class HttpExposeSpec(BaseModel):
internal: HttpInternal
public: HttpPublic | None = None
health_path: str | None = None
class ExposeSpec(BaseModel):
http: HttpExposeSpec | None = None
class CaddySpec(BaseModel):
enable: bool = True
path_prefix: str | None = None
# Route by hostname instead of (or alongside) a path prefix. The whole host
# maps to the backend root, so an app built with base="/" works unchanged —
# the fix for proxying a root-based SPA the gateway can't rebuild.
host: str | None = None
extra_snippets: list[str] = Field(default_factory=list)
class ProxySpec(BaseModel):
caddy: CaddySpec | None = None
# ---------------------
# 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 Capability(BaseModel):
type: str
name: str | None = None
meta: dict[str, str] = Field(default_factory=dict)
# ---------------------
# Defaults
# ---------------------
class DefaultsSpec(BaseModel):
env: EnvMap = Field(default_factory=dict)
# ---------------------
# Program spec — software identity
# ---------------------
class ProgramSpec(BaseModel):
"""Software catalog entry — what exists."""
id: str = ""
description: str | None = None
behavior: str | None = None
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
system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list)
version: str | None = None
build: BuildSpec | None = None
provides: list[Capability] = Field(default_factory=list)
consumes: list[Capability] = Field(default_factory=list)
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
# ---------------------
# Service spec — long-running daemon
# ---------------------
class ServiceSpec(BaseModel):
"""Long-running daemon deployment config."""
model_config = ConfigDict(populate_by_name=True)
id: str = ""
# The program this service deploys. (`component` accepted as a legacy alias.)
program: str | None = Field(
default=None, validation_alias=AliasChoices("program", "component")
)
description: str | None = None
run: RunSpec
expose: ExposeSpec | None = None
proxy: ProxySpec | None = None
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None
@model_validator(mode="after")
def _validate_consistency(self) -> ServiceSpec:
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.")
return self
# ---------------------
# Job spec — scheduled task
# ---------------------
class JobSpec(BaseModel):
"""Scheduled task that runs periodically and exits."""
model_config = ConfigDict(populate_by_name=True)
id: str = ""
# The program this job runs. (`component` accepted as a legacy alias.)
program: str | None = Field(
default=None, validation_alias=AliasChoices("program", "component")
)
description: str | None = None
run: RunSpec
schedule: str
timezone: str = "America/Los_Angeles"
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None
# ---------------------
# Unit spec — unified definition
# ---------------------
class UnitKind(str, Enum):
TOOL = "tool"
SERVICE = "service"
SITE = "site"
JOB = "job"
class UnitSpec(BaseModel):
"""Unified program+deployment definition.
Expands into ProgramSpec + optional ServiceSpec/JobSpec at config load time.
"""
id: str = ""
kind: UnitKind
description: str | None = None
# Program identity
source: str | None = None
stack: str | None = None
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)
# Service fields (kind=service)
port: int | None = Field(default=None, ge=1, le=65535)
path_prefix: str | None = None
health_path: str | None = None
# Job fields (kind=job)
schedule: str | None = None
timezone: str = "America/Los_Angeles"
argv: list[str] | None = None
# Simplified env (flat dict, replaces defaults.env nesting)
env: EnvMap = Field(default_factory=dict)
@model_validator(mode="after")
def _validate_kind_fields(self) -> UnitSpec:
if self.kind == UnitKind.SERVICE and self.port is None:
raise ValueError("kind=service requires 'port'")
if self.kind == UnitKind.JOB and self.schedule is None:
raise ValueError("kind=job requires 'schedule'")
if self.kind == UnitKind.JOB and self.argv is None:
raise ValueError("kind=job requires 'argv'")
return self