refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling

This commit is contained in:
2026-02-22 23:19:16 -08:00
parent 033a76ccfd
commit d52d8829ba
37 changed files with 1271 additions and 414 deletions

View File

@@ -31,6 +31,7 @@ def find_castle_root() -> Path:
CASTLE_HOME = Path.home() / ".castle"
GENERATED_DIR = CASTLE_HOME / "generated"
SECRETS_DIR = CASTLE_HOME / "secrets"
STATIC_DIR = CASTLE_HOME / "static"
@dataclass
@@ -51,23 +52,17 @@ class CastleConfig:
@property
def services(self) -> dict[str, ComponentManifest]:
"""Return components with the SERVICE role."""
return {
k: v for k, v in self.components.items() if Role.SERVICE in v.roles
}
return {k: v for k, v in self.components.items() if Role.SERVICE in v.roles}
@property
def tools(self) -> dict[str, ComponentManifest]:
"""Return components with the TOOL role."""
return {
k: v for k, v in self.components.items() if Role.TOOL in v.roles
}
return {k: v for k, v in self.components.items() if Role.TOOL in v.roles}
@property
def workers(self) -> dict[str, ComponentManifest]:
"""Return components with the WORKER role."""
return {
k: v for k, v in self.components.items() if Role.WORKER in v.roles
}
return {k: v for k, v in self.components.items() if Role.WORKER in v.roles}
@property
def managed(self) -> dict[str, ComponentManifest]:
@@ -158,7 +153,16 @@ def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> obje
# 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", "install", "path", "tool", "expose", "proxy", "caddy"}
_STRUCTURAL_KEYS = {
"manage",
"systemd",
"install",
"path",
"tool",
"expose",
"proxy",
"caddy",
}
def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
@@ -214,4 +218,5 @@ def ensure_dirs() -> None:
CASTLE_HOME.mkdir(parents=True, exist_ok=True)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
STATIC_DIR.mkdir(parents=True, exist_ok=True)
os.chmod(SECRETS_DIR, 0o700)

View File

