Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
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