deer-flow/backend/tests/test_provisioner_request_threading.py
Storm b9a75b869d
fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes (#6436)
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes

Why this change:
Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker
capability probe collapsed "provisioner unreachable" into "broker not
configured": one 1.5s timeout cached a negative for 300s, so new sandboxes
were created without the broker sidecar while the per-user credential
directories were still bind-mounted into the container, and existing Pods
kept being reused without their actual mode ever being read. Second, an
implicit-shell command that timed out or returned an unknown status only
marked the current client object; warm-pool reclaim and rediscovery built a
fresh client that re-entered a container whose shell state could not be
proven clean, and nothing about that ambiguity survived a Gateway restart.

What changed:
- The capability cache stores confirmed observations only, keyed per
  provisioner endpoint/auth identity; an unknown answer (timeout, malformed
  payload) raises instead of selecting credential placement, while a
  confirmed broker requirement is retained through outages. One in-flight
  probe per identity shares success and failure, and a Pod-attested mode
  contradicting the cache forces one shared refresh.
- Execution uses the mode attested for the actual sandbox:
  SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod
  observation into the client, so a probe outage delays drift detection but
  never denies reuse of an attested runtime, and never trusts an unattested
  one.
- The provisioner reports each Pod's actual broker mode, generation (Pod
  UID) and shell capacity from one read, rejects lark requests conflicting
  with its configuration (409/503 carrying a capability-refresh marker the
  Gateway uses to drop the stale cache entry), rejects conflicting or
  Terminating Pods on coordination creates while reporting Terminating as
  data to status/get/list, and answers Pod presence independently of
  Service readiness.
- A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by
  scope/sandbox/generation) fences containers after uncertain implicit
  shell outcomes across client rebuilds and restarts; in-process reuse,
  warm reclaim, discovery, create responses and orphan reconciliation all
  pass one policy gate, and replacement runs under the existing ownership
  and teardown fences. A fenced sandbox still tracked after a failed
  release-recycle is destroyed inline on the next acquire.
- Quarantine records retire only after confirmed absence, retrying partial
  cleanup for the quarantined generation and releasing remembered ports
  under the same fences; local stop failures now propagate instead of being
  logged away, and remote destroy errors surface to callers.

Problem solved:
A provisioner blip could silently strip broker isolation from new lark
sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted
default shells persist across warm reuse, rediscovery and restarts. Docs
updated: README, aio_sandbox and skills AGENTS.md.

