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
@@ -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