Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,255 @@
|
||||
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_stops_old_writer_before_locking_cache_and_reconciles_while_stopped():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
cache_lock = 'flock -x "$cache_lock_fd"'
|
||||
stop = 'writers_stopped=1\ndocker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
|
||||
backup = 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"'
|
||||
swap = 'mv -- "$CONTROLLER_DIR" "$previous"'
|
||||
|
||||
assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script
|
||||
assert script.index(stop) < script.index(cache_lock)
|
||||
assert script.index(stop) < script.index(backup)
|
||||
assert script.index(cache_lock) < script.index(backup)
|
||||
assert script.index(backup) < script.index(reconcile)
|
||||
assert script.index(reconcile) < script.index(swap)
|
||||
assert 'flock -u "$cache_lock_fd"' not in script
|
||||
|
||||
|
||||
def test_upgrade_retains_and_validates_optional_private_tls_before_stopping_controller():
|
||||
script = UPGRADE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
stage_copy = 'cp -a -- "$CONTROLLER_DIR" "${stage}/controller"'
|
||||
tls_gate = (
|
||||
'if [[ -e "${stage}/controller/private-tls.json" '
|
||||
'|| -L "${stage}/controller/private-tls.json" ]]; then\n'
|
||||
' python3 "${stage}/controller/status_server.py" --check-private-tls-config\n'
|
||||
'fi'
|
||||
)
|
||||
stop = (
|
||||
'writers_stopped=1\ndocker compose --project-directory "$ROOT" '
|
||||
'-f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
)
|
||||
|
||||
assert stage_copy in script
|
||||
assert script.index(stage_copy) < script.index(tls_gate)
|
||||
assert script.index(tls_gate) < script.index(stop)
|
||||
assert 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' in script
|
||||
|
||||
|
||||
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 = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"'
|
||||
rollback = '--rollback'
|
||||
restore_controller = 'mv -- "$previous" "$CONTROLLER_DIR"'
|
||||
start = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" up -d --force-recreate "$COMPOSE_SERVICE"'
|
||||
|
||||
assert restore.index(stop) < restore.index(rollback)
|
||||
assert restore.index(rollback) < restore.index(restore_controller)
|
||||
assert restore.index(restore_controller) < restore.index(start)
|
||||
assert "the old writer will remain stopped" in script
|
||||
reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root'
|
||||
assert script.index("reconciliation_active=1", script.index("writers_stopped=1")) < script.index(reconcile)
|
||||
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
|
||||
Reference in new issue
Block a user