250 lines
8.2 KiB
Python
250 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_SOURCE = Path("/opt/matrix-screen-controller/data")
|
|
DEFAULT_TARGET = Path("/var/lib/matrix-screen-controller")
|
|
STAGING_NAME = ".matrix-screen-controller.migrate"
|
|
|
|
ROOT_RUNTIME_FILES = {
|
|
"last_frame.png",
|
|
"startup_indicator.json",
|
|
"startup_indicator.json.tmp",
|
|
"config.json.tmp",
|
|
}
|
|
ROOT_RUNTIME_DIRECTORIES = {"runtime"}
|
|
ATOMIC_TEMP_RE = re.compile(r"^\..+\.[0-9a-f]{32}\.tmp$")
|
|
|
|
|
|
class StateRootMigrationError(RuntimeError):
|
|
"""Raised when the state root cannot be migrated without data loss."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MigrationResult:
|
|
status: str
|
|
source: Path
|
|
target: Path
|
|
file_count: int
|
|
total_bytes: int
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"status": self.status,
|
|
"source": str(self.source),
|
|
"target": str(self.target),
|
|
"file_count": self.file_count,
|
|
"total_bytes": self.total_bytes,
|
|
}
|
|
|
|
|
|
def _is_transient(relative: Path) -> bool:
|
|
if relative.parts and relative.parts[0] in ROOT_RUNTIME_DIRECTORIES:
|
|
return True
|
|
if len(relative.parts) == 1 and relative.name in ROOT_RUNTIME_FILES:
|
|
return True
|
|
return bool(ATOMIC_TEMP_RE.fullmatch(relative.name))
|
|
|
|
|
|
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 build_manifest(root: Path) -> dict[str, dict[str, Any]]:
|
|
root = Path(root)
|
|
if not root.exists():
|
|
return {}
|
|
if not root.is_dir() or root.is_symlink():
|
|
raise StateRootMigrationError(f"state root must be a real directory: {root}")
|
|
|
|
manifest: dict[str, dict[str, Any]] = {}
|
|
for path in sorted(root.rglob("*")):
|
|
relative = path.relative_to(root)
|
|
if _is_transient(relative):
|
|
continue
|
|
if path.is_symlink():
|
|
raise StateRootMigrationError(f"state root contains an unsupported symlink: {relative}")
|
|
if path.is_dir():
|
|
manifest[relative.as_posix()] = {"type": "directory"}
|
|
continue
|
|
if not path.is_file():
|
|
raise StateRootMigrationError(f"state root contains an unsupported entry: {relative}")
|
|
size = path.stat().st_size
|
|
manifest[relative.as_posix()] = {
|
|
"type": "file",
|
|
"size": size,
|
|
"sha256": _sha256(path),
|
|
}
|
|
return manifest
|
|
|
|
|
|
def _summary(manifest: dict[str, dict[str, Any]]) -> tuple[int, int]:
|
|
files = [entry for entry in manifest.values() if entry["type"] == "file"]
|
|
return len(files), sum(int(entry["size"]) for entry in files)
|
|
|
|
|
|
def _copy_ignore(source: Path):
|
|
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
base = Path(directory)
|
|
ignored: set[str] = set()
|
|
for name in names:
|
|
relative = (base / name).relative_to(source)
|
|
if _is_transient(relative):
|
|
ignored.add(name)
|
|
return ignored
|
|
|
|
return ignore
|
|
|
|
|
|
def _fsync_directory(path: Path) -> None:
|
|
if os.name == "nt":
|
|
return
|
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
def _fsync_tree(root: Path) -> None:
|
|
if os.name == "nt":
|
|
return
|
|
directories = [root]
|
|
for path in root.rglob("*"):
|
|
if path.is_file():
|
|
descriptor = os.open(path, os.O_RDONLY)
|
|
try:
|
|
os.fsync(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
elif path.is_dir():
|
|
directories.append(path)
|
|
for directory in reversed(directories):
|
|
_fsync_directory(directory)
|
|
|
|
|
|
def _assert_staging_path(staging: Path, target: Path) -> None:
|
|
if staging.parent != target.parent or staging.name != STAGING_NAME:
|
|
raise StateRootMigrationError(f"refusing to clean unexpected staging path: {staging}")
|
|
|
|
|
|
def _remove_staging(staging: Path, target: Path) -> None:
|
|
if not staging.exists():
|
|
return
|
|
_assert_staging_path(staging, target)
|
|
if staging.is_symlink() or not staging.is_dir():
|
|
raise StateRootMigrationError(f"refusing to replace unexpected staging entry: {staging}")
|
|
shutil.rmtree(staging)
|
|
|
|
|
|
def migrate_state_root(source: Path, target: Path) -> MigrationResult:
|
|
source_input = Path(source)
|
|
target_input = Path(target)
|
|
if source_input.is_symlink() or target_input.is_symlink():
|
|
raise StateRootMigrationError("source and target state roots must not be symlinks")
|
|
source = source_input.resolve(strict=False)
|
|
target = target_input.resolve(strict=False)
|
|
if source == target:
|
|
raise StateRootMigrationError("source and target state roots must be different")
|
|
if source in target.parents or target in source.parents:
|
|
raise StateRootMigrationError("source and target state roots must not contain each other")
|
|
|
|
source_manifest = build_manifest(source)
|
|
if not source.exists():
|
|
target_manifest = build_manifest(target)
|
|
file_count, total_bytes = _summary(target_manifest)
|
|
if file_count == 0:
|
|
raise StateRootMigrationError(
|
|
"legacy source is missing and target contains no persistent files"
|
|
)
|
|
return MigrationResult("target-only", source, target, file_count, total_bytes)
|
|
source_file_count, _source_total_bytes = _summary(source_manifest)
|
|
if source_file_count == 0:
|
|
raise StateRootMigrationError("legacy source contains no persistent files")
|
|
|
|
target_manifest = build_manifest(target)
|
|
if target_manifest:
|
|
if target_manifest != source_manifest:
|
|
raise StateRootMigrationError(
|
|
"source and target both contain different state; refusing to merge or overwrite"
|
|
)
|
|
file_count, total_bytes = _summary(target_manifest)
|
|
return MigrationResult("already-prepared", source, target, file_count, total_bytes)
|
|
|
|
target_parent = target.parent
|
|
target_parent.mkdir(parents=True, exist_ok=True)
|
|
staging = target_parent / STAGING_NAME
|
|
_remove_staging(staging, target)
|
|
|
|
if target.exists():
|
|
if target.is_symlink() or not target.is_dir():
|
|
raise StateRootMigrationError(f"target state root is not a real directory: {target}")
|
|
if any(target.iterdir()):
|
|
raise StateRootMigrationError(f"target state root is not empty: {target}")
|
|
|
|
try:
|
|
shutil.copytree(
|
|
source,
|
|
staging,
|
|
copy_function=shutil.copy2,
|
|
ignore=_copy_ignore(source),
|
|
)
|
|
copied_manifest = build_manifest(staging)
|
|
if copied_manifest != source_manifest:
|
|
raise StateRootMigrationError("copied state failed SHA-256 inventory verification")
|
|
_fsync_tree(staging)
|
|
if target.exists():
|
|
target.rmdir()
|
|
os.replace(staging, target)
|
|
if os.name != "nt":
|
|
os.chmod(target, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP)
|
|
_fsync_directory(target_parent)
|
|
except Exception:
|
|
_remove_staging(staging, target)
|
|
raise
|
|
|
|
file_count, total_bytes = _summary(source_manifest)
|
|
return MigrationResult("migrated", source, target, file_count, total_bytes)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Copy the legacy Matrix Screen Controller data directory to its persistent "
|
|
"Linux state root without deleting the source."
|
|
)
|
|
)
|
|
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
|
|
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
result = migrate_state_root(args.source, args.target)
|
|
except (OSError, StateRootMigrationError) as exc:
|
|
print(f"state migration failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(result.to_dict(), ensure_ascii=True, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|