refactor: Decouple roles.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user