@@ -1,12 +1,17 @@
"""Castle infrastructure generators."""
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile
from castle_core.generators.caddyfile import (
find_app_dist,
generate_caddyfile,
generate_caddyfile_from_registry,
)
from castle_core.generators.systemd import (
build_podman_command,
cron_to_interval_sec,
cron_to_oncalendar,
generate_timer,
generate_unit,
generate_unit_from_deployed,
get_schedule_trigger,
manifest_to_exec_start,
timer_name,
@@ -19,8 +24,10 @@ __all__ = [
"cron_to_oncalendar",
"find_app_dist",
"generate_caddyfile",
"generate_caddyfile_from_registry",
"generate_timer",
"generate_unit",
"generate_unit_from_deployed",
"get_schedule_trigger",
"manifest_to_exec_start",
"timer_name",

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from castle_core.config import GENERATED_DIR, CastleConfig
from castle_core.config import GENERATED_DIR, STATIC_DIR, CastleConfig
from castle_core.registry import NodeRegistry
def find_app_dist(config: CastleConfig) -> str | None:
"""Find the app dist/ directory if it exists."""
"""Find the app dist/ directory if it exists (legacy, checks repo)."""
dist = config.root / "app" / "dist"
if dist.exists() and (dist / "index.html").exists():
return str(dist)
@@ -14,12 +15,17 @@ def find_app_dist(config: CastleConfig) -> str | None:
def generate_caddyfile(config: CastleConfig) -> str:
"""Generate Caddyfile content from castle config."""
"""Generate Caddyfile content from castle config (legacy, uses manifest).
Prefer generate_caddyfile_from_registry() for registry-based generation.
"""
lines = [f":{config.gateway.port} {{"]
# Reverse proxy for each component with proxy.caddy and expose.http
for name, manifest in config.components.items():
if not (manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable):
if not (
manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable
):
continue
if not (manifest.expose and manifest.expose.http):
continue
@@ -53,3 +59,39 @@ def generate_caddyfile(config: CastleConfig) -> str:
lines.append("}")
return "\n".join(lines)
def generate_caddyfile_from_registry(registry: NodeRegistry) -> str:
"""Generate Caddyfile from the node registry.
Static files served from ~/.castle/static/castle-app/.
No repo-relative paths.
"""
lines = [f":{registry.node.gateway_port} {{"]
for name, deployed in registry.deployed.items():
if not deployed.proxy_path or not deployed.port:
continue
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy localhost:{deployed.port}")
lines.append(" }")
lines.append("")
# SPA from static dir
static_app = STATIC_DIR / "castle-app"
if (static_app / "index.html").exists():
lines.append(" handle {")
lines.append(f" root * {static_app}")
lines.append(" try_files {path} /index.html")
lines.append(" file_server")
lines.append(" }")
else:
fallback = GENERATED_DIR / "app"
lines.append(" handle / {")
lines.append(f" root * {fallback}")
lines.append(" file_server")
lines.append(" }")
lines.append("}")
return "\n".join(lines)

View File

@@ -6,7 +6,8 @@ import shutil
from pathlib import Path
from castle_core.config import CastleConfig, resolve_env_vars
from castle_core.manifest import ComponentManifest, RestartPolicy
from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec
from castle_core.registry import DeployedComponent
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
@@ -42,7 +43,13 @@ def cron_to_oncalendar(cron: str) -> str:
minute, hour, dom, month, dow = parts
# */N minutes → run every N minutes
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
if (
minute.startswith("*/")
and hour == "*"
and dom == "*"
and month == "*"
and dow == "*"
):
return "" # Use OnUnitActiveSec instead
# Specific time daily: "0 2 * * *" → "*-*-* 02:00:00"
@@ -60,7 +67,13 @@ def cron_to_interval_sec(cron: str) -> int | None:
if len(parts) != 5:
return None
minute, hour, dom, month, dow = parts
if minute.startswith("*/") and hour == "*" and dom == "*" and month == "*" and dow == "*":
if (
minute.startswith("*/")
and hour == "*"
and dom == "*"
and month == "*"
and dow == "*"
):
try:
return int(minute[2:]) * 60
except ValueError:
@@ -132,15 +145,19 @@ def build_podman_command(manifest: ComponentManifest) -> str:
def generate_unit(config: CastleConfig, name: str, manifest: ComponentManifest) -> str:
"""Generate a systemd user unit file for a component."""
"""Generate a systemd user unit file for a component (legacy, uses manifest).
Prefer generate_unit_from_deployed() for registry-based generation.
"""
run = manifest.run
if run is None:
raise ValueError(f"Component '{name}' has no run spec")
working_dir = config.root / (run.working_dir or name)
exec_start = manifest_to_exec_start(manifest, config.root)
resolved_env = resolve_env_vars(run.env, manifest)
# Env vars now come from manifest.defaults.env instead of run.env
raw_env = manifest.defaults.env if manifest.defaults else {}
resolved_env = resolve_env_vars(raw_env, manifest)
env_lines = ""
for key, value in resolved_env.items():
env_lines += f"Environment={key}={value}\n"
@@ -166,7 +183,6 @@ After={after}
[Service]
Type=oneshot
WorkingDirectory={working_dir}
ExecStart={exec_start}
{env_lines}"""
else:
@@ -178,7 +194,68 @@ After={after}
[Service]
Type=simple
WorkingDirectory={working_dir}
ExecStart={exec_start}
{env_lines}Restart={restart}
RestartSec={restart_sec}
SuccessExitStatus=143
"""
if sd and sd.exec_reload:
reload_argv = sd.exec_reload.split()
resolved_reload = shutil.which(reload_argv[0])
if resolved_reload:
reload_argv[0] = resolved_reload
unit += f"ExecReload={' '.join(reload_argv)}\n"
if sd and sd.no_new_privileges:
unit += "NoNewPrivileges=true\n"
unit += f"""
[Install]
WantedBy={wanted_by}
"""
return unit
def generate_unit_from_deployed(
name: str,
deployed: DeployedComponent,
systemd_spec: SystemdSpec | None = None,
) -> str:
"""Generate a systemd unit from a deployed component (registry-based).
No repo-relative paths — uses only resolved run_cmd and env from the registry.
"""
exec_start = " ".join(deployed.run_cmd)
env_lines = ""
for key, value in deployed.env.items():
env_lines += f"Environment={key}={value}\n"
env_lines += f'Environment="PATH={Path.home() / ".local/bin"}:/usr/local/bin:/usr/bin:/bin"\n'
sd = systemd_spec
description = deployed.description or name
after = " ".join(sd.after) if sd and sd.after else "network.target"
wanted_by = " ".join(sd.wanted_by) if sd else "default.target"
if deployed.schedule:
unit = f"""[Unit]
Description=Castle: {description}
After={after}
[Service]
Type=oneshot
ExecStart={exec_start}
{env_lines}"""
else:
restart = (sd.restart if sd else RestartPolicy.ON_FAILURE).value
restart_sec = sd.restart_sec if sd else 5
unit = f"""[Unit]
Description=Castle: {description}
After={after}
[Service]
Type=simple
ExecStart={exec_start}
{env_lines}Restart={restart}
RestartSec={restart_sec}

View File

@@ -39,8 +39,6 @@ class Role(str, Enum):
class RunBase(BaseModel):
runner: str
working_dir: str | None = None
env: EnvMap = Field(default_factory=dict)
class RunCommand(RunBase):
@@ -68,6 +66,7 @@ class RunContainer(RunBase):
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
@@ -85,7 +84,9 @@ class RunRemote(RunBase):
RunSpec = Annotated[
Union[RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote],
Union[
RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote
],
Field(discriminator="runner"),
]
@@ -221,7 +222,6 @@ class ProxySpec(BaseModel):
class BuildSpec(BaseModel):
working_dir: str | None = None
commands: list[list[str]] = Field(default_factory=list)
outputs: list[str] = Field(default_factory=list)
@@ -237,6 +237,15 @@ class Capability(BaseModel):
meta: dict[str, str] = Field(default_factory=dict)
# ---------------------
# Defaults
# ---------------------
class DefaultsSpec(BaseModel):
env: EnvMap = Field(default_factory=dict)
# ---------------------
# Component manifest
# ---------------------
@@ -247,6 +256,8 @@ class ComponentManifest(BaseModel):
name: str | None = None
description: str | None = None
source: str | None = None
run: RunSpec | None = None
triggers: list[TriggerSpec] = Field(default_factory=list)
@@ -258,6 +269,8 @@ class ComponentManifest(BaseModel):
proxy: ProxySpec | None = None
build: BuildSpec | None = None
defaults: DefaultsSpec | None = None
provides: list[Capability] = Field(default_factory=list)
consumes: list[Capability] = Field(default_factory=list)
@@ -306,13 +319,11 @@ class ComponentManifest(BaseModel):
def source_dir(self) -> str | None:
"""Best-effort relative directory for this component's source.
Resolution order: run.working_dir → build.working_dir → tool.source (strip trailing /).
Resolution order: source → tool.source (strip trailing /).
Returns None if no directory can be determined.
"""
if self.run and self.run.working_dir:
return self.run.working_dir
if self.build and self.build.working_dir:
return self.build.working_dir
if self.source:
return self.source.rstrip("/")
if self.tool and self.tool.source:
return self.tool.source.rstrip("/")
return None

View File

@@ -0,0 +1,138 @@
"""Node registry — per-machine deployment state."""
from __future__ import annotations
import socket
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from castle_core.config import CASTLE_HOME
REGISTRY_PATH = CASTLE_HOME / "registry.yaml"
STATIC_DIR = CASTLE_HOME / "static"
@dataclass
class NodeConfig:
"""Per-node identity and settings."""
hostname: str = ""
castle_root: str | None = None # repo path, for dev commands
gateway_port: int = 9000
def __post_init__(self) -> None:
if not self.hostname:
self.hostname = socket.gethostname()
@dataclass
class DeployedComponent:
"""A component deployed on this node with resolved runtime config."""
runner: str
run_cmd: list[str]
env: dict[str, str] = field(default_factory=dict)
description: str | None = None
roles: list[str] = field(default_factory=list)
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
schedule: str | None = None
managed: bool = False
@dataclass
class NodeRegistry:
"""What's deployed on this node."""
node: NodeConfig
deployed: dict[str, DeployedComponent] = field(default_factory=dict)
def load_registry(path: Path | None = None) -> NodeRegistry:
"""Load the node registry from ~/.castle/registry.yaml."""
if path is None:
path = REGISTRY_PATH
if not path.exists():
raise FileNotFoundError(
f"Registry not found: {path}\n"
"Run 'castle deploy' to generate it from castle.yaml."
)
with open(path) as f:
data = yaml.safe_load(f)
if not data:
raise ValueError(f"Empty registry: {path}")
node_data = data.get("node", {})
node = NodeConfig(
hostname=node_data.get("hostname", ""),
castle_root=node_data.get("castle_root"),
gateway_port=node_data.get("gateway_port", 9000),
)
deployed: dict[str, DeployedComponent] = {}
for name, comp_data in data.get("deployed", {}).items():
deployed[name] = DeployedComponent(
runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
roles=comp_data.get("roles", []),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
)
return NodeRegistry(node=node, deployed=deployed)
def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"""Write the node registry to ~/.castle/registry.yaml."""
if path is None:
path = REGISTRY_PATH
path.parent.mkdir(parents=True, exist_ok=True)
data: dict = {
"node": {
"hostname": registry.node.hostname,
"gateway_port": registry.node.gateway_port,
},
"deployed": {},
}
if registry.node.castle_root:
data["node"]["castle_root"] = registry.node.castle_root
for name, comp in registry.deployed.items():
entry: dict = {
"runner": comp.runner,
"run_cmd": comp.run_cmd,
}
if comp.env:
entry["env"] = comp.env
if comp.description:
entry["description"] = comp.description
if comp.roles:
entry["roles"] = comp.roles
if comp.port is not None:
entry["port"] = comp.port
if comp.health_path:
entry["health_path"] = comp.health_path
if comp.proxy_path:
entry["proxy_path"] = comp.proxy_path
if comp.schedule:
entry["schedule"] = comp.schedule
if comp.managed:
entry["managed"] = comp.managed
data["deployed"][name] = entry
with open(path, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)