refactor: Decouple roles.

This commit is contained in:
2026-02-23 01:49:24 -08:00
parent 72d35f2641
commit eeaa5045d0
55 changed files with 2144 additions and 1276 deletions

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import save_config
from castle_core.manifest import ComponentManifest
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
@@ -29,6 +29,8 @@ class ConfigSaveRequest(BaseModel):
class ConfigSaveResponse(BaseModel):
ok: bool
component_count: int
service_count: int
job_count: int
errors: list[str]
@@ -42,6 +44,14 @@ class ComponentConfigRequest(BaseModel):
config: dict
class ServiceConfigRequest(BaseModel):
config: dict
class JobConfigRequest(BaseModel):
config: dict
def _require_repo() -> None:
"""Raise 503 if repo is not available."""
if get_castle_root() is None:
@@ -76,22 +86,44 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
detail=f"Invalid YAML: {e}",
)
if not isinstance(data, dict) or "components" not in data:
if not isinstance(data, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="YAML must have a 'components' key",
detail="YAML must be a mapping",
)
# Validate each component
count = 0
# Validate components
comp_count = 0
for name, comp_data in data.get("components", {}).items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
ComponentManifest.model_validate(comp_data_copy)
count += 1
ComponentSpec.model_validate(comp_data_copy)
comp_count += 1
except Exception as e:
errors.append(f"{name}: {e}")
errors.append(f"components.{name}: {e}")
# Validate services
svc_count = 0
for name, svc_data in data.get("services", {}).items():
try:
svc_data_copy = dict(svc_data) if svc_data else {}
svc_data_copy["id"] = name
ServiceSpec.model_validate(svc_data_copy)
svc_count += 1
except Exception as e:
errors.append(f"services.{name}: {e}")
# Validate jobs
job_count = 0
for name, job_data in data.get("jobs", {}).items():
try:
job_data_copy = dict(job_data) if job_data else {}
job_data_copy["id"] = name
JobSpec.model_validate(job_data_copy)
job_count += 1
except Exception as e:
errors.append(f"jobs.{name}: {e}")
if errors:
raise HTTPException(
@@ -105,7 +137,10 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content)
return ConfigSaveResponse(ok=True, component_count=count, errors=[])
return ConfigSaveResponse(
ok=True, component_count=comp_count, service_count=svc_count,
job_count=job_count, errors=[],
)
@router.put("/components/{name}")
@@ -113,11 +148,10 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
_require_repo()
# Validate
try:
comp_data = dict(request.config)
comp_data["id"] = name
ComponentManifest.model_validate(comp_data)
ComponentSpec.model_validate(comp_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -125,7 +159,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
)
config = get_config()
config.components[name] = ComponentManifest.model_validate(
config.components[name] = ComponentSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
@@ -146,6 +180,80 @@ def delete_component(name: str) -> dict:
return {"ok": True, "component": name, "action": "deleted"}
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Update a single service's config in castle.yaml."""
_require_repo()
try:
svc_data = dict(request.config)
svc_data["id"] = name
ServiceSpec.model_validate(svc_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid service config: {e}",
)
config = get_config()
config.services[name] = ServiceSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "service": name}
@router.delete("/services/{name}")
def delete_service(name: str) -> dict:
"""Remove a service from castle.yaml."""
config = get_config()
if name not in config.services:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
del config.services[name]
save_config(config)
return {"ok": True, "service": name, "action": "deleted"}
@router.put("/jobs/{name}")
def save_job(name: str, request: JobConfigRequest) -> dict:
"""Update a single job's config in castle.yaml."""
_require_repo()
try:
job_data = dict(request.config)
job_data["id"] = name
JobSpec.model_validate(job_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid job config: {e}",
)
config = get_config()
config.jobs[name] = JobSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "job": name}
@router.delete("/jobs/{name}")
def delete_job(name: str) -> dict:
"""Remove a job from castle.yaml."""
config = get_config()
if name not in config.jobs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
del config.jobs[name]
save_config(config)
return {"ok": True, "job": name, "action": "deleted"}
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Apply config: restart managed services + regenerate and reload gateway."""

View File

@@ -8,9 +8,7 @@ from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse
from castle_core.config import load_config
from castle_api.config import settings
from castle_api.config import get_castle_root
router = APIRouter(prefix="/logs", tags=["logs"])
@@ -24,11 +22,24 @@ async def get_logs(
follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service."""
config = load_config(settings.castle_root)
if name not in config.managed:
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
is_managed = (
(name in config.services and config.services[name].manage is not None)
or (name in config.jobs and config.jobs[name].manage is not None)
)
if not is_managed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
detail="Castle root not available",
)
unit = f"{UNIT_PREFIX}{name}.service"

View File

