refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling

This commit is contained in:
2026-02-22 23:19:16 -08:00
parent 033a76ccfd
commit d52d8829ba
37 changed files with 1271 additions and 414 deletions

View File

@@ -73,17 +73,14 @@ def run_create(args: argparse.Namespace) -> int:
)
# Build manifest entry
env_prefix = package_name.upper()
if proj_type == "service":
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
run=RunPythonUvTool(
runner="python_uv_tool",
tool=name,
cwd=f"components/{name}",
env={f"{env_prefix}_DATA_DIR": f"/data/castle/{name}"},
),
expose=ExposeSpec(
http=HttpExposeSpec(
@@ -98,6 +95,7 @@ def run_create(args: argparse.Namespace) -> int:
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
tool=ToolSpec(source=f"components/{name}/"),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
@@ -106,6 +104,7 @@ def run_create(args: argparse.Namespace) -> int:
manifest = ComponentManifest(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
)
config.components[name] = manifest
@@ -120,8 +119,7 @@ def run_create(args: argparse.Namespace) -> int:
print(" uv sync")
if proj_type == "service":
print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name} # deploy to ~/.castle/")
print(f" castle test {name}")
return 0

View File

@@ -0,0 +1,285 @@
"""castle deploy — bridge spec (castle.yaml) to runtime (~/.castle/)."""
from __future__ import annotations
import argparse
import shutil
import subprocess
from pathlib import Path
from castle_core.config import (
GENERATED_DIR,
STATIC_DIR,
CastleConfig,
ensure_dirs,
load_config,
resolve_env_vars,
)
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.manifest import ComponentManifest
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)
DATA_ROOT = Path("/data/castle")
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
config = load_config()
component_name = getattr(args, "component", None)
if component_name:
if component_name not in config.components:
print(f"Error: component '{component_name}' not found in castle.yaml")
return 1
names = [component_name]
else:
names = list(config.components.keys())
ensure_dirs()
# Build node config
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
# Load existing registry to preserve components not being redeployed,
# or start fresh if deploying all
if component_name and REGISTRY_PATH.exists():
try:
existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
except (FileNotFoundError, ValueError):
registry = NodeRegistry(node=node)
else:
registry = NodeRegistry(node=node)
deployed_count = 0
for name in names:
manifest = config.components[name]
# Only deploy components with a run spec
if not manifest.run:
continue
deployed = _build_deployed(config, name, manifest)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
# Handle castle-app build artifacts
_copy_app_static(config)
# Save registry
save_registry(registry)
print(f"\nRegistry written: {REGISTRY_PATH}")
# Generate systemd units from registry
_generate_systemd_units(config, registry)
# Generate Caddyfile from registry
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_content = generate_caddyfile_from_registry(registry)
caddyfile_path.write_text(caddyfile_content)
print(f"Caddyfile written: {caddyfile_path}")
# Reload systemd daemon
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
print(f"\nDeployed {deployed_count} component(s).")
print("Run 'castle services start' to start all services.")
return 0
def _env_prefix(name: str) -> str:
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
return name.replace("-", "_").upper()
def _build_deployed(
config: CastleConfig, name: str, manifest: ComponentManifest
) -> DeployedComponent:
"""Build a DeployedComponent from a manifest spec."""
run = manifest.run
assert run is not None
# 1. Convention-based env vars
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention (for all managed components)
if manifest.manage and manifest.manage.systemd:
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Port convention (if exposed)
if manifest.expose and manifest.expose.http:
env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port)
# 2. Merge defaults.env (overrides conventions)
if manifest.defaults and manifest.defaults.env:
env.update(manifest.defaults.env)
# 3. Resolve secrets
env = resolve_env_vars(env, manifest)
# 4. Build run_cmd
run_cmd = _build_run_cmd(run, env)
# 5. Extract metadata
port = None
health_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
proxy_path = None
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable:
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}"
schedule = None
sched_trigger = get_schedule_trigger(manifest)
if sched_trigger:
schedule = sched_trigger.cron
managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable)
roles = [r.value for r in manifest.roles]
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=manifest.description,
roles=roles,
port=port,
health_path=health_path,
proxy_path=proxy_path,
schedule=schedule,
managed=managed,
)
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
case "python_uv_tool":
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", run.tool]
if run.args:
cmd.extend(run.args)
return cmd
case "python_module":
python = run.python or shutil.which("python3") or "python3"
cmd = [python, "-m", run.module]
if run.args:
cmd.extend(run.args)
return cmd
case "command":
cmd = list(run.argv)
resolved = shutil.which(cmd[0])
if resolved:
cmd[0] = resolved
return cmd
case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
image_name = run.image.split("/")[-1].split(":")[0]
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
for container_port, host_port in run.ports.items():
cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
# Container env comes from both run.env (container-specific) and deployed env
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
if run.args:
cmd.extend(run.args)
return cmd
case "node":
cmd = [run.package_manager, "run", run.script]
if run.args:
cmd.extend(run.args)
return cmd
case _:
raise ValueError(f"Unsupported runner: {run.runner}")
def _print_deployed(name: str, deployed: DeployedComponent) -> None:
"""Print deployment summary for a component."""
parts = [f" {name}"]
if deployed.port:
parts.append(f"port={deployed.port}")
if deployed.schedule:
parts.append(f"schedule={deployed.schedule}")
if deployed.proxy_path:
parts.append(f"proxy={deployed.proxy_path}")
print(" ".join(parts))
def _copy_app_static(config: CastleConfig) -> None:
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
if "castle-app" not in config.components:
return
manifest = config.components["castle-app"]
if not (manifest.build and manifest.build.outputs):
return
# Find the source dist directory
source_dir = manifest.source_dir or "app"
for output in manifest.build.outputs:
src = config.root / source_dir / output
if src.exists():
dest = STATIC_DIR / "castle-app"
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
print(f" Static: {src}{dest}")
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
if not deployed.managed:
continue
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
# Generate and write service unit
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer if scheduled
if name in config.components:
timer_content = generate_timer(name, config.components[name])
if timer_content:
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
print(f"Systemd units written: {SYSTEMD_USER_DIR}")

View File

@@ -23,9 +23,7 @@ def _has_pyproject(project_dir: Path) -> bool:
return (project_dir / "pyproject.toml").exists()
def _run_in_project(
project_dir: Path, cmd: list[str], label: str
) -> bool:
def _run_in_project(project_dir: Path, cmd: list[str], label: str) -> bool:
"""Run a command in a project directory. Returns True on success."""
if not _has_pyproject(project_dir):
return True # Skip projects without pyproject.toml

View File

@@ -5,27 +5,28 @@ from __future__ import annotations
import argparse
import subprocess
from castle_cli.config import GENERATED_DIR, CastleConfig, ensure_dirs, load_config
from castle_core.generators.caddyfile import find_app_dist, generate_caddyfile
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import CastleConfig, ensure_dirs, load_config
GATEWAY_COMPONENT = "castle-gateway"
GATEWAY_UNIT = "castle-castle-gateway.service"
def _write_generated_files(config: CastleConfig) -> None:
"""Write generated Caddyfile."""
def _write_generated_files() -> None:
"""Write generated Caddyfile from registry."""
ensure_dirs()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile(config))
print(f" Generated {caddyfile_path}")
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return
app_dist = find_app_dist(config)
if app_dist:
print(f" App: {app_dist}")
else:
print(" App: dist/ not found, using fallback")
registry = load_registry()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f" Generated {caddyfile_path}")
def run_gateway(args: argparse.Namespace) -> int:
@@ -38,24 +39,29 @@ def run_gateway(args: argparse.Namespace) -> int:
if args.gateway_command == "start":
if getattr(args, "dry_run", False):
return _gateway_dry_run(config)
return _gateway_dry_run()
return _gateway_start(config)
elif args.gateway_command == "stop":
return _gateway_stop()
elif args.gateway_command == "reload":
if getattr(args, "dry_run", False):
return _gateway_dry_run(config)
return _gateway_reload(config)
return _gateway_dry_run()
return _gateway_reload()
elif args.gateway_command == "status":
return _gateway_status()
return 1
def _gateway_dry_run(config: CastleConfig) -> int:
def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
print("# Caddyfile")
print(generate_caddyfile(config))
print(generate_caddyfile_from_registry(registry))
return 0
@@ -68,7 +74,7 @@ def _gateway_start(config: CastleConfig) -> int:
return 1
print("Generating gateway configuration...")
_write_generated_files(config)
_write_generated_files()
print(f"\nStarting gateway on port {config.gateway.port}...")
return _service_enable(config, GATEWAY_COMPONENT)
@@ -81,14 +87,15 @@ def _gateway_stop() -> int:
return _service_disable(GATEWAY_COMPONENT)
def _gateway_reload(config: CastleConfig) -> int:
def _gateway_reload() -> int:
"""Regenerate config and reload Caddy."""
print("Regenerating gateway configuration...")
_write_generated_files(config)
_write_generated_files()
result = subprocess.run(
["systemctl", "--user", "reload", GATEWAY_UNIT],
capture_output=True, text=True,
capture_output=True,
text=True,
)
if result.returncode == 0:
@@ -98,7 +105,8 @@ def _gateway_reload(config: CastleConfig) -> int:
print("Reload signal sent. Verifying...")
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True, text=True,
capture_output=True,
text=True,
)
if result.stdout.strip() == "active":
print("Gateway running.")
@@ -112,7 +120,8 @@ def _gateway_status() -> int:
"""Show gateway status via systemd."""
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],
capture_output=True, text=True,
capture_output=True,
text=True,
)
status = result.stdout.strip()

