483 lines
16 KiB
Python
483 lines
16 KiB
Python
"""Castle configuration and registry management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
from castle_core.manifest import (
|
|
CaddySpec,
|
|
DefaultsSpec,
|
|
ExposeSpec,
|
|
HttpExposeSpec,
|
|
HttpInternal,
|
|
JobSpec,
|
|
ManageSpec,
|
|
ProgramSpec,
|
|
ProxySpec,
|
|
RunCommand,
|
|
RunPython,
|
|
ServiceSpec,
|
|
SystemdSpec,
|
|
UnitKind,
|
|
UnitSpec,
|
|
)
|
|
|
|
|
|
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"
|
|
|
|
|
|
def _resolve_data_dir() -> Path:
|
|
"""Resolve the program data directory (service/program data I/O).
|
|
|
|
Decoupled from CASTLE_HOME so bulk data can live on a dedicated volume.
|
|
Defaults to /data/castle. Override with the CASTLE_DATA_DIR environment
|
|
variable (supports ~ and relative paths, which are expanded and made absolute).
|
|
"""
|
|
override = os.environ.get("CASTLE_DATA_DIR")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
return Path("/data/castle")
|
|
|
|
|
|
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"
|
|
DATA_DIR = _resolve_data_dir()
|
|
SECRETS_DIR = CASTLE_HOME / "secrets"
|
|
|
|
# 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(
|
|
"Could not find castle.yaml.\n"
|
|
f"Expected at: {CASTLE_HOME / 'castle.yaml'}"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class GatewayConfig:
|
|
"""Gateway configuration."""
|
|
|
|
port: int = 9000
|
|
|
|
|
|
@dataclass
|
|
class CastleConfig:
|
|
"""Full castle configuration."""
|
|
|
|
root: Path
|
|
gateway: GatewayConfig
|
|
repo: Path | None
|
|
programs: dict[str, ProgramSpec]
|
|
services: dict[str, ServiceSpec]
|
|
jobs: dict[str, JobSpec]
|
|
_unit_names: set[str] = field(default_factory=set)
|
|
_units_raw: dict[str, dict] = field(default_factory=dict)
|
|
|
|
@property
|
|
def tools(self) -> dict[str, ProgramSpec]:
|
|
"""Return programs that are tools (behavior == 'tool')."""
|
|
return {
|
|
k: v
|
|
for k, v in self.programs.items()
|
|
if v.behavior == "tool"
|
|
}
|
|
|
|
@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_vars(env: dict[str, str]) -> dict[str, str]:
|
|
"""Resolve ${secret:NAME} references in env values."""
|
|
resolved = {}
|
|
for key, value in env.items():
|
|
|
|
def replace_var(match: re.Match[str]) -> str:
|
|
ref = match.group(1)
|
|
if ref.startswith("secret:"):
|
|
secret_name = ref[7:]
|
|
return _read_secret(secret_name)
|
|
return match.group(0)
|
|
|
|
resolved[key] = re.sub(r"\$\{([^}]+)\}", replace_var, value)
|
|
return resolved
|
|
|
|
|
|
def _read_secret(name: str) -> str:
|
|
"""Read a secret from ~/.castle/secrets/<name>. Returns placeholder if not found."""
|
|
secret_path = SECRETS_DIR / name
|
|
if secret_path.exists():
|
|
return secret_path.read_text().strip()
|
|
return 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 _parse_service(name: str, data: dict) -> ServiceSpec:
|
|
"""Parse a services: entry into a ServiceSpec."""
|
|
data_copy = dict(data)
|
|
data_copy["id"] = name
|
|
return ServiceSpec.model_validate(data_copy)
|
|
|
|
|
|
def _parse_job(name: str, data: dict) -> JobSpec:
|
|
"""Parse a jobs: entry into a JobSpec."""
|
|
data_copy = dict(data)
|
|
data_copy["id"] = name
|
|
return JobSpec.model_validate(data_copy)
|
|
|
|
|
|
def _parse_unit(name: str, data: dict) -> UnitSpec:
|
|
"""Parse a units: entry into a UnitSpec."""
|
|
data_copy = dict(data)
|
|
data_copy["id"] = name
|
|
return UnitSpec.model_validate(data_copy)
|
|
|
|
|
|
# Stack convention defaults used during unit expansion.
|
|
_STACK_DEFAULTS: dict[str, dict[str, str]] = {
|
|
"python-fastapi": {"runner": "python", "health_path": "/health"},
|
|
"python-cli": {"runner": "command"},
|
|
"react-vite": {},
|
|
}
|
|
|
|
# Kind → ProgramSpec.behavior mapping.
|
|
_KIND_BEHAVIOR: dict[str, str] = {
|
|
"tool": "tool",
|
|
"service": "daemon",
|
|
"site": "frontend",
|
|
"job": "tool",
|
|
}
|
|
|
|
|
|
def _expand_units(
|
|
units: dict[str, UnitSpec],
|
|
programs: dict[str, ProgramSpec],
|
|
services: dict[str, ServiceSpec],
|
|
jobs: dict[str, JobSpec],
|
|
) -> None:
|
|
"""Expand units: entries into programs/services/jobs dicts (in-place)."""
|
|
for name, unit in units.items():
|
|
if name in programs or name in services or name in jobs:
|
|
raise ValueError(
|
|
f"Unit '{name}' conflicts with existing entry in "
|
|
f"programs/services/jobs"
|
|
)
|
|
|
|
defaults = _STACK_DEFAULTS.get(unit.stack or "", {})
|
|
|
|
# Always create a ProgramSpec
|
|
programs[name] = ProgramSpec(
|
|
id=name,
|
|
description=unit.description,
|
|
behavior=_KIND_BEHAVIOR[unit.kind.value],
|
|
source=unit.source,
|
|
stack=unit.stack,
|
|
system_dependencies=unit.system_dependencies,
|
|
install_extras=unit.install_extras,
|
|
version=unit.version,
|
|
build=unit.build,
|
|
tags=unit.tags,
|
|
)
|
|
|
|
if unit.kind == UnitKind.SERVICE:
|
|
assert unit.port is not None # guaranteed by validator
|
|
runner = defaults.get("runner", "python")
|
|
run_spec: RunCommand | RunPython
|
|
if runner == "python":
|
|
run_spec = RunPython(runner="python", program=name)
|
|
else:
|
|
run_spec = RunCommand(runner="command", argv=[name])
|
|
|
|
health = unit.health_path or defaults.get("health_path")
|
|
|
|
services[name] = ServiceSpec(
|
|
id=name,
|
|
component=name,
|
|
run=run_spec,
|
|
expose=ExposeSpec(
|
|
http=HttpExposeSpec(
|
|
internal=HttpInternal(port=unit.port),
|
|
health_path=health,
|
|
)
|
|
),
|
|
proxy=ProxySpec(caddy=CaddySpec(path_prefix=unit.path_prefix))
|
|
if unit.path_prefix
|
|
else None,
|
|
manage=ManageSpec(systemd=SystemdSpec()),
|
|
defaults=DefaultsSpec(env=dict(unit.env)) if unit.env else None,
|
|
)
|
|
|
|
elif unit.kind == UnitKind.JOB:
|
|
assert unit.schedule is not None # guaranteed by validator
|
|
assert unit.argv is not None # guaranteed by validator
|
|
jobs[name] = JobSpec(
|
|
id=name,
|
|
component=name,
|
|
description=unit.description,
|
|
run=RunCommand(runner="command", argv=list(unit.argv)),
|
|
schedule=unit.schedule,
|
|
timezone=unit.timezone,
|
|
manage=ManageSpec(systemd=SystemdSpec()),
|
|
defaults=DefaultsSpec(env=dict(unit.env)) if unit.env else None,
|
|
)
|
|
|
|
|
|
def load_config(root: Path | None = None) -> CastleConfig:
|
|
"""Load castle.yaml and return parsed configuration."""
|
|
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)
|
|
|
|
gateway_data = data.get("gateway", {})
|
|
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
|
|
|
# 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()
|
|
|
|
programs: dict[str, ProgramSpec] = {}
|
|
# Support both "programs:" and legacy "components:" key
|
|
programs_data = data.get("programs") or data.get("components") or {}
|
|
for name, comp_data in programs_data.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
|
|
|
|
services: dict[str, ServiceSpec] = {}
|
|
for name, svc_data in data.get("services", {}).items():
|
|
services[name] = _parse_service(name, svc_data)
|
|
|
|
jobs: dict[str, JobSpec] = {}
|
|
for name, job_data in data.get("jobs", {}).items():
|
|
jobs[name] = _parse_job(name, job_data)
|
|
|
|
# Expand units: section into programs/services/jobs
|
|
units_data = data.get("units") or {}
|
|
units: dict[str, UnitSpec] = {}
|
|
for name, unit_data in units_data.items():
|
|
units[name] = _parse_unit(name, unit_data)
|
|
|
|
unit_names: set[str] = set()
|
|
if units:
|
|
_expand_units(units, programs, services, jobs)
|
|
unit_names = set(units.keys())
|
|
# Resolve source paths for unit-generated programs
|
|
for name in unit_names:
|
|
prog = programs[name]
|
|
if prog.source:
|
|
if prog.source.startswith("repo:") and repo_path:
|
|
prog.source = str(repo_path / prog.source[5:])
|
|
elif not Path(prog.source).is_absolute():
|
|
prog.source = str(root / prog.source)
|
|
|
|
return CastleConfig(
|
|
root=root,
|
|
repo=repo_path,
|
|
gateway=gateway,
|
|
programs=programs,
|
|
services=services,
|
|
jobs=jobs,
|
|
_unit_names=unit_names,
|
|
_units_raw=dict(units_data),
|
|
)
|
|
|
|
|
|
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",
|
|
"proxy",
|
|
"caddy",
|
|
}
|
|
|
|
|
|
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
|
|
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
|
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)
|
|
return _clean_for_yaml(result)
|
|
|
|
|
|
def save_config(config: CastleConfig) -> None:
|
|
"""Save castle configuration to castle.yaml."""
|
|
data: dict = {"gateway": {"port": config.gateway.port}}
|
|
|
|
if config.repo:
|
|
data["repo"] = str(config.repo)
|
|
|
|
# Write units: section (raw roundtrip preserves user's original YAML)
|
|
if config._units_raw:
|
|
data["units"] = dict(config._units_raw)
|
|
|
|
# Write programs: (excluding unit-expanded ones)
|
|
non_unit_programs = {
|
|
k: v for k, v in config.programs.items() if k not in config._unit_names
|
|
}
|
|
if non_unit_programs:
|
|
data["programs"] = {}
|
|
for name, spec in non_unit_programs.items():
|
|
d = _spec_to_yaml_dict(spec)
|
|
# Store relative source paths in YAML
|
|
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))
|
|
data["programs"][name] = d
|
|
continue
|
|
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
|
|
data["programs"][name] = d
|
|
|
|
# Write services: (excluding unit-expanded ones)
|
|
non_unit_services = {
|
|
k: v for k, v in config.services.items() if k not in config._unit_names
|
|
}
|
|
if non_unit_services:
|
|
data["services"] = {}
|
|
for name, spec in non_unit_services.items():
|
|
data["services"][name] = _spec_to_yaml_dict(spec)
|
|
|
|
# Write jobs: (excluding unit-expanded ones)
|
|
non_unit_jobs = {
|
|
k: v for k, v in config.jobs.items() if k not in config._unit_names
|
|
}
|
|
if non_unit_jobs:
|
|
data["jobs"] = {}
|
|
for name, spec in non_unit_jobs.items():
|
|
data["jobs"][name] = _spec_to_yaml_dict(spec)
|
|
|
|
config_path = config.root / "castle.yaml"
|
|
with open(config_path, "w") as f:
|
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
|
|
|
|
def ensure_dirs() -> None:
|
|
"""Ensure castle directories exist."""
|
|
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)
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(SECRETS_DIR, 0o700)
|