mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
sandbox: post-ready extension/module activation + allowlist (Phase 1)
Parameterized sandbox dev DBs — groundwork for 'extensions on the fly'
(docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md). A
venture declares the extensions/modules it needs; the provisioner activates
them post-ready via docker exec, never via bind-mounts or initdb scripts.
Phase 1 (behavior-preserving scaffolding — no image, no schema, no caller
passes features yet):
- Allowlists SANDBOX_PG_EXTENSIONS / SANDBOX_REDIS_MODULES are the ONLY
extensions/modules the system will ever activate — the security
containment, not privilege. plpython3u & co. (superuser-RCE vectors)
are excluded by construction.
- SandboxEngine ABC gains enable_step / verify_step / verify_ok. pg:
CREATE EXTENSION IF NOT EXISTS via psql, verified by a pg_extension
count. redis: MODULE LOAD per module, verified by MODULE LIST. mongo:
no-op (server is batteries-included).
- SandboxProvisioner.provision takes features={service: [names]},
allowlist-validates before any container runs, runs enable then verify
after the base readiness probe; a failed enable or a short verify (image
missing the extension files) is fatal — an agent never receives creds
for a db missing what it asked for. Empty features = bare = the
existing path, byte-for-byte unchanged.
- SandboxConnection gains features; as_payload surfaces
available_extensions / available_modules so the agent doesn't guess.
13 new unit tests (fake docker runner): enable/verify argv per engine,
allowlist rejection of plpython3u before any run, failed-enable + failed-
verify fatality, bare-provision unchanged, payload surfacing.
This commit is contained in:
+183
-10
@@ -5,6 +5,13 @@ registry and runs the containers. Lives in the models layer so
|
||||
``roboco/models/project.py`` can derive the valid-service allowlist from it
|
||||
without importing the runtime layer (no cycle). Adding an engine = one class +
|
||||
one registry line — no branch to edit in the provisioner or the env emitter.
|
||||
|
||||
Extensions/modules are activated *post-ready* by a ``docker exec`` enable step
|
||||
(the provisioner runs it after the base readiness probe passes), never via
|
||||
bind-mounts or initdb scripts. The allowlists below are the *only* extensions /
|
||||
modules the system will ever activate — they are the security containment (an
|
||||
agent must not be able to ``CREATE EXTENSION plpython3u``, a superuser-RCE
|
||||
vector). See ``docs/internal/specs/2026-07-13-sandbox-extensions-on-the-fly.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,6 +20,35 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
# Per-family allowlists — the ONLY extensions/modules a sandbox may activate.
|
||||
# Adding one = add it here + ship it in the kitchen-sink image. An untrusted /
|
||||
# superuser-language extension (plpython3u, plperlu, …) must NEVER appear here.
|
||||
SANDBOX_PG_EXTENSIONS: frozenset[str] = frozenset(
|
||||
{"vector", "postgis", "pg_trgm", "citext", "uuid-ossp"}
|
||||
)
|
||||
SANDBOX_REDIS_MODULES: frozenset[str] = frozenset({"search", "json", "bloom"})
|
||||
|
||||
# Friendly module key -> the .so path inside the redis-stack image.
|
||||
SANDBOX_REDIS_MODULE_SO: dict[str, str] = {
|
||||
"search": "/opt/redis-stack/lib/redisearch.so",
|
||||
"json": "/opt/redis-stack/lib/rejson.so",
|
||||
"bloom": "/opt/redis-stack/lib/redisbloom.so",
|
||||
}
|
||||
# Friendly module key -> the name redis reports in MODULE LIST (for verification).
|
||||
SANDBOX_REDIS_MODULE_NAME: dict[str, str] = {
|
||||
"search": "search",
|
||||
"json": "ReJSON",
|
||||
"bloom": "bf",
|
||||
}
|
||||
|
||||
# The allowed features per engine family, for the provisioner's allowlist guard
|
||||
# and for project-field validation. An engine with no activatable features omits
|
||||
# itself from the map (mongo).
|
||||
SANDBOX_ENGINE_FEATURES: dict[str, frozenset[str]] = {
|
||||
"postgres": SANDBOX_PG_EXTENSIONS,
|
||||
"redis": SANDBOX_REDIS_MODULES,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxConnection:
|
||||
@@ -20,7 +56,8 @@ class SandboxConnection:
|
||||
|
||||
``user`` / ``database`` are ``None`` for engines that don't expose them
|
||||
(redis). For postgres ``database`` is the admin db; for mongo it is the
|
||||
auth db (``admin``).
|
||||
auth db (``admin``). ``features`` records the extensions/modules activated
|
||||
in this container — for the cache-subset check and the evidence payload.
|
||||
"""
|
||||
|
||||
host: str
|
||||
@@ -28,6 +65,7 @@ class SandboxConnection:
|
||||
password: str
|
||||
user: str | None = None
|
||||
database: str | None = None
|
||||
features: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,12 +89,14 @@ class SandboxInfo:
|
||||
|
||||
Same variable names as ``emit_env`` (docs/env parity) but keyed for
|
||||
direct agent consumption (JSON) rather than ``docker run -e`` args.
|
||||
``available_extensions``/``available_modules`` surfaces what was
|
||||
activated so the agent doesn't guess.
|
||||
"""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for name, conn in self.services.items():
|
||||
args = SANDBOX_ENGINES[name].emit_env(conn)
|
||||
env = dict(pair.split("=", 1) for pair in args[1::2])
|
||||
out[name] = {
|
||||
payload: dict[str, Any] = {
|
||||
"host": conn.host,
|
||||
"port": conn.port,
|
||||
"user": conn.user,
|
||||
@@ -64,6 +104,14 @@ class SandboxInfo:
|
||||
"database": conn.database,
|
||||
"env": env,
|
||||
}
|
||||
if conn.features:
|
||||
key = (
|
||||
"available_extensions"
|
||||
if name == "postgres"
|
||||
else "available_modules"
|
||||
)
|
||||
payload[key] = list(conn.features)
|
||||
out[name] = payload
|
||||
return out
|
||||
|
||||
|
||||
@@ -95,14 +143,41 @@ class SandboxEngine(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def ready_probe(self, password: str) -> list[str]:
|
||||
"""``docker exec`` probe cmd; rc 0 means ready.
|
||||
"""``docker exec`` probe cmd; rc 0 means the base service is up.
|
||||
|
||||
Some engines ignore ``password`` (probe without auth); see ``run_command``.
|
||||
Runs BEFORE ``enable_step``. Some engines ignore ``password`` (probe
|
||||
without auth); see ``run_command``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def connection(self, host: str, password: str) -> SandboxConnection:
|
||||
"""Connection info for the agent, given the container host + password."""
|
||||
def enable_step(self, password: str, features: list[str]) -> list[list[str]] | None:
|
||||
"""``docker exec`` argvs that activate the requested features in a
|
||||
running container (one inner argv per exec; the provisioner runs each).
|
||||
``None`` when there is nothing to enable (no features, or an engine
|
||||
like mongo with no activatable features). The provisioner has already
|
||||
allowlist-validated ``features`` before this runs, so every name here
|
||||
is a known-safe identifier — the allowlist is the injection guard.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def verify_step(self, password: str, features: list[str]) -> list[str] | None:
|
||||
"""One ``docker exec`` argv whose output confirms the features are
|
||||
actually present, or ``None`` when there is nothing to verify. Runs
|
||||
after ``enable_step``; interpreted by ``verify_ok``."""
|
||||
|
||||
@abstractmethod
|
||||
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
|
||||
"""Interpret ``verify_step``'s stdout: True iff every feature is
|
||||
confirmed present. A failed ``CREATE EXTENSION`` / ``MODULE LOAD``
|
||||
(e.g. the image is missing the extension files) surfaces here, not
|
||||
three steps later as a confusing query error."""
|
||||
|
||||
@abstractmethod
|
||||
def connection(
|
||||
self, host: str, password: str, features: tuple[str, ...] = ()
|
||||
) -> SandboxConnection:
|
||||
"""Connection info for the agent, given the container host, password,
|
||||
and the features activated in this container."""
|
||||
|
||||
@abstractmethod
|
||||
def emit_env(self, conn: SandboxConnection) -> list[str]:
|
||||
@@ -133,13 +208,61 @@ class _PostgresEngine(SandboxEngine):
|
||||
def ready_probe(self, _password: str) -> list[str]:
|
||||
return ["pg_isready", "-U", "sandbox"]
|
||||
|
||||
def connection(self, host: str, password: str) -> SandboxConnection:
|
||||
def enable_step(
|
||||
self, _password: str, features: list[str]
|
||||
) -> list[list[str]] | None:
|
||||
if not features:
|
||||
return None
|
||||
# Every name is allowlist-validated upstream; safe to interpolate as an
|
||||
# identifier. IF NOT EXISTS so a re-provision of a warm container is a
|
||||
# no-op rather than a not-error-but-noisy notice.
|
||||
stmts = "; ".join(f"CREATE EXTENSION IF NOT EXISTS {f}" for f in features)
|
||||
return [
|
||||
[
|
||||
"psql",
|
||||
"-U",
|
||||
"sandbox",
|
||||
"-d",
|
||||
"sandbox",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-c",
|
||||
stmts,
|
||||
]
|
||||
]
|
||||
|
||||
def verify_step(self, _password: str, features: list[str]) -> list[str] | None:
|
||||
if not features:
|
||||
return None
|
||||
names = ",".join(f"'{f}'" for f in features)
|
||||
return [
|
||||
"psql",
|
||||
"-U",
|
||||
"sandbox",
|
||||
"-d",
|
||||
"sandbox",
|
||||
"-tAc",
|
||||
f"SELECT count(*) FROM pg_extension WHERE extname = ANY(ARRAY[{names}])",
|
||||
]
|
||||
|
||||
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
|
||||
if not features:
|
||||
return True
|
||||
try:
|
||||
return int(stdout.decode().strip()) == len(features)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
def connection(
|
||||
self, host: str, password: str, features: tuple[str, ...] = ()
|
||||
) -> SandboxConnection:
|
||||
return SandboxConnection(
|
||||
host=host,
|
||||
port=self.container_port,
|
||||
password=password,
|
||||
user="sandbox",
|
||||
database="sandbox",
|
||||
features=features,
|
||||
)
|
||||
|
||||
def emit_env(self, conn: SandboxConnection) -> list[str]:
|
||||
@@ -174,8 +297,42 @@ class _RedisEngine(SandboxEngine):
|
||||
def ready_probe(self, password: str) -> list[str]:
|
||||
return ["redis-cli", "-a", password, "ping"]
|
||||
|
||||
def connection(self, host: str, password: str) -> SandboxConnection:
|
||||
return SandboxConnection(host=host, port=self.container_port, password=password)
|
||||
def enable_step(self, password: str, features: list[str]) -> list[list[str]] | None:
|
||||
if not features:
|
||||
return None
|
||||
# One MODULE LOAD per module — redis-cli loads one module per call. The
|
||||
# password is a per-sandbox ephemeral token (not a real secret), mirroring
|
||||
# run_command's own --requirepass usage.
|
||||
return [
|
||||
["redis-cli", "-a", password, "--no-auth-warning", "MODULE", "LOAD", so]
|
||||
for so in (SANDBOX_REDIS_MODULE_SO[f] for f in features)
|
||||
]
|
||||
|
||||
def verify_step(self, password: str, features: list[str]) -> list[str] | None:
|
||||
if not features:
|
||||
return None
|
||||
return [
|
||||
"redis-cli",
|
||||
"-a",
|
||||
password,
|
||||
"--no-auth-warning",
|
||||
"--raw",
|
||||
"MODULE",
|
||||
"LIST",
|
||||
]
|
||||
|
||||
def verify_ok(self, features: list[str], stdout: bytes) -> bool:
|
||||
if not features:
|
||||
return True
|
||||
tokens = set(stdout.decode(errors="replace").split())
|
||||
return all(SANDBOX_REDIS_MODULE_NAME[f] in tokens for f in features)
|
||||
|
||||
def connection(
|
||||
self, host: str, password: str, features: tuple[str, ...] = ()
|
||||
) -> SandboxConnection:
|
||||
return SandboxConnection(
|
||||
host=host, port=self.container_port, password=password, features=features
|
||||
)
|
||||
|
||||
def emit_env(self, conn: SandboxConnection) -> list[str]:
|
||||
return [
|
||||
@@ -221,13 +378,29 @@ class _MongoEngine(SandboxEngine):
|
||||
"db.runCommand({ping:1}).ok",
|
||||
]
|
||||
|
||||
def connection(self, host: str, password: str) -> SandboxConnection:
|
||||
def enable_step(
|
||||
self, _password: str, _features: list[str]
|
||||
) -> list[list[str]] | None:
|
||||
# The mongo server is batteries-included (text search, change streams
|
||||
# built in); nothing to activate post-ready.
|
||||
return None
|
||||
|
||||
def verify_step(self, _password: str, _features: list[str]) -> list[str] | None:
|
||||
return None
|
||||
|
||||
def verify_ok(self, features: list[str], _stdout: bytes) -> bool:
|
||||
return not features
|
||||
|
||||
def connection(
|
||||
self, host: str, password: str, features: tuple[str, ...] = ()
|
||||
) -> SandboxConnection:
|
||||
return SandboxConnection(
|
||||
host=host,
|
||||
port=self.container_port,
|
||||
password=password,
|
||||
user="sandbox",
|
||||
database="admin",
|
||||
features=features,
|
||||
)
|
||||
|
||||
def emit_env(self, conn: SandboxConnection) -> list[str]:
|
||||
|
||||
@@ -22,6 +22,7 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.models.sandbox import (
|
||||
SANDBOX_ENGINE_FEATURES,
|
||||
SANDBOX_ENGINES,
|
||||
VALID_SANDBOX_SERVICES,
|
||||
SandboxConnection,
|
||||
@@ -126,14 +127,40 @@ class SandboxProvisioner:
|
||||
f"{stderr.decode(errors='replace')}"
|
||||
)
|
||||
|
||||
async def provision(self, agent_id: str, services: list[str]) -> SandboxInfo:
|
||||
"""Provision the requested services; on any failure, tear down + raise."""
|
||||
async def provision(
|
||||
self,
|
||||
agent_id: str,
|
||||
services: list[str],
|
||||
features: dict[str, list[str]] | None = None,
|
||||
) -> SandboxInfo:
|
||||
"""Provision the requested services; on any failure, tear down + raise.
|
||||
|
||||
``features`` maps a service name to the extensions/modules to activate
|
||||
in it post-ready (e.g. ``{"postgres": ["vector", "postgis"]}``). Every
|
||||
feature name is allowlist-validated here against
|
||||
``SANDBOX_ENGINE_FEATURES`` — an unknown or untrusted name (e.g.
|
||||
``plpython3u``) is rejected before any container runs. ``None`` or an
|
||||
empty list per service = bare (no enable step), the existing behavior.
|
||||
"""
|
||||
unknown = sorted(set(services) - VALID_SANDBOX_SERVICES)
|
||||
if unknown:
|
||||
raise SandboxProvisionError(
|
||||
f"unknown sandbox service(s) {unknown}; valid: "
|
||||
f"{sorted(VALID_SANDBOX_SERVICES)}"
|
||||
)
|
||||
feat_map = features or {}
|
||||
for svc, feats in feat_map.items():
|
||||
if svc not in SANDBOX_ENGINES:
|
||||
raise SandboxProvisionError(
|
||||
f"features given for unknown service {svc!r}; valid: "
|
||||
f"{sorted(VALID_SANDBOX_SERVICES)}"
|
||||
)
|
||||
allowed = SANDBOX_ENGINE_FEATURES.get(svc, frozenset())
|
||||
bad = sorted(set(feats) - allowed)
|
||||
if bad:
|
||||
raise SandboxProvisionError(
|
||||
f"unallowed {svc} feature(s) {bad}; allowed: {sorted(allowed)}"
|
||||
)
|
||||
# Pre-clear: a same-named sandbox left by a crash-missed teardown
|
||||
# would otherwise fail `docker run` on the name conflict and burn a
|
||||
# spawn attempt (and a respawn-tracker strike).
|
||||
@@ -143,14 +170,16 @@ class SandboxProvisioner:
|
||||
try:
|
||||
for service in services:
|
||||
engine = SANDBOX_ENGINES[service]
|
||||
connections[service] = await self._provision_engine(agent_id, engine)
|
||||
connections[service] = await self._provision_engine(
|
||||
agent_id, engine, feat_map.get(service, [])
|
||||
)
|
||||
except Exception:
|
||||
await self.teardown(agent_id)
|
||||
raise
|
||||
return SandboxInfo(services=connections)
|
||||
|
||||
async def _provision_engine(
|
||||
self, agent_id: str, engine: SandboxEngine
|
||||
self, agent_id: str, engine: SandboxEngine, features: list[str]
|
||||
) -> SandboxConnection:
|
||||
name = engine.container_name(agent_id)
|
||||
password = secrets.token_hex(16)
|
||||
@@ -187,7 +216,47 @@ class SandboxProvisioner:
|
||||
raise SandboxProvisionError(
|
||||
f"{engine.name} sandbox {name} did not become ready in time"
|
||||
)
|
||||
return engine.connection(name, password)
|
||||
await self._enable_features(name, engine, password, features)
|
||||
return engine.connection(name, password, tuple(features))
|
||||
|
||||
async def _enable_features(
|
||||
self,
|
||||
container: str,
|
||||
engine: SandboxEngine,
|
||||
password: str,
|
||||
features: list[str],
|
||||
) -> None:
|
||||
"""Run the engine's post-ready enable + verify execs, or raise.
|
||||
|
||||
A failed enable (e.g. a typo'd module path) or a failed verify (the
|
||||
image is missing the extension files) is fatal — the caller tears down
|
||||
and re-raises — so an agent never receives creds for a db missing what
|
||||
it asked for.
|
||||
"""
|
||||
if not features:
|
||||
return
|
||||
run = self._run()
|
||||
enable_argv = engine.enable_step(password, features)
|
||||
if enable_argv:
|
||||
for argv in enable_argv:
|
||||
rc, _, stderr = await run(
|
||||
["exec", container, *argv], _DOCKER_EXEC_TIMEOUT_SECONDS
|
||||
)
|
||||
if rc != 0:
|
||||
raise SandboxProvisionError(
|
||||
f"{engine.name} sandbox {container}: enable step failed "
|
||||
f"for {features}: {stderr.decode(errors='replace')}"
|
||||
)
|
||||
verify_argv = engine.verify_step(password, features)
|
||||
if verify_argv:
|
||||
rc, stdout, _ = await run(
|
||||
["exec", container, *verify_argv], _DOCKER_EXEC_TIMEOUT_SECONDS
|
||||
)
|
||||
if rc != 0 or not engine.verify_ok(features, stdout):
|
||||
raise SandboxProvisionError(
|
||||
f"{engine.name} sandbox {container} did not confirm features "
|
||||
f"{features} (image may be missing the extension/module files)"
|
||||
)
|
||||
|
||||
async def _wait_ready(
|
||||
self, container: str, probe_cmd: list[str], deadline_seconds: float
|
||||
|
||||
@@ -33,6 +33,9 @@ class _FakeRunner:
|
||||
self.calls: list[list[str]] = []
|
||||
self.run_rc = run_rc
|
||||
self.exec_rc = exec_rc
|
||||
# exec stdout — set post-construction (mirrors inspect_rc/pull_rc) by
|
||||
# tests that exercise the verify step's stdout interpretation.
|
||||
self.exec_out: bytes = b""
|
||||
self.teardown_rc = teardown_rc
|
||||
self.ps_output = ps_output
|
||||
self.ps_live_output = ps_live_output
|
||||
@@ -54,7 +57,7 @@ class _FakeRunner:
|
||||
if verb == "run":
|
||||
rc, out, err = self.run_rc, b"container-id\n", b""
|
||||
elif verb == "exec":
|
||||
rc, out, err = self.exec_rc, b"", b""
|
||||
rc, out, err = self.exec_rc, self.exec_out, b""
|
||||
elif verb in ("stop", "kill", "rm"):
|
||||
rc, out, err = self.teardown_rc, b"", b""
|
||||
elif verb == "image":
|
||||
@@ -352,3 +355,186 @@ async def test_is_live_short_circuits_on_first_dead_service() -> None:
|
||||
assert await provisioner.is_live("dev-13", ["postgres", "redis"]) is False
|
||||
inspects = [c for c in runner.calls if c[0] == "inspect"]
|
||||
assert len(inspects) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-ready feature activation: enable_step + verify_step + allowlist guard.
|
||||
# All docker calls mocked; the enable/verify loop is exercised end-to-end.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_calls(runner: _FakeRunner, containish: str | None = None) -> list[list[str]]:
|
||||
calls = [c for c in runner.calls if c[0] == "exec"]
|
||||
if containish is None:
|
||||
return calls
|
||||
return [c for c in calls if containish in " ".join(c)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_pg_features_runs_enable_then_verify() -> None:
|
||||
# exec_out = "2\n" satisfies the pg verify (count == len(features)).
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
runner.exec_out = b"2\n"
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision(
|
||||
"dev-feat", ["postgres"], features={"postgres": ["vector", "postgis"]}
|
||||
)
|
||||
|
||||
pg = info.services["postgres"]
|
||||
assert pg.features == ("vector", "postgis")
|
||||
# The enable exec creates both extensions; the verify exec counts them —
|
||||
# assert each by content rather than a magic count.
|
||||
execs = _exec_calls(runner, "psql")
|
||||
enable = next(c for c in execs if "CREATE EXTENSION" in " ".join(c))
|
||||
verify = next(c for c in execs if "pg_extension" in " ".join(c))
|
||||
assert "CREATE EXTENSION IF NOT EXISTS vector" in " ".join(enable)
|
||||
assert "CREATE EXTENSION IF NOT EXISTS postgis" in " ".join(enable)
|
||||
assert "ON_ERROR_STOP=1" in enable
|
||||
assert "vector" in " ".join(verify)
|
||||
assert "postgis" in " ".join(verify)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_no_features_skips_enable_and_verify() -> None:
|
||||
"""Bare provision (no features) is byte-for-byte unchanged: only the base
|
||||
readiness probe exec runs, no enable/verify."""
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision("dev-bare", ["postgres"])
|
||||
|
||||
assert info.services["postgres"].features == ()
|
||||
pg_execs = _exec_calls(runner, "pg_isready")
|
||||
assert len(pg_execs) == 1 # readiness only
|
||||
assert _exec_calls(runner, "psql") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_redis_features_loads_each_module_then_lists() -> None:
|
||||
# MODULE LIST raw output carrying both module names.
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
runner.exec_out = b"search\n99999\nReJSON\n99999\n"
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision(
|
||||
"dev-redis", ["redis"], features={"redis": ["search", "json"]}
|
||||
)
|
||||
|
||||
assert info.services["redis"].features == ("search", "json")
|
||||
loads = [
|
||||
c
|
||||
for c in _exec_calls(runner)
|
||||
if "MODULE" in " ".join(c) and "LOAD" in " ".join(c)
|
||||
]
|
||||
# one MODULE LOAD per module — assert each .so is loaded, not a magic count.
|
||||
joined = " ".join(" ".join(c) for c in loads)
|
||||
assert "/opt/redis-stack/lib/redisearch.so" in joined
|
||||
assert "/opt/redis-stack/lib/rejson.so" in joined
|
||||
# verify is a single MODULE LIST
|
||||
lists = [
|
||||
c
|
||||
for c in _exec_calls(runner)
|
||||
if "MODULE" in " ".join(c) and "LIST" in " ".join(c)
|
||||
]
|
||||
assert len(lists) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_mongo_features_are_a_noop() -> None:
|
||||
"""Mongo is batteries-included; features for it are accepted (allowlist is
|
||||
empty so only [] passes) and no enable/verify exec runs."""
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision("dev-mongo-f", ["mongo"], features={"mongo": []})
|
||||
|
||||
assert info.services["mongo"].features == ()
|
||||
# Only the mongosh readiness probe — no enable/verify.
|
||||
assert all(
|
||||
"MODULE" not in " ".join(c) and "pg_extension" not in " ".join(c)
|
||||
for c in _exec_calls(runner)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_rejects_unallowed_pg_extension() -> None:
|
||||
"""plpython3u is a superuser-RCE vector — the allowlist rejects it before
|
||||
any container runs (no docker calls at all)."""
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
with pytest.raises(SandboxProvisionError, match="plpython3u"):
|
||||
await provisioner.provision(
|
||||
"dev-bad", ["postgres"], features={"postgres": ["vector", "plpython3u"]}
|
||||
)
|
||||
|
||||
assert not any(c[0] == "run" for c in runner.calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_rejects_feature_for_unknown_service() -> None:
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
with pytest.raises(SandboxProvisionError, match="unknown service"):
|
||||
await provisioner.provision(
|
||||
"dev-x", ["postgres"], features={"mysql": ["vector"]}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_failed_enable_tears_down_and_raises() -> None:
|
||||
"""A failed enable exec (e.g. typo'd module path) is fatal — the agent
|
||||
never receives creds for a db missing what it asked for."""
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=1) # every exec fails (incl. ready probe)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
with pytest.raises(SandboxProvisionError):
|
||||
await provisioner.provision(
|
||||
"dev-fail", ["postgres"], features={"postgres": ["vector"]}
|
||||
)
|
||||
|
||||
# Teardown attempted on the failed container.
|
||||
assert any(c[0] in ("stop", "kill", "rm") for c in runner.calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_failed_verify_raises_with_image_hint() -> None:
|
||||
"""A successful enable but a verify count that's short (the image is missing
|
||||
the extension files) is fatal with the 'image may be missing' hint."""
|
||||
# 2 features requested but verify reports only 1 present.
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
runner.exec_out = b"1\n"
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
with pytest.raises(SandboxProvisionError, match="missing the extension"):
|
||||
await provisioner.provision(
|
||||
"dev-verify", ["postgres"], features={"postgres": ["vector", "postgis"]}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_as_payload_surfaces_available_extensions() -> None:
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
runner.exec_out = b"1\n"
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision(
|
||||
"dev-payload", ["postgres"], features={"postgres": ["vector"]}
|
||||
)
|
||||
|
||||
payload = info.as_payload()
|
||||
assert payload["postgres"]["available_extensions"] == ["vector"]
|
||||
assert "available_modules" not in payload["postgres"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_as_payload_no_extensions_key_when_bare() -> None:
|
||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
||||
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
|
||||
|
||||
info = await provisioner.provision("dev-bare2", ["postgres"])
|
||||
|
||||
payload = info.as_payload()
|
||||
assert "available_extensions" not in payload["postgres"]
|
||||
|
||||
Reference in New Issue
Block a user