Implement XBPS packaging lifecycle for Void Linux (issue #85)

Add native XBPS package support with runit service lifecycle:
- packaging/xbps/install.sh: migration guard, fresh install (dormant),
  upgrade (snapshot, migrate, config preservation)
- packaging/xbps/remove.sh: sanctioned disable, purge (full cleanup)
- Makefile: package-xbps target with dependencies and config files
- tests/test_packaging.py: 5 XBPS-specific tests + updated shared tests

Acceptance criteria addressed:
- Fresh install remains dormant; runit down marker set
- Upgrade snapshots and migrates observation history safely
- Removal performs sanctioned pause and retains history
- Migration guard blocks install over make-install remnants
- Debian/RPM regressions verified via existing shared tests

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
xavierk
2026-09-15 01:33:51 +05:30
co-authored by CommandCodeBot
parent a279c56be5
commit d93238b0f3
5 changed files with 681 additions and 23 deletions
+573 -23
View File
@@ -87,10 +87,12 @@ def _find_package(fmt: str) -> Path:
candidate = DIST_DIR / f"fenris_{version}_amd64.deb"
elif fmt == "rpm":
candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm"
elif fmt == "xbps":
candidate = DIST_DIR / f"fenris-{version}_1.x86_64.xbps"
else:
raise ValueError(f"Unknown format: {fmt}")
if not candidate.exists():
pytest.skip(f"Package not found: {candidate} — run `make package` first")
pytest.skip(f"Package not found: {candidate} — run `make package-xbps` first")
return candidate
@@ -109,7 +111,11 @@ RPM_TARGETS = [
("opensuse/tumbleweed", "rpm"),
]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
XBPS_TARGETS = [
("voidlinux/void-linux:latest", "xbps"),
]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + XBPS_TARGETS
# ---------------------------------------------------------------------------
@@ -143,6 +149,89 @@ def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
""")
def _build_xbps_dockerfile(image: str, pkg_name: str) -> str:
"""Dockerfile for testing xbps installation."""
return textwrap.dedent(f"""\
FROM {image}
RUN xbps-install -Suyn void-repo-nonfree && \\
xbps-install -Syun python3 smartmontools runit && \\
xbps-remove -Oy void-repo-nonfree || true
COPY dist/{pkg_name} /pkg/{pkg_name}
""")
def _xbps_extract_and_install(container: str, pkg_name: str, *, action: str = "install") -> tuple[int, str]:
"""Extract xbps package and run lifecycle scripts in container.
action: "install" (fresh), "upgrade" (UPDATE=yes), "remove", or "purge"
"""
# xbps packages are zstd-compressed tar archives containing:
# ./INSTALL, ./REMOVE, ./files.plist, ./props.plist, ./usr/*, ./etc/*
extract = (
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf -"
)
# Ensure target directories exist for manual extraction
_container_exec(container, "mkdir -p /usr/libexec/fenris /usr/lib/systemd/system "
"/etc/sv/fenris-collect/log /usr/share/polkit-1/actions "
"/usr/lib/sysusers.d /usr/lib/tmpfiles.d /opt/fenris /etc/fenris")
if action == "install":
commands = (
f"{extract} && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
"sh INSTALL post fenris 0.3.5 no '' x86_64"
)
elif action == "upgrade":
commands = (
f"{extract} && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
# Do NOT copy fenris.conf — simulates noreplace behavior
"sh INSTALL post fenris 0.3.5 yes '' x86_64"
)
elif action == "remove":
commands = (
f"{extract} && "
"sh REMOVE pre fenris 0.3.5 no '' x86_64 && "
"rm -f /usr/bin/fenris && "
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
"rm -rf /opt/fenris && "
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
)
elif action == "purge":
commands = (
f"{extract} && "
"sh REMOVE purge fenris 0.3.5 no '' x86_64 && "
"rm -f /usr/bin/fenris && "
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
"rm -rf /opt/fenris && "
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
)
else:
raise ValueError(f"Unknown action: {action}")
return _container_exec(container, commands)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -262,18 +351,26 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
]
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}"
# Timer is disabled and inactive (dormant) — systemd only
if fmt != "xbps":
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}"
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}"
# runit: service is dormant (down marker present, no symlink)
if fmt == "xbps":
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Runit service not marked dormant (down file missing)"
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Runit service symlink should not exist (dormant)"
# No hand-rolled manifest
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
@@ -347,6 +444,23 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || "
"apt-get install -f -y 2>&1 || true",
)
elif fmt == "xbps":
# XBPS: extract, install files, run INSTALL post with UPDATE=yes
_container_exec(
container,
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
"sh INSTALL post fenris 0.3.5 yes '' x86_64",
)
else:
# RPM: invoke all three scriptlets in upgrade sequence
# preun ($1=1): should be no-op (only daemon-reload)
@@ -735,6 +849,8 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Write Dockerfile
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -745,7 +861,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
# Run container
@@ -759,6 +875,10 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
)
try:
if fmt == "xbps":
# XBPS: manually extract and run lifecycle scripts
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
_assert_dormant_layout(container, fmt, version)
finally:
subprocess.run(
@@ -797,6 +917,15 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
# Plant make-install remnant BEFORE installing
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
""")
elif fmt == "xbps":
dockerfile = textwrap.dedent(f"""\
FROM {image}
RUN xbps-install -Suyn void-repo-nonfree && \\
xbps-install -Syun python3 smartmontools runit && \\
xbps-remove -Oy void-repo-nonfree || true
COPY dist/{pkg_name} /pkg/{pkg_name}
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"
@@ -816,7 +945,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-guard-{os.getpid()}"
@@ -829,7 +958,17 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
)
try:
_assert_migration_guard(container, fmt, pkg_name)
if fmt == "xbps":
# XBPS: run the INSTALL pre script to check for remnants
rc, out = _container_exec(
container,
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 2>&1 || true",
)
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
f"Migration guard did not trigger: {out}"
else:
_assert_migration_guard(container, fmt, pkg_name)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
@@ -858,6 +997,8 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -867,7 +1008,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-upgrade-{os.getpid()}"
@@ -880,11 +1021,14 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
)
try:
_assert_upgrade_semantics(container, fmt, pkg_name, version)
if fmt == "xbps":
_assert_upgrade_semantics(container, fmt, pkg_name, version)
else:
_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)
# 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),
@@ -1004,6 +1148,8 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -1011,7 +1157,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
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,
check=True, capture_output=True, timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-upgrade-no-disable-{os.getpid()}"
@@ -1024,7 +1170,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
try:
_setup_store_and_config(container)
if fmt == "deb":
if fmt == "xbps":
# Fresh install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS fresh install failed: {out}"
# Upgrade — should NOT run REMOVE pre (no sanctioned disable)
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
assert rc == 0, f"XBPS upgrade failed: {out}"
elif fmt == "deb":
# Fake older version so dpkg treats reinstall as upgrade
_container_exec(
container,
@@ -1436,3 +1589,400 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
)
# ---------------------------------------------------------------------------
# Tests — XBPS/Void Linux lifecycle (issue #85)
# ---------------------------------------------------------------------------
def _assert_runit_dormant_layout(container: str, version: str) -> None:
"""Assert the dormant-install contract for runit-based systems (XBPS)."""
# Version-neutral vendored runtime exists
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 is executable
rc, _ = _container_exec(container, "test -x /usr/bin/fenris")
assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris"
# Version string in wrapper
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"
# CLI runs successfully
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 present (shipped for dual-init support)
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"
# Polkit policy
rc, _ = _container_exec(
container,
"test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy",
)
assert rc == 0, "Polkit policy not found"
# sysusers and tmpfiles fragments
rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf")
assert rc == 0, "sysusers fragment not found"
rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf")
assert rc == 0, "tmpfiles fragment not found"
# Placeholder-commented config
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
assert rc == 0, "fenris.conf not found"
assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \
"Config does not appear to be placeholder-commented"
# fenris group exists
rc, out = _container_exec(container, "getent group fenris")
assert rc == 0, "fenris group not created"
# Observation store directory
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]}"
# runit: service is dormant (down marker present, no symlink)
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Runit service not marked dormant (down file missing)"
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Runit service symlink should not exist (dormant)"
# 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"
@pytest.mark.slow
def test_xbps_dormant_install(skip_no_docker, version):
"""Install XBPS package in Void container, assert dormant runit layout."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps"
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_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
)
tag = "fenris-test-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-test-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
_assert_runit_dormant_layout(container, version)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_migration_guard(skip_no_docker, version):
"""Assert XBPS install aborts on make-install remnants."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-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,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
)
tag = "fenris-guard-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-guard-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Plant make-install remnant
_container_exec(container, "mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt")
# Attempt install — should fail with migration pointer
rc, out = _container_exec(container, f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 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
_container_exec(container, "rm -f /var/lib/fenris/manifest.txt")
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_upgrade_semantics(skip_no_docker, version):
"""Assert XBPS upgrade snapshots store, migrates, preserves config."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-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,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
)
tag = "fenris-upgrade-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-upgrade-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# 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()\"",
)
# Fresh install
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS fresh install failed: {out}"
# Modify config
_container_exec(
container,
"echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
)
# Upgrade
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
assert rc == 0, f"XBPS upgrade failed: {out}"
# Criterion 1: snapshot exists
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db.bak")
assert rc == 0, "Observation store snapshot not created on upgrade"
# Store not rebuilt
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db")
assert rc == 0, "Observation store missing after upgrade"
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: {out.strip()}"
)
# Criterion 2: config survives
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
assert rc == 0, "Config file missing after upgrade"
assert "nvme0n1" in out, "Modified config not preserved after upgrade"
# Criterion 4: files still exist (no-op removal)
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_xbps_remove_preserves_config_and_store(skip_no_docker, version):
"""XBPS remove keeps config, store, and group."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-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_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
)
tag = "fenris-remove-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-remove-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
# Plant store and config
_setup_store_and_config(container)
# Remove (pre action only — preserves config and store)
rc, out = _xbps_extract_and_install(container, pkg_name, action="remove")
assert rc == 0, f"XBPS remove failed: {out}"
# Config survives
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
assert rc == 0, "Config should survive XBPS remove"
# Store survives
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc == 0, "Store directory should survive XBPS remove"
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 XBPS remove"
# Group survives
rc, _ = _container_exec(container, "getent group fenris")
assert rc == 0, "Group should survive XBPS remove"
# Service symlink removed (dormant marker set)
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Service symlink should be removed"
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Down marker should be set after remove"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_purge_removes_everything(skip_no_docker, version):
"""XBPS purge removes config, store, backup, and group."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-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_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
)
tag = "fenris-purge-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-purge-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
# Plant store and config
_setup_store_and_config(container)
# Purge
rc, out = _xbps_extract_and_install(container, pkg_name, action="purge")
assert rc == 0, f"XBPS purge failed: {out}"
# Config removed
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
assert rc != 0, "Config should be removed by purge"
# Store removed
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc != 0, "Store directory should be removed by purge"
# Group removed
rc, _ = _container_exec(container, "getent group fenris 2>/dev/null || true")
assert rc != 0, "Group should be removed by purge"
# Service directory removed
rc, _ = _container_exec(container, "test -d /etc/sv/fenris-collect")
assert rc != 0, "Service directory should be removed by purge"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)