feat: deployment-scoped requires + UI editors, gateway nav, services filter
Model:
- Drop `requires` from ProgramSpec; requires is now deployment-only.
Collapse Requirement to {ref, bind} with kind defaulting to "deployment"
(drop the unused `version`). System-package preconditions stay on the
program as `system_dependencies`, synthesized into {kind: system} for the
functional check. relations.py/deploy.py read only the deployment's requires.
- create.py seeds a stack's substrate dependency onto the deployment, not the
program. Docs + tests updated.
UI:
- Add a `requires` editor (useRequires) on service, job, and static detail
pages — edit service→service deps (ref + optional bind env var).
- Restore the gateway route table "Open" column: a kind-aware in-app link to
each route's deployment.
- Reformat the reach control: name baked into each option
(localhost:PORT (local) / <name>.<domain> (internal) / <name>.<pubdomain> (public)).
- Services page: sort statics in with systemd services by name, add a
search + kind-filter bar mirroring the programs page.
This commit is contained in:
@@ -503,18 +503,12 @@ def _target_url(config: CastleConfig, target_name: str) -> str | None:
|
||||
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
|
||||
|
||||
|
||||
def _requires_env(
|
||||
config: CastleConfig, dep: DeploymentSpec, 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)."""
|
||||
prog = config.programs.get(config_key)
|
||||
reqs = list(getattr(dep, "requires", []) or [])
|
||||
if prog:
|
||||
reqs += list(prog.requires)
|
||||
def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
|
||||
"""Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}``
|
||||
requirement sets ``VAR`` to the target deployment's URL. Env is derived from the
|
||||
dependency, never scraped back into one (see docs/relationships.md)."""
|
||||
out: dict[str, str] = {}
|
||||
for r in reqs:
|
||||
for r in getattr(dep, "requires", []) or []:
|
||||
if r.kind == "deployment" and r.bind:
|
||||
url = _target_url(config, r.ref)
|
||||
if url:
|
||||
@@ -676,7 +670,7 @@ def _build_deployed(
|
||||
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, dep, config_key).items():
|
||||
for var, url in _requires_env(config, dep).items():
|
||||
raw_env.setdefault(var, url)
|
||||
public_url = _public_url(config, name, expose, port)
|
||||
ctx = _env_context(
|
||||
|
||||
@@ -284,23 +284,20 @@ class Capability(BaseModel):
|
||||
|
||||
|
||||
class Requirement(BaseModel):
|
||||
"""A precondition — something that must be true for a program/deployment to be
|
||||
*functional*. The ``kind`` fixes both the meaning and how it's checked (there is
|
||||
no separate purpose tag):
|
||||
"""A precondition — another **deployment** that must exist for this one to be
|
||||
*functional* (``ref`` = the target deployment's name). ``bind`` names the env
|
||||
var castle projects the target's URL into — env is derived *from* the
|
||||
requirement, never scraped back into it.
|
||||
|
||||
- ``system`` — a host package/binary must be installed (``ref`` = package).
|
||||
- ``deployment`` — another deployment must exist/run (``ref`` = its name).
|
||||
|
||||
``version`` is reserved for a future constraint (unused now). ``bind`` (for a
|
||||
``deployment`` requirement) names the env var castle projects the target's URL
|
||||
into — env is derived *from* the requirement, never scraped back into it.
|
||||
|
||||
See docs/relationships.md. ``system_dependencies`` is the ``kind: system`` case.
|
||||
A deployment declares these in its ``requires`` list; ``kind`` defaults to
|
||||
``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
|
||||
here — a program's host-package preconditions live in ``system_dependencies``,
|
||||
and the relationship model synthesizes ``kind: system`` requirements from it for
|
||||
the ``functional?`` check. See docs/relationships.md.
|
||||
"""
|
||||
|
||||
kind: Literal["system", "deployment"]
|
||||
kind: Literal["system", "deployment"] = "deployment"
|
||||
ref: str
|
||||
version: str | None = None
|
||||
bind: str | None = None
|
||||
|
||||
|
||||
@@ -384,10 +381,10 @@ class ProgramSpec(BaseModel):
|
||||
# Per-program dev verb overrides (declared verbs override the stack default).
|
||||
commands: CommandsSpec | None = None
|
||||
|
||||
# `requires` is the general precondition relation (see docs/relationships.md).
|
||||
# `system_dependencies` is kept as the `{kind: system}` alias/back-compat; both
|
||||
# are merged when evaluating what a program requires.
|
||||
requires: list[Requirement] = Field(default_factory=list)
|
||||
# Host-package preconditions (apt packages / binaries) intrinsic to this
|
||||
# software. The relationship model checks these (`which`/`dpkg`) to derive the
|
||||
# `functional?` light. Deployment-to-deployment dependencies are NOT here — they
|
||||
# live on the deployment's `requires` (see DeploymentBase). See docs/relationships.md.
|
||||
system_dependencies: list[str] = Field(default_factory=list)
|
||||
install_extras: list[str] = Field(default_factory=list)
|
||||
version: str | None = None
|
||||
@@ -431,8 +428,10 @@ class DeploymentBase(BaseModel):
|
||||
)
|
||||
description: str | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
# Runtime preconditions (e.g. another deployment that must exist). See
|
||||
# docs/relationships.md; merged with the program's `requires` when evaluated.
|
||||
# Deployment-to-deployment preconditions: other deployments this one needs
|
||||
# (e.g. a frontend that requires its API + the supabase substrate). Each entry
|
||||
# is `- ref: <deployment>` (+ optional `bind: ENV_VAR` to project the target's
|
||||
# URL into env). Drives the relationship graph's edges. See docs/relationships.md.
|
||||
requires: list[Requirement] = Field(default_factory=list)
|
||||
# Declared on/off state. `castle apply` converges reality to this: enabled
|
||||
# deployments are activated (service started, tool installed, route served),
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""The relationship model — derived, never stored. See docs/relationships.md.
|
||||
|
||||
Entities: **program**, **deployment**, **repo** (a repo is a git working copy;
|
||||
programs sharing a toplevel form a monorepo). One encoded relation, **`requires`**
|
||||
(a precondition, typed by ``kind``: ``system`` = must be installed, ``deployment``
|
||||
= must exist). Everything else — repos, env wiring, fan-in, and the predicates
|
||||
programs sharing a toplevel form a monorepo). Preconditions come from two encoded
|
||||
sources: a deployment's **`requires`** (other deployments it needs) and its
|
||||
program's **`system_dependencies`** (host packages). These are unified here into one
|
||||
requirement set (typed by ``kind``: ``deployment`` = must exist, ``system`` = must be
|
||||
installed). Everything else — repos, env wiring, fan-in, and the predicates
|
||||
``functional?`` / ``fresh?`` / ``deployed?`` — is computed here on demand.
|
||||
|
||||
Governing rule: *predicates are derived; we encode only the non-derivable.* So this
|
||||
module reads the encoded ``requires`` (plus ``system_dependencies`` as its
|
||||
``kind: system`` alias) and derives the rest. It does **not** scrape env for
|
||||
dependencies — env is generated *from* requirements, not the reverse.
|
||||
module reads the encoded ``requires`` + ``system_dependencies`` and derives the rest.
|
||||
It does **not** scrape env for dependencies — env is generated *from* requirements,
|
||||
not the reverse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -115,16 +117,16 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
|
||||
|
||||
|
||||
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
||||
"""The full requirement set for a deployment: its own ``requires`` plus its
|
||||
program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias),
|
||||
de-duplicated by (kind, ref)."""
|
||||
"""The full requirement set for a deployment: its own ``requires`` (deployment
|
||||
dependencies) plus its program's ``system_dependencies`` synthesized as
|
||||
``kind: system`` requirements, de-duplicated by (kind, ref)."""
|
||||
reqs: list[Requirement] = []
|
||||
# A bare name may span kinds — union their requirements (plus their program's).
|
||||
# A bare name may span kinds — union their requirements (plus their program's
|
||||
# host-package deps as the synthesized `kind: system` set).
|
||||
for _kind, dep in config.deployments_named(dep_name):
|
||||
reqs += list(getattr(dep, "requires", []) or [])
|
||||
prog = config.programs.get(_program_of(dep_name, dep))
|
||||
if prog:
|
||||
reqs += list(prog.requires)
|
||||
reqs += [
|
||||
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
|
||||
]
|
||||
|
||||
@@ -6,17 +6,18 @@ import pytest
|
||||
|
||||
import castle_core.config as C
|
||||
from castle_core import relations as R
|
||||
from castle_core.manifest import ProgramSpec, Requirement, SystemdDeployment
|
||||
from castle_core.manifest import ProgramSpec, SystemdDeployment
|
||||
|
||||
|
||||
def _dep(program: str) -> SystemdDeployment:
|
||||
return SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"program": program,
|
||||
"run": {"launcher": "command", "argv": [program]},
|
||||
}
|
||||
)
|
||||
def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
|
||||
spec: dict = {
|
||||
"manager": "systemd",
|
||||
"program": program,
|
||||
"run": {"launcher": "command", "argv": [program]},
|
||||
}
|
||||
if requires is not None:
|
||||
spec["requires"] = requires
|
||||
return SystemdDeployment.model_validate(spec)
|
||||
|
||||
|
||||
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
|
||||
@@ -38,20 +39,12 @@ def test_system_dependencies_is_the_system_requirement_alias() -> None:
|
||||
assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
|
||||
|
||||
|
||||
def test_requirements_merge_program_and_deployment_deduped() -> None:
|
||||
prog = ProgramSpec(
|
||||
id="web",
|
||||
system_dependencies=["pandoc"],
|
||||
requires=[Requirement(kind="deployment", ref="api", bind="API_URL")],
|
||||
)
|
||||
dep = SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"program": "web",
|
||||
"run": {"launcher": "command", "argv": ["web"]},
|
||||
"requires": [{"kind": "system", "ref": "pandoc"}],
|
||||
} # dup of program's
|
||||
)
|
||||
def test_requirements_merge_system_deps_and_deployment_requires_deduped() -> None:
|
||||
"""The requirement set unions the program's system_dependencies (as {kind: system})
|
||||
with the deployment's own requires, de-duplicated by (kind, ref)."""
|
||||
prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
|
||||
# Two identical deployment requires — deduped to one.
|
||||
dep = _dep("web", requires=[{"ref": "api"}, {"ref": "api"}])
|
||||
cfg = _cfg(
|
||||
{"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")}
|
||||
)
|
||||
@@ -62,15 +55,13 @@ def test_requirements_merge_program_and_deployment_deduped() -> None:
|
||||
def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
|
||||
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
|
||||
fan-in is the count of distinct dependents."""
|
||||
consumer = ProgramSpec(
|
||||
id="web", requires=[Requirement(kind="deployment", ref="api", bind="API_URL")]
|
||||
)
|
||||
consumer2 = ProgramSpec(
|
||||
id="cli", requires=[Requirement(kind="deployment", ref="api")]
|
||||
)
|
||||
cfg = _cfg(
|
||||
{"web": consumer, "cli": consumer2, "api": ProgramSpec(id="api")},
|
||||
{"web": _dep("web"), "cli": _dep("cli"), "api": _dep("api")},
|
||||
{"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")},
|
||||
{
|
||||
"web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
|
||||
"cli": _dep("cli", requires=[{"ref": "api"}]),
|
||||
"api": _dep("api"),
|
||||
},
|
||||
)
|
||||
m = R.build_model(cfg, check=False)
|
||||
edge = next(e for e in m.edges if e.src == "web" and e.dst == "api")
|
||||
@@ -82,14 +73,10 @@ def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
|
||||
def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`functional?` is derived: a missing system package is unmet; a present
|
||||
deployment requirement is satisfied."""
|
||||
prog = ProgramSpec(
|
||||
id="web",
|
||||
system_dependencies=["pandoc"],
|
||||
requires=[Requirement(kind="deployment", ref="api")],
|
||||
)
|
||||
prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
|
||||
cfg = _cfg(
|
||||
{"web": prog, "api": ProgramSpec(id="api")},
|
||||
{"web": _dep("web"), "api": _dep("api")},
|
||||
{"web": _dep("web", requires=[{"ref": "api"}]), "api": _dep("api")},
|
||||
)
|
||||
monkeypatch.setattr(R.shutil, "which", lambda _: None) # not on PATH
|
||||
monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package
|
||||
@@ -113,8 +100,10 @@ def test_system_requirement_satisfied_by_package_not_on_path(
|
||||
|
||||
|
||||
def test_missing_deployment_requirement_is_unmet() -> None:
|
||||
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
|
||||
cfg = _cfg({"web": prog}, {"web": _dep("web")})
|
||||
cfg = _cfg(
|
||||
{"web": ProgramSpec(id="web")},
|
||||
{"web": _dep("web", requires=[{"ref": "ghost"}])},
|
||||
)
|
||||
m = R.build_model(cfg, check=True)
|
||||
web = next(n for n in m.nodes if n.name == "web")
|
||||
assert web.unmet == ["deployment:ghost"] and web.functional is False
|
||||
|
||||
Reference in New Issue
Block a user