init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recoverable multi-file replacement using same-filesystem ``os.replace``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class TransactionError(OSError):
|
||||
"""A commit failed; rollback details are included in the message."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymlinkValue:
|
||||
"""A lexical symlink target to install with the surrounding transaction."""
|
||||
|
||||
target: str
|
||||
|
||||
|
||||
ReplacementValue = bytes | SymlinkValue
|
||||
OriginalValue = tuple[str, bytes | str, int | None]
|
||||
|
||||
|
||||
def _lexical_absolute(path: Path) -> Path:
|
||||
"""Make a path absolute without following an existing symlink."""
|
||||
|
||||
return Path(os.path.abspath(path))
|
||||
|
||||
|
||||
def _temporary_bytes(target: Path, data: bytes, mode: int | None = None) -> Path:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if mode is not None:
|
||||
os.chmod(temporary, mode)
|
||||
return temporary
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _temporary_symlink(target: Path, link_target: str) -> Path:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary_name)
|
||||
temporary.unlink()
|
||||
try:
|
||||
os.symlink(link_target, temporary)
|
||||
return temporary
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _temporary_value(target: Path, value: ReplacementValue, mode: int | None = None) -> Path:
|
||||
if isinstance(value, SymlinkValue):
|
||||
return _temporary_symlink(target, value.target)
|
||||
return _temporary_bytes(target, value, mode)
|
||||
|
||||
|
||||
def _capture_original(target: Path) -> OriginalValue | None:
|
||||
if target.is_symlink():
|
||||
return ("symlink", os.readlink(target), None)
|
||||
if target.exists():
|
||||
return ("bytes", target.read_bytes(), target.stat().st_mode & 0o777)
|
||||
return None
|
||||
|
||||
|
||||
def _write_target(path: Path, value: ReplacementValue, follow_symlinks: bool) -> Path:
|
||||
"""Pick the entry this value should land on.
|
||||
|
||||
vault cutover 이후 ``raw/**``·``wiki/**`` 의 문서는 ``vault/**`` 정본을 가리키는
|
||||
심링크다. ``os.replace`` 는 심링크를 *따라가지 않고* 그 자리를 실파일로 갈아치우므로,
|
||||
호출자가 심링크 경로를 그대로 넘기면 (1) 호환 계층이 끊기고 (2) 정본은 낡은 채로
|
||||
남는 split-brain 이 된다 — 그리고 그 사실이 조용하다. 그래서 bytes 쓰기는 기본적으로
|
||||
심링크를 따라 정본에 쓴다.
|
||||
|
||||
``SymlinkValue`` 는 링크 *자체* 를 설치하는 것이므로 언제나 lexical 경로에 쓴다.
|
||||
``follow_symlinks=False`` 는 cutover/rollback 처럼 심링크를 실파일로 되돌리는 것이
|
||||
목적인 호출자(``vault_migrate``)를 위한 예외다.
|
||||
"""
|
||||
if isinstance(value, SymlinkValue) or not follow_symlinks:
|
||||
return _lexical_absolute(path)
|
||||
if path.is_symlink():
|
||||
return _lexical_absolute(path.resolve())
|
||||
return _lexical_absolute(path)
|
||||
|
||||
|
||||
_STAGE_IGNORE = shutil.ignore_patterns(".git", "__pycache__", "*.pyc", ".DS_Store")
|
||||
|
||||
|
||||
def stage_repository(root: Path, destination: Path) -> None:
|
||||
"""Copy the working tree into ``destination`` *preserving symlinks*.
|
||||
|
||||
canonical 모드에서 ``raw/``·``wiki/`` 는 ``vault/`` 정본을 가리키는 상대 심링크
|
||||
디렉토리다. 기본 ``copytree``(``symlinks=False``)는 각 링크를 따라가 실파일로 복제하므로
|
||||
스테이지가 split-brain(정본·링크가 독립된 실파일 2개)이 되고, candidate 가 legacy 경로로
|
||||
정본을 우회 편집해도 투영/레이아웃 검증이 그 사실을 못 잡는다. ``symlinks=True`` 로
|
||||
링크를 링크 그대로 복제해 실제 저장소 구조를 재현한다.
|
||||
|
||||
(검증 스테이징의 단일 구현 — 예전엔 document_commit / branch_contract_check /
|
||||
migrate_graph_contracts 에 같은 함수가 세 벌 복사돼 있었고, 그 중 하나만 고치면
|
||||
나머지가 조용히 어긋났다.)
|
||||
"""
|
||||
for child in root.iterdir():
|
||||
if child.name == ".git":
|
||||
continue
|
||||
target = destination / child.name
|
||||
if child.is_dir():
|
||||
shutil.copytree(child, target, ignore=_STAGE_IGNORE, symlinks=True)
|
||||
elif child.is_file():
|
||||
shutil.copy2(child, target, follow_symlinks=False)
|
||||
|
||||
|
||||
def replace_many(
|
||||
changes: Mapping[Path, ReplacementValue],
|
||||
*,
|
||||
must_not_exist: set[Path] | None = None,
|
||||
follow_symlinks: bool = True,
|
||||
) -> None:
|
||||
"""Replace bytes/symlinks atomically and restore the original entry kind on failure."""
|
||||
forbidden = {_lexical_absolute(path) for path in (must_not_exist or set())}
|
||||
normalized = {
|
||||
_write_target(path, data, follow_symlinks): data for path, data in changes.items()
|
||||
}
|
||||
for target in forbidden:
|
||||
if target.exists() or target.is_symlink():
|
||||
raise FileExistsError(f"target already exists: {target}")
|
||||
|
||||
originals: dict[Path, OriginalValue | None] = {}
|
||||
pending: dict[Path, Path] = {}
|
||||
committed: list[Path] = []
|
||||
try:
|
||||
for target, value in normalized.items():
|
||||
originals[target] = _capture_original(target)
|
||||
original_mode = originals[target][2] if originals[target] is not None else None
|
||||
pending[target] = _temporary_value(target, value, original_mode)
|
||||
except Exception:
|
||||
for temporary in pending.values():
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
try:
|
||||
for target in sorted(pending, key=lambda item: item.as_posix()):
|
||||
os.replace(pending[target], target)
|
||||
committed.append(target)
|
||||
except Exception as exc:
|
||||
rollback_errors: list[str] = []
|
||||
for target in reversed(committed):
|
||||
original = originals[target]
|
||||
try:
|
||||
if original is None:
|
||||
target.unlink(missing_ok=True)
|
||||
else:
|
||||
kind, payload, mode = original
|
||||
restore = (
|
||||
_temporary_symlink(target, str(payload))
|
||||
if kind == "symlink"
|
||||
else _temporary_bytes(target, bytes(payload), mode)
|
||||
)
|
||||
os.replace(restore, target)
|
||||
except Exception as rollback_exc: # pragma: no cover - catastrophic filesystem failure
|
||||
rollback_errors.append(f"{target}: {rollback_exc}")
|
||||
detail = f"; rollback failures: {rollback_errors}" if rollback_errors else "; rollback completed"
|
||||
raise TransactionError(f"multi-file commit failed: {exc}{detail}") from exc
|
||||
finally:
|
||||
for temporary in pending.values():
|
||||
temporary.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user