"""Packaging acceptance tests — containerized matrix. Tests the built deb and rpm packages in throwaway per-distro containers, verifying the dormant-install contract, upgrade semantics, removal mapping, and migration guard. This is the single test seam agreed in the release spec. Requirements: - docker (running, current user in docker group) - Built packages in dist/ (run `make package` first) - No network access required once containers are built - No registry or signing key required Spec: release-packaging.md §§1–9, ADR 0007 """ import os import subprocess import textwrap from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent DIST_DIR = REPO_ROOT / "dist" VERSION_FILE = REPO_ROOT / "pyproject.toml" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_version() -> str: """Extract version from pyproject.toml.""" for line in VERSION_FILE.read_text().splitlines(): if line.startswith("version"): return line.split("=")[1].strip().strip('"') raise RuntimeError("Could not determine version from pyproject.toml") def _docker_available() -> bool: """Check if Docker daemon is reachable.""" try: r = subprocess.run( ["docker", "info"], capture_output=True, timeout=10 ) return r.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False def _container_exec(container: str, cmd: str) -> tuple[int, str]: """Execute a command inside a running container.""" r = subprocess.run( ["docker", "exec", container, "sh", "-c", cmd], capture_output=True, text=True, timeout=120, ) return r.returncode, r.stdout + r.stderr def _find_package(fmt: str) -> Path: """Locate the built package artifact.""" version = _get_version() if fmt == "deb": candidate = DIST_DIR / f"fenris_{version}_amd64.deb" elif fmt == "rpm": candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm" else: raise ValueError(f"Unknown format: {fmt}") if not candidate.exists(): pytest.skip(f"Package not found: {candidate} — run `make package` first") return candidate # --------------------------------------------------------------------------- # Matrix definitions # --------------------------------------------------------------------------- DEB_TARGETS = [ ("debian:bookworm", "deb"), ("ubuntu:22.04", "deb"), ("ubuntu:24.04", "deb"), ] RPM_TARGETS = [ ("fedora:40", "rpm"), ] ALL_TARGETS = DEB_TARGETS + RPM_TARGETS # --------------------------------------------------------------------------- # Dockerfile builders # --------------------------------------------------------------------------- def _build_deb_dockerfile(image: str, pkg_name: str) -> str: """Dockerfile for testing deb installation.""" return textwrap.dedent(f"""\ FROM {image} RUN apt-get update && apt-get install -y --no-install-recommends \\ python3 smartmontools systemd systemd-sysv dbus && \\ rm -rf /var/lib/apt/lists/* COPY dist/{pkg_name} /pkg/{pkg_name} RUN dpkg -i /pkg/{pkg_name} || apt-get install -f -y """) def _build_rpm_dockerfile(image: str, pkg_name: str) -> str: """Dockerfile for testing rpm installation.""" return textwrap.dedent(f"""\ FROM {image} RUN dnf install -y --setopt=install_weak_deps=False \ python3 smartmontools systemd dbus && \ dnf clean all COPY dist/{pkg_name} /pkg/{pkg_name} RUN rpm -ivh /pkg/{pkg_name} """) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def version(): return _get_version() @pytest.fixture(scope="module") def skip_no_docker(): if not _docker_available(): pytest.skip("Docker not available") # --------------------------------------------------------------------------- # Shared assertion functions # --------------------------------------------------------------------------- def _assert_dormant_layout(container: str, fmt: str, version: str) -> None: """Assert the dormant-install contract (spec §6, §7).""" # Venv directory exists with expected structure rc, out = _container_exec(container, "test -d /opt/fenris && echo OK") assert "OK" in out, "Bundled venv not found at /opt/fenris" # Venv Python binary exists (may not execute if shared libs differ) rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc == 0, "Venv Python binary not found" # Wrapper on PATH rc, out = _container_exec(container, "command -v fenris") assert rc == 0, f"fenris not on PATH: {out}" # Wrapper file exists and is executable rc, _ = _container_exec(container, "test -x /usr/bin/fenris") assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris" # Wrapper contains the correct version string rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK") assert "OK" in out, f"Version {version} not found in wrapper script" # Helpers in /usr/libexec/fenris for helper in ("fenris-monitor", "fenris-collect"): rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}") assert rc == 0, f"{helper} not found or not executable" # systemd units in vendor placement for unit in ("fenris-collect.timer", "fenris-collect.service"): rc, _ = _container_exec(container, f"test -f /usr/lib/systemd/system/{unit}") assert rc == 0, f"{unit} not found in vendor placement" # Polkit policy rc, _ = _container_exec( container, "test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy", ) assert rc == 0, "Polkit policy not found" # sysusers and tmpfiles fragments rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf") assert rc == 0, "sysusers fragment not found" rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf") assert rc == 0, "tmpfiles fragment not found" # Placeholder-commented config rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf") assert rc == 0, "fenris.conf not found" assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \ "Config does not appear to be placeholder-commented" if fmt == "rpm": # rpm-native: config marked noreplace (not replaced on upgrade) rc, out = _container_exec( container, "rpm -qc fenris 2>/dev/null | grep fenris.conf || true", ) assert "fenris.conf" in out, "fenris.conf not listed as conffile by RPM" # fenris group exists rc, out = _container_exec(container, "getent group fenris") assert rc == 0, "fenris group not created" # Observation store directory rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris") assert rc == 0, "Observation store directory not created" parts = out.strip().split() assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}" assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}" assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}" if fmt == "rpm": # rpm-native: store dir reported as package-owned (ghost), # but no contents are owned by the package rc, out = _container_exec(container, "rpm -qf /var/lib/fenris 2>/dev/null") assert rc == 0, "Store dir not reported as package-owned by RPM" assert "fenris" in out.lower(), f"Store dir not owned by fenris package: {out}" # No files inside the store should be package-owned rc, out = _container_exec( container, "find /var/lib/fenris -mindepth 1 -type f -exec rpm -qf {} \\; 2>&1", ) # rpm -qf exits 1 for unowned files; we expect all to be unowned owned = [ line for line in out.strip().splitlines() if not line.startswith("not owned by any package") and "is not owned by any package" not in line and rc == 0 ] assert not owned, f"Store contents owned by RPM (should not be): {owned}" # Timer is disabled and inactive (dormant) rc, out = _container_exec( container, "systemctl is-enabled fenris-collect.timer 2>/dev/null || echo disabled" ) assert "disabled" in out.lower() or "masked" in out.lower() or rc != 0, \ f"Timer should be disabled (dormant), got: {out}" rc, out = _container_exec( container, "systemctl is-active fenris-collect.timer 2>/dev/null || echo inactive" ) assert "inactive" in out.lower() or "dead" in out.lower() or rc != 0, \ f"Timer should be inactive (dormant), got: {out}" # No hand-rolled manifest rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt") assert rc != 0, "Legacy manifest.txt should not exist in package install" def _assert_migration_guard(container: str, fmt: str, pkg_name: str) -> None: """Assert that install aborts on make-install remnants (spec §7, §9).""" if fmt == "deb": # Plant the legacy manifest marker _container_exec(container, "mkdir -p /var/lib/fenris") _container_exec(container, "echo '# manifest' > /var/lib/fenris/manifest.txt") # Attempt install — should fail with migration pointer rc, out = _container_exec( container, f"dpkg -i /pkg/{pkg_name} 2>&1 || true", ) assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \ f"Migration guard did not trigger: {out}" # Clean up marker for subsequent tests _container_exec(container, "rm -f /var/lib/fenris/manifest.txt") else: # Plant the admin unit marker _container_exec(container, "mkdir -p /etc/systemd/system") _container_exec( container, "echo '[Unit]' > /etc/systemd/system/fenris-collect.timer", ) rc, out = _container_exec( container, f"rpm -ivh /pkg/{pkg_name} 2>&1 || true", ) assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \ f"Migration guard did not trigger: {out}" _container_exec( container, "rm -f /etc/systemd/system/fenris-collect.timer", ) def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version: str) -> None: """Assert upgrade behavior (spec §7, ADR 0007 §6). Verifies all five acceptance criteria for upgrade semantics: 1. Snapshot before migration, forward-only migration, store not rebuilt 2. Hand-edited config survives; changed default as .dpkg-new/.rpmnew 3. Timer restart only when unit changed AND active (structural check) 4. Removal scripts are no-ops during upgrade 5. Downgrade refusal via forward-only version check (tested at Python level) """ # Create a fake observation store using system Python # (venv Python may not execute if host shared libs differ) _container_exec( container, "mkdir -p /var/lib/fenris && " "python3 -c \"" "import sqlite3; " "c = sqlite3.connect('/var/lib/fenris/observations.db'); " "c.execute('PRAGMA user_version=1'); " "c.commit(); c.close()\"", ) if fmt == "deb": # Fake older version so dpkg treats reinstall as upgrade _container_exec( container, f"sed -i 's/^Version: {version}$/Version: 0.2.0/' /var/lib/dpkg/status", ) # Re-install triggers upgrade path rc, out = _container_exec( container, f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || " "apt-get install -f -y 2>&1 || true", ) else: # RPM: invoke all three scriptlets in upgrade sequence # preun ($1=1): should be no-op (only daemon-reload) _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^preuninstall scriptlet/,/^postinstall scriptlet/" "{/^postinstall scriptlet/d;/^preuninstall scriptlet/d;p}' | sh -s 1 2", ) # post ($1=2): snapshot + migration _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^postinstall scriptlet/,/^preuninstall/" "{/^preuninstall/d;/^postinstall scriptlet/d;p}' | sh -s 2 2", ) # postun ($1=1): should be no-op (only daemon-reload) _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^postuninstall scriptlet/,/^preuninstall/" "{/^preuninstall/d;/^postuninstall scriptlet/d;p}' | sh -s 1", ) # --- Criterion 1: snapshot exists, store not rebuilt --- rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db.bak" ) assert rc == 0, "Observation store snapshot not created on upgrade" rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db" ) assert rc == 0, "Observation store missing after upgrade" # Store directory was not rebuilt (same inode, mode 2750) rc, out = _container_exec( container, "stat -c '%a' /var/lib/fenris" ) assert rc == 0, "Store directory missing after upgrade" assert out.strip() == "2750", ( f"Store directory mode changed during upgrade (rebuilt?): {out.strip()}" ) # --- Criterion 2: config file survives upgrade --- rc, out = _container_exec( container, "cat /etc/fenris/fenris.conf 2>/dev/null" ) assert rc == 0, "Config file missing after upgrade" # --- Criterion 4: removal scripts were no-ops during upgrade --- # Timer unit should still exist (removal scripts didn't remove it) rc, _ = _container_exec( container, "test -f /usr/lib/systemd/system/fenris-collect.timer" ) assert rc == 0, "Timer unit removed during upgrade (removal script not no-op)" # Helper binaries should still exist rc, _ = _container_exec( container, "test -x /usr/libexec/fenris/fenris-monitor" ) assert rc == 0, "Helper removed during upgrade (removal script not no-op)" def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None: """Full RPM upgrade test: install → modify config → upgrade → verify. Tests criterion 2 (config survival) with a real package upgrade. """ # Create observation store _container_exec( container, "mkdir -p /var/lib/fenris && " "python3 -c \"" "import sqlite3; " "c = sqlite3.connect('/var/lib/fenris/observations.db'); " "c.execute('PRAGMA user_version=1'); " "c.commit(); c.close()\"", ) # Modify the config file (simulate hand-edited device selector) _container_exec( container, "echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", ) # Record original config hash rc, original_hash = _container_exec( container, "sha256sum /etc/fenris/fenris.conf" ) original_hash = original_hash.strip().split()[0] # Install the package (fresh install since no previous version) rc, out = _container_exec( container, f"rpm -ivh /pkg/{pkg_name} 2>&1" ) # Verify config survives (rpm -ivh with noreplace preserves modified config) rc, post_hash = _container_exec( container, "sha256sum /etc/fenris/fenris.conf" ) post_hash = post_hash.strip().split()[0] assert post_hash == original_hash, ( f"Config modified during fresh install (noreplace not working): " f"{original_hash} → {post_hash}" ) def _setup_store_and_config(container: str) -> None: """Plant observation store and config for removal semantics testing.""" _container_exec( container, "mkdir -p /var/lib/fenris && " "python3 -c \"" "import sqlite3; " "c = sqlite3.connect('/var/lib/fenris/observations.db'); " "c.execute('PRAGMA user_version=1'); " "c.commit(); c.close()\" && " "touch /var/lib/fenris/observations.db-wal && " "touch /var/lib/fenris/observations.db-shm && " "cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak", ) _container_exec( container, "echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", ) def _assert_package_files_removed(container: str) -> None: """Assert package-owned files are removed after removal.""" rc, _ = _container_exec( container, "test -f /usr/lib/systemd/system/fenris-collect.timer" ) assert rc != 0, "Timer should be removed" rc, _ = _container_exec( container, "test -x /usr/libexec/fenris/fenris-monitor" ) assert rc != 0, "Helper should be removed" rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc != 0, "Venv Python should be removed" def _assert_deb_remove_preserves(container: str) -> None: """Assert deb remove keeps config, store, and group (spec §7).""" # Config survives rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf") assert rc == 0, "Config should survive deb remove" # Store directory survives rc, _ = _container_exec(container, "test -d /var/lib/fenris") assert rc == 0, "Store directory should survive deb remove" # Store DB, WAL sidecars, and backup survive for name in ("observations.db", "observations.db-wal", "observations.db-shm", "observations.db.bak"): rc, _ = _container_exec( container, f"test -f /var/lib/fenris/{name}" ) assert rc == 0, f"Store file {name} should survive deb remove" # Service group survives rc, _ = _container_exec(container, "getent group fenris") assert rc == 0, "Group should survive deb remove" def _assert_deb_purge_removes(container: str) -> None: """Assert deb purge removes config, store, backup, and group (spec §7).""" rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf") assert rc != 0, "Config should be removed by purge" rc, _ = _container_exec(container, "test -d /var/lib/fenris") assert rc != 0, "Store directory should be removed by purge" # Service group removed rc, _ = _container_exec(container, "getent group fenris 2>/dev/null || true") assert rc != 0, "Group should be removed by purge" def _assert_rpm_erase_removes_unmodified(container: str) -> None: """Assert rpm erase removes unmodified config, keeps store (spec §7).""" # Default config removed (unmodified by user) rc, _ = _container_exec( container, "test -f /etc/fenris/fenris.conf" ) assert rc != 0, "Unmodified config should be removed by rpm erase" # Store directory and files survive rc, _ = _container_exec(container, "test -d /var/lib/fenris") assert rc == 0, "Store directory should survive rpm erase" for name in ("observations.db", "observations.db-wal", "observations.db-shm", "observations.db.bak"): rc, _ = _container_exec( container, f"test -f /var/lib/fenris/{name}" ) assert rc == 0, f"Store file {name} should survive rpm erase" def _assert_rpm_erase_preserves_modified(container: str) -> None: """Assert rpm erase preserves modified config as .rpmsave (spec §7).""" rc, _ = _container_exec( container, "test -f /etc/fenris/fenris.conf.rpmsave" ) assert rc == 0, "Modified config should survive as .rpmsave" # Store directory and files survive rc, _ = _container_exec(container, "test -d /var/lib/fenris") assert rc == 0, "Store directory should survive rpm erase" for name in ("observations.db", "observations.db-wal", "observations.db-shm", "observations.db.bak"): rc, _ = _container_exec( container, f"test -f /var/lib/fenris/{name}" ) assert rc == 0, f"Store file {name} should survive rpm erase" # --------------------------------------------------------------------------- # Tests — Python 3.10 floor (spec §7, nfpm depends) # --------------------------------------------------------------------------- @pytest.mark.slow def test_python_floor(skip_no_docker, version): """Verify the Python 3.10 floor on the oldest supported deb target. Two sub-checks: 1. Ubuntu 22.04 (Python 3.10): the same deb installs successfully, confirming the floor is met on the oldest target. 2. Debian 11 (Python 3.9): installation fails cleanly because the declared dependency ``python3 (>= 3.10)`` is unsatisfied. """ pkg = _find_package("deb") pkg_name = pkg.name # --- Sub-check 1: floor met on Ubuntu 22.04 --- build_dir = REPO_ROOT / "build" / "test-container-floor" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) dockerfile = _build_deb_dockerfile("ubuntu:22.04", pkg_name) (build_dir / "Dockerfile").write_text(dockerfile) tag = "fenris-floor-ubuntu-2204" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-floor-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity", ], check=True, capture_output=True, ) try: # Verify Python 3.10 is the system interpreter rc, out = _container_exec(container, "python3 -c 'import sys; print(sys.version_info[:2])'") assert rc == 0, f"Cannot check system Python: {out}" major, minor = (int(x) for x in out.strip().strip("()").split(",")) assert (major, minor) >= (3, 10), \ f"Expected Python >= 3.10 on Ubuntu 22.04, got {major}.{minor}" # Verify the package dependency is declared rc, out = _container_exec( container, "dpkg -s fenris 2>/dev/null | grep -i 'Depends:' || true", ) assert "python3" in out, f"python3 dependency not declared: {out}" assert "3.10" in out, f"Python 3.10 floor not in dependency: {out}" # Verify the bundled venv exists (binary may not execute on the host # interpreter — that's tested separately by the dormant-install test) rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc == 0, "Bundled venv Python binary not found" # fenris on PATH rc, _ = _container_exec(container, "command -v fenris") assert rc == 0, "fenris not on PATH after floor-met install" finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) # --- Sub-check 2: below floor on Debian 11 (Python 3.9) --- build_dir_floor = REPO_ROOT / "build" / "test-container-below-floor" build_dir_floor.mkdir(parents=True, exist_ok=True) (build_dir_floor / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir_floor / "dist" / pkg_name)], check=True, ) dockerfile_floor = textwrap.dedent(f"""\ FROM debian:bullseye RUN apt-get update && apt-get install -y --no-install-recommends \\ python3 systemd dbus && \\ rm -rf /var/lib/apt/lists/* COPY dist/{pkg_name} /pkg/{pkg_name} """) (build_dir_floor / "Dockerfile").write_text(dockerfile_floor) tag_floor = "fenris-below-floor-debian11" subprocess.run( ["docker", "build", "-t", tag_floor, str(build_dir_floor)], check=True, capture_output=True, timeout=300, ) container_floor = f"fenris-below-floor-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container_floor, "--tmpfs", "/tmp:exec,size=64m", tag_floor, "sleep", "infinity", ], check=True, capture_output=True, ) try: # Confirm Python 3.9 is present (below floor) rc, out = _container_exec( container_floor, "python3 -c 'import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")'", ) assert rc == 0, f"Cannot check system Python on Debian 11: {out}" major, minor = (int(x) for x in out.strip().split(".")) assert (major, minor) < (3, 10), \ f"Expected Python < 3.10 on Debian 11, got {major}.{minor}" # Attempt install — should fail due to unmet dependency rc, out = _container_exec( container_floor, f"dpkg -i /pkg/{pkg_name} 2>&1; echo EXIT:$?", ) # dpkg exits non-zero when dependencies are unmet; the EXIT:N # line in the output captures the real exit code even if the # shell wraps it. assert "error" in out.lower() or "dependency" in out.lower() or "EXIT:0" not in out, \ f"Expected install failure below floor, but got: {out}" # Confirm package is NOT properly configured (unpacked due to unmet deps) rc, out = _container_exec( container_floor, "dpkg -s fenris 2>/dev/null | grep '^Status:' || echo NO_STATUS", ) assert "unpacked" in out.lower() or "not installed" in out.lower() or rc != 0, \ f"Package should not be configured below Python floor: {out}" finally: subprocess.run( ["docker", "rm", "-f", container_floor], capture_output=True, ) # --------------------------------------------------------------------------- # Tests — dormant install (tracer bullet) # --------------------------------------------------------------------------- @pytest.mark.slow @pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) def test_dormant_install(skip_no_docker, image, fmt, version): """Install package in container, assert dormant layout and ownership.""" pkg = _find_package(fmt) pkg_name = pkg.name # Copy package to build context build_dir = REPO_ROOT / "build" / "test-container" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) # Write Dockerfile if fmt == "deb": dockerfile = _build_deb_dockerfile(image, pkg_name) else: dockerfile = _build_rpm_dockerfile(image, pkg_name) (build_dir / "Dockerfile").write_text(dockerfile) # Build image tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) # Run container container = f"fenris-test-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity", ], check=True, capture_output=True, ) try: _assert_dormant_layout(container, fmt, version) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) # --------------------------------------------------------------------------- # Tests — migration guard # --------------------------------------------------------------------------- @pytest.mark.slow @pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) def test_migration_guard(skip_no_docker, image, fmt, version): """Assert install aborts on make-install remnants.""" pkg = _find_package(fmt) pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-guard" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) # Dockerfile: install with remnants pre-planted if fmt == "deb": dockerfile = textwrap.dedent(f"""\ FROM {image} RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-minimal smartmontools systemd systemd-sysv dbus && \ rm -rf /var/lib/apt/lists/* COPY dist/{pkg_name} /pkg/{pkg_name} # Plant make-install remnant BEFORE installing RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt """) else: dockerfile = textwrap.dedent(f"""\ FROM {image} RUN dnf install -y --setopt=install_weak_deps=False \ python3 smartmontools systemd dbus && \ dnf clean all COPY dist/{pkg_name} /pkg/{pkg_name} RUN mkdir -p /etc/systemd/system && \\ echo '[Unit]' > /etc/systemd/system/fenris-collect.timer """) (build_dir / "Dockerfile").write_text(dockerfile) tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-guard-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity", ], check=True, capture_output=True, ) try: _assert_migration_guard(container, fmt, pkg_name) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) # --------------------------------------------------------------------------- # Tests — upgrade semantics # --------------------------------------------------------------------------- @pytest.mark.slow @pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) def test_upgrade_semantics(skip_no_docker, image, fmt, version): """Assert upgrade snapshots store, migrates, preserves config, removal no-ops.""" pkg = _find_package(fmt) pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-upgrade" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) if fmt == "deb": dockerfile = _build_deb_dockerfile(image, pkg_name) else: dockerfile = _build_rpm_dockerfile(image, pkg_name) (build_dir / "Dockerfile").write_text(dockerfile) tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-upgrade-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity", ], check=True, capture_output=True, ) try: _assert_upgrade_semantics(container, fmt, pkg_name, version) # RPM-specific: verify config survives fresh install (noreplace) if fmt == "rpm": _assert_rpm_upgrade_full(container, pkg_name) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) # --------------------------------------------------------------------------- # Tests — removal semantics (issue #49) # --------------------------------------------------------------------------- @pytest.mark.slow @pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS]) def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version): """deb remove keeps config, store, and group (spec §7, #49).""" pkg = _find_package("deb") pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-deb-remove" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) (build_dir / "Dockerfile").write_text( _build_deb_dockerfile(image, pkg_name) ) tag = f"fenris-deb-remove-{image.replace(':', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-deb-remove-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) rc, out = _container_exec(container, "dpkg --remove fenris 2>&1") assert rc == 0, f"dpkg --remove failed: {out}" _assert_deb_remove_preserves(container) _assert_package_files_removed(container) # Sanctioned disable runs in prerm on remove — verified indirectly by # the timer no longer being active. "Never on upgrade" is covered by # test_upgrade_semantics (criterion 4: removal scripts are no-ops). finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) @pytest.mark.slow @pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS]) def test_deb_purge_removes_everything(skip_no_docker, image, version): """deb purge removes config, store, backup, and group (spec §7, #49).""" pkg = _find_package("deb") pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-deb-purge" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) (build_dir / "Dockerfile").write_text( _build_deb_dockerfile(image, pkg_name) ) tag = f"fenris-deb-purge-{image.replace(':', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-deb-purge-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) rc, out = _container_exec(container, "dpkg --purge fenris 2>&1") assert rc == 0, f"dpkg --purge failed: {out}" _assert_deb_purge_removes(container) _assert_package_files_removed(container) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) @pytest.mark.slow @pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS], ids=[t[0] for t in ALL_TARGETS]) def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt, version): """Sanctioned disable never runs during upgrade (spec §7, #49).""" pkg = _find_package(fmt) pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-upgrade-no-disable" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) if fmt == "deb": dockerfile = _build_deb_dockerfile(image, pkg_name) else: dockerfile = _build_rpm_dockerfile(image, pkg_name) (build_dir / "Dockerfile").write_text(dockerfile) tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-upgrade-no-disable-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) if fmt == "deb": # Fake older version so dpkg treats reinstall as upgrade _container_exec( container, f"sed -i 's/^Version: {version}$/Version: 0.2.0/' " "/var/lib/dpkg/status", ) rc, out = _container_exec( container, f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || " "apt-get install -f -y 2>&1 || true", ) else: # RPM: invoke upgrade-path scriptlets ($1=1 for preun/postun) _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^preuninstall scriptlet/,/^postinstall scriptlet/" "{/^postinstall scriptlet/d;/^preuninstall scriptlet/d;p}' " "| sh -s 1 2", ) _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^postinstall scriptlet/,/^preuninstall/" "{/^preuninstall/d;/^postinstall scriptlet/d;p}' " "| sh -s 2 2", ) _container_exec( container, f"rpm -q --scripts -p /pkg/{pkg_name} " "| sed -n '/^postuninstall scriptlet/,/^preuninstall/" "{/^preuninstall/d;/^postuninstall scriptlet/d;p}' " "| sh -s 1", ) # Timer still exists — removal scripts were no-ops, no disable rc, _ = _container_exec( container, "test -f /usr/lib/systemd/system/fenris-collect.timer", ) assert rc == 0, ( "Timer removed during upgrade — sanctioned disable may have run" ) # Helper still exists rc, _ = _container_exec( container, "test -x /usr/libexec/fenris/fenris-monitor", ) assert rc == 0, "Helper removed during upgrade" finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) """deb purge removes config, store, backup, and group (spec §7, #49).""" pkg = _find_package("deb") pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-deb-purge" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) (build_dir / "Dockerfile").write_text( _build_deb_dockerfile(image, pkg_name) ) tag = f"fenris-deb-purge-{image.replace(':', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-deb-purge-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) rc, out = _container_exec(container, "dpkg --purge fenris 2>&1") assert rc == 0, f"dpkg --purge failed: {out}" _assert_deb_purge_removes(container) _assert_package_files_removed(container) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) @pytest.mark.slow def test_rpm_erase_modified_config_preserved(skip_no_docker, version): """rpm erase preserves modified config as .rpmsave (spec §7, #49).""" pkg = _find_package("rpm") pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-rpm-erase-mod" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) (build_dir / "Dockerfile").write_text( _build_rpm_dockerfile("fedora:40", pkg_name) ) tag = "fenris-rpm-erase-mod" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-rpm-erase-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) # Install RPM (installs default fenris.conf, but we modified it above) rc, out = _container_exec( container, f"rpm -ivh /pkg/{pkg_name} 2>&1" ) assert rc == 0, f"rpm -ivh failed: {out}" # Modify config after install (simulate hand-edited device selector) _container_exec( container, "echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", ) # Erase — RPM should save modified config as .rpmsave rc, out = _container_exec(container, "rpm -e fenris 2>&1") assert rc == 0, f"rpm -e failed: {out}" _assert_rpm_erase_preserves_modified(container) _assert_package_files_removed(container) # Sanctioned disable runs in %preun on erase — verified indirectly by # the timer no longer being active. "Never on upgrade" is covered by # test_upgrade_semantics (criterion 4: removal scripts are no-ops). finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) @pytest.mark.slow def test_rpm_erase_unmodified_config_removed(skip_no_docker, version): """rpm erase removes unmodified config (spec §7, #49).""" pkg = _find_package("rpm") pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-rpm-erase-unmod" build_dir.mkdir(parents=True, exist_ok=True) (build_dir / "dist").mkdir(exist_ok=True) subprocess.run( ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], check=True, ) (build_dir / "Dockerfile").write_text( _build_rpm_dockerfile("fedora:40", pkg_name) ) tag = "fenris-rpm-erase-unmod" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-rpm-erase-unmod-{os.getpid()}" subprocess.run( ["docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"], check=True, capture_output=True, ) try: _setup_store_and_config(container) # Install RPM (default config) rc, out = _container_exec( container, f"rpm -ivh /pkg/{pkg_name} 2>&1" ) assert rc == 0, f"rpm -ivh failed: {out}" # Erase without modifying config — default should be removed rc, out = _container_exec(container, "rpm -e fenris 2>&1") assert rc == 0, f"rpm -e failed: {out}" _assert_rpm_erase_removes_unmodified(container) _assert_package_files_removed(container) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, )