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:
2026-07-05 10:55:42 -07:00
parent 3c566540aa
commit add356dcf2
24 changed files with 1669 additions and 4 deletions

View File

@@ -244,7 +244,9 @@ def apply(
return "restart"
return "unchanged"
result = ApplyResult(registry=NodeRegistry(node=_node_config(config), deployed=desired))
result = ApplyResult(
registry=NodeRegistry(node=_node_config(config), deployed=desired)
)
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
@@ -469,6 +471,36 @@ def _public_url(
return None
def _target_url(config: CastleConfig, target_name: str) -> str | None:
"""The base URL another deployment is reachable at — how a ``{kind: deployment,
bind: VAR}`` requirement projects its target into the consumer's env."""
dep = config.deployments.get(target_name)
if dep is None:
return None
expose = getattr(dep, "expose", None)
http = getattr(expose, "http", None) if expose else None
tport = http.internal.port if http else None
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
def _requires_env(config: CastleConfig, name: str, config_key: str) -> dict[str, str]:
"""Env generated FROM a deployment's ``requires`` — a ``{kind: deployment,
bind: VAR}`` requirement sets ``VAR`` to the target's URL. Env is derived from
the dependency, never scraped back into one (see docs/relationships.md)."""
dep = config.deployments[name]
prog = config.programs.get(config_key)
reqs = list(getattr(dep, "requires", []) or [])
if prog:
reqs += list(prog.requires)
out: dict[str, str] = {}
for r in reqs:
if r.kind == "deployment" and r.bind:
url = _target_url(config, r.ref)
if url:
out[r.bind] = url
return out
def _supabase_app_schemas(config: CastleConfig) -> str:
"""The ``${supabase_app_schemas}`` placeholder: each registered supabase app's
own schema, comma-prefixed and joined (or '' when there are none).
@@ -621,8 +653,14 @@ def _build_deployed(
# names to castle's computed values. Secret-bearing vars split out to a
# mode-0600 file (never in the unit or argv).
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
# An explicit defaults.env value always wins — a hand-set var is never clobbered.
for var, url in _requires_env(config, name, config_key).items():
raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port)
ctx = _env_context(name, config_key, port, public_url, _supabase_app_schemas(config))
ctx = _env_context(
name, config_key, port, public_url, _supabase_app_schemas(config)
)
# ${tls_*}: paths to castle-materialized cert files for a TLS-material TCP
# service. The deployment maps them into its own config (mount ${tls_dir} for a
# container, or reference ${tls_cert}/${tls_key} directly for a native service).
@@ -647,7 +685,12 @@ def _build_deployed(
_ensure_python_tool(config, dep.program, messages)
run_cmd = _build_run_cmd(
name, run, env, messages, source_dir, secret_env_file=secret_env_file,
name,
run,
env,
messages,
source_dir,
secret_env_file=secret_env_file,
placeholders=ctx,
)
stop_cmd = _build_stop_cmd(name, run, source_dir)