refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
|
||||||
raise HTTPException(
|
if name in registry.deployed:
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
deployed = registry.deployed[name]
|
||||||
detail=f"Component '{name}' not found",
|
summary = _summary_from_deployed(name, deployed)
|
||||||
)
|
raw = {
|
||||||
manifest = config.components[name]
|
"runner": deployed.runner,
|
||||||
summary = _summary_from_manifest(name, manifest, config.root)
|
"run_cmd": deployed.run_cmd,
|
||||||
raw = manifest.model_dump(mode="json", exclude_none=True)
|
"env": deployed.env,
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
"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(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Component '{name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@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)}
|
|
||||||
|
|||||||
@@ -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(
|
||||||
id=override_name,
|
HealthStatus(
|
||||||
status="down" if override_status != "active" else "up",
|
id=override_name,
|
||||||
latency_ms=None,
|
status="down" if override_status != "active" else "up",
|
||||||
))
|
latency_ms=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result.append(s)
|
result.append(s)
|
||||||
|
|
||||||
await broadcast("health", {
|
await broadcast(
|
||||||
"statuses": [s.model_dump() for s in result],
|
"health",
|
||||||
"timestamp": time.time(),
|
{
|
||||||
})
|
"statuses": [s.model_dump() for s in result],
|
||||||
|
"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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
"statuses": [s.model_dump() for s in statuses],
|
"health",
|
||||||
"timestamp": time.time(),
|
{
|
||||||
})
|
"statuses": [s.model_dump() for s in statuses],
|
||||||
|
"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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
54
castle.yaml
54
castle.yaml
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
285
cli/src/castle_cli/commands/deploy.py
Normal file
285
cli/src/castle_cli/commands/deploy.py
Normal 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}")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -42,21 +42,25 @@ 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" {key}: {val}")
|
print(f" {BOLD}defaults.env{RESET}:")
|
||||||
|
for key, val in manifest.defaults.env.items():
|
||||||
|
print(f" {key}: {val}")
|
||||||
|
|
||||||
# Expose
|
# Expose
|
||||||
if manifest.expose and manifest.expose.http:
|
if manifest.expose and manifest.expose.http:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
systemd_spec = None
|
||||||
|
if name in config.components:
|
||||||
|
manifest = config.components[name]
|
||||||
|
if manifest.manage and manifest.manage.systemd:
|
||||||
|
systemd_spec = manifest.manage.systemd
|
||||||
|
|
||||||
manifest = managed[name]
|
svc_unit = unit_name(name)
|
||||||
if not manifest.run:
|
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||||
print(f"Error: '{name}' has no run spec defined")
|
print(f"# {svc_unit}")
|
||||||
return 1
|
print(svc_content)
|
||||||
|
|
||||||
svc_unit = unit_name(name)
|
manifest = config.components.get(name)
|
||||||
svc_content = generate_unit(config, name, manifest)
|
if manifest:
|
||||||
print(f"# {svc_unit}")
|
timer_content = generate_timer(name, manifest)
|
||||||
print(svc_content)
|
if timer_content:
|
||||||
|
print(f"# {timer_name(name)}")
|
||||||
|
print(timer_content)
|
||||||
|
return 0
|
||||||
|
|
||||||
timer_content = generate_timer(name, manifest)
|
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
|
||||||
if timer_content:
|
return 1
|
||||||
print(f"# {timer_name(name)}")
|
|
||||||
print(timer_content)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
138
core/src/castle_core/registry.py
Normal file
138
core/src/castle_core/registry.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Node registry — per-machine deployment state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from castle_core.config import CASTLE_HOME
|
||||||
|
|
||||||
|
REGISTRY_PATH = CASTLE_HOME / "registry.yaml"
|
||||||
|
STATIC_DIR = CASTLE_HOME / "static"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeConfig:
|
||||||
|
"""Per-node identity and settings."""
|
||||||
|
|
||||||
|
hostname: str = ""
|
||||||
|
castle_root: str | None = None # repo path, for dev commands
|
||||||
|
gateway_port: int = 9000
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.hostname:
|
||||||
|
self.hostname = socket.gethostname()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeployedComponent:
|
||||||
|
"""A component deployed on this node with resolved runtime config."""
|
||||||
|
|
||||||
|
runner: str
|
||||||
|
run_cmd: list[str]
|
||||||
|
env: dict[str, str] = field(default_factory=dict)
|
||||||
|
description: str | None = None
|
||||||
|
roles: list[str] = field(default_factory=list)
|
||||||
|
port: int | None = None
|
||||||
|
health_path: str | None = None
|
||||||
|
proxy_path: str | None = None
|
||||||
|
schedule: str | None = None
|
||||||
|
managed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NodeRegistry:
|
||||||
|
"""What's deployed on this node."""
|
||||||
|
|
||||||
|
node: NodeConfig
|
||||||
|
deployed: dict[str, DeployedComponent] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||||
|
"""Load the node registry from ~/.castle/registry.yaml."""
|
||||||
|
if path is None:
|
||||||
|
path = REGISTRY_PATH
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Registry not found: {path}\n"
|
||||||
|
"Run 'castle deploy' to generate it from castle.yaml."
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ValueError(f"Empty registry: {path}")
|
||||||
|
|
||||||
|
node_data = data.get("node", {})
|
||||||
|
node = NodeConfig(
|
||||||
|
hostname=node_data.get("hostname", ""),
|
||||||
|
castle_root=node_data.get("castle_root"),
|
||||||
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
|
)
|
||||||
|
|
||||||
|
deployed: dict[str, DeployedComponent] = {}
|
||||||
|
for name, comp_data in data.get("deployed", {}).items():
|
||||||
|
deployed[name] = DeployedComponent(
|
||||||
|
runner=comp_data.get("runner", "command"),
|
||||||
|
run_cmd=comp_data.get("run_cmd", []),
|
||||||
|
env=comp_data.get("env", {}),
|
||||||
|
description=comp_data.get("description"),
|
||||||
|
roles=comp_data.get("roles", []),
|
||||||
|
port=comp_data.get("port"),
|
||||||
|
health_path=comp_data.get("health_path"),
|
||||||
|
proxy_path=comp_data.get("proxy_path"),
|
||||||
|
schedule=comp_data.get("schedule"),
|
||||||
|
managed=comp_data.get("managed", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return NodeRegistry(node=node, deployed=deployed)
|
||||||
|
|
||||||
|
|
||||||
|
def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||||
|
"""Write the node registry to ~/.castle/registry.yaml."""
|
||||||
|
if path is None:
|
||||||
|
path = REGISTRY_PATH
|
||||||
|
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
data: dict = {
|
||||||
|
"node": {
|
||||||
|
"hostname": registry.node.hostname,
|
||||||
|
"gateway_port": registry.node.gateway_port,
|
||||||
|
},
|
||||||
|
"deployed": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry.node.castle_root:
|
||||||
|
data["node"]["castle_root"] = registry.node.castle_root
|
||||||
|
|
||||||
|
for name, comp in registry.deployed.items():
|
||||||
|
entry: dict = {
|
||||||
|
"runner": comp.runner,
|
||||||
|
"run_cmd": comp.run_cmd,
|
||||||
|
}
|
||||||
|
if comp.env:
|
||||||
|
entry["env"] = comp.env
|
||||||
|
if comp.description:
|
||||||
|
entry["description"] = comp.description
|
||||||
|
if comp.roles:
|
||||||
|
entry["roles"] = comp.roles
|
||||||
|
if comp.port is not None:
|
||||||
|
entry["port"] = comp.port
|
||||||
|
if comp.health_path:
|
||||||
|
entry["health_path"] = comp.health_path
|
||||||
|
if comp.proxy_path:
|
||||||
|
entry["proxy_path"] = comp.proxy_path
|
||||||
|
if comp.schedule:
|
||||||
|
entry["schedule"] = comp.schedule
|
||||||
|
if comp.managed:
|
||||||
|
entry["managed"] = comp.managed
|
||||||
|
data["deployed"][name] = entry
|
||||||
|
|
||||||
|
with open(path, "w") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user