From d93238b0f3b1c0bead5e5c491701dbd8d693c0d6 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 01:33:51 +0530 Subject: [PATCH] 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 --- .gitignore | 1 + Makefile | 5 + packaging/xbps/install.sh | 68 +++++ packaging/xbps/remove.sh | 34 +++ tests/test_packaging.py | 596 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 681 insertions(+), 23 deletions(-) create mode 100644 packaging/xbps/install.sh create mode 100644 packaging/xbps/remove.sh diff --git a/.gitignore b/.gitignore index dd6850e..1a43b02 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ src/fenris.egg-info/ .pytest_cache/ .venv/ MagicMock* +" \ -H "https://git.bongbetic.com/xavierk/Fenris" \ -l "MIT" \ + -D "python3>=3.10 smartmontools" \ + -F "/etc/fenris/fenris.conf" \ build/stage @echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_1.x86_64.xbps ===" diff --git a/packaging/xbps/install.sh b/packaging/xbps/install.sh new file mode 100644 index 0000000..118e58b --- /dev/null +++ b/packaging/xbps/install.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# XBPS INSTALL script — post-install and post-upgrade paths. +# +# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH +# +# Actions: pre (before files extracted), post (after files extracted) +# UPDATE: "yes" on upgrade, "no" on fresh install +set -eu + +STORE_DIR="/var/lib/fenris" +STORE_DB="${STORE_DIR}/observations.db" +STORE_BAK="${STORE_DIR}/observations.db.bak" +RUNTIME_PYTHON="/usr/bin/python3" +VENDOR_DIR="/opt/fenris/vendor" + +ACTION="$1" +UPDATE="$4" + +case "${ACTION}" in + pre) + # Migration guard — abort if make-install remnants detected + MARKER="/var/lib/fenris/manifest.txt" + if [ -f "${MARKER}" ]; then + echo >&2 + echo >&2 "Fenris make-install remnants detected — refusing to install." + echo >&2 + echo >&2 "Migrate to the package with:" + echo >&2 " sudo make uninstall # removes make-install files, preserves store + config" + echo >&2 " sudo xbps-install fenris" + echo >&2 + echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems" + echo >&2 + exit 1 + fi + ;; + post) + if [ "${UPDATE}" = "yes" ]; then + # Upgrade — snapshot, migration, runit-aware reload + if [ -f "${STORE_DB}" ]; then + cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true + fi + if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then + PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c " +from fenris.store import migrate_to_latest +from pathlib import Path +n = migrate_to_latest(Path('${STORE_DB}')) +print(f'Fenris migration: {n} step(s) applied') if n else None +" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" + fi + # Ensure log directory exists on upgrade + mkdir -p /var/log/fenris-collect + chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true + else + # Fresh install — runit service setup + groupadd -f fenris + install -d -o root -g fenris -m 2770 "${STORE_DIR}" 2>/dev/null || true + # Mark runit service as dormant (down) for fresh install + if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then + touch /etc/sv/fenris-collect/down + fi + # Ensure log directory exists + mkdir -p /var/log/fenris-collect + chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true + fi + ;; +esac + +exit 0 diff --git a/packaging/xbps/remove.sh b/packaging/xbps/remove.sh new file mode 100644 index 0000000..a84c994 --- /dev/null +++ b/packaging/xbps/remove.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# XBPS REMOVE script — pre-remove and purge paths. +# +# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH +# +# Actions: pre (before files removed), post (after files removed), +# purge (after metadata removed, for config cleanup) +set -eu + +ACTION="$1" + +case "${ACTION}" in + pre) + # Sanctioned disable — close monitoring period + if [ -x /usr/libexec/fenris/fenris-monitor ]; then + /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true + fi + # runit: remove the service symlink and mark dormant + rm -f /var/service/fenris-collect + touch /etc/sv/fenris-collect/down 2>/dev/null || true + ;; + purge) + # Full cleanup — remove config, store, and runit service directories + rm -rf /etc/fenris + rm -rf /var/lib/fenris + if getent group fenris > /dev/null 2>&1; then + groupdel fenris 2>/dev/null || true + fi + rm -rf /etc/sv/fenris-collect 2>/dev/null || true + rm -rf /var/log/fenris-collect 2>/dev/null || true + ;; +esac + +exit 0 diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a39b353..41a2a24 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -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, + )