From d119a09b1f60c653dcba1e590d0d1a83ee9c7ba2 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 15:30:51 +0530 Subject: [PATCH] Fix Void package lifecycle on host --- packaging/stage.sh | 5 +++++ packaging/xbps/remove.sh | 36 ++++++++++++++++-------------------- src/fenris/init_system.py | 25 +++++++++++++++++++++++-- tests/test_init_system.py | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/packaging/stage.sh b/packaging/stage.sh index fec735c..6d812f7 100755 --- a/packaging/stage.sh +++ b/packaging/stage.sh @@ -52,6 +52,11 @@ VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor" mkdir -p "${VENDOR_DIR}" python3 -m pip install --disable-pip-version-check --no-compile \ --target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}" +# Package files must be importable by unprivileged Fenris users regardless of +# the builder's umask. pip otherwise preserves a restrictive umask in the +# vendored runtime, which makes the installed CLI fail before it can read the +# observation store. +chmod -R a+rX "${VENDOR_DIR}" # --- Inject version into wrapper from pyproject.toml --- # The wrapper has a hardcoded version string; patch it for packaging. diff --git a/packaging/xbps/remove.sh b/packaging/xbps/remove.sh index a84c994..05e50aa 100644 --- a/packaging/xbps/remove.sh +++ b/packaging/xbps/remove.sh @@ -1,33 +1,29 @@ #!/bin/sh -# XBPS REMOVE script — pre-remove and purge paths. +# XBPS REMOVE script — pre-remove path. # # 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) +# Actions: pre (before files removed) set -eu ACTION="$1" - +UPDATE="$4" 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 + # Only a real erase is a sanctioned disable. An upgrade must preserve + # both monitoring intent and the active runit service. + if [ "${UPDATE}" = "no" ]; then + # Close the monitoring period while the store is still available. + if [ -x /usr/libexec/fenris/fenris-monitor ]; then + /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true + fi + # Configuration and the observation store are deliberately not + # package-owned. Leave them in place: XBPS does not guarantee a + # post-remove callback before its cleanup action. + # runit: remove the service symlink and mark dormant + rm -f /var/service/fenris-collect + touch /etc/sv/fenris-collect/down 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 diff --git a/src/fenris/init_system.py b/src/fenris/init_system.py index e177acb..83f57d7 100644 --- a/src/fenris/init_system.py +++ b/src/fenris/init_system.py @@ -319,16 +319,37 @@ def _runit_is_enabled() -> bool: def _runit_is_running() -> bool: """Check if the runit service is currently running. - Looks for a 'supervise/pid' file in the service directory. + Looks for a 'supervise/pid' file in the service directory. Void creates + that directory root-only, so unprivileged dashboard reads fall back to + the public process table when they cannot traverse it. """ + def runsv_process_exists() -> bool: + try: + result = subprocess.run( + ["pgrep", "-f", "^runsv fenris-collect$"], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return False + pid_file = FENRIS_SV_DIR / "supervise" / "pid" + # On Void, Path.exists() is false for an unprivileged process when it + # cannot traverse runit's root-only supervise directory. if not pid_file.exists(): - return False + return FENRIS_SERVICE_LINK.exists() and runsv_process_exists() try: pid = int(pid_file.read_text().strip()) # Check if the process is alive os.kill(pid, 0) return True + except PermissionError: + # runsv's supervisor state is root-only on Void. Its process command + # is still observable, which gives the read-only UI the same runtime + # fact without granting it service-control permissions. + return FENRIS_SERVICE_LINK.exists() and runsv_process_exists() except (ValueError, OSError): return False diff --git a/tests/test_init_system.py b/tests/test_init_system.py index 9a90651..a3f576a 100644 --- a/tests/test_init_system.py +++ b/tests/test_init_system.py @@ -471,7 +471,9 @@ class TestRunitIsRunning: sv_dir = tmp_path / "sv" / "fenris-collect" sv_dir.mkdir(parents=True) - with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir): + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("subprocess.run") as mock_run: + mock_run.return_value.returncode = 1 assert _runit_is_running() is False def test_is_running_false_dead_process(self, tmp_path): @@ -485,6 +487,37 @@ class TestRunitIsRunning: patch("os.kill", side_effect=OSError("No such process")): assert _runit_is_running() is False + def test_is_running_uses_process_table_when_supervise_is_unreadable(self, tmp_path): + """Void keeps runit's supervise directory root-only for normal users.""" + sv_dir = tmp_path / "sv" / "fenris-collect" + supervise_dir = sv_dir / "supervise" + supervise_dir.mkdir(parents=True) + pid_file = supervise_dir / "pid" + pid_file.write_text("12345") + service_link = tmp_path / "service" / "fenris-collect" + service_link.parent.mkdir(parents=True) + service_link.symlink_to(sv_dir) + + original_read_text = Path.read_text + + def deny_pid(path, *args, **kwargs): + if path == pid_file: + raise PermissionError("supervise is root-only") + return original_read_text(path, *args, **kwargs) + + with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \ + patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \ + patch.object(Path, "read_text", autospec=True, side_effect=deny_pid), \ + patch("subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + assert _runit_is_running() is True + mock_run.assert_called_once_with( + ["pgrep", "-f", "^runsv fenris-collect$"], + capture_output=True, + text=True, + timeout=5, + ) + # --------------------------------------------------------------------------- # Public API dispatching