Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ordo/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand All @@ -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/<id>/`. See ordo.buildspec.
build: BuildSpec = dataclasses.field(default_factory=BuildSpec)

@classmethod
def from_dict(cls, d: dict[str, Any]) -> Agent:
Expand All @@ -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:
Expand Down
155 changes: 155 additions & 0 deletions ordo/buildspec.py
Original file line number Diff line number Diff line change
@@ -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/<id>/` + `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 <context>" 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 = "<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/<id>/` 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/<id> (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
`…-<name>` 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 (`<project>/*`) 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/<id>` default), or EXTERNAL for an out-of-band image.

First writer wins, in canonical-owner order (agents, then dashboards, then plugins): the
`ordo/agent-<id>` 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
15 changes: 15 additions & 0 deletions ordo/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `…-<name>` 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"
Expand Down
11 changes: 11 additions & 0 deletions ordo/dashboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<name>/` (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"
Expand All @@ -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/<id>/`.
# 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:
Expand All @@ -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"])),
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions ordo/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import yaml

from .buildspec import BuildSpec
from .hardware import HardwareProfile


Expand Down Expand Up @@ -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/<id>/` + `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:
Expand All @@ -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
Expand Down
53 changes: 33 additions & 20 deletions ordo/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<project>/*`) 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}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions services/hermes/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ description: >
default: true
# image omitted -> resolves to <project>/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]
Expand Down
Loading
Loading