5935940276
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 29s
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m21s
CI / quality (pull_request) Successful in 2m33s
CI / unit_tests (pull_request) Successful in 3m10s
CI / integration_tests (pull_request) Successful in 5m17s
CI / coverage (pull_request) Successful in 4m25s
CI / docker (pull_request) Successful in 40s
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 31s
CI / quality (push) Successful in 15s
CI / security (push) Successful in 32s
CI / build (push) Successful in 21s
CI / integration_tests (push) Successful in 3m6s
CI / unit_tests (push) Successful in 3m30s
CI / benchmark-regression (push) Has been skipped
CI / docker (push) Successful in 1m43s
CI / coverage (push) Successful in 5m21s
CI / benchmark-regression (pull_request) Successful in 31m11s
CI / benchmark-publish (push) Successful in 17m24s
Implement sandbox_boundary(r) function that walks up containment edges in the resource DAG to the nearest sandboxable ancestor, enabling resources sharing a boundary to share one sandbox instance. Changes: - Add boundary.py: is_sandbox_boundary(), sandbox_boundary(), compute_sandbox_domains(), BoundaryCache (thread-safe, per-execution) - Update SandboxManager: resolve_sandbox_key() and get_or_create_sandbox_for_resource() key by (plan_id, boundary_id) instead of (plan_id, resource_id); boundary cache lifecycle methods - Define "sandboxable" via ResourceCapabilities.sandboxable + non-none sandbox_strategy as per specification section 24659-24674 - Export new symbols from sandbox __init__.py - Add vulture whitelist entries for new public API Tests: - 26 Behave BDD scenarios (features/sandbox_boundary_algebra.feature) - 5 Robot Framework integration tests (robot/sandbox_boundary_algebra.robot) - ASV benchmarks for boundary walk, domain grouping, and cache performance ISSUES CLOSED: #548
535 lines
19 KiB
Python
535 lines
19 KiB
Python
"""Step definitions for sandbox boundary algebra BDD tests.
|
|
|
|
Covers:
|
|
- ``sandbox_boundary(r)`` function
|
|
- ``is_sandbox_boundary(r)`` predicate
|
|
- ``compute_sandbox_domains()`` grouping
|
|
- ``BoundaryCache`` caching behaviour
|
|
- ``SandboxManager`` boundary-aware keying
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.domain.models.core.resource import (
|
|
PhysVirt,
|
|
Resource,
|
|
ResourceCapabilities,
|
|
SandboxStrategy,
|
|
)
|
|
from cleveragents.infrastructure.sandbox.boundary import (
|
|
BoundaryCache,
|
|
NoSandboxBoundaryError,
|
|
compute_sandbox_domains,
|
|
is_sandbox_boundary,
|
|
sandbox_boundary,
|
|
)
|
|
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
|
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
|
from cleveragents.infrastructure.sandbox.protocol import (
|
|
CommitResult,
|
|
SandboxContext,
|
|
SandboxStatus,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Counter for generating unique ULIDs in tests.
|
|
_ULID_COUNTER: int = 0
|
|
|
|
|
|
def _make_ulid(name: str) -> str:
|
|
"""Generate a deterministic ULID-like ID from a name.
|
|
|
|
Uses Crockford base-32 charset (0-9, A-H, J-K, M-N, P-T, V-Z)
|
|
to satisfy the ULID pattern ``^[0-9A-HJKMNP-TV-Z]{26}$``.
|
|
"""
|
|
global _ULID_COUNTER
|
|
_ULID_COUNTER += 1
|
|
# Pad the counter + hash of name into 26 chars of Crockford base32.
|
|
base = abs(hash(name)) % (10**20)
|
|
raw = f"{_ULID_COUNTER:06d}{base:020d}"
|
|
# Map digits to Crockford charset.
|
|
charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
result = ""
|
|
for ch in raw:
|
|
result += charset[int(ch)]
|
|
# Pad/truncate to 26.
|
|
return (result + "0" * 26)[:26]
|
|
|
|
|
|
def _make_resource(
|
|
name: str,
|
|
sandboxable: bool = False,
|
|
strategy: str = "none",
|
|
parents: list[str] | None = None,
|
|
location: str | None = None,
|
|
) -> Resource:
|
|
"""Create a test resource with the given properties."""
|
|
rid = _make_ulid(name)
|
|
return Resource(
|
|
resource_id=rid,
|
|
name=name,
|
|
resource_type_name="fs-file" if not sandboxable else "git-checkout",
|
|
classification=PhysVirt.PHYSICAL,
|
|
sandbox_strategy=SandboxStrategy(strategy),
|
|
capabilities=ResourceCapabilities(
|
|
readable=True,
|
|
writable=True,
|
|
sandboxable=sandboxable,
|
|
checkpointable=False,
|
|
),
|
|
parents=parents or [],
|
|
location=location,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a resource registry for boundary algebra tests")
|
|
def step_given_resource_registry(context: Any) -> None:
|
|
"""Initialise a fresh resource registry (dict) for this scenario."""
|
|
context.resource_registry = {}
|
|
context.resources_by_name = {}
|
|
context.boundary_result = None
|
|
context.boundary_error = None
|
|
context.is_boundary_result = None
|
|
context.domain_result = None
|
|
context.boundary_cache = None
|
|
context.sandbox_key_result = None
|
|
context.boundary_sandbox_results = []
|
|
context.error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps - resource construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a sandboxable resource "{name}" with strategy "{strategy}"')
|
|
def step_given_sandboxable_resource(context: Any, name: str, strategy: str) -> None:
|
|
res = _make_resource(name, sandboxable=True, strategy=strategy)
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given('a located sandboxable resource "{name}" strategy "{strategy}" location "{loc}"')
|
|
def step_given_sandboxable_resource_with_location(
|
|
context: Any, name: str, strategy: str, loc: str
|
|
) -> None:
|
|
res = _make_resource(name, sandboxable=True, strategy=strategy, location=loc)
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given('a non-sandboxable resource "{name}" with parent "{parent_name}"')
|
|
def step_given_nonsandboxable_with_parent(
|
|
context: Any, name: str, parent_name: str
|
|
) -> None:
|
|
parent = context.resources_by_name[parent_name]
|
|
res = _make_resource(name, sandboxable=False, parents=[parent.resource_id])
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given('a non-sandboxable resource "{name}" with no parents')
|
|
def step_given_nonsandboxable_orphan(context: Any, name: str) -> None:
|
|
res = _make_resource(name, sandboxable=False)
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given('a non-sandboxable resource "{name}" with missing parent "{missing_parent}"')
|
|
def step_given_nonsandboxable_dangling(
|
|
context: Any, name: str, missing_parent: str
|
|
) -> None:
|
|
# Parent ID is not registered in the registry.
|
|
fake_parent_id = _make_ulid(missing_parent)
|
|
res = _make_resource(name, sandboxable=False, parents=[fake_parent_id])
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given('a resource "{name}" with sandboxable true and strategy "{strategy}"')
|
|
def step_given_sandboxable_with_strategy(
|
|
context: Any, name: str, strategy: str
|
|
) -> None:
|
|
res = _make_resource(name, sandboxable=True, strategy=strategy)
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@given("a fresh boundary cache")
|
|
def step_given_boundary_cache(context: Any) -> None:
|
|
context.boundary_cache = BoundaryCache()
|
|
|
|
|
|
def _make_mock_sandbox(
|
|
resource_id: str = "res-mock",
|
|
plan_id: str = "plan-mock",
|
|
original_path: str = "/tmp/mock",
|
|
) -> MagicMock:
|
|
"""Create a mock sandbox satisfying the Sandbox protocol."""
|
|
sb = MagicMock()
|
|
sandbox_id = f"sb-{resource_id}"
|
|
sb.sandbox_id = sandbox_id
|
|
sb.status = SandboxStatus.CREATED
|
|
sb.context = SandboxContext(
|
|
sandbox_id=sandbox_id,
|
|
sandbox_path=original_path,
|
|
original_path=original_path,
|
|
resource_id=resource_id,
|
|
plan_id=plan_id,
|
|
created_at=datetime.now(),
|
|
)
|
|
sb.create.return_value = sb.context
|
|
sb.commit.return_value = CommitResult(
|
|
sandbox_id=sandbox_id,
|
|
success=True,
|
|
timestamp=datetime.now(),
|
|
)
|
|
sb.rollback.return_value = None
|
|
sb.cleanup.return_value = None
|
|
return sb
|
|
|
|
|
|
@given("a mock sandbox factory")
|
|
def step_given_mock_factory(context: Any) -> None:
|
|
"""Create a mock factory that returns mock sandboxes.
|
|
|
|
Mocks are cached by resource_id so the same mock is returned for
|
|
repeated calls with the same resource_id (mimics real dedup).
|
|
"""
|
|
factory = MagicMock(spec=SandboxFactory)
|
|
mock_cache: dict[str, MagicMock] = {}
|
|
|
|
def _create_sandbox(
|
|
resource_id: str,
|
|
original_path: str,
|
|
sandbox_strategy: str,
|
|
) -> MagicMock:
|
|
if resource_id not in mock_cache:
|
|
mock_cache[resource_id] = _make_mock_sandbox(
|
|
resource_id=resource_id,
|
|
original_path=original_path,
|
|
)
|
|
return mock_cache[resource_id]
|
|
|
|
factory.create_sandbox.side_effect = _create_sandbox
|
|
context.factory = factory
|
|
|
|
|
|
@given("a sandbox manager with the mock factory")
|
|
def step_given_manager_mock_factory(context: Any) -> None:
|
|
context.manager = SandboxManager(factory=context.factory, cleanup_on_exit=False)
|
|
context.error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I compute the sandbox boundary for "{name}"')
|
|
def step_when_compute_boundary(context: Any, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
context.boundary_result = sandbox_boundary(res, context.resource_registry)
|
|
|
|
|
|
@when('I check if "{name}" is a sandbox boundary')
|
|
def step_when_check_is_boundary(context: Any, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
context.is_boundary_result = is_sandbox_boundary(res)
|
|
|
|
|
|
@when("I check is_sandbox_boundary with None resource")
|
|
def step_when_check_is_boundary_none(context: Any) -> None:
|
|
try:
|
|
is_sandbox_boundary(None) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I compute sandbox domains for "{name_a}" and "{name_b}"')
|
|
def step_when_compute_domains(context: Any, name_a: str, name_b: str) -> None:
|
|
res_a = context.resources_by_name[name_a]
|
|
res_b = context.resources_by_name[name_b]
|
|
context.domain_result = compute_sandbox_domains(
|
|
[res_a, res_b], context.resource_registry
|
|
)
|
|
|
|
|
|
@when('I look up the boundary for "{name}" via the cache')
|
|
def step_when_cache_lookup(context: Any, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
context.boundary_result = context.boundary_cache.get_boundary(
|
|
res, context.resource_registry
|
|
)
|
|
|
|
|
|
@when('I look up the boundary for "{name}" via the cache again')
|
|
def step_when_cache_lookup_again(context: Any, name: str) -> None:
|
|
# Same as above; exercises cache hit path.
|
|
res = context.resources_by_name[name]
|
|
context.boundary_result = context.boundary_cache.get_boundary(
|
|
res, context.resource_registry
|
|
)
|
|
|
|
|
|
@when("I clear the boundary cache")
|
|
def step_when_clear_cache(context: Any) -> None:
|
|
if context.boundary_cache is not None:
|
|
context.boundary_cache.clear()
|
|
else:
|
|
context.manager.clear_boundary_cache()
|
|
|
|
|
|
@when('I resolve the sandbox key for plan "{plan_id}" resource "{name}"')
|
|
def step_when_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
context.sandbox_key_result = context.manager.resolve_sandbox_key(
|
|
res, context.resource_registry
|
|
)
|
|
|
|
|
|
@when('I try to resolve the sandbox key for plan "{plan_id}" resource "{name}"')
|
|
def step_when_try_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
try:
|
|
context.manager.resolve_sandbox_key(res, context.resource_registry)
|
|
except NoSandboxBoundaryError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I get or create a boundary sandbox for plan "{plan_id}" resource "{name}"')
|
|
def step_when_get_or_create_boundary_sandbox(
|
|
context: Any, plan_id: str, name: str
|
|
) -> None:
|
|
res = context.resources_by_name[name]
|
|
sb = context.manager.get_or_create_sandbox_for_resource(
|
|
plan_id=plan_id,
|
|
resource=res,
|
|
resource_registry=context.resource_registry,
|
|
)
|
|
context.boundary_sandbox_results.append(sb)
|
|
|
|
|
|
@when("I clear the manager boundary cache")
|
|
def step_when_clear_manager_cache(context: Any) -> None:
|
|
context.manager.clear_boundary_cache()
|
|
|
|
|
|
@given('a non-sandboxable resource "{name}" with parents "{parent_a}" and "{parent_b}"')
|
|
def step_given_nonsandboxable_multi_parent(
|
|
context: Any, name: str, parent_a: str, parent_b: str
|
|
) -> None:
|
|
pa = context.resources_by_name[parent_a]
|
|
pb = context.resources_by_name[parent_b]
|
|
res = _make_resource(
|
|
name, sandboxable=False, parents=[pa.resource_id, pb.resource_id]
|
|
)
|
|
context.resource_registry[res.resource_id] = res
|
|
context.resources_by_name[name] = res
|
|
|
|
|
|
@when("I call boundary cache get_boundary with None resource")
|
|
def step_when_cache_none_resource(context: Any) -> None:
|
|
try:
|
|
context.boundary_cache.get_boundary(None, context.resource_registry) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I call boundary cache get_boundary with None registry for "{name}"')
|
|
def step_when_cache_none_registry(context: Any, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
try:
|
|
context.boundary_cache.get_boundary(res, None) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I call sandbox_boundary with None resource")
|
|
def step_when_sandbox_boundary_none_resource(context: Any) -> None:
|
|
try:
|
|
sandbox_boundary(None, context.resource_registry) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I call sandbox_boundary with None registry for "{name}"')
|
|
def step_when_sandbox_boundary_none_registry(context: Any, name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
try:
|
|
sandbox_boundary(res, None) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I call compute_sandbox_domains with None resources")
|
|
def step_when_domains_none_resources(context: Any) -> None:
|
|
try:
|
|
compute_sandbox_domains(None, context.resource_registry) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when("I call compute_sandbox_domains with None registry")
|
|
def step_when_domains_none_registry(context: Any) -> None:
|
|
res = context.resources_by_name.get("dummy")
|
|
try:
|
|
compute_sandbox_domains([res] if res else [], None) # type: ignore[arg-type]
|
|
except ValueError as exc:
|
|
context.error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the boundary should be "{expected_name}"')
|
|
def step_then_boundary_is(context: Any, expected_name: str) -> None:
|
|
expected = context.resources_by_name[expected_name]
|
|
assert context.boundary_result is not None, (
|
|
f"Expected boundary '{expected_name}' but got None"
|
|
)
|
|
assert context.boundary_result.resource_id == expected.resource_id, (
|
|
f"Expected boundary {expected.resource_id}, "
|
|
f"got {context.boundary_result.resource_id}"
|
|
)
|
|
|
|
|
|
@then("the boundary should be None")
|
|
def step_then_boundary_is_none(context: Any) -> None:
|
|
assert context.boundary_result is None, (
|
|
f"Expected None boundary, got {context.boundary_result}"
|
|
)
|
|
|
|
|
|
@then("it should be a sandbox boundary")
|
|
def step_then_is_boundary(context: Any) -> None:
|
|
assert context.is_boundary_result is True, (
|
|
"Expected resource to be a sandbox boundary"
|
|
)
|
|
|
|
|
|
@then("it should not be a sandbox boundary")
|
|
def step_then_is_not_boundary(context: Any) -> None:
|
|
assert context.is_boundary_result is False, (
|
|
"Expected resource NOT to be a sandbox boundary"
|
|
)
|
|
|
|
|
|
@then('the error should be a ValueError with text "{msg}"')
|
|
def step_then_value_error_containing(context: Any, msg: str) -> None:
|
|
assert context.error is not None, "Expected a ValueError but no error was raised"
|
|
assert isinstance(context.error, ValueError), (
|
|
f"Expected ValueError, got {type(context.error).__name__}: {context.error}"
|
|
)
|
|
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
|
|
assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
|
|
|
|
|
|
@then('both resources should be in the domain of "{boundary_name}"')
|
|
def step_then_both_in_domain(context: Any, boundary_name: str) -> None:
|
|
boundary = context.resources_by_name[boundary_name]
|
|
domain = context.domain_result.get(boundary.resource_id, [])
|
|
assert len(domain) == 2, (
|
|
f"Expected 2 resources in domain of '{boundary_name}', got {len(domain)}"
|
|
)
|
|
|
|
|
|
@then('"{name}" should be in the domain of "{boundary_name}"')
|
|
def step_then_resource_in_domain(context: Any, name: str, boundary_name: str) -> None:
|
|
boundary = context.resources_by_name[boundary_name]
|
|
res = context.resources_by_name[name]
|
|
domain = context.domain_result.get(boundary.resource_id, [])
|
|
ids = [r.resource_id for r in domain]
|
|
assert res.resource_id in ids, (
|
|
f"Resource '{name}' not found in domain of '{boundary_name}'"
|
|
)
|
|
|
|
|
|
@then("both resources should be in the unsandboxable domain")
|
|
def step_then_both_unsandboxable(context: Any) -> None:
|
|
domain = context.domain_result.get("__unsandboxable__", [])
|
|
assert len(domain) == 2, f"Expected 2 unsandboxable resources, got {len(domain)}"
|
|
|
|
|
|
@then("the cache should contain {count:d} entry")
|
|
def step_then_cache_size_singular(context: Any, count: int) -> None:
|
|
cache = context.boundary_cache or context.manager._boundary_cache
|
|
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
|
|
|
|
|
|
@then("the cache should contain {count:d} entries")
|
|
def step_then_cache_size(context: Any, count: int) -> None:
|
|
cache = context.boundary_cache or context.manager._boundary_cache
|
|
assert cache.size == count, f"Expected cache size {count}, got {cache.size}"
|
|
|
|
|
|
@then('the cached boundary for "{name}" should be "{boundary_name}"')
|
|
def step_then_cached_boundary(context: Any, name: str, boundary_name: str) -> None:
|
|
res = context.resources_by_name[name]
|
|
boundary = context.resources_by_name[boundary_name]
|
|
cache = context.boundary_cache
|
|
result = cache.get_boundary(res, context.resource_registry)
|
|
assert result is not None, f"Cached boundary for '{name}' is None"
|
|
assert result.resource_id == boundary.resource_id
|
|
|
|
|
|
@then("the cached boundary result should be None")
|
|
def step_then_cached_boundary_none(context: Any) -> None:
|
|
assert context.boundary_result is None
|
|
|
|
|
|
@then('the sandbox key should be "{expected_name}"')
|
|
def step_then_sandbox_key(context: Any, expected_name: str) -> None:
|
|
expected = context.resources_by_name[expected_name]
|
|
assert context.sandbox_key_result == expected.resource_id, (
|
|
f"Expected key {expected.resource_id}, got {context.sandbox_key_result}"
|
|
)
|
|
|
|
|
|
@then("both resources should share the same sandbox instance")
|
|
def step_then_same_sandbox_instance(context: Any) -> None:
|
|
assert len(context.boundary_sandbox_results) == 2
|
|
assert context.boundary_sandbox_results[0] is context.boundary_sandbox_results[1], (
|
|
"Expected both resources to share the same sandbox instance"
|
|
)
|
|
|
|
|
|
@then("a NoSandboxBoundaryError should be raised")
|
|
def step_then_no_boundary_error(context: Any) -> None:
|
|
assert context.error is not None, (
|
|
"Expected NoSandboxBoundaryError but no error was raised"
|
|
)
|
|
assert isinstance(context.error, NoSandboxBoundaryError), (
|
|
f"Expected NoSandboxBoundaryError, got {type(context.error).__name__}"
|
|
)
|
|
|
|
|
|
@then("the manager boundary cache should have {count:d} entry")
|
|
def step_then_manager_cache_singular(context: Any, count: int) -> None:
|
|
assert context.manager.boundary_cache_size == count, (
|
|
f"Expected {count} entry, got {context.manager.boundary_cache_size}"
|
|
)
|
|
|
|
|
|
@then("the manager boundary cache should have {count:d} entries")
|
|
def step_then_manager_cache(context: Any, count: int) -> None:
|
|
assert context.manager.boundary_cache_size == count, (
|
|
f"Expected {count} entries, got {context.manager.boundary_cache_size}"
|
|
)
|