**retain_closets.py** — 90-day closet aging enforcement for #1083. Removes *.closet.json files older than --days (default 90) from the fleet palace. Supports --dry-run for safe preview. Wired into the weekly-audit workflow as a dry-run CI step; production cron guidance added to workflow comments. **tunnel_sync.py** — remote wizard wing pull client for #1078. Connects to a peer's fleet_api.py HTTP endpoint, discovers wings via /wings, and pulls core rooms via /search into local *.closet.json files. Zero new dependencies (stdlib urllib only). Supports --dry-run. This is the code side of the inter-wizard tunnel; infrastructure (second wizard VPS + fleet_api.py running) still required. **Tests:** 29 new tests, all passing. Total suite: 294 passing. Refs #1075, #1078, #1083
164 lines
4.6 KiB
Python
164 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
retain_closets.py — Retention policy enforcement for fleet palace closets.
|
|
|
|
Removes closet files older than a configurable retention window (default: 90 days).
|
|
Run this on the Alpha host (or any fleet palace directory) to enforce the
|
|
closet aging policy described in #1083.
|
|
|
|
Usage:
|
|
# Dry-run: show what would be removed (no deletions)
|
|
python mempalace/retain_closets.py --dry-run
|
|
|
|
# Enforce 90-day retention (default)
|
|
python mempalace/retain_closets.py
|
|
|
|
# Custom retention window
|
|
python mempalace/retain_closets.py --days 30
|
|
|
|
# Custom palace path
|
|
python mempalace/retain_closets.py /data/fleet --days 90
|
|
|
|
Exits:
|
|
0 — success (clean, or pruned without error)
|
|
1 — error (e.g., palace directory not found)
|
|
|
|
Refs: #1083, #1075
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
DEFAULT_RETENTION_DAYS = 90
|
|
DEFAULT_PALACE_PATH = "/var/lib/mempalace/fleet"
|
|
|
|
|
|
@dataclass
|
|
class RetentionResult:
|
|
scanned: int = 0
|
|
removed: int = 0
|
|
kept: int = 0
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return len(self.errors) == 0
|
|
|
|
|
|
def _file_age_days(path: Path) -> float:
|
|
"""Return the age of a file in days based on mtime."""
|
|
mtime = path.stat().st_mtime
|
|
now = time.time()
|
|
return (now - mtime) / 86400.0
|
|
|
|
|
|
def enforce_retention(
|
|
palace_dir: Path,
|
|
retention_days: int = DEFAULT_RETENTION_DAYS,
|
|
dry_run: bool = False,
|
|
) -> RetentionResult:
|
|
"""
|
|
Remove *.closet.json files older than *retention_days* from *palace_dir*.
|
|
|
|
Only closet files are pruned — raw drawer files are never present in a
|
|
compliant fleet palace, so this script does not touch them.
|
|
|
|
Args:
|
|
palace_dir: Root directory of the fleet palace to scan.
|
|
retention_days: Files older than this many days will be removed.
|
|
dry_run: If True, report what would be removed but make no changes.
|
|
|
|
Returns:
|
|
RetentionResult with counts and any errors.
|
|
"""
|
|
result = RetentionResult()
|
|
|
|
for closet_file in sorted(palace_dir.rglob("*.closet.json")):
|
|
result.scanned += 1
|
|
try:
|
|
age = _file_age_days(closet_file)
|
|
except OSError as exc:
|
|
result.errors.append(f"Could not stat {closet_file}: {exc}")
|
|
continue
|
|
|
|
if age > retention_days:
|
|
if dry_run:
|
|
print(
|
|
f"[retain_closets] DRY-RUN would remove ({age:.0f}d old): {closet_file}"
|
|
)
|
|
result.removed += 1
|
|
else:
|
|
try:
|
|
closet_file.unlink()
|
|
print(f"[retain_closets] Removed ({age:.0f}d old): {closet_file}")
|
|
result.removed += 1
|
|
except OSError as exc:
|
|
result.errors.append(f"Could not remove {closet_file}: {exc}")
|
|
else:
|
|
result.kept += 1
|
|
|
|
return result
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Enforce retention policy on fleet palace closets."
|
|
)
|
|
parser.add_argument(
|
|
"palace_dir",
|
|
nargs="?",
|
|
default=os.environ.get("FLEET_PALACE_PATH", DEFAULT_PALACE_PATH),
|
|
help=f"Fleet palace directory (default: {DEFAULT_PALACE_PATH})",
|
|
)
|
|
parser.add_argument(
|
|
"--days",
|
|
type=int,
|
|
default=DEFAULT_RETENTION_DAYS,
|
|
metavar="N",
|
|
help=f"Retention window in days (default: {DEFAULT_RETENTION_DAYS})",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Show what would be removed without deleting anything.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
palace_dir = Path(args.palace_dir)
|
|
if not palace_dir.exists():
|
|
print(
|
|
f"[retain_closets] ERROR: palace directory not found: {palace_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
mode = "DRY-RUN" if args.dry_run else "LIVE"
|
|
print(
|
|
f"[retain_closets] {mode} — scanning {palace_dir} "
|
|
f"(retention: {args.days} days)"
|
|
)
|
|
|
|
result = enforce_retention(palace_dir, retention_days=args.days, dry_run=args.dry_run)
|
|
|
|
if result.errors:
|
|
for err in result.errors:
|
|
print(f"[retain_closets] ERROR: {err}", file=sys.stderr)
|
|
return 1
|
|
|
|
action = "would remove" if args.dry_run else "removed"
|
|
print(
|
|
f"[retain_closets] Done — scanned {result.scanned}, "
|
|
f"{action} {result.removed}, kept {result.kept}."
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|