595 lines
21 KiB
Python
595 lines
21 KiB
Python
"""Step definitions for CONC3 - Garbage collection and cleanup.
|
|
|
|
Tests the cleanup service, retention policies, dry-run mode,
|
|
purge mode, and per-resource cleanup summaries.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.cleanup_service import (
|
|
CleanupReport,
|
|
CleanupService,
|
|
)
|
|
from cleveragents.config.settings import Settings
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_settings(**overrides: object) -> Settings:
|
|
"""Create a Settings instance with optional field overrides."""
|
|
# Reset the singleton to get fresh defaults
|
|
Settings._instance = None
|
|
settings = Settings()
|
|
for key, value in overrides.items():
|
|
setattr(settings, key, value)
|
|
return settings
|
|
|
|
|
|
def _create_sandbox_dir(
|
|
prefix: str = "ca-sandbox-testplan-",
|
|
age_hours: float = 72,
|
|
) -> Path:
|
|
"""Create a fake sandbox temp directory with a given age."""
|
|
d = Path(tempfile.mkdtemp(prefix=prefix))
|
|
# Set mtime to simulate age
|
|
old_time = time.time() - (age_hours * 3600)
|
|
os.utime(d, (old_time, old_time))
|
|
return d
|
|
|
|
|
|
def _create_checkpoint_files(
|
|
plan_dir: Path,
|
|
count: int,
|
|
) -> list[Path]:
|
|
"""Create N checkpoint JSON files in plan_dir."""
|
|
plan_dir.mkdir(parents=True, exist_ok=True)
|
|
files: list[Path] = []
|
|
for i in range(count):
|
|
f = plan_dir / f"checkpoint_{i:04d}.json"
|
|
f.write_text(f'{{"step": {i}}}')
|
|
files.append(f)
|
|
return files
|
|
|
|
|
|
# ── Cleanup after each scenario ──────────────────────────────────
|
|
|
|
# Paths created during scenarios, cleaned in after_scenario via
|
|
# context._cleanup_handlers (set by the Behave environment.py).
|
|
|
|
|
|
def _register_cleanup(context: Context, path: Path) -> None:
|
|
"""Register a path for cleanup after the scenario."""
|
|
if not hasattr(context, "_temp_paths"):
|
|
context._temp_paths = []
|
|
context._temp_paths.append(path)
|
|
|
|
def _cleanup() -> None:
|
|
for p in getattr(context, "_temp_paths", []):
|
|
if p.exists():
|
|
if p.is_dir():
|
|
shutil.rmtree(p, ignore_errors=True)
|
|
else:
|
|
p.unlink(missing_ok=True)
|
|
|
|
if _cleanup not in context._cleanup_handlers:
|
|
context._cleanup_handlers.append(_cleanup)
|
|
|
|
|
|
# ── Retention policy defaults ─────────────────────────────────────
|
|
|
|
|
|
@given("the default cleanup settings")
|
|
def step_default_settings(context: Context) -> None:
|
|
context.settings = _make_settings()
|
|
|
|
|
|
@then("the sandbox max age hours should be {hours:d}")
|
|
def step_sandbox_max_age(context: Context, hours: int) -> None:
|
|
assert context.settings.cleanup_sandbox_max_age_hours == hours, (
|
|
f"Expected {hours}, got {context.settings.cleanup_sandbox_max_age_hours}"
|
|
)
|
|
|
|
|
|
@then("the checkpoint max per plan should be {count:d}")
|
|
def step_checkpoint_max(context: Context, count: int) -> None:
|
|
assert context.settings.cleanup_checkpoint_max_per_plan == count, (
|
|
f"Expected {count}, got {context.settings.cleanup_checkpoint_max_per_plan}"
|
|
)
|
|
|
|
|
|
@then("the session inactivity days should be {days:d}")
|
|
def step_session_inactivity(context: Context, days: int) -> None:
|
|
assert context.settings.cleanup_session_inactivity_days == days, (
|
|
f"Expected {days}, got {context.settings.cleanup_session_inactivity_days}"
|
|
)
|
|
|
|
|
|
@then("the log retention days should be {days:d}")
|
|
def step_log_retention(context: Context, days: int) -> None:
|
|
assert context.settings.cleanup_log_retention_days == days, (
|
|
f"Expected {days}, got {context.settings.cleanup_log_retention_days}"
|
|
)
|
|
|
|
|
|
@then("the backup retention days should be {days:d}")
|
|
def step_backup_retention(context: Context, days: int) -> None:
|
|
assert context.settings.cleanup_backup_retention_days == days, (
|
|
f"Expected {days}, got {context.settings.cleanup_backup_retention_days}"
|
|
)
|
|
|
|
|
|
@then('the cleanup schedule should be "{schedule}"')
|
|
def step_cleanup_schedule(context: Context, schedule: str) -> None:
|
|
assert context.settings.cleanup_schedule == schedule, (
|
|
f"Expected '{schedule}', got '{context.settings.cleanup_schedule}'"
|
|
)
|
|
|
|
|
|
# ── Sandbox cleanup scenarios ─────────────────────────────────────
|
|
|
|
|
|
@given("a sandbox directory older than the max age")
|
|
def step_stale_sandbox(context: Context) -> None:
|
|
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
|
context.sandbox_dir = _create_sandbox_dir(age_hours=72)
|
|
_register_cleanup(context, context.sandbox_dir)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@given("a sandbox directory linked to a running plan")
|
|
def step_active_sandbox(context: Context) -> None:
|
|
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
|
context.sandbox_dir = _create_sandbox_dir(
|
|
prefix="ca-sandbox-activeplan-",
|
|
age_hours=72,
|
|
)
|
|
_register_cleanup(context, context.sandbox_dir)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset({"activeplan"}),
|
|
)
|
|
|
|
|
|
@given('a stale git worktree sandbox with branch "{branch}"')
|
|
def step_stale_worktree(context: Context, branch: str) -> None:
|
|
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
|
context.sandbox_dir = _create_sandbox_dir(
|
|
prefix="ca-sandbox-test-plan-",
|
|
age_hours=72,
|
|
)
|
|
_register_cleanup(context, context.sandbox_dir)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@when("I scan for stale sandboxes")
|
|
def step_scan_sandboxes(context: Context) -> None:
|
|
context.report = context.service.scan()
|
|
|
|
|
|
@when("I purge stale sandboxes")
|
|
def step_purge_sandboxes(context: Context) -> None:
|
|
context.report = context.service.purge()
|
|
|
|
|
|
@then("the stale sandbox should be identified")
|
|
def step_stale_identified(context: Context) -> None:
|
|
sandbox_items = [
|
|
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
|
]
|
|
assert len(sandbox_items) >= 1, (
|
|
f"Expected at least 1 stale sandbox, found {len(sandbox_items)}"
|
|
)
|
|
|
|
|
|
@then("the active sandbox should be skipped")
|
|
def step_active_skipped(context: Context) -> None:
|
|
assert context.report.sandboxes.skipped >= 1, (
|
|
f"Expected skipped >= 1, got {context.report.sandboxes.skipped}"
|
|
)
|
|
|
|
|
|
@then("the skipped sandbox should be logged")
|
|
def step_skipped_logged(context: Context) -> None:
|
|
assert len(context.report.sandboxes.skipped_reasons) >= 1, (
|
|
"Expected at least one skip reason logged"
|
|
)
|
|
|
|
|
|
@then("the sandbox directory should be removed")
|
|
def step_sandbox_removed(context: Context) -> None:
|
|
assert not context.sandbox_dir.exists(), (
|
|
f"Sandbox dir {context.sandbox_dir} still exists"
|
|
)
|
|
|
|
|
|
@then("the worktree directory should be removed")
|
|
def step_worktree_removed(context: Context) -> None:
|
|
assert not context.sandbox_dir.exists(), (
|
|
f"Worktree dir {context.sandbox_dir} still exists"
|
|
)
|
|
|
|
|
|
@then("the cleanup summary should report {count:d} sandbox removed")
|
|
def step_sandbox_summary(context: Context, count: int) -> None:
|
|
assert context.report.sandboxes.removed >= count, (
|
|
f"Expected >= {count} removed, got {context.report.sandboxes.removed}"
|
|
)
|
|
|
|
|
|
# ── Checkpoint cleanup scenarios ──────────────────────────────────
|
|
|
|
|
|
@given("a plan with {total:d} checkpoints and max per plan is {max_cp:d}")
|
|
def step_excess_checkpoints(context: Context, total: int, max_cp: int) -> None:
|
|
context.settings = _make_settings(cleanup_checkpoint_max_per_plan=max_cp)
|
|
context.checkpoint_dir = Path(tempfile.mkdtemp(prefix="ca-checkpoint-test-"))
|
|
_register_cleanup(context, context.checkpoint_dir)
|
|
context.checkpoint_files = _create_checkpoint_files(
|
|
context.checkpoint_dir,
|
|
total,
|
|
)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@when("I scan for excess checkpoints")
|
|
def step_scan_checkpoints(context: Context) -> None:
|
|
context.excess = context.service.scan_checkpoints_for_plan(
|
|
context.checkpoint_dir,
|
|
)
|
|
|
|
|
|
@when("I prune excess checkpoints")
|
|
def step_prune_checkpoints(context: Context) -> None:
|
|
context.removed_count = context.service.prune_checkpoints_for_plan(
|
|
context.checkpoint_dir,
|
|
)
|
|
|
|
|
|
@then("{count:d} excess checkpoints should be identified")
|
|
def step_excess_identified(context: Context, count: int) -> None:
|
|
assert len(context.excess) == count, (
|
|
f"Expected {count} excess, found {len(context.excess)}"
|
|
)
|
|
|
|
|
|
@then("{count:d} checkpoints should remain")
|
|
def step_checkpoints_remain(context: Context, count: int) -> None:
|
|
remaining = list(context.checkpoint_dir.glob("checkpoint_*.json"))
|
|
assert len(remaining) == count, (
|
|
f"Expected {count} remaining, found {len(remaining)}"
|
|
)
|
|
|
|
|
|
@then("the first checkpoint should be preserved")
|
|
def step_first_preserved(context: Context) -> None:
|
|
first = context.checkpoint_files[0]
|
|
assert first.exists(), f"First checkpoint {first} was deleted"
|
|
|
|
|
|
@then("the most recent checkpoint should be preserved")
|
|
def step_last_preserved(context: Context) -> None:
|
|
last = context.checkpoint_files[-1]
|
|
assert last.exists(), f"Most recent checkpoint {last} was deleted"
|
|
|
|
|
|
# ── Session cleanup scenarios ─────────────────────────────────────
|
|
|
|
|
|
@given("a session inactive for {days:d} days and threshold is {threshold:d}")
|
|
def step_inactive_session(context: Context, days: int, threshold: int) -> None:
|
|
context.settings = _make_settings(cleanup_session_inactivity_days=threshold)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
updated = datetime.now(tz=UTC) - timedelta(days=days)
|
|
context.test_sessions = [
|
|
{"session_id": "sess-001", "updated_at": updated},
|
|
]
|
|
|
|
|
|
@when("I scan for inactive sessions")
|
|
def step_scan_sessions(context: Context) -> None:
|
|
sessions = getattr(context, "test_sessions", [])
|
|
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
|
|
|
|
|
@when("I purge inactive sessions")
|
|
def step_purge_sessions(context: Context) -> None:
|
|
# For the unit test we simulate by scanning + counting
|
|
sessions = getattr(context, "test_sessions", [])
|
|
context.inactive_sessions = context.service.scan_inactive_sessions(sessions)
|
|
context.report = CleanupReport(dry_run=False)
|
|
context.report.sessions.removed = len(context.inactive_sessions)
|
|
|
|
|
|
@then("the inactive session should be identified")
|
|
def step_inactive_identified(context: Context) -> None:
|
|
assert len(context.inactive_sessions) >= 1, (
|
|
f"Expected >= 1 inactive, found {len(context.inactive_sessions)}"
|
|
)
|
|
|
|
|
|
@then("the session should not be identified")
|
|
def step_session_not_identified(context: Context) -> None:
|
|
assert len(context.inactive_sessions) == 0, (
|
|
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
|
)
|
|
|
|
|
|
@then("the session should be removed")
|
|
def step_session_removed(context: Context) -> None:
|
|
assert context.report.sessions.removed >= 1, (
|
|
f"Expected >= 1 removed, got {context.report.sessions.removed}"
|
|
)
|
|
|
|
|
|
@then("the cleanup summary should report {count:d} session removed")
|
|
def step_session_summary(context: Context, count: int) -> None:
|
|
assert context.report.sessions.removed == count, (
|
|
f"Expected {count} sessions removed, got {context.report.sessions.removed}"
|
|
)
|
|
|
|
|
|
# ── Log cleanup scenarios ─────────────────────────────────────────
|
|
|
|
|
|
@given("a log file older than {days:d} days")
|
|
def step_old_log(context: Context, days: int) -> None:
|
|
context.settings = _make_settings(
|
|
cleanup_log_retention_days=days,
|
|
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
|
)
|
|
_register_cleanup(context, context.settings.log_dir)
|
|
context.log_file = context.settings.log_dir / "old.log"
|
|
context.log_file.write_text("old log content")
|
|
old_time = time.time() - ((days + 5) * 86400)
|
|
os.utime(context.log_file, (old_time, old_time))
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@given("a log file created {days:d} days ago")
|
|
def step_fresh_log(context: Context, days: int) -> None:
|
|
context.settings = _make_settings(
|
|
cleanup_log_retention_days=30,
|
|
log_dir=Path(tempfile.mkdtemp(prefix="ca-log-test-")),
|
|
)
|
|
_register_cleanup(context, context.settings.log_dir)
|
|
context.log_file = context.settings.log_dir / "fresh.log"
|
|
context.log_file.write_text("fresh log content")
|
|
recent_time = time.time() - (days * 86400)
|
|
os.utime(context.log_file, (recent_time, recent_time))
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@when("I scan for expired logs")
|
|
def step_scan_logs(context: Context) -> None:
|
|
context.expired_logs = context.service.scan_expired_files(
|
|
context.settings.log_dir,
|
|
context.settings.cleanup_log_retention_days,
|
|
pattern="*.log",
|
|
)
|
|
|
|
|
|
@then("the expired log file should be identified")
|
|
def step_expired_log_found(context: Context) -> None:
|
|
assert len(context.expired_logs) >= 1, (
|
|
f"Expected >= 1 expired log, found {len(context.expired_logs)}"
|
|
)
|
|
|
|
|
|
@then("the log file should not be identified")
|
|
def step_log_not_found(context: Context) -> None:
|
|
assert len(context.expired_logs) == 0, (
|
|
f"Expected 0 expired logs, found {len(context.expired_logs)}"
|
|
)
|
|
|
|
|
|
@then("the log file should be removed")
|
|
def step_log_removed(context: Context) -> None:
|
|
assert not context.log_file.exists(), f"Log file {context.log_file} still exists"
|
|
|
|
|
|
# ── Backup cleanup scenarios ──────────────────────────────────────
|
|
|
|
|
|
@given("a backup file older than {days:d} days")
|
|
def step_old_backup(context: Context, days: int) -> None:
|
|
context.settings = _make_settings(
|
|
cleanup_backup_retention_days=days,
|
|
data_dir=Path(tempfile.mkdtemp(prefix="ca-data-test-")),
|
|
)
|
|
backup_dir = context.settings.data_dir / "backups"
|
|
backup_dir.mkdir(parents=True)
|
|
_register_cleanup(context, context.settings.data_dir)
|
|
context.backup_file = backup_dir / "backup-20250101.tar.gz"
|
|
context.backup_file.write_text("backup data")
|
|
old_time = time.time() - ((days + 5) * 86400)
|
|
os.utime(context.backup_file, (old_time, old_time))
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
|
|
|
|
@when("I scan for expired backups")
|
|
def step_scan_backups(context: Context) -> None:
|
|
backup_dir = context.service._get_backup_dir()
|
|
context.expired_backups = context.service.scan_expired_files(
|
|
backup_dir,
|
|
context.settings.cleanup_backup_retention_days,
|
|
pattern="*",
|
|
)
|
|
|
|
|
|
@then("the expired backup should be identified")
|
|
def step_expired_backup_found(context: Context) -> None:
|
|
assert len(context.expired_backups) >= 1, (
|
|
f"Expected >= 1 expired backup, found {len(context.expired_backups)}"
|
|
)
|
|
|
|
|
|
# ── Dry-run mode ──────────────────────────────────────────────────
|
|
|
|
|
|
@when("I run cleanup in dry-run mode")
|
|
def step_dry_run(context: Context) -> None:
|
|
context.report = context.service.scan()
|
|
|
|
|
|
@then("the dry-run report should show {count:d} sandbox to clean")
|
|
def step_dry_run_sandboxes(context: Context, count: int) -> None:
|
|
sandbox_items = [
|
|
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
|
]
|
|
assert len(sandbox_items) == count, (
|
|
f"Expected {count} sandbox items, found {len(sandbox_items)}"
|
|
)
|
|
|
|
|
|
@then("the dry-run report should show {count:d} session to clean")
|
|
def step_dry_run_sessions(context: Context, count: int) -> None:
|
|
# Sessions are identified externally; we track via inactive_sessions
|
|
inactive = getattr(context, "inactive_sessions", None)
|
|
if inactive is None:
|
|
sessions = getattr(context, "test_sessions", [])
|
|
inactive = context.service.scan_inactive_sessions(sessions)
|
|
assert len(inactive) == count, (
|
|
f"Expected {count} session items, found {len(inactive)}"
|
|
)
|
|
|
|
|
|
@then("no files should be deleted")
|
|
def step_no_files_deleted(context: Context) -> None:
|
|
# In dry-run, the sandbox_dir should still exist
|
|
if hasattr(context, "sandbox_dir"):
|
|
assert context.sandbox_dir.exists(), "Sandbox was deleted in dry-run mode"
|
|
|
|
|
|
@then("the dry-run report should include the sandbox path")
|
|
def step_dry_run_path(context: Context) -> None:
|
|
paths = [i.path for i in context.report.stale_items]
|
|
assert str(context.sandbox_dir) in paths, (
|
|
f"Expected {context.sandbox_dir} in stale paths, got {paths}"
|
|
)
|
|
|
|
|
|
# ── Full purge mode ──────────────────────────────────────────────
|
|
|
|
|
|
@when("I run cleanup with purge all")
|
|
def step_purge_all(context: Context) -> None:
|
|
context.report = context.service.purge(purge_all=True)
|
|
# For session cleanup, simulate removal count
|
|
if hasattr(context, "test_sessions"):
|
|
inactive = context.service.scan_inactive_sessions(context.test_sessions)
|
|
context.report.sessions.removed += len(inactive)
|
|
|
|
|
|
# ── Per-resource summary ──────────────────────────────────────────
|
|
|
|
|
|
@then("the cleanup summary should report 0 sandboxes removed")
|
|
def step_zero_sandboxes(context: Context) -> None:
|
|
assert context.report.sandboxes.removed == 0
|
|
|
|
|
|
@then("the cleanup summary should report 0 sessions removed")
|
|
def step_zero_sessions(context: Context) -> None:
|
|
assert context.report.sessions.removed == 0
|
|
|
|
|
|
@then("the cleanup summary should report 0 checkpoints removed")
|
|
def step_zero_checkpoints(context: Context) -> None:
|
|
assert context.report.checkpoints.removed == 0
|
|
|
|
|
|
# ── Custom config ─────────────────────────────────────────────────
|
|
|
|
|
|
@given("cleanup settings with sandbox max age {hours:d} hours")
|
|
def step_custom_settings(context: Context, hours: int) -> None:
|
|
context.settings = _make_settings(cleanup_sandbox_max_age_hours=hours)
|
|
|
|
|
|
# ── Edge cases ────────────────────────────────────────────────────
|
|
|
|
|
|
@given("the sandbox temp directory does not exist")
|
|
def step_missing_temp(context: Context) -> None:
|
|
context.settings = _make_settings(cleanup_sandbox_max_age_hours=48)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
# Patch _get_sandbox_dirs to return empty (non-existent temp)
|
|
context.service._get_sandbox_dirs = lambda: []
|
|
|
|
|
|
@then("zero stale sandboxes should be found")
|
|
def step_zero_stale(context: Context) -> None:
|
|
sandbox_items = [
|
|
i for i in context.report.stale_items if i.resource_type == "sandbox"
|
|
]
|
|
assert len(sandbox_items) == 0, f"Expected 0 stale, found {len(sandbox_items)}"
|
|
|
|
|
|
@given("an empty database with no sessions or plans")
|
|
def step_empty_db(context: Context) -> None:
|
|
context.settings = _make_settings(cleanup_session_inactivity_days=30)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
context.test_sessions = []
|
|
|
|
|
|
@then("zero inactive sessions should be found")
|
|
def step_zero_sessions_found(context: Context) -> None:
|
|
assert len(context.inactive_sessions) == 0, (
|
|
f"Expected 0 inactive, found {len(context.inactive_sessions)}"
|
|
)
|
|
|
|
|
|
# ── Shared steps that work across scenarios ───────────────────────
|
|
|
|
|
|
@given("no stale resources exist")
|
|
def step_no_stale(context: Context) -> None:
|
|
context.settings = _make_settings(
|
|
cleanup_sandbox_max_age_hours=48,
|
|
cleanup_session_inactivity_days=30,
|
|
cleanup_checkpoint_max_per_plan=50,
|
|
)
|
|
context.service = CleanupService(
|
|
settings=context.settings,
|
|
active_plan_ids=frozenset(),
|
|
)
|
|
# Ensure no temp sandbox dirs match (patch to empty)
|
|
context.service._get_sandbox_dirs = lambda: []
|