From 1ab54b1efdbcf732f8325c54cbd4e573ecb849b3 Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Sat, 25 Jul 2026 13:08:51 -0400 Subject: [PATCH] feat(manifest): declare build context in the schema + test every project image resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes audit §2.1: build-context identity was not in the manifest schema, so the image→context map lived only in README prose + one hardcoded preflight special-case, and folder-id ≠ image-name for 5 services. - Add an optional `build:` field (context/dockerfile/external) to the Plugin/Agent/ Dashboard/DashboardBackend schema (ordo/buildspec.BuildSpec). METADATA only — never emitted into the rendered compose (render stays byte-identical). Defaults to the service's own services// + Dockerfile. Declared explicitly only where the default is wrong: the v1-parity dashboard's nested build context, and the two pluggable agents (hermes, openai-agent) built out-of-band (build.external). - Add a single image→context resolver (ordo/buildspec.context_resolver) over BOTH manifest services and the hardcoded substrate services (SUBSTRATE_BUILD_CONTEXTS in compose.py, co-located with their definitions). Preflight now emits a GENERIC "build from " hint for every project image; the `"llamacpp-patched" in image` substring special-case and the one-image hardcoded hint are DELETED. - Add tests/substrate/test_build_contexts.py: every project image (ordo/* or ordo-ai-stack-*) referenced by any manifest OR the substrate services resolves to an existing Dockerfile (or is declared external) — a rename/typo fails CI, not deploy. Upstream pull-only images are exempt. Update the preflight-hint test to the generic form. Co-Authored-By: Claude Opus 4.8 --- ordo/agents.py | 7 ++ ordo/buildspec.py | 155 +++++++++++++++++++++++++ ordo/compose.py | 15 +++ ordo/dashboards.py | 11 ++ ordo/plugins.py | 6 + ordo/preflight.py | 53 +++++---- services/hermes/agent.yaml | 5 + services/openai-agent/agent.yaml | 8 +- services/v1-parity/dashboard.yaml | 5 + tests/substrate/test_build_contexts.py | 111 ++++++++++++++++++ tests/substrate/test_preflight.py | 18 ++- 11 files changed, 370 insertions(+), 24 deletions(-) create mode 100644 ordo/buildspec.py create mode 100644 tests/substrate/test_build_contexts.py diff --git a/ordo/agents.py b/ordo/agents.py index 27a35c26..c017464e 100644 --- a/ordo/agents.py +++ b/ordo/agents.py @@ -23,6 +23,8 @@ import yaml +from .buildspec import BuildSpec + # The core services an agent may declare it consumes — used to validate a manifest isn't asking # for something the core doesn't provide. KNOWN_SERVICES = frozenset({"model-gateway", "mcp-gateway", "ops-controller", "dashboard"}) @@ -49,6 +51,10 @@ class Agent: # Empty -> compose omits it (render adds the core-peer list). A value -> emitted with conditions. depends_on: dict[str, str] = dataclasses.field(default_factory=dict) healthcheck: dict[str, Any] = dataclasses.field(default_factory=dict) + # Build-context identity (METADATA for preflight/tests; NEVER rendered into compose). Agents are + # pluggable: an operator/third-party often ships a PREBUILT image (no in-repo Dockerfile) — those + # declare `build: {external: true}`. Absent -> the agent's own `services//`. See ordo.buildspec. + build: BuildSpec = dataclasses.field(default_factory=BuildSpec) @classmethod def from_dict(cls, d: dict[str, Any]) -> Agent: @@ -69,6 +75,7 @@ def from_dict(cls, d: dict[str, Any]) -> Agent: ), depends_on={str(k): str(v) for k, v in (d.get("depends_on", {}) or {}).items()}, healthcheck=dict(d.get("healthcheck", {}) or {}), + build=BuildSpec.from_dict(d.get("build")), ) def image_for(self, project: str) -> str: diff --git a/ordo/buildspec.py b/ordo/buildspec.py new file mode 100644 index 00000000..72ddc8e0 --- /dev/null +++ b/ordo/buildspec.py @@ -0,0 +1,155 @@ +"""Build-context identity — the missing link between an `image:` and the folder it builds from. + +The rendered compose is image-only (build is out-of-band), so historically the image→context map +lived only in per-service README prose plus one hardcoded preflight branch, and folder-id ≠ +image-name for several services (e.g. `memory-vault` builds `ordo/mcpvault-mcp`). That is the +structural seam audit §2.1 calls out: a new special-case each time. + +This module makes build-context a DECLARED, testable property: + - `BuildSpec` is the optional `build:` block a plugin/agent/dashboard manifest may carry + (context dir + dockerfile), defaulting to the service's own `services//` + `Dockerfile`. + It is pure METADATA — never emitted into the rendered compose (render stays image-only). + - `context_resolver()` returns a single image→context resolver covering BOTH manifest services + (from their `build:`/default) AND the hardcoded substrate services (`SUBSTRATE_BUILD_CONTEXTS` + in compose.py). Preflight uses it to emit a generic "build from " hint for EVERY + project image, and the substrate test uses it to assert every project image resolves to an + existing Dockerfile (so a rename/typo fails CI, not deploy). +""" +from __future__ import annotations + +import dataclasses +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from .compose import SUBSTRATE_BUILD_CONTEXTS + +if TYPE_CHECKING: + from .agents import AgentRegistry + from .dashboards import DashboardRegistry + from .plugins import Plugin, PluginRegistry + +# Sentinel context: a project-namespaced image that is genuinely built OUT-OF-BAND (no in-repo +# Dockerfile) — e.g. a pluggable agent image the operator/third-party builds from their own tree. +# Declared via `build: {external: true}` so "this image has no in-repo build context" is an +# explicit manifest property, not tribal knowledge. Buildable (Docker can't pull it) but exempt +# from the "must resolve to a Dockerfile" substrate test. +EXTERNAL = "" + + +@dataclasses.dataclass(frozen=True) +class BuildSpec: + """The optional `build:` block on a manifest. METADATA only — NEVER rendered into compose. + + Absent -> defaults to the service's own `services//` context + `Dockerfile`. Declare it + explicitly only when the context isn't the service's own dir (e.g. a nested build context) or + the image is built out-of-band (`external: true`).""" + context: str = "" # "" -> services/ (filled by context_for) + dockerfile: str = "Dockerfile" + external: bool = False # image built out-of-band; no in-repo Dockerfile + + @classmethod + def from_dict(cls, d: dict[str, Any] | None) -> BuildSpec: + d = d or {} + return cls( + context=str(d.get("context", "") or ""), + dockerfile=str(d.get("dockerfile", "") or "Dockerfile"), + external=bool(d.get("external", False)), + ) + + def context_for(self, service_id: str) -> str: + """The build-context dir (repo-root-relative), defaulting to the service's own folder.""" + return self.context or f"services/{service_id}" + + def resolved(self, service_id: str) -> str: + """EXTERNAL for an out-of-band image, else the build-context dir.""" + return EXTERNAL if self.external else self.context_for(service_id) + + +def image_ident(image: str) -> str: + """Strip a `${VAR:-default}` wrapper + registry tag/digest -> the `repo/name` identity used as + the map key. Unwrapping the default makes the RAW manifest ref (`${LTX_TRAINER_IMAGE:-ordo/ + ltx-trainer:9377…}`) and the preflight-EXPANDED ref (`ordo/ltx-trainer:9377…`) resolve alike. + + `ordo/model-gateway:latest` -> `ordo/model-gateway` + `ordo-ai-stack-llamacpp-patched:qwen36…` -> `ordo-ai-stack-llamacpp-patched` + `${LTX_TRAINER_IMAGE:-ordo/ltx-trainer:9…}` -> `ordo/ltx-trainer` + `ghcr.io/x/y@sha256:…` -> `ghcr.io/x/y` + """ + img = str(image) + if img.startswith("${") and ":-" in img: # unwrap ${VAR:-default} to its default + img = img.split(":-", 1)[1].rstrip("}") + img = img.split("@", 1)[0] # drop @sha256:… + if ":" in img.rsplit("/", 1)[-1]: # a :tag on the final path segment + img = img.rsplit(":", 1)[0] + return img + + +def substrate_context(image: str) -> str | None: + """Build context for a hardcoded substrate image, else None. Matches on the `repo/name` or the + `…-` suffix so both `ordo/model-gateway` and `ordo-ai-stack-llamacpp-patched` resolve.""" + ident = image_ident(image) + for name, ctx in SUBSTRATE_BUILD_CONTEXTS.items(): + if ident == name or ident.endswith("/" + name) or ident.endswith("-" + name): + return ctx + return None + + +def _is_project(image: str, project: str) -> bool: + """A repo-built image: project-namespaced (`/*`) or a known substrate build.""" + return image_ident(image).startswith(f"{project}/") or substrate_context(image) is not None + + +def _plugin_images(p: Plugin) -> list[str]: + """Every image a plugin declares (kind=mcp `mcp.image` + each kind=service `services[].image`).""" + imgs: list[str] = [] + mcp_img = str((p.mcp or {}).get("image", "") or "") + if mcp_img: + imgs.append(mcp_img) + imgs.extend(str(s.image) for s in p.services if s.image) + return imgs + + +def manifest_image_contexts( + plugins: PluginRegistry, agents: AgentRegistry, dashboards: DashboardRegistry, *, project: str, +) -> dict[str, str]: + """Map every PROJECT image a manifest builds -> its build context (from `build:` or the + `services/` default), or EXTERNAL for an out-of-band image. + + First writer wins, in canonical-owner order (agents, then dashboards, then plugins): the + `ordo/agent-` image is OWNED by the agent, so the hermes-dashboard plugin that merely + REUSES that image doesn't mis-claim it under its own (Dockerfile-less) folder.""" + m: dict[str, str] = {} + + def claim(image: str, ctx: str) -> None: + if _is_project(image, project): + m.setdefault(image_ident(image), ctx) + + for a in agents.agents: + claim(a.image_for(project), a.build.resolved(a.id)) + for d in dashboards.dashboards: + claim(d.image_for(project), d.build.resolved(d.id)) + if d.backend and d.backend.name: + claim(d.backend.image_for(project), d.backend.build.resolved(d.backend.name)) + for p in plugins.plugins: + for img in _plugin_images(p): + claim(img, p.build.resolved(p.id)) + return m + + +def context_resolver( + plugins: PluginRegistry, agents: AgentRegistry, dashboards: DashboardRegistry, *, project: str, +) -> Callable[[str], str | None]: + """A single image->context resolver over substrate + manifests. Returns: + - a build-context dir (repo-root-relative) for a repo-built project image, + - EXTERNAL for a project image built out-of-band (no in-repo Dockerfile), + - None for an upstream image Docker pulls (not a project image). + Substrate wins over manifests (the 4 hardcoded core-service images are unambiguous).""" + mmap = manifest_image_contexts(plugins, agents, dashboards, project=project) + + def resolve(image: str) -> str | None: + sc = substrate_context(image) + if sc is not None: + return sc + return mmap.get(image_ident(image)) + + return resolve diff --git a/ordo/compose.py b/ordo/compose.py index abaaf9c1..ba5642c1 100644 --- a/ordo/compose.py +++ b/ordo/compose.py @@ -24,6 +24,21 @@ # remote-access plugin, so it's not here — a local floor install is localhost-only. _CORE = ["llamacpp", "model-gateway", "mcp-gateway", "ops-controller", "dashboard"] +# Build contexts for the SUBSTRATE services — the project images hardcoded below that have NO +# manifest (`_model_gateway`/`_mcp_gateway`/`_ops_controller`, and the patched llama.cpp build +# pinned via a model's catalog `backend_image`). Manifest services (plugins/agents/dashboards, +# incl. the v1-parity dashboard's `ops-api` backend) declare their own context via `build:` in +# the manifest; only these hardcoded ones need to be declared here. `ordo.buildspec` reads this +# to give preflight + the substrate test a single image→context resolver — so a rename/typo fails +# CI, not deploy. Keyed by the image `repo/name` (matched on the `…-` suffix too, for the +# `ordo-ai-stack-llamacpp-patched` build tag). This is build METADATA — never rendered into compose. +SUBSTRATE_BUILD_CONTEXTS: dict[str, str] = { + "model-gateway": "services/model-gateway", + "mcp-gateway": "services/mcp-gateway", + "ops-controller": "services/ops-controller", + "llamacpp-patched": "services/llamacpp-patched", +} + # --metrics turns on llama-server's native Prometheus endpoint at /metrics:8080 (token rates, # queue depth). Always-on — it's cheap, and the monitoring plugin's prometheus scrapes it. LLAMACPP_METRICS_ARG = "--metrics" diff --git a/ordo/dashboards.py b/ordo/dashboards.py index c480a9bb..71ca4540 100644 --- a/ordo/dashboards.py +++ b/ordo/dashboards.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any +from .buildspec import BuildSpec + def _gpu_caps(b: dict[str, Any]) -> tuple[str, ...]: """Parse a backend's GPU reservation capabilities, data-driven. Accepts either the explicit @@ -63,6 +65,9 @@ class DashboardBackend: # report "No GPUs returned from registry". `count: all` (via the empty device_ids) so it reads # BOTH cards. Empty -> no reservation (a backend that doesn't touch the GPU). Mirrors V1 exactly. gpu_capabilities: tuple[str, ...] = () + # Build-context identity (METADATA; NEVER rendered). Absent -> `services//` (the backend's + # own dir, e.g. `ops-api` -> services/ops-api). See ordo.buildspec. + build: BuildSpec = dataclasses.field(default_factory=BuildSpec) def image_for(self, project: str) -> str: return self.image or f"{project}/{self.name}:latest" @@ -89,6 +94,10 @@ class Dashboard: # gpus:[]. Declared via `gpu: utility` (or `gpu_capabilities: [utility]`) in the manifest; # `count: all` (empty device_ids) so it reads BOTH cards. Empty -> no reservation. gpu_capabilities: tuple[str, ...] = () + # Build-context identity (METADATA; NEVER rendered). Absent -> the dashboard's own `services//`. + # The v1-parity dashboard's Dockerfile is nested (services/v1-parity/dashboard), so it declares + # an explicit `build.context`. See ordo.buildspec. + build: BuildSpec = dataclasses.field(default_factory=BuildSpec) @classmethod def from_dict(cls, d: dict[str, Any]) -> Dashboard: @@ -105,6 +114,7 @@ def from_dict(cls, d: dict[str, Any]) -> Dashboard: group_add_root=bool(b.get("group_add_root", False)), wants_secrets=bool(b.get("wants_secrets", True)), gpu_capabilities=_gpu_caps(b), + build=BuildSpec.from_dict(b.get("build")), ) return cls( id=str(d["id"]), name=str(d.get("name", d["id"])), @@ -118,6 +128,7 @@ def from_dict(cls, d: dict[str, Any]) -> Dashboard: wants_secrets=bool(d.get("wants_secrets", True)), backend=backend, gpu_capabilities=_gpu_caps(d), + build=BuildSpec.from_dict(d.get("build")), ) def image_for(self, project: str) -> str: diff --git a/ordo/plugins.py b/ordo/plugins.py index 81e3384f..fe6ff579 100644 --- a/ordo/plugins.py +++ b/ordo/plugins.py @@ -12,6 +12,7 @@ import yaml +from .buildspec import BuildSpec from .hardware import HardwareProfile @@ -82,6 +83,10 @@ class Plugin: # render emits these into secrets.env.example; the rendered compose reads them via a second # env_file `secrets.env` — derived (.env) config and operator secrets stay in separate files. secrets: tuple[str, ...] = () + # Build-context identity (METADATA for preflight/tests; NEVER rendered into compose). Absent -> + # the plugin's own `services//` + `Dockerfile`. Declared only when the context isn't the + # plugin's own dir. See ordo.buildspec. + build: BuildSpec = dataclasses.field(default_factory=BuildSpec) @classmethod def from_dict(cls, d: dict[str, Any]) -> Plugin: @@ -99,6 +104,7 @@ def from_dict(cls, d: dict[str, Any]) -> Plugin: mcp=dict(d.get("mcp", {}) or {}), services=tuple(PluginService.from_dict(s) for s in (d.get("services", []) or [])), secrets=tuple(str(s) for s in (d.get("secrets", []) or [])), + build=BuildSpec.from_dict(d.get("build")), ) @property diff --git a/ordo/preflight.py b/ordo/preflight.py index e66888fa..ded6df87 100644 --- a/ordo/preflight.py +++ b/ordo/preflight.py @@ -20,11 +20,17 @@ import re from pathlib import Path -from . import parity +from . import buildspec, parity +from .agents import AgentRegistry from .catalog import Catalog from .config import Source +from .dashboards import DashboardRegistry from .plugins import PluginRegistry -from .render import render +from .render import ( + DEFAULT_AGENTS_DIR, + DEFAULT_DASHBOARDS_DIR, + render, +) # ${VAR} or ${VAR:-default} — the compose interpolation syntax a plugin image ref may carry # (e.g. `${COMFYUI_IMAGE:-yanwk/comfyui-boot@sha256:…}`). Resolved against the rendered .env @@ -54,16 +60,6 @@ def required_images(rc, project: str = "ordo") -> list[str]: return sorted({_expand(svc["image"], rc.env) for svc in c["services"].values()}) -def _is_buildable(image: str, project: str) -> bool: - """True for images this repo builds locally (so a registry pull can NOT provide them). - - Project images (`/*`) are always local. The patched llama.cpp build is also - local-only — it has a docker/ build context but no registry to pull from — so a missing - one is 'build first', not 'Docker will pull'. - """ - return image.startswith(f"{project}/") or "llamacpp-patched" in image - - def _missing_secret_keys(rc, secrets_env: str) -> list[str]: """Keys the enabled stack requires that a present secrets.env leaves empty/absent.""" present = {k for k, v in parity.load_env(secrets_env).items() if v} @@ -76,8 +72,24 @@ def run( images_present: set[str] | None = None, secrets_env: str | None = None, project: str = "ordo", + agents: AgentRegistry | None = None, + dashboards: DashboardRegistry | None = None, ) -> tuple[bool, list[Check]]: - rc = render(source, catalog, registry) + # Load the agent/dashboard registries the SAME way render() does (from the co-located manifest + # dirs) so the image→context resolver sees exactly what was rendered. + if agents is None: + agents = AgentRegistry.load(DEFAULT_AGENTS_DIR) + if dashboards is None: + dashboards = DashboardRegistry.load(DEFAULT_DASHBOARDS_DIR) + rc = render(source, catalog, registry, agents=agents, dashboards=dashboards) + # Single image→context resolver over substrate + manifests. A project image resolves to a build + # context (or buildspec.EXTERNAL for an out-of-band image); an upstream pull image resolves to + # None. This REPLACES the old `_is_buildable` substring special-case + the hardcoded llamacpp hint. + resolve_ctx = buildspec.context_resolver(registry, agents, dashboards, project=project) + + def _is_buildable(image: str) -> bool: + return resolve_ctx(image) is not None + checks: list[Check] = [] # 1. drift gate — one ctx value everywhere @@ -110,7 +122,7 @@ def _float(img: str) -> bool: img = str(img) if img.startswith("${") and ":-" in img: # unwrap ${VAR:-default} img = img.split(":-", 1)[1].rstrip("}") - if _is_buildable(img, project) or "@sha256:" in img: + if _is_buildable(img) or "@sha256:" in img: return False tag = img.rsplit(":", 1)[-1] if ":" in img.rsplit("/", 1)[-1] else "latest" return not re.match(r"^v?\d+(\.\d+)+", tag) # not a version tag => rolling @@ -138,17 +150,18 @@ def _float(img: str) -> bool: # 6. images available — project images must be built (blocking); upstream may be pulled (note) if images_present is not None: needed = required_images(rc, project) - proj_missing = [i for i in needed if _is_buildable(i, project) and i not in images_present] + proj_missing = [i for i in needed if _is_buildable(i) and i not in images_present] upstream_missing = [i for i in needed - if not _is_buildable(i, project) and i not in images_present] + if not _is_buildable(i) and i not in images_present] detail = "all built" if proj_missing: + # Generic build-from hint derived from the single resolver — every project image gets + # a context pointer (no per-image special-case). An out-of-band image (EXTERNAL) has no + # in-repo context, so it's shown bare. hints = [] for i in proj_missing: - if "llamacpp-patched" in i: - hints.append(f"{i} (build from services/llamacpp-patched)") - else: - hints.append(i) + ctx = resolve_ctx(i) + hints.append(f"{i} (build from {ctx})" if ctx and ctx != buildspec.EXTERNAL else i) detail = f"build first: {', '.join(hints)}" checks.append(Check("project images built locally", not proj_missing, detail)) if upstream_missing: diff --git a/services/hermes/agent.yaml b/services/hermes/agent.yaml index 9a932b35..af76179b 100644 --- a/services/hermes/agent.yaml +++ b/services/hermes/agent.yaml @@ -6,6 +6,11 @@ description: > default: true # image omitted -> resolves to /agent-hermes:latest (the operator builds this from their # Hermes data/); pin an explicit image here to ship a prebuilt one. +# The agent-hermes image is built OUT-OF-BAND (no in-repo Dockerfile — it's built from the operator's +# Hermes tree), so `build.external` declares that: the substrate test exempts it from the +# "must resolve to a Dockerfile" check. METADATA only; never rendered into compose. +build: + external: true # The agent-hermes image's default CMD is `hermes --help` (prints usage and exits); the gateway is # started explicitly so the container runs the persistent messaging gateway (mirrors V1 compose). command: [hermes, gateway] diff --git a/services/openai-agent/agent.yaml b/services/openai-agent/agent.yaml index 9d3777af..50697bd7 100644 --- a/services/openai-agent/agent.yaml +++ b/services/openai-agent/agent.yaml @@ -3,11 +3,13 @@ name: Generic OpenAI-compatible agent description: > A minimal reference adapter for third parties: any agent that speaks the OpenAI chat API to the model-gateway and MCP to the mcp-gateway. Proves the core is genuinely agent-agnostic — swap it - in with `agent: openai-agent` in ordo.yaml. Ships as a project-buildable `:latest`, pinned by its - build context — the same convention every other locally-built image in this repo follows (see - `ordo/agent-hermes:latest`, `ordo/model-gateway:latest`) — not an upstream registry pull. + in with `agent: openai-agent` in ordo.yaml. A third party ships the image built from their own + tree (there is no in-repo Dockerfile), so `build.external` marks it built out-of-band. default: false image: ordo/agent-openai-agent:latest +# Built out-of-band (no in-repo Dockerfile) — a third party supplies the image. METADATA only. +build: + external: true consumes: - model-gateway - mcp-gateway diff --git a/services/v1-parity/dashboard.yaml b/services/v1-parity/dashboard.yaml index c9aa0d9a..0ebfd635 100644 --- a/services/v1-parity/dashboard.yaml +++ b/services/v1-parity/dashboard.yaml @@ -13,6 +13,11 @@ description: > default: false # The V1 dashboard image, tagged under the project namespace (built from V1's context verbatim). image: ordo/dashboard-v1:latest +# Build context is NESTED (the Dockerfile lives in the dashboard/ subdir, not this manifest's own +# services/v1-parity/ folder), so it's declared explicitly — the resolver/substrate test otherwise +# defaults to services/. METADATA only; never rendered into compose. +build: + context: services/v1-parity/dashboard # Read-only GPU visibility for the dashboard SERVICE ITSELF. Its `/api/hardware` route (the hw-stat # bar's GPU widgets) shells to nvidia-smi (_probe_gpu) + enumerates cards (gpu_stats.list_gpus), # which the NVIDIA runtime only injects when the service reserves a GPU with the `utility` cap. diff --git a/tests/substrate/test_build_contexts.py b/tests/substrate/test_build_contexts.py new file mode 100644 index 00000000..e172719a --- /dev/null +++ b/tests/substrate/test_build_contexts.py @@ -0,0 +1,111 @@ +"""Build-context identity is a DECLARED, testable property (audit §2.1). + +Every PROJECT-built image (`ordo/*` or `ordo-ai-stack-*`) referenced by ANY manifest OR by the +hardcoded substrate services must resolve — through the single `ordo.buildspec` resolver — to an +EXISTING build context + Dockerfile under `services/` (or be explicitly declared built out-of-band +via `build: {external: true}`). So a folder rename or an image typo fails CI, not deploy. + +Pull-only UPSTREAM images (caddy, qdrant, n8n, node/llama.cpp bases, …) are exempt by construction: +the resolver returns None for them (they are not project images), and this test only asserts over +images the resolver classifies as project-built. +""" +from pathlib import Path + +from ordo import buildspec +from ordo.agents import AgentRegistry +from ordo.compose import SUBSTRATE_BUILD_CONTEXTS +from ordo.dashboards import DashboardRegistry +from ordo.plugins import PluginRegistry + +ROOT = Path(__file__).resolve().parents[2] +SERVICES = ROOT / "services" + +PLUGINS = PluginRegistry.load(SERVICES) +AGENTS = AgentRegistry.load(SERVICES) +DASHBOARDS = DashboardRegistry.load(SERVICES) +RESOLVE = buildspec.context_resolver(PLUGINS, AGENTS, DASHBOARDS, project="ordo") + + +def _all_project_images() -> set[str]: + """Every project image any manifest declares + the substrate service images.""" + imgs: set[str] = set() + for p in PLUGINS.plugins: + imgs.update(buildspec._plugin_images(p)) + for a in AGENTS.agents: + imgs.add(a.image_for("ordo")) + for d in DASHBOARDS.dashboards: + imgs.add(d.image_for("ordo")) + if d.backend and d.backend.name: + imgs.add(d.backend.image_for("ordo")) + # substrate images (hardcoded in compose.py, no manifest): the project-namespaced core services + # + the patched llama.cpp build referenced via a model's catalog backend_image. + for name in SUBSTRATE_BUILD_CONTEXTS: + imgs.add(f"ordo/{name}:latest") + imgs.add("ordo-ai-stack-llamacpp-patched:qwen36-swa-86b9470") + return {i for i in imgs if buildspec._is_project(i, "ordo")} + + +def test_every_project_image_resolves_to_a_context(): + unresolved = [i for i in _all_project_images() if RESOLVE(i) is None] + assert not unresolved, f"project images with NO build context (rename/typo?): {unresolved}" + + +def test_every_resolved_context_has_a_dockerfile(): + """A real (non-external) context must point at an existing Dockerfile — so renaming a folder + without updating the manifest/substrate map (or an image typo) fails here, not at deploy.""" + missing = [] + for img in sorted(_all_project_images()): + ctx = RESOLVE(img) + if ctx is None or ctx == buildspec.EXTERNAL: + continue + dockerfile = ROOT / ctx / "Dockerfile" + if not dockerfile.is_file(): + missing.append(f"{img} -> {ctx}/Dockerfile (absent)") + assert not missing, f"project images whose build context has no Dockerfile: {missing}" + + +def test_substrate_map_contexts_all_exist(): + """The hardcoded substrate map must not drift from the filesystem.""" + missing = [ctx for ctx in SUBSTRATE_BUILD_CONTEXTS.values() + if not (ROOT / ctx / "Dockerfile").is_file()] + assert not missing, f"SUBSTRATE_BUILD_CONTEXTS point at folders with no Dockerfile: {missing}" + + +def test_folder_id_differs_from_image_name_resolves_to_folder(): + """The 5 services where folder-id ≠ image-name (audit §2.1) must resolve to their real folder, + not the (nonexistent) image-named one — the exact bug the resolver cures.""" + expected = { + "ordo/mcpvault-mcp:latest": "services/memory-vault", + "ordo/rag-ingestion:latest": "services/rag", + "ordo/codebase-memory-mcp:latest": "services/codebase-memory", + "ordo/orchestration-mcp:latest": "services/orchestration", + "ordo/qdrant-rag-mcp:latest": "services/qdrant-rag", + } + for img, ctx in expected.items(): + assert RESOLVE(img) == ctx, f"{img} resolved to {RESOLVE(img)!r}, expected {ctx!r}" + + +def test_external_agents_declared_out_of_band(): + """Pluggable agent images with no in-repo Dockerfile must be declared external (not silently + treated as pullable) — so a NEW agent lacking both a Dockerfile and build.external fails CI.""" + assert RESOLVE("ordo/agent-hermes:latest") == buildspec.EXTERNAL + assert RESOLVE("ordo/agent-openai-agent:latest") == buildspec.EXTERNAL + + +def test_patched_llamacpp_resolves_via_substrate_not_substring(): + """The patched llama.cpp build (image name has NO `ordo/` prefix) resolves through the substrate + map — the generic replacement for the deleted `'llamacpp-patched' in image` special-case.""" + assert RESOLVE("ordo-ai-stack-llamacpp-patched:qwen36-swa-86b9470") == "services/llamacpp-patched" + + +def test_build_field_is_not_rendered_into_compose(): + """`build:` is METADATA — it must never leak into a rendered compose service (which is image-only).""" + from ordo.catalog import Catalog + from ordo.config import Source + from ordo.render import render + src = Source.from_dict({"hardware": {"gpus": [{"vram_gb": 32}], "ram_gb": 128}, + "model": "auto", "plugins": "auto"}) + cat = Catalog.load(ROOT / "catalog" / "models.yaml") + rc = render(src, cat, PLUGINS, agents=AGENTS, dashboards=DASHBOARDS) + for name, svc in rc.compose_dict(project="ordo")["services"].items(): + assert "build" not in svc, f"service {name!r} leaked a build: key into compose" diff --git a/tests/substrate/test_preflight.py b/tests/substrate/test_preflight.py index 9064fa72..09e01d6f 100644 --- a/tests/substrate/test_preflight.py +++ b/tests/substrate/test_preflight.py @@ -84,11 +84,27 @@ def test_patched_llamacpp_is_buildable_not_pullable(): go, checks = preflight.run(GPU, CATALOG, REGISTRY, images_present=present) proj = _byname(checks)["project images built locally"] assert not proj.ok and proj.blocking and not go # NO-GO — it can't be pulled - assert "services/llamacpp-patched" in proj.detail # points at the build context + # GENERIC hint from the single resolver (no per-image special-case): llamacpp-patched, whose + # image name has NO `ordo/` prefix, still resolves to its build context through the substrate map. + assert f"{patched} (build from services/llamacpp-patched)" in proj.detail # it must NOT show up as a pullable upstream image assert not any(c.name.startswith("upstream") and patched in c.detail for c in checks) +def test_all_project_images_get_a_generic_build_from_hint(): + # Every missing PROJECT image (not just llamacpp) gets a "build from " pointer derived + # from the single resolver — the whole point of deleting the one-image special-case. Folder-id ≠ + # image-name (e.g. ordo/rag-ingestion -> services/rag) resolves to the real folder. + rc = render(GPU, CATALOG, REGISTRY) + needed = preflight.required_images(rc) + go, checks = preflight.run(GPU, CATALOG, REGISTRY, images_present=set()) # nothing cached + proj = _byname(checks)["project images built locally"] + assert not proj.ok and not go + assert "ordo/model-gateway:latest (build from services/model-gateway)" in proj.detail + if "ordo/rag-ingestion:latest" in needed: + assert "ordo/rag-ingestion:latest (build from services/rag)" in proj.detail + + def test_mcp_pinned_check_passes_with_real_images(): # qdrant-rag (project image) + searxng (real digest) → the digest-pinned check is OK _go, checks = preflight.run(GPU, CATALOG, REGISTRY)