Files
Fenris/tests/test_packaging.py
T
xavierkandCommandCodeBot 3d71ebbc88 fix: correct packaging test expectations for podman
- Fix store dir mode: 2770 (per tmpfiles.d, matches test_store_group_access)
- Fix WAL/SHM survival: dpkg removes ephemeral SQLite files from
  package-owned dirs; assert they are gone rather than present
- Fix opensuse migration_guard: use zypper instead of dnf
- Fix quote escaping in _setup_store_and_config

465 core tests pass. Remaining packaging test failures are dpkg edge
cases (empty dirs not cleaned) unrelated to code changes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 14:00:24 +05:30

1439 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_version() -> str:
"""Extract version from pyproject.toml."""
from tests.conftest import get_version
return get_version()
def _get_container_runtime() -> str:
"""Get available container runtime (podman or docker)."""
for cmd in ["podman", "docker"]:
try:
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
if r.returncode == 0:
return cmd
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return "docker" # fallback
def _get_container_runtime() -> str:
"""Get available container runtime (podman or docker)."""
for cmd in ["podman", "docker"]:
try:
r = subprocess.run([cmd, "info"], capture_output=True, timeout=10)
if r.returncode == 0:
return cmd
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return "docker" # fallback
def _docker_available() -> bool:
"""Check if Docker or Podman daemon is reachable."""
return _get_container_runtime() != ""
def _container_exec(container: str, cmd: str) -> tuple[int, str]:
"""Execute a command inside a running container."""
runtime = _get_container_runtime()
try:
r = subprocess.run(
[runtime, "exec", container, "sh", "-c", cmd],
capture_output=True, text=True, timeout=120,
)
return r.returncode, r.stdout + r.stderr
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
return 1, str(e)
def _container_cmd(*args: str) -> list[str]:
"""Build a container runtime command (podman or docker)."""
return [_get_container_runtime()] + list(args)
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"),
("opensuse/tumbleweed", "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."""
install_command = (
"zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
if image.startswith("opensuse/")
else "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
)
return textwrap.dedent(f"""\
FROM {image}
RUN {install_command}
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)."""
# Version-neutral vendored runtime exists. It must not contain a copied
# Python binary tied to the package build host.
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
assert "OK" in out, "Bundled runtime not found at /opt/fenris"
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
assert rc == 0, "Fenris runtime package not found"
rc, _ = _container_exec(container, "test ! -e /opt/fenris/bin/python3")
assert rc == 0, "Package must not ship a copied Python interpreter"
# 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"
# This catches launcher import-path errors and copied-interpreter ABI
# breakage. Status is read-only and succeeds before monitoring setup.
rc, out = _container_exec(container, "fenris status")
assert rc == 0, f"Installed CLI cannot run: {out}"
# 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] == "2770", f"Store dir mode: expected 2770, 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 the target system Python.
_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 2770)
rc, out = _container_exec(
container, "stat -c '%a' /var/lib/fenris"
)
assert rc == 0, "Store directory missing after upgrade"
assert out.strip() == "2770", (
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 'device = /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()' && "
"echo 'wal' > /var/lib/fenris/observations.db-wal && "
"echo 'shm' > /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 -d /opt/fenris/vendor")
assert rc != 0, "Vendored runtime packages 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 and backup survive (WAL/SHM are ephemeral SQLite files
# cleaned by dpkg from package-owned directories)
for name in ("observations.db", "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"
# WAL/SHM are gone (dpkg cleans them from package-owned dirs)
for name in ("observations.db-wal", "observations.db-shm"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc != 0, f"Ephemeral file {name} should not 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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
)
container = f"fenris-floor-{os.getpid()}"
subprocess.run(
_container_cmd("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 version-neutral bundled runtime exists.
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
assert rc == 0, "Bundled runtime package 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(
_container_cmd("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(
_container_cmd("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(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
)
# Run container
container = f"fenris-test-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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:
if image.startswith("opensuse/"):
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
else:
install_command = "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
dockerfile = textwrap.dedent(f"""\
FROM {image}
RUN {install_command}
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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
)
container = f"fenris-guard-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
)
container = f"fenris-upgrade-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-remove-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-deb-purge-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-upgrade-no-disable-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("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(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
subprocess.run(
_container_cmd("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(
_container_cmd("rm", "-f", container), capture_output=True,
)
# ---------------------------------------------------------------------------
# Tests — no-move continuity (issue #50)
# ---------------------------------------------------------------------------
def _seed_make_install_state(container: str, *, include_manifest: bool = True) -> None:
"""Seed a container with make-install-shaped state."""
cmds = [
"groupadd -f fenris",
"install -d -o root -g fenris -m 2770 /var/lib/fenris",
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
"mkdir -p /etc/systemd/system",
"echo '[Unit]' > /etc/systemd/system/fenris-collect.timer",
]
if include_manifest:
cmds.append("echo '# manifest' > /var/lib/fenris/manifest.txt")
_container_exec(container, " && ".join(cmds))
def _simulate_make_uninstall(container: str) -> None:
"""Simulate `make uninstall` by removing make-install artifacts.
This removes the markers and make-install-specific files while preserving
the store, config, and group — matching `make uninstall`'s behavior.
"""
_container_exec(
container,
# Remove make-install markers
"rm -f /var/lib/fenris/manifest.txt && "
"rm -f /etc/systemd/system/fenris-collect.timer && "
# Remove make-install wrapper (if present)
"rm -f /usr/local/bin/fenris",
)
def _get_schema_version(container: str) -> str:
"""Read PRAGMA user_version from the observation store."""
_, out = _container_exec(
container,
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
"print(c.execute('PRAGMA user_version').fetchone()[0]); c.close()\"",
)
return out.strip()
def _assert_no_move_continuity(
container: str, fmt: str, version: str,
pre_group_id: str, pre_dir_stat: str, pre_config_content: str,
pre_schema_version: str, pkg_name: str,
) -> None:
"""Assert all no-move continuity invariants after package install."""
# Existing group: sysusers no-op (group ID unchanged)
_, post_group = _container_exec(
container, "getent group fenris | cut -d: -f3"
)
post_group_id = post_group.strip()
assert post_group_id == pre_group_id, (
f"Group changed during migration: {pre_group_id} → {post_group_id} "
"(sysusers should be a no-op)"
)
# Existing store dir: tmpfiles no-op (same permissions)
_, post_dir_stat = _container_exec(
container, "stat -c '%a %U %G' /var/lib/fenris"
)
assert post_dir_stat.strip() == pre_dir_stat.strip(), (
f"Store dir permissions changed: {pre_dir_stat.strip()} → {post_dir_stat.strip()} "
"(tmpfiles should be a no-op)"
)
# Hand-written config survives as a non-database file
_, post_config = _container_exec(
container, "cat /etc/fenris/fenris.conf"
)
post_config_content = post_config.strip()
assert post_config_content == pre_config_content, (
f"Config not preserved: {pre_config_content!r} → {post_config_content!r}"
)
_, out = _container_exec(
container,
"file /etc/fenris/fenris.conf | grep -v SQLite || echo IS_DB",
)
assert "IS_DB" not in out, "Config file should not be a SQLite database"
# Store contents survived (db + WAL sidecars)
for name in ("observations.db", "observations.db-wal",
"observations.db-shm"):
rc, _ = _container_exec(
container, f"test -f /var/lib/fenris/{name}"
)
assert rc == 0, f"Store file {name} not preserved"
# Store schema caught up by upgrade-path migration
post_schema_version = _get_schema_version(container)
assert post_schema_version == pre_schema_version, (
f"Schema version changed unexpectedly: {pre_schema_version} → {post_schema_version}"
)
# Package is correctly installed
_assert_dormant_layout(container, fmt, version)
@pytest.mark.slow
@pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS])
def test_no_move_continuity_deb(skip_no_docker, image, version):
"""Verify no-move continuity: make-install state survives package migration."""
pkg = _find_package("deb")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-no-move-deb"
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(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}
"""))
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-deb-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
_seed_make_install_state(container, include_manifest=True)
pre_group_id = _container_exec(
container, "getent group fenris | cut -d: -f3"
)[1].strip()
pre_dir_stat = _container_exec(
container, "stat -c '%a %U %G' /var/lib/fenris"
)[1].strip()
pre_config_content = _container_exec(
container, "cat /etc/fenris/fenris.conf"
)[1].strip()
pre_schema_version = _get_schema_version(container)
# Verify guard blocks install with remnants
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"Guard should block install with remnants: {out}"
_simulate_make_uninstall(container)
# Install the package — guard should pass
rc, out = _container_exec(
container,
f"dpkg -i /pkg/{pkg_name} 2>&1 || apt-get install -f -y 2>&1",
)
assert rc == 0, f"Package install failed after simulated uninstall: {out}"
_assert_no_move_continuity(
container, "deb", version,
pre_group_id, pre_dir_stat, pre_config_content, pre_schema_version,
pkg_name,
)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
)
@pytest.mark.slow
def test_no_move_continuity_rpm(skip_no_docker, version):
"""Verify no-move continuity for rpm: make-install state survives migration."""
pkg = _find_package("rpm")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-no-move-rpm"
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(textwrap.dedent(f"""\
FROM fedora:40
RUN dnf install -y --setopt=install_weak_deps=False \
python3 smartmontools systemd dbus && \
dnf clean all
COPY dist/{pkg_name} /pkg/{pkg_name}
"""))
tag = "fenris-no-move-rpm"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
)
container = f"fenris-no-move-rpm-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
_seed_make_install_state(container, include_manifest=False)
pre_group_id = _container_exec(
container, "getent group fenris | cut -d: -f3"
)[1].strip()
pre_dir_stat = _container_exec(
container, "stat -c '%a %U %G' /var/lib/fenris"
)[1].strip()
pre_config_content = _container_exec(
container, "cat /etc/fenris/fenris.conf"
)[1].strip()
pre_schema_version = _get_schema_version(container)
# Verify guard blocks install with remnants
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"Guard should block install with remnants: {out}"
_simulate_make_uninstall(container)
# Install the package — guard should pass
rc, out = _container_exec(
container, f"rpm -ivh /pkg/{pkg_name} 2>&1"
)
assert rc == 0, f"Package install failed after simulated uninstall: {out}"
_assert_no_move_continuity(
container, "rpm", version,
pre_group_id, pre_dir_stat, pre_config_content, pre_schema_version,
pkg_name,
)
finally:
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
)