Files
cleveragents-core/robot/helper_overlay_sandbox.py
T
brent.edwards 8a87262f86
CI / build (push) Successful in 17s
CI / lint (push) Successful in 3m41s
CI / quality (push) Successful in 3m47s
CI / unit_tests (push) Successful in 3m58s
CI / typecheck (push) Successful in 4m17s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 4m22s
CI / docker (push) Successful in 1m22s
CI / e2e_tests (push) Successful in 8m57s
CI / coverage (push) Successful in 11m16s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (push) Successful in 19m45s
CI / integration_tests (push) Failing after 20m53s
feat(sandbox): implement overlay filesystem sandbox strategy (#994)
## Summary

Implement the overlay filesystem sandbox strategy with OverlayFS support and userspace fallback.

### Implementation

**`OverlaySandbox`** (`infrastructure/sandbox/overlay.py`, 497 lines):
- Detects OverlayFS availability at runtime via `/proc/filesystems` + `os.geteuid() == 0` check
- **Real OverlayFS mode** (requires root): creates upper/work/merged dirs, mounts overlay filesystem, captures writes in upper layer
- **Userspace fallback** (default in CI/containers): `shutil.copytree` the original into merged dir, tracks changes via `filecmp` diff on commit
- `create()`: sets up directory structure, mounts if available
- `commit()`: copies changed/added files from overlay to original, removes deleted files
- `rollback()`: unmounts (or removes) merged, recreates from scratch
- `cleanup()`: unmounts, removes all temp dirs, idempotent

### Domain Model Updates
- Added `OVERLAY = "overlay"` to `SandboxStrategy` enum in both `resource_type.py` and `resource.py`
- Added `STRATEGY_OVERLAY` to `SandboxFactory`, registered for `fs-mount`, `fs-directory`, `fs-file` resources

### Tests
- **22 Behave scenarios**: full lifecycle (create/commit/rollback/cleanup), status transitions, path traversal guard, fallback detection, error handling
- **6 Robot integration tests**: end-to-end overlay sandbox operations

### Quality Gates

| Session | Result |
|---|---|
| `nox -s lint` | PASS |
| `nox -s typecheck` | PASS (0 errors) |
| `nox -s unit_tests` | PASS (10,917 scenarios) |
| `nox -s coverage_report` | 97% (>= 97%) |

Closes #880

Reviewed-on: #994
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
2026-03-21 03:14:40 +00:00

255 lines
7.5 KiB
Python

"""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()