"""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, distro: str | None = None) -> 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" # fenris group exists rc, out = _container_exec(container, "getent group fenris") assert rc == 0, "fenris group not created" # Observation store directory if fmt == "deb": 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]}" else: # rpm: directory owned by package (ghost) rc, _ = _container_exec(container, "test -d /var/lib/fenris") assert rc == 0, "Observation store directory not found" # 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).""" # 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: manually invoke the post scriptlet with upgrade arguments ($1=2) # because faking a different version in the binary RPM database is not # practical — we only need to verify the scriptlet's upgrade behaviour. _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", ) # Snapshot should exist rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db.bak" ) assert rc == 0, "Observation store snapshot not created on upgrade" # Original store still exists rc, _ = _container_exec( container, "test -f /var/lib/fenris/observations.db" ) assert rc == 0, "Observation store missing after upgrade" def _assert_removal_semantics(container: str, fmt: str) -> None: """Assert removal mapping (spec §7).""" if fmt == "deb": # remove (not purge) — config and store survive rc, out = _container_exec( container, "dpkg --purge fenris 2>&1 || true" ) # After purge: config gone, store gone (our postrm removes on purge) # But the store dir may survive since it's not package-owned else: # rpm erase rc, out = _container_exec( container, "rpm -e fenris 2>&1 || true" ) # Units removed rc, _ = _container_exec( container, "test -f /usr/lib/systemd/system/fenris-collect.timer" ) assert rc != 0, "Timer unit should be removed after uninstall" # Helpers removed rc, _ = _container_exec( container, "test -f /usr/libexec/fenris/fenris-monitor" ) assert rc != 0, "Helper should be removed after uninstall" # Venv key files removed (directories may remain if non-empty) rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc != 0, "Venv Python should be removed after uninstall" # --------------------------------------------------------------------------- # 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 and preserves config.""" 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) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, ) # --------------------------------------------------------------------------- # Tests — removal semantics # --------------------------------------------------------------------------- @pytest.mark.slow @pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) def test_removal_semantics(skip_no_docker, image, fmt, version): """Assert removal cleans package-owned files.""" pkg = _find_package(fmt) pkg_name = pkg.name build_dir = REPO_ROOT / "build" / "test-container-removal" 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-removal-{image.replace(':', '-').replace('/', '-')}" subprocess.run( ["docker", "build", "-t", tag, str(build_dir)], check=True, capture_output=True, timeout=300, ) container = f"fenris-removal-{os.getpid()}" subprocess.run( [ "docker", "run", "-d", "--name", container, "--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity", ], check=True, capture_output=True, ) try: _assert_removal_semantics(container, fmt) finally: subprocess.run( ["docker", "rm", "-f", container], capture_output=True, )