Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.
- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
normalizes old runner shapes; CastleConfig.deployments with derived
services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.
Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
This commit is contained in:
@@ -12,12 +12,14 @@ from pydantic import BaseModel
|
||||
from castle_core.config import (
|
||||
CastleConfig,
|
||||
GatewayConfig,
|
||||
_DEPLOYMENT_ADAPTER,
|
||||
_normalize_deployment_dict,
|
||||
_program_to_yaml_dict,
|
||||
_spec_to_yaml_dict,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, kind_for
|
||||
|
||||
from castle_api.config import get_castle_root, get_config, get_registry
|
||||
from castle_api.stream import broadcast
|
||||
@@ -79,12 +81,10 @@ def _aggregate_yaml(config: CastleConfig) -> str:
|
||||
data["programs"] = {
|
||||
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
|
||||
}
|
||||
if config.services:
|
||||
data["services"] = {
|
||||
n: _spec_to_yaml_dict(s) for n, s in config.services.items()
|
||||
if config.deployments:
|
||||
data["deployments"] = {
|
||||
n: _spec_to_yaml_dict(s) for n, s in config.deployments.items()
|
||||
}
|
||||
if config.jobs:
|
||||
data["jobs"] = {n: _spec_to_yaml_dict(s) for n, s in config.jobs.items()}
|
||||
return yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
@@ -148,25 +148,20 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
except Exception as e:
|
||||
errors.append(f"programs.{name}: {e}")
|
||||
|
||||
# Validate services
|
||||
services: dict[str, ServiceSpec] = {}
|
||||
for name, svc_data in data.get("services", {}).items():
|
||||
# Validate deployments (accepting a legacy services:/jobs: split too, which
|
||||
# the normalizer folds into the single manager-discriminated collection).
|
||||
deployments = {}
|
||||
raw_deps: dict = dict(data.get("deployments") or {})
|
||||
for legacy in ("services", "jobs"):
|
||||
raw_deps.update(data.get(legacy) or {})
|
||||
for name, dep_data in raw_deps.items():
|
||||
try:
|
||||
svc_data_copy = dict(svc_data) if svc_data else {}
|
||||
svc_data_copy["id"] = name
|
||||
services[name] = ServiceSpec.model_validate(svc_data_copy)
|
||||
dep_copy = _normalize_deployment_dict(dict(dep_data) if dep_data else {})
|
||||
dep_copy = dict(dep_copy)
|
||||
dep_copy["id"] = name
|
||||
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
|
||||
except Exception as e:
|
||||
errors.append(f"services.{name}: {e}")
|
||||
|
||||
# Validate jobs
|
||||
jobs: dict[str, JobSpec] = {}
|
||||
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
|
||||
jobs[name] = JobSpec.model_validate(job_data_copy)
|
||||
except Exception as e:
|
||||
errors.append(f"jobs.{name}: {e}")
|
||||
errors.append(f"deployments.{name}: {e}")
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
@@ -175,8 +170,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
)
|
||||
|
||||
prog_count = len(programs)
|
||||
svc_count = len(services)
|
||||
job_count = len(jobs)
|
||||
svc_count = sum(1 for d in deployments.values() if kind_for(d) == "service")
|
||||
job_count = sum(1 for d in deployments.values() if kind_for(d) == "job")
|
||||
|
||||
gateway_data = data.get("gateway", {})
|
||||
config = CastleConfig(
|
||||
@@ -184,8 +179,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
repo=repo_path,
|
||||
gateway=GatewayConfig(port=gateway_data.get("port", 9000)),
|
||||
programs=programs,
|
||||
services=services,
|
||||
jobs=jobs,
|
||||
deployments=deployments,
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
@@ -232,8 +226,7 @@ def delete_program(name: str) -> dict:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Program '{name}' not found",
|
||||
)
|
||||
refs = [s for s, spec in config.services.items() if spec.program == name]
|
||||
refs += [j for j, spec in config.jobs.items() if spec.program == name]
|
||||
refs = [d for d, spec in config.deployments.items() if spec.program == name]
|
||||
if refs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -247,74 +240,70 @@ def delete_program(name: str) -> dict:
|
||||
return {"ok": True, "program": name, "action": "deleted"}
|
||||
|
||||
|
||||
@router.put("/services/{name}")
|
||||
def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Update a single service's config in castle.yaml."""
|
||||
def _save_deployment(name: str, config_dict: dict) -> dict:
|
||||
"""Validate a deployment (any manager) and persist it to config.deployments."""
|
||||
_require_repo()
|
||||
|
||||
try:
|
||||
svc_data = dict(request.config)
|
||||
svc_data["id"] = name
|
||||
ServiceSpec.model_validate(svc_data)
|
||||
dep_data = _normalize_deployment_dict({**config_dict, "id": name})
|
||||
_DEPLOYMENT_ADAPTER.validate_python(dep_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid service config: {e}",
|
||||
detail=f"Invalid deployment config: {e}",
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.services[name] = ServiceSpec.model_validate({**request.config, "id": name})
|
||||
config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(
|
||||
_normalize_deployment_dict({**config_dict, "id": name})
|
||||
)
|
||||
save_config(config)
|
||||
return {"ok": True, "service": name}
|
||||
return {"ok": True, "deployment": name}
|
||||
|
||||
|
||||
def _delete_deployment(name: str) -> dict:
|
||||
config = get_config()
|
||||
if name not in config.deployments:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Deployment '{name}' not found",
|
||||
)
|
||||
del config.deployments[name]
|
||||
save_config(config)
|
||||
return {"ok": True, "deployment": name, "action": "deleted"}
|
||||
|
||||
|
||||
# The deployment endpoints — `deployments` is canonical; `services`/`jobs` remain
|
||||
# as aliases (the kind is derived, so all three target the one collection).
|
||||
@router.put("/deployments/{name}")
|
||||
def save_deployment(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update a deployment of any kind (service/job/tool/static)."""
|
||||
return _save_deployment(name, request.config)
|
||||
|
||||
|
||||
@router.delete("/deployments/{name}")
|
||||
def delete_deployment(name: str) -> dict:
|
||||
return _delete_deployment(name)
|
||||
|
||||
|
||||
@router.put("/services/{name}")
|
||||
def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
|
||||
return _save_deployment(name, request.config)
|
||||
|
||||
|
||||
@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"}
|
||||
return _delete_deployment(name)
|
||||
|
||||
|
||||
@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}
|
||||
"""Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
|
||||
return _save_deployment(name, request.config)
|
||||
|
||||
|
||||
@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"}
|
||||
return _delete_deployment(name)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
|
||||
@@ -27,10 +27,8 @@ async def get_logs(
|
||||
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)
|
||||
)
|
||||
dep = config.deployments.get(name)
|
||||
is_managed = dep is not None and getattr(dep, "manage", None) is not None
|
||||
if not is_managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -17,9 +17,10 @@ class DeploymentSummary(BaseModel):
|
||||
id: str
|
||||
category: str | None = None # "program", "service", or "job"
|
||||
description: str | None = None
|
||||
behavior: str | None = None
|
||||
kind: str | None = None # derived: service|job|tool|static|reference
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
manager: str | None = None # systemd|caddy|path|none
|
||||
launcher: str | None = None # python|command|container|compose|node (systemd only)
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
|
||||
@@ -49,7 +50,7 @@ class ServiceSummary(BaseModel):
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
launcher: str | None = None # python|command|container|compose|node
|
||||
run_target: str | None = None # what it runs: program name, argv, image, …
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
@@ -73,7 +74,7 @@ class JobSummary(BaseModel):
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
launcher: str | None = None # python|command|container|compose|node
|
||||
run_target: str | None = None # what it runs: program name, argv, …
|
||||
schedule: str | None = None
|
||||
managed: bool = False
|
||||
@@ -94,9 +95,8 @@ class ProgramSummary(BaseModel):
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
behavior: str | None = None
|
||||
kind: str | None = None # derived: service|job|tool|static|reference
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
repo: str | None = None
|
||||
|
||||
@@ -43,8 +43,9 @@ def _registry_to_json(registry: NodeRegistry) -> str:
|
||||
|
||||
for name, comp in registry.deployed.items():
|
||||
entry: dict = {
|
||||
"runner": comp.runner,
|
||||
"behavior": comp.behavior,
|
||||
"manager": comp.manager,
|
||||
"launcher": comp.launcher,
|
||||
"kind": comp.kind,
|
||||
}
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
@@ -77,11 +78,12 @@ def _json_to_registry(payload: str) -> NodeRegistry:
|
||||
deployed: dict[str, Deployment] = {}
|
||||
for name, comp_data in data.get("deployed", {}).items():
|
||||
deployed[name] = Deployment(
|
||||
runner=comp_data.get("runner", "command"),
|
||||
manager=comp_data.get("manager", "systemd"),
|
||||
launcher=comp_data.get("launcher"),
|
||||
run_cmd=comp_data.get("run_cmd", []),
|
||||
env=comp_data.get("env", {}),
|
||||
description=comp_data.get("description"),
|
||||
behavior=comp_data.get("behavior", "daemon"),
|
||||
kind=comp_data.get("kind", "service"),
|
||||
stack=comp_data.get("stack"),
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
|
||||
@@ -48,9 +48,10 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu
|
||||
id=name,
|
||||
category="job" if d.schedule else "service",
|
||||
description=d.description,
|
||||
behavior=d.behavior,
|
||||
kind=d.kind,
|
||||
stack=d.stack,
|
||||
runner=d.runner,
|
||||
manager=d.manager,
|
||||
launcher=d.launcher,
|
||||
port=d.port,
|
||||
health_path=d.health_path,
|
||||
subdomain=d.subdomain,
|
||||
|
||||
@@ -13,10 +13,8 @@ from castle_core.config import SPECS_DIR
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
from castle_core.manifest import (
|
||||
ProgramSpec,
|
||||
JobSpec,
|
||||
ServiceSpec,
|
||||
behavior_for_runner,
|
||||
manager_for,
|
||||
SystemdDeployment,
|
||||
kind_for,
|
||||
)
|
||||
from castle_core.stacks import available_actions
|
||||
|
||||
@@ -80,7 +78,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
|
||||
|
||||
# A PATH-managed deployment (a tool) is "installed" when it's on PATH.
|
||||
installed: bool | None = None
|
||||
if manager_for(deployed.runner) == "path":
|
||||
if deployed.manager == "path":
|
||||
installed = shutil.which(name) is not None
|
||||
|
||||
category = "job" if deployed.schedule else "service"
|
||||
@@ -89,9 +87,10 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
|
||||
id=name,
|
||||
category=category,
|
||||
description=deployed.description,
|
||||
behavior=deployed.behavior,
|
||||
kind=deployed.kind,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
manager=deployed.manager,
|
||||
launcher=deployed.launcher,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
subdomain=deployed.subdomain,
|
||||
@@ -103,9 +102,9 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
|
||||
|
||||
|
||||
def _summary_from_service(
|
||||
name: str, svc: ServiceSpec, config: object
|
||||
name: str, svc: SystemdDeployment, config: object
|
||||
) -> DeploymentSummary:
|
||||
"""Build a DeploymentSummary from a ServiceSpec (non-deployed)."""
|
||||
"""Build a DeploymentSummary from a systemd deployment (service, non-deployed)."""
|
||||
port = None
|
||||
health_path = None
|
||||
if svc.expose and svc.expose.http:
|
||||
@@ -133,15 +132,14 @@ def _summary_from_service(
|
||||
source = comp.source
|
||||
stack = comp.stack
|
||||
|
||||
runner = svc.run.runner
|
||||
|
||||
return DeploymentSummary(
|
||||
id=name,
|
||||
category="service",
|
||||
description=description,
|
||||
behavior=behavior_for_runner(runner),
|
||||
kind=kind_for(svc),
|
||||
stack=stack,
|
||||
runner=runner,
|
||||
manager="systemd",
|
||||
launcher=svc.run.launcher,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
subdomain=subdomain,
|
||||
@@ -151,8 +149,8 @@ def _summary_from_service(
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary:
|
||||
"""Build a DeploymentSummary from a JobSpec (non-deployed)."""
|
||||
def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> DeploymentSummary:
|
||||
"""Build a DeploymentSummary from a systemd deployment (job, non-deployed)."""
|
||||
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||
|
||||
systemd_info: SystemdInfo | None = None
|
||||
@@ -175,9 +173,10 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
|
||||
id=name,
|
||||
category="job",
|
||||
description=description,
|
||||
behavior="tool",
|
||||
kind="job",
|
||||
stack=stack,
|
||||
runner=job.run.runner,
|
||||
manager="systemd",
|
||||
launcher=job.run.launcher,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
schedule=job.schedule,
|
||||
@@ -188,18 +187,9 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
|
||||
def _summary_from_program(
|
||||
name: str, comp: ProgramSpec, root: Path
|
||||
) -> DeploymentSummary:
|
||||
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends)."""
|
||||
"""Build a DeploymentSummary from a ProgramSpec (its derived kind)."""
|
||||
source = comp.source
|
||||
|
||||
# Infer runner from source directory
|
||||
runner = None
|
||||
if source:
|
||||
source_dir = Path(source)
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
installed: bool | None = None
|
||||
if comp.source and (comp.stack or comp.commands):
|
||||
installed = shutil.which(name) is not None
|
||||
@@ -208,9 +198,8 @@ def _summary_from_program(
|
||||
id=name,
|
||||
category="program",
|
||||
description=comp.description,
|
||||
behavior=comp.behavior,
|
||||
kind=comp.kind,
|
||||
stack=comp.stack,
|
||||
runner=runner,
|
||||
version=comp.version,
|
||||
source=source,
|
||||
repo=comp.repo,
|
||||
@@ -268,7 +257,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
launcher=deployed.launcher,
|
||||
run_target=run_target,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
@@ -278,8 +267,8 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||
)
|
||||
|
||||
|
||||
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary:
|
||||
"""Build a ServiceSummary from a ServiceSpec."""
|
||||
def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> ServiceSummary:
|
||||
"""Build a ServiceSummary from a systemd deployment."""
|
||||
port = None
|
||||
health_path = None
|
||||
if svc.expose and svc.expose.http:
|
||||
@@ -305,7 +294,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
|
||||
id=name,
|
||||
description=description,
|
||||
stack=stack,
|
||||
runner=svc.run.runner,
|
||||
launcher=svc.run.launcher,
|
||||
run_target=_run_target(svc.run),
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
@@ -325,7 +314,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
launcher=deployed.launcher,
|
||||
run_target=run_target,
|
||||
schedule=deployed.schedule,
|
||||
managed=deployed.managed,
|
||||
@@ -333,8 +322,8 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||
)
|
||||
|
||||
|
||||
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
||||
"""Build a JobSummary from a JobSpec."""
|
||||
def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSummary:
|
||||
"""Build a JobSummary from a systemd deployment (job)."""
|
||||
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||
systemd_info = _make_systemd_info(name, timer=True) if managed else None
|
||||
|
||||
@@ -352,7 +341,7 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
||||
id=name,
|
||||
description=description,
|
||||
stack=stack,
|
||||
runner=job.run.runner,
|
||||
launcher=job.run.launcher,
|
||||
run_target=_run_target(job.run),
|
||||
schedule=job.schedule,
|
||||
managed=managed,
|
||||
@@ -367,13 +356,6 @@ def _program_from_spec(
|
||||
) -> ProgramSummary:
|
||||
"""Build a ProgramSummary from a ProgramSpec."""
|
||||
source = comp.source
|
||||
runner = None
|
||||
if source:
|
||||
source_dir = Path(source)
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
installed: bool | None = None
|
||||
if comp.source and (comp.stack or comp.commands):
|
||||
@@ -394,9 +376,8 @@ def _program_from_spec(
|
||||
return ProgramSummary(
|
||||
id=name,
|
||||
description=comp.description,
|
||||
behavior=comp.behavior,
|
||||
kind=comp.kind,
|
||||
stack=comp.stack,
|
||||
runner=runner,
|
||||
version=comp.version,
|
||||
source=source,
|
||||
repo=comp.repo,
|
||||
@@ -508,7 +489,8 @@ def get_service(name: str) -> ServiceDetail:
|
||||
if config is not None and summary.source is None:
|
||||
summary.source = _backfill_source(name, config)
|
||||
manifest = {
|
||||
"runner": deployed.runner,
|
||||
"manager": deployed.manager,
|
||||
"launcher": deployed.launcher,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"secret_env_keys": deployed.secret_env_keys,
|
||||
@@ -516,7 +498,7 @@ def get_service(name: str) -> ServiceDetail:
|
||||
"health_path": deployed.health_path,
|
||||
"subdomain": deployed.subdomain,
|
||||
"managed": deployed.managed,
|
||||
"behavior": deployed.behavior,
|
||||
"kind": deployed.kind,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return ServiceDetail(**summary.model_dump(), manifest=manifest)
|
||||
@@ -609,13 +591,14 @@ def get_job(name: str) -> JobDetail:
|
||||
if config is not None and summary.source is None:
|
||||
summary.source = _backfill_source(name, config)
|
||||
manifest = {
|
||||
"runner": deployed.runner,
|
||||
"manager": deployed.manager,
|
||||
"launcher": deployed.launcher,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"secret_env_keys": deployed.secret_env_keys,
|
||||
"managed": deployed.managed,
|
||||
"schedule": deployed.schedule,
|
||||
"behavior": deployed.behavior,
|
||||
"kind": deployed.kind,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return JobDetail(**summary.model_dump(), manifest=manifest)
|
||||
@@ -627,10 +610,10 @@ def get_job(name: str) -> JobDetail:
|
||||
|
||||
|
||||
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
|
||||
def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
|
||||
def list_programs(kind: str | None = None) -> list[ProgramSummary]:
|
||||
"""List all programs from the software catalog (castle.yaml programs section).
|
||||
|
||||
Optionally filter by behavior: daemon, tool, or frontend.
|
||||
Optionally filter by derived kind: service, job, tool, static, or reference.
|
||||
"""
|
||||
root = get_castle_root()
|
||||
if not root:
|
||||
@@ -648,9 +631,9 @@ def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
|
||||
|
||||
for name, comp in config.programs.items():
|
||||
summary = _program_from_spec(name, comp, root, config)
|
||||
if summary.behavior is None:
|
||||
if summary.kind is None:
|
||||
continue
|
||||
if behavior and summary.behavior != behavior:
|
||||
if kind and summary.kind != kind:
|
||||
continue
|
||||
summary.node = hostname
|
||||
summaries.append(summary)
|
||||
@@ -742,7 +725,7 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
|
||||
# Programs from the software catalog
|
||||
for name, comp in config.programs.items():
|
||||
summary = _summary_from_program(name, comp, root)
|
||||
if summary.behavior is None:
|
||||
if summary.kind is None:
|
||||
continue
|
||||
summary.node = local_hostname
|
||||
summaries.append(summary)
|
||||
@@ -759,9 +742,10 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
|
||||
id=name,
|
||||
category="job" if d.schedule else "service",
|
||||
description=d.description,
|
||||
behavior=d.behavior,
|
||||
kind=d.kind,
|
||||
stack=d.stack,
|
||||
runner=d.runner,
|
||||
manager=d.manager,
|
||||
launcher=d.launcher,
|
||||
port=d.port,
|
||||
health_path=d.health_path,
|
||||
subdomain=d.subdomain,
|
||||
@@ -805,7 +789,8 @@ def get_component(name: str) -> DeploymentDetail:
|
||||
pass
|
||||
|
||||
raw = {
|
||||
"runner": deployed.runner,
|
||||
"manager": deployed.manager,
|
||||
"launcher": deployed.launcher,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"secret_env_keys": deployed.secret_env_keys,
|
||||
@@ -813,7 +798,7 @@ def get_component(name: str) -> DeploymentDetail:
|
||||
"health_path": deployed.health_path,
|
||||
"subdomain": deployed.subdomain,
|
||||
"managed": deployed.managed,
|
||||
"behavior": deployed.behavior,
|
||||
"kind": deployed.kind,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return DeploymentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
@@ -140,17 +140,13 @@ def get_unit(name: str) -> dict[str, str | None]:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
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
|
||||
dep = config.deployments.get(name)
|
||||
if dep is not None:
|
||||
manage = getattr(dep, "manage", None)
|
||||
if manage and manage.systemd:
|
||||
systemd_spec = manage.systemd
|
||||
description = dep.description
|
||||
schedule = getattr(dep, "schedule", None)
|
||||
|
||||
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
timer = generate_timer(name, schedule, description) if schedule else None
|
||||
|
||||
@@ -110,14 +110,15 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
|
||||
),
|
||||
deployed={
|
||||
"test-svc": Deployment(
|
||||
runner="python",
|
||||
manager="systemd",
|
||||
launcher="python",
|
||||
run_cmd=["uv", "run", "test-svc"],
|
||||
env={
|
||||
"TEST_SVC_PORT": "19000",
|
||||
"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc",
|
||||
},
|
||||
description="Test service",
|
||||
behavior="daemon",
|
||||
kind="service",
|
||||
port=19000,
|
||||
health_path="/health",
|
||||
subdomain="test-svc",
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestComponents:
|
||||
assert svc["health_path"] == "/health"
|
||||
assert svc["subdomain"] == "test-svc"
|
||||
assert svc["managed"] is True
|
||||
assert svc["behavior"] == "daemon"
|
||||
assert svc["kind"] == "service"
|
||||
|
||||
def test_tool_has_no_port(self, client: TestClient) -> None:
|
||||
"""Tool component has no port."""
|
||||
@@ -42,14 +42,14 @@ class TestComponents:
|
||||
data = response.json()
|
||||
tool = next(c for c in data if c["id"] == "test-tool")
|
||||
assert tool["port"] is None
|
||||
assert tool["behavior"] == "tool"
|
||||
assert tool["kind"] == "tool"
|
||||
|
||||
def test_job_has_schedule(self, client: TestClient) -> None:
|
||||
"""Job component has schedule."""
|
||||
response = client.get("/deployments")
|
||||
data = response.json()
|
||||
job = next(c for c in data if c["id"] == "test-job")
|
||||
assert job["behavior"] == "tool"
|
||||
assert job["kind"] == "job"
|
||||
assert job["schedule"] == "0 2 * * *"
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestDeploymentDetail:
|
||||
data = response.json()
|
||||
assert data["id"] == "test-svc"
|
||||
assert "manifest" in data
|
||||
assert data["manifest"]["runner"] == "python"
|
||||
assert data["manifest"]["launcher"] == "python"
|
||||
|
||||
def test_not_found(self, client: TestClient) -> None:
|
||||
"""Returns 404 for unknown component."""
|
||||
@@ -125,7 +125,7 @@ class TestServiceDetail:
|
||||
assert data["id"] == "test-svc"
|
||||
assert "manifest" in data
|
||||
# manifest is the editable castle.yaml ServiceSpec (nested run spec)
|
||||
assert data["manifest"]["run"]["runner"] == "python"
|
||||
assert data["manifest"]["run"]["launcher"] == "python"
|
||||
assert data["run_target"] == "test-svc"
|
||||
|
||||
def test_not_found(self, client: TestClient) -> None:
|
||||
@@ -196,12 +196,12 @@ class TestProgramsList:
|
||||
names = [p["id"] for p in data]
|
||||
assert "test-tool" in names
|
||||
|
||||
def test_program_has_behavior(self, client: TestClient) -> None:
|
||||
"""Program summary includes behavior."""
|
||||
def test_program_has_kind(self, client: TestClient) -> None:
|
||||
"""Program summary includes the derived kind."""
|
||||
response = client.get("/programs")
|
||||
data = response.json()
|
||||
tool = next(p for p in data if p["id"] == "test-tool")
|
||||
assert tool["behavior"] == "tool"
|
||||
assert tool["kind"] == "tool"
|
||||
|
||||
def test_no_port_field(self, client: TestClient) -> None:
|
||||
"""ProgramSummary does not have port field."""
|
||||
@@ -228,7 +228,7 @@ class TestProgramDetail:
|
||||
data = response.json()
|
||||
assert data["id"] == "test-tool"
|
||||
assert "manifest" in data
|
||||
assert data["behavior"] == "tool"
|
||||
assert data["kind"] == "tool"
|
||||
|
||||
def test_not_found(self, client: TestClient) -> None:
|
||||
"""Returns 404 for unknown program."""
|
||||
@@ -287,23 +287,23 @@ class TestConfigEditor:
|
||||
assert response.status_code == 200
|
||||
data = yaml.safe_load(response.json()["yaml_content"])
|
||||
assert "test-tool" in data["programs"]
|
||||
assert "test-svc" in data["services"]
|
||||
assert "test-job" in data["jobs"]
|
||||
# service, job, and tool all live under the single deployments section now.
|
||||
assert "test-svc" in data["deployments"]
|
||||
assert "test-job" in data["deployments"]
|
||||
|
||||
def test_put_scatters_and_prunes(self, client: TestClient, castle_root) -> None:
|
||||
"""PUT /config writes resource files and prunes removed ones."""
|
||||
import yaml
|
||||
|
||||
current = yaml.safe_load(client.get("/config").json()["yaml_content"])
|
||||
current["services"].pop("test-svc")
|
||||
current["deployments"].pop("test-svc")
|
||||
current["programs"]["new-tool"] = {
|
||||
"description": "Brand new",
|
||||
"behavior": "tool",
|
||||
}
|
||||
resp = client.put("/config", json={"yaml_content": yaml.dump(current)})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "programs" / "new-tool.yaml").exists()
|
||||
after = yaml.safe_load(client.get("/config").json()["yaml_content"])
|
||||
assert "new-tool" in after["programs"]
|
||||
assert "test-svc" not in (after.get("services") or {})
|
||||
assert "test-svc" not in (after.get("deployments") or {})
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestMeshStateManager:
|
||||
mgr.update_node("devbox", _make_registry("devbox"))
|
||||
new_reg = _make_registry(
|
||||
"devbox",
|
||||
{"svc": Deployment(runner="python", run_cmd=["svc"])},
|
||||
{"svc": Deployment(manager="systemd", launcher="python", run_cmd=["svc"])},
|
||||
)
|
||||
mgr.update_node("devbox", new_reg)
|
||||
node = mgr.get_node("devbox")
|
||||
|
||||
@@ -12,11 +12,12 @@ def _make_registry() -> NodeRegistry:
|
||||
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
|
||||
deployed={
|
||||
"my-svc": Deployment(
|
||||
runner="python",
|
||||
manager="systemd",
|
||||
launcher="python",
|
||||
run_cmd=["uv", "run", "my-svc"],
|
||||
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
|
||||
description="My service",
|
||||
behavior="daemon",
|
||||
kind="service",
|
||||
stack="python-fastapi",
|
||||
port=9001,
|
||||
health_path="/health",
|
||||
@@ -24,9 +25,10 @@ def _make_registry() -> NodeRegistry:
|
||||
managed=True,
|
||||
),
|
||||
"my-job": Deployment(
|
||||
runner="command",
|
||||
manager="systemd",
|
||||
launcher="command",
|
||||
run_cmd=["my-job"],
|
||||
behavior="tool",
|
||||
kind="job",
|
||||
stack="python-cli",
|
||||
schedule="0 2 * * *",
|
||||
),
|
||||
@@ -51,12 +53,13 @@ class TestRegistrySerialization:
|
||||
|
||||
assert "my-svc" in restored.deployed
|
||||
svc = restored.deployed["my-svc"]
|
||||
assert svc.runner == "python"
|
||||
assert svc.manager == "systemd"
|
||||
assert svc.launcher == "python"
|
||||
assert svc.port == 9001
|
||||
assert svc.health_path == "/health"
|
||||
assert svc.subdomain == "my-svc"
|
||||
assert svc.managed is True
|
||||
assert svc.behavior == "daemon"
|
||||
assert svc.kind == "service"
|
||||
assert svc.stack == "python-fastapi"
|
||||
|
||||
def test_job_fields_preserved(self) -> None:
|
||||
@@ -65,9 +68,9 @@ class TestRegistrySerialization:
|
||||
|
||||
assert "my-job" in restored.deployed
|
||||
job = restored.deployed["my-job"]
|
||||
assert job.runner == "command"
|
||||
assert job.launcher == "command"
|
||||
assert job.schedule == "0 2 * * *"
|
||||
assert job.behavior == "tool"
|
||||
assert job.kind == "job"
|
||||
assert job.stack == "python-cli"
|
||||
|
||||
def test_optional_fields_omitted(self) -> None:
|
||||
@@ -75,7 +78,7 @@ class TestRegistrySerialization:
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(hostname="minimal"),
|
||||
deployed={
|
||||
"bare": Deployment(runner="command", run_cmd=["bare"]),
|
||||
"bare": Deployment(manager="systemd", launcher="command", run_cmd=["bare"]),
|
||||
},
|
||||
)
|
||||
restored = _json_to_registry(_registry_to_json(reg))
|
||||
|
||||
@@ -42,10 +42,10 @@ class TestNodesList:
|
||||
node=NodeConfig(hostname="devbox", gateway_port=9000),
|
||||
deployed={
|
||||
"remote-svc": Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["svc"],
|
||||
port=9050,
|
||||
behavior="daemon",
|
||||
kind="service",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -24,9 +24,9 @@ class TestProgramCommands:
|
||||
assert set(w["actions"]) >= {"lint", "test", "run"}
|
||||
assert "build" not in w["actions"] # not declared, no stack
|
||||
|
||||
def test_tools_via_behavior_filter(self, client: TestClient) -> None:
|
||||
"""Tools are reached via /programs?behavior=tool (no dedicated /tools)."""
|
||||
resp = client.get("/programs", params={"behavior": "tool"})
|
||||
def test_tools_via_kind_filter(self, client: TestClient) -> None:
|
||||
"""Tools are reached via /programs?kind=tool (no dedicated /tools)."""
|
||||
resp = client.get("/programs", params={"kind": "tool"})
|
||||
assert resp.status_code == 200
|
||||
ids = [p["id"] for p in resp.json()]
|
||||
assert "wired-in" in ids and "test-tool" in ids
|
||||
|
||||
Reference in New Issue
Block a user