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:
2026-07-06 04:00:24 -07:00
parent 20bf78caf1
commit e438f45b54
13 changed files with 329 additions and 132 deletions

View File

@@ -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