""" Content-Addressable Store (CAS) — shared artifact cache for sorcery-go + Fester. This module provides both the storage backend (SQLite-backed CAS table) and the FastAPI router for HTTP access. sorcery-go pushes build artifacts (including .svb Sovereign Bundles) here after BundleSovereign completes, and Fester's PipelineEngine checks the CAS before dispatching — eliminating redundant rebuilds across the cluster. Storage layout: - /var/lib/fester/cas/objects// — artifact file - /var/lib/fester/cas/meta/.json — metadata JSON API endpoints: PUT /api/cas/{sha256} — store an artifact (multipart upload) GET /api/cas/{sha256} — retrieve an artifact (streamed download) HEAD /api/cas/{sha256} — check existence (returns metadata JSON) DELETE /api/cas/{sha256} — remove an artifact GET /api/cas/ — list all artifacts (paginated) GET /api/cas/stats — cache statistics (hits, misses, size) DAG-aware cache integration: The PipelineEngine calls cas_check(action_hash) before executing each action. If the artifact exists in the CAS, the action is skipped and a cache_update event is emitted. This turns a 45-minute dependency tree into a 2-minute cache assembly. Cross-runtime deduplication: An artifact built inside LXC on one node is instantly available to a Firecracker microVM on another. The CAS key is the content SHA-256, so the runtime doesn't matter — only the content hash matters. Integration with sorcery-go: sorcery-go's BundleSovereign produces .svb files with SHA-256 in the METADATA.json. The Cauldron's post-bundle hook calls PUT /api/cas/{sha} with the .svb file. Fester's DAG executor calls HEAD /api/cas/{sha} to skip already-built targets. Config (config.yaml or env vars): cas.enabled: true/false (default: true) cas.path: /var/lib/fester/cas (or FESTER_CAS_PATH env) cas.max_size: 50GB (or FESTER_CAS_MAX_SIZE env) cas.on_btrfs: auto-detect (prefer reflink) """ from __future__ import annotations import hashlib import json import os import shutil import sqlite3 import threading import time from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from fastapi import APIRouter, HTTPException, UploadFile, File, Response from fastapi.responses import FileResponse, JSONResponse # ---------------------------------------------------------------- # Configuration # ---------------------------------------------------------------- def _resolve_cas_path() -> Path: """Resolve the CAS root directory with graceful fallbacks.""" env = os.environ.get("FESTER_CAS_PATH") if env: p = Path(env) p.mkdir(parents=True, exist_ok=True) return p for c in [ Path("/var/lib/fester/cas"), Path.home() / ".fester" / "cas", Path("/tmp/fester_cas"), ]: try: c.mkdir(parents=True, exist_ok=True) # Write-test test = c / ".fester_cas_test" test.write_text("") test.unlink() return c except (OSError, PermissionError): continue return Path("/tmp/fester_cas") # last resort CAS_ROOT = _resolve_cas_path() OBJECTS_DIR = CAS_ROOT / "objects" META_DIR = CAS_ROOT / "meta" OBJECTS_DIR.mkdir(parents=True, exist_ok=True) META_DIR.mkdir(parents=True, exist_ok=True) # Max cache size in bytes (default: 50 GB) MAX_CAS_SIZE = int(os.environ.get("FESTER_CAS_MAX_SIZE", str(50 * 1024 ** 3))) # ---------------------------------------------------------------- # Storage backend # ---------------------------------------------------------------- class CASStore: """Content-addressable artifact store. Artifacts are stored on disk at: objects// Metadata is stored at: meta/.json An in-process SQLite table tracks existence, size, and access time for fast listing and LRU eviction. """ def __init__(self, root: Path = CAS_ROOT): self.root = root self.objects_dir = root / "objects" self.meta_dir = root / "meta" self.objects_dir.mkdir(parents=True, exist_ok=True) self.meta_dir.mkdir(parents=True, exist_ok=True) # SQLite index for fast queries self._db_path = root / "cas.db" self._lock = threading.Lock() self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._migrate() # Cache statistics self._stats_lock = threading.Lock() self._stats = {"hits": 0, "misses": 0, "stores": 0} def _migrate(self): with self._lock: self._conn.executescript(""" CREATE TABLE IF NOT EXISTS cas_entries ( sha256 TEXT PRIMARY KEY, size INTEGER NOT NULL, content_type TEXT, source TEXT, build_id TEXT, target TEXT, runtime TEXT, node TEXT, created_at REAL, last_accessed REAL, access_count INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_cas_target ON cas_entries(target); CREATE INDEX IF NOT EXISTS idx_cas_node ON cas_entries(node); CREATE INDEX IF NOT EXISTS idx_cas_build ON cas_entries(build_id); CREATE INDEX IF NOT EXISTS idx_cas_accessed ON cas_entries(last_accessed); """) self._conn.commit() def _object_path(self, sha256: str) -> Path: """Return the on-disk path for an artifact.""" prefix = sha256[:2] suffix = sha256[2:] return self.objects_dir / prefix / suffix def _meta_path(self, sha256: str) -> Path: """Return the metadata JSON path.""" return self.meta_dir / f"{sha256}.json" # ---------------------------------------------------------------- # Core operations # ---------------------------------------------------------------- def exists(self, sha256: str) -> bool: """Check if an artifact exists. Updates access time on hit.""" path = self._object_path(sha256) if not path.exists(): with self._stats_lock: self._stats["misses"] += 1 return False with self._stats_lock: self._stats["hits"] += 1 # Update access time with self._lock: self._conn.execute( "UPDATE cas_entries SET last_accessed = ?, access_count = access_count + 1 WHERE sha256 = ?", (time.time(), sha256), ) self._conn.commit() return True def store(self, sha256: str, data: bytes, content_type: str = "application/octet-stream", source: str = "", build_id: str = "", target: str = "", runtime: str = "", node: str = "") -> Dict[str, Any]: """Store an artifact. Returns metadata dict.""" path = self._object_path(sha256) path.parent.mkdir(parents=True, exist_ok=True) # Check btrfs reflink first — if we already have this hash, we # can skip the write entirely (content-addressable means no dupes). if path.exists(): # Already stored — just update metadata. with self._stats_lock: self._stats["hits"] += 1 return self.get_meta(sha256) # Write the artifact tmp_path = path.parent / f".tmp_{sha256}" try: with open(tmp_path, "wb") as f: f.write(data) # Atomic rename tmp_path.rename(path) except Exception: if tmp_path.exists(): tmp_path.unlink() raise # Write metadata JSON now = time.time() meta = { "sha256": sha256, "size": len(data), "content_type": content_type, "source": source, "build_id": build_id, "target": target, "runtime": runtime, "node": node, "created_at": now, "last_accessed": now, "access_count": 0, } meta_path = self._meta_path(sha256) with open(meta_path, "w") as f: json.dump(meta, f, indent=2) # Update SQLite index with self._lock: self._conn.execute(""" INSERT OR REPLACE INTO cas_entries (sha256, size, content_type, source, build_id, target, runtime, node, created_at, last_accessed, access_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) """, (sha256, len(data), content_type, source, build_id, target, runtime, node, now, now)) self._conn.commit() with self._stats_lock: self._stats["stores"] += 1 return meta def store_file(self, sha256: str, src_path: str, content_type: str = "application/octet-stream", source: str = "", build_id: str = "", target: str = "", runtime: str = "", node: str = "") -> Dict[str, Any]: """Store an artifact from a file path (uses reflink on btrfs).""" path = self._object_path(sha256) if path.exists(): with self._stats_lock: self._stats["hits"] += 1 return self.get_meta(sha256) path.parent.mkdir(parents=True, exist_ok=True) # Try btrfs reflink first (instant, zero-copy) import subprocess try: result = subprocess.run( ["cp", "--reflink=auto", src_path, str(path)], capture_output=True, timeout=30, ) if result.returncode == 0: size = os.path.getsize(path) else: raise OSError(f"cp --reflink failed: {result.stderr.decode()}") except (FileNotFoundError, subprocess.TimeoutExpired, OSError): # Fallback: shutil copy shutil.copy2(src_path, str(path)) size = os.path.getsize(path) now = time.time() meta = { "sha256": sha256, "size": size, "content_type": content_type, "source": source, "build_id": build_id, "target": target, "runtime": runtime, "node": node, "created_at": now, "last_accessed": now, "access_count": 0, } meta_path = self._meta_path(sha256) with open(meta_path, "w") as f: json.dump(meta, f, indent=2) with self._lock: self._conn.execute(""" INSERT OR REPLACE INTO cas_entries (sha256, size, content_type, source, build_id, target, runtime, node, created_at, last_accessed, access_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) """, (sha256, size, content_type, source, build_id, target, runtime, node, now, now)) self._conn.commit() with self._stats_lock: self._stats["stores"] += 1 return meta def retrieve(self, sha256: str) -> Optional[Tuple[bytes, Dict[str, Any]]]: """Retrieve an artifact and its metadata. Returns (data, meta) or None.""" path = self._object_path(sha256) if not path.exists(): return None with self._lock: self._conn.execute( "UPDATE cas_entries SET last_accessed = ?, access_count = access_count + 1 WHERE sha256 = ?", (time.time(), sha256), ) self._conn.commit() with open(path, "rb") as f: data = f.read() meta = self.get_meta(sha256) return (data, meta) def retrieve_path(self, sha256: str, dst_path: str) -> bool: """Retrieve an artifact to a destination path (uses reflink on btrfs).""" path = self._object_path(sha256) if not path.exists(): return False dst = Path(dst_path) dst.parent.mkdir(parents=True, exist_ok=True) # Try reflink import subprocess try: result = subprocess.run( ["cp", "--reflink=auto", str(path), str(dst)], capture_output=True, timeout=30, ) if result.returncode == 0: with self._lock: self._conn.execute( "UPDATE cas_entries SET last_accessed = ?, access_count = access_count + 1 WHERE sha256 = ?", (time.time(), sha256), ) self._conn.commit() return True except (FileNotFoundError, subprocess.TimeoutExpired): pass # Fallback shutil.copy2(str(path), str(dst)) with self._lock: self._conn.execute( "UPDATE cas_entries SET last_accessed = ?, access_count = access_count + 1 WHERE sha256 = ?", (time.time(), sha256), ) self._conn.commit() return True def delete(self, sha256: str) -> bool: """Delete an artifact from the CAS.""" path = self._object_path(sha256) meta_path = self._meta_path(sha256) removed = False if path.exists(): path.unlink() removed = True if meta_path.exists(): meta_path.unlink() with self._lock: self._conn.execute("DELETE FROM cas_entries WHERE sha256 = ?", (sha256,)) self._conn.commit() return removed def get_meta(self, sha256: str) -> Optional[Dict[str, Any]]: """Get metadata for an artifact.""" meta_path = self._meta_path(sha256) if meta_path.exists(): try: with open(meta_path) as f: return json.load(f) except (json.JSONDecodeError, OSError): pass return None def list_entries(self, limit: int = 100, offset: int = 0, target: Optional[str] = None) -> List[Dict[str, Any]]: """List CAS entries, optionally filtered by target.""" with self._lock: if target: rows = self._conn.execute( "SELECT * FROM cas_entries WHERE target = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", (target, limit, offset), ).fetchall() else: rows = self._conn.execute( "SELECT * FROM cas_entries ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, offset), ).fetchall() return [dict(r) for r in rows] def stats(self) -> Dict[str, Any]: """Return cache statistics.""" with self._lock: row = self._conn.execute( "SELECT COUNT(*) as count, COALESCE(SUM(size), 0) as total_size FROM cas_entries" ).fetchone() with self._stats_lock: s = dict(self._stats) return { "total_artifacts": row["count"], "total_bytes": row["total_size"], "max_bytes": MAX_CAS_SIZE, "utilization_pct": round(row["total_size"] / MAX_CAS_SIZE * 100, 1) if MAX_CAS_SIZE > 0 else 0, "hits": s["hits"], "misses": s["misses"], "stores": s["stores"], "hit_rate_pct": round( s["hits"] / max(s["hits"] + s["misses"], 1) * 100, 1 ), } def evict_lru(self, target_bytes: int) -> int: """Evict least-recently-used entries until we free target_bytes. Returns the number of entries evicted.""" evicted = 0 freed = 0 with self._lock: rows = self._conn.execute( "SELECT sha256, size FROM cas_entries ORDER BY last_accessed ASC" ).fetchall() for row in rows: if freed >= target_bytes: break sha = row["sha256"] if self.delete(sha): freed += row["size"] evicted += 1 return evicted def check_action(self, action_hash: str) -> Optional[Dict[str, Any]]: """Check if a build action's output is already cached. This is the DAG-aware cache integration point. The PipelineEngine calls this before executing each action. If the artifact exists, the action is skipped entirely. Args: action_hash: SHA-256 of the action's expected output. This can be the content hash of the build output, or a synthetic hash of (target + env + source_hash). Returns: Metadata dict if cached, None if not. """ if self.exists(action_hash): return self.get_meta(action_hash) return None def close(self): with self._lock: self._conn.close() # ---------------------------------------------------------------- # Process-wide singleton # ---------------------------------------------------------------- STORE = CASStore() # ---------------------------------------------------------------- # FastAPI router # ---------------------------------------------------------------- router = APIRouter(prefix="/api/cas", tags=["cas"]) @router.get("/stats") async def api_cas_stats(): """Cache statistics — hits, misses, utilization.""" return STORE.stats() @router.get("") async def api_cas_list(limit: int = 100, offset: int = 0, target: Optional[str] = None): """List all cached artifacts.""" return {"artifacts": STORE.list_entries(limit=limit, offset=offset, target=target)} @router.head("/{sha256}") async def api_cas_head(sha256: str): """Check if an artifact exists. Returns metadata JSON on hit.""" if not STORE.exists(sha256): raise HTTPException(404, "artifact not found") meta = STORE.get_meta(sha256) return JSONResponse(content=meta or {"sha256": sha256, "cached": True}) @router.get("/{sha256}") async def api_cas_get(sha256: str): """Retrieve an artifact. Streams the file from disk.""" path = STORE._object_path(sha256) if not path.exists(): raise HTTPException(404, "artifact not found") meta = STORE.get_meta(sha256) content_type = (meta or {}).get("content_type", "application/octet-stream") return FileResponse( str(path), media_type=content_type, filename=f"{sha256[:16]}{'.' + (meta or {}).get('source', '').rsplit('.', 1)[-1] if meta and meta.get('source') else ''}", ) @router.put("/{sha256}") async def api_cas_put( sha256: str, file: UploadFile = File(...), source: str = "", build_id: str = "", target: str = "", runtime: str = "", node: str = "", ): """Store an artifact. The sha256 in the URL must match the file content. Query params: source: source file name (e.g., "busybox-x86_64.svb") build_id: sorcery-go or Fester build ID target: build target (e.g., "x86_64-linux-gnu") runtime: execution runtime (e.g., "podman", "firecracker") node: node name that produced the artifact """ data = await file.read() # Verify the SHA-256 matches actual_sha = hashlib.sha256(data).hexdigest() if actual_sha != sha256: raise HTTPException( 400, f"SHA-256 mismatch: expected {sha256}, got {actual_sha}", ) meta = STORE.store( sha256=sha256, data=data, content_type=file.content_type or "application/octet-stream", source=source or file.filename or "", build_id=build_id, target=target, runtime=runtime, node=node, ) return {"status": "stored", "sha256": sha256, "meta": meta} @router.delete("/{sha256}") async def api_cas_delete(sha256: str): """Delete an artifact from the cache.""" if STORE.delete(sha256): return {"status": "deleted", "sha256": sha256} raise HTTPException(404, "artifact not found")