feat(deploy): run RoboCo from pre-built registry images

The orchestrator spawned agents only by bare image names and built any
missing image from source on the host, so a deployment had to carry the
build context and a toolchain — there was no way to just pull and run the
images the release workflow publishes.

Add two settings (default empty = unchanged local-build behavior):
ROBOCO_AGENT_IMAGE_REGISTRY and ROBOCO_AGENT_IMAGE_TAG. When a registry is
set, the orchestrator spawns and ensures {registry}/roboco-agent-*[:tag] and
pulls (never builds) any image it lacks. Also adds the previously-missing
agent-secretary image to the lazy-build map.

Ship docker-compose.registry.yml: a standalone compose that pulls every
published image (GHCR or Docker Hub, pinnable version) and wires the
orchestrator to spawn the matching pre-built agent images. The existing
build compose files are unchanged.
This commit is contained in:
Renn F
2026-06-16 20:38:36 +02:00
parent 78b22a4c1b
commit 1dc9e8e47a
4 changed files with 416 additions and 28 deletions
+253
View File
@@ -0,0 +1,253 @@
# ============================================================================
# RoboCo — pre-built (registry) deployment
# ============================================================================
# This is the "pull and run" compose for USERS: it runs the images the release
# workflow publishes (GHCR + Docker Hub) instead of building from source, so a
# host needs neither the repo's build context nor a build toolchain.
#
# 1. Copy `.env.example` to `.env` and fill in the required secrets.
# 2. docker compose -f docker-compose.registry.yml pull
# 3. docker compose -f docker-compose.registry.yml up -d
#
# Pick the registry + version with two env vars (defaults shown):
# ROBOCO_REGISTRY=ghcr.io/rennf93 # or docker.io/renzof93
# ROBOCO_VERSION=latest # or a pinned release, e.g. 0.5.0
#
# The orchestrator spawns agent containers itself; ROBOCO_AGENT_IMAGE_REGISTRY
# + ROBOCO_AGENT_IMAGE_TAG below tell it to spawn the SAME pre-built agent
# images (it pulls any it doesn't already have). The one-shot agent-*-image
# services exist only so `docker compose pull` fetches every agent image up
# front; they pull then exit.
#
# NOTE: this file is the registry counterpart of docker-compose.yml — when you
# add or change a service there, mirror it here. The infra services
# (postgres/redis/ollama/nginx) are byte-identical to the build compose.
# ============================================================================
services:
postgres:
image: pgvector/pgvector:pg16
container_name: roboco-postgres
restart: unless-stopped
environment:
POSTGRES_USER: roboco
POSTGRES_PASSWORD: roboco
POSTGRES_DB: roboco
ports:
- "15432:5432"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:8-alpine
container_name: roboco-redis
restart: unless-stopped
command: redis-server --appendonly yes
ports:
- "16379:6379"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama:latest
container_name: roboco-ollama
restart: unless-stopped
environment:
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-}
ports:
- "11435:11434"
volumes:
- ${ROBOCO_DATA_DIR:-./data}/ollama:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ollama-init:
image: curlimages/curl:latest
container_name: roboco-ollama-init
depends_on:
ollama:
condition: service_healthy
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
echo "=== Pulling embedding model (qwen3-embedding:0.6b) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"qwen3-embedding:0.6b"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
echo "=== All models ready! ==="
# --------------------------------------------------------------------------
# Agent image pre-pull (one-shot). Each pulls its published image then exits,
# so `docker compose pull` fetches every agent image up front. The
# orchestrator spawns these same images at runtime.
# --------------------------------------------------------------------------
agent-base-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-base:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-base image present'"]
restart: "no"
agent-pm-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-pm:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-pm image present'"]
restart: "no"
agent-dev-be-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-dev-be:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-dev-be image present'"]
restart: "no"
agent-dev-fe-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-dev-fe:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-dev-fe image present'"]
restart: "no"
agent-qa-be-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-qa-be:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-qa-be image present'"]
restart: "no"
agent-qa-fe-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-qa-fe:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-qa-fe image present'"]
restart: "no"
agent-ux-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-ux:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-ux image present'"]
restart: "no"
agent-doc-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-doc:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-doc image present'"]
restart: "no"
agent-prompter-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-prompter:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-prompter image present'"]
restart: "no"
agent-secretary-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-secretary:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'agent-secretary image present'"]
restart: "no"
# --------------------------------------------------------------------------
# Orchestrator — API server + agent spawner
# --------------------------------------------------------------------------
orchestrator:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-orchestrator:${ROBOCO_VERSION:-latest}
container_name: roboco-orchestrator
restart: unless-stopped
ports:
- "8000:8000"
environment:
ROBOCO_DATABASE_HOST: roboco-postgres
ROBOCO_DATABASE_PORT: 5432
ROBOCO_DATABASE_USER: roboco
ROBOCO_DATABASE_PASSWORD: roboco
ROBOCO_DATABASE_NAME: roboco
ROBOCO_REDIS_HOST: roboco-redis
ROBOCO_REDIS_PORT: 6379
ROBOCO_HOST: 0.0.0.0
ROBOCO_PORT: 8000
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
ROBOCO_AGENT_AUTH_SECRET: ${ROBOCO_AGENT_AUTH_SECRET:?ROBOCO_AGENT_AUTH_SECRET is required}
ROBOCO_AGENT_AUTH_REQUIRED: ${ROBOCO_AGENT_AUTH_REQUIRED:-false}
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Spawn the PRE-BUILT agent images from the same registry instead of
# building them from source (the orchestrator pulls any it lacks).
ROBOCO_AGENT_IMAGE_REGISTRY: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}
ROBOCO_AGENT_IMAGE_TAG: ${ROBOCO_VERSION:-latest}
# Host paths for spawning agent containers (Docker-in-Docker). MUST be
# absolute paths on the host. Default to this compose project's ./data.
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/opt/roboco}
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-${HOME}/.claude}
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data}
# Reachable base URL for commit-trailer links — set to your host's LAN
# address or domain so the links in commit bodies resolve.
ROBOCO_PUBLIC_BASE_URL: ${ROBOCO_PUBLIC_BASE_URL:-http://localhost:8000}
ROBOCO_ENVIRONMENT: production
ROBOCO_CLAIM_STALE_SECONDS: "1800"
ROBOCO_STALE_CLAIM_REAP_SECONDS: "1800"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${CLAUDE_AUTH_DIR:-${HOME}/.claude}:/root/.claude
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
- ${ROBOCO_DATA_DIR:-./data}/briefings:/app/briefings
- ${ROBOCO_DATA_DIR:-./data}/manifests:/app/manifests
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ollama:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
agent-base-image:
condition: service_completed_successfully
# --------------------------------------------------------------------------
# Next.js control panel (fronted by nginx; not exposed directly)
# --------------------------------------------------------------------------
panel:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-panel:${ROBOCO_VERSION:-latest}
container_name: roboco-panel
restart: unless-stopped
expose:
- "3000"
depends_on:
- orchestrator
# --------------------------------------------------------------------------
# Nginx — single entry point on port 3000
# --------------------------------------------------------------------------
nginx:
image: nginx:alpine
container_name: roboco-nginx
restart: unless-stopped
ports:
- "3000:80"
environment:
ROBOCO_PANEL_AGENT_TOKEN: ${ROBOCO_PANEL_AGENT_TOKEN:-}
NGINX_ENVSUBST_FILTER: "^ROBOCO_"
volumes:
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template:ro
depends_on:
- panel
- orchestrator
networks:
default:
name: roboco_default
+26
View File
@@ -393,6 +393,32 @@ class Settings(BaseSettings):
),
)
# ==========================================================================
# Agent container images (spawn source)
# ==========================================================================
# By default the orchestrator builds each specialized agent image locally
# from docker/agent-*.Dockerfile the first time it spawns that role (the
# build/test flow). Set a registry to run the PRE-BUILT images the release
# workflow publishes instead — the orchestrator then pulls
# `{registry}/roboco-agent-*[:tag]` rather than building, so a deployment
# never needs the source tree or a build toolchain. Empty = local build
# (unchanged behavior).
agent_image_registry: str = Field(
default="",
description=(
"Registry namespace for pre-built agent images, e.g. "
"'ghcr.io/rennf93' or 'docker.io/renzof93'. Empty builds locally."
),
)
agent_image_tag: str = Field(
default="",
description=(
"Tag for pre-built agent images (e.g. 'latest' or '0.5.0'). Empty "
"leaves the tag implicit (':latest'); only meaningful with "
"agent_image_registry set."
),
)
# ==========================================================================
# Transcript retention (agent Claude Code transcripts under ~/.claude)
# ==========================================================================
+67 -28
View File
@@ -130,9 +130,23 @@ AGENT_IMAGES: dict[str, str] = {
}
def _qualify_agent_image(bare: str) -> str:
"""Apply the configured registry namespace + tag to a bare agent image.
Default (no ``agent_image_registry``, no ``agent_image_tag``) returns the
bare name unchanged the local build flow. With a registry set the
orchestrator spawns (and ensures) ``{registry}/roboco-agent-*[:tag]``, the
pre-built images the release workflow publishes, instead of building.
"""
registry = settings.agent_image_registry.rstrip("/")
name = f"{registry}/{bare}" if registry else bare
tag = settings.agent_image_tag
return f"{name}:{tag}" if tag else name
def get_agent_image(agent_id: str) -> str:
"""Get the Docker image for an agent."""
return AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)
"""Get the Docker image for an agent (registry-qualified when configured)."""
return _qualify_agent_image(AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE))
# When running in a container, we need host paths for volume mounts.
@@ -731,9 +745,12 @@ class AgentOrchestrator:
logger.info("Orchestrator stopped")
async def _ensure_agent_image(self, agent_id: str | None = None) -> None:
"""Ensure the agent Docker images are built.
"""Ensure the agent Docker images are present.
Builds base image first, then specialized image if agent_id provided.
Local mode (no ``agent_image_registry``) builds the base image first,
then the role-specialized image, from ``docker/agent-*.Dockerfile``.
Registry mode pulls the pre-built images instead. Idempotent skips
anything already present locally.
"""
# Determine build context
if PROJECT_HOST_PATH:
@@ -744,17 +761,17 @@ class AgentOrchestrator:
docker_dir = str(self.project_root / "docker")
# Always ensure base image exists
await self._build_image_if_missing(
await self._ensure_image_present(
AGENT_BASE_IMAGE,
f"{docker_dir}/agent-base.Dockerfile",
build_context,
)
# Build specialized image if agent specified
# Ensure the role-specialized image if this agent uses one
if agent_id:
image = get_agent_image(agent_id)
if image != AGENT_BASE_IMAGE:
# Map image name to dockerfile
bare = AGENT_IMAGES.get(agent_id, AGENT_BASE_IMAGE)
if bare != AGENT_BASE_IMAGE:
# Map the bare image name to its dockerfile
dockerfile_map = {
"roboco-agent-pm": "agent-pm.Dockerfile",
"roboco-agent-dev-be": "agent-dev-be.Dockerfile",
@@ -764,49 +781,71 @@ class AgentOrchestrator:
"roboco-agent-doc": "agent-doc.Dockerfile",
"roboco-agent-ux": "agent-ux.Dockerfile",
"roboco-agent-prompter": "agent-prompter.Dockerfile",
"roboco-agent-secretary": "agent-secretary.Dockerfile",
}
dockerfile = dockerfile_map.get(image)
dockerfile = dockerfile_map.get(bare)
if dockerfile:
await self._build_image_if_missing(
image,
await self._ensure_image_present(
bare,
f"{docker_dir}/{dockerfile}",
build_context,
)
async def _build_image_if_missing(
self, image_name: str, dockerfile_path: str, build_context: str
async def _ensure_image_present(
self, bare_image: str, dockerfile_path: str, build_context: str
) -> None:
"""Build a Docker image if it doesn't exist."""
"""Ensure one agent image is present locally.
Pulls it (registry mode) or builds it from its Dockerfile (local mode)
when missing; no-op if already present.
"""
image = _qualify_agent_image(bare_image)
# Check if image exists
proc = await asyncio.create_subprocess_exec(
"docker",
"image",
"inspect",
image_name,
image,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
if proc.returncode == 0:
return
if proc.returncode != 0:
logger.info("Building Docker image...", image=image_name)
if settings.agent_image_registry:
# Registry mode: pull the pre-built image; never build from source
# (a deployment running pre-built images has no build context).
logger.info("Pulling agent image...", image=image)
proc = await asyncio.create_subprocess_exec(
"docker",
"build",
"-t",
image_name,
"-f",
dockerfile_path,
build_context,
"pull",
image,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(
f"Failed to build image {image_name}: {stderr.decode()}"
)
logger.info("Docker image built successfully", image=image_name)
raise RuntimeError(f"Failed to pull image {image}: {stderr.decode()}")
logger.info("Agent image pulled", image=image)
return
logger.info("Building Docker image...", image=image)
proc = await asyncio.create_subprocess_exec(
"docker",
"build",
"-t",
image,
"-f",
dockerfile_path,
build_context,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(f"Failed to build image {image}: {stderr.decode()}")
logger.info("Docker image built successfully", image=image)
# =========================================================================
# PER-AGENT SETTINGS GENERATION
@@ -0,0 +1,70 @@
"""Agent image resolution — local build vs. pre-built registry images.
``_qualify_agent_image`` decides what image name the orchestrator spawns (and
ensures). Empty registry/tag MUST return the bare name unchanged so the local
build flow and existing NAS deployment are untouched; a configured registry
switches every agent to the pre-built ``{registry}/roboco-agent-*[:tag]``.
"""
from __future__ import annotations
import pytest
from roboco.runtime import orchestrator as orch
@pytest.mark.parametrize(
("registry", "tag", "bare", "expected"),
[
# Default: no registry, no tag -> bare name unchanged (local build).
("", "", "roboco-agent-pm", "roboco-agent-pm"),
# Registry only -> qualified, implicit :latest.
("ghcr.io/rennf93", "", "roboco-agent-pm", "ghcr.io/rennf93/roboco-agent-pm"),
# Trailing slash on the registry is tolerated.
(
"ghcr.io/rennf93/",
"latest",
"roboco-agent-pm",
"ghcr.io/rennf93/roboco-agent-pm:latest",
),
# Docker Hub namespace + pinned version.
(
"docker.io/renzof93",
"0.5.0",
"roboco-agent-base",
"docker.io/renzof93/roboco-agent-base:0.5.0",
),
# Tag without registry is still applied (edge case, valid).
("", "latest", "roboco-agent-pm", "roboco-agent-pm:latest"),
],
)
def test_qualify_agent_image(
monkeypatch: pytest.MonkeyPatch,
registry: str,
tag: str,
bare: str,
expected: str,
) -> None:
monkeypatch.setattr(orch.settings, "agent_image_registry", registry)
monkeypatch.setattr(orch.settings, "agent_image_tag", tag)
assert orch._qualify_agent_image(bare) == expected
def test_get_agent_image_local_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orch.settings, "agent_image_registry", "")
monkeypatch.setattr(orch.settings, "agent_image_tag", "")
assert orch.get_agent_image("be-dev-1") == "roboco-agent-dev-be"
# Unknown agent id falls back to the base image.
assert orch.get_agent_image("pr-reviewer-1") == "roboco-agent-base"
def test_get_agent_image_registry_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orch.settings, "agent_image_registry", "ghcr.io/rennf93")
monkeypatch.setattr(orch.settings, "agent_image_tag", "0.5.0")
assert (
orch.get_agent_image("be-dev-1")
== "ghcr.io/rennf93/roboco-agent-dev-be:0.5.0"
)
assert (
orch.get_agent_image("pr-reviewer-1")
== "ghcr.io/rennf93/roboco-agent-base:0.5.0"
)