* docs(changelog): record sandbox attestation and quarantine fix (#6436)

* fix(sandbox): preserve active leases and fail closed on unattested broker modes

Concurrent uploads could recycle a healthy AIO runtime while another holder
was creating or cleaning up a command session. Older provisioner responses
could also trigger destructive replacement before failing admission.

- Distinguish pending session creation from ambiguous outcomes and reserve
  idle teardown until execution and upload holders finish scope cleanup.
- Reject stale fork lookups and reconcile cancelled acquires without
  releasing a sandbox still borrowed by another holder.
- Default Lark broker mode to unverified and preserve unattested Pods until
  the provisioner is upgraded.
- Recognize Docker 29 absence diagnostics while matching resource names exactly.
- Preserve Gateway upload errors and cover admission and lifecycle regressions.

* fix(sandbox): scope the unverified-broker refuse path to attested providers

The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox
without a lark_cli_broker attribute as unverified, but only AioSandbox
carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no
broker sidecar, no credential mounts, and no provisioner to upgrade, so
every lark-cli command on those providers failed with an unactionable
error.

Declare the tri-state on the Sandbox base class (defaulting to False,
the only mode brokerless providers have) so the None fail-closed path
stays scoped to AioSandbox's deliberate unverified default.

- Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring
  the persistent_shell_sessions tri-state pattern
- Add a regression test pinning the non-broker overlay for a provider
  without the attribute

* fix(sandbox): close review gaps in broker attestation and teardown lifecycle

Address the latest #6436 review round:

- The provisioner's post-build broker-mode 409 now carries the
  capability-refresh marker, so an incompatible-mode conflict surfaced by
  idempotent re-create, attestation drift or post-create verification drops
  the Gateway's cached capability observation instead of repeating the same
  409 for the full negative-cache TTL.
- AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False
  when teardown is deferred because an execution/upload holder or a peer
  teardown is still active, so callers can no longer mistake a deferred
  destroy for a completed one.
- A live replacement under the same logical ID proves the fenced old
  generations are absent; retire_replaced_generations prunes only those
  stale records so they cannot strand future creates, while the live
  generation's fence and unversioned crash fences are never touched.
- SandboxInfo.from_dict keeps a missing lark_cli_broker attestation
  unverified (None) instead of silently rehydrating as attested non-broker.
- The restricted-network inspect path shares the strict
  _is_no_such_network_error parser, closing the substring loophole for
  context-name collisions and wrong-target errors.

* docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate

The three lease notes added in 26c307f3 pushed the effective guidance chain
to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance
(AG002). Fold them into two lines carrying the same rules.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-10 07:56:09 +08:00

763 lines
33 KiB
Python

"""Regression tests for provisioner request-path K8s IO threading."""
from __future__ import annotations
import asyncio
import inspect
import json
import threading
import time
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import requests
from blockbuster import BlockBuster
from kubernetes.client.rest import ApiException
def test_presence_checks_pod_without_requiring_service_or_access_url(monkeypatch, provisioner_module):
core = MagicMock()
core.read_namespaced_pod.return_value = SimpleNamespace(metadata=SimpleNamespace(uid="pod-generation"), status=SimpleNamespace(phase="Pending"))
monkeypatch.setattr(provisioner_module, "core_v1", core)
access_url = MagicMock(side_effect=AssertionError("Service readiness cannot attest Pod absence"))
monkeypatch.setattr(provisioner_module, "_sandbox_access_url", access_url)
assert provisioner_module.sandbox_presence("sandbox-id").model_dump() == {"sandbox_id": "sandbox-id", "exists": True, "container_id": "pod-generation"}
access_url.assert_not_called()
core.read_namespaced_pod.assert_called_once_with("sandbox-sandbox-id", provisioner_module.K8S_NAMESPACE)
@pytest.mark.parametrize("status", [404, 403, 503])
def test_presence_only_attests_absence_on_kubernetes_not_found(monkeypatch, provisioner_module, status):
core = MagicMock()
core.read_namespaced_pod.side_effect = ApiException(status=status)
monkeypatch.setattr(provisioner_module, "core_v1", core)
if status == 404:
assert provisioner_module.sandbox_presence("sandbox-id").model_dump() == {"sandbox_id": "sandbox-id", "exists": False, "container_id": None}
else:
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.sandbox_presence("sandbox-id")
assert error.value.status_code == 500
def test_provisioner_thread_id_pattern_matches_gateway_contract(provisioner_module) -> None:
from deerflow.utils.thread_id import THREAD_ID_PATTERN
assert provisioner_module.SAFE_THREAD_ID_PATTERN == THREAD_ID_PATTERN
@pytest.mark.parametrize("thread_id", ["", "thread.with.dot", "../escape", "x" * 65])
def test_provisioner_rejects_noncanonical_thread_ids(provisioner_module, thread_id: str) -> None:
from pydantic import ValidationError
with pytest.raises(ValidationError):
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-validation",
thread_id=thread_id,
)
@pytest.mark.parametrize("thread_id", ["a", "A1_b-2", "x" * 64])
def test_provisioner_accepts_canonical_thread_ids(provisioner_module, thread_id: str) -> None:
request = provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-validation",
thread_id=thread_id,
)
assert request.thread_id == thread_id
def test_provisioner_request_defaults_skills_container_path(provisioner_module) -> None:
request = provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-validation",
)
assert request.skills_container_path == "/mnt/skills"
assert request.max_shell_sessions is None
def test_provisioner_threads_shell_capacity_into_sandbox_pod(
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(
event_loop_thread_id=-1,
ready_after_service_reads={"sandbox-capacity": 1},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
response = provisioner_module.create_sandbox(
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-capacity",
thread_id="thread-1",
max_shell_sessions=13,
)
)
assert response.status == "Running"
pod = fake_core_v1.created_pod_specs["sandbox-capacity"]
env = {item.name: item.value for item in (pod.spec.containers[0].env or [])}
assert env["MAX_SHELL_SESSIONS"] == "13"
def test_broker_request_without_image_cannot_mount_credentials(monkeypatch, provisioner_module):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
monkeypatch.setattr(provisioner_module, "LARK_CLI_BROKER_IMAGE", "")
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="new-sandbox", provision_lark_cli_broker=True))
assert error.value.status_code == 503
assert core.created_pods == []
def test_broker_request_cannot_reuse_non_broker_pod(monkeypatch, provisioner_module):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
monkeypatch.setattr(provisioner_module, "LARK_CLI_BROKER_IMAGE", "broker-image")
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", provision_lark_cli_broker=True))
assert error.value.status_code == 409
assert core.created_pods == []
assert "sandbox-existing" in core.service_sandboxes
def test_stale_negative_capability_cannot_bypass_configured_broker(monkeypatch, provisioner_module):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
monkeypatch.setattr(provisioner_module, "LARK_CLI_BROKER_IMAGE", "broker-image")
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="new-sandbox", provision_lark_cli_runtime=True, provision_lark_cli_broker=False))
assert error.value.status_code == 409
assert core.created_pods == []
def test_provisioner_rejects_insufficient_capacity_without_replacing_existing_pod(
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-existing",
thread_id="thread-1",
max_shell_sessions=13,
)
)
assert error.value.status_code == 409
assert "capacity" in error.value.detail
assert fake_core_v1.created_pods == []
assert fake_core_v1.pod_shell_capacities["sandbox-existing"] == 10
assert "sandbox-existing" in fake_core_v1.service_sandboxes
def test_capacity_replacement_recovers_when_service_outlives_old_pod(monkeypatch, provisioner_module):
"""A create racing asynchronous deletion can leave a Service without a Pod."""
core = _RecordingCoreV1(event_loop_thread_id=-1)
core.pod_shell_capacities.pop("sandbox-existing")
monkeypatch.setattr(provisioner_module, "core_v1", core)
def existing_service(*_args):
raise ApiException(status=409)
monkeypatch.setattr(core, "create_namespaced_service", existing_service)
result = provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", thread_id="thread-a", max_shell_sessions=13))
assert result.max_shell_sessions == 13
assert core.created_pods == ["sandbox-existing"]
assert "sandbox-existing" in core.service_sandboxes
@pytest.mark.parametrize("failure", [ApiException(status=403), ApiException(status=503), RuntimeError("invalid persisted capacity")])
def test_capacity_read_failure_does_not_authorize_creation(monkeypatch, provisioner_module, failure):
core = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", core)
monkeypatch.setattr(provisioner_module, "_pod_shell_capacity", MagicMock(side_effect=failure))
with pytest.raises(provisioner_module.HTTPException) as error:
provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(sandbox_id="sandbox-existing", max_shell_sessions=13))
assert error.value.status_code == 500
assert core.created_pods == []
assert core.pod_shell_capacities["sandbox-existing"] == 10
assert "sandbox-existing" in core.service_sandboxes
def test_list_sandboxes_skips_invalid_capacity_metadata(
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
def invalid_capacity(_sandbox_id: str) -> int:
raise RuntimeError("invalid capacity")
monkeypatch.setattr(
provisioner_module,
"_pod_shell_capacity",
invalid_capacity,
)
response = provisioner_module.list_sandboxes()
assert response == {"sandboxes": [], "count": 0}
@pytest.mark.asyncio
@pytest.mark.parametrize("async_acquire", [False, True], ids=["sync", "async"])
async def test_capacity_upgrade_preserves_peer_pod_after_discovery_error(monkeypatch, tmp_path, provisioner_module, async_acquire):
"""Discovery failure must not bypass ownership; a later safe retry replaces."""
from test_sandbox_orphan_reconciliation import _make_provider_for_reconciliation, _make_shared_ownership_store
from deerflow.community.aio_sandbox import aio_sandbox_provider as provider_mod
from deerflow.community.aio_sandbox import remote_backend as remote_mod
from deerflow.community.aio_sandbox.ownership import compute_lease_ttl
from deerflow.config.paths import Paths
shared = _make_shared_ownership_store()
old = _make_provider_for_reconciliation(worker_id="old-gateway", store=shared)
new = _make_provider_for_reconciliation(worker_id="new-gateway", store=shared)
sid = "sandbox-existing"
old._publish_ownership(sid)
new._backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002", max_shell_sessions=13)
core = _RecordingCoreV1(event_loop_thread_id=threading.get_ident())
monkeypatch.setattr(provisioner_module, "core_v1", core)
deleted_under_lease = []
discovery_failed = False
def response(status, payload):
result = requests.Response()
result.status_code = status
result._content = json.dumps(payload).encode()
return result
def get(_url, **_kwargs):
nonlocal discovery_failed
if not discovery_failed:
discovery_failed = True
return response(503, {"detail": "temporarily unavailable"})
return response(200, provisioner_module.get_sandbox(sid).model_dump())
def post(_url, *, json, **_kwargs):
try:
result = provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(**json))
except provisioner_module.HTTPException as exc:
return response(exc.status_code, {"detail": exc.detail})
return response(200, result.model_dump())
def delete(_url, **_kwargs):
deleted_under_lease.append((shared.owner(sid), old._ownership.claim(sid)))
return response(200, provisioner_module.destroy_sandbox(sid))
monkeypatch.setattr(requests, "get", get)
monkeypatch.setattr(requests, "post", post)
monkeypatch.setattr(requests, "delete", delete)
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda _uid: False)
monkeypatch.setattr(provider_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready", lambda *_a, **_kw: True)
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready_async", AsyncMock(return_value=True))
monkeypatch.setattr(provider_mod, "AioSandbox", MagicMock())
monkeypatch.setattr(new, "_get_extra_mounts", lambda *_a, **_kw: [])
monkeypatch.setattr(new, "_lark_integration_active", lambda *_a: False)
monkeypatch.setattr(new, "_lark_broker_active", lambda *_a: False)
async def acquire():
if async_acquire:
return await new._discover_or_create_with_lock_async("thread-a", sid, user_id="user-a")
return await asyncio.to_thread(new._discover_or_create_with_lock, "thread-a", sid, user_id="user-a")
try:
with pytest.raises(RuntimeError, match="409"):
await acquire()
assert core.pod_shell_capacities[sid] == 10
assert core.created_pods == []
assert sid in core.service_sandboxes
assert shared.owner(sid) == "old-gateway"
with pytest.raises(provider_mod.SandboxPolicyReplacementDeferredError):
await acquire()
old._ownership.release(sid)
with pytest.raises(provider_mod.SandboxPolicyReplacementDeferredError):
await acquire()
assert deleted_under_lease == []
new._unowned_since[sid] = time.time() - compute_lease_ttl(new._ownership_config) - 1
assert await acquire() == sid
assert deleted_under_lease == [("new-gateway", False)]
assert core.created_pods == [sid]
assert core.pod_shell_capacities[sid] == 13
assert shared.owner(sid) == "new-gateway"
finally:
old._acquire_serializer.close()
new._acquire_serializer.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("async_acquire", [False, True], ids=["sync", "async"])
async def test_quarantined_pod_cleanup_recovers_after_partial_delete(monkeypatch, tmp_path, provisioner_module, async_acquire):
"""A lost Service must not strand the fenced Pod after a temporary delete failure."""
from test_sandbox_orphan_reconciliation import _make_provider_for_reconciliation
from deerflow.community.aio_sandbox import aio_sandbox_provider as provider_mod
from deerflow.community.aio_sandbox import remote_backend as remote_mod
from deerflow.community.aio_sandbox.quarantine import SandboxQuarantine
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
from deerflow.config.paths import Paths
provider = _make_provider_for_reconciliation()
sid = "sandbox-existing"
core = _RecordingCoreV1(event_loop_thread_id=threading.get_ident())
old_info = SandboxInfo(sid, "http://sandbox", container_id="old-pod")
provider._backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002")
provider._quarantine = SandboxQuarantine(tmp_path / "quarantine", "provisioner")
sandbox = MagicMock()
sandbox.requires_container_recycle = True
provider._sandboxes[sid] = sandbox
provider._sandbox_infos[sid] = old_info
provider._thread_sandboxes[("user-a", "thread-a")] = sid
provider._active_sandbox_identity[sid] = ("user-a", "thread-a")
provider._publish_ownership(sid)
monkeypatch.setattr(provisioner_module, "core_v1", core)
monkeypatch.setattr(provider_mod, "get_paths", lambda: Paths(base_dir=tmp_path / "state"))
monkeypatch.setattr(provider, "_ensure_skills_projection", lambda *_args: None)
monkeypatch.setattr(provider, "_thread_skill_projection_active", lambda *_args: False)
monkeypatch.setattr(provider, "_sandbox_id_for_thread", lambda *_args: sid)
monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_args, **_kwargs: [])
monkeypatch.setattr(provider, "_lark_integration_active", lambda *_args: False)
monkeypatch.setattr(provider, "_lark_broker_active", lambda *_args: False)
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready", lambda *_args, **_kwargs: True)
monkeypatch.setattr(provider_mod, "wait_for_sandbox_ready_async", AsyncMock(return_value=True))
monkeypatch.setattr(provider_mod, "AioSandbox", MagicMock())
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda _uid: False)
original_read_pod = core.read_namespaced_pod
original_delete_pod = core.delete_namespaced_pod
deleted_under_fence = []
def read_pod(*args):
pod = original_read_pod(*args)
if not core.created_pods:
pod.metadata.uid = old_info.container_id
return pod
def delete_pod(*args):
deleted_under_fence.append((provider._ownership.owner(sid), sid in provider._local_teardown))
if len(deleted_under_fence) == 1:
raise ApiException(status=503, reason="temporary Kubernetes outage")
original_delete_pod(*args)
def response(status, payload):
result = requests.Response()
result.status_code = status
result._content = json.dumps(payload).encode()
return result
def get(url, **_kwargs):
try:
if url.endswith("/presence"):
payload = provisioner_module.sandbox_presence(sid).model_dump()
elif url.endswith("/api/sandboxes"):
payload = provisioner_module.list_sandboxes()
else:
payload = provisioner_module.get_sandbox(sid).model_dump()
return response(200, payload)
except provisioner_module.HTTPException as exc:
return response(exc.status_code, {"detail": exc.detail})
def post(_url, *, json, **_kwargs):
try:
result = provisioner_module.create_sandbox(provisioner_module.CreateSandboxRequest(**json))
return response(200, result.model_dump())
except provisioner_module.HTTPException as exc:
return response(exc.status_code, {"detail": exc.detail})
def delete(_url, **_kwargs):
try:
return response(200, provisioner_module.destroy_sandbox(sid))
except provisioner_module.HTTPException as exc:
return response(exc.status_code, {"detail": exc.detail})
monkeypatch.setattr(core, "read_namespaced_pod", read_pod)
monkeypatch.setattr(core, "delete_namespaced_pod", delete_pod)
monkeypatch.setattr(core, "list_namespaced_service", lambda *_args, **_kwargs: SimpleNamespace(items=[_node_port_service(item) for item in core.service_sandboxes]))
monkeypatch.setattr(requests, "get", get)
monkeypatch.setattr(requests, "post", post)
monkeypatch.setattr(requests, "delete", delete)
try:
await asyncio.to_thread(provider.release, sid)
assert sid not in core.service_sandboxes
assert sid in core.pod_shell_capacities
assert provider._quarantine.contains(old_info)
await asyncio.to_thread(provider._reconcile_orphans)
if async_acquire:
acquired = await provider.acquire_async("thread-a", user_id="user-a")
else:
acquired = await asyncio.to_thread(provider.acquire, "thread-a", user_id="user-a")
assert acquired == sid
assert len(deleted_under_fence) == 2
assert deleted_under_fence == [(provider._owner_id, True)] * 2
assert sid in core.service_sandboxes
assert provider._sandbox_infos[sid].container_id != old_info.container_id
assert not provider._quarantine.contains(old_info)
finally:
provider.reset()
provider._ownership.close()
class _RecordingCoreV1:
def __init__(
self,
*,
event_loop_thread_id: int,
ready_after_service_reads: dict[str, int] | None = None,
service_read_failures: dict[str, list[int]] | None = None,
) -> None:
self.event_loop_thread_id = event_loop_thread_id
self.thread_ids: list[int] = []
self.service_sandboxes: set[str] = {"sandbox-existing"}
self.pod_shell_capacities: dict[str, int] = {
"sandbox-existing": 10,
"sandbox-listed": 10,
}
self.ready_after_service_reads = ready_after_service_reads or {}
self.service_read_failures = service_read_failures or {}
self.service_read_counts: dict[str, int] = {}
self.created_pods: list[str] = []
self.created_pod_specs: dict[str, object] = {}
self.created_services: list[str] = []
def _record_k8s_call(self) -> None:
thread_id = threading.get_ident()
self.thread_ids.append(thread_id)
time.sleep(0)
if thread_id == self.event_loop_thread_id:
raise AssertionError("Kubernetes client call ran on the ASGI event-loop thread")
try:
asyncio.get_running_loop()
except RuntimeError:
return
raise AssertionError("Kubernetes client call ran inside an asyncio event loop")
def read_namespaced_service(self, _name: str, _namespace: str):
self._record_k8s_call()
sandbox_id = _sandbox_id_from_service_name(_name)
self.service_read_counts[sandbox_id] = self.service_read_counts.get(sandbox_id, 0) + 1
failures = self.service_read_failures.get(sandbox_id) or []
if failures:
raise ApiException(status=failures.pop(0))
ready_after_reads = self.ready_after_service_reads.get(sandbox_id, 1)
if sandbox_id not in self.service_sandboxes or self.service_read_counts[sandbox_id] < ready_after_reads:
raise ApiException(status=404)
return _node_port_service(sandbox_id)
def read_namespaced_pod(self, _name: str, _namespace: str):
self._record_k8s_call()
sandbox_id = _name[len("sandbox-") :]
capacity = self.pod_shell_capacities.get(sandbox_id)
if capacity is None:
raise ApiException(status=404)
return SimpleNamespace(
metadata=SimpleNamespace(uid=f"pod-{sandbox_id}-{len(self.created_pods)}", deletion_timestamp=None),
status=SimpleNamespace(phase="Running"),
spec=self.created_pod_specs[sandbox_id].spec
if sandbox_id in self.created_pod_specs
else SimpleNamespace(
containers=[
SimpleNamespace(
name="sandbox",
env=[SimpleNamespace(name="MAX_SHELL_SESSIONS", value=str(capacity))],
)
]
),
)
def create_namespaced_pod(self, _namespace: str, pod) -> None:
self._record_k8s_call()
sandbox_id = pod.metadata.labels["sandbox-id"]
self.created_pods.append(sandbox_id)
self.created_pod_specs[sandbox_id] = pod
env = {item.name: item.value for item in (pod.spec.containers[0].env or [])}
self.pod_shell_capacities[sandbox_id] = int(env.get("MAX_SHELL_SESSIONS", "10"))
def create_namespaced_service(self, _namespace: str, service) -> None:
self._record_k8s_call()
sandbox_id = service.metadata.labels["sandbox-id"]
self.created_services.append(sandbox_id)
self.service_sandboxes.add(sandbox_id)
def delete_namespaced_service(self, _name: str, _namespace: str) -> None:
self._record_k8s_call()
self.service_sandboxes.discard(_sandbox_id_from_service_name(_name))
def delete_namespaced_pod(self, _name: str, _namespace: str) -> None:
self._record_k8s_call()
self.pod_shell_capacities.pop(_name[len("sandbox-") :], None)
def list_namespaced_service(self, _namespace: str, *, label_selector: str):
self._record_k8s_call()
assert label_selector == "app=deer-flow-sandbox"
return SimpleNamespace(items=[_node_port_service("sandbox-listed")])
def _node_port_service(sandbox_id: str):
return SimpleNamespace(
metadata=SimpleNamespace(labels={"sandbox-id": sandbox_id}),
spec=SimpleNamespace(ports=[SimpleNamespace(name="http", port=8080, node_port=32123)]),
)
def _sandbox_id_from_service_name(name: str) -> str:
assert name.startswith("sandbox-")
assert name.endswith("-svc")
return name[len("sandbox-") : -len("-svc")]
@contextmanager
def _detect_provisioner_blocking_io(provisioner_module):
detector = BlockBuster(scanned_modules=[provisioner_module.__name__])
detector.activate()
try:
yield
finally:
detector.deactivate()
def test_sandbox_business_route_handlers_are_sync(provisioner_module) -> None:
"""FastAPI runs sync handlers in its worker pool, away from the event loop."""
for handler in (
provisioner_module.create_sandbox,
provisioner_module.destroy_sandbox,
provisioner_module.get_sandbox,
provisioner_module.sandbox_presence,
provisioner_module.list_sandboxes,
):
assert not inspect.iscoroutinefunction(handler)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("method", "path", "json_body", "expected_created_sandbox"),
[
("POST", "/api/sandboxes", {"sandbox_id": "sandbox-existing", "thread_id": "thread-1", "user_id": "user-1"}, None),
("POST", "/api/sandboxes", {"sandbox_id": "sandbox-new", "thread_id": "thread-1", "user_id": "user-1"}, "sandbox-new"),
("DELETE", "/api/sandboxes/sandbox-existing", None, None),
("GET", "/api/sandboxes/sandbox-existing", None, None),
("GET", "/api/sandboxes/sandbox-existing/presence", None, None),
("GET", "/api/sandboxes", None, None),
],
ids=["create-existing", "create-new", "destroy", "get", "presence", "list"],
)
async def test_sandbox_business_routes_run_k8s_client_off_event_loop_thread(
method: str,
path: str,
json_body: dict[str, str] | None,
expected_created_sandbox: str | None,
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(
event_loop_thread_id=threading.get_ident(),
ready_after_service_reads={"sandbox-new": 3},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret")
with _detect_provisioner_blocking_io(provisioner_module):
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {"X-API-Key": "test-secret"}
if json_body is None:
response = await client.request(method, path, headers=headers)
else:
response = await client.request(method, path, json=json_body, headers=headers)
assert response.status_code == 200
assert fake_core_v1.thread_ids
if expected_created_sandbox is not None:
assert fake_core_v1.created_pods == [expected_created_sandbox]
assert fake_core_v1.created_services == [expected_created_sandbox]
@pytest.mark.parametrize(
("include_legacy_skills", "expected_mount_names"),
[
(
False,
["skills-public", "skills-custom", "skills-legacy", "user-data"],
),
(
True,
["skills-public", "skills-custom", "skills-legacy", "user-data"],
),
],
ids=["without-legacy", "with-legacy"],
)
def test_create_sandbox_route_builds_expected_skills_mount_layout(
include_legacy_skills: bool,
expected_mount_names: list[str],
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(
event_loop_thread_id=-1,
ready_after_service_reads={"sandbox-layout": 1},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
response = provisioner_module.create_sandbox(
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-layout",
thread_id="thread-1",
user_id="user-1",
include_legacy_skills=include_legacy_skills,
)
)
assert response.status == "Running"
pod = fake_core_v1.created_pod_specs["sandbox-layout"]
volume_names = [volume.name for volume in pod.spec.volumes]
mount_names = [mount.name for mount in pod.spec.containers[0].volume_mounts]
assert volume_names == expected_mount_names
assert mount_names == expected_mount_names
def test_create_sandbox_route_threads_custom_skills_root_into_pod(
monkeypatch: pytest.MonkeyPatch,
provisioner_module,
) -> None:
fake_core_v1 = _RecordingCoreV1(
event_loop_thread_id=-1,
ready_after_service_reads={"sandbox-custom-skills": 1},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
categories = ("public", "custom", "legacy", "integrations")
response = provisioner_module.create_sandbox(
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-custom-skills",
thread_id="thread-1",
user_id="alice",
skills_container_path="/custom-skills",
extra_mounts=[
provisioner_module.ExtraMount(
host_path=(f"/.deer-flow/users/alice/threads/thread-1/skills_view/{category}"),
container_path=f"/custom-skills/{category}",
read_only=True,
)
for category in categories
],
)
)
assert response.status == "Running"
pod = fake_core_v1.created_pod_specs["sandbox-custom-skills"]
mount_paths = {mount.mount_path for mount in pod.spec.containers[0].volume_mounts}
assert not any(path.startswith("/mnt/skills") for path in mount_paths)
assert {f"/custom-skills/{category}" for category in categories} <= mount_paths
def test_create_sandbox_retries_transient_service_read_errors(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
fake_core_v1 = _RecordingCoreV1(
event_loop_thread_id=-1,
ready_after_service_reads={"sandbox-transient": 3},
service_read_failures={"sandbox-transient": [503, 429]},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
monkeypatch.setattr(provisioner_module.time, "sleep", lambda _seconds: None)
response = provisioner_module.create_sandbox(
provisioner_module.CreateSandboxRequest(
sandbox_id="sandbox-transient",
thread_id="thread-1",
user_id="user-1",
)
)
assert response.status == "Running"
assert response.sandbox_url == provisioner_module._sandbox_url("sandbox-transient", node_port=32123)
assert fake_core_v1.service_read_counts["sandbox-transient"] == 3
def test_sandbox_service_defaults_to_node_port_with_node_host_url(provisioner_module) -> None:
provisioner_module.K8S_NAMESPACE = "mdv-sit"
provisioner_module.SANDBOX_CONTAINER_PORT = 8080
provisioner_module.SANDBOX_SERVICE_TYPE = "NodePort"
provisioner_module.NODE_HOST = "node.example"
service = provisioner_module._build_service("abc123")
assert service.spec.type == "NodePort"
assert service.spec.ports[0].port == 8080
assert service.spec.ports[0].target_port == 8080
assert provisioner_module._sandbox_url("abc123", node_port=32123) == "http://node.example:32123"
def test_sandbox_service_supports_cluster_ip_with_dns_url(provisioner_module) -> None:
provisioner_module.K8S_NAMESPACE = "mdv-sit"
provisioner_module.SANDBOX_CONTAINER_PORT = 8080
provisioner_module.SANDBOX_SERVICE_TYPE = "ClusterIP"
service = provisioner_module._build_service("abc123")
assert service.spec.type == "ClusterIP"
assert service.spec.ports[0].port == 8080
assert service.spec.ports[0].target_port == 8080
assert provisioner_module._sandbox_url("abc123") == ("http://sandbox-abc123-svc.mdv-sit.svc.cluster.local:8080")
@pytest.mark.asyncio
async def test_auth_middleware(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
"""Verify the X-API-Key middleware: /health is open; /api/* requires a correct key."""
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret")
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# /health is always open — no key needed
r = await client.get("/health")
assert r.status_code == 200
# /api/* with no header → 401
r = await client.get("/api/sandboxes")
assert r.status_code == 401
# /api/* with wrong key → 401
r = await client.get("/api/sandboxes", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 401
# /api/* with a non-ASCII key → 401, not a TypeError from compare_digest
r = await client.get("/api/sandboxes", headers={"X-API-Key": "test-secr\xe9t".encode("latin-1")})
assert r.status_code == 401
# /api/* with correct key → not 401 (auth passed; handler runs with the K8s mock)
r = await client.get("/api/sandboxes", headers={"X-API-Key": "test-secret"})
assert r.status_code != 401
@pytest.mark.asyncio
async def test_auth_middleware_unset_key(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
"""When PROVISIONER_API_KEY is unset/empty, all /api/* routes return 401."""
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "")
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# /health is always open even when key is unset
r = await client.get("/health")
assert r.status_code == 200
# /api/* is always 401 when key is unset — even with a header
r = await client.get("/api/sandboxes")
assert r.status_code == 401
r = await client.get("/api/sandboxes", headers={"X-API-Key": "anything"})
assert r.status_code == 401