Files
Unraid-AppOps-Gateway-Public/backend/tests/test_controller_state_reconciliation.py
T

251 lines
10 KiB
Python

from __future__ import annotations
import importlib.util
import os
import stat
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER_PATH = ROOT / "scripts" / "reconcile_controller_state.py"
UPGRADE_PATH = ROOT / "scripts" / "upgrade_deployment_controller.sh"
WRAPPER_PATH = ROOT / "scripts" / "upgrade_deployment_controller.ps1"
spec = importlib.util.spec_from_file_location("reconcile_controller_state", HELPER_PATH)
assert spec is not None and spec.loader is not None
reconciliation = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reconciliation)
def _roots(tmp_path: Path) -> tuple[Path, Path]:
legacy = tmp_path / "legacy-state"
cache = tmp_path / "cache-state"
legacy.mkdir()
cache.mkdir()
return legacy, cache
def test_reconciliation_migrates_only_missing_legacy_state_with_metadata(tmp_path: Path):
legacy, cache = _roots(tmp_path)
(legacy / "nested").mkdir(mode=0o750)
source = legacy / "nested" / "receipt.json"
source.write_bytes(b'{"revision":"abc"}\n')
source.chmod(0o640)
identical = legacy / "existing.json"
identical.write_bytes(b'{"status":"healthy"}\n')
(cache / "existing.json").write_bytes(identical.read_bytes())
result = reconciliation.reconcile_state_roots(legacy, cache)
migrated = cache / "nested" / "receipt.json"
assert migrated.read_bytes() == source.read_bytes()
assert stat.S_IMODE(migrated.stat().st_mode) == stat.S_IMODE(source.stat().st_mode)
assert result == {"legacyEntries": 3, "cacheEntries": 1, "migratedDirectories": 1, "migratedFiles": 1}
def test_reconciliation_detects_every_conflict_before_copying_any_missing_file(tmp_path: Path):
legacy, cache = _roots(tmp_path)
(legacy / "a-missing.json").write_text("legacy-only", encoding="utf-8")
(legacy / "z-conflict.json").write_text("legacy", encoding="utf-8")
(cache / "z-conflict.json").write_text("cache", encoding="utf-8")
with pytest.raises(reconciliation.StateConflictError, match="z-conflict.json"):
reconciliation.reconcile_state_roots(legacy, cache)
assert not (cache / "a-missing.json").exists()
assert (cache / "z-conflict.json").read_text(encoding="utf-8") == "cache"
def test_reconciliation_rejects_symlinks_without_mutating_cache(tmp_path: Path):
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
legacy, cache = _roots(tmp_path)
target = tmp_path / "outside.json"
target.write_text("outside", encoding="utf-8")
try:
(legacy / "unsafe.json").symlink_to(target)
except OSError:
pytest.skip("the test process cannot create symlinks")
with pytest.raises(reconciliation.UnsafeStateError, match="unsafe.json"):
reconciliation.reconcile_state_roots(legacy, cache)
assert list(cache.iterdir()) == []
def test_reconciliation_allows_cache_only_state_and_keeps_it_unchanged(tmp_path: Path):
legacy, cache = _roots(tmp_path)
(legacy / "shared.json").write_text("shared", encoding="utf-8")
(cache / "shared.json").write_text("shared", encoding="utf-8")
cache_only = cache / "new-controller.json"
cache_only.write_text("cache-only", encoding="utf-8")
result = reconciliation.reconcile_state_roots(legacy, cache)
assert cache_only.read_text(encoding="utf-8") == "cache-only"
assert result["migratedDirectories"] == 0
assert result["migratedFiles"] == 0
def test_reconciliation_rejects_metadata_conflict_before_copying_missing_state(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
legacy, cache = _roots(tmp_path)
(legacy / "missing.json").write_text("legacy-only", encoding="utf-8")
(legacy / "shared.json").write_text("shared", encoding="utf-8")
(cache / "shared.json").write_text("shared", encoding="utf-8")
real_inventory = reconciliation._inventory
def inventory_with_conflicting_cache_metadata(root: Path):
entries = real_inventory(root)
if root == cache:
entries["shared.json"] = {
**entries["shared.json"],
"mode": int(entries["shared.json"]["mode"]) ^ 0o020,
"uid": int(entries["shared.json"]["uid"]) + 1,
"gid": int(entries["shared.json"]["gid"]) + 1,
}
return entries
monkeypatch.setattr(reconciliation, "_inventory", inventory_with_conflicting_cache_metadata)
with pytest.raises(reconciliation.StateConflictError, match="shared.json"):
reconciliation.reconcile_state_roots(legacy, cache)
assert not (cache / "missing.json").exists()
def test_reconciliation_rejects_legacy_inode_replacement_and_rolls_back(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
legacy, cache = _roots(tmp_path)
source = legacy / "receipt.json"
source.write_bytes(b'{"revision":"abc"}\n')
real_create_file = reconciliation._create_file
def replace_source_before_copy(path: Path, payload: bytes, metadata: dict[str, int | str]):
replacement = source.with_suffix(".replacement")
replacement.write_bytes(source.read_bytes())
os.replace(replacement, source)
real_create_file(path, payload, metadata)
monkeypatch.setattr(reconciliation, "_create_file", replace_source_before_copy)
with pytest.raises(reconciliation.UnsafeStateError, match="changed during migration"):
reconciliation.reconcile_state_roots(legacy, cache)
assert list(cache.iterdir()) == []
def test_reconciliation_rolls_back_every_created_entry_after_partial_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
legacy, cache = _roots(tmp_path)
(legacy / "nested").mkdir()
(legacy / "nested" / "a.json").write_text("first", encoding="utf-8")
(legacy / "nested" / "b.json").write_text("second", encoding="utf-8")
real_create_file = reconciliation._create_file
created_files = 0
def fail_after_first_file(path: Path, payload: bytes, metadata: dict[str, int | str]):
nonlocal created_files
if created_files == 1:
raise OSError("injected second-file failure")
real_create_file(path, payload, metadata)
created_files += 1
monkeypatch.setattr(reconciliation, "_create_file", fail_after_first_file)
with pytest.raises(OSError, match="injected second-file failure"):
reconciliation.reconcile_state_roots(legacy, cache)
assert list(cache.iterdir()) == []
def test_successful_reconciliation_can_be_rolled_back_before_controller_swap(tmp_path: Path):
legacy, cache = _roots(tmp_path)
preexisting = cache / "cache-only.json"
preexisting.write_text("keep", encoding="utf-8")
(legacy / "nested").mkdir()
(legacy / "nested" / "receipt.json").write_text("migrate", encoding="utf-8")
journal = tmp_path / "migration-journal.json"
reconciliation.reconcile_state_roots(legacy, cache, journal_path=journal)
assert (cache / "nested" / "receipt.json").read_text(encoding="utf-8") == "migrate"
reconciliation.rollback_reconciliation(cache, journal)
assert preexisting.read_text(encoding="utf-8") == "keep"
assert not (cache / "nested").exists()
assert not journal.exists()
def test_upgrade_prepares_before_stop_and_reconciles_between_stop_and_activation():
script = UPGRADE_PATH.read_text(encoding="utf-8")
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
reconcile = 'python3 "${work}/reconcile_controller_state.py" \\\n --legacy-root'
activate = 'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"'
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script
assert script.index(prepare) < script.index(stop)
assert script.index(stop) < script.index(reconcile)
assert script.index(reconcile) < script.index(activate)
assert 'flock -x "$promotion_lock_fd"' in script
assert 'mv -- "$CONTROLLER_DIR"' not in script
assert 'cp -a -- "$CONTROLLER_DIR"' not in script
def test_upgrade_prepares_and_validates_captured_optional_private_tls_before_stopping_controller():
script = UPGRADE_PATH.read_text(encoding="utf-8")
prepare = 'backup="$(python3 "${work}/${PREPARER_NAME}" prepare \\'
stop = 'python3 "${work}/${PREPARER_NAME}" stop --backup "$backup"'
preparer = (ROOT / "scripts/prepare_controller_hub_release_volume.py").read_text(encoding="utf-8")
assert "_validate_previous_private_tls(stage_root)" in preparer
assert script.index(prepare) < script.index(stop)
def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer():
script = UPGRADE_PATH.read_text(encoding="utf-8")
restore = script[script.index("restore_previous() {") : script.index("cleanup() {")]
stop = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup" --stop-only; then'
rollback = '--rollback'
restore_controller = 'if ! python3 "${work}/${PREPARER_NAME}" rollback --backup "$backup"; then'
assert restore.index(stop) < restore.index(rollback)
assert restore.index(rollback) < restore.index(restore_controller)
assert "controller remains stopped" in script
assert 'mv -- "$previous"' not in restore
assert 'docker compose --project-directory "$ROOT"' not in restore
reconcile = 'python3 "${work}/reconcile_controller_state.py"'
stopped = script.index("writers_stopped=1", script.index(stop))
reconcile_index = script.index(reconcile, stopped)
activate_index = script.index(
'python3 "${work}/${PREPARER_NAME}" activate --backup "$backup"',
reconcile_index,
)
assert stopped < reconcile_index < activate_index
assert '[[ -f "$migration_journal" && ! -L "$migration_journal" ]]' in restore
assert '[[ -e "$migration_journal" || -L "$migration_journal" ]]' in restore
def test_wrapper_binds_reconciliation_helper_to_the_immutable_revision():
wrapper = WRAPPER_PATH.read_text(encoding="utf-8")
assert "scripts/reconcile_controller_state.py" in wrapper
assert "git archive" in wrapper
def test_wrapper_archives_the_fixed_operator_handoff_required_by_root_upgrade():
wrapper = WRAPPER_PATH.read_text(encoding="utf-8")
upgrade = UPGRADE_PATH.read_text(encoding="utf-8")
archive_line = next(line for line in wrapper.splitlines() if "git archive" in line)
assert "scripts/run_repopulse_adoption.py" in archive_line
assert '"scripts/run_repopulse_adoption.py"' in upgrade