#!/usr/bin/env python3 # PTOS: LAYER=STORE / DOMAIN=OBSERVE / CONDITION=ACTIVE / STAGE=ACTIVE / SOURCE=CODE """ storage_agent.py — Full-loop storage/backup observer, optimizer, and actor. Shim boundary (per AGENTS.md §7.1): ALLOWED: subprocess calls to existing CLI tools, JSON I/O, log/receipt emission, threshold comparisons on Q16_16-encoded integers read back from existing receipts. FORBIDDEN: reimplementing restic/Garage/rclone logic, Float arithmetic, cost functions (those belong in Lean), new external dependencies. Observe → Decide → Act → Emit Receipt (JSON, hash-chained, sharded to S3). Receipt schema: storage_agent_receipt_v1 Usage: python3 4-Infrastructure/storage/storage_agent.py [options] Options: --probe-only Observe and emit receipt but take no corrective actions. --dry-run Show what actions would be taken, do not execute them. --no-s3 Skip uploading receipt to Garage S3 (local JSONL only). --once Run exactly one cycle and exit (default when called from systemd or a hook; loop mode requires --loop). --loop Run in a polling loop (INTERVAL_SECONDS between ticks). --interval N Seconds between loop ticks (default: 900 = 15 min). Environment (all read from /etc/garage/garage.env or environment): GARAGE_ACCESS_KEY_ID, GARAGE_SECRET_ACCESS_KEY, RESTIC_REPOSITORY, RESTIC_PASSWORD_FILE, AWS_ENDPOINT_URL (default: http://localhost:3900) """ from __future__ import annotations import argparse import hashlib import json import os import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Any # ── constants ───────────────────────────────────────────────────────────────── SCHEMA = "storage_agent_receipt_v1" VERSION = "1.0.0" REPO_ROOT = Path(__file__).resolve().parents[3] STORAGE_DIR = Path(__file__).resolve().parent RESTIC_DIR = STORAGE_DIR / "restic" GARAGE_DIR = STORAGE_DIR / "garage" BACKUP_SH = RESTIC_DIR / "backup.sh" CONSOLIDATE_SH = GARAGE_DIR / "db-consolidate.sh" LOG_PATH = Path.home() / ".cache" / "storage-agent.jsonl" GARAGE_ENDPOINT = os.environ.get("AWS_ENDPOINT_URL", "http://localhost:3900") GARAGE_BUCKET = "research-stack" RECEIPT_PREFIX = "agent-receipts" # ── Q16_16 constants ──────────────────────────────────────────────────────── # All thresholds stored as Q16_16 (UInt32: one = 0x00010000 = 65536). # See 6-Documentation/docs/AGENTS.md §1.4 for encoding rules. Q16_ONE: int = 0x00010000 # 1.0 Q16_HALF: int = 0x00008000 # 0.5 Q16_POINT_9: int = 58982 # ≈ 0.9 (58982 / 65536) Q16_TWO: int = 0x00020000 # 2.0 Q16_FIVE: int = 0x00050000 # 5.0 # Dedup-ratio threshold: act on cold-copy if dedup ratio < 30% (poor dedup) Q16_DEDUP_LOW: int = 19661 # 0.3 # ── credential loading ──────────────────────────────────────────────────────── def _load_garage_env() -> dict[str, str]: """ Load Garage credentials from /etc/garage/garage.env into a dict. Fallback to environment variables already set by caller. """ env: dict[str, str] = {} garage_env = Path("/etc/garage/garage.env") if garage_env.exists(): try: raw_lines = garage_env.read_text().splitlines() except PermissionError: raw_lines = [] for line in raw_lines: line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") env[key.strip()] = val.strip() # Map GARAGE_* → AWS_* so aws cli + restic can use them cred_map = { "AWS_ACCESS_KEY_ID": env.get("GARAGE_ACCESS_KEY_ID", os.environ.get("AWS_ACCESS_KEY_ID", "")), "AWS_SECRET_ACCESS_KEY": env.get("GARAGE_SECRET_ACCESS_KEY", os.environ.get("AWS_SECRET_ACCESS_KEY", "")), "AWS_DEFAULT_REGION": env.get("AWS_DEFAULT_REGION", os.environ.get("AWS_DEFAULT_REGION", "garage")), "AWS_ENDPOINT_URL": env.get("AWS_ENDPOINT_URL", GARAGE_ENDPOINT), "RESTIC_REPOSITORY": os.environ.get("RESTIC_REPOSITORY", "s3:http://localhost:3900/research-stack"), "RESTIC_PASSWORD_FILE": os.environ.get("RESTIC_PASSWORD_FILE", "/etc/garage/restic-password"), } return cred_map # ── subprocess helpers ──────────────────────────────────────────────────────── def _run( args: list[str], env_extra: dict[str, str] | None = None, timeout: int = 120, check: bool = False, ) -> subprocess.CompletedProcess[str]: """Run a command, merge env_extra into environment, return CompletedProcess.""" env = os.environ.copy() if env_extra: env.update(env_extra) return subprocess.run( args, capture_output=True, text=True, timeout=timeout, check=check, env=env, ) def _sh( script: Path, subcmd: str, env_extra: dict[str, str] | None = None, timeout: int = 300, ) -> tuple[int, str, str]: """Run bash