From 3838d64eaaa036d0b17d4a2859458f272893a22f Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 13 Jul 2026 16:37:07 +0200 Subject: [PATCH] sandbox: kitchen-sink images, feature-aware selection (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/release.yml | 1 + docker-compose.registry.yml | 8 ++ docker-compose.yml | 14 ++++ docker/sandbox-pg.Dockerfile | 22 +++++ roboco/models/sandbox.py | 27 +++++++ roboco/runtime/sandbox.py | 5 +- tests/e2e_smoke/test_sandbox_image_tags.py | 48 +++++++++-- .../unit/runtime/test_sandbox_provisioner.py | 81 +++++++++++++++++++ 8 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 docker/sandbox-pg.Dockerfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feecfb6f..c9a0da60 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 16749850..d4e7aa60 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -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 # -------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index cc662260..3c348906 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 # ========================================================================== diff --git a/docker/sandbox-pg.Dockerfile b/docker/sandbox-pg.Dockerfile new file mode 100644 index 00000000..fe2dc64c --- /dev/null +++ b/docker/sandbox-pg.Dockerfile @@ -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" \ No newline at end of file diff --git a/roboco/models/sandbox.py b/roboco/models/sandbox.py index 351ea81e..e13919ca 100644 --- a/roboco/models/sandbox.py +++ b/roboco/models/sandbox.py @@ -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 [] diff --git a/roboco/runtime/sandbox.py b/roboco/runtime/sandbox.py index 00be2eab..8c30e7c3 100644 --- a/roboco/runtime/sandbox.py +++ b/roboco/runtime/sandbox.py @@ -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: diff --git a/tests/e2e_smoke/test_sandbox_image_tags.py b/tests/e2e_smoke/test_sandbox_image_tags.py index f4d99c29..1dea1d1f 100644 --- a/tests/e2e_smoke/test_sandbox_image_tags.py +++ b/tests/e2e_smoke/test_sandbox_image_tags.py @@ -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}" ) diff --git a/tests/unit/runtime/test_sandbox_provisioner.py b/tests/unit/runtime/test_sandbox_provisioner.py index 0f15db47..c86c333d 100644 --- a/tests/unit/runtime/test_sandbox_provisioner.py +++ b/tests/unit/runtime/test_sandbox_provisioner.py @@ -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- 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