"""Step definitions for overlay filesystem sandbox lifecycle tests. Covers the OverlaySandbox implementation including creation, path resolution, commit, rollback, cleanup, and userspace fallback mode. """ from __future__ import annotations import os import re import shutil import tempfile from unittest.mock import patch from behave import given, then, when from cleveragents.infrastructure.sandbox.overlay import OverlaySandbox from cleveragents.infrastructure.sandbox.protocol import ( SandboxCommitError, SandboxCreationError, SandboxRollbackError, SandboxStateError, SandboxStatus, ) # --------------------------------------------------------------------------- # Givens # --------------------------------------------------------------------------- @given("an ovl test directory is initialised") def given_ovl_test_directory(context): """Create a temporary directory with test files for overlay sandbox.""" context.ovl_tmpdir = tempfile.mkdtemp(prefix="ovl-test-") context.add_cleanup(shutil.rmtree, context.ovl_tmpdir) context.ovl_original = os.path.join(context.ovl_tmpdir, "original") os.makedirs(context.ovl_original) # Create test files with open(os.path.join(context.ovl_original, "existing.txt"), "w") as fh: fh.write("original content") with open(os.path.join(context.ovl_original, "to_delete.txt"), "w") as fh: fh.write("delete me") context.ovl_error = None context.ovl_sandbox = None context.ovl_commit_result = None context.ovl_resolved_path = None @given("an ovl non-existent directory") def given_ovl_nonexistent_dir(context): """Set up a path to a non-existent directory.""" context.ovl_nonexistent = os.path.join(context.ovl_tmpdir, "nonexistent") # --------------------------------------------------------------------------- # Whens - Creation # --------------------------------------------------------------------------- @when('an ovl sandbox is created for plan "{plan_id}"') def when_ovl_sandbox_created(context, plan_id: str): """Create an OverlaySandbox and call create().""" context.ovl_error = None context.ovl_sandbox = OverlaySandbox( resource_id="test-resource", original_path=context.ovl_original, ) try: context.ovl_context = context.ovl_sandbox.create(plan_id) except Exception as exc: context.ovl_error = exc @when("an ovl sandbox is created with empty plan_id") def when_ovl_sandbox_empty_plan(context): """Attempt to create sandbox with empty plan_id.""" context.ovl_error = None context.ovl_sandbox = OverlaySandbox( resource_id="test-resource", original_path=context.ovl_original, ) try: context.ovl_sandbox.create("") except ValueError as exc: context.ovl_error = exc @when("an ovl sandbox is prepared with empty resource_id") def when_ovl_sandbox_empty_resource_id(context): """Attempt to instantiate sandbox with empty resource_id.""" context.ovl_error = None try: OverlaySandbox(resource_id="", original_path=context.ovl_original) except ValueError as exc: context.ovl_error = exc @when("an ovl sandbox is prepared with empty original_path") def when_ovl_sandbox_empty_path(context): """Attempt to instantiate sandbox with empty original_path.""" context.ovl_error = None try: OverlaySandbox(resource_id="test-resource", original_path="") except ValueError as exc: context.ovl_error = exc @when('an ovl sandbox is created on the non-existent directory for plan "{plan_id}"') def when_ovl_sandbox_nonexistent(context, plan_id: str): """Create sandbox pointing to a non-existent directory.""" context.ovl_error = None context.ovl_sandbox = OverlaySandbox( resource_id="test-resource", original_path=context.ovl_nonexistent, ) try: context.ovl_sandbox.create(plan_id) except SandboxCreationError as exc: context.ovl_error = exc @when("an ovl sandbox is instantiated") def when_ovl_sandbox_instantiated(context): """Instantiate sandbox without calling create().""" context.ovl_sandbox = OverlaySandbox( resource_id="test-resource", original_path=context.ovl_original, ) # --------------------------------------------------------------------------- # Whens - Path resolution # --------------------------------------------------------------------------- @when('the ovl path "{path}" is resolved') def when_ovl_path_resolved(context, path: str): """Resolve a path in the overlay sandbox.""" context.ovl_error = None try: context.ovl_resolved_path = context.ovl_sandbox.get_path(path) except (ValueError, SandboxStateError) as exc: context.ovl_error = exc @when('the ovl path "{path}" is resolved on a cleaned-up sandbox') def when_ovl_path_on_cleaned(context, path: str): """Resolve a path after sandbox cleanup.""" context.ovl_error = None try: context.ovl_sandbox.get_path(path) except SandboxStateError as exc: context.ovl_error = exc # --------------------------------------------------------------------------- # Whens - File operations in sandbox # --------------------------------------------------------------------------- @when('an ovl file "{filename}" is created in the sandbox with content "{content}"') def when_ovl_file_created(context, filename: str, content: str): """Create a new file in the merged directory.""" merged = context.ovl_sandbox._merged_dir filepath = os.path.join(merged, filename) os.makedirs(os.path.dirname(filepath), exist_ok=True) with open(filepath, "w") as fh: fh.write(content) @when( 'the ovl existing file "{filename}" is modified ' 'in the sandbox with content "{content}"' ) def when_ovl_file_modified(context, filename: str, content: str): """Modify an existing file in the merged directory.""" merged = context.ovl_sandbox._merged_dir filepath = os.path.join(merged, filename) with open(filepath, "w") as fh: fh.write(content) @when('the ovl existing file "{filename}" is deleted from the sandbox') def when_ovl_file_deleted(context, filename: str): """Delete a file from the merged directory.""" merged = context.ovl_sandbox._merged_dir filepath = os.path.join(merged, filename) os.remove(filepath) # --------------------------------------------------------------------------- # Whens - Commit / Rollback / Cleanup # --------------------------------------------------------------------------- @when("the ovl sandbox is committed") def when_ovl_sandbox_committed(context): """Commit sandbox changes.""" context.ovl_error = None try: context.ovl_commit_result = context.ovl_sandbox.commit() except Exception as exc: context.ovl_error = exc @when("the ovl sandbox commit is attempted on cleaned-up sandbox") def when_ovl_commit_on_cleaned(context): """Attempt to commit after cleanup.""" context.ovl_error = None try: context.ovl_sandbox.commit() except SandboxStateError as exc: context.ovl_error = exc @when("the ovl sandbox is rolled back") def when_ovl_sandbox_rolled_back(context): """Roll back the sandbox.""" context.ovl_error = None try: context.ovl_sandbox.rollback() except Exception as exc: context.ovl_error = exc @when("the ovl sandbox rollback is attempted on created sandbox") def when_ovl_rollback_on_created(context): """Attempt rollback from CREATED state (invalid).""" context.ovl_error = None try: context.ovl_sandbox.rollback() except SandboxStateError as exc: context.ovl_error = exc @when("the ovl sandbox is cleaned up") def when_ovl_sandbox_cleaned_up(context): """Clean up the sandbox.""" context.ovl_sandbox.cleanup() @when("the ovl sandbox is cleaned up again") def when_ovl_sandbox_cleaned_up_again(context): """Clean up the sandbox again (idempotency check).""" context.ovl_sandbox.cleanup() # --------------------------------------------------------------------------- # Thens - State # --------------------------------------------------------------------------- @then('the ovl sandbox should be in the "{state}" state') def then_ovl_sandbox_state(context, state: str): """Assert sandbox is in the expected state.""" assert context.ovl_sandbox.status.value == state, ( f"Expected {state}, got {context.ovl_sandbox.status.value}" ) @then('the ovl sandbox status should be "{status}"') def then_ovl_sandbox_status(context, status: str): """Assert sandbox status value.""" assert context.ovl_sandbox.status.value == status # --------------------------------------------------------------------------- # Thens - Context # --------------------------------------------------------------------------- @then('the ovl sandbox context should reference plan "{plan_id}"') def then_ovl_context_plan(context, plan_id: str): """Assert context references the correct plan.""" assert context.ovl_sandbox.context is not None assert context.ovl_sandbox.context.plan_id == plan_id @then('the ovl sandbox context should have strategy metadata "{strategy}"') def then_ovl_context_strategy(context, strategy: str): """Assert context metadata includes strategy.""" assert context.ovl_sandbox.context is not None assert context.ovl_sandbox.context.metadata["strategy"] == strategy @then("the ovl sandbox context should be None") def then_ovl_context_none(context): """Assert context is None before creation.""" assert context.ovl_sandbox.context is None # --------------------------------------------------------------------------- # Thens - Directory structure # --------------------------------------------------------------------------- @then("the ovl sandbox merged path should exist") def then_ovl_merged_exists(context): """Assert the merged directory exists.""" assert context.ovl_sandbox._merged_dir is not None assert os.path.isdir(context.ovl_sandbox._merged_dir) @then("the ovl sandbox should have upper, work, and merged directories") def then_ovl_directory_structure(context): """Assert all overlay layer directories exist.""" assert context.ovl_sandbox._upper_dir is not None assert context.ovl_sandbox._work_dir is not None assert context.ovl_sandbox._merged_dir is not None assert os.path.isdir(context.ovl_sandbox._upper_dir) assert os.path.isdir(context.ovl_sandbox._work_dir) assert os.path.isdir(context.ovl_sandbox._merged_dir) @then("the ovl sandbox base path should not exist") def then_ovl_base_gone(context): """Assert the base directory has been removed.""" if context.ovl_sandbox._base_dir is not None: assert not os.path.exists(context.ovl_sandbox._base_dir) # --------------------------------------------------------------------------- # Thens - Path resolution # --------------------------------------------------------------------------- @then("the ovl resolved path should be inside the merged directory") def then_ovl_path_in_merged(context): """Assert resolved path is within the merged directory.""" assert context.ovl_resolved_path is not None assert context.ovl_sandbox._merged_dir is not None assert context.ovl_resolved_path.startswith(context.ovl_sandbox._merged_dir) # --------------------------------------------------------------------------- # Thens - Errors # --------------------------------------------------------------------------- @then('an ovl ValueError should be raised with message "{msg}"') def then_ovl_value_error(context, msg: str): """Assert a ValueError was raised with expected message.""" assert context.ovl_error is not None, "Expected a ValueError" assert isinstance(context.ovl_error, ValueError) assert msg in str(context.ovl_error) @then("an ovl SandboxCreationError should be raised") def then_ovl_creation_error(context): """Assert a SandboxCreationError was raised.""" assert context.ovl_error is not None, "Expected a SandboxCreationError" assert isinstance(context.ovl_error, SandboxCreationError) @then("an ovl SandboxStateError should be raised") def then_ovl_state_error(context): """Assert a SandboxStateError was raised.""" assert context.ovl_error is not None, "Expected a SandboxStateError" assert isinstance(context.ovl_error, SandboxStateError) # --------------------------------------------------------------------------- # Thens - Commit results # --------------------------------------------------------------------------- @then("the ovl commit result should indicate success") def then_ovl_commit_success(context): """Assert commit was successful.""" assert context.ovl_commit_result is not None assert context.ovl_commit_result.success is True @then("the ovl commit result should have {count:d} changed files") def then_ovl_commit_changed(context, count: int): """Assert commit result has expected number of changed files.""" assert len(context.ovl_commit_result.changed_files) == count @then("the ovl commit result should have {count:d} added files") def then_ovl_commit_added(context, count: int): """Assert commit result has expected number of added files.""" assert len(context.ovl_commit_result.added_files) == count @then("the ovl commit result should have {count:d} deleted files") def then_ovl_commit_deleted(context, count: int): """Assert commit result has expected number of deleted files.""" assert len(context.ovl_commit_result.deleted_files) == count # --------------------------------------------------------------------------- # Thens - File assertions # --------------------------------------------------------------------------- @then( 'the ovl file "{filename}" should exist in the original ' 'directory with content "{content}"' ) def then_ovl_file_in_original(context, filename: str, content: str): """Assert file exists in original directory with expected content.""" filepath = os.path.join(context.ovl_original, filename) assert os.path.isfile(filepath), f"File not found: {filepath}" with open(filepath) as fh: assert fh.read() == content @then('the ovl file "{filename}" in the original should have content "{content}"') def then_ovl_file_content(context, filename: str, content: str): """Assert file in original has expected content.""" filepath = os.path.join(context.ovl_original, filename) with open(filepath) as fh: assert fh.read() == content @then('the ovl file "{filename}" should not exist in the original directory') def then_ovl_file_not_in_original(context, filename: str): """Assert file does not exist in original directory.""" filepath = os.path.join(context.ovl_original, filename) assert not os.path.exists(filepath) @then('the ovl file "{filename}" should not exist in the sandbox') def then_ovl_file_not_in_sandbox(context, filename: str): """Assert file does not exist in the merged directory.""" merged = context.ovl_sandbox._merged_dir assert merged is not None filepath = os.path.join(merged, filename) assert not os.path.exists(filepath) # --------------------------------------------------------------------------- # Thens - ULID / Protocol # --------------------------------------------------------------------------- _ULID_RE = re.compile(r"^[0-9A-Z]{26}$") @then("the ovl sandbox_id should be a valid ULID") def then_ovl_valid_ulid(context): """Assert sandbox_id matches ULID format.""" assert _ULID_RE.match(context.ovl_sandbox.sandbox_id), ( f"Not a valid ULID: {context.ovl_sandbox.sandbox_id}" ) # --------------------------------------------------------------------------- # Thens - Fallback mode # --------------------------------------------------------------------------- @then("the ovl sandbox should use userspace fallback") def then_ovl_fallback(context): """Assert sandbox is using userspace fallback (common in CI).""" # In CI/containers, OverlayFS is typically not available assert context.ovl_sandbox._use_real_overlay is False # --------------------------------------------------------------------------- # Coverage boost steps # --------------------------------------------------------------------------- @when('the ovl sandbox is committed with message "{msg}"') def when_ovl_commit_with_message(context, msg: str): """Commit the overlay sandbox with a message.""" context.ovl_commit_result = context.ovl_sandbox.commit(message=msg) @then('the ovl commit metadata should contain message "{msg}"') def then_ovl_commit_metadata_message(context, msg: str): """Assert commit result metadata contains the message.""" assert context.ovl_commit_result.metadata.get("message") == msg @given("an ovl sandbox with merged_dir forced to None") def given_ovl_sandbox_merged_dir_none(context): """Create an OverlaySandbox with merged_dir forced to None.""" tmpdir = tempfile.mkdtemp(prefix="ovl-none-") os.makedirs(os.path.join(tmpdir, "orig")) context.ovl_sandbox = OverlaySandbox( resource_id="res-none", original_path=os.path.join(tmpdir, "orig"), ) context.ovl_sandbox.create(plan_id="plan-none") # Force merged_dir to None after creation context.ovl_sandbox._merged_dir = None context.ovl_sandbox._status = SandboxStatus.ACTIVE context.ovl_error = None context.ovl_tmpdir = tmpdir @when('ovl get_path is called with "{path}" expecting an error') def when_ovl_get_path_error(context, path: str): """Call get_path expecting an error.""" try: context.ovl_sandbox.get_path(path) context.ovl_error = None except SandboxStateError as exc: context.ovl_error = exc @then('an ovl SandboxStateError should be raised with message "{msg}"') def then_ovl_sandbox_state_error(context, msg: str): """Assert a SandboxStateError was raised with the expected message.""" assert context.ovl_error is not None, "Expected SandboxStateError but none raised" assert msg in str(context.ovl_error), f"Expected '{msg}' in '{context.ovl_error}'" @given('an ovl sandbox is created and activated for plan "{plan_id}"') def given_ovl_sandbox_active(context, plan_id: str): """Create an overlay sandbox and activate it via get_path.""" context.ovl_sandbox = OverlaySandbox( resource_id="res-active", original_path=context.ovl_tmpdir, ) context.ovl_sandbox.create(plan_id=plan_id) context.ovl_sandbox.get_path("existing.txt") context.ovl_error = None @given("ovl shutil.copy2 is patched to raise OSError") def given_ovl_copy2_raises(context): """Patch shutil.copy2 to raise OSError.""" context.ovl_copy2_patcher = patch( "cleveragents.infrastructure.sandbox.overlay.shutil.copy2", side_effect=OSError("disk full"), ) context.ovl_copy2_patcher.start() @when("the ovl sandbox commit is attempted") def when_ovl_commit_attempted(context): """Attempt to commit, catching errors.""" # Ensure there's a changed file to trigger the copy2 path merged = context.ovl_sandbox._merged_dir if merged: fpath = os.path.join(merged, "existing.txt") with open(fpath, "w") as f: f.write("modified for commit error test") try: context.ovl_sandbox.commit() context.ovl_error = None except SandboxCommitError as exc: context.ovl_error = exc finally: if hasattr(context, "ovl_copy2_patcher"): context.ovl_copy2_patcher.stop() @then("an ovl SandboxCommitError should be raised") def then_ovl_commit_error(context): """Assert a SandboxCommitError was raised.""" assert context.ovl_error is not None, "Expected SandboxCommitError" assert isinstance(context.ovl_error, SandboxCommitError) @given("ovl shutil.rmtree is patched to raise OSError for rollback") def given_ovl_rmtree_raises(context): """Patch shutil.rmtree to raise OSError.""" context.ovl_rmtree_patcher = patch( "cleveragents.infrastructure.sandbox.overlay.shutil.rmtree", side_effect=OSError("permission denied"), ) context.ovl_rmtree_patcher.start() @when("the ovl sandbox rollback is attempted") def when_ovl_rollback_attempted(context): """Attempt to rollback, catching errors.""" try: context.ovl_sandbox.rollback() context.ovl_error = None except SandboxRollbackError as exc: context.ovl_error = exc finally: if hasattr(context, "ovl_rmtree_patcher"): context.ovl_rmtree_patcher.stop() @then("an ovl SandboxRollbackError should be raised") def then_ovl_rollback_error(context): """Assert a SandboxRollbackError was raised.""" assert context.ovl_error is not None, "Expected SandboxRollbackError" assert isinstance(context.ovl_error, SandboxRollbackError) @given("ovl shutil.copytree is patched to raise OSError") def given_ovl_copytree_raises(context): """Patch shutil.copytree to raise OSError.""" context.ovl_copytree_patcher = patch( "cleveragents.infrastructure.sandbox.overlay.shutil.copytree", side_effect=OSError("I/O error"), ) context.ovl_copytree_patcher.start() @when('an ovl sandbox create is attempted for plan "{plan_id}"') def when_ovl_create_attempted(context, plan_id: str): """Attempt to create a sandbox, catching errors.""" context.ovl_sandbox = OverlaySandbox( resource_id="res-create-err", original_path=context.ovl_tmpdir, ) try: context.ovl_sandbox.create(plan_id=plan_id) context.ovl_error = None except SandboxCreationError as exc: context.ovl_error = exc finally: if hasattr(context, "ovl_copytree_patcher"): context.ovl_copytree_patcher.stop() # --------------------------------------------------------------------------- # Atomic rollback from COMMITTED steps # --------------------------------------------------------------------------- @when("the ovl sandbox is rolled back from committed") def when_ovl_sandbox_rolled_back_from_committed(context): """Roll back the sandbox from COMMITTED state.""" assert context.ovl_sandbox.status == SandboxStatus.COMMITTED, ( f"Expected COMMITTED, got {context.ovl_sandbox.status.value}" ) context.ovl_error = None try: context.ovl_sandbox.rollback() except Exception as exc: context.ovl_error = exc @then('the ovl original file "{filename}" should contain "{expected}"') def then_ovl_original_contains(context, filename: str, expected: str): """Verify content of a file in the original directory.""" path = os.path.join(context.ovl_original, filename) assert os.path.isfile(path), f"Expected file at {path}" with open(path) as fh: content = fh.read() assert content == expected, f"Expected '{expected}', got '{content}'" @then('the ovl merged directory should not contain stale file "{filename}"') def then_ovl_merged_no_stale(context, filename: str): """Verify a file does NOT appear in the merged directory after rollback. After rollback from COMMITTED the merged directory is reset from the restored original, so sandbox-only files should be gone. """ merged = context.ovl_sandbox._merged_dir assert merged is not None, "Merged directory is None" stale_path = os.path.join(merged, filename) assert not os.path.exists(stale_path), ( f"Stale file {filename} should not exist in merged dir after rollback" )