diff --git a/CHANGELOG.md b/CHANGELOG.md index c9926050..620ae8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added overlay filesystem sandbox strategy with real OverlayFS support + and userspace copy-tree fallback. Includes lifecycle management, diff + computation, and sandbox factory registration. (#880) - Added CLI polish infrastructure: shared constants.py (exit codes, format constants), centralized errors.py (cli_error, cli_not_found, cli_warning), and completion command for shell tab-completion generation. (#861) diff --git a/features/consolidated_resource.feature b/features/consolidated_resource.feature index c1297159..bb74d8fa 100644 --- a/features/consolidated_resource.feature +++ b/features/consolidated_resource.feature @@ -383,8 +383,8 @@ Feature: Consolidated Resource # SandboxStrategy enum - Scenario: SandboxStrategy has exactly five values - Then the SandboxStrategy enum should have values "git_worktree, copy_on_write, transaction_rollback, snapshot, none" + Scenario: SandboxStrategy has exactly six values + Then the SandboxStrategy enum should have values "git_worktree, copy_on_write, transaction_rollback, snapshot, overlay, none" Scenario Outline: SandboxStrategy accepts valid values @@ -397,12 +397,13 @@ Feature: Consolidated Resource | copy_on_write | | transaction_rollback | | snapshot | + | overlay | | none | - Scenario: SandboxStrategy rejects overlay - When I try to create a SandboxStrategy with value "overlay" - Then a resource validation error should be raised + Scenario: SandboxStrategy accepts overlay + Given a SandboxStrategy value of "overlay" + Then the SandboxStrategy string value should be "overlay" Scenario: SandboxStrategy rejects versioning diff --git a/features/consolidated_sandbox.feature b/features/consolidated_sandbox.feature index 15338621..deffd46f 100644 --- a/features/consolidated_sandbox.feature +++ b/features/consolidated_sandbox.feature @@ -758,10 +758,10 @@ Feature: Consolidated Sandbox Then the factory should reject the request due to an unknown strategy - Scenario: The overlay strategy is now unknown and rejected + Scenario: The overlay strategy is supported Given the sandbox factory is available - When a sandbox is requested for resource "fs-root" at "/mnt/data" using the overlay strategy - Then the factory should reject the request due to an unknown strategy + When the factory is asked whether the overlay strategy is supported + Then the factory should confirm the strategy is supported Scenario: The versioning strategy is now unknown and rejected @@ -810,22 +810,22 @@ Feature: Consolidated Sandbox Then the factory should return only the none strategy as compatible - Scenario: A fs-mount resource supports copy-on-write and none + Scenario: A fs-mount resource supports copy-on-write, overlay, and none Given the sandbox factory is available When the compatible strategies for a "fs-mount" resource are queried - Then the factory should return copy-on-write and none as compatible + Then the factory should return copy-on-write, overlay, and none as compatible - Scenario: A fs-directory resource supports copy-on-write and none + Scenario: A fs-directory resource supports copy-on-write, overlay, and none Given the sandbox factory is available When the compatible strategies for a "fs-directory" resource are queried - Then the factory should return copy-on-write and none as compatible + Then the factory should return copy-on-write, overlay, and none as compatible - Scenario: A fs-file resource supports copy-on-write and none + Scenario: A fs-file resource supports copy-on-write, overlay, and none Given the sandbox factory is available When the compatible strategies for a "fs-file" resource are queried - Then the factory should return copy-on-write and none as compatible + Then the factory should return copy-on-write, overlay, and none as compatible Scenario: An api_endpoint resource supports only the none strategy diff --git a/features/overlay_sandbox.feature b/features/overlay_sandbox.feature new file mode 100644 index 00000000..9de356ac --- /dev/null +++ b/features/overlay_sandbox.feature @@ -0,0 +1,205 @@ +Feature: Overlay filesystem sandbox lifecycle + As a developer + I want a sandbox that isolates plan changes using overlay filesystem semantics + So that changes are captured in an upper layer and merged on commit + + # --- Creation --- + + Scenario: Create an overlay sandbox + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + Then the ovl sandbox should be in the "created" state + And the ovl sandbox context should reference plan "plan-001" + And the ovl sandbox context should have strategy metadata "overlay" + And the ovl sandbox merged path should exist + + Scenario: Creating an overlay sandbox with empty plan_id raises ValueError + Given an ovl test directory is initialised + When an ovl sandbox is created with empty plan_id + Then an ovl ValueError should be raised with message "plan_id cannot be empty" + + Scenario: Creating an overlay sandbox with empty resource_id raises ValueError + Given an ovl test directory is initialised + When an ovl sandbox is prepared with empty resource_id + Then an ovl ValueError should be raised with message "resource_id cannot be empty" + + Scenario: Creating an overlay sandbox with empty original_path raises ValueError + Given an ovl test directory is initialised + When an ovl sandbox is prepared with empty original_path + Then an ovl ValueError should be raised with message "original_path cannot be empty" + + Scenario: Creating an overlay sandbox on a non-existent directory raises SandboxCreationError + Given an ovl test directory is initialised + Given an ovl non-existent directory + When an ovl sandbox is created on the non-existent directory for plan "plan-001" + Then an ovl SandboxCreationError should be raised + + # --- Directory structure --- + + Scenario: Overlay sandbox creates correct directory structure + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + Then the ovl sandbox should have upper, work, and merged directories + + # --- Path resolution --- + + Scenario: Resolve a path in the overlay merged directory + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl path "data/file.txt" is resolved + Then the ovl resolved path should be inside the merged directory + And the ovl sandbox should be in the "active" state + + Scenario: Path traversal is rejected + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl path "../etc/passwd" is resolved + Then an ovl ValueError should be raised with message "Path traversal not allowed" + + Scenario: Resolving a path on a cleaned-up sandbox raises SandboxStateError + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox is cleaned up + And the ovl path "file.txt" is resolved on a cleaned-up sandbox + Then an ovl SandboxStateError should be raised + + # --- Commit --- + + Scenario: Commit with no changes produces empty result + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox is committed + Then the ovl commit result should indicate success + And the ovl commit result should have 0 changed files + And the ovl commit result should have 0 added files + And the ovl commit result should have 0 deleted files + + Scenario: Commit with a new file syncs it to original + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And an ovl file "new_file.txt" is created in the sandbox with content "hello world" + And the ovl sandbox is committed + Then the ovl commit result should indicate success + And the ovl commit result should have 1 added files + And the ovl file "new_file.txt" should exist in the original directory with content "hello world" + + Scenario: Commit with a modified file syncs the change + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl existing file "existing.txt" is modified in the sandbox with content "updated" + And the ovl sandbox is committed + Then the ovl commit result should indicate success + And the ovl commit result should have 1 changed files + And the ovl file "existing.txt" in the original should have content "updated" + + Scenario: Commit with a deleted file removes it from original + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl existing file "existing.txt" is deleted from the sandbox + And the ovl sandbox is committed + Then the ovl commit result should indicate success + And the ovl commit result should have 1 deleted files + And the ovl file "existing.txt" should not exist in the original directory + + Scenario: Commit on a cleaned-up sandbox raises SandboxStateError + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox is cleaned up + And the ovl sandbox commit is attempted on cleaned-up sandbox + Then an ovl SandboxStateError should be raised + + # --- Rollback --- + + Scenario: Rollback restores the sandbox to original state + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And an ovl file "temp.txt" is created in the sandbox with content "temporary" + And the ovl path "temp.txt" is resolved + And the ovl sandbox is rolled back + Then the ovl sandbox should be in the "rolled_back" state + And the ovl file "temp.txt" should not exist in the sandbox + + Scenario: Rollback on a non-active sandbox raises SandboxStateError + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox rollback is attempted on created sandbox + Then an ovl SandboxStateError should be raised + + # --- Cleanup --- + + Scenario: Cleanup removes the overlay directory + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox is cleaned up + Then the ovl sandbox should be in the "cleaned_up" state + And the ovl sandbox base path should not exist + + Scenario: Cleanup is idempotent + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + And the ovl sandbox is cleaned up + And the ovl sandbox is cleaned up again + Then the ovl sandbox should be in the "cleaned_up" state + + # --- Protocol properties --- + + Scenario: Overlay sandbox has a unique ULID identifier + Given an ovl test directory is initialised + When an ovl sandbox is instantiated + Then the ovl sandbox_id should be a valid ULID + And the ovl sandbox status should be "pending" + And the ovl sandbox context should be None + + # --- Fallback mode --- + + Scenario: Fallback mode works when OverlayFS is unavailable + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-fallback" + Then the ovl sandbox should use userspace fallback + + # --- Coverage boost: edge cases --- + + Scenario: Commit with a message stores it in metadata + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-msg" + And an ovl file "new.txt" is created in the sandbox with content "data" + And the ovl sandbox is committed with message "snapshot before deploy" + Then the ovl commit result should indicate success + And the ovl commit metadata should contain message "snapshot before deploy" + + Scenario: get_path raises SandboxStateError when merged_dir is None + Given an ovl sandbox with merged_dir forced to None + When ovl get_path is called with "file.txt" expecting an error + Then an ovl SandboxStateError should be raised with message "merged directory not set" + + Scenario: Commit wraps OSError into SandboxCommitError + Given an ovl test directory is initialised + And an ovl sandbox is created and activated for plan "plan-commiterr" + And ovl shutil.copy2 is patched to raise OSError + When the ovl sandbox commit is attempted + Then an ovl SandboxCommitError should be raised + And the ovl sandbox should be in the "errored" state + + Scenario: Rollback wraps OSError into SandboxRollbackError + Given an ovl test directory is initialised + And an ovl sandbox is created and activated for plan "plan-rberr" + And ovl shutil.rmtree is patched to raise OSError for rollback + When the ovl sandbox rollback is attempted + Then an ovl SandboxRollbackError should be raised + And the ovl sandbox should be in the "errored" state + + Scenario: Create wraps OSError into SandboxCreationError + Given an ovl test directory is initialised + And ovl shutil.copytree is patched to raise OSError + When an ovl sandbox create is attempted for plan "plan-createerr" + Then an ovl SandboxCreationError should be raised + And the ovl sandbox should be in the "errored" state + + # --- Status transitions --- + + Scenario: Status transitions follow protocol + Given an ovl test directory is initialised + When an ovl sandbox is created for plan "plan-001" + Then the ovl sandbox should be in the "created" state + When the ovl path "existing.txt" is resolved + Then the ovl sandbox should be in the "active" state diff --git a/features/steps/overlay_sandbox_steps.py b/features/steps/overlay_sandbox_steps.py new file mode 100644 index 00000000..1bdc478c --- /dev/null +++ b/features/steps/overlay_sandbox_steps.py @@ -0,0 +1,615 @@ +"""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() diff --git a/features/steps/sandbox_factory_coverage_steps.py b/features/steps/sandbox_factory_coverage_steps.py index fe272ed9..d9822672 100644 --- a/features/steps/sandbox_factory_coverage_steps.py +++ b/features/steps/sandbox_factory_coverage_steps.py @@ -214,6 +214,12 @@ def when_factory_is_supported_cow(context): 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.""" @@ -378,6 +384,12 @@ def then_factory_cow_none_strategies(context): 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.""" diff --git a/robot/helper_overlay_sandbox.py b/robot/helper_overlay_sandbox.py new file mode 100644 index 00000000..3545e6ba --- /dev/null +++ b/robot/helper_overlay_sandbox.py @@ -0,0 +1,254 @@ +"""Helper utilities for overlay sandbox Robot integration tests. + +Covers the OverlaySandbox implementation: creation, path resolution, +commit, rollback, cleanup, and userspace fallback mode. +""" + +from __future__ import annotations + +import os +import sys +import tempfile + +from cleveragents.infrastructure.sandbox.overlay import OverlaySandbox +from cleveragents.infrastructure.sandbox.protocol import ( + SandboxCreationError, + SandboxStateError, + SandboxStatus, +) + + +def _overlay_lifecycle() -> None: + """Integration test: full overlay sandbox lifecycle.""" + tmpdir = tempfile.mkdtemp(prefix="ovl-robot-") + original = os.path.join(tmpdir, "original") + os.makedirs(original) + + with open(os.path.join(original, "readme.txt"), "w") as fh: + fh.write("original readme") + + sandbox = OverlaySandbox(resource_id="robot-res", original_path=original) + assert sandbox.status == SandboxStatus.PENDING + assert sandbox.context is None + + ctx = sandbox.create("plan-robot-001") + assert sandbox.status == SandboxStatus.CREATED + assert ctx.plan_id == "plan-robot-001" + assert ctx.metadata["strategy"] == "overlay" + + # Resolve path -> transitions to ACTIVE + resolved = sandbox.get_path("readme.txt") + assert sandbox.status == SandboxStatus.ACTIVE + assert resolved.endswith("readme.txt") + + # Modify file in merged directory + with open(resolved, "w") as fh: + fh.write("modified readme") + + # Add a new file + new_path = sandbox.get_path("new_file.txt") + with open(new_path, "w") as fh: + fh.write("new content") + + # Commit + result = sandbox.commit() + assert sandbox.status == SandboxStatus.COMMITTED + assert result.success is True + assert len(result.changed_files) == 1 + assert len(result.added_files) == 1 + assert len(result.deleted_files) == 0 + + # Verify changes in original + with open(os.path.join(original, "readme.txt")) as fh: + assert fh.read() == "modified readme" + with open(os.path.join(original, "new_file.txt")) as fh: + assert fh.read() == "new content" + + # Cleanup + sandbox.cleanup() + assert sandbox.status == SandboxStatus.CLEANED_UP + + # Idempotent cleanup + sandbox.cleanup() + assert sandbox.status == SandboxStatus.CLEANED_UP + + print("overlay-lifecycle-ok") + + +def _overlay_rollback() -> None: + """Integration test: overlay sandbox rollback.""" + tmpdir = tempfile.mkdtemp(prefix="ovl-robot-rb-") + original = os.path.join(tmpdir, "original") + os.makedirs(original) + + with open(os.path.join(original, "data.txt"), "w") as fh: + fh.write("original data") + + sandbox = OverlaySandbox(resource_id="rb-res", original_path=original) + sandbox.create("plan-robot-rb") + + # Make change and activate + merged = sandbox.get_path("data.txt") + with open(merged, "w") as fh: + fh.write("modified data") + + # Rollback + sandbox.rollback() + assert sandbox.status == SandboxStatus.ROLLED_BACK + + # Verify merged directory is restored by directly checking + # the merged dir (get_path not available in ROLLED_BACK state) + assert sandbox._merged_dir is not None + restored_path = os.path.join(sandbox._merged_dir, "data.txt") + with open(restored_path) as fh: + assert fh.read() == "original data" + + sandbox.cleanup() + print("overlay-rollback-ok") + + +def _overlay_validation() -> None: + """Integration test: overlay sandbox input validation.""" + # Empty resource_id + try: + OverlaySandbox(resource_id="", original_path="/tmp") + print("FAIL: Expected ValueError for empty resource_id") + return + except ValueError: + pass + + # Empty original_path + try: + OverlaySandbox(resource_id="res", original_path="") + print("FAIL: Expected ValueError for empty original_path") + return + except ValueError: + pass + + # Non-existent directory + sandbox = OverlaySandbox(resource_id="res", original_path="/nonexistent/path") + try: + sandbox.create("plan-1") + print("FAIL: Expected SandboxCreationError") + return + except SandboxCreationError: + pass + + # Empty plan_id + tmpdir = tempfile.mkdtemp(prefix="ovl-val-") + os.makedirs(os.path.join(tmpdir, "orig")) + sandbox2 = OverlaySandbox( + resource_id="res", original_path=os.path.join(tmpdir, "orig") + ) + try: + sandbox2.create("") + print("FAIL: Expected ValueError for empty plan_id") + return + except ValueError: + pass + + print("overlay-validation-ok") + + +def _overlay_directory_structure() -> None: + """Integration test: overlay sandbox creates correct structure.""" + tmpdir = tempfile.mkdtemp(prefix="ovl-robot-struct-") + original = os.path.join(tmpdir, "original") + os.makedirs(original) + + with open(os.path.join(original, "file.txt"), "w") as fh: + fh.write("content") + + sandbox = OverlaySandbox(resource_id="struct-res", original_path=original) + sandbox.create("plan-struct") + + assert sandbox._upper_dir is not None + assert sandbox._work_dir is not None + assert sandbox._merged_dir is not None + assert os.path.isdir(sandbox._upper_dir) + assert os.path.isdir(sandbox._work_dir) + assert os.path.isdir(sandbox._merged_dir) + + # Merged should contain original files + assert os.path.isfile(os.path.join(sandbox._merged_dir, "file.txt")) + + sandbox.cleanup() + print("overlay-directory-structure-ok") + + +def _overlay_fallback_mode() -> None: + """Integration test: overlay sandbox uses userspace fallback.""" + tmpdir = tempfile.mkdtemp(prefix="ovl-robot-fb-") + original = os.path.join(tmpdir, "original") + os.makedirs(original) + + sandbox = OverlaySandbox(resource_id="fb-res", original_path=original) + # In CI/containers, OverlayFS is typically not available + assert sandbox._use_real_overlay is False + + sandbox.create("plan-fb") + sandbox.cleanup() + print("overlay-fallback-mode-ok") + + +def _overlay_state_errors() -> None: + """Integration test: overlay sandbox state errors.""" + tmpdir = tempfile.mkdtemp(prefix="ovl-robot-se-") + original = os.path.join(tmpdir, "original") + os.makedirs(original) + + sandbox = OverlaySandbox(resource_id="se-res", original_path=original) + sandbox.create("plan-se") + + # Rollback from CREATED should raise + try: + sandbox.rollback() + print("FAIL: Expected SandboxStateError for rollback from CREATED") + return + except SandboxStateError: + pass + + # Commit then attempt commit again + sandbox.commit() + try: + sandbox.commit() + print("FAIL: Expected SandboxStateError for double commit") + return + except SandboxStateError: + pass + + sandbox.cleanup() + + # Get path on cleaned up sandbox should raise + try: + sandbox.get_path("file.txt") + print("FAIL: Expected SandboxStateError for get_path after cleanup") + return + except SandboxStateError: + pass + + print("overlay-state-errors-ok") + + +_COMMANDS = { + "overlay-lifecycle": _overlay_lifecycle, + "overlay-rollback": _overlay_rollback, + "overlay-validation": _overlay_validation, + "overlay-directory-structure": _overlay_directory_structure, + "overlay-fallback-mode": _overlay_fallback_mode, + "overlay-state-errors": _overlay_state_errors, +} + + +def main() -> None: + """Entry point for Robot helper.""" + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + valid = "|".join(sorted(_COMMANDS)) + print(f"Usage: {sys.argv[0]} <{valid}>") + sys.exit(1) + + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/robot/overlay_sandbox.robot b/robot/overlay_sandbox.robot new file mode 100644 index 00000000..a43d56a2 --- /dev/null +++ b/robot/overlay_sandbox.robot @@ -0,0 +1,53 @@ +*** Settings *** +Documentation Integration tests for overlay filesystem sandbox. +... Covers OverlaySandbox lifecycle, rollback, validation, +... directory structure, fallback mode, and state errors. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER_SCRIPT} robot/helper_overlay_sandbox.py + +*** Test Cases *** +Overlay Sandbox Full Lifecycle + [Documentation] Verify create -> get_path -> modify -> commit -> cleanup + [Tags] sandbox overlay lifecycle + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-lifecycle cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-lifecycle-ok + +Overlay Sandbox Rollback + [Documentation] Verify rollback discards changes and restores original state + [Tags] sandbox overlay rollback + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-rollback cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-rollback-ok + +Overlay Sandbox Input Validation + [Documentation] Verify empty inputs and non-existent paths are rejected + [Tags] sandbox overlay validation + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-validation cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-validation-ok + +Overlay Sandbox Directory Structure + [Documentation] Verify upper/work/merged directories are created + [Tags] sandbox overlay structure + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-directory-structure cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-directory-structure-ok + +Overlay Sandbox Fallback Mode + [Documentation] Verify userspace fallback when OverlayFS unavailable + [Tags] sandbox overlay fallback + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-fallback-mode cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-fallback-mode-ok + +Overlay Sandbox State Errors + [Documentation] Verify state machine rejects invalid transitions + [Tags] sandbox overlay state + ${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} overlay-state-errors cwd=${WORKSPACE} on_timeout=kill timeout=60s + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} overlay-state-errors-ok diff --git a/src/cleveragents/domain/models/core/resource.py b/src/cleveragents/domain/models/core/resource.py index 21d9431f..c0cf4b77 100644 --- a/src/cleveragents/domain/models/core/resource.py +++ b/src/cleveragents/domain/models/core/resource.py @@ -77,6 +77,7 @@ class SandboxStrategy(StrEnum): COPY_ON_WRITE = "copy_on_write" TRANSACTION_ROLLBACK = "transaction_rollback" SNAPSHOT = "snapshot" + OVERLAY = "overlay" NONE = "none" diff --git a/src/cleveragents/domain/models/core/resource_type.py b/src/cleveragents/domain/models/core/resource_type.py index 0651494c..f36d0722 100644 --- a/src/cleveragents/domain/models/core/resource_type.py +++ b/src/cleveragents/domain/models/core/resource_type.py @@ -81,6 +81,7 @@ class SandboxStrategy(StrEnum): COPY_ON_WRITE = "copy_on_write" TRANSACTION_ROLLBACK = "transaction_rollback" SNAPSHOT = "snapshot" + OVERLAY = "overlay" NONE = "none" diff --git a/src/cleveragents/infrastructure/sandbox/__init__.py b/src/cleveragents/infrastructure/sandbox/__init__.py index b6356d4a..aa8ca7ec 100644 --- a/src/cleveragents/infrastructure/sandbox/__init__.py +++ b/src/cleveragents/infrastructure/sandbox/__init__.py @@ -34,6 +34,7 @@ from cleveragents.infrastructure.sandbox.merge import ( SequentialMergeStrategy, ) from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox +from cleveragents.infrastructure.sandbox.overlay import OverlaySandbox from cleveragents.infrastructure.sandbox.protocol import ( CommitResult, Sandbox, @@ -65,6 +66,7 @@ __all__ = [ "MergeStrategy", "NoSandbox", "NoSandboxBoundaryError", + "OverlaySandbox", "Sandbox", "SandboxBoundaryError", "SandboxCheckpoint", diff --git a/src/cleveragents/infrastructure/sandbox/factory.py b/src/cleveragents/infrastructure/sandbox/factory.py index c5287243..f7188743 100644 --- a/src/cleveragents/infrastructure/sandbox/factory.py +++ b/src/cleveragents/infrastructure/sandbox/factory.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Literal from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox +from cleveragents.infrastructure.sandbox.overlay import OverlaySandbox from cleveragents.infrastructure.sandbox.protocol import ( Sandbox, ) @@ -29,11 +30,12 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# Strategy string constants aligned with spec SandboxStrategy enum (5 values) +# Strategy string constants aligned with spec SandboxStrategy enum (6 values) STRATEGY_GIT_WORKTREE: Literal["git_worktree"] = "git_worktree" STRATEGY_COPY_ON_WRITE: Literal["copy_on_write"] = "copy_on_write" STRATEGY_TRANSACTION_ROLLBACK: Literal["transaction_rollback"] = "transaction_rollback" STRATEGY_SNAPSHOT: Literal["snapshot"] = "snapshot" +STRATEGY_OVERLAY: Literal["overlay"] = "overlay" STRATEGY_NONE: Literal["none"] = "none" SandboxStrategyStr = Literal[ @@ -41,21 +43,22 @@ SandboxStrategyStr = Literal[ "copy_on_write", "transaction_rollback", "snapshot", + "overlay", "none", ] # Strategies that have concrete implementations _IMPLEMENTED_STRATEGIES: frozenset[str] = frozenset( - {"none", "git_worktree", "copy_on_write", "transaction_rollback"} + {"none", "git_worktree", "copy_on_write", "transaction_rollback", "overlay"} ) # Resource type to supported strategies mapping (spec-aligned) _SUPPORTED_STRATEGIES: dict[str, list[SandboxStrategyStr]] = { "git-checkout": ["git_worktree", "copy_on_write", "none"], "git": ["none"], - "fs-mount": ["copy_on_write", "none"], - "fs-directory": ["copy_on_write", "none"], - "fs-file": ["copy_on_write", "none"], + "fs-mount": ["copy_on_write", "overlay", "none"], + "fs-directory": ["copy_on_write", "overlay", "none"], + "fs-file": ["copy_on_write", "overlay", "none"], "api_endpoint": ["none"], "postgres": ["transaction_rollback", "none"], "mysql": ["transaction_rollback", "none"], @@ -74,6 +77,7 @@ class SandboxFactory: - ``"git_worktree"`` -> :class:`GitWorktreeSandbox` - ``"copy_on_write"`` -> :class:`CopyOnWriteSandbox` - ``"transaction_rollback"`` -> :class:`TransactionSandbox` + - ``"overlay"`` -> :class:`OverlaySandbox` ``"snapshot"`` raises ``NotImplementedError``. @@ -146,6 +150,12 @@ class SandboxFactory: original_path=original_path, ) + if sandbox_strategy == STRATEGY_OVERLAY: + return OverlaySandbox( + resource_id=resource_id, + original_path=original_path, + ) + if sandbox_strategy == STRATEGY_SNAPSHOT: raise NotImplementedError("Snapshot sandbox not yet implemented") diff --git a/src/cleveragents/infrastructure/sandbox/overlay.py b/src/cleveragents/infrastructure/sandbox/overlay.py new file mode 100644 index 00000000..31fe869e --- /dev/null +++ b/src/cleveragents/infrastructure/sandbox/overlay.py @@ -0,0 +1,498 @@ +"""Overlay filesystem sandbox — OverlayFS with userspace fallback. + +Creates upper/work/merged layers. Commit syncs to original; rollback +discards and recreates. For fs-mount, fs-directory, fs-file resources. +""" + +from __future__ import annotations + +import filecmp +import functools +import logging +import os +import shutil +import subprocess +import tempfile +from datetime import datetime + +from ulid import ULID + +from cleveragents.infrastructure.sandbox.protocol import ( + CommitResult, + SandboxCommitError, + SandboxContext, + SandboxCreationError, + SandboxRollbackError, + SandboxStateError, + SandboxStatus, +) + +logger = logging.getLogger(__name__) + + +@functools.lru_cache(maxsize=1) +def _is_overlayfs_available() -> bool: + """Return True only if we can actually mount OverlayFS. + + Uses a real mount probe — ``mount --fake`` gives false positives + in Docker containers that lack CAP_SYS_ADMIN. + """ + try: + proc_fs = "/proc/filesystems" + if os.path.isfile(proc_fs): + with open(proc_fs) as fh: + if "overlay" not in fh.read(): + return False + else: + return False + except OSError: + return False + + if os.geteuid() != 0: + return False + # Real mount probe — attempt actual overlay mount in a temp dir. + probe_dir = None + try: + probe_dir = tempfile.mkdtemp(prefix="ca-overlay-probe-") + lower, upper = os.path.join(probe_dir, "l"), os.path.join(probe_dir, "u") + work, merged = os.path.join(probe_dir, "w"), os.path.join(probe_dir, "m") + for d in (lower, upper, work, merged): + os.makedirs(d) + opts = f"lowerdir={lower},upperdir={upper},workdir={work}" + rc = subprocess.run( + ["mount", "-t", "overlay", "overlay", "-o", opts, merged], + capture_output=True, + timeout=5, + ).returncode + if rc != 0: + return False + subprocess.run(["umount", merged], capture_output=True, timeout=5) + return True + except (OSError, subprocess.TimeoutExpired): + return False + finally: + if probe_dir is not None: + shutil.rmtree(probe_dir, ignore_errors=True) + + +class OverlaySandbox: + """Sandbox that isolates changes using an overlay filesystem model. + + Mounts a real OverlayFS union when available, otherwise falls back + to a userspace copy-based overlay. + + Implements the + :class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox` + protocol. + """ + + def __init__( + self, + resource_id: str, + original_path: str, + ) -> None: + """Initialise an overlay sandbox. + + Args: + resource_id: Identifier of the resource being sandboxed. + original_path: Path to the directory to sandbox. + + Raises: + ValueError: If *resource_id* or *original_path* is empty. + """ + if not resource_id: + raise ValueError("resource_id cannot be empty") + if not original_path: + raise ValueError("original_path cannot be empty") + + self._sandbox_id: str = str(ULID()) + self._resource_id: str = resource_id + self._original_path: str = os.path.abspath(original_path) + self._status: SandboxStatus = SandboxStatus.PENDING + self._context: SandboxContext | None = None + + # Layer directories — set after create() + self._base_dir: str | None = None + self._upper_dir: str | None = None + self._work_dir: str | None = None + self._merged_dir: str | None = None + + self._use_real_overlay: bool = _is_overlayfs_available() + + # -- protocol properties ------------------------------------------------- + @property + def sandbox_id(self) -> str: + """Unique identifier for this sandbox instance.""" + return self._sandbox_id + + @property + def status(self) -> SandboxStatus: + """Current lifecycle status.""" + return self._status + + @property + def context(self) -> SandboxContext | None: + """Context after creation, ``None`` before ``create``.""" + return self._context + + # -- protocol methods ---------------------------------------------------- + def create(self, plan_id: str) -> SandboxContext: + """Set up overlay directory layers and populate the merged view. + + Args: + plan_id: The plan that owns this sandbox. + + Returns: + A :class:`SandboxContext` with the merged layer path. + + Raises: + ValueError: If *plan_id* is empty. + SandboxStateError: If not in ``PENDING`` status. + SandboxCreationError: If the directory setup fails. + """ + if not plan_id: + raise ValueError("plan_id cannot be empty") + + SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED) + + try: + if not os.path.isdir(self._original_path): + raise SandboxCreationError( + f"Original path is not a directory: {self._original_path}" + ) + + self._base_dir = tempfile.mkdtemp(prefix="ca-overlay-sandbox-") + self._upper_dir = os.path.join(self._base_dir, "upper") + self._work_dir = os.path.join(self._base_dir, "work") + self._merged_dir = os.path.join(self._base_dir, "merged") + + os.makedirs(self._upper_dir, exist_ok=True) + os.makedirs(self._work_dir, exist_ok=True) + os.makedirs(self._merged_dir, exist_ok=True) + + if self._use_real_overlay: + self._mount_overlay() + else: + # Userspace fallback: copy original into merged + shutil.copytree( + self._original_path, + self._merged_dir, + symlinks=False, + dirs_exist_ok=True, + ) + + except SandboxCreationError: + self._status = SandboxStatus.ERRORED + raise + except OSError as exc: + self._status = SandboxStatus.ERRORED + raise SandboxCreationError( + f"Failed to create overlay sandbox for resource " + f"{self._resource_id}: {exc}" + ) from exc + + self._context = SandboxContext( + sandbox_id=self._sandbox_id, + sandbox_path=self._merged_dir, + original_path=self._original_path, + resource_id=self._resource_id, + plan_id=plan_id, + created_at=datetime.now(), + metadata={ + "strategy": "overlay", + "sandbox_path": self._merged_dir, + "use_real_overlay": self._use_real_overlay, + }, + ) + self._status = SandboxStatus.CREATED + + logger.info( + "Created overlay sandbox: plan=%s resource=%s merged=%s real_overlay=%s", + plan_id, + self._resource_id, + self._merged_dir, + self._use_real_overlay, + ) + + return self._context + + def get_path(self, resource_path: str) -> str: + """Translate a resource-relative path to the merged layer. + + Args: + resource_path: Path relative to the resource root. + + Returns: + Absolute path inside the merged directory. + + Raises: + SandboxStateError: If sandbox is not in a usable status. + ValueError: If *resource_path* attempts directory traversal. + """ + if self._status not in ( + SandboxStatus.CREATED, + SandboxStatus.ACTIVE, + SandboxStatus.ROLLED_BACK, + ): + raise SandboxStateError( + f"Cannot resolve path in status {self._status.value}" + ) + + if ".." in resource_path.split("/"): + raise ValueError(f"Path traversal not allowed: {resource_path}") + + if self._status == SandboxStatus.CREATED: + self._status = SandboxStatus.ACTIVE + + if self._merged_dir is None: + raise SandboxStateError("Sandbox merged directory not set") + + return os.path.join(self._merged_dir, resource_path) + + def commit(self, message: str | None = None) -> CommitResult: + """Apply overlay changes back to the original directory. + + For real OverlayFS: copies the upper layer contents to original. + For userspace fallback: diffs merged against original. + + Args: + message: Optional log message (stored in result metadata). + + Returns: + A :class:`CommitResult` describing the outcome. + + Raises: + SandboxCommitError: If the sync fails. + SandboxStateError: If sandbox is not in a committable status. + """ + if self._status not in ( + SandboxStatus.CREATED, + SandboxStatus.ACTIVE, + ): + raise SandboxStateError(f"Cannot commit from status {self._status.value}") + + SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED) + + if self._merged_dir is None: + raise SandboxStateError("Sandbox merged directory not set") + + try: + changed_files, added_files, deleted_files = self._compute_diff( + self._merged_dir, self._original_path + ) + + # Apply changes: copy modified and new files + for rel_path in changed_files + added_files: + src = os.path.join(self._merged_dir, rel_path) + dst = os.path.join(self._original_path, rel_path) + dst_dir = os.path.dirname(dst) + if dst_dir: + os.makedirs(dst_dir, exist_ok=True) + shutil.copy2(src, dst) + + # Apply deletions + for rel_path in deleted_files: + dst = os.path.join(self._original_path, rel_path) + if os.path.exists(dst): + os.remove(dst) + + except OSError as exc: + self._status = SandboxStatus.ERRORED + raise SandboxCommitError( + f"Failed to commit overlay sandbox {self._sandbox_id}: {exc}" + ) from exc + + self._status = SandboxStatus.COMMITTED + + logger.info( + "Committed overlay sandbox: sandbox_id=%s changed=%d added=%d deleted=%d", + self._sandbox_id, + len(changed_files), + len(added_files), + len(deleted_files), + ) + + metadata: dict[str, object] = {} + if message: + metadata["message"] = message + + return CommitResult( + sandbox_id=self._sandbox_id, + success=True, + commit_ref=None, + changed_files=changed_files, + added_files=added_files, + deleted_files=deleted_files, + error=None, + timestamp=datetime.now(), + metadata=metadata, + ) + + def rollback(self) -> None: + """Discard overlay changes by resetting the merged view. + + For real OverlayFS: unmounts, recreates upper/work, remounts. + For userspace fallback: deletes merged, re-copies original. + + Raises: + SandboxRollbackError: If the rollback fails. + SandboxStateError: If called in an invalid status. + """ + if self._status != SandboxStatus.ACTIVE: + raise SandboxStateError(f"Cannot rollback from status {self._status.value}") + + SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK) + + if self._merged_dir is None: + raise SandboxStateError("Sandbox merged directory not set") + + try: + if self._use_real_overlay: + self._unmount_overlay() + # Reset upper and work directories + if self._upper_dir is not None: + shutil.rmtree(self._upper_dir, ignore_errors=True) + os.makedirs(self._upper_dir, exist_ok=True) + if self._work_dir is not None: + shutil.rmtree(self._work_dir, ignore_errors=True) + os.makedirs(self._work_dir, exist_ok=True) + self._mount_overlay() + else: + # Userspace fallback: delete merged, re-copy + shutil.rmtree(self._merged_dir, ignore_errors=True) + shutil.copytree( + self._original_path, + self._merged_dir, + symlinks=False, + dirs_exist_ok=False, + ) + + except OSError as exc: + self._status = SandboxStatus.ERRORED + raise SandboxRollbackError( + f"Failed to rollback overlay sandbox {self._sandbox_id}: {exc}" + ) from exc + + self._status = SandboxStatus.ROLLED_BACK + + logger.info( + "Rolled back overlay sandbox: sandbox_id=%s", + self._sandbox_id, + ) + + def cleanup(self) -> None: + """Remove all overlay layers and mountpoints. + + Idempotent — safe to call multiple times. + + Raises: + SandboxError: On unexpected errors during cleanup. + """ + if self._status == SandboxStatus.CLEANED_UP: + return + + logger.debug( + "Cleaning up overlay sandbox: sandbox_id=%s base=%s", + self._sandbox_id, + self._base_dir, + ) + + # Unmount if real overlay was used + if self._use_real_overlay and self._merged_dir is not None: + self._unmount_overlay() + + if self._base_dir is not None and os.path.exists(self._base_dir): + shutil.rmtree(self._base_dir, ignore_errors=True) + + self._status = SandboxStatus.CLEANED_UP + + logger.info( + "Cleaned up overlay sandbox: sandbox_id=%s", + self._sandbox_id, + ) + + # -- internal helpers ---------------------------------------------------- + def _mount_overlay(self) -> None: + """Mount an OverlayFS union at the merged directory. + + Raises: + SandboxCreationError: If the mount command fails. + """ + opts = ( + f"lowerdir={self._original_path}," + f"upperdir={self._upper_dir}," + f"workdir={self._work_dir}" + ) + try: + subprocess.run( + [ + "mount", + "-t", + "overlay", + "overlay", + "-o", + opts, + str(self._merged_dir), + ], + capture_output=True, + check=True, + timeout=30, + ) + except (subprocess.CalledProcessError, OSError) as exc: + raise SandboxCreationError(f"Failed to mount OverlayFS: {exc}") from exc + + def _unmount_overlay(self) -> None: + """Unmount the OverlayFS union. Errors are logged but not raised.""" + if self._merged_dir is None: + return + try: + subprocess.run( + ["umount", str(self._merged_dir)], + capture_output=True, + check=True, + timeout=30, + ) + except (subprocess.CalledProcessError, OSError) as exc: + logger.warning( + "Failed to unmount overlay at %s: %s", + self._merged_dir, + exc, + ) + + @staticmethod + def _compute_diff( + sandbox_dir: str, original_dir: str + ) -> tuple[list[str], list[str], list[str]]: + """Compare sandbox to original and return changed/added/deleted. + + Args: + sandbox_dir: Path to the merged/sandbox directory. + original_dir: Path to the original directory. + + Returns: + Tuple of ``(changed, added, deleted)`` relative-path lists. + """ + # Collect all files in both trees + sandbox_files: set[str] = { + os.path.relpath(os.path.join(dp, f), sandbox_dir) + for dp, _, fnames in os.walk(sandbox_dir) + for f in fnames + } + original_files: set[str] = { + os.path.relpath(os.path.join(dp, f), original_dir) + for dp, _, fnames in os.walk(original_dir) + for f in fnames + } + + added = sorted(sandbox_files - original_files) + deleted = sorted(original_files - sandbox_files) + changed = [ + rel + for rel in sorted(sandbox_files & original_files) + if not filecmp.cmp( + os.path.join(sandbox_dir, rel), + os.path.join(original_dir, rel), + shallow=False, + ) + ] + return changed, added, deleted diff --git a/src/cleveragents/infrastructure/sandbox/protocol.py b/src/cleveragents/infrastructure/sandbox/protocol.py index 5929cf44..83337f36 100644 --- a/src/cleveragents/infrastructure/sandbox/protocol.py +++ b/src/cleveragents/infrastructure/sandbox/protocol.py @@ -190,6 +190,9 @@ class CommitResult: timestamp: datetime = field(default_factory=datetime.now) """When the commit occurred.""" + metadata: dict[str, Any] = field(default_factory=dict) + """Arbitrary key-value metadata (e.g. commit message).""" + # --------------------------------------------------------------------------- # Sandbox protocol (B3.1d) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index 1969c2c7..442ff59e 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -1061,6 +1061,10 @@ validate_connection # noqa: B018, F821 # Transaction sandbox — public API (issue #342) TransactionSandbox # noqa: B018, F821 +# Overlay sandbox — public API (issue #880) +OverlaySandbox # noqa: B018, F821 +_is_overlayfs_available # noqa: B018, F821 + # ComponentResolver — pluggable scope chain resolution (#552) ComponentResolver # noqa: B018, F821 ComponentNotFoundError # noqa: B018, F821