fester/backend/storage/router.py

125 lines
3.8 KiB
Python

"""
Storage router — unified entry point for workspace preparation,
btrfs CAS, tmpfs acceleration, and qcow2 freeze.
Used by:
- BuildRunner (to prepare per-build workspace)
- /api/storage/* endpoints (status, config, freeze)
"""
import time
from pathlib import Path
from typing import Any, Dict, Optional
from backend.storage.config import load_storage_config, save_storage_config
from backend.storage.tmpfs import get_tmpfs_workspace
from backend.storage.btrfs_cas import (
cas_path, cas_store, cas_exists, cas_retrieve, is_btrfs, reflink_snapshot,
)
from backend.storage.qcow2_freeze import (
freeze_to_qcow2, qcow2_info, list_snapshots,
)
def prepare_workspace(project_name: str) -> Dict[str, Any]:
"""Prepare a workspace for a build. Always returns a tmpfs path,
optionally also a btrfs CAS path."""
cfg = load_storage_config()
workspace = {
"tmpfs": None,
"cas": None,
"freeze": None,
"config": cfg,
}
# Always: tmpfs workspace
try:
workspace["tmpfs"] = get_tmpfs_workspace(cfg, project_name)
except Exception:
workspace["tmpfs"] = None
# Optional: btrfs CAS path
if cfg.get("btrfs"):
try:
workspace["cas"] = cas_path(cfg, project_name)
except Exception:
workspace["cas"] = None
return workspace
def maybe_freeze_workspace(workdir, project_name) -> Optional[str]:
"""If qcow2_freeze is enabled, snapshot the workdir into a qcow2 image.
Returns the path to the .qcow2 file, or None if freezing is disabled
or failed."""
cfg = load_storage_config()
if not cfg.get("qcow2_freeze"):
return None
output_dir = Path(cfg.get("qcow2_path", "/var/lib/fester/snapshots"))
output = output_dir / f"{project_name}-{int(time.time())}.qcow2"
return freeze_to_qcow2(workdir, str(output))
def storage_status() -> Dict[str, Any]:
"""Return a snapshot of the current storage configuration + capabilities."""
cfg = load_storage_config()
# Check which capabilities are actually available
import shutil as _sh
have_qemu_img = _sh.which("qemu-img") is not None
have_qemu_nbd = _sh.which("qemu-nbd") is not None
have_rsync = _sh.which("rsync") is not None
# Check if tmpfs path is on tmpfs
tmpfs_path = Path(cfg.get("tmpfs_path", "/dev/shm/fester-build"))
on_tmpfs = False
if tmpfs_path.exists():
try:
import subprocess
out = subprocess.check_output(
["stat", "-f", "-c", "%T", str(tmpfs_path)],
stderr=subprocess.DEVNULL,
).decode().strip()
on_tmpfs = out == "tmpfs"
except Exception:
pass
# Check if btrfs path is on btrfs
btrfs_path = Path(cfg.get("btrfs_path", "/var/lib/fester/cas"))
on_btrfs = is_btrfs(btrfs_path) if btrfs_path.exists() else False
return {
"config": cfg,
"capabilities": {
"tmpfs": {
"enabled": cfg.get("tmpfs", False),
"path": str(tmpfs_path),
"on_tmpfs": on_tmpfs,
},
"btrfs_cas": {
"enabled": cfg.get("btrfs", False),
"path": str(btrfs_path),
"on_btrfs": on_btrfs,
},
"qcow2_freeze": {
"enabled": cfg.get("qcow2_freeze", False),
"path": cfg.get("qcow2_path", "/var/lib/fester/snapshots"),
"qemu_img_available": have_qemu_img,
"qemu_nbd_available": have_qemu_nbd,
"rsync_available": have_rsync,
},
},
}
def update_storage_config(updates: Dict[str, Any]) -> Dict[str, Any]:
"""Merge updates into the storage config and persist."""
cfg = load_storage_config()
cfg.update(updates)
saved = save_storage_config(cfg)
return {"saved": saved, "config": cfg}