refactor: Decouple roles.
This commit is contained in:
@@ -7,7 +7,7 @@ import argparse
|
||||
from castle_cli.config import load_config, save_config
|
||||
from castle_cli.manifest import (
|
||||
CaddySpec,
|
||||
ComponentManifest,
|
||||
ComponentSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
|
||||
PathInstallSpec,
|
||||
ProxySpec,
|
||||
RunPython,
|
||||
ServiceSpec,
|
||||
SystemdSpec,
|
||||
ToolSpec,
|
||||
)
|
||||
@@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project
|
||||
def next_available_port(config: object) -> int:
|
||||
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
||||
used_ports = set()
|
||||
for manifest in config.components.values():
|
||||
if manifest.expose and manifest.expose.http:
|
||||
used_ports.add(manifest.expose.http.internal.port)
|
||||
for svc in config.services.values():
|
||||
if svc.expose and svc.expose.http:
|
||||
used_ports.add(svc.expose.http.internal.port)
|
||||
# Also reserve gateway port
|
||||
used_ports.add(config.gateway.port)
|
||||
|
||||
@@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
name = args.name
|
||||
proj_type = args.type
|
||||
|
||||
if name in config.components:
|
||||
print(f"Error: component '{name}' already exists in castle.yaml")
|
||||
if name in config.components or name in config.services or name in config.jobs:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
components_dir = config.root / "components"
|
||||
@@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
port=port,
|
||||
)
|
||||
|
||||
# Build manifest entry
|
||||
# Build entries
|
||||
if proj_type == "service":
|
||||
manifest = ComponentManifest(
|
||||
# Component for software identity
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
run=RunPython(
|
||||
runner="python",
|
||||
tool=name,
|
||||
),
|
||||
)
|
||||
# Service for deployment
|
||||
config.services[name] = ServiceSpec(
|
||||
id=name,
|
||||
component=name,
|
||||
run=RunPython(runner="python", tool=name),
|
||||
expose=ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
internal=HttpInternal(port=port),
|
||||
@@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
elif proj_type == "tool":
|
||||
manifest = ComponentManifest(
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
tool=ToolSpec(source=f"components/{name}/"),
|
||||
tool=ToolSpec(),
|
||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||
)
|
||||
else:
|
||||
# library or other
|
||||
manifest = ComponentManifest(
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
)
|
||||
|
||||
config.components[name] = manifest
|
||||
save_config(config)
|
||||
|
||||
print(f"Created {proj_type} '{name}' at {project_dir}")
|
||||
|
||||
@@ -19,11 +19,10 @@ 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.manifest import JobSpec, ServiceSpec
|
||||
from castle_core.registry import (
|
||||
REGISTRY_PATH,
|
||||
DeployedComponent,
|
||||
@@ -40,15 +39,7 @@ 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())
|
||||
target_name = getattr(args, "component", None)
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
@@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int:
|
||||
|
||||
# Load existing registry to preserve components not being redeployed,
|
||||
# or start fresh if deploying all
|
||||
if component_name and REGISTRY_PATH.exists():
|
||||
if target_name and REGISTRY_PATH.exists():
|
||||
try:
|
||||
existing = load_registry()
|
||||
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
|
||||
@@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int:
|
||||
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:
|
||||
# Deploy services
|
||||
for name, svc in config.services.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_service(config, name, svc)
|
||||
registry.deployed[name] = deployed
|
||||
deployed_count += 1
|
||||
_print_deployed(name, deployed)
|
||||
|
||||
deployed = _build_deployed(config, name, manifest)
|
||||
# Deploy jobs
|
||||
for name, job in config.jobs.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_job(config, name, job)
|
||||
registry.deployed[name] = deployed
|
||||
deployed_count += 1
|
||||
_print_deployed(name, deployed)
|
||||
@@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str:
|
||||
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
|
||||
def _resolve_description(
|
||||
config: CastleConfig, spec: ServiceSpec | JobSpec
|
||||
) -> str | None:
|
||||
"""Get description, falling through to component if referenced."""
|
||||
if spec.description:
|
||||
return spec.description
|
||||
if spec.component and spec.component in config.components:
|
||||
return config.components[spec.component].description
|
||||
return None
|
||||
|
||||
# 1. Convention-based env vars
|
||||
|
||||
def _build_deployed_service(
|
||||
config: CastleConfig, name: str, svc: ServiceSpec
|
||||
) -> DeployedComponent:
|
||||
"""Build a DeployedComponent from a ServiceSpec."""
|
||||
run = svc.run
|
||||
prefix = _env_prefix(name)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
# Data dir convention (for all managed components)
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
# Data dir convention (for managed services)
|
||||
managed = True # Services are always managed by default
|
||||
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||
managed = False
|
||||
if managed:
|
||||
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
|
||||
if svc.expose and svc.expose.http:
|
||||
port = svc.expose.http.internal.port
|
||||
env[f"{prefix}_PORT"] = str(port)
|
||||
health_path = svc.expose.http.health_path
|
||||
|
||||
# Merge defaults.env (overrides conventions)
|
||||
if svc.defaults and svc.defaults.env:
|
||||
env.update(svc.defaults.env)
|
||||
|
||||
# Resolve secrets
|
||||
env = resolve_env_vars(env)
|
||||
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
# Proxy 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]
|
||||
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
||||
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=manifest.description,
|
||||
roles=roles,
|
||||
description=_resolve_description(config, svc),
|
||||
category="service",
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
schedule=schedule,
|
||||
managed=managed,
|
||||
)
|
||||
|
||||
|
||||
def _build_deployed_job(
|
||||
config: CastleConfig, name: str, job: JobSpec
|
||||
) -> DeployedComponent:
|
||||
"""Build a DeployedComponent from a JobSpec."""
|
||||
run = job.run
|
||||
prefix = _env_prefix(name)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
# Data dir convention
|
||||
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
|
||||
|
||||
# Merge defaults.env (overrides conventions)
|
||||
if job.defaults and job.defaults.env:
|
||||
env.update(job.defaults.env)
|
||||
|
||||
# Resolve secrets
|
||||
env = resolve_env_vars(env)
|
||||
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=_resolve_description(config, job),
|
||||
category="job",
|
||||
schedule=job.schedule,
|
||||
managed=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
||||
"""Build a run command list from a RunSpec."""
|
||||
match run.runner:
|
||||
@@ -199,7 +228,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
||||
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():
|
||||
@@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None:
|
||||
if "castle-app" not in config.components:
|
||||
return
|
||||
|
||||
manifest = config.components["castle-app"]
|
||||
if not (manifest.build and manifest.build.outputs):
|
||||
comp = config.components["castle-app"]
|
||||
if not (comp.build and comp.build.outputs):
|
||||
return
|
||||
|
||||
# Find the source dist directory
|
||||
source_dir = manifest.source_dir or "app"
|
||||
for output in manifest.build.outputs:
|
||||
source_dir = comp.source_dir or "app"
|
||||
for output in comp.build.outputs:
|
||||
src = config.root / source_dir / output
|
||||
if src.exists():
|
||||
dest = STATIC_DIR / "castle-app"
|
||||
@@ -262,23 +289,30 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
|
||||
if not deployed.managed:
|
||||
continue
|
||||
|
||||
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
|
||||
# Get systemd spec from config (services or jobs)
|
||||
systemd_spec = None
|
||||
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
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.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)
|
||||
# Generate timer for jobs
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_name = timer_name(name)
|
||||
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
|
||||
|
||||
print(f"Systemd units written: {SYSTEMD_USER_DIR}")
|
||||
|
||||
@@ -69,8 +69,8 @@ def _gateway_start(config: CastleConfig) -> int:
|
||||
"""Generate config and enable the gateway service."""
|
||||
from castle_cli.commands.service import _service_enable
|
||||
|
||||
if GATEWAY_COMPONENT not in config.managed:
|
||||
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed")
|
||||
if GATEWAY_COMPONENT not in config.services:
|
||||
print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
|
||||
return 1
|
||||
|
||||
print("Generating gateway configuration...")
|
||||
@@ -101,7 +101,6 @@ def _gateway_reload() -> int:
|
||||
if result.returncode == 0:
|
||||
print("Gateway reloaded.")
|
||||
else:
|
||||
# Fall back to restart if reload not supported
|
||||
print("Reload signal sent. Verifying...")
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", GATEWAY_UNIT],
|
||||
|
||||
@@ -29,105 +29,102 @@ def _load_deployed_component(name: str) -> object | None:
|
||||
|
||||
|
||||
def run_info(args: argparse.Namespace) -> int:
|
||||
"""Show detailed info for a component."""
|
||||
"""Show detailed info for a component, service, or job."""
|
||||
config = load_config()
|
||||
name = args.project
|
||||
|
||||
if name not in config.components:
|
||||
print(f"Error: component '{name}' not found in castle.yaml")
|
||||
# Look up in all sections
|
||||
component = config.components.get(name)
|
||||
service = config.services.get(name)
|
||||
job = config.jobs.get(name)
|
||||
|
||||
if not component and not service and not job:
|
||||
print(f"Error: '{name}' not found in castle.yaml")
|
||||
return 1
|
||||
|
||||
manifest = config.components[name]
|
||||
deployed = _load_deployed_component(name)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
data = manifest.model_dump(exclude_none=True)
|
||||
# Include CLAUDE.md content if it exists
|
||||
claude_md = _find_claude_md(config.root, manifest.source_dir or name)
|
||||
if claude_md:
|
||||
data["claude_md"] = claude_md
|
||||
if deployed:
|
||||
data["deployed"] = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"managed": deployed.managed,
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
return _info_json(config, name, component, service, job, deployed)
|
||||
|
||||
# Human-readable output
|
||||
print(f"\n{BOLD}{name}{RESET}")
|
||||
print(f"{'─' * 40}")
|
||||
print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}")
|
||||
if manifest.description:
|
||||
print(f" {BOLD}description{RESET}: {manifest.description}")
|
||||
|
||||
# Source
|
||||
if manifest.source:
|
||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||
# Determine category
|
||||
if service:
|
||||
print(f" {BOLD}category{RESET}: service")
|
||||
elif job:
|
||||
print(f" {BOLD}category{RESET}: job")
|
||||
elif component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
print(f" {BOLD}category{RESET}: tool")
|
||||
elif component.build:
|
||||
print(f" {BOLD}category{RESET}: frontend")
|
||||
else:
|
||||
print(f" {BOLD}category{RESET}: component")
|
||||
|
||||
# Run spec
|
||||
if manifest.run:
|
||||
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
|
||||
if hasattr(manifest.run, "tool"):
|
||||
print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
|
||||
elif hasattr(manifest.run, "argv"):
|
||||
print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
|
||||
elif hasattr(manifest.run, "image"):
|
||||
print(f" {BOLD}image{RESET}: {manifest.run.image}")
|
||||
# Component info
|
||||
if component:
|
||||
if component.description:
|
||||
print(f" {BOLD}description{RESET}: {component.description}")
|
||||
if component.source:
|
||||
print(f" {BOLD}source{RESET}: {component.source}")
|
||||
if component.install and component.install.path:
|
||||
pi = component.install.path
|
||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||
if component.tool:
|
||||
t = component.tool
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
if component.tags:
|
||||
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
|
||||
|
||||
# Defaults env
|
||||
if manifest.defaults and manifest.defaults.env:
|
||||
print(f" {BOLD}defaults.env{RESET}:")
|
||||
for key, val in manifest.defaults.env.items():
|
||||
print(f" {key}: {val}")
|
||||
# Service info
|
||||
spec = service or job
|
||||
if spec:
|
||||
desc = spec.description
|
||||
if not desc and spec.component and spec.component in config.components:
|
||||
desc = config.components[spec.component].description
|
||||
if desc and not (component and component.description == desc):
|
||||
print(f" {BOLD}description{RESET}: {desc}")
|
||||
if spec.component:
|
||||
print(f" {BOLD}component{RESET}: {spec.component}")
|
||||
|
||||
# Expose
|
||||
if manifest.expose and manifest.expose.http:
|
||||
http = manifest.expose.http
|
||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||
if http.health_path:
|
||||
print(f" {BOLD}health{RESET}: {http.health_path}")
|
||||
# Run spec
|
||||
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
||||
if hasattr(spec.run, "tool"):
|
||||
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
|
||||
elif hasattr(spec.run, "argv"):
|
||||
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
|
||||
elif hasattr(spec.run, "image"):
|
||||
print(f" {BOLD}image{RESET}: {spec.run.image}")
|
||||
|
||||
# Proxy
|
||||
if manifest.proxy and manifest.proxy.caddy:
|
||||
caddy = manifest.proxy.caddy
|
||||
if caddy.path_prefix:
|
||||
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
|
||||
# Defaults env
|
||||
if spec.defaults and spec.defaults.env:
|
||||
print(f" {BOLD}defaults.env{RESET}:")
|
||||
for key, val in spec.defaults.env.items():
|
||||
print(f" {key}: {val}")
|
||||
|
||||
# Manage
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
sd = manifest.manage.systemd
|
||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
||||
# Service-specific: expose, proxy, manage
|
||||
if service:
|
||||
if service.expose and service.expose.http:
|
||||
http = service.expose.http
|
||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||
if http.health_path:
|
||||
print(f" {BOLD}health{RESET}: {http.health_path}")
|
||||
if service.proxy and service.proxy.caddy:
|
||||
caddy = service.proxy.caddy
|
||||
if caddy.path_prefix:
|
||||
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
|
||||
if service.manage and service.manage.systemd:
|
||||
sd = service.manage.systemd
|
||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
||||
|
||||
# Install
|
||||
if manifest.install and manifest.install.path:
|
||||
pi = manifest.install.path
|
||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||
|
||||
# Tool
|
||||
if manifest.tool:
|
||||
t = manifest.tool
|
||||
if t.source:
|
||||
print(f" {BOLD}tool.source{RESET}: {t.source}")
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
|
||||
# Tags
|
||||
if manifest.tags:
|
||||
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
|
||||
|
||||
# Capabilities
|
||||
if manifest.provides:
|
||||
print(f" {BOLD}provides{RESET}:")
|
||||
for cap in manifest.provides:
|
||||
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
|
||||
if manifest.consumes:
|
||||
print(f" {BOLD}consumes{RESET}:")
|
||||
for cap in manifest.consumes:
|
||||
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
|
||||
# Job-specific
|
||||
if job:
|
||||
print(f" {BOLD}schedule{RESET}: {job.schedule}")
|
||||
print(f" {BOLD}timezone{RESET}: {job.timezone}")
|
||||
|
||||
# Deployed state from registry
|
||||
if deployed:
|
||||
@@ -142,20 +139,66 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
for key, val in deployed.env.items():
|
||||
print(f" {key}={val}")
|
||||
else:
|
||||
print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}")
|
||||
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}")
|
||||
|
||||
# Show CLAUDE.md if it exists
|
||||
source_dir = manifest.source_dir or name
|
||||
claude_md = _find_claude_md(config.root, source_dir)
|
||||
if claude_md:
|
||||
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
|
||||
print(f"{CYAN}{'─' * 40}{RESET}")
|
||||
print(f"{DIM}{claude_md}{RESET}")
|
||||
source_dir = None
|
||||
if component and component.source_dir:
|
||||
source_dir = component.source_dir
|
||||
elif spec and spec.component and spec.component in config.components:
|
||||
source_dir = config.components[spec.component].source_dir
|
||||
|
||||
if source_dir:
|
||||
claude_md = _find_claude_md(config.root, source_dir)
|
||||
if claude_md:
|
||||
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
|
||||
print(f"{CYAN}{'─' * 40}{RESET}")
|
||||
print(f"{DIM}{claude_md}{RESET}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _info_json(
|
||||
config: object,
|
||||
name: str,
|
||||
component: object | None,
|
||||
service: object | None,
|
||||
job: object | None,
|
||||
deployed: object | None,
|
||||
) -> int:
|
||||
"""Output JSON info."""
|
||||
data: dict = {"name": name}
|
||||
|
||||
if component:
|
||||
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
|
||||
if service:
|
||||
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "service"
|
||||
if job:
|
||||
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "job"
|
||||
if not service and not job and component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
data["category"] = "tool"
|
||||
elif component.build:
|
||||
data["category"] = "frontend"
|
||||
else:
|
||||
data["category"] = "component"
|
||||
|
||||
if deployed:
|
||||
data["deployed"] = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"managed": deployed.managed,
|
||||
}
|
||||
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _find_claude_md(root: Path, source_dir: str) -> str | None:
|
||||
"""Read CLAUDE.md from project directory if it exists."""
|
||||
claude_path = root / source_dir / "CLAUDE.md"
|
||||
|
||||
@@ -7,7 +7,6 @@ import json
|
||||
import logging
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_cli.manifest import Role
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,15 +16,15 @@ RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
CYAN = "\033[96m"
|
||||
MAGENTA = "\033[95m"
|
||||
YELLOW = "\033[93m"
|
||||
|
||||
ROLE_COLORS: dict[str, str] = {
|
||||
Role.SERVICE: "\033[92m", # green
|
||||
Role.TOOL: "\033[96m", # cyan
|
||||
Role.WORKER: "\033[94m", # blue
|
||||
Role.JOB: "\033[95m", # magenta
|
||||
Role.FRONTEND: "\033[93m", # yellow
|
||||
Role.REMOTE: "\033[90m", # dim
|
||||
Role.CONTAINERIZED: "\033[33m", # orange
|
||||
CATEGORY_COLORS: dict[str, str] = {
|
||||
"service": GREEN,
|
||||
"job": MAGENTA,
|
||||
"tool": CYAN,
|
||||
"frontend": YELLOW,
|
||||
}
|
||||
|
||||
|
||||
@@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None:
|
||||
|
||||
|
||||
def run_list(args: argparse.Namespace) -> int:
|
||||
"""List all components."""
|
||||
"""List all components, services, and jobs."""
|
||||
config = load_config()
|
||||
deployed = _load_deployed()
|
||||
|
||||
components = config.components
|
||||
|
||||
filter_role = getattr(args, "role", None)
|
||||
if filter_role:
|
||||
components = {k: v for k, v in components.items() if filter_role in v.roles}
|
||||
filter_type = getattr(args, "type", None)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
output = []
|
||||
for name, manifest in components.items():
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"roles": [r.value for r in manifest.roles],
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
}
|
||||
if manifest.description:
|
||||
entry["description"] = manifest.description
|
||||
if manifest.expose and manifest.expose.http:
|
||||
entry["port"] = manifest.expose.http.internal.port
|
||||
if deployed and name in deployed:
|
||||
dep = deployed[name]
|
||||
if dep.port is not None:
|
||||
entry["port"] = dep.port
|
||||
output.append(entry)
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
return _list_json(config, deployed, filter_type)
|
||||
|
||||
if not components:
|
||||
any_output = False
|
||||
|
||||
# Services
|
||||
if not filter_type or filter_type == "service":
|
||||
if config.services:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["service"]
|
||||
print(f"\n{BOLD}{color}Services{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, svc in config.services.items():
|
||||
port_str = ""
|
||||
if svc.expose and svc.expose.http:
|
||||
port_str = f" :{svc.expose.http.internal.port}"
|
||||
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
|
||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
|
||||
|
||||
# Jobs
|
||||
if not filter_type or filter_type == "job":
|
||||
if config.jobs:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["job"]
|
||||
print(f"\n{BOLD}{color}Jobs{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, job in config.jobs.items():
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
|
||||
sched = f" {DIM}[{job.schedule}]{RESET}"
|
||||
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
||||
|
||||
# Tools
|
||||
if not filter_type or filter_type == "tool":
|
||||
tools = config.tools
|
||||
if tools:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["tool"]
|
||||
print(f"\n{BOLD}{color}Tools{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in tools.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
|
||||
# Frontends
|
||||
if not filter_type or filter_type == "frontend":
|
||||
frontends = config.frontends
|
||||
if frontends:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["frontend"]
|
||||
print(f"\n{BOLD}{color}Frontends{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in frontends.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
|
||||
if not any_output:
|
||||
print("No components found.")
|
||||
return 0
|
||||
|
||||
# Group by primary role (first in sorted list)
|
||||
by_role: dict[str, list[tuple[str, object]]] = {}
|
||||
for name, manifest in components.items():
|
||||
primary_role = manifest.roles[0].value if manifest.roles else "other"
|
||||
by_role.setdefault(primary_role, []).append((name, manifest))
|
||||
|
||||
# Display order
|
||||
role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
|
||||
for role_name in role_order:
|
||||
items = by_role.get(role_name, [])
|
||||
if not items:
|
||||
continue
|
||||
color = ROLE_COLORS.get(role_name, "")
|
||||
print(f"\n{BOLD}{color}{role_name}s{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, manifest in items:
|
||||
port_str = ""
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port_str = f" :{manifest.expose.http.internal.port}"
|
||||
|
||||
# Show deployed status indicator
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else ""
|
||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
|
||||
|
||||
if deployed is None:
|
||||
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _list_json(
|
||||
config: object, deployed: dict | None, filter_type: str | None
|
||||
) -> int:
|
||||
"""Output JSON list of all entries."""
|
||||
output = []
|
||||
|
||||
if not filter_type or filter_type == "service":
|
||||
for name, svc in config.services.items():
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"category": "service",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
}
|
||||
if svc.description:
|
||||
entry["description"] = svc.description
|
||||
if svc.expose and svc.expose.http:
|
||||
entry["port"] = svc.expose.http.internal.port
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "job":
|
||||
for name, job in config.jobs.items():
|
||||
entry = {
|
||||
"name": name,
|
||||
"category": "job",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
"schedule": job.schedule,
|
||||
}
|
||||
if job.description:
|
||||
entry["description"] = job.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "tool":
|
||||
for name, comp in config.tools.items():
|
||||
entry = {"name": name, "category": "tool"}
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "frontend":
|
||||
for name, comp in config.frontends.items():
|
||||
entry = {"name": name, "category": "frontend"}
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
|
||||
@@ -10,25 +10,22 @@ from castle_cli.config import load_config
|
||||
|
||||
|
||||
def run_logs(args: argparse.Namespace) -> int:
|
||||
"""View logs for a component."""
|
||||
"""View logs for a service or job."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
|
||||
if name not in config.components:
|
||||
print(f"Error: component '{name}' not found in castle.yaml")
|
||||
return 1
|
||||
|
||||
manifest = config.components[name]
|
||||
|
||||
# Container logs
|
||||
if manifest.run and manifest.run.runner == "container":
|
||||
return _container_logs(name, args)
|
||||
|
||||
# Systemd logs (default for managed services)
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
# Check services
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.run.runner == "container":
|
||||
return _container_logs(name, args)
|
||||
return _systemd_logs(name, args)
|
||||
|
||||
print(f"Error: '{name}' has no log source (not systemd-managed or containerized)")
|
||||
# Check jobs
|
||||
if name in config.jobs:
|
||||
return _systemd_logs(name, args)
|
||||
|
||||
print(f"Error: '{name}' not found in services or jobs")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from castle_core.generators.systemd import (
|
||||
SYSTEMD_USER_DIR,
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
get_schedule_trigger,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
@@ -21,6 +20,9 @@ from castle_cli.config import (
|
||||
load_config,
|
||||
)
|
||||
|
||||
# Re-export for use by other commands
|
||||
UNIT_PREFIX = "castle-"
|
||||
|
||||
|
||||
def _install_unit(uname: str, content: str) -> None:
|
||||
"""Write a systemd unit file."""
|
||||
@@ -76,7 +78,6 @@ def run_services(args: argparse.Namespace) -> int:
|
||||
|
||||
def _service_enable(config: CastleConfig, name: str) -> int:
|
||||
"""Enable and start a single service (or timer for scheduled jobs)."""
|
||||
# Require registry
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
return 1
|
||||
@@ -93,22 +94,29 @@ def _service_enable(config: CastleConfig, name: str) -> int:
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Get systemd spec from manifest for restart policy etc.
|
||||
# Get systemd spec from config (services or jobs)
|
||||
systemd_spec = None
|
||||
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
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
# Generate and install the service unit from registry
|
||||
svc_unit = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
_install_unit(svc_unit, svc_content)
|
||||
|
||||
# Check for timer (still uses manifest for schedule config)
|
||||
manifest = config.components.get(name)
|
||||
timer_content = generate_timer(name, manifest) if manifest else None
|
||||
if timer_content:
|
||||
# Check for timer (jobs have schedule)
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_unit = timer_name(name)
|
||||
_install_unit(tmr_unit, timer_content)
|
||||
|
||||
@@ -156,7 +164,6 @@ def _service_disable(name: str) -> int:
|
||||
|
||||
print(f"Disabling {name}...")
|
||||
|
||||
# Stop and disable timer if exists
|
||||
timer_path = SYSTEMD_USER_DIR / tmr_unit
|
||||
if timer_path.exists():
|
||||
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
|
||||
@@ -172,14 +179,35 @@ def _service_disable(name: str) -> int:
|
||||
|
||||
|
||||
def _service_status(config: CastleConfig) -> int:
|
||||
"""Show status of all managed services."""
|
||||
"""Show status of all managed services and jobs."""
|
||||
print("\nCastle Services")
|
||||
print("=" * 50)
|
||||
|
||||
for name, manifest in config.managed.items():
|
||||
is_scheduled = get_schedule_trigger(manifest) is not None
|
||||
for name, svc in config.services.items():
|
||||
svc_unit = unit_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", svc_unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status == "active":
|
||||
color = "\033[92m"
|
||||
elif status == "inactive":
|
||||
color = "\033[90m"
|
||||
else:
|
||||
color = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
|
||||
if is_scheduled:
|
||||
port_str = ""
|
||||
if svc.expose and svc.expose.http:
|
||||
port_str = f":{svc.expose.http.internal.port}"
|
||||
print(f" {color}{status:10s}{reset} {name}{port_str}")
|
||||
|
||||
if config.jobs:
|
||||
print(f"\n{'─' * 50}")
|
||||
print("Jobs")
|
||||
for name in config.jobs:
|
||||
tmr_unit = timer_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", tmr_unit],
|
||||
@@ -195,26 +223,6 @@ def _service_status(config: CastleConfig) -> int:
|
||||
color = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
print(f" {color}{status:10s}{reset} {name} (timer)")
|
||||
else:
|
||||
svc_unit = unit_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", svc_unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status == "active":
|
||||
color = "\033[92m"
|
||||
elif status == "inactive":
|
||||
color = "\033[90m"
|
||||
else:
|
||||
color = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
|
||||
port_str = ""
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port_str = f":{manifest.expose.http.internal.port}"
|
||||
print(f" {color}{status:10s}{reset} {name}{port_str}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
@@ -222,28 +230,33 @@ def _service_status(config: CastleConfig) -> int:
|
||||
|
||||
def _service_dry_run(config: CastleConfig, name: str) -> int:
|
||||
"""Print the generated systemd unit(s) without installing."""
|
||||
# Try registry first, fall back to showing what deploy would generate
|
||||
if REGISTRY_PATH.exists():
|
||||
registry = load_registry()
|
||||
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
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
svc_unit = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
print(f"# {svc_unit}")
|
||||
print(svc_content)
|
||||
|
||||
manifest = config.components.get(name)
|
||||
if manifest:
|
||||
timer_content = generate_timer(name, manifest)
|
||||
if timer_content:
|
||||
print(f"# {timer_name(name)}")
|
||||
print(timer_content)
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
print(f"# {timer_name(name)}")
|
||||
print(timer_content)
|
||||
return 0
|
||||
|
||||
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
|
||||
@@ -252,14 +265,12 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
|
||||
|
||||
def _services_start(config: CastleConfig) -> int:
|
||||
"""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()
|
||||
|
||||
# Generate Caddyfile from registry
|
||||
from castle_core.config import GENERATED_DIR
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
|
||||
@@ -268,7 +279,13 @@ def _services_start(config: CastleConfig) -> int:
|
||||
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
|
||||
print(f"Generated {caddyfile_path}")
|
||||
|
||||
for name in config.managed:
|
||||
for name in config.services:
|
||||
if name not in registry.deployed:
|
||||
print(f" {name}: skipped (not in registry, run 'castle deploy')")
|
||||
continue
|
||||
_service_enable(config, name)
|
||||
|
||||
for name in config.jobs:
|
||||
if name not in registry.deployed:
|
||||
print(f" {name}: skipped (not in registry, run 'castle deploy')")
|
||||
continue
|
||||
@@ -279,12 +296,15 @@ def _services_start(config: CastleConfig) -> int:
|
||||
|
||||
|
||||
def _services_stop(config: CastleConfig) -> int:
|
||||
"""Stop all managed services and gateway."""
|
||||
for name, manifest in config.managed.items():
|
||||
is_scheduled = get_schedule_trigger(manifest) is not None
|
||||
if is_scheduled:
|
||||
tmr_unit = timer_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
|
||||
"""Stop all managed services and jobs."""
|
||||
for name in config.jobs:
|
||||
tmr_unit = timer_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
|
||||
svc_unit = unit_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
|
||||
print(f" {name}: stopped")
|
||||
|
||||
for name in config.services:
|
||||
svc_unit = unit_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
|
||||
print(f" {name}: stopped")
|
||||
|
||||
@@ -8,26 +8,6 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from castle_cli.config import ensure_dirs, load_config
|
||||
from castle_cli.manifest import ComponentManifest
|
||||
|
||||
|
||||
def _sync_cmd(manifest: ComponentManifest) -> list[str] | None:
|
||||
"""Derive the sync command from the manifest's runner."""
|
||||
run = manifest.run
|
||||
if run is None:
|
||||
# No runner — check for build commands (frontends)
|
||||
if manifest.build and manifest.build.commands:
|
||||
# Frontends declare build commands; infer from source dir at call site
|
||||
return None
|
||||
return None
|
||||
|
||||
match run.runner:
|
||||
case "python":
|
||||
return ["uv", "sync"]
|
||||
case "node":
|
||||
return [run.package_manager, "install"]
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def run_sync(args: argparse.Namespace) -> int:
|
||||
@@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
if result.returncode != 0:
|
||||
print("Warning: git submodule update failed (may not be a git repo)")
|
||||
|
||||
# Sync dependencies in each project
|
||||
# Sync dependencies in each component's source directory
|
||||
all_ok = True
|
||||
synced_dirs: set[Path] = set()
|
||||
for name, manifest in config.components.items():
|
||||
source_dir = manifest.source_dir
|
||||
|
||||
for name, comp in config.components.items():
|
||||
source_dir = comp.source_dir
|
||||
if not source_dir:
|
||||
continue
|
||||
project_dir = config.root / source_dir
|
||||
@@ -56,14 +37,16 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
if project_dir in synced_dirs or not project_dir.is_dir():
|
||||
continue
|
||||
|
||||
cmd = _sync_cmd(manifest)
|
||||
# Determine sync command based on project type
|
||||
cmd = None
|
||||
if (project_dir / "pyproject.toml").exists():
|
||||
cmd = ["uv", "sync"]
|
||||
elif (project_dir / "package.json").exists():
|
||||
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
|
||||
cmd = [pm, "install"]
|
||||
|
||||
if cmd is None:
|
||||
# No runner — check if it's a frontend with a package.json
|
||||
if manifest.build and (project_dir / "package.json").exists():
|
||||
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
|
||||
cmd = [pm, "install"]
|
||||
else:
|
||||
continue
|
||||
continue
|
||||
|
||||
label = cmd[0]
|
||||
print(f"\nSyncing {name} ({label})...")
|
||||
@@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
print(" OK")
|
||||
synced_dirs.add(project_dir)
|
||||
|
||||
# Install components as uv tools or symlinks
|
||||
# Install components and python-runner services as uv tools
|
||||
uv_path = shutil.which("uv") or "uv"
|
||||
installed_dirs: set[Path] = set()
|
||||
|
||||
for name, manifest in config.components.items():
|
||||
# Install if: has install.path, or is a python runner service
|
||||
if not (
|
||||
(manifest.install and manifest.install.path)
|
||||
or (manifest.run and manifest.run.runner == "python")
|
||||
):
|
||||
# Install components with install.path
|
||||
for name, comp in config.components.items():
|
||||
if not (comp.install and comp.install.path):
|
||||
continue
|
||||
|
||||
source = manifest.source_dir
|
||||
source = comp.source_dir
|
||||
if not source:
|
||||
continue
|
||||
_try_install(config.root / source, name, comp, uv_path, installed_dirs)
|
||||
|
||||
# Install python-runner services
|
||||
for name, svc in config.services.items():
|
||||
if svc.run.runner != "python":
|
||||
continue
|
||||
# Find source from component reference
|
||||
source = None
|
||||
if svc.component and svc.component in config.components:
|
||||
source = config.components[svc.component].source_dir
|
||||
if not source:
|
||||
continue
|
||||
source_dir = config.root / source
|
||||
|
||||
if source_dir in installed_dirs:
|
||||
continue
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
# Python package — uv tool install
|
||||
if source_dir in installed_dirs:
|
||||
continue
|
||||
print(f"\nInstalling {name}...")
|
||||
result = subprocess.run(
|
||||
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
|
||||
@@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
print(f" {name}: OK")
|
||||
installed_dirs.add(source_dir)
|
||||
|
||||
elif source_dir.is_file():
|
||||
# Script file — symlink to ~/.local/bin/
|
||||
alias = name
|
||||
if manifest.install and manifest.install.path and manifest.install.path.alias:
|
||||
alias = manifest.install.path.alias
|
||||
if not shutil.which(alias):
|
||||
link = Path.home() / ".local" / "bin" / alias
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not link.exists():
|
||||
link.symlink_to(source_dir)
|
||||
print(f"\n Linked {alias} → {source_dir}")
|
||||
|
||||
if all_ok:
|
||||
print("\nAll projects synced.")
|
||||
else:
|
||||
print("\nSync completed with warnings.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _try_install(
|
||||
source_dir: Path,
|
||||
name: str,
|
||||
comp: object,
|
||||
uv_path: str,
|
||||
installed_dirs: set[Path],
|
||||
) -> bool:
|
||||
"""Try to install a component. Returns True if installed."""
|
||||
if source_dir in installed_dirs:
|
||||
return False
|
||||
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
print(f"\nInstalling {name}...")
|
||||
result = subprocess.run(
|
||||
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if "already installed" in result.stderr.lower():
|
||||
print(f" {name}: already installed")
|
||||
else:
|
||||
print(f" Warning: {result.stderr.strip()}")
|
||||
return False
|
||||
else:
|
||||
print(f" {name}: OK")
|
||||
installed_dirs.add(source_dir)
|
||||
return True
|
||||
|
||||
elif source_dir.is_file():
|
||||
alias = name
|
||||
if comp.install and comp.install.path and comp.install.path.alias:
|
||||
alias = comp.install.path.alias
|
||||
if not shutil.which(alias):
|
||||
link = Path.home() / ".local" / "bin" / alias
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not link.exists():
|
||||
link.symlink_to(source_dir)
|
||||
print(f"\n Linked {alias} → {source_dir}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -67,8 +67,8 @@ def _tool_info(name: str) -> int:
|
||||
if manifest.description:
|
||||
print(f" {manifest.description}")
|
||||
print(f" {BOLD}version{RESET}: {t.version}")
|
||||
if t.source:
|
||||
print(f" {BOLD}source{RESET}: {t.source}")
|
||||
if manifest.source:
|
||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# castle list
|
||||
list_parser = subparsers.add_parser("list", help="List all components")
|
||||
list_parser.add_argument(
|
||||
"--role",
|
||||
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
|
||||
help="Filter by role",
|
||||
"--type",
|
||||
choices=["service", "job", "tool", "frontend"],
|
||||
help="Filter by type",
|
||||
)
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
Capability,
|
||||
ComponentManifest,
|
||||
ComponentSpec,
|
||||
DefaultsSpec,
|
||||
EnvMap,
|
||||
ExposeSpec,
|
||||
@@ -13,12 +13,12 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
HttpInternal,
|
||||
HttpPublic,
|
||||
InstallSpec,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
PathInstallSpec,
|
||||
ProxySpec,
|
||||
ReadinessHttpGet,
|
||||
RestartPolicy,
|
||||
Role,
|
||||
RunBase,
|
||||
RunCommand,
|
||||
RunContainer,
|
||||
@@ -26,12 +26,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
RunPython,
|
||||
RunRemote,
|
||||
RunSpec,
|
||||
ServiceSpec,
|
||||
SystemdSpec,
|
||||
TLSMode,
|
||||
ToolSpec,
|
||||
TriggerEvent,
|
||||
TriggerManual,
|
||||
TriggerRequest,
|
||||
TriggerSchedule,
|
||||
TriggerSpec,
|
||||
)
|
||||
|
||||
@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
config = {
|
||||
"gateway": {"port": 18000},
|
||||
"components": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
"test-svc": {
|
||||
"component": "test-svc-comp",
|
||||
"description": "Test service",
|
||||
"source": "test-svc",
|
||||
"run": {
|
||||
"runner": "python",
|
||||
"tool": "test-svc",
|
||||
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"systemd": {},
|
||||
},
|
||||
},
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
"jobs": {
|
||||
"test-job": {
|
||||
"description": "Test job",
|
||||
"run": {
|
||||
"runner": "command",
|
||||
"argv": ["test-job"],
|
||||
},
|
||||
"schedule": "0 2 * * *",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_cli.manifest import Role
|
||||
|
||||
|
||||
class TestCreateCommand:
|
||||
@@ -42,11 +41,12 @@ class TestCreateCommand:
|
||||
assert (project_dir / "tests" / "test_health.py").exists()
|
||||
assert (project_dir / "CLAUDE.md").exists()
|
||||
|
||||
# Verify registered as ComponentManifest
|
||||
# Verify registered as ComponentSpec + ServiceSpec
|
||||
assert "my-api" in config.components
|
||||
manifest = config.components["my-api"]
|
||||
assert Role.SERVICE in manifest.roles
|
||||
assert manifest.expose.http.internal.port == 9050
|
||||
assert "my-api" in config.services
|
||||
svc = config.services["my-api"]
|
||||
assert svc.expose.http.internal.port == 9050
|
||||
assert svc.component == "my-api"
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_create_tool(self, castle_root: Path) -> None:
|
||||
@@ -60,17 +60,18 @@ class TestCreateCommand:
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
args = Namespace(name="my-tool", type="tool", description="My tool", port=None)
|
||||
args = Namespace(name="my-tool2", type="tool", description="My tool", port=None)
|
||||
result = run_create(args)
|
||||
|
||||
assert result == 0
|
||||
project_dir = castle_root / "components" / "my-tool"
|
||||
project_dir = castle_root / "components" / "my-tool2"
|
||||
assert project_dir.exists()
|
||||
assert (project_dir / "src" / "my_tool" / "main.py").exists()
|
||||
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
|
||||
assert (project_dir / "CLAUDE.md").exists()
|
||||
assert "my-tool" in config.components
|
||||
manifest = config.components["my-tool"]
|
||||
assert Role.TOOL in manifest.roles
|
||||
assert "my-tool2" in config.components
|
||||
comp = config.components["my-tool2"]
|
||||
assert comp.tool is not None
|
||||
assert comp.install is not None
|
||||
|
||||
def test_create_library(self, castle_root: Path) -> None:
|
||||
"""Create a new library project."""
|
||||
@@ -101,6 +102,7 @@ class TestCreateCommand:
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
# test-svc exists in the services section
|
||||
args = Namespace(
|
||||
name="test-svc",
|
||||
type="service",
|
||||
@@ -130,8 +132,8 @@ class TestCreateCommand:
|
||||
)
|
||||
run_create(args)
|
||||
|
||||
manifest = config.components["auto-port-svc"]
|
||||
port = manifest.expose.http.internal.port
|
||||
svc = config.services["auto-port-svc"]
|
||||
port = svc.expose.http.internal.port
|
||||
# Port 18000 is gateway, 19000 is test-svc, so next should be 9001+
|
||||
assert port is not None
|
||||
assert port not in (18000, 19000)
|
||||
|
||||
@@ -59,8 +59,8 @@ class TestInfoCommand:
|
||||
output = capsys.readouterr().out
|
||||
assert "not found" in output
|
||||
|
||||
def test_info_json(self, castle_root: Path, capsys) -> None:
|
||||
"""--json produces valid JSON with manifest fields."""
|
||||
def test_info_json_service(self, castle_root: Path, capsys) -> None:
|
||||
"""--json produces valid JSON with service fields."""
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
@@ -73,48 +73,8 @@ class TestInfoCommand:
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
data = json.loads(output)
|
||||
assert data["id"] == "test-svc"
|
||||
assert "service" in data["roles"]
|
||||
assert data["expose"]["http"]["internal"]["port"] == 19000
|
||||
|
||||
def test_info_shows_claude_md(self, castle_root: Path, capsys) -> None:
|
||||
"""Info shows CLAUDE.md content when present."""
|
||||
project_dir = castle_root / "test-svc"
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
(project_dir / "CLAUDE.md").write_text("# Test Service\n\nSome docs here.")
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
result = run_info(Namespace(project="test-svc", json=False))
|
||||
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "Some docs here" in output
|
||||
|
||||
def test_info_json_includes_claude_md(self, castle_root: Path, capsys) -> None:
|
||||
"""--json includes claude_md field when CLAUDE.md exists."""
|
||||
project_dir = castle_root / "test-svc"
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
(project_dir / "CLAUDE.md").write_text("# Docs\n")
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
result = run_info(Namespace(project="test-svc", json=True))
|
||||
|
||||
assert result == 0
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "claude_md" in data
|
||||
assert "# Docs" in data["claude_md"]
|
||||
assert data["category"] == "service"
|
||||
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
|
||||
|
||||
def test_info_shows_env(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays environment variables."""
|
||||
@@ -131,8 +91,8 @@ class TestInfoCommand:
|
||||
output = capsys.readouterr().out
|
||||
assert "TEST_SVC_DATA_DIR" in output
|
||||
|
||||
def test_info_shows_roles(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays derived roles."""
|
||||
def test_info_shows_category(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays category instead of roles."""
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
@@ -144,5 +104,5 @@ class TestInfoCommand:
|
||||
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "roles" in output
|
||||
assert "category" in output
|
||||
assert "service" in output
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestListCommand:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role=None, json=False)
|
||||
args = Namespace(type=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -27,27 +27,13 @@ class TestListCommand:
|
||||
assert "test-svc" in captured.out
|
||||
assert "test-tool" in captured.out
|
||||
|
||||
def test_list_filter_role(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered by role."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role="tool", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-tool" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to services."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role="service", json=False)
|
||||
args = Namespace(type="service", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -55,13 +41,41 @@ class TestListCommand:
|
||||
assert "test-svc" in captured.out
|
||||
assert "test-tool" not in captured.out
|
||||
|
||||
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to tools."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="tool", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-tool" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to jobs."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="job", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-job" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_json(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List output as JSON."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role=None, json=True)
|
||||
args = Namespace(type=None, json=True)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -71,5 +85,4 @@ class TestListCommand:
|
||||
assert "test-svc" in names
|
||||
assert "test-tool" in names
|
||||
svc = next(p for p in data if p["name"] == "test-svc")
|
||||
assert "roles" in svc
|
||||
assert "service" in svc["roles"]
|
||||
assert svc["category"] == "service"
|
||||
|
||||
Reference in New Issue
Block a user