Files
wild-pc/core/src/castle_core/config.py
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

731 lines
30 KiB
Python

"""Castle configuration and registry management."""
from __future__ import annotations
import os
import re
from dataclasses import InitVar, dataclass, field
from pathlib import Path
import yaml
from pydantic import BaseModel, TypeAdapter
from castle_core.manifest import (
AgentSpec,
DeploymentSpec,
ProgramSpec,
kind_for,
)
# Validator for the manager-discriminated deployment union (it's an Annotated
# Union, not a BaseModel, so it needs a TypeAdapter to parse a dict).
_DEPLOYMENT_ADAPTER: TypeAdapter[DeploymentSpec] = TypeAdapter(DeploymentSpec)
def _resolve_castle_home() -> Path:
"""Resolve the castle home directory (config, code, artifacts, secrets).
Defaults to ~/.castle. Override with the CASTLE_HOME environment variable
(supports ~ and relative paths, which are expanded and made absolute).
"""
override = os.environ.get("CASTLE_HOME")
if override:
return Path(override).expanduser().resolve()
return Path.home() / ".castle"
_DEFAULT_DATA_DIR = Path("/data/castle")
_DEFAULT_REPOS_DIR = Path("/data/repos")
class CastleDirError(RuntimeError):
"""A required castle directory can't be created (e.g. data_dir outside a writable
parent). Carries an actionable message; surfaced to the CLI and the api instead of
a bare PermissionError traceback."""
def _resolve_root_path(
env_var: str, yaml_value: object, anchor: Path, default: Path
) -> Path:
"""Resolve a configurable root with precedence: env var > castle.yaml > default.
`~` is expanded; a relative path is anchored to `anchor` (the dir containing
castle.yaml) — never cwd, so the CLI (shell cwd) and the api service (unit cwd)
resolve identically. The built-in default is returned as-is (so it compares equal
for the "persist only when non-default" check in save_config)."""
raw = os.environ.get(env_var) or yaml_value
if not raw:
return default
p = Path(str(raw)).expanduser()
if not p.is_absolute():
p = anchor / p
return p.resolve()
CASTLE_HOME = _resolve_castle_home()
CODE_DIR = CASTLE_HOME / "code"
ARTIFACTS_DIR = CASTLE_HOME / "artifacts"
SPECS_DIR = ARTIFACTS_DIR / "specs"
CONTENT_DIR = ARTIFACTS_DIR / "content"
SECRETS_DIR = CASTLE_HOME / "secrets"
# data_dir and repos_dir are deliberately NOT module constants. Unlike the CASTLE_HOME
# family above (env-or-default — the dir that *holds* castle.yaml can't be configured
# inside it), these are per-instance settings read from castle.yaml. A module global
# would be a second copy of that value, resolved once at import against one process's
# environment — exactly what let the CLI and the api service drift. They live only on
# the loaded CastleConfig; read config.data_dir / config.repos_dir (see load_config).
# User tool directories — the single source of truth for "where our CLIs live".
# Used both at build time (dev-verb subprocess PATH) and at run time (generated
# systemd unit PATH) so a service sees the same tools castle used to build it.
# Order matters: pnpm's modern standalone installer puts its shim in
# $PNPM_HOME/bin, which must win over the bare dir (older installs leave a stale
# version wrapper there). nvm/node is intentionally omitted — it's versioned and
# brittle to hardcode. A program's node is resolved per-program from its declared
# pin (.node-version/.nvmrc/engines) and prepended ahead of these dirs at both the
# build subprocess (stacks._build_env) and the runtime unit PATH (Deployment
# .path_prepend) — see castle_core.toolchains.
USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin",
Path.home() / ".local" / "share" / "pnpm",
Path("/usr/local/go/bin"),
]
# Backwards-compat aliases (used by existing imports)
GENERATED_DIR = SPECS_DIR
STATIC_DIR = CONTENT_DIR
def find_castle_root() -> Path:
"""Find the castle config root (directory containing castle.yaml).
Search order:
1. ~/.castle/castle.yaml (the canonical instance location)
2. Walk up from cwd (for development/testing)
"""
# Canonical location first
if (CASTLE_HOME / "castle.yaml").exists():
return CASTLE_HOME
# Fallback: walk up from cwd
current = Path.cwd()
while current != current.parent:
if (current / "castle.yaml").exists():
return current
current = current.parent
raise FileNotFoundError(
f"Could not find castle.yaml.\nExpected at: {CASTLE_HOME / 'castle.yaml'}"
)
@dataclass
class GatewayConfig:
"""Gateway configuration."""
port: int = 9000
# None/"off" → HTTP-only gateway. "acme" → real Let's Encrypt wildcard cert
# (*.domain) via a DNS-01 challenge; publicly trusted, no CA to install.
tls: str | None = None
# acme mode only: the zone for the wildcard cert and host-route subdomains
# (e.g. "civil.payne.io" → host routes become <service>.civil.payne.io).
domain: str | None = None
acme_email: str | None = None
acme_dns_provider: str = "cloudflare"
# Public exposure via the Cloudflare tunnel (optional). `public_domain` is the
# separate zone public services are published at (e.g. "pub.payne.io" →
# <service>.pub.payne.io), kept distinct from `domain` so internal subdomain
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
public_domain: str | None = None
tunnel_id: str | None = None
# acme mode only: emit the `events { on cert_obtained exec castle tls reconcile }`
# hook so certs materialized onto raw-TCP services refresh on renewal. Requires
# the events-exec plugin in the gateway's Caddy build — set true only once that
# Caddy is installed (see docs/tcp-exposure.md §5). Default false keeps a
# plugin-less gateway parseable.
cert_hook: bool = False
# Deployment kinds and the CastleConfig store each lives in. Kind is STRUCTURAL —
# a deployment's identity is (name, kind), so names are unique within a kind but may
# collide across kinds (a `backup` tool + service + job coexist). `kind_for` (manifest)
# stays only to validate that a spec's manager/schedule matches the store it's in.
KINDS = ("service", "job", "tool", "static", "reference")
_KIND_STORE = {
"service": "services",
"job": "jobs",
"tool": "tools",
"static": "statics",
"reference": "references",
}
@dataclass
class CastleConfig:
"""Full castle configuration."""
root: Path
gateway: GatewayConfig
repo: Path | None
programs: dict[str, ProgramSpec]
# Per-kind deployment stores (the primary representation). Each is name-keyed and
# unique within its kind; a name may appear in more than one store. There is no
# single flat `deployments` dict — use `all_deployments()` / `deployments_named()`
# / the kind store directly (`config.services[name]`).
services: dict[str, DeploymentSpec] = field(default_factory=dict)
jobs: dict[str, DeploymentSpec] = field(default_factory=dict)
tools: dict[str, DeploymentSpec] = field(default_factory=dict)
statics: dict[str, DeploymentSpec] = field(default_factory=dict)
references: dict[str, DeploymentSpec] = field(default_factory=dict)
# Launchable agent CLIs for the dashboard terminal UX (assistant-agnostic).
# Optional; empty means the API falls back to a built-in default set.
agents: dict[str, AgentSpec] = field(default_factory=dict)
# Configurable roots — the single source of truth (no module-constant twin).
# load_config sets them (env > castle.yaml > default); a bare constructor gets the
# built-in defaults so tests/callers that don't care stay valid.
data_dir: Path = field(default_factory=lambda: _DEFAULT_DATA_DIR)
repos_dir: Path = field(default_factory=lambda: _DEFAULT_REPOS_DIR)
# Fleet role: "authority" (may write shared config/secrets to the mesh) or
# "follower" (reconciles from it). Static — pinned here, no election.
role: str = "follower"
# Construction convenience only (not stored): a flat name→spec dict is routed
# into the per-kind stores by kind_for. Lets callers/tests hand us a flat map
# without pre-splitting it; there is still no flat `deployments` attribute.
deployments: InitVar[dict[str, DeploymentSpec] | None] = None
def __post_init__(self, deployments: dict[str, DeploymentSpec] | None) -> None:
for name, spec in (deployments or {}).items():
self.store_for(kind_for(spec))[name] = spec
def store_for(self, kind: str) -> dict[str, DeploymentSpec]:
"""The name-keyed deployment store for a kind (service|job|tool|static|reference)."""
return getattr(self, _KIND_STORE[kind])
def all_deployments(self) -> list[tuple[str, str, DeploymentSpec]]:
"""Every deployment as `(kind, name, spec)`, kind-then-name ordered. The single
shared iterate-all — the converge loop dispatches on `spec.manager`, so the
machinery stays shared; only the namespace is split by kind."""
out: list[tuple[str, str, DeploymentSpec]] = []
for kind in KINDS:
store = self.store_for(kind)
out.extend((kind, name, store[name]) for name in sorted(store))
return out
def deployment(self, kind: str, name: str) -> DeploymentSpec | None:
"""A single deployment by its `(kind, name)` identity, or None."""
return self.store_for(kind).get(name)
def deployments_named(self, name: str) -> list[tuple[str, DeploymentSpec]]:
"""`(kind, spec)` for every kind that has a deployment with this bare name
(≤5). Used where a caller has only a name and no kind (apply/restart/redirect)."""
return [(kind, spec) for kind, n, spec in self.all_deployments() if n == name]
def deployments_of(self, program: str) -> list[tuple[str, str]]:
"""A program's deployments as (deployment-name, kind) pairs, name-sorted.
A deployment belongs to a program when it names it (`program:`) or shares its
name (the 1:1 tool/static case). Empty for a bare, undeployed program.
"""
return sorted(
(name, kind)
for kind, name, dep in self.all_deployments()
if name == program or dep.program == program
)
@property
def frontends(self) -> dict[str, ProgramSpec]:
"""Return programs that are frontends (have build outputs)."""
return {
k: v
for k, v in self.programs.items()
if v.build and (v.build.outputs or v.build.commands)
}
def resolve_env_split(
env: dict[str, str], context: dict[str, str] | None = None
) -> tuple[dict[str, str], dict[str, str]]:
"""Resolve placeholders, splitting secret-bearing vars from plain ones.
Returns ``(plain, secret)``. A var is *secret-bearing* if its raw value
contained a ``${secret:...}`` reference — including composite values like
``neo4j/${secret:NEO4J_PASSWORD}``. Both dicts hold fully-resolved values;
partitioning lets callers keep secrets out of unit files and process argv
(routing them through a mode-0600 env file) while inlining the rest.
- ``${secret:NAME}`` reads `~/.castle/secrets/NAME`.
- ``${port}`` / ``${data_dir}`` / ``${name}`` / ``${public_url}`` (and
anything else in ``context``) expand to castle's computed values, so a
service maps them to whatever env var its program reads (e.g.
``MY_PORT: ${port}``) without hardcoding or castle silently injecting a
guessed var name. ``${public_url}`` is the service's gateway-facing base
URL (``https://<name>.<domain>`` under acme) — the origin an app allowlists.
"""
context = context or {}
plain: dict[str, str] = {}
secret: dict[str, str] = {}
for key, value in env.items():
def replace_var(match: re.Match[str]) -> str:
ref = match.group(1)
if ref.startswith("secret:"):
return _read_secret(ref[7:])
if ref in context:
return context[ref]
return match.group(0)
resolved = re.sub(r"\$\{([^}]+)\}", replace_var, value)
if re.search(r"\$\{secret:[^}]+\}", value):
secret[key] = resolved
else:
plain[key] = resolved
return plain, secret
def resolve_placeholders(value: str, context: dict[str, str] | None) -> str:
"""Expand ``${key}`` refs in a single string from ``context``.
The one ``${...}`` grammar shared by env resolution (:func:`resolve_env_split`)
and run-spec expansion (argv/volumes/env in a container launch), so a new
placeholder only has to be added to the context dict, never to a second engine.
Unknown refs — including ``${secret:...}`` — pass through untouched (secrets
never belong in argv; they go via ``--env-file``). Write ``$${key}`` to emit a
literal ``${key}`` (e.g. a container arg the container's own shell must expand).
"""
if not context:
return value
def replace_var(match: re.Match[str]) -> str:
ref = match.group(1)
return context.get(ref, match.group(0))
# Split on the `$$` escape so an escaped `$${x}` never reaches the substitution
# regex, then rejoin with a literal `$`.
return "$".join(
re.sub(r"\$\{([^}]+)\}", replace_var, part) for part in value.split("$$")
)
def resolve_env_vars(
env: dict[str, str], context: dict[str, str] | None = None
) -> dict[str, str]:
"""Resolve placeholders in env values (secrets included), preserving order.
Convenience wrapper over :func:`resolve_env_split` for callers that want a
single flat dict. Prefer ``resolve_env_split`` when secrets must be kept out
of generated artifacts.
"""
plain, secret = resolve_env_split(env, context)
return {k: secret[k] if k in secret else plain[k] for k in env}
def _secrets_settings() -> dict:
"""The ``secrets:`` block of castle.yaml — selects the backend."""
try:
data = yaml.safe_load((CASTLE_HOME / "castle.yaml").read_text()) or {}
return data.get("secrets") or {}
except Exception:
return {}
def read_secret(name: str) -> str | None:
"""Resolve a secret via the active backend, or None if unset.
The public helper for code that reads secrets directly (DNS/supabase/etc.) so
everything goes through the one backend rather than the filesystem.
"""
from castle_core.secret_backends import build_backend
return build_backend(SECRETS_DIR, _secrets_settings()).read(name)
def _read_secret(name: str) -> str:
"""Like :func:`read_secret` but returns a ``<MISSING_SECRET:...>`` placeholder
for unresolved secrets (used by ``${secret:...}`` env substitution)."""
value = read_secret(name)
return value if value is not None else f"<MISSING_SECRET:{name}>"
def _parse_program(name: str, data: dict) -> ProgramSpec:
"""Parse a programs: entry into a ProgramSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ProgramSpec.model_validate(data_copy)
def _normalize_deployment_dict(data: dict) -> dict:
"""Map a legacy service/job entry to the manager-discriminated shape.
Legacy entries carry `run.runner` (including static/path/remote); new entries
carry `manager` and (for systemd) `run.launcher`. New-shape entries pass through.
"""
if "manager" in data:
return data
d = dict(data)
run = dict(d.pop("run", None) or {})
runner = run.get("runner")
if runner == "static":
d["manager"] = "caddy"
if run.get("root"):
d["root"] = run["root"]
elif runner == "path":
d["manager"] = "path"
elif runner == "remote":
d["manager"] = "none"
if run.get("base_url"):
d["base_url"] = run["base_url"]
if run.get("health_url"):
d["health_url"] = run["health_url"]
else:
# A process launcher (python/command/container/compose/node) → systemd.
d["manager"] = "systemd"
launch = {k: v for k, v in run.items() if k != "runner"}
launch["launcher"] = runner
d["run"] = launch
return d
def _parse_deployment(name: str, data: dict) -> DeploymentSpec:
"""Parse a deployment entry (new or legacy shape) into a DeploymentSpec."""
data_copy = _normalize_deployment_dict(data)
data_copy = dict(data_copy)
data_copy["id"] = name
return _DEPLOYMENT_ADAPTER.validate_python(data_copy)
def _load_resource_dir(directory: Path) -> dict[str, dict]:
"""Load every *.yaml file in a resource directory.
The filename stem becomes the resource id. Returns a mapping of
id → parsed YAML dict (empty mappings normalized to {}).
"""
result: dict[str, dict] = {}
if not directory.is_dir():
return result
for path in sorted(directory.glob("*.yaml")):
with open(path) as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} must contain a YAML mapping")
result[path.stem] = data
return result
def parse_gateway(gateway_data: dict) -> GatewayConfig:
"""Build a GatewayConfig from a castle.yaml ``gateway:`` mapping.
The single parser shared by ``load_config`` and the API's whole-file editor,
so a newly added gateway field can't be honored in one place and silently
dropped in the other (which is exactly how ``cert_hook`` got wiped on a
full-config save).
"""
return GatewayConfig(
port=gateway_data.get("port", 9000),
tls=gateway_data.get("tls"),
domain=gateway_data.get("domain"),
acme_email=gateway_data.get("acme_email"),
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
public_domain=gateway_data.get("public_domain"),
tunnel_id=gateway_data.get("tunnel_id"),
cert_hook=gateway_data.get("cert_hook", False),
)
def load_config(root: Path | None = None) -> CastleConfig:
"""Load castle config: global castle.yaml + programs/ and deployments/ dirs."""
if root is None:
root = find_castle_root()
config_path = root / "castle.yaml"
if not config_path.exists():
raise FileNotFoundError(f"Castle config not found: {config_path}")
with open(config_path) as f:
data = yaml.safe_load(f) or {}
gateway = parse_gateway(data.get("gateway", {}))
# repo: field points to the git repo for repo-relative sources
repo_path: Path | None = None
if data.get("repo"):
repo_path = Path(data["repo"]).expanduser()
# Configurable roots: env > this file's data_dir/repos_dir > default, anchored to
# `root` (the dir holding this castle.yaml) so a per-call load_config is correct
# regardless of the import-time constants (which resolved against CASTLE_HOME).
data_dir = _resolve_root_path(
"CASTLE_DATA_DIR", data.get("data_dir"), root, _DEFAULT_DATA_DIR
)
repos_dir = _resolve_root_path(
"CASTLE_REPOS_DIR", data.get("repos_dir"), root, _DEFAULT_REPOS_DIR
)
programs: dict[str, ProgramSpec] = {}
for name, comp_data in _load_resource_dir(root / "programs").items():
prog = _parse_program(name, comp_data)
# Resolve source paths to absolute
if prog.source:
if prog.source.startswith("repo:") and repo_path:
# repo:castle-api → /data/repos/castle/castle-api
prog.source = str(repo_path / prog.source[5:])
elif not Path(prog.source).is_absolute():
prog.source = str(root / prog.source)
programs[name] = prog
stores = _load_deployments(root)
_validate_subdomains(stores)
agents: dict[str, AgentSpec] = {
name: AgentSpec.model_validate(spec or {})
for name, spec in (data.get("agents") or {}).items()
}
config = CastleConfig(
root=root,
repo=repo_path,
gateway=gateway,
programs=programs,
agents=agents,
data_dir=data_dir,
repos_dir=repos_dir,
role=data.get("role", "follower"),
**stores,
)
return config
def _validate_subdomains(stores: dict[str, dict[str, DeploymentSpec]]) -> None:
"""A gateway subdomain (``<name>.<domain>``) must be globally unique across kinds.
Only HTTP-exposed kinds claim one: a proxied service, or a static site. A name may
still be a tool/service/job trio (only the service is HTTP-exposed), but a service
and a static can't share a name (they'd fight over the same subdomain)."""
claimants: dict[str, list[str]] = {}
for name, spec in stores["services"].items():
if getattr(spec, "http_exposed", False):
claimants.setdefault(name, []).append("service")
for name in stores["statics"]:
claimants.setdefault(name, []).append("static")
for name, kinds in claimants.items():
if len(kinds) > 1:
raise ValueError(
f"subdomain '{name}' is claimed by multiple HTTP-exposed deployments "
f"({', '.join(kinds)}); a name can be HTTP-exposed by at most one kind"
)
def _load_deployments(root: Path) -> dict[str, dict[str, DeploymentSpec]]:
"""Load the per-kind deployment stores for a config root.
New layout: ``deployments/<store>/<name>.yaml`` (store = services|jobs|tools|
statics|references). Read-compat for the pre-migration layouts: flat
``deployments/*.yaml`` files, and the older ``services/``+``jobs/`` split. Every
file is routed to its store by ``kind_for(spec)`` — the dir is a namespace, the
spec's manager/schedule is the source of truth (they must agree post-migration).
"""
stores: dict[str, dict[str, DeploymentSpec]] = {s: {} for s in _KIND_STORE.values()}
def route(name: str, data: dict) -> None:
spec = _parse_deployment(name, data)
stores[_KIND_STORE[kind_for(spec)]][name] = spec
dep_dir = root / "deployments"
# New per-kind subdirs.
for store in _KIND_STORE.values():
for name, data in _load_resource_dir(dep_dir / store).items():
route(name, data)
# Legacy flat deployments/*.yaml (top-level only — subdirs handled above).
for name, data in _load_resource_dir(dep_dir).items():
route(name, data)
# Oldest layout: services/ + jobs/ dirs, only if there's no deployments/ dir.
if not dep_dir.is_dir():
for legacy in ("services", "jobs"):
for name, data in _load_resource_dir(root / legacy).items():
route(name, data)
return stores
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
"""Recursively remove empty lists and non-structural empty dicts."""
if preserve_keys is None:
preserve_keys = _STRUCTURAL_KEYS
if isinstance(data, dict):
cleaned = {}
for k, v in data.items():
v = _clean_for_yaml(v, preserve_keys)
# Keep structural keys even if empty dict
if k in preserve_keys and isinstance(v, dict):
cleaned[k] = v
continue
# Skip empty collections
if isinstance(v, (list, dict)) and not v:
continue
cleaned[k] = v
return cleaned
elif isinstance(data, list):
return [_clean_for_yaml(item, preserve_keys) for item in data]
return data
# Keys whose presence is structurally significant even with all-default values.
# We serialize these as empty dicts `{}` so they survive a roundtrip.
_STRUCTURAL_KEYS = {
"manage",
"systemd",
"expose",
}
def _spec_to_yaml_dict(spec: BaseModel) -> dict:
"""Serialize a ProgramSpec or DeploymentSpec to a YAML-friendly dict."""
exclude_fields = {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
minimal = spec.model_dump(
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
)
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
if isinstance(full_val, dict):
result = {}
for k, fv in full_val.items():
mv = min_val.get(k) if isinstance(min_val, dict) else None
if k in _STRUCTURAL_KEYS:
merged = merge(fv, mv, k)
if merged is not None:
result[k] = merged
elif mv is not None:
result[k] = merge(fv, mv, k)
elif isinstance(fv, dict):
merged = merge(fv, None, k)
if merged:
result[k] = merged
return result if result else ({} if key in _STRUCTURAL_KEYS else result)
elif isinstance(full_val, list):
if min_val is not None:
return full_val
return []
else:
if min_val is not None:
return full_val
return None
result = merge(full, minimal)
cleaned = _clean_for_yaml(result)
return cleaned if isinstance(cleaned, dict) else {}
def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict:
"""Serialize a ProgramSpec, rewriting absolute source paths to relative."""
d = _spec_to_yaml_dict(spec)
if d.get("source") and Path(d["source"]).is_absolute():
src = Path(d["source"])
# If source is under repo, store as repo:relative
if config.repo:
try:
d["source"] = "repo:" + str(src.relative_to(config.repo))
return d
except ValueError:
pass
# Otherwise store relative to config root
try:
d["source"] = str(src.relative_to(config.root))
except ValueError:
pass # not under root — keep absolute
return d
def _write_resource_dir(directory: Path, specs: dict[str, dict]) -> None:
"""Write each spec to <directory>/<name>.yaml and prune orphaned files."""
directory.mkdir(parents=True, exist_ok=True)
for name, d in specs.items():
with open(directory / f"{name}.yaml", "w") as f:
yaml.dump(d, f, default_flow_style=False, sort_keys=False)
# Prune files with no corresponding in-memory entry
for path in directory.glob("*.yaml"):
if path.stem not in specs:
path.unlink()
def save_config(config: CastleConfig) -> None:
"""Save castle config: global castle.yaml + programs/ and deployments/ dirs."""
gateway_data: dict = {"port": config.gateway.port}
if config.gateway.tls:
gateway_data["tls"] = config.gateway.tls
if config.gateway.domain:
gateway_data["domain"] = config.gateway.domain
if config.gateway.acme_email:
gateway_data["acme_email"] = config.gateway.acme_email
# Only persist the provider when non-default, to keep castle.yaml minimal.
if (
config.gateway.acme_dns_provider
and config.gateway.acme_dns_provider != "cloudflare"
):
gateway_data["acme_dns_provider"] = config.gateway.acme_dns_provider
if config.gateway.public_domain:
gateway_data["public_domain"] = config.gateway.public_domain
if config.gateway.tunnel_id:
gateway_data["tunnel_id"] = config.gateway.tunnel_id
if config.gateway.cert_hook:
gateway_data["cert_hook"] = config.gateway.cert_hook
data: dict = {"gateway": gateway_data}
if config.repo:
data["repo"] = str(config.repo)
# Persist the configurable roots only when non-default, keeping castle.yaml minimal.
# These MUST round-trip: save_config rewrites the file from scratch, so a root that
# isn't re-emitted here would be silently dropped on the next apply.
if config.data_dir != _DEFAULT_DATA_DIR:
data["data_dir"] = str(config.data_dir)
if config.repos_dir != _DEFAULT_REPOS_DIR:
data["repos_dir"] = str(config.repos_dir)
if config.agents:
data["agents"] = {
n: s.model_dump(exclude_none=True, exclude_defaults=True)
for n, s in config.agents.items()
}
config_path = config.root / "castle.yaml"
with open(config_path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
_write_resource_dir(
config.root / "programs",
{n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()},
)
# Per-kind: deployments/<store>/<name>.yaml (each store pruned independently).
dep_dir = config.root / "deployments"
for kind, store in _KIND_STORE.items():
_write_resource_dir(
dep_dir / store,
{n: _spec_to_yaml_dict(d) for n, d in config.store_for(kind).items()},
)
# Migration cleanup: drop any pre-migration flat deployments/*.yaml files.
if dep_dir.is_dir():
for path in dep_dir.glob("*.yaml"):
path.unlink()
def ensure_dirs(config: CastleConfig) -> None:
"""Ensure castle directories exist. Takes the config so the data dir comes from the
one source of truth (config.data_dir), not a process-resolved global."""
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
CODE_DIR.mkdir(parents=True, exist_ok=True)
SPECS_DIR.mkdir(parents=True, exist_ok=True)
CONTENT_DIR.mkdir(parents=True, exist_ok=True)
# The data dir can live outside $HOME (a dedicated volume), so its parent may be
# unwritable or absent — fail loud with a fix, not a bare PermissionError.
data_dir = config.data_dir
try:
data_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise CastleDirError(
f"Cannot create data dir {data_dir}: {e.strerror or e}. "
f"Set data_dir: in {CASTLE_HOME / 'castle.yaml'} (or export CASTLE_DATA_DIR) "
f"to a writable path, or create it: "
f"sudo mkdir -p {data_dir} && sudo chown $(id -un) {data_dir}"
) from e
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(SECRETS_DIR, 0o700)
# Generated per-deployment secret env files (EnvironmentFile= / --env-file)
# live here, kept out of unit files and process argv.
secret_env_dir = SECRETS_DIR / "env"
secret_env_dir.mkdir(parents=True, exist_ok=True)
os.chmod(secret_env_dir, 0o700)