Files
Fenris/tests/test_packaging.py
T
xavierkandCommandCodeBot c91ca10df7 test(upgrade): add migration unit tests and enhance packaging upgrade tests for #48
Add comprehensive test coverage for upgrade semantics:
- 12 Python unit tests for store migration (forward-only, downgrade
  refusal, idempotent behavior) across migrate_to_latest, init_store,
  and open_store_readonly
- Enhanced packaging upgrade test to verify all five acceptance
  criteria: snapshot before migration, store not rebuilt, config
  survival, timer/removal no-ops during upgrade
- RPM-specific test for config file preservation (noreplace conffile)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:58:38 +05:30

833 lines
30 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"
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 _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 — 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
# ---------------------------------------------------------------------------
@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,
)