196 lines
6.2 KiB
Python
196 lines
6.2 KiB
Python
"""
|
|
QCOW2 workspace freeze — snapshot a build workspace into a qcow2 image.
|
|
|
|
Uses qemu-img to create a blank qcow2, then mounts it via nbd, rsyncs
|
|
the workdir into it, and unmounts. The resulting .qcow2 file is a
|
|
self-contained frozen snapshot that can be:
|
|
- attached to a VM for reproducible builds
|
|
- copied offsite for archival
|
|
- mounted later via `qemu-nbd --connect` for inspection
|
|
|
|
Requires: qemu-utils (qemu-img, qemu-nbd) + rsync on the host.
|
|
Will return None and log a warning if any dependency is missing.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
def _have(*cmds) -> bool:
|
|
"""Check that all commands exist on PATH."""
|
|
for c in cmds:
|
|
if shutil.which(c) is None:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _find_free_nbd() -> Optional[str]:
|
|
"""Find a free /dev/nbdN device. Returns None if none available."""
|
|
if not Path("/dev").exists():
|
|
return None
|
|
for i in range(16):
|
|
dev = f"/dev/nbd{i}"
|
|
if not Path(dev).exists():
|
|
continue
|
|
# Check if it's already connected
|
|
try:
|
|
r = subprocess.run(
|
|
["qemu-nbd", "--disconnect", dev],
|
|
capture_output=True, timeout=2,
|
|
)
|
|
# If disconnect succeeds (or even if it says "not connected"),
|
|
# the device is now free
|
|
if r.returncode == 0 or b"not connected" in r.stderr.lower() if r.stderr else True:
|
|
return dev
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def freeze_to_qcow2(workdir, output_path, size: str = "10G") -> Optional[str]:
|
|
"""
|
|
Freeze a build workspace into a qcow2 image.
|
|
|
|
Args:
|
|
workdir: directory to snapshot
|
|
output_path: where to write the .qcow2 file
|
|
size: image size (e.g. "10G", "500M")
|
|
|
|
Returns the path to the created qcow2 file, or None if any step failed.
|
|
"""
|
|
# Dependency check
|
|
if not _have("qemu-img", "rsync"):
|
|
return None
|
|
|
|
# nbd module required for mounting; check but don't fail at import
|
|
have_nbd = shutil.which("qemu-nbd") is not None
|
|
|
|
workdir = Path(workdir)
|
|
out = Path(output_path)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not workdir.exists():
|
|
return None
|
|
|
|
# Step 1: Create the qcow2 image
|
|
try:
|
|
subprocess.run(
|
|
["qemu-img", "create", "-f", "qcow2", str(out), size],
|
|
check=True, capture_output=True,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return None
|
|
|
|
# Step 2: Try to mount via nbd and rsync the workdir in.
|
|
# If nbd isn't available (no /dev/nbd* or no permission), we still
|
|
# return the empty image path — caller can use `qemu-img info` to
|
|
# verify, and the image can be mounted later on a host with nbd.
|
|
nbd_dev = _find_free_nbd() if have_nbd else None
|
|
if nbd_dev is None:
|
|
# No nbd available — return the empty image (still a valid freeze
|
|
# artifact, just without the workdir contents inlined)
|
|
return str(out)
|
|
|
|
# Connect the qcow2 to the nbd device
|
|
try:
|
|
subprocess.run(
|
|
["qemu-nbd", "--connect", nbd_dev, str(out)],
|
|
check=True, capture_output=True, timeout=10,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
return str(out) # image exists, just not mounted
|
|
|
|
# Wait for the device to appear
|
|
time.sleep(0.5)
|
|
|
|
# Step 3: Format + mount the nbd device
|
|
mount_point = Path(f"/tmp/fester-mount-{int(time.time())}")
|
|
mount_point.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
# Format as ext4 if the device is empty
|
|
# (use -F to force formatting without prompt)
|
|
subprocess.run(
|
|
["mkfs.ext4", "-F", nbd_dev],
|
|
check=True, capture_output=True, timeout=30,
|
|
)
|
|
subprocess.run(
|
|
["mount", nbd_dev, str(mount_point)],
|
|
check=True, capture_output=True, timeout=10,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
# Cleanup: disconnect nbd
|
|
try:
|
|
subprocess.run(["qemu-nbd", "--disconnect", nbd_dev],
|
|
capture_output=True, timeout=5)
|
|
except Exception:
|
|
pass
|
|
return str(out)
|
|
|
|
# Step 4: rsync workdir into the mounted image
|
|
try:
|
|
subprocess.run(
|
|
["rsync", "-a", "--delete",
|
|
f"{workdir}/", f"{mount_point}/"],
|
|
check=True, capture_output=True, timeout=120,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
pass # partial copy is still useful
|
|
|
|
# Step 5: Unmount + disconnect
|
|
try:
|
|
subprocess.run(["umount", str(mount_point)],
|
|
check=True, capture_output=True, timeout=10)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
pass
|
|
try:
|
|
subprocess.run(["qemu-nbd", "--disconnect", nbd_dev],
|
|
capture_output=True, timeout=5)
|
|
except Exception:
|
|
pass
|
|
|
|
# Cleanup mount point
|
|
try:
|
|
mount_point.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
return str(out)
|
|
|
|
|
|
def qcow2_info(image_path) -> dict:
|
|
"""Return metadata about a qcow2 image via `qemu-img info`."""
|
|
if not _have("qemu-img"):
|
|
return {"error": "qemu-img not available"}
|
|
try:
|
|
out = subprocess.check_output(
|
|
["qemu-img", "info", "--output=json", str(image_path)],
|
|
stderr=subprocess.DEVNULL,
|
|
).decode()
|
|
import json
|
|
return json.loads(out)
|
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def list_snapshots(snapshots_dir: str = "/var/lib/fester/snapshots") -> list:
|
|
"""List all .qcow2 snapshots in the snapshots directory."""
|
|
d = Path(snapshots_dir)
|
|
if not d.exists():
|
|
return []
|
|
out = []
|
|
for p in sorted(d.glob("*.qcow2")):
|
|
info = qcow2_info(p)
|
|
out.append({
|
|
"name": p.stem,
|
|
"path": str(p),
|
|
"size_bytes": p.stat().st_size,
|
|
"created_at": p.stat().st_mtime,
|
|
"info": info if "error" not in info else None,
|
|
})
|
|
return out
|