feat: relationship model (requires/repos/predicates) + git sync
A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.
Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
precondition relation; `system_dependencies` is its `{kind: system}` alias.
kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.
Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
monorepo banner on Programs.
Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
This commit is contained in:
30
castle-api/src/castle_api/graph.py
Normal file
30
castle-api/src/castle_api/graph.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""The relationship model as JSON — repos, `requires` edges, derived status.
|
||||
|
||||
Read-only diagnostic (see docs/relationships.md). Everything is computed on the
|
||||
fly: repos from git, predicates (functional/fresh) from config + git. Nothing
|
||||
stored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from castle_core.relations import build_model
|
||||
|
||||
from castle_api.config import get_config
|
||||
|
||||
graph_router = APIRouter(tags=["graph"])
|
||||
|
||||
|
||||
@graph_router.get("/graph")
|
||||
def get_graph() -> dict:
|
||||
"""The whole relationship model: repos (with freshness), deployment nodes (with
|
||||
`functional?`), and `requires` edges."""
|
||||
model = build_model(get_config(), check=True, freshness=True)
|
||||
return {
|
||||
"repos": [dataclasses.asdict(r) for r in model.repos],
|
||||
"nodes": [dataclasses.asdict(n) for n in model.nodes],
|
||||
"edges": [dataclasses.asdict(e) for e in model.edges],
|
||||
}
|
||||
@@ -17,6 +17,8 @@ from castle_api.agents import router as agents_router
|
||||
from castle_api.config import get_registry, settings
|
||||
from castle_api.config_editor import router as config_router
|
||||
from castle_api.deploy_routes import router as deploy_router
|
||||
from castle_api.graph import graph_router
|
||||
from castle_api.repos import repos_router
|
||||
from castle_api.logs import router as logs_router
|
||||
from castle_api.routes import router as dashboard_router
|
||||
from castle_api.secrets import router as secrets_router
|
||||
@@ -127,6 +129,8 @@ app.include_router(services_router)
|
||||
app.include_router(programs_router)
|
||||
app.include_router(deploy_router)
|
||||
app.include_router(agents_router)
|
||||
app.include_router(graph_router)
|
||||
app.include_router(repos_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core import git
|
||||
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"])
|
||||
@@ -17,6 +21,88 @@ def list_stacks() -> list[str]:
|
||||
and keeps it in sync with the backend (no hardcoded frontend list)."""
|
||||
return available_stacks()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
83
castle-api/src/castle_api/repos.py
Normal file
83
castle-api/src/castle_api/repos.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Repo endpoints — a repo (git working copy) is the unit of sync. A monorepo backs
|
||||
several programs; an adopted program is a repo of one. See docs/relationships.md.
|
||||
|
||||
Sync is pull-only (fast-forward); converge (build/apply/restart) stays a separate,
|
||||
explicit step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core import git
|
||||
from castle_core.relations import Repo, derive_repos
|
||||
|
||||
from castle_api import stream
|
||||
from castle_api.config import get_config
|
||||
|
||||
repos_router = APIRouter(tags=["repos"])
|
||||
|
||||
|
||||
def _resolve(key: str) -> tuple[Repo, object]:
|
||||
config = get_config()
|
||||
repos = derive_repos(config)
|
||||
if key not in repos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"repo '{key}' not found"
|
||||
)
|
||||
return repos[key], config
|
||||
|
||||
|
||||
@repos_router.get("/repos")
|
||||
def list_repos() -> list[dict]:
|
||||
"""Every repo with members and last-known git state (no fetch — fast)."""
|
||||
out: list[dict] = []
|
||||
for repo in derive_repos(get_config()).values():
|
||||
st = git.git_status(Path(repo.path), fetch=False)
|
||||
out.append(
|
||||
{
|
||||
**dataclasses.asdict(repo),
|
||||
"branch": st.branch,
|
||||
"behind": st.behind,
|
||||
"dirty": st.dirty,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@repos_router.get("/repos/{key}/git")
|
||||
def repo_git(key: str) -> dict:
|
||||
"""A repo's git status (fetches, so ``behind`` is current) plus its members."""
|
||||
repo, _ = _resolve(key)
|
||||
return {
|
||||
**dataclasses.asdict(git.git_status(Path(repo.path), fetch=True)),
|
||||
"key": key,
|
||||
"programs": repo.programs,
|
||||
"deployments": repo.deployments,
|
||||
}
|
||||
|
||||
|
||||
@repos_router.post("/repos/{key}/sync")
|
||||
async def repo_sync(key: str) -> dict:
|
||||
"""Fast-forward the repo's working copy. Pull-only — reports which deployments
|
||||
may now need a restart/apply, but does not converge them."""
|
||||
repo, _ = _resolve(key)
|
||||
before = git.head(Path(repo.path))
|
||||
ok, output = git.pull(Path(repo.path))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail=output or "git pull failed")
|
||||
pulled = git.head(Path(repo.path)) != before
|
||||
if pulled:
|
||||
await stream.broadcast(
|
||||
"repo-sync", {"repo": key, "deployments": repo.deployments}
|
||||
)
|
||||
return {
|
||||
"repo": key,
|
||||
"status": "ok",
|
||||
"output": output,
|
||||
"pulled": pulled,
|
||||
"deployments": repo.deployments if pulled else [],
|
||||
}
|
||||
63
castle-api/tests/test_graph_repos.py
Normal file
63
castle-api/tests/test_graph_repos.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests for the /graph diagnostic and /repos (repo-scoped sync) endpoints."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
}
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> None:
|
||||
subprocess.run(["git", "-C", str(cwd), *args], check=True,
|
||||
capture_output=True, text=True, env={**os.environ, **_ENV})
|
||||
|
||||
|
||||
def _commit(cwd: Path, fname: str) -> None:
|
||||
(cwd / fname).write_text(fname)
|
||||
_git(cwd, "add", fname)
|
||||
_git(cwd, "commit", "-m", f"add {fname}")
|
||||
|
||||
|
||||
def _setup(work: Path) -> None:
|
||||
upstream = work.parent / f"{work.name}-upstream"
|
||||
upstream.mkdir()
|
||||
_git(upstream, "init", "-q", "-b", "main")
|
||||
_commit(upstream, "a.txt")
|
||||
_git(work.parent, "clone", "-q", str(upstream), str(work))
|
||||
|
||||
|
||||
class TestGraph:
|
||||
def test_graph_shape(self, client: TestClient) -> None:
|
||||
resp = client.get("/graph")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert {"repos", "nodes", "edges"} <= body.keys()
|
||||
# every node carries the derived predicates
|
||||
assert all("functional" in n for n in body["nodes"])
|
||||
|
||||
|
||||
class TestRepos:
|
||||
def test_list_and_sync(self, client: TestClient, castle_root: Path) -> None:
|
||||
_setup(castle_root / "wired-in") # program `wired-in` source → <root>/wired-in
|
||||
_commit(castle_root / "wired-in-upstream", "b.txt") # advance remote
|
||||
|
||||
repos = {r["key"]: r for r in client.get("/repos").json()}
|
||||
assert "wired-in" in repos
|
||||
assert "wired-in" in repos["wired-in"]["programs"]
|
||||
|
||||
gitinfo = client.get("/repos/wired-in/git").json()
|
||||
assert gitinfo["is_repo"] is True and gitinfo["behind"] == 1
|
||||
|
||||
synced = client.post("/repos/wired-in/sync").json()
|
||||
assert synced["pulled"] is True and "wired-in" in synced["deployments"]
|
||||
assert (castle_root / "wired-in" / "b.txt").exists()
|
||||
|
||||
def test_unknown_repo_404(self, client: TestClient) -> None:
|
||||
assert client.get("/repos/nope/git").status_code == 404
|
||||
assert client.post("/repos/nope/sync").status_code == 404
|
||||
79
castle-api/tests/test_programs_git.py
Normal file
79
castle-api/tests/test_programs_git.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Tests for the program git-status / sync endpoints."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t",
|
||||
"GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t",
|
||||
"GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
"GIT_CONFIG_SYSTEM": "/dev/null",
|
||||
}
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> None:
|
||||
subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **_ENV},
|
||||
)
|
||||
|
||||
|
||||
def _commit(cwd: Path, fname: str) -> None:
|
||||
(cwd / fname).write_text(fname)
|
||||
_git(cwd, "add", fname)
|
||||
_git(cwd, "commit", "-m", f"add {fname}")
|
||||
|
||||
|
||||
def _setup_git_program(work: Path) -> None:
|
||||
"""Make `work` a git clone tracking an `-upstream` repo with one commit."""
|
||||
upstream = work.parent / f"{work.name}-upstream"
|
||||
upstream.mkdir()
|
||||
_git(upstream, "init", "-q", "-b", "main")
|
||||
_commit(upstream, "a.txt")
|
||||
_git(work.parent, "clone", "-q", str(upstream), str(work))
|
||||
|
||||
|
||||
class TestProgramGit:
|
||||
def test_non_git_source_is_benign(self, client: TestClient) -> None:
|
||||
"""A program whose source isn't a git repo returns is_repo:false, not 500."""
|
||||
resp = client.get("/programs/test-tool/git")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_repo"] is False
|
||||
|
||||
def test_status_behind_then_sync_pulls(
|
||||
self, client: TestClient, castle_root: Path
|
||||
) -> None:
|
||||
work = castle_root / "wired-in" # source: "wired-in" → <root>/wired-in
|
||||
_setup_git_program(work)
|
||||
_commit(work.parent / "wired-in-upstream", "b.txt") # advance the remote
|
||||
|
||||
status = client.get("/programs/wired-in/git")
|
||||
assert status.status_code == 200
|
||||
body = status.json()
|
||||
assert body["is_repo"] is True and body["branch"] == "main"
|
||||
assert body["behind"] == 1 and body["ahead"] == 0
|
||||
|
||||
synced = client.post("/programs/wired-in/sync")
|
||||
assert synced.status_code == 200
|
||||
s = synced.json()
|
||||
assert s["status"] == "ok" and s["pulled"] is True
|
||||
assert "wired-in" in s["deployments"] # affected deployment surfaced
|
||||
assert (work / "b.txt").exists()
|
||||
|
||||
# A second sync is a no-op: nothing pulled, no affected deployments.
|
||||
again = client.post("/programs/wired-in/sync").json()
|
||||
assert again["pulled"] is False and again["deployments"] == []
|
||||
|
||||
def test_sync_unknown_program_404(self, client: TestClient) -> None:
|
||||
assert client.post("/programs/nope/sync").status_code == 404
|
||||
|
||||
def test_sync_non_git_source_400(self, client: TestClient) -> None:
|
||||
assert client.post("/programs/test-tool/sync").status_code == 400
|
||||
Reference in New Issue
Block a user