sandbox: kitchen-sink images, feature-aware selection (Phase 2)

Phase 1 made the provisioner able to activate allowlisted extensions
post-ready but kept the bare upstream images. Phase 2 ships the images that
actually carry the extension/module files, and selects them only when a
venture requests features — bare sandboxes stay on the light upstream image
(no heavier pull, honoring the 'existing opters stay bare' decision).

- _PostgresEngine / _RedisEngine gain kitchen_sink_image + image_for(features):
  bare (no features) -> the light image; features requested -> the kitchen-sink
  image. The provisioner runs engine.image_for(features), not engine.image, so
  the bare path is byte-for-byte unchanged. Mongo inherits the base image_for
  (returns its image regardless — no activatable features).
- docker/sandbox-pg.Dockerfile: pgvector/pgvector:pg16 (ships vector) + postgis
  apt install; contrib (pg_trgm/citext/uuid-ossp) inherited from the official
  postgres base. Built at deploy via the sandbox-pg-image compose one-shot
  (mirrors the agent-image builders); the provisioner's _ensure_image finds the
  local tag and never pulls. Published by release.yml; pulled in registry
  compose. The verify step fails loudly if an extension's files are missing.
- _RedisEngine kitchen-sink image: redis/redis-stack-server:latest (headless;
  ships search/json/bloom as loadable-but-unloaded modules — no custom build).
- Extended the sandbox image-tag ghost-tag guard (the mongo:8-alpine regression
  test) to also cover kitchen_sink_image: skips locally-built roboco-* images,
  uses the namespaced Docker Hub endpoint for redis/redis-stack-server.

