Register an existing repo as a program from the /programs page — the web equivalent of `castle program add`. An inline form with an editable, autocompleting path bar browses the *server's* filesystem (a browser's own file dialog only sees the client machine) and also accepts a git URL. - core: extract the adopt logic (target parsing, stack/command detection, ProgramSpec build) into castle_core.adopt so the CLI and API share it; the CLI's add.py now delegates to it. - castle-api: GET /fs/browse (directory listing, per-entry stat errors skipped so one unreadable child can't 403 the dir) and POST /programs/adopt. - app: AddProgramForm + Add-program button on the Programs page; useBrowse / useAdoptProgram hooks. Also tidy ProgramCard: deployments render below the description, and the deployment name label is hidden when it matches the program title.
265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
"""Program action endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException, status
|
|
from pydantic import BaseModel
|
|
|
|
from castle_core import git
|
|
from castle_core.adopt import (
|
|
AdoptError,
|
|
build_adopted_program,
|
|
is_git_url,
|
|
looks_like_program,
|
|
)
|
|
from castle_core.config import write_program_file
|
|
from castle_core.stacks import available_actions, available_stacks, run_action
|
|
|
|
from castle_api import stream
|
|
from castle_api.config import get_config
|
|
|
|
programs_router = APIRouter(tags=["programs"])
|
|
|
|
|
|
@programs_router.get("/stacks")
|
|
def list_stacks() -> list[str]:
|
|
"""Stack names castle has handlers for — populates the dashboard's stack select
|
|
and keeps it in sync with the backend (no hardcoded frontend list)."""
|
|
return available_stacks()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
|
|
# web equivalent of `castle program add <path|git-url>`. Programs live on the
|
|
# server's filesystem, so the picker browses the *server's* dirs (a browser's
|
|
# native file dialog only sees the client machine).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@programs_router.get("/fs/browse")
|
|
def browse_filesystem(path: str | None = None) -> dict:
|
|
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
|
|
can browse to a program on the server. Directories only; hidden dirs skipped.
|
|
Each entry is flagged when it looks adoptable (a project manifest or git repo).
|
|
"""
|
|
config = get_config()
|
|
base = Path(path).expanduser() if path else config.repos_dir
|
|
try:
|
|
base = base.resolve()
|
|
except OSError as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
|
|
if not base.exists():
|
|
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
|
|
if not base.is_dir():
|
|
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
|
|
|
|
try:
|
|
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
|
|
except PermissionError:
|
|
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
|
|
|
|
entries: list[dict] = []
|
|
for child in children:
|
|
if child.name.startswith("."):
|
|
continue
|
|
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
|
|
# listing — skip it rather than 403 the directory.
|
|
try:
|
|
if not child.is_dir():
|
|
continue
|
|
entries.append(
|
|
{
|
|
"name": child.name,
|
|
"path": str(child),
|
|
"is_program": looks_like_program(child),
|
|
"is_git": (child / ".git").exists(),
|
|
}
|
|
)
|
|
except OSError:
|
|
continue
|
|
|
|
parent = str(base.parent) if base.parent != base else None
|
|
return {
|
|
"path": str(base),
|
|
"parent": parent,
|
|
"repos_dir": str(config.repos_dir),
|
|
"entries": entries,
|
|
}
|
|
|
|
|
|
class AdoptRequest(BaseModel):
|
|
target: str # a local server path or a git URL
|
|
name: str | None = None
|
|
description: str = ""
|
|
|
|
|
|
@programs_router.post("/programs/adopt")
|
|
def adopt_program(request: AdoptRequest) -> dict:
|
|
"""Adopt an existing repo as a program (the web `castle program add`).
|
|
|
|
``target`` is a local server path or a git URL. Writes just the new program's
|
|
file; declaring a deployment (service/job/tool/static) stays a separate step.
|
|
"""
|
|
target = request.target.strip()
|
|
if not target:
|
|
raise HTTPException(status_code=422, detail="A path or git URL is required.")
|
|
|
|
config = get_config()
|
|
try:
|
|
adopted = build_adopted_program(
|
|
config, target, name=request.name, description=request.description
|
|
)
|
|
except AdoptError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
config.programs[adopted.name] = adopted.spec
|
|
write_program_file(config, adopted.name)
|
|
return {
|
|
"ok": True,
|
|
"program": adopted.name,
|
|
"source": adopted.source,
|
|
"stack": adopted.stack,
|
|
"repo": adopted.repo,
|
|
"commands": adopted.commands,
|
|
"is_git_url": is_git_url(target),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Git sync — pull a program's source working copy up to date (pull only; no
|
|
# build/apply/restart — converge stays an explicit, separate step). Declared
|
|
# BEFORE the generic /{action} route below so the literal `git`/`sync` segments
|
|
# win over the `{action}` path param (Starlette matches in declaration order).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _program_source(name: str):
|
|
"""The (program, config) for a named program, or raise the standard 404/400s."""
|
|
config = get_config()
|
|
if name not in config.programs:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
|
)
|
|
return config.programs[name], config
|
|
|
|
|
|
@programs_router.get("/programs/{name}/git")
|
|
def program_git_status(name: str) -> dict:
|
|
"""Git status of a program's working copy (branch, dirty, ahead/behind).
|
|
|
|
Fetches from the remote first so ``behind`` is current. A program with no
|
|
source or a non-git source returns a benign ``{"is_repo": false}`` (not an
|
|
error) so the dashboard can simply hide the sync control."""
|
|
comp, config = _program_source(name)
|
|
if not comp.source:
|
|
return {"is_repo": False}
|
|
out = asdict(git.git_status(comp.source, fetch=True))
|
|
# Repo context: which repo this program's source lives in and who else shares it
|
|
# (a monorepo). Sync is a repo operation — the UI labels it and lists siblings.
|
|
from castle_core.relations import derive_repos
|
|
|
|
for key, repo in derive_repos(config).items():
|
|
if name in repo.programs:
|
|
out["repo"] = {
|
|
"key": key,
|
|
"programs": repo.programs,
|
|
"multi": repo.multi,
|
|
"deployments": repo.deployments,
|
|
}
|
|
break
|
|
return out
|
|
|
|
|
|
@programs_router.post("/programs/{name}/sync")
|
|
async def program_sync(name: str) -> dict:
|
|
"""Fast-forward a program's working copy (``git pull --ff-only``).
|
|
|
|
Pull-only: it updates the source on disk and reports which deployments may now
|
|
need a restart/apply, but does not build, apply, or restart anything itself."""
|
|
comp, config = _program_source(name)
|
|
if not comp.source:
|
|
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
|
|
if not git.is_git_repo(comp.source):
|
|
raise HTTPException(
|
|
status_code=400, detail=f"'{name}' source is not a git repository"
|
|
)
|
|
|
|
before = git.head(comp.source)
|
|
ok, output = git.pull(comp.source)
|
|
if not ok:
|
|
raise HTTPException(status_code=500, detail=output or "git pull failed")
|
|
|
|
pulled = git.head(comp.source) != before
|
|
deployments = [dname for dname, _ in config.deployments_of(name)]
|
|
if pulled:
|
|
# Nudge other clients to refresh this program's git status.
|
|
await stream.broadcast(
|
|
"program-sync", {"program": name, "deployments": deployments}
|
|
)
|
|
|
|
return {
|
|
"program": name,
|
|
"status": "ok",
|
|
"output": output,
|
|
"pulled": pulled,
|
|
"deployments": deployments if pulled else [],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unified program action endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
|
|
# it happens through `POST /apply`, not as a program action.
|
|
_VALID_ACTIONS = {
|
|
"build",
|
|
"test",
|
|
"lint",
|
|
"type-check",
|
|
"check",
|
|
}
|
|
|
|
|
|
@programs_router.post("/programs/{name}/{action}")
|
|
async def program_action(name: str, action: str) -> dict:
|
|
"""Run a lifecycle action on a program.
|
|
|
|
Resolution-aware: a declared `commands:` entry overrides the stack default,
|
|
so a program with no stack can still be linted/tested/built/installed.
|
|
"""
|
|
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")
|
|
|
|
if action not in available_actions(comp):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Action '{action}' not available for '{name}' "
|
|
f"(no declared command and no stack handler provides it)",
|
|
)
|
|
|
|
result = await run_action(action, name, comp, config.root)
|
|
|
|
if result.status != "ok":
|
|
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
|
|
|
|
return {
|
|
"program": result.program,
|
|
"action": result.action,
|
|
"status": result.status,
|
|
"output": result.output,
|
|
}
|