from __future__ import annotations import importlib.util import os import re import shlex 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 'lock_path="${ROOT}/promotion.lock"' in script assert 'lock_path="${VOLUME_ROOT}/promotion.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_start = script.index("restore_previous() {") cleanup_start = script.index("cleanup() {", restore_start) restore = script[restore_start:cleanup_start] 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) source_paths_match = re.search(r"(?ms)^source_paths=\((.*?)^\)", upgrade) assert "scripts/run_repopulse_adoption.py" in archive_line assert source_paths_match is not None assert "scripts/run_repopulse_adoption.py" in shlex.split(source_paths_match.group(1)) assert ( 'install -o root -g root -m 0700 "${script_dir}/run_repopulse_adoption.py" ' '"${work}/source/controller/run_repopulse_adoption.py"' ) in upgrade