@@ -16,7 +16,7 @@ class ComponentSummary(BaseModel):
id: str
description: str | None = None
roles: list[str]
category: str
runner: str | None = None
port: int | None = None
health_path: str | None = None

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health
@@ -39,13 +40,13 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
# Check if tool is installed on PATH
installed: bool | None = None
if "tool" in deployed.roles:
if deployed.category == "tool":
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
roles=deployed.roles,
category=deployed.category,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
@@ -57,67 +58,116 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
)
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a manifest (for non-deployed components)."""
def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
proxy_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
if manifest.proxy and manifest.proxy.caddy:
proxy_path = manifest.proxy.caddy.path_prefix
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
managed = bool(
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
)
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any(
getattr(t, "type", None) == "schedule" for t in manifest.triggers
)
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
schedule = None
for t in manifest.triggers:
if t.type == "schedule":
schedule = t.cron
break
description = svc.description
source = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if not description:
description = comp.description
source = comp.source
runner = manifest.run.runner if manifest.run else None
if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if manifest.install and manifest.install.path:
alias = manifest.install.path.alias or name
installed = shutil.which(alias) is not None
runner = svc.run.runner
return ComponentSummary(
id=name,
description=manifest.description,
roles=[r.value for r in manifest.roles],
description=description,
category="service",
runner=runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
systemd=systemd_info,
version=manifest.tool.version if manifest.tool else None,
source=manifest.tool.source if manifest.tool else None,
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
schedule=schedule,
source=source,
)
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=True)
description = job.description
source = None
if job.component and job.component in config.components:
comp = config.components[job.component]
if not description:
description = comp.description
source = comp.source
return ComponentSummary(
id=name,
description=description,
category="job",
runner=job.run.runner,
managed=managed,
systemd=systemd_info,
schedule=job.schedule,
source=source,
)
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
# Determine category
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
category = "tool"
elif is_frontend:
category = "frontend"
else:
category = "component"
source = comp.source
# Infer runner from source directory
runner = None
if source:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
return ComponentSummary(
id=name,
description=comp.description,
category=category,
runner=runner,
version=comp.tool.version if comp.tool else None,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
installed=installed,
)
@@ -134,16 +184,49 @@ def list_components() -> list[ComponentSummary]:
summaries.append(_summary_from_deployed(name, deployed))
seen.add(name)
# Non-deployed components from castle.yaml (if repo available)
# Non-deployed 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():
# Services not in registry
for name, svc in config.services.items():
if name not in seen:
summaries.append(_summary_from_manifest(name, manifest, root))
summaries.append(_summary_from_service(name, svc, config))
seen.add(name)
# Jobs not in registry
for name, job in config.jobs.items():
if name not in seen:
summaries.append(_summary_from_job(name, job, config))
seen.add(name)
# Backfill source from component refs for deployed items
for s in summaries:
if s.source is None and s.id in config.components:
s.source = config.components[s.id].source
elif s.source is None:
# Check if a service/job references a component
ref = None
if s.id in config.services:
ref = config.services[s.id].component
elif s.id in config.jobs:
ref = config.jobs[s.id].component
if ref and ref in config.components:
s.source = config.components[ref].source
# Components (tools/frontends) — always listed, even if a
# service/job with the same name exists. A component is
# "what software exists", services/jobs are "how it runs".
for name, comp in config.components.items():
summary = _summary_from_component(name, comp, root)
# Skip if this exact category is already represented
# (e.g. a deployed tool already in the list)
if not any(s.id == name and s.category == summary.category for s in summaries):
summaries.append(summary)
except FileNotFoundError:
pass
@@ -158,6 +241,27 @@ def get_component(name: str) -> ComponentDetail:
if name in registry.deployed:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
summary.source = config.components[name].source
else:
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
if ref and ref in config.components:
summary.source = config.components[ref].source
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
@@ -166,7 +270,7 @@ def get_component(name: str) -> ComponentDetail:
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"roles": deployed.roles,
"category": deployed.category,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)
@@ -176,10 +280,23 @@ def get_component(name: str) -> ComponentDetail:
from castle_core.config import load_config
config = load_config(root)
if name in config.services:
svc = config.services[name]
summary = _summary_from_service(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.jobs:
job = config.jobs[name]
summary = _summary_from_job(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
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)
comp = config.components[name]
summary = _summary_from_component(name, comp, root)
raw = comp.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(

View File

@@ -131,21 +131,29 @@ def get_unit(name: str) -> dict[str, str | None]:
registry = get_registry()
deployed = registry.deployed[name]
# Get systemd spec from manifest if repo available
# Get systemd spec from config if repo available
systemd_spec = None
schedule = None
description = 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
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
description = svc.description
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
schedule = job.schedule
description = job.description
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, manifest) if manifest else None
timer = generate_timer(name, schedule, description) if schedule else None
return {"service": unit, "timer": timer}

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.manifest import ComponentManifest
from castle_core.manifest import ComponentSpec
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
@@ -15,20 +15,25 @@ from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _is_tool(comp: ComponentSpec) -> bool:
"""Check if a component is a tool (has install.path or tool spec)."""
return bool((comp.install and comp.install.path) or comp.tool)
def _tool_summary(
name: str, manifest: ComponentManifest, root: Path | None = None
name: str, comp: ComponentSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a manifest that has a tool spec."""
t = manifest.tool
assert t is not None
"""Build a ToolSummary from a ComponentSpec that is a tool."""
t = comp.tool
installed = bool(
manifest.install and manifest.install.path and manifest.install.path.enable
comp.install and comp.install.path and comp.install.path.enable
)
# Infer runner from run block or source directory
runner = manifest.run.runner if manifest.run else None
if runner is None and t.source and root:
source_dir = root / t.source
# Infer runner from source directory
runner = None
source = comp.source
if source and root:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
@@ -36,11 +41,11 @@ def _tool_summary(
return ToolSummary(
id=name,
description=manifest.description,
source=t.source,
version=t.version,
description=comp.description,
source=source,
version=t.version if t else None,
runner=runner,
system_dependencies=t.system_dependencies,
system_dependencies=t.system_dependencies if t else [],
installed=installed,
)
@@ -49,12 +54,12 @@ def _tool_summary(
def list_tools() -> list[ToolSummary]:
"""List all registered tools (requires repo access)."""
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 _is_tool(v)}
return sorted(
[
_tool_summary(name, manifest, config.root)
for name, manifest in tools.items()
_tool_summary(name, comp, config.root)
for name, comp in tools.items()
],
key=lambda t: t.id,
)
@@ -71,14 +76,14 @@ def get_tool(name: str) -> ToolDetail:
detail=f"Component '{name}' not found",
)
manifest = config.components[name]
if not manifest.tool:
comp = config.components[name]
if not _is_tool(comp):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a tool",
)
summary = _tool_summary(name, manifest, config.root)
summary = _tool_summary(name, comp, config.root)
return ToolDetail(**summary.model_dump())
@@ -91,16 +96,16 @@ async def install_tool(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
comp = config.components[name]
if not comp.source:
raise HTTPException(
status_code=400, detail=f"'{name}' has no tool source to install"
status_code=400, detail=f"'{name}' has no source to install"
)
source_dir = config.root / manifest.tool.source
source_dir = config.root / comp.source
if not (source_dir / "pyproject.toml").exists():
raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
status_code=400, detail=f"No pyproject.toml in {comp.source}"
)
proc = await asyncio.create_subprocess_exec(
@@ -131,12 +136,12 @@ async def uninstall_tool(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source")
comp = config.components[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source")
# uv tool uninstall uses the package name from pyproject.toml
source_dir = config.root / manifest.tool.source
source_dir = config.root / comp.source
pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml"
if pyproject.exists():

View File

@@ -24,9 +24,26 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = {
"gateway": {"port": 9000},
"components": {
"test-tool": {
"description": "Test tool",
"source": "test-tool",
"install": {"path": {"alias": "test-tool"}},
"tool": {
"system_dependencies": ["pandoc"],
},
},
"test-tool-2": {
"description": "Another test tool",
"source": "test-tool-2",
"tool": {
"version": "2.0.0",
},
},
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python",
"tool": "test-svc",
@@ -40,20 +57,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"proxy": {"caddy": {"path_prefix": "/test-svc"}},
"manage": {"systemd": {}},
},
"test-tool": {
"description": "Test tool",
"install": {"path": {"alias": "test-tool"}},
"tool": {
"source": "test-tool",
"system_dependencies": ["pandoc"],
},
},
"test-tool-2": {
"description": "Another test tool",
"tool": {
"source": "test-tool-2",
"version": "2.0.0",
},
"jobs": {
"test-job": {
"description": "Test job",
"run": {
"runner": "command",
"argv": ["test-job"],
},
"schedule": "0 2 * * *",
},
},
}
@@ -80,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
},
description="Test service",
roles=["service"],
category="service",
port=19000,
health_path="/health",
proxy_path="/test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True
assert "service" in svc["roles"]
assert svc["category"] == "service"
def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port."""
@@ -42,7 +42,15 @@ class TestComponents:
data = response.json()
tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None
assert "tool" in tool["roles"]
assert tool["category"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule."""
response = client.get("/components")
data = response.json()
job = next(c for c in data if c["id"] == "test-job")
assert job["category"] == "job"
assert job["schedule"] == "0 2 * * *"
class TestComponentDetail: