refactor: Separate runtime from build. Enhance configuration and registry management, migrate to registry-based component handling
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
285
cli/src/castle_cli/commands/deploy.py
Normal file
285
cli/src/castle_cli/commands/deploy.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
CaddySpec,
|
||||
Capability,
|
||||
ComponentManifest,
|
||||
DefaultsSpec,
|
||||
EnvMap,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user