forked from HAL9000/cleveragents-core
8a87262f86
## Summary Implement the overlay filesystem sandbox strategy with OverlayFS support and userspace fallback. ### Implementation **`OverlaySandbox`** (`infrastructure/sandbox/overlay.py`, 497 lines): - Detects OverlayFS availability at runtime via `/proc/filesystems` + `os.geteuid() == 0` check - **Real OverlayFS mode** (requires root): creates upper/work/merged dirs, mounts overlay filesystem, captures writes in upper layer - **Userspace fallback** (default in CI/containers): `shutil.copytree` the original into merged dir, tracks changes via `filecmp` diff on commit - `create()`: sets up directory structure, mounts if available - `commit()`: copies changed/added files from overlay to original, removes deleted files - `rollback()`: unmounts (or removes) merged, recreates from scratch - `cleanup()`: unmounts, removes all temp dirs, idempotent ### Domain Model Updates - Added `OVERLAY = "overlay"` to `SandboxStrategy` enum in both `resource_type.py` and `resource.py` - Added `STRATEGY_OVERLAY` to `SandboxFactory`, registered for `fs-mount`, `fs-directory`, `fs-file` resources ### Tests - **22 Behave scenarios**: full lifecycle (create/commit/rollback/cleanup), status transitions, path traversal guard, fallback detection, error handling - **6 Robot integration tests**: end-to-end overlay sandbox operations ### Quality Gates | Session | Result | |---|---| | `nox -s lint` | PASS | | `nox -s typecheck` | PASS (0 errors) | | `nox -s unit_tests` | PASS (10,917 scenarios) | | `nox -s coverage_report` | 97% (>= 97%) | Closes #880 Reviewed-on: cleveragents/cleveragents-core#994 Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
397 lines
15 KiB
Python
397 lines
15 KiB
Python
"""Step definitions for sandbox factory strategy routing coverage.
|
|
|
|
Covers lines in ``factory.py``:
|
|
- Empty resource_id / original_path validation
|
|
- none strategy -> NoSandbox
|
|
- git_worktree strategy -> GitWorktreeSandbox
|
|
- copy_on_write strategy -> CopyOnWriteSandbox
|
|
- transaction_rollback -> TransactionSandbox
|
|
- snapshot -> NotImplementedError
|
|
- overlay / versioning (removed) -> ValueError (unknown)
|
|
- Unknown strategy -> ValueError
|
|
- is_supported() for all strategies
|
|
- get_supported_strategies() for spec-aligned resource types
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
|
|
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
|
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
|
|
from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStatus
|
|
from cleveragents.infrastructure.sandbox.transaction_sandbox import TransactionSandbox
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Givens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the sandbox factory is available")
|
|
def given_sandbox_factory_available(context):
|
|
"""Instantiate a SandboxFactory for the scenario."""
|
|
context.sandbox_factory = SandboxFactory()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whens - create_sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("a sandbox is requested with an empty resource identifier")
|
|
def when_factory_empty_resource_id(context):
|
|
"""Attempt to create a sandbox with an empty resource_id."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id="",
|
|
original_path="/some/path",
|
|
sandbox_strategy="none",
|
|
)
|
|
except ValueError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
@when("a sandbox is requested with an empty resource path")
|
|
def when_factory_empty_path(context):
|
|
"""Attempt to create a sandbox with an empty original_path."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id="res-1",
|
|
original_path="",
|
|
sandbox_strategy="none",
|
|
)
|
|
except ValueError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" using the none strategy'
|
|
)
|
|
def when_factory_none_strategy(context, res_id: str, path: str):
|
|
"""Request a sandbox with the 'none' (passthrough) strategy."""
|
|
context.factory_error = None
|
|
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="none",
|
|
)
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the git worktree strategy"
|
|
)
|
|
def when_factory_git_worktree(context, res_id: str, path: str):
|
|
"""Request a sandbox with the 'git_worktree' strategy."""
|
|
context.factory_error = None
|
|
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="git_worktree",
|
|
)
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the copy-on-write strategy"
|
|
)
|
|
def when_factory_copy_on_write(context, res_id: str, path: str):
|
|
"""Request a sandbox with the 'copy_on_write' strategy."""
|
|
context.factory_error = None
|
|
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="copy_on_write",
|
|
)
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the overlay strategy"
|
|
)
|
|
def when_factory_overlay(context, res_id: str, path: str):
|
|
"""Request a sandbox with the removed 'overlay' strategy (now unknown)."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="overlay", # type: ignore[arg-type]
|
|
)
|
|
except ValueError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the transaction rollback strategy"
|
|
)
|
|
def when_factory_transaction_rollback(context, res_id: str, path: str):
|
|
"""Request a sandbox with the 'transaction_rollback' strategy."""
|
|
context.factory_error = None
|
|
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="transaction_rollback",
|
|
)
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the versioning strategy"
|
|
)
|
|
def when_factory_versioning(context, res_id: str, path: str):
|
|
"""Request a sandbox with the removed 'versioning' strategy (now unknown)."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="versioning", # type: ignore[arg-type]
|
|
)
|
|
except ValueError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using the snapshot strategy"
|
|
)
|
|
def when_factory_snapshot(context, res_id: str, path: str):
|
|
"""Request a sandbox with the 'snapshot' strategy (not yet implemented)."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="snapshot",
|
|
)
|
|
except NotImplementedError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" at "{path}" '
|
|
"using an unrecognised strategy"
|
|
)
|
|
def when_factory_unknown_strategy(context, res_id: str, path: str):
|
|
"""Request a sandbox with a completely bogus strategy name."""
|
|
context.factory_error = None
|
|
try:
|
|
context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="quantum_entanglement", # type: ignore[arg-type]
|
|
)
|
|
except ValueError as exc:
|
|
context.factory_error = exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whens - is_supported
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("the factory is asked whether the none strategy is supported")
|
|
def when_factory_is_supported_none(context):
|
|
"""Check support for the 'none' strategy."""
|
|
context.factory_supported = SandboxFactory.is_supported("none")
|
|
|
|
|
|
@when("the factory is asked whether the git worktree strategy is supported")
|
|
def when_factory_is_supported_git(context):
|
|
"""Check support for the 'git_worktree' strategy."""
|
|
context.factory_supported = SandboxFactory.is_supported("git_worktree")
|
|
|
|
|
|
@when("the factory is asked whether the copy-on-write strategy is supported")
|
|
def when_factory_is_supported_cow(context):
|
|
"""Check support for the 'copy_on_write' strategy."""
|
|
context.factory_supported = SandboxFactory.is_supported("copy_on_write")
|
|
|
|
|
|
@when("the factory is asked whether the overlay strategy is supported")
|
|
def when_factory_is_supported_overlay(context):
|
|
"""Check support for the 'overlay' strategy."""
|
|
context.factory_supported = SandboxFactory.is_supported("overlay")
|
|
|
|
|
|
@when("the factory is asked whether an unrecognised strategy is supported")
|
|
def when_factory_is_supported_unknown(context):
|
|
"""Check support for a made-up strategy."""
|
|
context.factory_supported = SandboxFactory.is_supported("quantum_entanglement")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Whens - get_supported_strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the compatible strategies for a "{resource_type}" resource are queried')
|
|
def when_factory_get_strategies(context, resource_type: str):
|
|
"""Query the compatible strategies for a known resource type."""
|
|
context.factory_strategies = SandboxFactory.get_supported_strategies(resource_type)
|
|
|
|
|
|
@when('the compatible strategies for an "{resource_type}" resource are queried')
|
|
def when_factory_get_strategies_an(context, resource_type: str):
|
|
"""Query the compatible strategies for a known resource type (article 'an')."""
|
|
context.factory_strategies = SandboxFactory.get_supported_strategies(resource_type)
|
|
|
|
|
|
@when("the compatible strategies for an unknown resource type are queried")
|
|
def when_factory_get_strategies_unknown(context):
|
|
"""Query the compatible strategies for a resource type not in the map."""
|
|
context.factory_strategies = SandboxFactory.get_supported_strategies(
|
|
"quantum_computer"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - validation errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should reject the request due to a missing resource identifier")
|
|
def then_factory_missing_resource_id(context):
|
|
"""Assert a ValueError about resource_id was raised."""
|
|
assert context.factory_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.factory_error, ValueError)
|
|
assert "resource_id" in str(context.factory_error)
|
|
|
|
|
|
@then("the factory should reject the request due to a missing resource path")
|
|
def then_factory_missing_path(context):
|
|
"""Assert a ValueError about original_path was raised."""
|
|
assert context.factory_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.factory_error, ValueError)
|
|
assert "original_path" in str(context.factory_error)
|
|
|
|
|
|
@then("the factory should reject the request due to an unknown strategy")
|
|
def then_factory_unknown_strategy(context):
|
|
"""Assert a ValueError about an unknown strategy was raised."""
|
|
assert context.factory_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.factory_error, ValueError)
|
|
assert "unknown" in str(context.factory_error).lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - none strategy produces passthrough sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should produce a passthrough sandbox")
|
|
def then_factory_produces_nosandbox(context):
|
|
"""Assert the returned sandbox is a NoSandbox instance."""
|
|
assert isinstance(context.factory_sandbox, NoSandbox)
|
|
|
|
|
|
@then("the produced sandbox should be in the pending state")
|
|
def then_factory_sandbox_pending(context):
|
|
"""Assert the sandbox is in PENDING status."""
|
|
assert context.factory_sandbox.status == SandboxStatus.PENDING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - git worktree strategy produces sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should produce a git worktree sandbox")
|
|
def then_factory_produces_git_worktree(context):
|
|
"""Assert the returned sandbox is a GitWorktreeSandbox instance."""
|
|
assert isinstance(context.factory_sandbox, GitWorktreeSandbox)
|
|
|
|
|
|
@then("the produced git worktree sandbox should be in the pending state")
|
|
def then_factory_git_worktree_pending(context):
|
|
"""Assert the git worktree sandbox is in PENDING status."""
|
|
assert context.factory_sandbox.status == SandboxStatus.PENDING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - copy-on-write strategy produces sandbox
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should produce a copy-on-write sandbox")
|
|
def then_factory_produces_cow(context):
|
|
"""Assert the returned sandbox is a CopyOnWriteSandbox instance."""
|
|
assert isinstance(context.factory_sandbox, CopyOnWriteSandbox)
|
|
|
|
|
|
@then("the produced copy-on-write sandbox should be in the pending state")
|
|
def then_factory_cow_pending(context):
|
|
"""Assert the copy-on-write sandbox is in PENDING status."""
|
|
assert context.factory_sandbox.status == SandboxStatus.PENDING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - not yet implemented
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should indicate the strategy is not yet implemented")
|
|
def then_factory_not_implemented(context):
|
|
"""Assert a NotImplementedError was raised."""
|
|
assert context.factory_error is not None, "Expected a NotImplementedError"
|
|
assert isinstance(context.factory_error, NotImplementedError)
|
|
|
|
|
|
@then("the factory should produce a transaction sandbox in the pending state")
|
|
def then_factory_produces_transaction_sandbox(context):
|
|
"""Assert the returned sandbox is a TransactionSandbox in PENDING."""
|
|
assert isinstance(context.factory_sandbox, TransactionSandbox)
|
|
assert context.factory_sandbox.status == SandboxStatus.PENDING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - is_supported
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should confirm the strategy is supported")
|
|
def then_factory_supported_true(context):
|
|
"""Assert the strategy is reported as supported."""
|
|
assert context.factory_supported is True
|
|
|
|
|
|
@then("the factory should indicate the strategy is not supported")
|
|
def then_factory_supported_false(context):
|
|
"""Assert the strategy is reported as not supported."""
|
|
assert context.factory_supported is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thens - get_supported_strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the factory should return git worktree, copy-on-write, and none as compatible")
|
|
def then_factory_git_checkout_strategies(context):
|
|
"""Assert the git-checkout strategies are correct."""
|
|
assert context.factory_strategies == ["git_worktree", "copy_on_write", "none"]
|
|
|
|
|
|
@then("the factory should return copy-on-write and none as compatible")
|
|
def then_factory_cow_none_strategies(context):
|
|
"""Assert the resource type supports copy_on_write and none."""
|
|
assert context.factory_strategies == ["copy_on_write", "none"]
|
|
|
|
|
|
@then("the factory should return copy-on-write, overlay, and none as compatible")
|
|
def then_factory_cow_overlay_none_strategies(context):
|
|
"""Assert the resource type supports copy_on_write, overlay, and none."""
|
|
assert context.factory_strategies == ["copy_on_write", "overlay", "none"]
|
|
|
|
|
|
@then("the factory should return only the none strategy as compatible")
|
|
def then_factory_none_only_strategies(context):
|
|
"""Assert only the 'none' strategy is returned."""
|
|
assert context.factory_strategies == ["none"]
|