初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user