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

@@ -4,11 +4,13 @@ from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from castle_core.config import CastleConfig, load_config
from castle_core.registry import NodeRegistry, load_registry
class Settings(BaseSettings): class Settings(BaseSettings):
"""Service settings loaded from environment variables.""" """Service settings loaded from environment variables."""
castle_root: Path = Path("/data/repos/castle")
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 9020 port: int = 9020
@@ -19,3 +21,32 @@ class Settings(BaseSettings):
settings = Settings() settings = Settings()
def get_registry() -> NodeRegistry:
"""Load the node registry. Raises if not found."""
return load_registry()
def get_castle_root() -> Path | None:
"""Get the castle repo root from the registry, if available."""
try:
registry = load_registry()
if registry.node.castle_root:
return Path(registry.node.castle_root)
except (FileNotFoundError, ValueError):
pass
return None
def get_config() -> CastleConfig:
"""Load castle.yaml via the registry's castle_root.
Raises FileNotFoundError if repo not available.
"""
root = get_castle_root()
if root is None:
raise FileNotFoundError(
"Castle repo not available. Set castle_root in registry."
)
return load_config(root)

View File

@@ -9,10 +9,10 @@ import yaml
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from castle_core.config import load_config, save_config from castle_core.config import save_config
from castle_core.manifest import ComponentManifest from castle_core.manifest import ComponentManifest
from castle_api.config import settings from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast from castle_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"]) router = APIRouter(prefix="/config", tags=["config"])
@@ -42,16 +42,29 @@ class ComponentConfigRequest(BaseModel):
config: dict config: dict
def _require_repo() -> None:
"""Raise 503 if repo is not available."""
if get_castle_root() is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Castle repo not available on this node.",
)
@router.get("", response_model=ConfigResponse) @router.get("", response_model=ConfigResponse)
def get_config() -> ConfigResponse: def get_config_yaml() -> ConfigResponse:
"""Get the raw castle.yaml content.""" """Get the raw castle.yaml content."""
config_path = settings.castle_root / "castle.yaml" _require_repo()
root = get_castle_root()
config_path = root / "castle.yaml"
return ConfigResponse(yaml_content=config_path.read_text()) return ConfigResponse(yaml_content=config_path.read_text())
@router.put("", response_model=ConfigSaveResponse) @router.put("", response_model=ConfigSaveResponse)
def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse: def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
"""Validate and save castle.yaml. Does NOT apply changes.""" """Validate and save castle.yaml. Does NOT apply changes."""
_require_repo()
root = get_castle_root()
errors: list[str] = [] errors: list[str] = []
# Parse YAML # Parse YAML
@@ -87,7 +100,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
) )
# Backup and save # Backup and save
config_path = settings.castle_root / "castle.yaml" config_path = root / "castle.yaml"
backup_path = config_path.with_suffix(".yaml.bak") backup_path = config_path.with_suffix(".yaml.bak")
shutil.copy2(config_path, backup_path) shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content) config_path.write_text(request.yaml_content)
@@ -98,6 +111,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
@router.put("/components/{name}") @router.put("/components/{name}")
def save_component(name: str, request: ComponentConfigRequest) -> dict: def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml.""" """Update a single component's config in castle.yaml."""
_require_repo()
# Validate # Validate
try: try:
comp_data = dict(request.config) comp_data = dict(request.config)
@@ -109,7 +124,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
detail=f"Invalid component config: {e}", detail=f"Invalid component config: {e}",
) )
config = load_config(settings.castle_root) config = get_config()
config.components[name] = ComponentManifest.model_validate( config.components[name] = ComponentManifest.model_validate(
{**request.config, "id": name} {**request.config, "id": name}
) )
@@ -120,7 +135,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
@router.delete("/components/{name}") @router.delete("/components/{name}")
def delete_component(name: str) -> dict: def delete_component(name: str) -> dict:
"""Remove a component from castle.yaml.""" """Remove a component from castle.yaml."""
config = load_config(settings.castle_root) config = get_config()
if name not in config.components: if name not in config.components:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -133,13 +148,15 @@ def delete_component(name: str) -> dict:
@router.post("/apply", response_model=ApplyResponse) @router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse: async def apply_config() -> ApplyResponse:
"""Apply config: regenerate systemd units for managed services + reload gateway.""" """Apply config: restart managed services + regenerate and reload gateway."""
config = load_config(settings.castle_root) registry = get_registry()
actions: list[str] = [] actions: list[str] = []
errors: list[str] = [] errors: list[str] = []
# Regenerate and restart managed services # Restart managed services
for name in config.managed: for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
unit = f"castle-{name}.service" unit = f"castle-{name}.service"
ok, output = await _systemctl("restart", unit) ok, output = await _systemctl("restart", unit)
if ok: if ok:
@@ -149,17 +166,17 @@ async def apply_config() -> ApplyResponse:
# Reload gateway # Reload gateway
from castle_core.config import GENERATED_DIR, ensure_dirs from castle_core.config import GENERATED_DIR, ensure_dirs
from castle_core.generators import generate_caddyfile from castle_core.generators.caddyfile import generate_caddyfile_from_registry
ensure_dirs() ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile" caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile(config)) caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
actions.append("Generated Caddyfile") actions.append("Generated Caddyfile")
if shutil.which("caddy"): if shutil.which("caddy"):
ok, output = await _run("caddy", "reload", ok, output = await _run(
"--config", str(caddyfile_path), "caddy", "reload", "--config", str(caddyfile_path), "--adapter", "caddyfile"
"--adapter", "caddyfile") )
if ok: if ok:
actions.append("Reloaded gateway") actions.append("Reloaded gateway")
else: else:
@@ -171,7 +188,10 @@ async def apply_config() -> ApplyResponse:
async def _systemctl(action: str, unit: str) -> tuple[bool, str]: async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, unit, "systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )

View File

@@ -7,23 +7,18 @@ import time
import httpx import httpx
from castle_core.config import CastleConfig from castle_core.registry import NodeRegistry
from castle_api.models import HealthStatus from castle_api.models import HealthStatus
async def check_all_health(config: CastleConfig) -> list[HealthStatus]: async def check_all_health(registry: NodeRegistry) -> list[HealthStatus]:
"""Check health of all components with expose.http and a health_path.""" """Check health of all deployed components with a port and health_path."""
targets: list[tuple[str, str]] = [] targets: list[tuple[str, str]] = []
for name, manifest in config.components.items(): for name, deployed in registry.deployed.items():
if not (manifest.expose and manifest.expose.http): if not deployed.port or not deployed.health_path:
continue continue
http = manifest.expose.http url = f"http://127.0.0.1:{deployed.port}{deployed.health_path}"
if not http.health_path:
continue
host = http.internal.host or "127.0.0.1"
port = http.internal.port
url = f"http://{host}:{port}{http.health_path}"
targets.append((name, url)) targets.append((name, url))
if not targets: if not targets:

View File

@@ -42,7 +42,13 @@ async def get_logs(
# Static tail # Static tail
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "--no-pager", "journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"--no-pager",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -54,7 +60,14 @@ async def get_logs(
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]: async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
"""Stream journalctl -f output as SSE events.""" """Stream journalctl -f output as SSE events."""
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"journalctl", "--user", "-u", unit, "-n", str(n), "-f", "--no-pager", "journalctl",
"--user",
"-u",
unit,
"-n",
str(n),
"-f",
"--no-pager",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )

View File

@@ -17,7 +17,12 @@ from castle_api.logs import router as logs_router
from castle_api.routes import router as dashboard_router from castle_api.routes import router as dashboard_router
from castle_api.secrets import router as secrets_router from castle_api.secrets import router as secrets_router
from castle_api.services import router as services_router from castle_api.services import router as services_router
from castle_api.stream import close_all_subscribers, health_poll_loop, subscribe, unsubscribe from castle_api.stream import (
close_all_subscribers,
health_poll_loop,
subscribe,
unsubscribe,
)
from castle_api.tools import router as tools_router from castle_api.tools import router as tools_router
# Set by _watch_shutdown when uvicorn begins its shutdown sequence. # Set by _watch_shutdown when uvicorn begins its shutdown sequence.

View File

