mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 09:08:38 +00:00
932 lines
32 KiB
Python
932 lines
32 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
import deerflow.skills.storage as storage_mod
|
|
from deerflow.community.aio_sandbox import remote_backend as remote_backend_mod
|
|
from deerflow.community.aio_sandbox.remote_backend import RemoteSandboxBackend
|
|
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
|
|
from deerflow.skills.types import SkillCategory
|
|
|
|
|
|
class _StubResponse:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
status_code: int = 200,
|
|
payload: object | None = None,
|
|
json_exc: Exception | None = None,
|
|
):
|
|
self.status_code = status_code
|
|
self._payload = {} if payload is None else payload
|
|
self._json_exc = json_exc
|
|
self.ok = 200 <= status_code < 400
|
|
self.text = ""
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise requests.HTTPError(f"HTTP {self.status_code}")
|
|
|
|
def json(self) -> object:
|
|
if self._json_exc is not None:
|
|
raise self._json_exc
|
|
return self._payload
|
|
|
|
|
|
def test_list_running_delegates_to_provisioner_list(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
sandbox_info = SandboxInfo(sandbox_id="test-id", sandbox_url="http://localhost:8080")
|
|
|
|
def mock_list():
|
|
return [sandbox_info]
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_list", mock_list)
|
|
|
|
assert backend.list_running() == [sandbox_info]
|
|
|
|
|
|
def test_reconciliation_discovery_does_not_trust_legacy_route_404(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(requests, "get", lambda *_args, **_kwargs: _StubResponse(status_code=404))
|
|
|
|
result = backend.discover_for_reconciliation("old-id")
|
|
|
|
assert result.status == "unknown"
|
|
assert result.info is None
|
|
|
|
|
|
def test_reconciliation_discovery_accepts_pod_authoritative_absence(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"get",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "old-id",
|
|
"status": "absent",
|
|
"backend_namespace": "namespace-uid-1",
|
|
}
|
|
),
|
|
)
|
|
|
|
result = backend.discover_for_reconciliation("old-id")
|
|
|
|
assert result.status == "absent"
|
|
assert result.backend_namespace == "namespace-uid-1"
|
|
|
|
|
|
def test_reconciliation_discovery_preserves_transport_uncertainty(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def fail(*_args, **_kwargs):
|
|
raise requests.ConnectionError("offline")
|
|
|
|
monkeypatch.setattr(requests, "get", fail)
|
|
|
|
result = backend.discover_for_reconciliation("old-id")
|
|
|
|
assert result.status == "unknown"
|
|
assert result.info is None
|
|
|
|
|
|
def test_reconciliation_discovery_accepts_exact_old_mount_contract(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"get",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "old-id",
|
|
"status": "found",
|
|
"backend_namespace": "namespace-uid-1",
|
|
"incarnation_id": "pod-uid-1",
|
|
"sandbox_url": "http://sandbox-old",
|
|
"thread_id": "thread-1",
|
|
"user_id": "alice",
|
|
"mount_contract_version": 1,
|
|
}
|
|
),
|
|
)
|
|
|
|
result = backend.discover_for_reconciliation("old-id")
|
|
|
|
assert result.status == "found"
|
|
assert result.info is not None
|
|
assert result.info.sandbox_id == "old-id"
|
|
assert result.info.mount_contract_version == 1
|
|
assert result.backend_namespace == "namespace-uid-1"
|
|
assert result.incarnation_id == "pod-uid-1"
|
|
|
|
|
|
def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
assert url == "http://provisioner:8002/api/sandboxes"
|
|
assert timeout == 10
|
|
assert headers == {}
|
|
return _StubResponse(
|
|
payload={
|
|
"sandboxes": [
|
|
{"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"},
|
|
{"sandbox_id": "missing-url"},
|
|
{"sandbox_url": "http://k3s:31002"},
|
|
]
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
infos = backend._provisioner_list()
|
|
assert len(infos) == 1
|
|
assert infos[0].sandbox_id == "abc123"
|
|
assert infos[0].sandbox_url == "http://k3s:31001"
|
|
|
|
|
|
def test_provisioner_list_sends_auth_header_when_api_key_set(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002", api_key="secret")
|
|
captured: list[dict] = []
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
captured.append({"headers": headers})
|
|
return _StubResponse(payload={"sandboxes": []})
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
backend._provisioner_list()
|
|
assert captured[0]["headers"] == {"X-API-Key": "secret"}
|
|
|
|
|
|
def test_provisioner_list_returns_empty_on_request_exception(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
raise requests.RequestException("network down")
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
assert backend._provisioner_list() == []
|
|
|
|
|
|
def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(payload=[{"sandbox_id": "abc", "sandbox_url": "http://k3s:31001"}])
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
assert backend._provisioner_list() == []
|
|
|
|
|
|
def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(payload={"sandboxes": {"sandbox_id": "abc"}})
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
assert backend._provisioner_list() == []
|
|
|
|
|
|
def test_provisioner_list_skips_non_dict_sandbox_entries(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(
|
|
payload={
|
|
"sandboxes": [
|
|
{"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"},
|
|
"bad-entry",
|
|
123,
|
|
None,
|
|
]
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
infos = backend._provisioner_list()
|
|
assert len(infos) == 1
|
|
assert infos[0].sandbox_id == "abc123"
|
|
assert infos[0].sandbox_url == "http://k3s:31001"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("categories", "expected"),
|
|
[
|
|
([SkillCategory.LEGACY], True),
|
|
(["legacy"], True),
|
|
([SkillCategory.CUSTOM], False),
|
|
],
|
|
)
|
|
def test_user_should_see_legacy_skills_follows_storage_visibility_rule(monkeypatch, categories, expected):
|
|
class _Storage:
|
|
def load_skills(self, *, enabled_only: bool = False):
|
|
assert enabled_only is False
|
|
return [type("SkillStub", (), {"category": category})() for category in categories]
|
|
|
|
monkeypatch.setattr(storage_mod, "get_or_new_user_skill_storage", lambda user_id: _Storage())
|
|
|
|
assert storage_mod.user_should_see_legacy_skills("user-1") is expected
|
|
|
|
|
|
@pytest.mark.parametrize("expected_user_id", [None, "owner-1"])
|
|
def test_create_delegates_to_provisioner_create(monkeypatch, expected_user_id):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
expected = SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001")
|
|
|
|
def mock_create(
|
|
thread_id: str,
|
|
sandbox_id: str,
|
|
extra_mounts=None,
|
|
*,
|
|
user_id=None,
|
|
provision_lark_cli_runtime=False,
|
|
provision_lark_cli_broker=False,
|
|
required_mount_contract_version=None,
|
|
):
|
|
assert thread_id == "thread-1"
|
|
assert sandbox_id == "abc123"
|
|
assert extra_mounts == [("/host", "/container", False)]
|
|
assert user_id == expected_user_id
|
|
assert provision_lark_cli_runtime is True
|
|
assert provision_lark_cli_broker is False
|
|
assert required_mount_contract_version == 2
|
|
return expected
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_create", mock_create)
|
|
|
|
result = backend.create(
|
|
"thread-1",
|
|
"abc123",
|
|
extra_mounts=[("/host", "/container", False)],
|
|
user_id=expected_user_id,
|
|
provision_lark_cli_runtime=True,
|
|
)
|
|
assert result == expected
|
|
|
|
|
|
def test_create_rejects_nondefault_user_on_unverified_legacy_provisioner(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://legacy-provisioner:8002")
|
|
called = False
|
|
|
|
def unexpected_create(*_args, **_kwargs):
|
|
nonlocal called
|
|
called = True
|
|
raise AssertionError("unsafe legacy create should not be attempted")
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_create", unexpected_create)
|
|
|
|
with pytest.raises(RuntimeError, match="cannot isolate user 'alice'"):
|
|
backend.create("shared-thread", "sandbox-1", user_id="alice")
|
|
|
|
assert called is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_payload",
|
|
[
|
|
{
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://k3s:31001",
|
|
"user_id": "mallory",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 2,
|
|
},
|
|
{
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://k3s:31001",
|
|
"user_id": "alice",
|
|
"thread_id": "other-thread",
|
|
"mount_contract_version": 2,
|
|
},
|
|
],
|
|
)
|
|
def test_current_contract_create_rejects_mismatched_identity(monkeypatch, response_payload):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(payload=response_payload),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="mount contract response"):
|
|
backend.create("thread-1", "abc123", user_id="alice")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"response_payload",
|
|
[
|
|
{"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"},
|
|
{
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://k3s:31001",
|
|
"user_id": "alice",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 1,
|
|
},
|
|
],
|
|
)
|
|
def test_current_contract_create_restarts_after_response_contract_change(monkeypatch, response_payload):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(payload=response_payload),
|
|
)
|
|
|
|
with pytest.raises(remote_backend_mod.MountContractChangedError):
|
|
backend.create("thread-1", "abc123", user_id="alice")
|
|
|
|
assert backend._capability_next_probe_at == 0.0
|
|
|
|
|
|
def test_current_contract_create_returns_verified_identity(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://k3s:31001",
|
|
"user_id": "alice",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 2,
|
|
}
|
|
),
|
|
)
|
|
|
|
info = backend.create("thread-1", "abc123", user_id="alice")
|
|
|
|
assert (info.sandbox_id, info.user_id, info.thread_id, info.mount_contract_version) == (
|
|
"abc123",
|
|
"alice",
|
|
"thread-1",
|
|
2,
|
|
)
|
|
|
|
|
|
def test_create_keeps_v2_response_validation_when_capability_changes_during_post(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
|
|
def old_peer_response(*_args, **_kwargs):
|
|
backend._mount_contract_version = 0
|
|
backend._mount_contract_capability_known = False
|
|
return _StubResponse(
|
|
payload={
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://legacy.local",
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(requests, "post", old_peer_response)
|
|
|
|
with pytest.raises(remote_backend_mod.MountContractChangedError):
|
|
backend.create("thread-1", "abc123", user_id="alice")
|
|
|
|
assert backend._capability_next_probe_at == 0.0
|
|
|
|
|
|
def test_legacy_create_restarts_when_response_advertises_another_version(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 0
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://legacy.local",
|
|
"mount_contract_version": 1,
|
|
}
|
|
),
|
|
)
|
|
|
|
with pytest.raises(remote_backend_mod.MountContractChangedError):
|
|
backend.create("thread-1", "abc123", user_id="default")
|
|
|
|
assert backend._capability_next_probe_at == 0.0
|
|
|
|
|
|
def test_capability_retry_backoff_does_not_overflow_after_long_outage(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._capability_probe_failures = 1024
|
|
backend._capability_next_probe_at = 0.0
|
|
now = [100.0]
|
|
monkeypatch.setattr(remote_backend_mod.time, "monotonic", lambda: now[0])
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"get",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(requests.ConnectionError("still down")),
|
|
)
|
|
|
|
assert backend.refresh_capabilities_if_stale() is True
|
|
assert backend._capability_probe_failures == 1025
|
|
assert backend._capability_next_probe_at == 130.0
|
|
|
|
|
|
def test_mount_mode_waits_for_an_inflight_stale_probe(monkeypatch):
|
|
provider_mod = importlib.import_module("deerflow.sandbox.sandbox_provider")
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = 0.0
|
|
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
|
provider._config = {"thread_data_mounts": True}
|
|
provider._backend = backend
|
|
result: list[bool] = []
|
|
completed = threading.Event()
|
|
|
|
backend._capability_probe_lock.acquire()
|
|
try:
|
|
worker = threading.Thread(
|
|
target=lambda: (
|
|
result.append(provider_mod.sandbox_provider_uses_thread_data_mounts(provider)),
|
|
completed.set(),
|
|
),
|
|
daemon=True,
|
|
)
|
|
worker.start()
|
|
assert not completed.wait(0.05)
|
|
backend._mount_contract_version = 0
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
finally:
|
|
backend._capability_probe_lock.release()
|
|
|
|
assert completed.wait(1)
|
|
worker.join(timeout=1)
|
|
assert result == [False]
|
|
|
|
|
|
def test_create_restarts_when_required_mount_contract_changed(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 0
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
post = MagicMock()
|
|
monkeypatch.setattr(requests, "post", post)
|
|
|
|
with pytest.raises(remote_backend_mod.MountContractChangedError):
|
|
backend.create(
|
|
"thread-1",
|
|
"sandbox-v2",
|
|
user_id="default",
|
|
required_mount_contract_version=2,
|
|
)
|
|
|
|
post.assert_not_called()
|
|
|
|
|
|
def test_create_accepts_and_validates_future_compatible_contract(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 3
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "sandbox-v3",
|
|
"sandbox_url": "http://v3.local",
|
|
"user_id": "alice",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 3,
|
|
}
|
|
),
|
|
)
|
|
|
|
info = backend.create("thread-1", "sandbox-v3", user_id="alice")
|
|
|
|
assert info.mount_contract_version == 3
|
|
|
|
|
|
def test_create_sends_frozen_mount_contract_as_a_provisioner_precondition(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
captured: dict = {}
|
|
|
|
def post(_url, *, json, **_kwargs):
|
|
captured.update(json)
|
|
return _StubResponse(
|
|
payload={
|
|
"sandbox_id": "sandbox-v2",
|
|
"sandbox_url": "http://v2.local",
|
|
"user_id": "alice",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 2,
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(requests, "post", post)
|
|
|
|
backend.create("thread-1", "sandbox-v2", user_id="alice")
|
|
|
|
assert captured["required_mount_contract_version"] == 2
|
|
|
|
|
|
def test_create_retries_structured_provisioner_contract_precondition_failure(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 2
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
status_code=409,
|
|
payload={
|
|
"detail": {
|
|
"code": "mount_contract_changed",
|
|
"expected": 2,
|
|
"actual": 3,
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
with pytest.raises(remote_backend_mod.MountContractChangedError) as exc_info:
|
|
backend.create("thread-1", "sandbox-v2", user_id="alice")
|
|
|
|
assert (exc_info.value.expected, exc_info.value.actual) == (2, 3)
|
|
assert backend._capability_next_probe_at == 0.0
|
|
|
|
|
|
@pytest.mark.parametrize("invalid_version", ["2", True])
|
|
def test_legacy_create_rejects_non_integer_response_contract(monkeypatch, invalid_version):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
backend._mount_contract_version = 0
|
|
backend._mount_contract_capability_known = True
|
|
backend._capability_next_probe_at = time.monotonic() + 30
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda _user_id: False)
|
|
monkeypatch.setattr(
|
|
requests,
|
|
"post",
|
|
lambda *_args, **_kwargs: _StubResponse(
|
|
payload={
|
|
"sandbox_id": "sandbox-v0",
|
|
"sandbox_url": "http://legacy.local",
|
|
"mount_contract_version": invalid_version,
|
|
}
|
|
),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="mount contract response"):
|
|
backend.create("thread-1", "sandbox-v0", user_id="default")
|
|
|
|
|
|
def test_provisioner_create_returns_sandbox_info(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: True)
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
assert url == "http://provisioner:8002/api/sandboxes"
|
|
assert json == {
|
|
"sandbox_id": "abc123",
|
|
"thread_id": "thread-1",
|
|
"user_id": "test-user-autouse",
|
|
"required_mount_contract_version": 0,
|
|
"include_legacy_skills": True,
|
|
"provision_lark_cli_runtime": False,
|
|
"provision_lark_cli_broker": False,
|
|
}
|
|
assert timeout == 30
|
|
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
info = backend._provisioner_create("thread-1", "abc123")
|
|
assert info.sandbox_id == "abc123"
|
|
assert info.sandbox_url == "http://k3s:31001"
|
|
|
|
|
|
def test_provisioner_create_forwards_supported_extra_mounts(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
assert url == "http://provisioner:8002/api/sandboxes"
|
|
assert json["include_legacy_skills"] is False
|
|
assert json["extra_mounts"] == [
|
|
{
|
|
"host_path": "/state/users/alice/skills/integrations",
|
|
"container_path": "/mnt/skills/integrations",
|
|
"read_only": True,
|
|
},
|
|
{
|
|
"host_path": "/state/users/alice/integrations/lark-cli/config",
|
|
"container_path": "/mnt/integrations/lark-cli/config",
|
|
"read_only": False,
|
|
},
|
|
]
|
|
assert timeout == 30
|
|
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
backend._provisioner_create(
|
|
"thread-1",
|
|
"abc123",
|
|
extra_mounts=[
|
|
("/state/users/alice/threads/thread-1/user-data/workspace", "/mnt/user-data/workspace", False),
|
|
("/skills", "/mnt/skills", True),
|
|
("/state/users/alice/skills/integrations", "/mnt/skills/integrations", True),
|
|
("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
|
|
],
|
|
user_id="alice",
|
|
)
|
|
|
|
|
|
def test_provisioner_create_strips_runtime_mount_when_init_container_enabled(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
captured: dict = {}
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
captured.update(json)
|
|
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
backend._provisioner_create(
|
|
"thread-1",
|
|
"abc123",
|
|
extra_mounts=[
|
|
("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
|
|
("/state/users/alice/integrations/lark-cli/data", "/mnt/integrations/lark-cli/data", False),
|
|
("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
|
|
],
|
|
user_id="alice",
|
|
provision_lark_cli_runtime=True,
|
|
)
|
|
|
|
assert captured["provision_lark_cli_runtime"] is True
|
|
container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
|
|
# The init container supplies the runtime, so its mount is dropped, but the
|
|
# per-user credential mounts are still forwarded.
|
|
assert "/mnt/integrations/lark-cli/runtime" not in container_paths
|
|
assert "/mnt/integrations/lark-cli/config" in container_paths
|
|
assert "/mnt/integrations/lark-cli/data" in container_paths
|
|
|
|
|
|
def test_provisioner_create_keeps_runtime_mount_when_init_container_disabled(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
captured: dict = {}
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
captured.update(json)
|
|
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
backend._provisioner_create(
|
|
"thread-1",
|
|
"abc123",
|
|
extra_mounts=[
|
|
("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
|
|
],
|
|
user_id="alice",
|
|
provision_lark_cli_runtime=False,
|
|
)
|
|
|
|
assert captured["provision_lark_cli_runtime"] is False
|
|
container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
|
|
assert "/mnt/integrations/lark-cli/runtime" in container_paths
|
|
|
|
|
|
def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
assert url == "http://provisioner:8002/api/sandboxes"
|
|
assert json == {
|
|
"sandbox_id": "anon123",
|
|
"thread_id": None,
|
|
"user_id": "test-user-autouse",
|
|
"required_mount_contract_version": 0,
|
|
"include_legacy_skills": False,
|
|
"provision_lark_cli_runtime": False,
|
|
"provision_lark_cli_broker": False,
|
|
}
|
|
assert timeout == 30
|
|
return _StubResponse(payload={"sandbox_id": "anon123", "sandbox_url": "http://k3s:31002"})
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
info = backend.create(None, "anon123")
|
|
assert info.sandbox_id == "anon123"
|
|
assert info.sandbox_url == "http://k3s:31002"
|
|
|
|
|
|
def test_provisioner_create_raises_runtime_error_on_request_exception(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
def mock_post(url: str, json: dict, timeout: int, headers=None):
|
|
raise requests.RequestException("boom")
|
|
|
|
monkeypatch.setattr(requests, "post", mock_post)
|
|
|
|
with pytest.raises(RuntimeError, match="Provisioner create failed"):
|
|
backend._provisioner_create("thread-1", "abc123")
|
|
|
|
|
|
def test_destroy_delegates_to_provisioner_destroy(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
called: list[str] = []
|
|
|
|
def mock_destroy(sandbox_id: str):
|
|
called.append(sandbox_id)
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_destroy", mock_destroy)
|
|
|
|
backend.destroy(SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001"))
|
|
assert called == ["abc123"]
|
|
|
|
|
|
def test_provisioner_destroy_calls_delete(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_delete(url: str, timeout: int, headers=None):
|
|
assert url == "http://provisioner:8002/api/sandboxes/abc123"
|
|
assert timeout == 15
|
|
return _StubResponse(status_code=200)
|
|
|
|
monkeypatch.setattr(requests, "delete", mock_delete)
|
|
|
|
backend._provisioner_destroy("abc123")
|
|
|
|
|
|
def test_provisioner_destroy_swallows_request_exception(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_delete(url: str, timeout: int, headers=None):
|
|
raise requests.RequestException("network down")
|
|
|
|
monkeypatch.setattr(requests, "delete", mock_delete)
|
|
|
|
backend._provisioner_destroy("abc123")
|
|
|
|
|
|
def test_is_alive_delegates_to_provisioner_is_alive(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_is_alive(sandbox_id: str):
|
|
assert sandbox_id == "abc123"
|
|
return True
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_is_alive", mock_is_alive)
|
|
|
|
alive = backend.is_alive(SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001"))
|
|
assert alive is True
|
|
|
|
|
|
def test_provisioner_is_alive_true_only_when_status_running(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get_running(url: str, timeout: int, headers=None):
|
|
return _StubResponse(payload={"status": "Running"})
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get_running)
|
|
assert backend._provisioner_is_alive("abc123") is True
|
|
|
|
def mock_get_pending(url: str, timeout: int, headers=None):
|
|
return _StubResponse(payload={"status": "Pending"})
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get_pending)
|
|
assert backend._provisioner_is_alive("abc123") is False
|
|
|
|
|
|
def test_provisioner_is_alive_returns_false_on_404(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(status_code=404)
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
assert backend._provisioner_is_alive("abc123") is False
|
|
|
|
|
|
def test_provisioner_is_alive_raises_on_request_exception(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
raise requests.RequestException("boom")
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
with pytest.raises(RuntimeError, match="Provisioner health check failed for abc123"):
|
|
backend._provisioner_is_alive("abc123")
|
|
|
|
|
|
def test_provisioner_is_alive_raises_on_server_error(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
response = _StubResponse(status_code=503)
|
|
response.text = "unavailable"
|
|
return response
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
with pytest.raises(RuntimeError, match="HTTP 503 unavailable"):
|
|
backend._provisioner_is_alive("abc123")
|
|
|
|
|
|
def test_discover_delegates_to_provisioner_discover(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
expected = SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001")
|
|
|
|
def mock_discover(sandbox_id: str):
|
|
assert sandbox_id == "abc123"
|
|
return expected
|
|
|
|
monkeypatch.setattr(backend, "_provisioner_discover", mock_discover)
|
|
|
|
result = backend.discover("abc123")
|
|
assert result == expected
|
|
|
|
|
|
def test_provisioner_discover_returns_none_on_404(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(status_code=404)
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
assert backend._provisioner_discover("abc123") is None
|
|
|
|
|
|
def test_provisioner_discover_returns_info_on_success(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
return _StubResponse(
|
|
payload={
|
|
"sandbox_id": "abc123",
|
|
"sandbox_url": "http://k3s:31001",
|
|
"user_id": "alice",
|
|
"thread_id": "thread-1",
|
|
"mount_contract_version": 2,
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
info = backend._provisioner_discover("abc123")
|
|
assert info is not None
|
|
assert info.sandbox_id == "abc123"
|
|
assert info.sandbox_url == "http://k3s:31001"
|
|
assert (info.user_id, info.thread_id, info.mount_contract_version) == (
|
|
"alice",
|
|
"thread-1",
|
|
2,
|
|
)
|
|
|
|
|
|
def test_provisioner_discover_returns_none_on_request_exception(monkeypatch):
|
|
backend = RemoteSandboxBackend("http://provisioner:8002")
|
|
|
|
def mock_get(url: str, timeout: int, headers=None):
|
|
raise requests.RequestException("boom")
|
|
|
|
monkeypatch.setattr(requests, "get", mock_get)
|
|
|
|
assert backend._provisioner_discover("abc123") is None
|