View File

@@ -42,21 +42,25 @@ def run_info(args: argparse.Namespace) -> int:
if manifest.description:
print(f" {BOLD}description{RESET}: {manifest.description}")
# Source
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
# Run spec
if manifest.run:
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
if manifest.run.working_dir:
print(f" {BOLD}working_dir{RESET}: {manifest.run.working_dir}")
if hasattr(manifest.run, "tool"):
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}")
if manifest.run.env:
print(f" {BOLD}env{RESET}:")
for key, val in manifest.run.env.items():
print(f" {key}: {val}")
# 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}")
# Expose
if manifest.expose and manifest.expose.http:
@@ -85,7 +89,7 @@ def run_info(args: argparse.Namespace) -> int:
if manifest.tool:
t = manifest.tool
if t.source:
print(f" {BOLD}source{RESET}: {t.source}")
print(f" {BOLD}tool.source{RESET}: {t.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
@@ -104,8 +108,8 @@ def run_info(args: argparse.Namespace) -> int:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
# Show CLAUDE.md if it exists
cwd = manifest.run.working_dir if manifest.run else None
claude_md = _find_claude_md(config.root, cwd or name)
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}")
@@ -115,9 +119,9 @@ def run_info(args: argparse.Namespace) -> int:
return 0
def _find_claude_md(root: Path, working_dir: str) -> str | None:
def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists."""
claude_path = root / working_dir / "CLAUDE.md"
claude_path = root / source_dir / "CLAUDE.md"
if claude_path.exists():
return claude_path.read_text()
return None

View File

@@ -32,9 +32,7 @@ def run_list(args: argparse.Namespace) -> int:
filter_role = getattr(args, "role", None)
if filter_role:
components = {
k: v for k, v in components.items() if filter_role in v.roles
}
components = {k: v for k, v in components.items() if filter_role in v.roles}
if getattr(args, "json", False):
output = []

View File

@@ -4,93 +4,36 @@ from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from castle_cli.config import load_config, resolve_env_vars
from castle_core.registry import REGISTRY_PATH, load_registry
def run_run(args: argparse.Namespace) -> int:
"""Run a component in the foreground (dev mode)."""
config = load_config()
"""Run a component in the foreground using the registry."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
registry = load_registry()
name = args.name
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
if name not in registry.deployed:
print(f"Error: component '{name}' not found in registry.")
print("Run 'castle deploy' to update the registry.")
return 1
manifest = config.components[name]
run = manifest.run
deployed = registry.deployed[name]
if run is None:
print(f"Error: component '{name}' has no run spec")
return 1
# Build command
# Build command with any extra args
extra_args = getattr(args, "extra", []) or []
cmd = _build_command(run, extra_args)
if cmd is None:
print(f"Error: unsupported runner '{run.runner}' for foreground execution")
return 1
# Working directory
cwd = config.root / (run.working_dir or name)
if not cwd.exists():
print(f"Error: working directory '{cwd}' does not exist")
return 1
cmd = list(deployed.run_cmd) + extra_args
# Merge environment
env = dict(os.environ)
resolved = resolve_env_vars(run.env, manifest)
env.update(resolved)
env.update(deployed.env)
# Run in foreground
result = subprocess.run(cmd, cwd=cwd, env=env)
# Run in foreground (no cwd — registry-based, no repo dependency)
print(f"Running {name}: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env)
return result.returncode
def _build_command(run: object, extra_args: list[str]) -> list[str] | None:
"""Build command list from RunSpec."""
match run.runner:
case "python_uv_tool":
uv = shutil.which("uv") or "uv"
cmd = [uv, "run", run.tool]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "python_module":
python = run.python or sys.executable
cmd = [python, "-m", run.module]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "command":
cmd = list(run.argv)
cmd.extend(extra_args)
return cmd
case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman"
cmd = [runtime, "run", "--rm", "-it"]
for cp, hp in run.ports.items():
cmd.extend(["-p", f"{hp}:{cp}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
if run.workdir:
cmd.extend(["-w", run.workdir])
cmd.append(run.image)
if run.command:
cmd.extend(run.command)
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case "node":
pm = run.package_manager
cmd = [pm, "run", run.script]
cmd.extend(run.args)
cmd.extend(extra_args)
return cmd
case _:
return None

View File

@@ -5,19 +5,21 @@ from __future__ import annotations
import argparse
import subprocess
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import (
CastleConfig,
ensure_dirs,
load_config,
)
from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit,
get_schedule_trigger,
timer_name,
unit_name,
)
def _install_unit(uname: str, content: str) -> None:
@@ -74,25 +76,38 @@ 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)."""
managed = config.managed
if name not in managed:
print(f"Error: '{name}' is not a managed service")
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
manifest = managed[name]
if not manifest.run:
print(f"Error: '{name}' has no run spec defined")
registry = load_registry()
if name not in registry.deployed:
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
deployed = registry.deployed[name]
if not deployed.managed:
print(f"Error: '{name}' is not a managed service")
return 1
ensure_dirs()
# Generate and install the service unit
# Get systemd spec from manifest for restart policy etc.
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
# Generate and install the service unit from registry
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
_install_unit(svc_unit, svc_content)
# Check for timer
timer_content = generate_timer(name, manifest)
# 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:
tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content)
@@ -103,7 +118,8 @@ def _service_enable(config: CastleConfig, name: str) -> int:
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
@@ -117,12 +133,13 @@ def _service_enable(config: CastleConfig, name: str) -> int:
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True,
capture_output=True,
text=True,
)
status = result.stdout.strip()
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f" (port {manifest.expose.http.internal.port})"
if deployed.port:
port_str = f" (port {deployed.port})"
if status == "active":
print(f" {name}: running{port_str}")
else:
@@ -166,7 +183,8 @@ def _service_status(config: CastleConfig) -> int:
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True, text=True,
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
@@ -181,7 +199,8 @@ def _service_status(config: CastleConfig) -> int:
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
capture_output=True, text=True,
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status == "active":
@@ -203,41 +222,55 @@ def _service_status(config: CastleConfig) -> int:
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
managed = config.managed
if name not in managed:
print(f"Error: '{name}' is not a managed service")
return 1
# 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
manifest = managed[name]
if not manifest.run:
print(f"Error: '{name}' has no run spec defined")
return 1
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}")
print(svc_content)
svc_unit = unit_name(name)
svc_content = generate_unit(config, name, manifest)
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)
return 0
timer_content = generate_timer(name, manifest)
if timer_content:
print(f"# {timer_name(name)}")
print(timer_content)
return 0
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
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 before starting gateway
from castle_cli.commands.gateway import _write_generated_files
# Generate Caddyfile from registry
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
print("Generating gateway configuration...")
_write_generated_files(config)
registry = load_registry()
caddyfile_path = GENERATED_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
for name, manifest in config.managed.items():
if not manifest.run:
for name in config.managed:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)

View File

@@ -64,9 +64,9 @@ def run_sync(args: argparse.Namespace) -> int:
continue
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir),
"--force"],
capture_output=True, text=True,
[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():

View File

@@ -5,6 +5,7 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
CASTLE_HOME,
GENERATED_DIR,
SECRETS_DIR,
STATIC_DIR,
CastleConfig,
GatewayConfig,
ensure_dirs,
@@ -13,3 +14,11 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
resolve_env_vars,
save_config,
)
from castle_core.registry import ( # noqa: F401
REGISTRY_PATH,
DeployedComponent,
NodeConfig,
NodeRegistry,
load_registry,
save_registry,
)

View File

@@ -14,9 +14,7 @@ def build_parser() -> argparse.ArgumentParser:
prog="castle",
description="Castle platform CLI - manage projects, services, and infrastructure",
)
parser.add_argument(
"--version", action="version", version=f"castle {__version__}"
)
parser.add_argument("--version", action="version", version=f"castle {__version__}")
subparsers = parser.add_subparsers(dest="command", help="Available commands")
@@ -27,9 +25,7 @@ def build_parser() -> argparse.ArgumentParser:
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
help="Filter by role",
)
list_parser.add_argument(
"--json", action="store_true", help="Output as JSON"
)
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
# castle create
create_parser = subparsers.add_parser("create", help="Create a new project")
@@ -40,18 +36,12 @@ def build_parser() -> argparse.ArgumentParser:
required=True,
help="Project type",
)
create_parser.add_argument(
"--description", default="", help="Project description"
)
create_parser.add_argument(
"--port", type=int, help="Port number (services only)"
)
create_parser.add_argument("--description", default="", help="Project description")
create_parser.add_argument("--port", type=int, help="Port number (services only)")
# castle info
info_parser = subparsers.add_parser("info", help="Show component details")
info_parser.add_argument("project", help="Component name")
info_parser.add_argument(
"--json", action="store_true", help="Output as JSON"
)
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
# castle test
test_parser = subparsers.add_parser("test", help="Run tests")
@@ -86,16 +76,12 @@ def build_parser() -> argparse.ArgumentParser:
enable_parser.add_argument(
"--dry-run", action="store_true", help="Print generated unit without installing"
)
disable_parser = service_sub.add_parser(
"disable", help="Stop and disable a service"
)
disable_parser = service_sub.add_parser("disable", help="Stop and disable a service")
disable_parser.add_argument("name", help="Service name")
service_sub.add_parser("status", help="Show status of all services")
# castle services (plural - manage all services)
services_parser = subparsers.add_parser(
"services", help="Manage all services together"
)
services_parser = subparsers.add_parser("services", help="Manage all services together")
services_sub = services_parser.add_subparsers(dest="services_command")
services_sub.add_parser("start", help="Start all services and gateway")
services_sub.add_parser("stop", help="Stop all services and gateway")
@@ -103,9 +89,7 @@ def build_parser() -> argparse.ArgumentParser:
# castle logs
logs_parser = subparsers.add_parser("logs", help="View component logs")
logs_parser.add_argument("name", help="Component name")
logs_parser.add_argument(
"-f", "--follow", action="store_true", help="Follow log output"
)
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
logs_parser.add_argument(
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
)
@@ -117,6 +101,12 @@ def build_parser() -> argparse.ArgumentParser:
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
)
# castle deploy
deploy_parser = subparsers.add_parser(
"deploy", help="Deploy components to ~/.castle/ (spec → runtime)"
)
deploy_parser.add_argument("component", nargs="?", help="Component to deploy (default: all)")
# castle tool
tool_parser = subparsers.add_parser("tool", help="Manage tools")
tool_sub = tool_parser.add_subparsers(dest="tool_command")
@@ -187,6 +177,11 @@ def main() -> int:
return run_logs(args)
elif args.command == "deploy":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
elif args.command == "run":
from castle_cli.commands.run_cmd import run_run

View File

@@ -6,6 +6,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
CaddySpec,
Capability,
ComponentManifest,
DefaultsSpec,
EnvMap,
ExposeSpec,
HttpExposeSpec,

View File

@@ -18,10 +18,12 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"components": {
"test-svc": {
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python_uv_tool",
"tool": "test-svc",
"working_dir": "test-svc",
},
"defaults": {
"env": {"TEST_SVC_DATA_DIR": str(tmp_path / "data" / "test-svc")},
},
"expose": {

View File

@@ -15,9 +15,10 @@ class TestCreateCommand:
def test_create_service(self, castle_root: Path) -> None:
"""Create a new service project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
) as mock_save:
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config") as mock_save,
):
config = load_config(castle_root)
mock_load.return_value = config
@@ -50,17 +51,16 @@ class TestCreateCommand:
def test_create_tool(self, castle_root: Path) -> None:
"""Create a new tool project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="my-tool", type="tool", description="My tool", port=None
)
args = Namespace(name="my-tool", type="tool", description="My tool", port=None)
result = run_create(args)
assert result == 0
@@ -74,17 +74,16 @@ class TestCreateCommand:
def test_create_library(self, castle_root: Path) -> None:
"""Create a new library project."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="my-lib", type="library", description="My library", port=None
)
args = Namespace(name="my-lib", type="library", description="My library", port=None)
result = run_create(args)
assert result == 0
@@ -114,8 +113,9 @@ class TestCreateCommand:
def test_create_auto_port(self, castle_root: Path) -> None:
"""Service creation auto-assigns next available port."""
with patch("castle_cli.commands.create.load_config") as mock_load, patch(
"castle_cli.commands.create.save_config"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
):
config = load_config(castle_root)
mock_load.return_value = config