271 lines
11 KiB
Python
271 lines
11 KiB
Python
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 -n -x "$promotion_lock_fd"' in script
|
|
assert script.index('flock -n -x "$promotion_lock_fd"') < script.index(prepare)
|
|
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")
|
|
archive_line = next(
|
|
line for line in wrapper.splitlines()
|
|
if re.match(r"^\s*& git(?:\s+-c\s+\S+)*\s+archive\b", line)
|
|
)
|
|
|
|
assert "scripts/reconcile_controller_state.py" in archive_line
|
|
assert "$SourceRevision" in archive_line
|
|
|
|
|
|
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 re.match(r"^\s*& git(?:\s+-c\s+\S+)*\s+archive\b", 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
|