fester/backend/storage/cas_api.py

582 lines
20 KiB
Python
Executable File

"""
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/<sha256[0:2]>/<sha256[2:]> — artifact file
- /var/lib/fester/cas/meta/<sha256>.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/<sha256[0:2]>/<sha256[2:]>
Metadata is stored at:
meta/<sha256>.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")