refactor: Extract deploy logic to castle-core and add deploy API endpoint

- Extract all deploy logic from CLI into castle-core/deploy.py as a reusable deploy() function
  * Resolves services/jobs to DeployedComponents
  * Generates systemd units and Caddyfile
  * Copies frontend assets and reloads systemd
  * Returns DeployResult with deployed count and messages
- Simplify CLI deploy command to a thin wrapper calling castle_core.deploy()
- Add POST /deploy endpoint to castle-api for remote deployment management
  * Supports optional {name} body param to deploy one or all services
  * Returns deployment result as JSON
- Register deploy router in castle-api main.py
- Update README with new deploy endpoint documentation

This enables full remote management: build, test, lint, and deploy any castle
node over HTTP (e.g. POST http://node:9000/api/deploy)
This commit is contained in:
2026-04-27 22:01:39 -07:00
parent e1a9386177
commit 691216b04e
7 changed files with 539 additions and 387 deletions

View File

@@ -0,0 +1,71 @@
"""Program action endpoints."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from castle_core.stacks import available_actions, get_handler
from castle_api.config import get_config
programs_router = APIRouter(tags=["programs"])
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------
_VALID_ACTIONS = {
"build",
"test",
"lint",
"type-check",
"check",
"install",
"uninstall",
}
@programs_router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program via its stack handler."""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
config = get_config()
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.programs[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
actions = available_actions(comp)
if action not in actions:
raise HTTPException(
status_code=400,
detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})",
)
handler = get_handler(comp.stack)
if handler is None:
raise HTTPException(
status_code=400, detail=f"No handler for stack '{comp.stack}'"
)
# Map hyphenated action names to method names (type-check → type_check)
method_name = action.replace("-", "_")
method = getattr(handler, method_name)
result = await method(name, comp, config.root)
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
return {
"component": result.component,
"action": result.action,
"status": result.status,
"output": result.output,
}