Image-specific package names / module .so paths are verified at the CEO's NAS
deploy (the spec's NAS smoke); the unit tests with the fake runner remain the
CI bar, and the verify step is the fail-loud safety net for a wrong build.
This commit is contained in:
Renn F
2026-07-13 20:05:45 +02:00
committed by Renzo F
parent b015cde9ad
commit 3838d64eaa
8 changed files with 196 additions and 10 deletions
+1
View File
@@ -101,6 +101,7 @@ jobs:
[roboco-orchestrator]=docker/orchestrator.Dockerfile
[roboco-panel]=docker/panel.Dockerfile
[roboco-video-renderer]=docker/video-renderer.Dockerfile
[roboco-sandbox-pg]=docker/sandbox-pg.Dockerfile
[roboco-agent-pm]=docker/agent-pm.Dockerfile
[roboco-agent-dev-be]=docker/agent-dev-be.Dockerfile
[roboco-agent-dev-fe]=docker/agent-dev-fe.Dockerfile
+8
View File
@@ -243,6 +243,14 @@ services:
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-secretary image present'"]
restart: "no"
# Sandbox PG (kitchen-sink) — pulled by the provisioner when a venture opts
# into pg extensions. Bare sandboxes use the upstream postgres image, so this
# is only needed by extension-using projects.
sandbox-pg-image:
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-sandbox-pg:${ROBOCO_VERSION:-latest}
entrypoint: ["/bin/sh", "-c", "echo 'sandbox-pg image present'"]
restart: "no"
# --------------------------------------------------------------------------
# Orchestrator — API server + agent spawner
# --------------------------------------------------------------------------
+14
View File
@@ -383,6 +383,20 @@ services:
depends_on:
- agent-grok-image
# ==========================================================================
# Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs)
# Only pulled by the provisioner when a venture requests pg extensions; bare
# sandboxes stay on the light upstream postgres image. See
# docker/sandbox-pg.Dockerfile + docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md
# ==========================================================================
sandbox-pg-image:
build:
context: .
dockerfile: docker/sandbox-pg.Dockerfile
image: roboco-sandbox-pg
entrypoint: ["/bin/sh", "-c", "echo 'Sandbox PG (kitchen-sink) image built'"]
restart: "no"
# ==========================================================================
# Orchestrator - API Server + Agent Spawner
# ==========================================================================
+22
View File
@@ -0,0 +1,22 @@
# Sandbox kitchen-sink postgres: every allowlisted pg extension present so any
# requested subset can be activated post-ready by the provisioner's
# `CREATE EXTENSION IF NOT EXISTS` enable step. Built at deploy time like the
# agent images (docker-compose `sandbox-pg-image` one-shot); the provisioner's
# `_ensure_image` finds the local tag via `image inspect` and never pulls.
#
# Base is the Debian-flavored pgvector image (ships `vector`), which inherits
# `postgresql-contrib` from the official postgres image — so pg_trgm / citext /
# uuid-ossp control files + libs are already present. PostGIS is the one
# extension not in contrib, so it is the only install. The provisioner's verify
# step (pg_extension count) fails loudly if an extension's files are missing,
# so a bad build surfaces at first provision, not as a silent query error.
# See docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md.
FROM pgvector/pgvector:pg16
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-16-postgis-3 \
&& rm -rf /var/lib/apt/lists/*
LABEL org.opencontainers.image.title="RoboCo sandbox postgres (kitchen-sink)"
LABEL org.opencontainers.image.description="PostgreSQL 16 + vector + postgis + contrib for parameterized sandbox dev DBs"
+27
View File
@@ -128,6 +128,18 @@ class SandboxEngine(ABC):
def container_name(self, agent_id: str) -> str:
return f"roboco-sandbox-{self.container_slug}-{agent_id}"
def image_for(self, _features: list[str]) -> str:
"""Image to run for this provision, given the features requested.
Default: the bare ``image`` for every provision — bare projects pull
only the light upstream image (no heavier kitchen-sink pull). An engine
whose features need files the bare image lacks (pg extensions, redis
modules) overrides this to return the kitchen-sink image when features
is non-empty. The provisioner calls this (not ``image`` directly) so the
bare path stays byte-for-byte unchanged.
"""
return self.image
@abstractmethod
def run_env(self, password: str) -> list[str]:
"""``-e KEY=VAL`` pairs baked into the sandbox container's ``docker run``."""
@@ -187,11 +199,18 @@ class SandboxEngine(ABC):
class _PostgresEngine(SandboxEngine):
name = "postgres"
image = "postgres:16-alpine"
# Kitchen-sink image: pgvector base + postgis + contrib (pg_trgm/citext/
# uuid-ossp). Only pulled when a venture requests extensions — bare
# provisions stay on the light `image` above (no heavier pull).
kitchen_sink_image = "roboco-sandbox-pg:latest"
container_port = 5432
ready_deadline = 60.0
tmpfs = ("/var/lib/postgresql/data",)
container_slug = "pg"
def image_for(self, features: list[str]) -> str:
return self.kitchen_sink_image if features else self.image
def run_env(self, password: str) -> list[str]:
return [
"-e",
@@ -283,11 +302,19 @@ class _PostgresEngine(SandboxEngine):
class _RedisEngine(SandboxEngine):
name = "redis"
image = "redis:8-alpine"
# redis-stack-server ships search/json/bloom as loadable-but-unloaded
# modules — no custom build. Headless (-server) variant: no RedisInsight
# web UI, appropriate for an ephemeral dev sandbox. Only pulled when a
# venture requests modules; bare provisions stay on the light `image`.
kitchen_sink_image = "redis/redis-stack-server:latest"
container_port = 6379
ready_deadline = 15.0
tmpfs: tuple[str, ...] = ()
container_slug = "redis"
def image_for(self, features: list[str]) -> str:
return self.kitchen_sink_image if features else self.image
def run_env(self, _password: str) -> list[str]:
return []
+3 -2
View File
@@ -184,7 +184,8 @@ class SandboxProvisioner:
name = engine.container_name(agent_id)
password = secrets.token_hex(16)
run = self._run()
await self._ensure_image(engine.image)
image = engine.image_for(features)
await self._ensure_image(image)
args = [
"run",
"-d",
@@ -201,7 +202,7 @@ class SandboxProvisioner:
args += ["--tmpfs", mount]
args += ["--memory", "512m", "--cpus", "1"]
args += engine.run_env(password)
args.append(engine.image)
args.append(image)
args += engine.run_command(password)
rc, _, stderr = await run(args, _DOCKER_RUN_TIMEOUT_SECONDS)
if rc != 0:
+40 -8
View File
@@ -5,8 +5,15 @@ a tag that has never existed on Docker Hub (MongoDB ships no Alpine variant).
Every unit test mocks the docker CLI, so none of them ever touch a real
registry and none caught it — the bug only surfaces the moment a real
``docker run`` pulls the image. This test queries the Docker Hub registry API
for every ``SANDBOX_ENGINES`` entry's pinned ``image:tag`` and fails if the
tag does not actually exist, which is the check that would have caught it.
for every upstream image a sandbox engine may run (the bare ``image`` AND any
``kitchen_sink_image`` used when extensions/modules are requested) and fails if
the tag does not actually exist — the check that would have caught it.
Locally-built images (the ``roboco-sandbox-pg`` kitchen-sink, built by the
``sandbox-pg-image`` compose service) are skipped: they aren't on Docker Hub.
Namespaced upstream images (``redis/redis-stack-server``) use the namespaced
registry endpoint; bare library images (``postgres``, ``redis``, ``mongo``)
use the ``library/`` endpoint.
Network-dependent by design; skips cleanly when the registry is unreachable
rather than failing (mirrors ``test_background_engines.py``'s local-Redis
@@ -19,9 +26,12 @@ import httpx
import pytest
from roboco.models.sandbox import SANDBOX_ENGINES
_REGISTRY_URL = (
# Docker Hub registry endpoints — library images live under ``library/``;
# namespaced images (e.g. ``redis/redis-stack-server``) live under their ns.
_REGISTRY_URL_LIBRARY = (
"https://registry.hub.docker.com/v2/repositories/library/{name}/tags/{tag}"
)
_REGISTRY_URL_NS = "https://registry.hub.docker.com/v2/repositories/{name}/tags/{tag}"
_TIMEOUT_SECONDS = 10.0
_HTTP_OK = 200
@@ -31,11 +41,33 @@ def _split_image(image: str) -> tuple[str, str]:
return name, tag or "latest"
@pytest.mark.parametrize("engine_name", sorted(SANDBOX_ENGINES))
def test_sandbox_engine_image_tag_exists_on_docker_hub(engine_name: str) -> None:
image = SANDBOX_ENGINES[engine_name].image
def _engine_images() -> list[tuple[str, str]]:
"""(engine_name, image) pairs for every upstream image an engine may run."""
pairs: list[tuple[str, str]] = []
for name, engine in sorted(SANDBOX_ENGINES.items()):
pairs.append((name, engine.image))
kitchen = getattr(engine, "kitchen_sink_image", None)
if kitchen:
pairs.append((name, kitchen))
return pairs
@pytest.mark.parametrize(
"engine_name,image",
_engine_images(),
ids=[f"{n}={img}" for n, img in _engine_images()],
)
def test_sandbox_engine_image_tag_exists_on_docker_hub(
engine_name: str, image: str
) -> None:
name, tag = _split_image(image)
url = _REGISTRY_URL.format(name=name, tag=tag)
# Locally-built project images (no registry namespace, roboco- prefix) are
# built by a compose service, not pulled — skip the Docker Hub check.
if "/" not in name and name.startswith("roboco-"):
pytest.skip(f"{image!r} is a locally-built image (not on Docker Hub)")
url = (_REGISTRY_URL_NS if "/" in name else _REGISTRY_URL_LIBRARY).format(
name=name, tag=tag
)
try:
resp = httpx.get(url, timeout=_TIMEOUT_SECONDS)
@@ -44,5 +76,5 @@ def test_sandbox_engine_image_tag_exists_on_docker_hub(engine_name: str) -> None
assert resp.status_code == _HTTP_OK, (
f"{engine_name}: pinned image {image!r} not found on Docker Hub "
f"(library/{name}, tag {tag!r}, status {resp.status_code}) — {url}"
f"({name}, tag {tag!r}, status {resp.status_code}) — {url}"
)
@@ -457,6 +457,87 @@ async def test_provision_mongo_features_are_a_noop() -> None:
)
# ---------------------------------------------------------------------------
# Feature-aware image selection: kitchen-sink image only when features are
# requested, bare provisions stay on the light upstream image (no heavier pull).
# ---------------------------------------------------------------------------
def _run_call(runner: _FakeRunner) -> list[str]:
runs = [c for c in runner.calls if c[0] == "run"]
assert runs, "no docker run call recorded"
return runs[0]
@pytest.mark.asyncio
async def test_provision_pg_features_uses_kitchen_sink_image() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"1\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision(
"dev-pgimg", ["postgres"], features={"postgres": ["vector"]}
)
joined = " ".join(_run_call(runner))
assert "roboco-sandbox-pg:latest" in joined
assert "postgres:16-alpine" not in joined
@pytest.mark.asyncio
async def test_provision_pg_bare_uses_light_image() -> None:
"""Bare pg (no extensions) stays on the light upstream image — no heavier pull."""
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-pgbare", ["postgres"])
joined = " ".join(_run_call(runner))
assert "postgres:16-alpine" in joined
# The container is named roboco-sandbox-pg-<agent> regardless of image, so
# check the full image ref (with tag) — the name carries no :latest.
assert "roboco-sandbox-pg:latest" not in joined
@pytest.mark.asyncio
async def test_provision_redis_features_uses_redis_stack_server() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
runner.exec_out = b"search\n99999\n"
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision(
"dev-redisimg", ["redis"], features={"redis": ["search"]}
)
joined = " ".join(_run_call(runner))
assert "redis/redis-stack-server:latest" in joined
assert "redis:8-alpine" not in joined
@pytest.mark.asyncio
async def test_provision_redis_bare_uses_light_image() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-redisbare", ["redis"])
joined = " ".join(_run_call(runner))
assert "redis:8-alpine" in joined
assert "redis/redis-stack-server:latest" not in joined
def test_engine_image_for_selects_kitchen_sink_iff_features() -> None:
"""Pure unit check on the registry: bare -> light image, features -> kitchen-sink;
mongo (no activatable features) ignores features and returns its base image."""
pg = sandbox_module.SANDBOX_ENGINES["postgres"]
assert pg.image_for([]) == "postgres:16-alpine"
assert pg.image_for(["vector"]) == "roboco-sandbox-pg:latest"
redis = sandbox_module.SANDBOX_ENGINES["redis"]
assert redis.image_for([]) == "redis:8-alpine"
assert redis.image_for(["search"]) == "redis/redis-stack-server:latest"
assert sandbox_module.SANDBOX_ENGINES["mongo"].image_for(["anything"]) == "mongo:8"
@pytest.mark.asyncio
async def test_provision_rejects_unallowed_pg_extension() -> None:
"""plpython3u is a superuser-RCE vector — the allowlist rejects it before