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
## 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>
255 lines
7.5 KiB
Python
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()
|