1439 lines
53 KiB
Python
1439 lines
53 KiB
Python
"""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,fmt", 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,
|
||
)
|