7f078f75a5
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 28s
CI / security (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 20s
CI / helm (pull_request) Successful in 34s
CI / quality (pull_request) Successful in 3m53s
CI / integration_tests (pull_request) Successful in 4m2s
CI / typecheck (pull_request) Successful in 4m9s
CI / unit_tests (pull_request) Successful in 4m48s
CI / docker (pull_request) Successful in 1m19s
CI / e2e_tests (pull_request) Successful in 9m47s
CI / coverage (pull_request) Successful in 11m41s
CI / status-check (pull_request) Successful in 1s
CI / quality (push) Successful in 52s
CI / lint (push) Successful in 3m18s
CI / build (push) Successful in 20s
CI / typecheck (push) Successful in 3m56s
CI / helm (push) Successful in 22s
CI / security (push) Successful in 4m15s
CI / unit_tests (push) Successful in 4m39s
CI / docker (push) Successful in 18s
CI / integration_tests (push) Successful in 6m56s
CI / e2e_tests (push) Successful in 12m34s
CI / coverage (push) Successful in 11m39s
CI / status-check (push) Successful in 1s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Successful in 30m10s
CI / benchmark-regression (pull_request) Successful in 57m33s
Changed SandboxManager.commit_all() from partial-commit semantics to all-or-nothing atomic operation per specification requirement. On partial failure, already-committed sandboxes are rolled back. Error reporting indicates which sandbox failed and what was rolled back. Added Behave scenarios verifying atomicity guarantee. Hardened atomicity guarantees after code review: - commit_all and _rollback_committed catch Exception (not just SandboxError) so unexpected errors cannot bypass rollback. Non-SandboxError exceptions are wrapped in a new AtomicCommitError (chaining the original as __cause__) that carries rolled_back_ids and failed_rollback_ids attributes so callers can programmatically determine rollback outcomes. - _rollback_committed returns both rolled_back_ids and failed_rollback_ids; the error result metadata now carries both "rolled_back" and "rollback_failed" keys. - _rollback_committed iterates in reverse (LIFO) order following the standard transaction-log undo pattern. Clarified in docstring that this is distinct from the specification DAG-based "top-down" rollback ordering (line 24632). - TransactionSandbox.rollback() from COMMITTED now raises SandboxRollbackError (database commits are irreversible) instead of silently transitioning to ROLLED_BACK. - TransactionSandbox is now classified as non-rollbackable in commit_all alongside NoSandbox and committed last in the batch, since database COMMIT is irreversible. Docstring corrected to match the raising behavior. - CopyOnWriteSandbox and OverlaySandbox rollback-from-COMMITTED uses rename-based safe_restore() to prevent data loss when the copytree step fails after the original was removed. - CopyOnWriteSandbox and OverlaySandbox rollback() now catches Exception (not just OSError), matching the broader catch used in _rollback_committed, so non-OSError exceptions from safe_restore set the status to ERRORED correctly. - Extracted shared _fs_utils module (backup_directory, safe_restore, compute_diff) with symlink, permission, and timestamp preservation (including directory timestamps), replacing duplicated per-class _backup_directory and _compute_diff methods. - backup_directory defers directory permissions and timestamps to a bottom-up post-walk pass, fixing incorrect mtime preservation (POSIX file creation inside a directory overwrites its mtime) and preventing restrictive source permissions from blocking backup writes. - backup_directory skips non-regular files (FIFOs, sockets, device files) with a warning to prevent hangs on special files. - CopyOnWriteSandbox and OverlaySandbox commit() now attempts to restore the original from the pre-commit backup when the file-copy phase fails midway, preventing partial corruption. If the restore itself fails the backup is preserved for manual recovery (cleanup() still removes it). - CopyOnWriteSandbox and OverlaySandbox commit() error handler now catches Exception (not just OSError) so that unexpected errors during the file-copy phase also trigger pre-commit backup restoration, preventing partial corruption of the original directory. - Pre-commit backup exception handler catches Exception (not just OSError) preventing temp directory leaks on non-OSError failures from backup_directory. - Fixed _pre_commit_backup assignment timing: the backup reference is now assigned only AFTER backup_directory() succeeds, preventing safe_restore() from corrupting an intact original with a partial backup when backup_directory() fails (e.g. disk full). - Rollback from COMMITTED with no pre-commit backup (no changes were applied) is now a no-op instead of raising SandboxRollbackError, preventing false rollback-failure reports in commit_all error metadata. - commit_all logs a warning when NoSandbox or TransactionSandbox instances are present in the batch since their changes cannot be rolled back, which breaks the atomicity guarantee. - Pre-commit backup is skipped when compute_diff returns no changes, avoiding a full directory copy for no-op commits. - GitWorktreeSandbox clears _pre_merge_commit on commit failure so the stale value cannot be used by future code. - commit_all docstring documents Raises clause for AtomicCommitError exception wrapping behavior. - GitWorktreeSandbox.rollback() docstring warns about multi-worktree safety when rolling back from COMMITTED. - Updated SandboxStatus transition diagram in protocol.py to clearly show the COMMITTED -> ROLLED_BACK path. - Added spec-contradiction note (line 45938 vs 19193) in commit_all docstring. - OverlaySandbox rollback from COMMITTED now properly remounts OverlayFS for real overlay (unmount, clean upper/work dirs, remount) and uses dirs_exist_ok=True for userspace fallback to prevent FileExistsError if rmtree silently fails. The merged directory is reset from the restored original, preventing stale pre-rollback data from being exposed on re-activation via get_path() (which allows ROLLED_BACK status). - OverlaySandbox rollback from COMMITTED now raises SandboxRollbackError if the OverlayFS unmount fails, preventing a double-mount attempt that would leave the sandbox in an inconsistent state. - OverlaySandbox rollback from ACTIVE now uses dirs_exist_ok=True for userspace fallback to prevent FileExistsError when rmtree with ignore_errors=True silently fails. - CopyOnWriteSandbox rollback from ACTIVE now uses dirs_exist_ok=True in copytree to prevent FileExistsError when rmtree with ignore_errors=True silently fails, matching the fix already applied to OverlaySandbox. - Non-rollbackable sandboxes (NoSandbox, TransactionSandbox) are committed last in the batch so that all rollbackable sandboxes commit first; if any rollbackable sandbox fails, none of the non-rollbackable sandboxes will have committed yet. - Moved NoSandbox and TransactionSandbox imports to module level in manager.py (no circular dependency exists). - Pre-commit backups are now created on the same filesystem as the original directory (using dir= argument to mkdtemp), avoiding cross-device copy overhead and ensuring os.rename compatibility. - safe_restore now renames the target into the mkdtemp directory instead of removing the mkdtemp dir first, eliminating the residual TOCTOU window between rmdir and rename. - safe_restore catches BaseException (not just OSError) to ensure the original directory is always renamed back on unexpected errors, preventing the original from being left in the renamed-aside state. - Added AtomicCommitError exception class to protocol.py carrying rolled_back_ids and failed_rollback_ids attributes. - Exported AtomicCommitError from sandbox package __init__.py so callers can import it from the public API. - Added BDD scenarios: LIFO rollback order, AtomicCommitError wrapping with RuntimeError cause and rollback metadata, _fs_utils backup/restore coverage, no-change commit rollback success, directory timestamp preservation, OverlaySandbox merged dir reset after COMMITTED rollback, CopyOnWriteSandbox rollback from COMMITTED restores original, GitWorktreeSandbox rollback from COMMITTED undoes merge, TransactionSandbox rollback from COMMITTED raises SandboxRollbackError about irreversible commit. ISSUES CLOSED: #925 Post-review hardening (PR #1146 review findings): - OverlaySandbox rollback from COMMITTED with no backup (no-op) now skips the merged directory reset entirely, preventing unnecessary unmount/remount or re-copy that could fail and turn a harmless no-op rollback into a SandboxRollbackError during commit_all atomic recovery. - OverlaySandbox rollback no longer double-wraps SandboxRollbackError: the outer except Exception handler now has a preceding except SandboxRollbackError clause that re-raises directly, avoiding a confusing double-wrapped error chain. - CopyOnWriteSandbox.get_path() now accepts ROLLED_BACK status for consistency with OverlaySandbox and the protocol status transition table (ROLLED_BACK -> ACTIVE). - CopyOnWriteSandbox rollback from COMMITTED now resets the sandbox copy from the restored original via rmtree+copytree, preventing stale pre-rollback modifications from being exposed on re-activation. - rollback_all now catches Exception (not just SandboxError) so that unexpected rollback errors do not prevent remaining sandboxes from being rolled back, consistent with the pattern already used in _rollback_committed. - commit_all docstring now documents a thread-safety warning: the method is not safe for concurrent calls on the same plan_id since sandbox commit/rollback runs outside the lock. - Fixed CHANGELOG.md whitespace inconsistencies (double leading spaces on two lines). Post-review hardening round 2 (PR #1146 automated review): - safe_restore now uses os.rename (O(1) atomic rename) instead of shutil.copytree (O(n) recursive copy) for the main restore path, since backup and target are always on the same filesystem. This eliminates the ENOTEMPTY bug where a partial copytree failure left target_path partially populated, causing the recovery os.rename to fail and strand the original in the stale temp directory. - OverlaySandbox.get_path() now transitions ROLLED_BACK to ACTIVE, matching CopyOnWriteSandbox and the protocol transition table (ROLLED_BACK -> ACTIVE). - GitWorktreeSandbox.get_path() now accepts ROLLED_BACK status for consistency with all other sandbox implementations and the protocol transition table (ROLLED_BACK -> ACTIVE). - rollback_all now also handles sandboxes in COMMITTED status (not just ACTIVE), consistent with the state machine allowing COMMITTED -> ROLLED_BACK. - cleanup_all now catches Exception (not just SandboxError) so a single unexpected error does not abort cleanup of remaining sandboxes, consistent with _rollback_committed and rollback_all. - Restructured CHANGELOG entry from a single ~90-line paragraph into structured sub-bullets for readability. - Added BDD scenarios: no-op rollback from COMMITTED for CopyOnWriteSandbox and OverlaySandbox (zero-change commit), commit ordering verification (rollbackable before non-rollbackable). Post-review hardening round 3 (PR #1146 deep automated review): - cleanup_abandoned now catches Exception (not just SandboxError) so that unexpected errors (e.g. raw OSError, PermissionError) do not crash the loop and prevent remaining abandoned sandboxes from being cleaned up, consistent with cleanup_all, rollback_all, and _rollback_committed. - OverlaySandbox._mount_overlay() now catches subprocess.TimeoutExpired (in addition to CalledProcessError and OSError), preventing create() from leaving the sandbox in PENDING status when mount hangs beyond the timeout. - OverlaySandbox._unmount_overlay() now catches subprocess.TimeoutExpired (in addition to CalledProcessError and OSError), preventing cleanup() from leaving the sandbox in a zombie state when umount hangs beyond the timeout. - OverlaySandbox._mount_overlay() validates that overlay paths do not contain commas, which would corrupt the OverlayFS mount options string (comma is the mount option delimiter). - GitWorktreeSandbox.commit() now checks git diff return code so that a failed diff command raises CalledProcessError instead of silently concluding there are no changes and skipping the merge. - safe_restore cleanup of the temporary rollback container now runs in a finally block, preventing a temp directory leak when the rename fails and the exception is re-raised. - Fixed misleading BDD step name: "backup path that will cause copytree to fail" renamed to "backup path that will cause rename to fail" since safe_restore now uses os.rename.
659 lines
23 KiB
Python
659 lines
23 KiB
Python
"""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"
|
|
)
|