@@ -7,9 +7,9 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.config import load_config from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_api.config import settings from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health from castle_api.health import check_all_health
from castle_api.models import ( from castle_api.models import (
ComponentDetail, ComponentDetail,
@@ -22,8 +22,43 @@ from castle_api.models import (
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
"""Build a ComponentSummary from a DeployedComponent."""
managed = deployed.managed
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = deployed.schedule is not None
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
# Check if tool is installed on PATH
installed: bool | None = None
if "tool" in deployed.roles:
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
roles=deployed.roles,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
managed=managed,
systemd=systemd_info,
schedule=deployed.schedule,
installed=installed,
)
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary: def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a manifest.""" """Build a ComponentSummary from a manifest (for non-deployed components)."""
port = None port = None
health_path = None health_path = None
proxy_path = None proxy_path = None
@@ -37,26 +72,25 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
) )
# Systemd info for managed components
systemd_info: SystemdInfo | None = None systemd_info: SystemdInfo | None = None
if managed: if managed:
unit_name = f"castle-{name}.service" unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name) unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any(getattr(t, "type", None) == "schedule" for t in manifest.triggers) has_timer = any(
getattr(t, "type", None) == "schedule" for t in manifest.triggers
)
systemd_info = SystemdInfo( systemd_info = SystemdInfo(
unit_name=unit_name, unit_name=unit_name,
unit_path=unit_path, unit_path=unit_path,
timer=has_timer, timer=has_timer,
) )
# Extract cron schedule from first schedule trigger, if any
schedule = None schedule = None
for t in manifest.triggers: for t in manifest.triggers:
if t.type == "schedule": if t.type == "schedule":
schedule = t.cron schedule = t.cron
break break
# Infer runner — from run block or from tool source
runner = manifest.run.runner if manifest.run else None runner = manifest.run.runner if manifest.run else None
if runner is None and manifest.tool and manifest.tool.source: if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source source_dir = root / manifest.tool.source
@@ -65,7 +99,6 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
elif source_dir.is_file(): elif source_dir.is_file():
runner = "command" runner = "command"
# Check if tool is actually installed on PATH
installed: bool | None = None installed: bool | None = None
if manifest.install and manifest.install.path: if manifest.install and manifest.install.path:
alias = manifest.install.path.alias or name alias = manifest.install.path.alias or name
@@ -91,53 +124,95 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
@router.get("/components", response_model=list[ComponentSummary]) @router.get("/components", response_model=list[ComponentSummary])
def list_components() -> list[ComponentSummary]: def list_components() -> list[ComponentSummary]:
"""List all registered components.""" """List all components — deployed from registry, non-deployed from castle.yaml."""
config = load_config(settings.castle_root) registry = get_registry()
return [ summaries: list[ComponentSummary] = []
_summary_from_manifest(name, m, config.root) seen: set[str] = set()
for name, m in config.components.items()
] # Deployed components from registry
for name, deployed in registry.deployed.items():
summaries.append(_summary_from_deployed(name, deployed))
seen.add(name)
# Non-deployed components from castle.yaml (if repo available)
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, manifest in config.components.items():
if name not in seen:
summaries.append(_summary_from_manifest(name, manifest, root))
except FileNotFoundError:
pass
return summaries
@router.get("/components/{name}", response_model=ComponentDetail) @router.get("/components/{name}", response_model=ComponentDetail)
def get_component(name: str) -> ComponentDetail: def get_component(name: str) -> ComponentDetail:
"""Get detailed info for a single component.""" """Get detailed info for a single component."""
config = load_config(settings.castle_root) registry = get_registry()
if name not in config.components:
if name in registry.deployed:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"roles": deployed.roles,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)
# Fall back to castle.yaml
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest, root)
raw = manifest.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found", detail=f"Component '{name}' not found",
) )
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest, config.root)
raw = manifest.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
@router.get("/status", response_model=StatusResponse) @router.get("/status", response_model=StatusResponse)
async def get_status() -> StatusResponse: async def get_status() -> StatusResponse:
"""Get live health status for all exposed services.""" """Get live health status for all deployed services."""
config = load_config(settings.castle_root) registry = get_registry()
statuses = await check_all_health(config) statuses = await check_all_health(registry)
return StatusResponse(statuses=statuses) return StatusResponse(statuses=statuses)
@router.get("/gateway", response_model=GatewayInfo) @router.get("/gateway", response_model=GatewayInfo)
def get_gateway() -> GatewayInfo: def get_gateway() -> GatewayInfo:
"""Get gateway configuration summary.""" """Get gateway configuration summary."""
config = load_config(settings.castle_root) registry = get_registry()
deployed_count = len(registry.deployed)
service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
managed_count = sum(1 for d in registry.deployed.values() if d.managed)
return GatewayInfo( return GatewayInfo(
port=config.gateway.port, port=registry.node.gateway_port,
component_count=len(config.components), component_count=deployed_count,
service_count=len(config.services), service_count=service_count,
managed_count=len(config.managed), managed_count=managed_count,
) )
@router.get("/gateway/caddyfile") @router.get("/gateway/caddyfile")
def get_caddyfile() -> dict[str, str]: def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content.""" """Return the generated Caddyfile content."""
from castle_core.generators import generate_caddyfile registry = get_registry()
return {"content": generate_caddyfile_from_registry(registry)}
config = load_config(settings.castle_root)
return {"content": generate_caddyfile(config)}

View File

@@ -8,9 +8,12 @@ import time
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from castle_core.config import load_config from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
)
from castle_api.config import settings from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health from castle_api.health import check_all_health
from castle_api.models import HealthStatus from castle_api.models import HealthStatus
from castle_api.stream import broadcast from castle_api.stream import broadcast
@@ -24,7 +27,10 @@ SELF_NAME = "castle-api"
async def _systemctl(action: str, unit: str) -> tuple[bool, str]: async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
"""Run a systemctl --user command. Returns (success, output).""" """Run a systemctl --user command. Returns (success, output)."""
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, unit, "systemctl",
"--user",
action,
unit,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -36,7 +42,10 @@ async def _systemctl(action: str, unit: str) -> tuple[bool, str]:
async def _get_unit_status(unit: str) -> str: async def _get_unit_status(unit: str) -> str:
"""Get the active status of a systemd unit.""" """Get the active status of a systemd unit."""
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "is-active", unit, "systemctl",
"--user",
"is-active",
unit,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -45,9 +54,9 @@ async def _get_unit_status(unit: str) -> str:
def _validate_managed(name: str) -> None: def _validate_managed(name: str) -> None:
"""Raise 404 if the component isn't systemd-managed.""" """Raise 404 if the component isn't managed in the registry."""
config = load_config(settings.castle_root) registry = get_registry()
if name not in config.managed: if name not in registry.deployed or not registry.deployed[name].managed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service", detail=f"'{name}' is not a managed service",
@@ -58,25 +67,29 @@ async def _broadcast_health_with_override(
override_name: str, override_status: str override_name: str, override_status: str
) -> None: ) -> None:
"""Run health checks but override one component's status from systemd.""" """Run health checks but override one component's status from systemd."""
config = load_config(settings.castle_root) registry = get_registry()
statuses = await check_all_health(config) statuses = await check_all_health(registry)
# Replace the overridden component's status with the systemd truth
result = [] result = []
for s in statuses: for s in statuses:
if s.id == override_name: if s.id == override_name:
result.append(HealthStatus( result.append(
HealthStatus(
id=override_name, id=override_name,
status="down" if override_status != "active" else "up", status="down" if override_status != "active" else "up",
latency_ms=None, latency_ms=None,
)) )
)
else: else:
result.append(s) result.append(s)
await broadcast("health", { await broadcast(
"health",
{
"statuses": [s.model_dump() for s in result], "statuses": [s.model_dump() for s in result],
"timestamp": time.time(), "timestamp": time.time(),
}) },
)
async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None: async def _deferred_systemctl(action: str, unit: str, delay: float = 0.5) -> None:
@@ -104,7 +117,6 @@ async def _do_action(name: str, action: str) -> JSONResponse:
if not ok: if not ok:
raise HTTPException(status_code=500, detail=output or f"Failed to {action}") raise HTTPException(status_code=500, detail=output or f"Failed to {action}")
# Broadcast immediately with systemd status as the source of truth
await _broadcast_health_with_override(name, unit_status) await _broadcast_health_with_override(name, unit_status)
return JSONResponse( return JSONResponse(
@@ -115,13 +127,25 @@ async def _do_action(name: str, action: str) -> JSONResponse:
@router.get("/{name}/unit") @router.get("/{name}/unit")
def get_unit(name: str) -> dict[str, str | None]: def get_unit(name: str) -> dict[str, str | None]:
"""Return the generated systemd unit file(s) for a managed component.""" """Return the generated systemd unit file(s) for a managed component."""
from castle_core.generators import generate_timer, generate_unit
_validate_managed(name) _validate_managed(name)
config = load_config(settings.castle_root) registry = get_registry()
manifest = config.managed[name] deployed = registry.deployed[name]
unit = generate_unit(config, name, manifest)
timer = generate_timer(name, manifest) # Get systemd spec from manifest if repo available
systemd_spec = None
root = get_castle_root()
manifest = None
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, manifest) if manifest else None
return {"service": unit, "timer": timer} return {"service": unit, "timer": timer}

View File

@@ -7,9 +7,7 @@ import json
import logging import logging
import time import time
from castle_core.config import load_config from castle_api.config import get_registry
from castle_api.config import settings
from castle_api.health import check_all_health from castle_api.health import check_all_health
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,12 +58,15 @@ async def health_poll_loop(interval: float = 10.0) -> None:
"""Background task that polls health and broadcasts updates.""" """Background task that polls health and broadcasts updates."""
while True: while True:
try: try:
config = load_config(settings.castle_root) registry = get_registry()
statuses = await check_all_health(config) statuses = await check_all_health(registry)
await broadcast("health", { await broadcast(
"health",
{
"statuses": [s.model_dump() for s in statuses], "statuses": [s.model_dump() for s in statuses],
"timestamp": time.time(), "timestamp": time.time(),
}) },
)
except Exception: except Exception:
logger.exception("Health poll failed") logger.exception("Health poll failed")
await asyncio.sleep(interval) await asyncio.sleep(interval)

View File

@@ -7,20 +7,23 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.config import load_config
from castle_core.manifest import ComponentManifest from castle_core.manifest import ComponentManifest
from castle_api.config import settings from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"]) router = APIRouter(tags=["tools"])
def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = None) -> ToolSummary: def _tool_summary(
name: str, manifest: ComponentManifest, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a manifest that has a tool spec.""" """Build a ToolSummary from a manifest that has a tool spec."""
t = manifest.tool t = manifest.tool
assert t is not None assert t is not None
installed = bool(manifest.install and manifest.install.path and manifest.install.path.enable) installed = bool(
manifest.install and manifest.install.path and manifest.install.path.enable
)
# Infer runner from run block or source directory # Infer runner from run block or source directory
runner = manifest.run.runner if manifest.run else None runner = manifest.run.runner if manifest.run else None
@@ -44,12 +47,15 @@ def _tool_summary(name: str, manifest: ComponentManifest, root: Path | None = No
@router.get("/tools", response_model=list[ToolSummary]) @router.get("/tools", response_model=list[ToolSummary])
def list_tools() -> list[ToolSummary]: def list_tools() -> list[ToolSummary]:
"""List all registered tools.""" """List all registered tools (requires repo access)."""
config = load_config(settings.castle_root) config = get_config()
tools = {k: v for k, v in config.components.items() if v.tool} tools = {k: v for k, v in config.components.items() if v.tool}
return sorted( return sorted(
[_tool_summary(name, manifest, config.root) for name, manifest in tools.items()], [
_tool_summary(name, manifest, config.root)
for name, manifest in tools.items()
],
key=lambda t: t.id, key=lambda t: t.id,
) )
@@ -57,7 +63,7 @@ def list_tools() -> list[ToolSummary]:
@router.get("/tools/{name}", response_model=ToolDetail) @router.get("/tools/{name}", response_model=ToolDetail)
def get_tool(name: str) -> ToolDetail: def get_tool(name: str) -> ToolDetail:
"""Get detailed info for a single tool.""" """Get detailed info for a single tool."""
config = load_config(settings.castle_root) config = get_config()
if name not in config.components: if name not in config.components:
raise HTTPException( raise HTTPException(
@@ -79,20 +85,31 @@ def get_tool(name: str) -> ToolDetail:
@router.post("/tools/{name}/install") @router.post("/tools/{name}/install")
async def install_tool(name: str) -> dict: async def install_tool(name: str) -> dict:
"""Install a tool to PATH via uv tool install.""" """Install a tool to PATH via uv tool install."""
config = load_config(settings.castle_root) config = get_config()
if name not in config.components: if name not in config.components:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name] manifest = config.components[name]
if not manifest.tool or not manifest.tool.source: if not manifest.tool or not manifest.tool.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source to install") raise HTTPException(
status_code=400, detail=f"'{name}' has no tool source to install"
)
source_dir = config.root / manifest.tool.source source_dir = config.root / manifest.tool.source
if not (source_dir / "pyproject.toml").exists(): if not (source_dir / "pyproject.toml").exists():
raise HTTPException(status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}") raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"uv", "tool", "install", "--editable", str(source_dir), "--force", "uv",
"tool",
"install",
"--editable",
str(source_dir),
"--force",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -108,9 +125,11 @@ async def install_tool(name: str) -> dict:
@router.post("/tools/{name}/uninstall") @router.post("/tools/{name}/uninstall")
async def uninstall_tool(name: str) -> dict: async def uninstall_tool(name: str) -> dict:
"""Uninstall a tool from PATH via uv tool uninstall.""" """Uninstall a tool from PATH via uv tool uninstall."""
config = load_config(settings.castle_root) config = get_config()
if name not in config.components: if name not in config.components:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name] manifest = config.components[name]
if not manifest.tool or not manifest.tool.source: if not manifest.tool or not manifest.tool.source:
@@ -118,17 +137,20 @@ async def uninstall_tool(name: str) -> dict:
# uv tool uninstall uses the package name from pyproject.toml # uv tool uninstall uses the package name from pyproject.toml
source_dir = config.root / manifest.tool.source source_dir = config.root / manifest.tool.source
# Try to read the package name; fall back to the source dir name
pkg_name = source_dir.name pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml" pyproject = source_dir / "pyproject.toml"
if pyproject.exists(): if pyproject.exists():
import tomllib import tomllib
with open(pyproject, "rb") as f: with open(pyproject, "rb") as f:
data = tomllib.load(f) data = tomllib.load(f)
pkg_name = data.get("project", {}).get("name", pkg_name) pkg_name = data.get("project", {}).get("name", pkg_name)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"uv", "tool", "uninstall", pkg_name, "uv",
"tool",
"uninstall",
pkg_name,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )

View File

@@ -7,8 +7,14 @@ import pytest
import yaml import yaml
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from castle_api.config import settings import castle_api.config as api_config
from castle_api.main import app from castle_api.main import app
from castle_core.registry import (
DeployedComponent,
NodeConfig,
NodeRegistry,
save_registry,
)
@pytest.fixture @pytest.fixture
@@ -20,10 +26,10 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"components": { "components": {
"test-svc": { "test-svc": {
"description": "Test service", "description": "Test service",
"source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python_uv_tool",
"tool": "test-svc", "tool": "test-svc",
"cwd": "test-svc",
}, },
"expose": { "expose": {
"http": { "http": {
@@ -52,15 +58,67 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
}, },
} }
castle_yaml.write_text(yaml.dump(config, default_flow_style=False)) castle_yaml.write_text(yaml.dump(config, default_flow_style=False))
original = settings.castle_root
settings.castle_root = tmp_path
yield tmp_path yield tmp_path
settings.castle_root = original
@pytest.fixture @pytest.fixture
def client(castle_root: Path) -> Generator[TestClient, None, None]: def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, None]:
"""Create a test client pointing to temporary castle root.""" """Create a temporary registry.yaml and patch the module to use it."""
reg_path = tmp_path / "registry.yaml"
registry = NodeRegistry(
node=NodeConfig(
hostname="test-node",
castle_root=str(castle_root),
gateway_port=9000,
),
deployed={
"test-svc": DeployedComponent(
runner="python_uv_tool",
run_cmd=["uv", "run", "test-svc"],
env={
"TEST_SVC_PORT": "19000",
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
},
description="Test service",
roles=["service"],
port=19000,
health_path="/health",
proxy_path="/test-svc",
managed=True,
),
},
)
save_registry(registry, reg_path)
# Patch the registry path and helper functions
import castle_core.registry as reg_mod
original_path = reg_mod.REGISTRY_PATH
reg_mod.REGISTRY_PATH = reg_path
original_get_registry = api_config.get_registry
original_get_castle_root = api_config.get_castle_root
def _get_registry() -> NodeRegistry:
from castle_core.registry import load_registry
return load_registry(reg_path)
def _get_castle_root() -> Path | None:
return castle_root
api_config.get_registry = _get_registry
api_config.get_castle_root = _get_castle_root
yield reg_path
reg_mod.REGISTRY_PATH = original_path
api_config.get_registry = original_get_registry
api_config.get_castle_root = original_get_castle_root
@pytest.fixture
def client(registry_path: Path) -> Generator[TestClient, None, None]:
"""Create a test client with temporary registry."""
with TestClient(app) as client: with TestClient(app) as client:
yield client yield client

View File

@@ -55,7 +55,7 @@ class TestComponentDetail:
data = response.json() data = response.json()
assert data["id"] == "test-svc" assert data["id"] == "test-svc"
assert "manifest" in data assert "manifest" in data
assert data["manifest"]["run"]["runner"] == "python_uv_tool" assert data["manifest"]["runner"] == "python_uv_tool"
def test_not_found(self, client: TestClient) -> None: def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component.""" """Returns 404 for unknown component."""
@@ -67,11 +67,12 @@ class TestGateway:
"""Gateway info endpoint tests.""" """Gateway info endpoint tests."""
def test_gateway_info(self, client: TestClient) -> None: def test_gateway_info(self, client: TestClient) -> None:
"""Returns gateway configuration.""" """Returns gateway configuration from registry."""
response = client.get("/gateway") response = client.get("/gateway")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["port"] == 9000 assert data["port"] == 9000
assert data["component_count"] == 3 # Registry has 1 deployed component (test-svc)
assert data["component_count"] == 1
assert data["service_count"] == 1 assert data["service_count"] == 1
assert data["managed_count"] == 1 assert data["managed_count"] == 1

View File

@@ -1,7 +1,5 @@
"""Tests for tools endpoints.""" """Tests for tools endpoints."""
from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View File

@@ -5,7 +5,6 @@ components:
description: Caddy reverse proxy gateway description: Caddy reverse proxy gateway
run: run:
runner: command runner: command
working_dir: .
argv: argv:
- caddy - caddy
- run - run
@@ -25,12 +24,9 @@ components:
central-context: central-context:
description: Content storage API useful to get context into my data dir from anywhere description: Content storage API useful to get context into my data dir from anywhere
on the LAN on the LAN
source: components/central-context
run: run:
runner: python_uv_tool runner: python_uv_tool
working_dir: components/central-context
env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: '9001'
tool: central-context tool: central-context
manage: manage:
systemd: {} systemd: {}
@@ -46,14 +42,15 @@ components:
description: Desktop notification forwarder. This taps into your notifications description: Desktop notification forwarder. This taps into your notifications
system on the desktop and will forward all notifications the the central context system on the desktop and will forward all notifications the the central context
server. server.
source: components/notification-bridge
run: run:
runner: python_uv_tool runner: python_uv_tool
working_dir: components/notification-bridge tool: notification-bridge
defaults:
env: env:
CENTRAL_CONTEXT_URL: http://localhost:9001 CENTRAL_CONTEXT_URL: http://localhost:9001
BUCKET_NAME: notifications BUCKET_NAME: notifications
PORT: '9002' PORT: '9002'
tool: notification-bridge
manage: manage:
systemd: {} systemd: {}
expose: expose:
@@ -66,11 +63,9 @@ components:
path_prefix: /notifications path_prefix: /notifications
castle-api: castle-api:
description: Castle API description: Castle API
source: castle-api
run: run:
runner: python_uv_tool runner: python_uv_tool
working_dir: castle-api
env:
CASTLE_API_CASTLE_ROOT: /data/repos/castle
tool: castle-api tool: castle-api
manage: manage:
systemd: {} systemd: {}
@@ -84,15 +79,16 @@ components:
path_prefix: /api path_prefix: /api
protonmail: protonmail:
description: ProtonMail email sync via Bridge description: ProtonMail email sync via Bridge
source: components/protonmail
run: run:
runner: command runner: command
working_dir: components/protonmail
env:
PROTONMAIL_USERNAME: paul@payne.io
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
argv: argv:
- protonmail - protonmail
- sync - sync
defaults:
env:
PROTONMAIL_USERNAME: paul@payne.io
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
triggers: triggers:
- type: schedule - type: schedule
cron: '*/5 * * * *' cron: '*/5 * * * *'
@@ -106,13 +102,14 @@ components:
source: components/protonmail/ source: components/protonmail/
backup-collect: backup-collect:
description: Collect files from various sources into backup directory description: Collect files from various sources into backup directory
source: components/backup-collect
run: run:
runner: command runner: command
working_dir: .
env:
DBACKUP: /data/backup
argv: argv:
- backup-collect - backup-collect
defaults:
env:
DBACKUP: /data/backup
triggers: triggers:
- type: schedule - type: schedule
cron: 0 2 * * * cron: 0 2 * * *
@@ -127,12 +124,12 @@ components:
description: Nightly restic backup of /data to /storage description: Nightly restic backup of /data to /storage
run: run:
runner: command runner: command
working_dir: . argv:
- backup-data
defaults:
env: env:
RESTIC_REPOSITORY: /storage/restic-data RESTIC_REPOSITORY: /storage/restic-data
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
argv:
- backup-data
triggers: triggers:
- type: schedule - type: schedule
cron: 30 3 * * * cron: 30 3 * * *
@@ -142,8 +139,8 @@ components:
tool: {} tool: {}
castle-app: castle-app:
description: Castle web app description: Castle web app
source: app
build: build:
working_dir: app
commands: commands:
- - pnpm - - pnpm
- build - build
@@ -151,6 +148,7 @@ components:
- dist/ - dist/
devbox-connect: devbox-connect:
description: SSH tunnel manager with auto-reconnect description: SSH tunnel manager with auto-reconnect
source: components/devbox-connect
install: install:
path: path:
alias: devbox-connect alias: devbox-connect
@@ -158,6 +156,7 @@ components:
source: components/devbox-connect/ source: components/devbox-connect/
mbox2eml: mbox2eml:
description: MBOX to EML email converter description: MBOX to EML email converter
source: components/mbox2eml
install: install:
path: path:
alias: mbox2eml alias: mbox2eml
@@ -165,6 +164,7 @@ components:
source: components/mbox2eml/ source: components/mbox2eml/
android-backup: android-backup:
description: Backup Android device using ADB description: Backup Android device using ADB
source: components/android-backup
install: install:
path: path:
alias: android-backup alias: android-backup
@@ -174,6 +174,7 @@ components:
- adb - adb
browser: browser:
description: Browse the web using natural language via browser-use description: Browse the web using natural language via browser-use
source: components/browser
install: install:
path: path:
alias: browser alias: browser
@@ -181,6 +182,7 @@ components:
source: components/browser/ source: components/browser/
docx-extractor: docx-extractor:
description: Extract content and metadata from Word .docx files description: Extract content and metadata from Word .docx files
source: components/docx-extractor
install: install:
path: path:
alias: docx-extractor alias: docx-extractor
@@ -190,6 +192,7 @@ components:
- pandoc - pandoc
docx2md: docx2md:
description: Convert Word .docx files to Markdown description: Convert Word .docx files to Markdown
source: components/docx2md
install: install:
path: path:
alias: docx2md alias: docx2md
@@ -199,6 +202,7 @@ components:
- pandoc - pandoc
gpt: gpt:
description: OpenAI text generation utility description: OpenAI text generation utility
source: components/gpt
install: install:
path: path:
alias: gpt alias: gpt
@@ -206,6 +210,7 @@ components:
source: components/gpt/ source: components/gpt/
html2text: html2text:
description: Convert HTML content to plain text description: Convert HTML content to plain text
source: components/html2text
install: install:
path: path:
alias: html2text alias: html2text
@@ -213,6 +218,7 @@ components:
source: components/html2text/ source: components/html2text/
md2pdf: md2pdf:
description: Convert Markdown files to PDF description: Convert Markdown files to PDF
source: components/md2pdf
install: install:
path: path:
alias: md2pdf alias: md2pdf
@@ -223,6 +229,7 @@ components:
- texlive-latex-base - texlive-latex-base
mdscraper: mdscraper:
description: Combine text files into a single markdown document description: Combine text files into a single markdown document
source: components/mdscraper
install: install:
path: path:
alias: mdscraper alias: mdscraper
@@ -230,6 +237,7 @@ components:
source: components/mdscraper/ source: components/mdscraper/
pdf-extractor: pdf-extractor:
description: Extract content and metadata from PDF files description: Extract content and metadata from PDF files
source: components/pdf-extractor
install: install:
path: path:
alias: pdf-extractor alias: pdf-extractor
@@ -237,6 +245,7 @@ components:
source: components/pdf-extractor/ source: components/pdf-extractor/
pdf2md: pdf2md:
description: Convert PDF files to Markdown description: Convert PDF files to Markdown
source: components/pdf2md
install: install:
path: path:
alias: pdf2md alias: pdf2md
@@ -247,6 +256,7 @@ components:
- poppler-utils - poppler-utils
schedule: schedule:
description: Manage systemd user timers and scheduled tasks description: Manage systemd user timers and scheduled tasks
source: components/schedule
install: install:
path: path:
alias: schedule alias: schedule
@@ -254,6 +264,7 @@ components:
source: components/schedule/ source: components/schedule/
search: search:
description: Manage self-contained searchable collections of files description: Manage self-contained searchable collections of files
source: components/search
install: install:
path: path:
alias: search alias: search
@@ -261,6 +272,7 @@ components:
source: components/search/ source: components/search/
text-extractor: text-extractor:
description: Extract content and metadata from text files description: Extract content and metadata from text files
source: components/text-extractor
install: install:
path: path:
alias: text-extractor alias: text-extractor

View File

@@ -73,17 +73,14 @@ def run_create(args: argparse.Namespace) -> int:
) )
# Build manifest entry # Build manifest entry
env_prefix = package_name.upper()
if proj_type == "service": if proj_type == "service":
manifest = ComponentManifest( manifest = ComponentManifest(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
run=RunPythonUvTool( run=RunPythonUvTool(
runner="python_uv_tool", runner="python_uv_tool",
tool=name, tool=name,
cwd=f"components/{name}",
env={f"{env_prefix}_DATA_DIR": f"/data/castle/{name}"},
), ),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
@@ -98,6 +95,7 @@ def run_create(args: argparse.Namespace) -> int:
manifest = ComponentManifest( manifest = ComponentManifest(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
tool=ToolSpec(source=f"components/{name}/"), tool=ToolSpec(source=f"components/{name}/"),
install=InstallSpec(path=PathInstallSpec(alias=name)), install=InstallSpec(path=PathInstallSpec(alias=name)),
) )
@@ -106,6 +104,7 @@ def run_create(args: argparse.Namespace) -> int:
manifest = ComponentManifest( manifest = ComponentManifest(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
) )
config.components[name] = manifest config.components[name] = manifest
@@ -120,8 +119,7 @@ def run_create(args: argparse.Namespace) -> int:
print(" uv sync") print(" uv sync")
if proj_type == "service": if proj_type == "service":
print(f" uv run {name} # starts on port {port}") print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}") print(f" castle test {name}")
return 0 return 0

View File

@@ -0,0 +1,285 @@
"""castle deploy — bridge spec (castle.yaml) to runtime (~/.castle/)."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_core.config import (
GENERATED_DIR,
STATIC_DIR,
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.manifest import ComponentManifest
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)
DATA_ROOT = Path("/data/castle")
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
config = load_config()
component_name = getattr(args, "component", None)
if component_name:
if component_name not in config.components:
print(f"Error: component '{component_name}' not found in castle.yaml")
return 1
names = [component_name]
else:
names = list(config.components.keys())
ensure_dirs()
# Build node config
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
# Load existing registry to preserve components not being redeployed,
# or start fresh if deploying all
if component_name and REGISTRY_PATH.exists():
try:
existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
except (FileNotFoundError, ValueError):
registry = NodeRegistry(node=node)
else:
registry = NodeRegistry(node=node)
deployed_count = 0
for name in names:
manifest = config.components[name]
# Only deploy components with a run spec
if not manifest.run:
continue
deployed = _build_deployed(config, name, manifest)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
# Handle castle-app build artifacts
_copy_app_static(config)
# Save registry
save_registry(registry)
print(f"\nRegistry written: {REGISTRY_PATH}")
# Generate systemd units from registry
_generate_systemd_units(config, registry)
# Generate Caddyfile from registry
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_content = generate_caddyfile_from_registry(registry)
caddyfile_path.write_text(caddyfile_content)
print(f"Caddyfile written: {caddyfile_path}")
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
print(f"\nDeployed {deployed_count} component(s).")
print("Run 'castle services start' to start all services.")
return 0
def _env_prefix(name: str) -> str:
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _build_deployed(
config: CastleConfig, name: str, manifest: ComponentManifest
) -> DeployedComponent:
"""Build a DeployedComponent from a manifest spec."""
run = manifest.run
assert run is not None
# 1. Convention-based env vars
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention (for all managed components)
if manifest.manage and manifest.manage.systemd:
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Port convention (if exposed)
if manifest.expose and manifest.expose.http:
env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port)
# 2. Merge defaults.env (overrides conventions)
if manifest.defaults and manifest.defaults.env:
env.update(manifest.defaults.env)
# 3. Resolve secrets
env = resolve_env_vars(env, manifest)
# 4. Build run_cmd
run_cmd = _build_run_cmd(run, env)
# 5. Extract metadata
port = None
health_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
proxy_path = None
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable:
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}"
schedule = None
sched_trigger = get_schedule_trigger(manifest)
if sched_trigger:
schedule = sched_trigger.cron
managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable)
roles = [r.value for r in manifest.roles]
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=manifest.description,
roles=roles,
port=port,
health_path=health_path,
proxy_path=proxy_path,
schedule=schedule,
managed=managed,
)
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
case "python_uv_tool":
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", run.tool]
if run.args:
cmd.extend(run.args)
return cmd
case "python_module":
python = run.python or shutil.which("python3") or "python3"
cmd = [python, "-m", run.module]
if run.args:
cmd.extend(run.args)
return cmd
case "command":
cmd = list(run.argv)
resolved = shutil.which(cmd[0])
if resolved:
cmd[0] = resolved
return cmd
case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
image_name = run.image.split("/")[-1].split(":")[0]
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
for container_port, host_port in run.ports.items():
cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
# Container env comes from both run.env (container-specific) and deployed env
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
if run.args:
cmd.extend(run.args)
return cmd
case "node":
cmd = [run.package_manager, "run", run.script]
if run.args:
cmd.extend(run.args)
return cmd
case _:
raise ValueError(f"Unsupported runner: {run.runner}")
def _print_deployed(name: str, deployed: DeployedComponent) -> None:
"""Print deployment summary for a component."""
parts = [f" {name}"]
if deployed.port:
parts.append(f"port={deployed.port}")
if deployed.schedule:
parts.append(f"schedule={deployed.schedule}")
if deployed.proxy_path:
parts.append(f"proxy={deployed.proxy_path}")
print(" ".join(parts))
def _copy_app_static(config: CastleConfig) -> None:
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
if "castle-app" not in config.components:
return
manifest = config.components["castle-app"]
if not (manifest.build and manifest.build.outputs):
return
# Find the source dist directory
source_dir = manifest.source_dir or "app"
for output in manifest.build.outputs:
src = config.root / source_dir / output
if src.exists():
dest = STATIC_DIR / "castle-app"
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
print(f" Static: {src}{dest}")
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
# Generate and write service unit
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer if scheduled
if name in config.components:
timer_content = generate_timer(name, config.components[name])
if timer_content:
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
print(f"Systemd units written: {SYSTEMD_USER_DIR}")

View File

@@ -23,9 +23,7 @@ def _has_pyproject(project_dir: Path) -> bool:
return (project_dir / "pyproject.toml").exists() return (project_dir / "pyproject.toml").exists()
def _run_in_project( def _run_in_project(project_dir: Path, cmd: list[str], label: str) -> bool:
project_dir: Path, cmd: list[str], label: str
) -> bool:
"""Run a command in a project directory. Returns True on success.""" """Run a command in a project directory. Returns True on success."""
if not _has_pyproject(project_dir): if not _has_pyproject(project_dir):
return True # Skip projects without pyproject.toml return True # Skip projects without pyproject.toml

View File

@@ -5,27 +5,28 @@ from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import CastleConfig, ensure_dirs, load_config
GATEWAY_COMPONENT = "castle-gateway" GATEWAY_COMPONENT = "castle-gateway"
GATEWAY_UNIT = "castle-castle-gateway.service" GATEWAY_UNIT = "castle-castle-gateway.service"
def _write_generated_files(config: CastleConfig) -> None: def _write_generated_files() -> None:
"""Write generated Caddyfile.""" """Write generated Caddyfile from registry."""
ensure_dirs() ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile" if not REGISTRY_PATH.exists():
caddyfile_path.write_text(generate_caddyfile(config)) print("Error: no registry found. Run 'castle deploy' first.")
print(f" Generated {caddyfile_path}") return
app_dist = find_app_dist(config) registry = load_registry()
if app_dist: caddyfile_path = GENERATED_DIR / "Caddyfile"
print(f" App: {app_dist}") caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
else: print(f" Generated {caddyfile_path}")
print(" App: dist/ not found, using fallback")
def run_gateway(args: argparse.Namespace) -> int: def run_gateway(args: argparse.Namespace) -> int:
@@ -38,24 +39,29 @@ def run_gateway(args: argparse.Namespace) -> int:
if args.gateway_command == "start": if args.gateway_command == "start":
if getattr(args, "dry_run", False): if getattr(args, "dry_run", False):
return _gateway_dry_run(config) return _gateway_dry_run()
return _gateway_start(config) return _gateway_start(config)
elif args.gateway_command == "stop": elif args.gateway_command == "stop":
return _gateway_stop() return _gateway_stop()
elif args.gateway_command == "reload": elif args.gateway_command == "reload":
if getattr(args, "dry_run", False): if getattr(args, "dry_run", False):
return _gateway_dry_run(config) return _gateway_dry_run()
return _gateway_reload(config) return _gateway_reload()
elif args.gateway_command == "status": elif args.gateway_command == "status":
return _gateway_status() return _gateway_status()
return 1 return 1
def _gateway_dry_run(config: CastleConfig) -> int: def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying.""" """Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
print("# Caddyfile") print("# Caddyfile")
print(generate_caddyfile(config)) print(generate_caddyfile_from_registry(registry))
return 0 return 0
@@ -68,7 +74,7 @@ def _gateway_start(config: CastleConfig) -> int:
return 1 return 1
print("Generating gateway configuration...") print("Generating gateway configuration...")
_write_generated_files(config) _write_generated_files()
print(f"\nStarting gateway on port {config.gateway.port}...") print(f"\nStarting gateway on port {config.gateway.port}...")
return _service_enable(config, GATEWAY_COMPONENT) return _service_enable(config, GATEWAY_COMPONENT)
@@ -81,14 +87,15 @@ def _gateway_stop() -> int:
return _service_disable(GATEWAY_COMPONENT) return _service_disable(GATEWAY_COMPONENT)
def _gateway_reload(config: CastleConfig) -> int: def _gateway_reload() -> int:
"""Regenerate config and reload Caddy.""" """Regenerate config and reload Caddy."""
print("Regenerating gateway configuration...") print("Regenerating gateway configuration...")
_write_generated_files(config) _write_generated_files()
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "reload", GATEWAY_UNIT], ["systemctl", "--user", "reload", GATEWAY_UNIT],
capture_output=True, text=True, capture_output=True,
text=True,
) )
if result.returncode == 0: if result.returncode == 0:
@@ -98,7 +105,8 @@ def _gateway_reload(config: CastleConfig) -> int:
print("Reload signal sent. Verifying...") print("Reload signal sent. Verifying...")
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT], ["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True, text=True, capture_output=True,
text=True,
) )
if result.stdout.strip() == "active": if result.stdout.strip() == "active":
print("Gateway running.") print("Gateway running.")
@@ -112,7 +120,8 @@ def _gateway_status() -> int:
"""Show gateway status via systemd.""" """Show gateway status via systemd."""
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT], ["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True, text=True, capture_output=True,
text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()

View File

@@ -42,20 +42,24 @@ def run_info(args: argparse.Namespace) -> int:
if manifest.description: if manifest.description:
print(f" {BOLD}description{RESET}: {manifest.description}") print(f" {BOLD}description{RESET}: {manifest.description}")
# Source
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
# Run spec # Run spec
if manifest.run: if manifest.run:
print(f" {BOLD}runner{RESET}: {manifest.run.runner}") print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
if manifest.run.working_dir:
print(f" {BOLD}working_dir{RESET}: {manifest.run.working_dir}")
if hasattr(manifest.run, "tool"): if hasattr(manifest.run, "tool"):
print(f" {BOLD}tool{RESET}: {manifest.run.tool}") print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
elif hasattr(manifest.run, "argv"): elif hasattr(manifest.run, "argv"):
print(f" {BOLD}argv{RESET}: {manifest.run.argv}") print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
elif hasattr(manifest.run, "image"): elif hasattr(manifest.run, "image"):
print(f" {BOLD}image{RESET}: {manifest.run.image}") print(f" {BOLD}image{RESET}: {manifest.run.image}")
if manifest.run.env:
print(f" {BOLD}env{RESET}:") # Defaults env
for key, val in manifest.run.env.items(): if manifest.defaults and manifest.defaults.env:
print(f" {BOLD}defaults.env{RESET}:")
for key, val in manifest.defaults.env.items():
print(f" {key}: {val}") print(f" {key}: {val}")
# Expose # Expose
@@ -85,7 +89,7 @@ def run_info(args: argparse.Namespace) -> int:
if manifest.tool: if manifest.tool:
t = manifest.tool t = manifest.tool
if t.source: if t.source:
print(f" {BOLD}source{RESET}: {t.source}") print(f" {BOLD}tool.source{RESET}: {t.source}")
if t.system_dependencies: if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
@@ -104,8 +108,8 @@ def run_info(args: argparse.Namespace) -> int:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else "")) print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
# Show CLAUDE.md if it exists # Show CLAUDE.md if it exists
cwd = manifest.run.working_dir if manifest.run else None source_dir = manifest.source_dir or name
claude_md = _find_claude_md(config.root, cwd or name) claude_md = _find_claude_md(config.root, source_dir)
if claude_md: if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}") print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
print(f"{CYAN}{'' * 40}{RESET}") print(f"{CYAN}{'' * 40}{RESET}")
@@ -115,9 +119,9 @@ def run_info(args: argparse.Namespace) -> int:
return 0 return 0
def _find_claude_md(root: Path, working_dir: str) -> str | None: def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists.""" """Read CLAUDE.md from project directory if it exists."""
claude_path = root / working_dir / "CLAUDE.md" claude_path = root / source_dir / "CLAUDE.md"
if claude_path.exists(): if claude_path.exists():
return claude_path.read_text() return claude_path.read_text()
return None return None

View File

@@ -32,9 +32,7 @@ def run_list(args: argparse.Namespace) -> int:
filter_role = getattr(args, "role", None) filter_role = getattr(args, "role", None)
if filter_role: if filter_role:
components = { components = {k: v for k, v in components.items() if filter_role in v.roles}
k: v for k, v in components.items() if filter_role in v.roles
}
if getattr(args, "json", False): if getattr(args, "json", False):
output = [] output = []

View File

@@ -4,93 +4,36 @@ from __future__ import annotations
import argparse import argparse
import os import os
import shutil
import subprocess import subprocess
import sys
from castle_cli.config import load_config, resolve_env_vars from castle_core.registry import REGISTRY_PATH, load_registry
def run_run(args: argparse.Namespace) -> int: def run_run(args: argparse.Namespace) -> int:
"""Run a component in the foreground (dev mode).""" """Run a component in the foreground using the registry."""
config = load_config() if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
name = args.name name = args.name
if name not in config.components: if name not in registry.deployed:
print(f"Error: component '{name}' not found in castle.yaml") print(f"Error: component '{name}' not found in registry.")
print("Run 'castle deploy' to update the registry.")
return 1 return 1
manifest = config.components[name] deployed = registry.deployed[name]
run = manifest.run
if run is None: # Build command with any extra args
print(f"Error: component '{name}' has no run spec")
return 1
# Build command
extra_args = getattr(args, "extra", []) or [] extra_args = getattr(args, "extra", []) or []
cmd = _build_command(run, extra_args) cmd = list(deployed.run_cmd) + extra_args
if cmd is None:
print(f"Error: unsupported runner '{run.runner}' for foreground execution")
return 1
# Working directory
cwd = config.root / (run.working_dir or name)
if not cwd.exists():
print(f"Error: working directory '{cwd}' does not exist")
return 1
# Merge environment # Merge environment
env = dict(os.environ) env = dict(os.environ)
resolved = resolve_env_vars(run.env, manifest) env.update(deployed.env)
env.update(resolved)
# Run in foreground # Run in foreground (no cwd — registry-based, no repo dependency)
result = subprocess.run(cmd, cwd=cwd, env=env) print(f"Running {name}: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env)
return result.returncode return result.returncode
def _build_command(run: object, extra_args: list[str]) -> list[str] | None:
"""Build command list from RunSpec."""
match run.runner:
case "python_uv_tool":
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", run.tool]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "python_module":
python = run.python or sys.executable
cmd = [python, "-m", run.module]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "command":
cmd = list(run.argv)
cmd.extend(extra_args)
return cmd
case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
cmd = [runtime, "run", "--rm", "-it"]
for cp, hp in run.ports.items():
cmd.extend(["-p", f"{hp}:{cp}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "node":
pm = run.package_manager
cmd = [pm, "run", run.script]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case _:
return None

View File

@@ -5,19 +5,21 @@ from __future__ import annotations
import argparse import argparse
import subprocess import subprocess
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import ( from castle_cli.config import (
CastleConfig, CastleConfig,
ensure_dirs, ensure_dirs,
load_config, load_config,
) )
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit,
get_schedule_trigger,
timer_name,
unit_name,
)
def _install_unit(uname: str, content: str) -> None: def _install_unit(uname: str, content: str) -> None:
@@ -74,25 +76,38 @@ def run_services(args: argparse.Namespace) -> int:
def _service_enable(config: CastleConfig, name: str) -> int: def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs).""" """Enable and start a single service (or timer for scheduled jobs)."""
managed = config.managed # Require registry
if name not in managed: if not REGISTRY_PATH.exists():
print(f"Error: '{name}' is not a managed service") print("Error: no registry found. Run 'castle deploy' first.")
return 1 return 1
manifest = managed[name] registry = load_registry()
if not manifest.run: if name not in registry.deployed:
print(f"Error: '{name}' has no run spec defined") print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
deployed = registry.deployed[name]
if not deployed.managed:
print(f"Error: '{name}' is not a managed service")
return 1 return 1
ensure_dirs() ensure_dirs()
# Generate and install the service unit # Get systemd spec from manifest for restart policy etc.
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
# Generate and install the service unit from registry
svc_unit = unit_name(name) svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
_install_unit(svc_unit, svc_content) _install_unit(svc_unit, svc_content)
# Check for timer # Check for timer (still uses manifest for schedule config)
timer_content = generate_timer(name, manifest) manifest = config.components.get(name)
timer_content = generate_timer(name, manifest) if manifest else None
if timer_content: if timer_content:
tmr_unit = timer_name(name) tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content) _install_unit(tmr_unit, timer_content)
@@ -103,7 +118,8 @@ def _service_enable(config: CastleConfig, name: str) -> int:
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit], ["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True, capture_output=True,
text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()
if status in ("active", "waiting"): if status in ("active", "waiting"):
@@ -117,12 +133,13 @@ def _service_enable(config: CastleConfig, name: str) -> int:
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit], ["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True, capture_output=True,
text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()
port_str = "" port_str = ""
if manifest.expose and manifest.expose.http: if deployed.port:
port_str = f" (port {manifest.expose.http.internal.port})" port_str = f" (port {deployed.port})"
if status == "active": if status == "active":
print(f" {name}: running{port_str}") print(f" {name}: running{port_str}")
else: else:
@@ -166,7 +183,8 @@ def _service_status(config: CastleConfig) -> int:
tmr_unit = timer_name(name) tmr_unit = timer_name(name)
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit], ["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True, capture_output=True,
text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()
if status in ("active", "waiting"): if status in ("active", "waiting"):
@@ -181,7 +199,8 @@ def _service_status(config: CastleConfig) -> int:
svc_unit = unit_name(name) svc_unit = unit_name(name)
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit], ["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True, capture_output=True,
text=True,
) )
status = result.stdout.strip() status = result.stdout.strip()
if status == "active": if status == "active":
@@ -203,41 +222,55 @@ def _service_status(config: CastleConfig) -> int:
def _service_dry_run(config: CastleConfig, name: str) -> int: def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing.""" """Print the generated systemd unit(s) without installing."""
managed = config.managed # Try registry first, fall back to showing what deploy would generate
if name not in managed: if REGISTRY_PATH.exists():
print(f"Error: '{name}' is not a managed service") registry = load_registry()
return 1 if name in registry.deployed:
deployed = registry.deployed[name]
manifest = managed[name] systemd_spec = None
if not manifest.run: if name in config.components:
print(f"Error: '{name}' has no run spec defined") manifest = config.components[name]
return 1 if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
svc_unit = unit_name(name) svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}") print(f"# {svc_unit}")
print(svc_content) print(svc_content)
manifest = config.components.get(name)
if manifest:
timer_content = generate_timer(name, manifest) timer_content = generate_timer(name, manifest)
if timer_content: if timer_content:
print(f"# {timer_name(name)}") print(f"# {timer_name(name)}")
print(timer_content) print(timer_content)
return 0 return 0
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
def _services_start(config: CastleConfig) -> int: def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway.""" """Start all managed services and gateway."""
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
ensure_dirs() ensure_dirs()
# Generate Caddyfile before starting gateway # Generate Caddyfile from registry
from castle_cli.commands.gateway import _write_generated_files from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
print("Generating gateway configuration...") registry = load_registry()
_write_generated_files(config) caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
for name, manifest in config.managed.items(): for name in config.managed:
if not manifest.run: if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue continue
_service_enable(config, name) _service_enable(config, name)

View File

@@ -64,9 +64,9 @@ def run_sync(args: argparse.Namespace) -> int:
continue continue
print(f"\nInstalling {name}...") print(f"\nInstalling {name}...")
result = subprocess.run( result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), [uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
"--force"], capture_output=True,
capture_output=True, text=True, text=True,
) )
if result.returncode != 0: if result.returncode != 0:
if "already installed" in result.stderr.lower(): if "already installed" in result.stderr.lower():

View File

@@ -5,6 +5,7 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
CASTLE_HOME, CASTLE_HOME,
GENERATED_DIR, GENERATED_DIR,
SECRETS_DIR, SECRETS_DIR,
STATIC_DIR,
CastleConfig, CastleConfig,
GatewayConfig, GatewayConfig,
ensure_dirs, ensure_dirs,
@@ -13,3 +14,11 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
resolve_env_vars, resolve_env_vars,
save_config, save_config,
) )
from castle_core.registry import ( # noqa: F401
REGISTRY_PATH,
DeployedComponent,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)

View File

@@ -14,9 +14,7 @@ def build_parser() -> argparse.ArgumentParser:
prog="castle", prog="castle",
description="Castle platform CLI - manage projects, services, and infrastructure", description="Castle platform CLI - manage projects, services, and infrastructure",
) )
parser.add_argument( parser.add_argument("--version", action="version", version=f"castle {__version__}")
"--version", action="version", version=f"castle {__version__}"
)
subparsers = parser.add_subparsers(dest="command", help="Available commands") subparsers = parser.add_subparsers(dest="command", help="Available commands")
@@ -27,9 +25,7 @@ def build_parser() -> argparse.ArgumentParser:
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"], choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
help="Filter by role", help="Filter by role",
) )
list_parser.add_argument( list_parser.add_argument("--json", action="store_true", help="Output as JSON")
"--json", action="store_true", help="Output as JSON"
)
# castle create # castle create
create_parser = subparsers.add_parser("create", help="Create a new project") create_parser = subparsers.add_parser("create", help="Create a new project")
@@ -40,18 +36,12 @@ def build_parser() -> argparse.ArgumentParser:
required=True, required=True,
help="Project type", help="Project type",
) )
create_parser.add_argument( create_parser.add_argument("--description", default="", help="Project description")
"--description", default="", help="Project description" create_parser.add_argument("--port", type=int, help="Port number (services only)")
)
create_parser.add_argument(
"--port", type=int, help="Port number (services only)"
)
# castle info # castle info
info_parser = subparsers.add_parser("info", help="Show component details") info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name") info_parser.add_argument("project", help="Component name")
info_parser.add_argument( info_parser.add_argument("--json", action="store_true", help="Output as JSON")
"--json", action="store_true", help="Output as JSON"
)
# castle test # castle test
test_parser = subparsers.add_parser("test", help="Run tests") test_parser = subparsers.add_parser("test", help="Run tests")
@@ -86,16 +76,12 @@ def build_parser() -> argparse.ArgumentParser:
enable_parser.add_argument( enable_parser.add_argument(
"--dry-run", action="store_true", help="Print generated unit without installing" "--dry-run", action="store_true", help="Print generated unit without installing"
) )
disable_parser = service_sub.add_parser( disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
"disable", help="Stop and disable a service"
)
disable_parser.add_argument("name", help="Service name") disable_parser.add_argument("name", help="Service name")
service_sub.add_parser("status", help="Show status of all services") service_sub.add_parser("status", help="Show status of all services")
# castle services (plural - manage all services) # castle services (plural - manage all services)
services_parser = subparsers.add_parser( services_parser = subparsers.add_parser("services", help="Manage all services together")
"services", help="Manage all services together"
)
services_sub = services_parser.add_subparsers(dest="services_command") services_sub = services_parser.add_subparsers(dest="services_command")
services_sub.add_parser("start", help="Start all services and gateway") services_sub.add_parser("start", help="Start all services and gateway")
services_sub.add_parser("stop", help="Stop all services and gateway") services_sub.add_parser("stop", help="Stop all services and gateway")
@@ -103,9 +89,7 @@ def build_parser() -> argparse.ArgumentParser:
# castle logs # castle logs
logs_parser = subparsers.add_parser("logs", help="View component logs") logs_parser = subparsers.add_parser("logs", help="View component logs")
logs_parser.add_argument("name", help="Component name") logs_parser.add_argument("name", help="Component name")
logs_parser.add_argument( logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
"-f", "--follow", action="store_true", help="Follow log output"
)
logs_parser.add_argument( logs_parser.add_argument(
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)" "-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
) )
@@ -117,6 +101,12 @@ def build_parser() -> argparse.ArgumentParser:
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component" "extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
) )
# castle deploy
deploy_parser = subparsers.add_parser(
"deploy", help="Deploy components to ~/.castle/ (spec → runtime)"
)
deploy_parser.add_argument("component", nargs="?", help="Component to deploy (default: all)")
# castle tool # castle tool
tool_parser = subparsers.add_parser("tool", help="Manage tools") tool_parser = subparsers.add_parser("tool", help="Manage tools")
tool_sub = tool_parser.add_subparsers(dest="tool_command") tool_sub = tool_parser.add_subparsers(dest="tool_command")
@@ -187,6 +177,11 @@ def main() -> int:
return run_logs(args) return run_logs(args)
elif args.command == "deploy":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
elif args.command == "run": elif args.command == "run":
from castle_cli.commands.run_cmd import run_run from castle_cli.commands.run_cmd import run_run

View File

@@ -6,6 +6,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
CaddySpec, CaddySpec,
Capability, Capability,
ComponentManifest, ComponentManifest,
DefaultsSpec,
EnvMap, EnvMap,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,

View File

@@ -18,10 +18,12 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"components": { "components": {
"test-svc": { "test-svc": {
"description": "Test service", "description": "Test service",
"source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python_uv_tool",
"tool": "test-svc", "tool": "test-svc",
"working_dir": "test-svc", },
"defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")}, "env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
}, },
"expose": { "expose": {

View File

@@ -15,9 +15,10 @@ class TestCreateCommand:
def test_create_service(self, castle_root: Path) -> None: def test_create_service(self, castle_root: Path) -> None:
"""Create a new service project.""" """Create a new service project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch( with (
"castle_cli.commands.create.save_config" patch("castle_cli.commands.create.load_config") as mock_load,
) as mock_save: patch("castle_cli.commands.create.save_config") as mock_save,
):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config
@@ -50,17 +51,16 @@ class TestCreateCommand:
def test_create_tool(self, castle_root: Path) -> None: def test_create_tool(self, castle_root: Path) -> None:
"""Create a new tool project.""" """Create a new tool project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch( with (
"castle_cli.commands.create.save_config" patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from castle_cli.commands.create import run_create
args = Namespace( args = Namespace(name="my-tool", type="tool", description="My tool", port=None)
name="my-tool", type="tool", description="My tool", port=None
)
result = run_create(args) result = run_create(args)
assert result == 0 assert result == 0
@@ -74,17 +74,16 @@ class TestCreateCommand:
def test_create_library(self, castle_root: Path) -> None: def test_create_library(self, castle_root: Path) -> None:
"""Create a new library project.""" """Create a new library project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch( with (
"castle_cli.commands.create.save_config" patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config
from castle_cli.commands.create import run_create from castle_cli.commands.create import run_create
args = Namespace( args = Namespace(name="my-lib", type="library", description="My library", port=None)
name="my-lib", type="library", description="My library", port=None
)
result = run_create(args) result = run_create(args)
assert result == 0 assert result == 0
@@ -114,8 +113,9 @@ class TestCreateCommand:
def test_create_auto_port(self, castle_root: Path) -> None: def test_create_auto_port(self, castle_root: Path) -> None:
"""Service creation auto-assigns next available port.""" """Service creation auto-assigns next available port."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch( with (
"castle_cli.commands.create.save_config" patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
): ):
config = load_config(castle_root) config = load_config(castle_root)
mock_load.return_value = config mock_load.return_value = config

View File

@@ -31,6 +31,7 @@ def find_castle_root() -> Path:
CASTLE_HOME = Path.home() / ".castle" CASTLE_HOME = Path.home() / ".castle"
GENERATED_DIR = CASTLE_HOME / "generated" GENERATED_DIR = CASTLE_HOME / "generated"
SECRETS_DIR = CASTLE_HOME / "secrets" SECRETS_DIR = CASTLE_HOME / "secrets"
STATIC_DIR = CASTLE_HOME / "static"
@dataclass @dataclass
@@ -51,23 +52,17 @@ class CastleConfig:
@property @property
def services(self) -> dict[str, ComponentManifest]: def services(self) -> dict[str, ComponentManifest]:
"""Return components with the SERVICE role.""" """Return components with the SERVICE role."""
return { return {k: v for k, v in self.components.items() if Role.SERVICE in v.roles}
k: v for k, v in self.components.items() if Role.SERVICE in v.roles
}
@property @property
def tools(self) -> dict[str, ComponentManifest]: def tools(self) -> dict[str, ComponentManifest]:
"""Return components with the TOOL role.""" """Return components with the TOOL role."""
return { return {k: v for k, v in self.components.items() if Role.TOOL in v.roles}
k: v for k, v in self.components.items() if Role.TOOL in v.roles
}
@property @property
def workers(self) -> dict[str, ComponentManifest]: def workers(self) -> dict[str, ComponentManifest]:
"""Return components with the WORKER role.""" """Return components with the WORKER role."""
return { return {k: v for k, v in self.components.items() if Role.WORKER in v.roles}
k: v for k, v in self.components.items() if Role.WORKER in v.roles
}
@property @property
def managed(self) -> dict[str, ComponentManifest]: 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. # Keys whose presence is structurally significant even with all-default values.
# We serialize these as empty dicts `{}` so they survive a roundtrip. # 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: def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
@@ -214,4 +218,5 @@ def ensure_dirs() -> None:
CASTLE_HOME.mkdir(parents=True, exist_ok=True) CASTLE_HOME.mkdir(parents=True, exist_ok=True)
GENERATED_DIR.mkdir(parents=True, exist_ok=True) GENERATED_DIR.mkdir(parents=True, exist_ok=True)
SECRETS_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) os.chmod(SECRETS_DIR, 0o700)

View File

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

View File

@@ -2,11 +2,12 @@
from __future__ import annotations 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: 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" dist = config.root / "app" / "dist"
if dist.exists() and (dist / "index.html").exists(): if dist.exists() and (dist / "index.html").exists():
return str(dist) return str(dist)
@@ -14,12 +15,17 @@ def find_app_dist(config: CastleConfig) -> str | None:
def generate_caddyfile(config: CastleConfig) -> str: 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} {{"] lines = [f":{config.gateway.port} {{"]
# Reverse proxy for each component with proxy.caddy and expose.http # Reverse proxy for each component with proxy.caddy and expose.http
for name, manifest in config.components.items(): 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 continue
if not (manifest.expose and manifest.expose.http): if not (manifest.expose and manifest.expose.http):
continue continue
@@ -53,3 +59,39 @@ def generate_caddyfile(config: CastleConfig) -> str:
lines.append("}") lines.append("}")
return "\n".join(lines) 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 pathlib import Path
from castle_core.config import CastleConfig, resolve_env_vars 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" SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-" UNIT_PREFIX = "castle-"
@@ -42,7 +43,13 @@ def cron_to_oncalendar(cron: str) -> str:
minute, hour, dom, month, dow = parts minute, hour, dom, month, dow = parts
# */N minutes → run every N minutes # */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 return "" # Use OnUnitActiveSec instead
# Specific time daily: "0 2 * * *" → "*-*-* 02:00:00" # 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: if len(parts) != 5:
return None return None
minute, hour, dom, month, dow = parts 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: try:
return int(minute[2:]) * 60 return int(minute[2:]) * 60
except ValueError: except ValueError:
@@ -132,15 +145,19 @@ def build_podman_command(manifest: ComponentManifest) -> str:
def generate_unit(config: CastleConfig, name: str, 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 run = manifest.run
if run is None: if run is None:
raise ValueError(f"Component '{name}' has no run spec") 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) 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 = "" env_lines = ""
for key, value in resolved_env.items(): for key, value in resolved_env.items():
env_lines += f"Environment={key}={value}\n" env_lines += f"Environment={key}={value}\n"
@@ -166,7 +183,6 @@ After={after}
[Service] [Service]
Type=oneshot Type=oneshot
WorkingDirectory={working_dir}
ExecStart={exec_start} ExecStart={exec_start}
{env_lines}""" {env_lines}"""
else: else:
@@ -178,7 +194,68 @@ After={after}
[Service] [Service]
Type=simple 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} ExecStart={exec_start}
{env_lines}Restart={restart} {env_lines}Restart={restart}
RestartSec={restart_sec} RestartSec={restart_sec}

View File

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

View File

@@ -18,10 +18,12 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"components": { "components": {
"test-svc": { "test-svc": {
"description": "Test service", "description": "Test service",
"source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python_uv_tool",
"tool": "test-svc", "tool": "test-svc",
"working_dir": "test-svc", },
"defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")}, "env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
}, },
"expose": { "expose": {

View File

@@ -62,7 +62,7 @@ class TestLoadConfig:
svc = config.components["test-svc"] svc = config.components["test-svc"]
assert svc.run.runner == "python_uv_tool" assert svc.run.runner == "python_uv_tool"
assert svc.run.tool == "test-svc" assert svc.run.tool == "test-svc"
assert svc.run.working_dir == "test-svc" assert svc.source == "test-svc"
def test_tool_no_run(self, castle_root: Path) -> None: def test_tool_no_run(self, castle_root: Path) -> None:
"""Tool without run block has no run spec.""" """Tool without run block has no run spec."""

View File

@@ -33,9 +33,7 @@ class TestRoleDerivation:
m = ComponentManifest( m = ComponentManifest(
id="svc", id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"), run=RunPythonUvTool(runner="python_uv_tool", tool="svc"),
expose=ExposeSpec( expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
) )
assert Role.SERVICE in m.roles assert Role.SERVICE in m.roles
@@ -117,9 +115,7 @@ class TestRoleDerivation:
m = ComponentManifest( m = ComponentManifest(
id="multi", id="multi",
run=RunPythonUvTool(runner="python_uv_tool", tool="multi"), run=RunPythonUvTool(runner="python_uv_tool", tool="multi"),
expose=ExposeSpec( expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
install=InstallSpec(path=PathInstallSpec(alias="multi")), install=InstallSpec(path=PathInstallSpec(alias="multi")),
) )
assert Role.SERVICE in m.roles assert Role.SERVICE in m.roles
@@ -130,9 +126,7 @@ class TestRoleDerivation:
m = ComponentManifest( m = ComponentManifest(
id="svc", id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"), run=RunPythonUvTool(runner="python_uv_tool", tool="svc"),
expose=ExposeSpec( expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
http=HttpExposeSpec(internal=HttpInternal(port=8000))
),
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
assert Role.SERVICE in m.roles assert Role.SERVICE in m.roles
@@ -144,7 +138,9 @@ class TestConsistencyValidation:
def test_remote_with_systemd_raises(self) -> None: def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid.""" """Remote runner + systemd management is invalid."""
with pytest.raises(ValueError, match="manage.systemd cannot be enabled for runner=remote"): with pytest.raises(
ValueError, match="manage.systemd cannot be enabled for runner=remote"
):
ComponentManifest( ComponentManifest(
id="bad", id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"), run=RunRemote(runner="remote", base_url="http://example.com"),

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from castle_core.config import load_config from castle_core.config import load_config
from castle_core.generators.systemd import generate_unit, unit_name from castle_core.generators.systemd import (
generate_unit,
generate_unit_from_deployed,
unit_name,
)
from castle_core.registry import DeployedComponent
class TestUnitName: class TestUnitName:
@@ -27,15 +32,15 @@ class TestUnitGeneration:
unit = generate_unit(config, "test-svc", manifest) unit = generate_unit(config, "test-svc", manifest)
assert "Description=Castle: Test service" in unit assert "Description=Castle: Test service" in unit
def test_contains_working_dir(self, castle_root: Path) -> None: def test_no_working_directory(self, castle_root: Path) -> None:
"""Unit file has correct working directory.""" """Unit file has no WorkingDirectory (source/runtime separation)."""
config = load_config(castle_root) config = load_config(castle_root)
manifest = config.components["test-svc"] manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest) unit = generate_unit(config, "test-svc", manifest)
assert f"WorkingDirectory={castle_root / 'test-svc'}" in unit assert "WorkingDirectory" not in unit
def test_contains_environment(self, castle_root: Path) -> None: def test_contains_environment(self, castle_root: Path) -> None:
"""Unit file has environment variables.""" """Unit file has environment variables from defaults.env."""
config = load_config(castle_root) config = load_config(castle_root)
manifest = config.components["test-svc"] manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest) unit = generate_unit(config, "test-svc", manifest)
@@ -55,3 +60,47 @@ class TestUnitGeneration:
manifest = config.components["test-svc"] manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest) unit = generate_unit(config, "test-svc", manifest)
assert "run test-svc" in unit assert "run test-svc" in unit
class TestUnitFromDeployed:
"""Tests for registry-based systemd unit generation."""
def test_basic_service(self) -> None:
"""Generate a unit from a deployed component."""
deployed = DeployedComponent(
runner="python_uv_tool",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/data/castle/my-svc"},
description="My service",
)
unit = generate_unit_from_deployed("my-svc", deployed)
assert "Description=Castle: My service" in unit
assert "ExecStart=/home/user/.local/bin/uv run my-svc" in unit
assert "Environment=MY_SVC_PORT=9001" in unit
assert "Environment=MY_SVC_DATA_DIR=/data/castle/my-svc" in unit
assert "WorkingDirectory" not in unit
assert "Restart=on-failure" in unit
def test_scheduled_job(self) -> None:
"""Scheduled component generates oneshot unit."""
deployed = DeployedComponent(
runner="command",
run_cmd=["/home/user/.local/bin/my-job"],
env={},
description="Nightly job",
schedule="0 2 * * *",
)
unit = generate_unit_from_deployed("my-job", deployed)
assert "Type=oneshot" in unit
assert "Restart=" not in unit
def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths."""
deployed = DeployedComponent(
runner="python_uv_tool",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/data/castle/my-svc"},
description="Test",
)
unit = generate_unit_from_deployed("my-svc", deployed)
assert "/data/repos/" not in unit