Files
matrix-screen-controller/核桃派软件源代码/scripts/migrate_state_root.py
T

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())