refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
138
core/src/castle_core/registry.py
Normal file
138
core/src/castle_core/registry.py
Normal 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)
|
||||
Reference in New Issue
Block a user