GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
377 lines
15 KiB
Python
377 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Create and verify byte-complete, symlink-safe release backup snapshots."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import errno
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
|
|
MANIFEST_HEADER = "relative_path\tsize_bytes\tmtime_ns\tsha256"
|
|
SAFE_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
FICLONE = 0x40049409
|
|
FICLONE_FALLBACK_ERRORS = {
|
|
errno.EXDEV,
|
|
errno.EOPNOTSUPP,
|
|
errno.ENOTTY,
|
|
errno.EINVAL,
|
|
errno.ENOSYS,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceEntry:
|
|
path: Path
|
|
relative_path: str
|
|
stat_result: os.stat_result
|
|
is_directory: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ManifestEntry:
|
|
relative_path: str
|
|
size_bytes: int
|
|
mtime_ns: int
|
|
sha256: str
|
|
|
|
|
|
def _safe_relative(value: str) -> str:
|
|
if not value or "\t" in value or "\n" in value or "\r" in value:
|
|
raise RuntimeError(f"Unsupported snapshot path: {value!r}")
|
|
candidate = PurePosixPath(value)
|
|
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
|
raise RuntimeError(f"Unsafe snapshot path: {value!r}")
|
|
return candidate.as_posix()
|
|
|
|
|
|
def _collect(root: Path) -> list[SourceEntry]:
|
|
entries: list[SourceEntry] = []
|
|
for current, directory_names, file_names in os.walk(root, topdown=True, followlinks=False):
|
|
directory_names.sort()
|
|
file_names.sort()
|
|
current_path = Path(current)
|
|
for name, is_directory in [
|
|
*((name, True) for name in directory_names),
|
|
*((name, False) for name in file_names),
|
|
]:
|
|
path = current_path / name
|
|
details = path.lstat()
|
|
relative = _safe_relative(path.relative_to(root).as_posix())
|
|
if stat.S_ISLNK(details.st_mode):
|
|
raise RuntimeError(f"Release snapshot refuses symlinked content: {relative}")
|
|
if is_directory and not stat.S_ISDIR(details.st_mode):
|
|
raise RuntimeError(f"Snapshot directory changed during inventory: {relative}")
|
|
if not is_directory and not stat.S_ISREG(details.st_mode):
|
|
raise RuntimeError(f"Release snapshot refuses non-regular content: {relative}")
|
|
entries.append(SourceEntry(path, relative, details, is_directory))
|
|
return entries
|
|
|
|
|
|
def _same_file_state(before: os.stat_result, after: os.stat_result) -> bool:
|
|
return (
|
|
before.st_dev,
|
|
before.st_ino,
|
|
before.st_size,
|
|
before.st_mtime_ns,
|
|
) == (
|
|
after.st_dev,
|
|
after.st_ino,
|
|
after.st_size,
|
|
after.st_mtime_ns,
|
|
)
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _copy_all(source_descriptor: int, destination_descriptor: int) -> None:
|
|
while True:
|
|
value = os.read(source_descriptor, 1024 * 1024)
|
|
if not value:
|
|
return
|
|
view = memoryview(value)
|
|
while view:
|
|
written = os.write(destination_descriptor, view)
|
|
if written <= 0:
|
|
raise RuntimeError("Snapshot copy stopped before writing all bytes")
|
|
view = view[written:]
|
|
|
|
|
|
def _clone_or_copy(entry: SourceEntry, destination: Path) -> ManifestEntry:
|
|
source_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
destination_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
|
source_descriptor = os.open(entry.path, source_flags)
|
|
destination_descriptor = -1
|
|
try:
|
|
opened = os.fstat(source_descriptor)
|
|
if not stat.S_ISREG(opened.st_mode) or not _same_file_state(entry.stat_result, opened):
|
|
raise RuntimeError(f"Snapshot file changed before copying: {entry.relative_path}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination_descriptor = os.open(destination, destination_flags, stat.S_IMODE(opened.st_mode))
|
|
cloned = False
|
|
if os.name == "posix":
|
|
try:
|
|
import fcntl
|
|
|
|
fcntl.ioctl(destination_descriptor, FICLONE, source_descriptor)
|
|
cloned = True
|
|
except OSError as exc:
|
|
if exc.errno not in FICLONE_FALLBACK_ERRORS:
|
|
raise
|
|
if not cloned:
|
|
os.lseek(source_descriptor, 0, os.SEEK_SET)
|
|
os.ftruncate(destination_descriptor, 0)
|
|
_copy_all(source_descriptor, destination_descriptor)
|
|
os.fsync(destination_descriptor)
|
|
after = os.fstat(source_descriptor)
|
|
if not _same_file_state(opened, after):
|
|
raise RuntimeError(f"Snapshot file changed while copying: {entry.relative_path}")
|
|
except BaseException:
|
|
if destination_descriptor >= 0:
|
|
os.close(destination_descriptor)
|
|
destination_descriptor = -1
|
|
destination.unlink(missing_ok=True)
|
|
raise
|
|
finally:
|
|
if destination_descriptor >= 0:
|
|
os.close(destination_descriptor)
|
|
os.close(source_descriptor)
|
|
|
|
os.chmod(destination, stat.S_IMODE(entry.stat_result.st_mode) & ~0o222, follow_symlinks=False)
|
|
retained_times = (entry.stat_result.st_atime_ns, entry.stat_result.st_mtime_ns)
|
|
try:
|
|
os.utime(destination, ns=retained_times, follow_symlinks=False)
|
|
except NotImplementedError:
|
|
# Windows does not expose no-follow utime. The destination was created
|
|
# exclusively above; recheck it before using the portable call.
|
|
if destination.is_symlink():
|
|
destination.unlink(missing_ok=True)
|
|
raise RuntimeError(f"Snapshot destination became a symlink: {entry.relative_path}")
|
|
os.utime(destination, ns=retained_times)
|
|
source_checksum = _sha256(entry.path)
|
|
snapshot_checksum = _sha256(destination)
|
|
final_source = entry.path.lstat()
|
|
if not _same_file_state(entry.stat_result, final_source):
|
|
raise RuntimeError(f"Snapshot file changed during checksum verification: {entry.relative_path}")
|
|
if source_checksum != snapshot_checksum:
|
|
raise RuntimeError(f"Snapshot checksum differs from source: {entry.relative_path}")
|
|
return ManifestEntry(
|
|
relative_path=entry.relative_path,
|
|
size_bytes=entry.stat_result.st_size,
|
|
mtime_ns=entry.stat_result.st_mtime_ns,
|
|
sha256=snapshot_checksum,
|
|
)
|
|
|
|
|
|
def _link_verified_prior(
|
|
entry: SourceEntry,
|
|
destination: Path,
|
|
prior_root: Path,
|
|
prior_manifest: dict[str, ManifestEntry],
|
|
) -> ManifestEntry | None:
|
|
retained = prior_manifest.get(entry.relative_path)
|
|
if retained is None or retained.size_bytes != entry.stat_result.st_size:
|
|
return None
|
|
source_checksum = _sha256(entry.path)
|
|
final_source = entry.path.lstat()
|
|
if not _same_file_state(entry.stat_result, final_source):
|
|
raise RuntimeError(f"Snapshot file changed during prior comparison: {entry.relative_path}")
|
|
if source_checksum != retained.sha256:
|
|
return None
|
|
prior_path = prior_root / entry.relative_path
|
|
try:
|
|
prior_details = prior_path.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
if not stat.S_ISREG(prior_details.st_mode) or prior_details.st_size != retained.size_bytes:
|
|
raise RuntimeError(f"Prior snapshot file is not reusable: {entry.relative_path}")
|
|
if _sha256(prior_path) != retained.sha256:
|
|
raise RuntimeError(f"Prior snapshot checksum changed: {entry.relative_path}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
os.link(prior_path, destination, follow_symlinks=False)
|
|
if destination.stat().st_size != retained.size_bytes or _sha256(destination) != retained.sha256:
|
|
destination.unlink(missing_ok=True)
|
|
raise RuntimeError(f"Hard-linked snapshot verification failed: {entry.relative_path}")
|
|
return ManifestEntry(
|
|
relative_path=entry.relative_path,
|
|
size_bytes=entry.stat_result.st_size,
|
|
mtime_ns=entry.stat_result.st_mtime_ns,
|
|
sha256=source_checksum,
|
|
)
|
|
|
|
|
|
def read_manifest(path: Path) -> dict[str, ManifestEntry]:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
if not lines or lines[0] != MANIFEST_HEADER:
|
|
raise RuntimeError(f"Snapshot inventory has an invalid header: {path}")
|
|
entries: dict[str, ManifestEntry] = {}
|
|
for line in lines[1:]:
|
|
fields = line.split("\t")
|
|
if len(fields) != 4:
|
|
raise RuntimeError(f"Snapshot inventory has an invalid row: {line!r}")
|
|
relative_path, size_text, mtime_text, checksum = fields
|
|
relative_path = _safe_relative(relative_path)
|
|
if relative_path in entries:
|
|
raise RuntimeError(f"Snapshot inventory repeats a path: {relative_path}")
|
|
try:
|
|
size_bytes = int(size_text)
|
|
mtime_ns = int(mtime_text)
|
|
except ValueError as exc:
|
|
raise RuntimeError(f"Snapshot inventory has invalid metadata: {relative_path}") from exc
|
|
if size_bytes < 0 or mtime_ns < 0 or not re.fullmatch(r"[0-9a-f]{64}", checksum):
|
|
raise RuntimeError(f"Snapshot inventory has invalid retained state: {relative_path}")
|
|
entries[relative_path] = ManifestEntry(relative_path, size_bytes, mtime_ns, checksum)
|
|
return entries
|
|
|
|
|
|
def verify_snapshot(snapshot_path: Path, manifest_path: Path) -> None:
|
|
root = snapshot_path.expanduser().resolve(strict=True)
|
|
if not root.is_dir():
|
|
raise RuntimeError(f"Snapshot path is not a directory: {root}")
|
|
expected = read_manifest(manifest_path)
|
|
observed_entries = _collect(root)
|
|
observed_files = {item.relative_path: item for item in observed_entries if not item.is_directory}
|
|
extra = sorted(set(observed_files) - set(expected))
|
|
missing = sorted(set(expected) - set(observed_files))
|
|
if extra:
|
|
raise RuntimeError(f"Snapshot contains unmanifested files: {', '.join(extra[:10])}")
|
|
if missing:
|
|
raise RuntimeError(f"Snapshot omits manifested files: {', '.join(missing[:10])}")
|
|
for relative, retained in expected.items():
|
|
current = observed_files[relative]
|
|
if current.stat_result.st_size != retained.size_bytes:
|
|
raise RuntimeError(f"Snapshot size differs for: {relative}")
|
|
if _sha256(current.path) != retained.sha256:
|
|
raise RuntimeError(f"Snapshot checksum differs for: {relative}")
|
|
|
|
|
|
def create_snapshot(
|
|
source: Path,
|
|
snapshot_path: Path,
|
|
manifest_path: Path,
|
|
*,
|
|
label: str,
|
|
link_dest_snapshot: Path | None = None,
|
|
link_dest_manifest: Path | None = None,
|
|
) -> None:
|
|
if not SAFE_LABEL.fullmatch(label):
|
|
raise RuntimeError(f"Unsafe snapshot label: {label!r}")
|
|
root = source.expanduser()
|
|
if root.is_symlink():
|
|
raise RuntimeError(f"Release snapshot refuses a symlinked root: {root}")
|
|
root = root.resolve(strict=True)
|
|
if not root.is_dir():
|
|
raise RuntimeError(f"Snapshot source is not a directory: {root}")
|
|
snapshot_path = snapshot_path.expanduser().resolve()
|
|
manifest_path = manifest_path.expanduser().resolve()
|
|
for output in (snapshot_path, manifest_path):
|
|
try:
|
|
output.relative_to(root)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise RuntimeError("Release snapshot output must not be inside its source tree")
|
|
if snapshot_path.exists():
|
|
raise RuntimeError(f"Snapshot destination already exists: {snapshot_path}")
|
|
snapshot_path.mkdir(parents=True, exist_ok=False)
|
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
prior_root: Path | None = None
|
|
prior_manifest: dict[str, ManifestEntry] = {}
|
|
if (link_dest_snapshot is None) != (link_dest_manifest is None):
|
|
raise RuntimeError("Prior snapshot and manifest must be supplied together")
|
|
if link_dest_snapshot is not None and link_dest_manifest is not None:
|
|
prior_root = link_dest_snapshot.expanduser().resolve(strict=True)
|
|
prior_manifest = read_manifest(link_dest_manifest.expanduser().resolve(strict=True))
|
|
|
|
initial = _collect(root)
|
|
retained: list[ManifestEntry] = []
|
|
for entry in initial:
|
|
destination = snapshot_path / entry.relative_path
|
|
if entry.is_directory:
|
|
destination.mkdir(parents=True, exist_ok=False)
|
|
os.chmod(destination, stat.S_IMODE(entry.stat_result.st_mode), follow_symlinks=False)
|
|
continue
|
|
linked = (
|
|
_link_verified_prior(entry, destination, prior_root, prior_manifest)
|
|
if prior_root is not None
|
|
else None
|
|
)
|
|
retained.append(linked or _clone_or_copy(entry, destination))
|
|
final = _collect(root)
|
|
if [(item.relative_path, item.is_directory) for item in initial] != [
|
|
(item.relative_path, item.is_directory) for item in final
|
|
]:
|
|
raise RuntimeError(f"Snapshot source contents changed while backup was running: {root}")
|
|
with manifest_path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
handle.write(f"{MANIFEST_HEADER}\n")
|
|
for entry in retained:
|
|
handle.write(
|
|
f"{entry.relative_path}\t{entry.size_bytes}\t{entry.mtime_ns}\t{entry.sha256}\n"
|
|
)
|
|
verify_snapshot(snapshot_path, manifest_path)
|
|
|
|
|
|
def verify_backup(backup_dir: Path) -> None:
|
|
root = backup_dir.expanduser().resolve(strict=True)
|
|
payload = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
|
for label in ("storage", "models"):
|
|
requested = payload.get(f"{label}_inventory_requested") is True
|
|
snapshotted = payload.get(f"{label}_snapshot_requested") is True
|
|
if requested != snapshotted:
|
|
raise RuntimeError(f"Backup manifest does not bind the {label} inventory to a snapshot")
|
|
if requested:
|
|
verify_snapshot(root / f"{label}-snapshot", root / f"{label}-manifest.tsv")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
create = subparsers.add_parser("create")
|
|
create.add_argument("--source", type=Path, required=True)
|
|
create.add_argument("--snapshot", type=Path, required=True)
|
|
create.add_argument("--manifest", type=Path, required=True)
|
|
create.add_argument("--label", required=True)
|
|
create.add_argument("--link-dest-snapshot", type=Path)
|
|
create.add_argument("--link-dest-manifest", type=Path)
|
|
verify = subparsers.add_parser("verify")
|
|
verify.add_argument("--snapshot", type=Path, required=True)
|
|
verify.add_argument("--manifest", type=Path, required=True)
|
|
verify_backup_parser = subparsers.add_parser("verify-backup")
|
|
verify_backup_parser.add_argument("--backup-dir", type=Path, required=True)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if args.command == "create":
|
|
create_snapshot(
|
|
args.source,
|
|
args.snapshot,
|
|
args.manifest,
|
|
label=args.label,
|
|
link_dest_snapshot=args.link_dest_snapshot,
|
|
link_dest_manifest=args.link_dest_manifest,
|
|
)
|
|
elif args.command == "verify":
|
|
verify_snapshot(args.snapshot, args.manifest)
|
|
else:
|
|
verify_backup(args.backup_dir)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|