From 2d4cb16a0083d1c277f7c35621df1a561a0d8f27 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 07:47:34 +0530 Subject: [PATCH 1/9] Implement independent format gates for release workflow (issue #86) - Add XBPS build and sign steps to CI workflow - Add XBPS publication as independent gate (requires manual trigger) - Track format availability in release notes - Update release-footer.md with XBPS install instructions - Add --available/--withheld arguments to extract_changelog.py - Attach XBPS artifacts to Gitea release - Clean up XBPS signing key material after use Co-authored-by: CommandCodeBot --- .gitea/workflows/release.yml | 113 +++++++++++++++++++++++++++++++++-- packaging/release-footer.md | 7 +++ scripts/extract_changelog.py | 30 ++++++++++ tests/test_changelog.py | 83 +++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6bcb11f..3668751 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -8,6 +8,12 @@ on: tags: - 'v*' workflow_dispatch: + inputs: + publish_xbps: + description: 'Publish XBPS package to distribution repository (requires host acceptance)' + required: false + default: false + type: boolean # Built-in Gitea token needs write access for release assets and package registry. permissions: @@ -62,6 +68,9 @@ jobs: - name: Build packages run: make package + - name: Build XBPS package + run: make package-xbps + - name: Import packaging key env: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} @@ -87,6 +96,26 @@ jobs: - name: Sign RPM payload run: make sign-rpm + - name: Import XBPS signing key + id: import-xbps-key + env: + XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }} + run: | + set -euo pipefail + if [ -z "${XBPS_SIGNING_KEY}" ]; then + echo "::warning::XBPS_SIGNING_KEY secret not configured; XBPS signing skipped" + echo "xbps_signed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + mkdir -p ~/.ssh + printf '%s\n' "${XBPS_SIGNING_KEY}" > ~/.ssh/id_xbps + chmod 600 ~/.ssh/id_xbps + echo "xbps_signed=true" >> "$GITHUB_OUTPUT" + + - name: Sign XBPS package + if: steps.import-xbps-key.outputs.xbps_signed == 'true' + run: make sign-xbps + - name: Generate and clearsign SHA256SUMS run: | set -euo pipefail @@ -116,6 +145,7 @@ jobs: gpg --batch --yes --delete-secret-keys "${FINGERPRINT}" gpg --batch --yes --delete-keys "${FINGERPRINT}" fi + rm -f ~/.ssh/id_xbps - name: Determine version id: version @@ -159,6 +189,54 @@ jobs: *) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;; esac + - name: Publish XBPS to distribution repository + if: github.event.inputs.publish_xbps == 'true' + run: | + set -euo pipefail + VERSION=${{ steps.version.outputs.version }} + XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps" + if [ ! -f "${XBPS_FILE}" ]; then + echo "::error::XBPS package not found: ${XBPS_FILE}" + exit 1 + fi + if [ ! -f "${XBPS_FILE}.sig2" ]; then + echo "::error::XBPS signature not found: ${XBPS_FILE}.sig2" + exit 1 + fi + bash scripts/xbps-publish.sh --publish + + - name: Track format availability + id: formats + run: | + set -euo pipefail + VERSION=${{ steps.version.outputs.version }} + DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false") + RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false") + XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false") + XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false") + echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT" + echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT" + echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT" + echo "xbps_published=${XBPS_PUBLISHED}" >> "$GITHUB_OUTPUT" + # Build format availability summary for release notes + AVAILABLE_FORMATS="" + WITHHELD_FORMATS="" + if [ "${DEB_EXISTS}" = "true" ]; then + AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Debian/Ubuntu (deb), " + fi + if [ "${RPM_EXISTS}" = "true" ]; then + AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Fedora/openSUSE (rpm), " + fi + if [ "${XBPS_EXISTS}" = "true" ] && [ "${XBPS_PUBLISHED}" = "true" ]; then + AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Void Linux (xbps)" + elif [ "${XBPS_EXISTS}" = "true" ]; then + WITHHELD_FORMATS="Void Linux (xbps) — pending host acceptance" + fi + # Remove trailing comma and space + AVAILABLE_FORMATS=$(echo "${AVAILABLE_FORMATS}" | sed 's/, $//') + echo "available_formats=${AVAILABLE_FORMATS}" >> "$GITHUB_OUTPUT" + echo "withheld_formats=${WITHHELD_FORMATS}" >> "$GITHUB_OUTPUT" + - name: Create Gitea release env: GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }} @@ -174,6 +252,18 @@ jobs: echo "::error::validated release body is missing or empty" exit 1 fi + # Append format availability to release notes + AVAILABLE_FORMATS="${{ steps.formats.outputs.available_formats }}" + WITHHELD_FORMATS="${{ steps.formats.outputs.withheld_formats }}" + RELEASE_BODY_WITH_FORMATS="${RUNNER_TEMP}/release-body-formats.md" + cp "${RELEASE_BODY}" "${RELEASE_BODY_WITH_FORMATS}" + echo "" >> "${RELEASE_BODY_WITH_FORMATS}" + echo "## Package formats" >> "${RELEASE_BODY_WITH_FORMATS}" + echo "" >> "${RELEASE_BODY_WITH_FORMATS}" + echo "Available: ${AVAILABLE_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}" + if [ -n "${WITHHELD_FORMATS}" ]; then + echo "Withheld: ${WITHHELD_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}" + fi EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json" EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \ -H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \ @@ -182,11 +272,11 @@ jobs: 200) echo "Release v${VERSION} exists; resynchronizing its notes" REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ - --body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")" + --body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")" ;; 404) REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \ - --body-file "${RELEASE_BODY}")" + --body-file "${RELEASE_BODY_WITH_FORMATS}")" ;; *) echo "::error::release lookup failed with HTTP ${EXISTING}" @@ -214,10 +304,21 @@ jobs: "https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}") RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") - # Attach deb, rpm, and clearsigned checksums once. - for FILE in "dist/fenris_${VERSION}_amd64.deb" \ - "dist/fenris-${VERSION}-1.x86_64.rpm" \ - "dist/SHA256SUMS.asc"; do + # Attach deb, rpm, clearsigned checksums, and XBPS artifacts once. + ARTIFACTS=( + "dist/fenris_${VERSION}_amd64.deb" + "dist/fenris-${VERSION}-1.x86_64.rpm" + "dist/SHA256SUMS.asc" + ) + # Add XBPS artifacts if they exist + XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps" + if [ -f "${XBPS_FILE}" ]; then + ARTIFACTS+=("${XBPS_FILE}") + if [ -f "${XBPS_FILE}.sig2" ]; then + ARTIFACTS+=("${XBPS_FILE}.sig2") + fi + fi + for FILE in "${ARTIFACTS[@]}"; do ASSET_NAME="${FILE##*/}" if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then echo "${ASSET_NAME}: already attached" diff --git a/packaging/release-footer.md b/packaging/release-footer.md index 2e5294a..063ae6e 100644 --- a/packaging/release-footer.md +++ b/packaging/release-footer.md @@ -6,6 +6,13 @@ Install Fenris from its package channel after following the [package setup instr sudo apt update && sudo apt install fenris # Debian / Ubuntu sudo dnf install fenris # Fedora sudo zypper install fenris # openSUSE Tumbleweed +sudo xbps-install fenris # Void Linux +``` + +For Void Linux, configure the XBPS repository first: + +```bash +sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 ``` ## Verify downloads diff --git a/scripts/extract_changelog.py b/scripts/extract_changelog.py index 9ec55d4..5ff956a 100755 --- a/scripts/extract_changelog.py +++ b/scripts/extract_changelog.py @@ -68,6 +68,20 @@ def assemble_release_body(section: str, footer: str) -> str: return f"{section}{separator}{footer}" +def format_availability_section( + available: list[str], withheld: list[str] +) -> str: + """Generate a package formats section for release notes.""" + if not available and not withheld: + return "" + lines = ["\n## Package formats\n"] + if available: + lines.append(f"Available: {', '.join(available)}") + if withheld: + lines.append(f"Withheld: {', '.join(withheld)}") + return "\n".join(lines) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("changelog", type=Path) @@ -77,6 +91,18 @@ def main(argv: list[str] | None = None) -> int: type=Path, help="append this standing release guidance after the extracted section", ) + parser.add_argument( + "--available", + action="append", + default=[], + help="format available for this release (can be repeated)", + ) + parser.add_argument( + "--withheld", + action="append", + default=[], + help="format withheld from this release (can be repeated)", + ) args = parser.parse_args(argv) try: section = extract_changelog(args.changelog, args.version) @@ -88,6 +114,10 @@ def main(argv: list[str] | None = None) -> int: f"cannot read release footer {args.footer}: {error.strerror}" ) from error section = assemble_release_body(section, footer) + if args.available or args.withheld: + formats = format_availability_section(args.available, args.withheld) + if formats: + section = f"{section}\n{formats}" sys.stdout.write(section) except ChangelogError as error: print(f"::error::{error}", file=sys.stderr) diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 18351f6..b117c3f 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -209,3 +209,86 @@ def test_release_request_command_reports_create_or_patch_decisions(tmp_path): "path": "/releases/17", "payload": {"body": "## [1.4.0] - 2026-09-10\n"}, } + + +def test_format_availability_section_with_both(): + extractor = _extractor_module() + result = extractor.format_availability_section( + available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)"], + withheld=["Void Linux (xbps) — pending host acceptance"], + ) + assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result + assert "Withheld: Void Linux (xbps) — pending host acceptance" in result + assert "## Package formats" in result + + +def test_format_availability_section_available_only(): + extractor = _extractor_module() + result = extractor.format_availability_section( + available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)", "Void Linux (xbps)"], + withheld=[], + ) + assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm), Void Linux (xbps)" in result + assert "Withheld" not in result + + +def test_format_availability_section_empty(): + extractor = _extractor_module() + result = extractor.format_availability_section(available=[], withheld=[]) + assert result == "" + + +def test_command_includes_format_availability(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n" + "### Added\n\n- Show a release summary.\n", + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(EXTRACTOR_PATH), + str(changelog), + "1.4.0", + "--available", + "Debian/Ubuntu (deb)", + "--available", + "Fedora/openSUSE (rpm)", + "--withheld", + "Void Linux (xbps)", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "## Package formats" in result.stdout + assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result.stdout + assert "Withheld: Void Linux (xbps)" in result.stdout + + +def test_command_without_format_args_has_no_formats_section(tmp_path): + changelog = tmp_path / "CHANGELOG.md" + changelog.write_text( + "# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n" + "### Added\n\n- Show a release summary.\n", + encoding="utf-8", + ) + + result = subprocess.run( + [ + sys.executable, + str(EXTRACTOR_PATH), + str(changelog), + "1.4.0", + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "## Package formats" not in result.stdout From 1113532c9ae795895a0aee8d855e51a566237203 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 15:30:51 +0530 Subject: [PATCH 2/9] 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 From efcfd266b73425b0ccb5823489624b0481f7dd80 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 15:40:29 +0530 Subject: [PATCH 3/9] Clarify TUI monitoring activation --- src/fenris/tui.py | 23 +++++++++++++---------- tests/test_tui.py | 9 +++++---- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 6ad5800..23d8e60 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -67,6 +67,11 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors # Helpers # --------------------------------------------------------------------------- +_RESUME_HINT = "r resume — enable monitoring and future boots" +_ACTION_LEGEND = ( + "p pause · " + _RESUME_HINT + " · c collect · t theme · m motion · d disclosures" +) + def _format_remaining(seconds: float) -> str: """Format remaining lifespan as human-readable string.""" if seconds <= 0: @@ -1164,7 +1169,7 @@ class FenrisTuiApp(App): # Empty store — greeting with enable hint (IN-3) self._render_headline( "[bold]No observations yet[/bold]\n\n" - "Enable monitoring: fenris monitor resume" + "[bold]%s[/bold]" % _RESUME_HINT ) self.query_one("#usage-history").set_data([]) if self._is_constrained_mode: @@ -1173,10 +1178,10 @@ class FenrisTuiApp(App): ) self.query_one("#drive-health").update("") self.query_one("#service-strip").update( - "boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n" - "[bold]CONTINUITY[/bold] %s\n" - "p pause · r resume · c collect · t theme · m motion · d disclosures" - % monitoring_continuity({"boot_enabled": False}) + ( + "boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n" + "[bold]CONTINUITY[/bold] %s\n" + _ACTION_LEGEND + ) % monitoring_continuity({"boot_enabled": False}) ) else: # Store fault (FL-4) @@ -1191,7 +1196,7 @@ class FenrisTuiApp(App): ) self.query_one("#drive-health").update("") self.query_one("#service-strip").update( - "p pause · r resume · c collect · t theme · m motion · d disclosures" + _ACTION_LEGEND ) def _render_all_regions(self, conn: sqlite3.Connection) -> None: @@ -1279,16 +1284,14 @@ class FenrisTuiApp(App): status_text = render_status_tui(comp) # Add TUI-only actions self.query_one("#service-strip").update( - "%s\n" - "p pause · r resume · c collect · t theme · m motion · d disclosures" - % status_text + "%s\n%s" % (status_text, _ACTION_LEGEND) ) self._render_paused_banner(comp) except Exception: self._hide_paused_banner() self.query_one("#service-strip").update( "boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n" - "p pause · r resume · c collect · t theme · m motion · d disclosures" + + _ACTION_LEGEND ) def _render_paused_banner(self, comp) -> None: diff --git a/tests/test_tui.py b/tests/test_tui.py index 72e81e6..603e5fb 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -425,7 +425,7 @@ class TestDenseScreen: quit_rail = app.query_one("#quit-rail") assert "continuity" in strip assert "monitoring: active in background · persists across reboots" in strip - assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip + assert "r resume — enable monitoring and future boots" in strip assert "q quit" not in strip assert rail == "q QUIT TUI" assert usage.region.y < service.region.y < quit_rail.region.y @@ -597,13 +597,14 @@ class TestDisclosuresAndGreeting: class TestFirstRun: @pytest.mark.asyncio async def test_first_run_prompt(self, tmp_path): - """First-run prompt enables timer and opens first period.""" + """First-run prompt makes the keyboard action and boot effect explicit.""" app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") async with app.run_test() as pilot: headline = str(app.query_one("#headline-band").render()) + strip = str(app.query_one("#service-strip").render()) assert "no observations yet" in headline.lower() - # The enable hint should mention resume - assert "resume" in headline.lower() + assert "r resume — enable monitoring and future boots" in headline.lower() + assert "r resume — enable monitoring and future boots" in strip.lower() # --------------------------------------------------------------------------- From 967ba6964fa8fd5712587e7a1c62d8f2ed1d401b Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 15:44:10 +0530 Subject: [PATCH 4/9] Route TUI controls through polkit --- src/fenris/tui.py | 3 +++ tests/test_tui.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/fenris/tui.py b/src/fenris/tui.py index 23d8e60..d2a2724 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -14,6 +14,7 @@ Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8. """ from __future__ import annotations +import os import sqlite3 import subprocess import sys @@ -1454,6 +1455,8 @@ class FenrisTuiApp(App): cmd = [self.helper_path, operation] if extra_args: cmd.extend(extra_args) + if os.geteuid() != 0: + cmd.insert(0, "pkexec") try: with self.suspend(): diff --git a/tests/test_tui.py b/tests/test_tui.py index 603e5fb..8b94f4e 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -595,6 +595,22 @@ class TestDisclosuresAndGreeting: # --------------------------------------------------------------------------- class TestFirstRun: + def test_unprivileged_resume_uses_polkit(self, tmp_path): + """TUI controls use the same authenticated path as the CLI.""" + app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db") + + with patch("fenris.tui.os.geteuid", return_value=1000), \ + patch("fenris.tui.subprocess.run") as run, \ + patch.object(app, "suspend"), \ + patch.object(app, "_refresh"): + run.return_value.returncode = 0 + app._run_helper("enable", ["--now"]) + + run.assert_called_once_with( + ["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"], + timeout=30, + ) + @pytest.mark.asyncio async def test_first_run_prompt(self, tmp_path): """First-run prompt makes the keyboard action and boot effect explicit.""" From dea2186d6b04a5a81355e5d97eef70b73c1e2fa4 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 16:43:41 +0530 Subject: [PATCH 5/9] Prepare XBPS repository publication --- Makefile | 10 +++--- README.md | 75 ++++++++++++++++++++++++++++++++++++----- scripts/xbps-publish.sh | 7 ++-- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index c0f31e4..8c56552 100644 --- a/Makefile +++ b/Makefile @@ -277,28 +277,30 @@ package: package-deb package-rpm # XBPS signing key (separate from SSH authentication key) XBPS_SIGNING_KEY ?= $(HOME)/.ssh/id_xbps +XBPS_REVISION ?= 1 package-xbps: stage @echo "=== Building XBPS package ===" cp packaging/xbps/install.sh build/stage/INSTALL cp packaging/xbps/remove.sh build/stage/REMOVE + install -D -m 0644 packaging/fenris.conf build/stage/etc/fenris/fenris.conf chmod 755 build/stage/INSTALL build/stage/REMOVE xbps-create -A x86_64 \ - -n fenris-$(FENRIS_VERSION)_1 \ + -n fenris-$(FENRIS_VERSION)_$(XBPS_REVISION) \ -s "Fenris NVMe wear monitor" \ -S "NVMe wear monitor with persistent TUI" \ -m "Fenris Packaging " \ -H "https://git.bongbetic.com/xavierk/Fenris" \ -l "MIT" \ - -D "python3>=3.10 smartmontools" \ + -D "python3>=3.10 smartmontools>=0" \ -F "/etc/fenris/fenris.conf" \ build/stage - @echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_1.x86_64.xbps ===" + @echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps ===" sign-xbps: package-xbps @echo "=== Signing XBPS package ===" xbps-rindex --sign-pkg --privkey $(XBPS_SIGNING_KEY) \ - fenris-$(FENRIS_VERSION)_1.x86_64.xbps + fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps @echo "=== XBPS package signed ===" xbps-publish: diff --git a/README.md b/README.md index 1f5940d..01d586b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ *Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.* -Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes. +Fenris is a persistent TUI monitor backed by a short-lived privileged collector on the host's native scheduler. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes. --- @@ -10,7 +10,7 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector - **Python ≥ 3.10** (verified at install time) - **smartmontools** (`smartctl` — verified at install time) -- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit) +- **systemd** or **runit**, with a polkit agent (the collector runs as root; elevation is exclusively polkit) No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile). @@ -66,6 +66,44 @@ The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to TLS). +### Void Linux (XBPS) + +Void x86_64 with glibc and runit is the native target. Its XBPS channel is +withheld until the host-acceptance gate passes; do not install proof artifacts +from it. Once a Fenris Release lists XBPS as available, add the permanent +signed repository, refresh its metadata, and install the package: + +```bash +sudo install -d -m 0755 /etc/xbps.d +echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \ + | sudo tee /etc/xbps.d/fenris.conf +sudo xbps-install -S fenris +``` + +XBPS requires remote repositories to be signed. On the first refresh it +displays the repository signing key embedded in the signed metadata; accept it +only when its RSA SHA256 fingerprint is +`SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`. The public key is also +available at +`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`. +For later updates, always refresh first so XBPS fetches the current index: + +```bash +sudo xbps-install -Syu +``` + +The runit service remains dormant after installation. `fenris monitor resume` +creates `/var/service/fenris-collect`; pause removes that link and records a +deliberate disable in the observation history. + +Fenris keeps the observation store root-written and readable by the `fenris` +group. Add each TUI user to that group, then start a new login session before +running Fenris: + +```bash +sudo usermod -aG fenris "$USER" +``` + ### Package signature verification The RPM payload is signed with the Fenris packaging key (RSA 3072). @@ -84,12 +122,12 @@ The packaging public key is published in-repo — no keyservers. See ### Dormant install -A fresh package install is fully dormant. Units are present but disabled; -nothing runs. The only opt-in is the sanctioned toggle: +A fresh package install is fully dormant. Its native scheduler is present but +disabled; nothing runs. The only opt-in is the sanctioned toggle: ```bash -fenris monitor resume # enable timer + open first monitoring period -fenris monitor pause # close the period, disable timer +fenris monitor resume # enable scheduling + open first monitoring period +fenris monitor pause # close the period, disable scheduling ``` ## Development install (make install) @@ -117,6 +155,7 @@ make purge # also removes /etc/fenris and /var/lib/fenris ```bash sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu sudo dnf upgrade fenris # Fedora +sudo xbps-install -Syu # Void Linux ``` ### Development upgrade @@ -133,6 +172,12 @@ What it does: 5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist). Rollback: reinstall the previous version and restore `observations.db.bak`. +On Void, pause monitoring first, copy the compatible snapshot back to +`/var/lib/fenris/observations.db`, then force-install the matching older +package version. If that version is no longer indexed, add its retained XBPS +archive to a local repository with `xbps-rindex -a` and use +`xbps-install -R -f fenris-`. Installing an older +package over a newer observation store is unsupported. ## Migration from make install @@ -150,6 +195,7 @@ continuity. Over-installing the package over a `make install` is sudo apt remove fenris # preserves config and store sudo apt purge fenris # also removes config and store sudo dnf remove fenris # preserves config and store +sudo xbps-remove fenris # preserves config and store ``` ### Development removal @@ -174,6 +220,19 @@ sudo systemctl edit fenris-collect.timer No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key. +On Void, Fenris uses its native runit service instead: its initial collection +is delayed by two minutes and later collections run five minutes after the +previous run finishes. Inspect its state and diagnostics with: + +```bash +sv status fenris-collect +sudo tail -n 50 /var/log/fenris-collect/current +``` + +`fenris status` also reports the separate boot-enabled, runtime-active, +collection outcome, and observation-store freshness facts. A failed collection +is retried at the next interval; it never fabricates missing observations. + ## CLI reference | Command | Behavior | @@ -181,8 +240,8 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer | `fenris` | Opens the TUI (no arguments). | | `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. | | `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. | -| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. | -| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. | +| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables native scheduling and closes the monitoring period. | +| `fenris monitor resume` | Sanctioned enable — enables native scheduling and opens a monitoring period. No confirmation. | | `fenris baseline set ` | CLI-side validation, then polkit-guarded persistence. | | `fenris baseline clear` | Remove the endurance baseline. | | `fenris import ` | Idempotent single-transaction legacy import. | diff --git a/scripts/xbps-publish.sh b/scripts/xbps-publish.sh index 1f7e7c8..3febfc4 100755 --- a/scripts/xbps-publish.sh +++ b/scripts/xbps-publish.sh @@ -84,13 +84,13 @@ for tool in xbps-create xbps-rindex git; do if ! command -v "$tool" &>/dev/null; then echo "ERROR: Required tool not found: $tool" >&2 exit 1 - done + fi done # ── Main ───────────────────────────────────────────────────────────────── VERSION=$(_version) -REVISION=1 +REVISION="${XBPS_REVISION:-1}" PKGVER="fenris-${VERSION}_${REVISION}" XBPS_FILE="${PKGVER}.${ARCH}.xbps" @@ -105,8 +105,7 @@ fi # ── Step 1: Build XBPS package ─────────────────────────────────────────── echo "--- Build XBPS package ---" -_run "make stage" -_run "xbps-create -A ${ARCH} -n ${PKGVER} -s 'Fenris NVMe wear monitor' -S 'NVMe wear monitor with persistent TUI' -m 'Fenris Packaging ' -H 'https://git.bongbetic.com/xavierk/Fenris' -l 'MIT' build/stage" +_run "make XBPS_REVISION=${REVISION} package-xbps" echo "" # ── Step 2: Sign package ───────────────────────────────────────────────── From cca6804964e28f81d382e3ac99584eee6508661e Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 16:44:27 +0530 Subject: [PATCH 6/9] Fix XBPS publication artifact paths --- scripts/xbps-publish.sh | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/xbps-publish.sh b/scripts/xbps-publish.sh index 3febfc4..9147d22 100755 --- a/scripts/xbps-publish.sh +++ b/scripts/xbps-publish.sh @@ -93,6 +93,8 @@ VERSION=$(_version) REVISION="${XBPS_REVISION:-1}" PKGVER="fenris-${VERSION}_${REVISION}" XBPS_FILE="${PKGVER}.${ARCH}.xbps" +SOURCE_DIR=$(pwd) +XBPS_PATH="${SOURCE_DIR}/${XBPS_FILE}" echo "=== Fenris XBPS Publication v${VERSION} ===" echo "" @@ -111,7 +113,7 @@ echo "" # ── Step 2: Sign package ───────────────────────────────────────────────── echo "--- Sign XBPS package ---" -_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_FILE}" +_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_PATH}" echo "" # ── Step 3: Clone/update distribution repository ───────────────────────── @@ -119,24 +121,24 @@ echo "" echo "--- Prepare distribution repository ---" WORK_DIR=$(mktemp -d) _run "git clone ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}.git ${WORK_DIR}" -_run "cd ${WORK_DIR} && git checkout ${GITEA_BRANCH}" +_run "git -C ${WORK_DIR} checkout ${GITEA_BRANCH}" _run "mkdir -p ${WORK_DIR}/${ARCH}" echo "" # ── Step 4: Copy artifacts and update index ────────────────────────────── echo "--- Update repository index ---" -_run "cp ${XBPS_FILE} ${XBPS_FILE}.sig2 ${WORK_DIR}/${ARCH}/" -_run "cd ${WORK_DIR} && xbps-rindex --add ${ARCH}/${XBPS_FILE}" -_run "cd ${WORK_DIR} && xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${ARCH}" +_run "cp ${XBPS_PATH} ${XBPS_PATH}.sig2 ${WORK_DIR}/${ARCH}/" +_run "xbps-rindex --add ${WORK_DIR}/${ARCH}/${XBPS_FILE}" +_run "xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${WORK_DIR}/${ARCH}" echo "" # ── Step 5: Commit and push ────────────────────────────────────────────── echo "--- Commit and push ---" -_run "cd ${WORK_DIR} && git add -A" -_run "cd ${WORK_DIR} && git commit -m 'Release fenris ${VERSION}'" -_run "cd ${WORK_DIR} && git push origin ${GITEA_BRANCH}" +_run "git -C ${WORK_DIR} add -A" +_run "git -C ${WORK_DIR} commit -m 'Release fenris ${VERSION}'" +_run "git -C ${WORK_DIR} push origin ${GITEA_BRANCH}" echo "" # ── Step 6: Verify publication ─────────────────────────────────────────── From 72dacab03bf014ff0ddacb2e8c9d6bde9e85f29c Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 16:45:08 +0530 Subject: [PATCH 7/9] Configure XBPS release committer --- scripts/xbps-publish.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/xbps-publish.sh b/scripts/xbps-publish.sh index 9147d22..ae69440 100755 --- a/scripts/xbps-publish.sh +++ b/scripts/xbps-publish.sh @@ -95,6 +95,13 @@ PKGVER="fenris-${VERSION}_${REVISION}" XBPS_FILE="${PKGVER}.${ARCH}.xbps" SOURCE_DIR=$(pwd) XBPS_PATH="${SOURCE_DIR}/${XBPS_FILE}" +GIT_USER_NAME=$(git config user.name || true) +GIT_USER_EMAIL=$(git config user.email || true) + +if [[ -z "$GIT_USER_NAME" || -z "$GIT_USER_EMAIL" ]]; then + echo "ERROR: Configure git user.name and user.email in the source repository before publishing" >&2 + exit 1 +fi echo "=== Fenris XBPS Publication v${VERSION} ===" echo "" @@ -121,6 +128,8 @@ echo "" echo "--- Prepare distribution repository ---" WORK_DIR=$(mktemp -d) _run "git clone ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}.git ${WORK_DIR}" +_run "git -C ${WORK_DIR} config user.name '${GIT_USER_NAME}'" +_run "git -C ${WORK_DIR} config user.email '${GIT_USER_EMAIL}'" _run "git -C ${WORK_DIR} checkout ${GITEA_BRANCH}" _run "mkdir -p ${WORK_DIR}/${ARCH}" echo "" From 8b6d4b2447a3d8510a6234e97c758e9579afc5c2 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 16:47:35 +0530 Subject: [PATCH 8/9] Index XBPS artifacts in repository --- scripts/xbps-publish.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/xbps-publish.sh b/scripts/xbps-publish.sh index ae69440..f55b7ef 100755 --- a/scripts/xbps-publish.sh +++ b/scripts/xbps-publish.sh @@ -138,8 +138,8 @@ echo "" echo "--- Update repository index ---" _run "cp ${XBPS_PATH} ${XBPS_PATH}.sig2 ${WORK_DIR}/${ARCH}/" -_run "xbps-rindex --add ${WORK_DIR}/${ARCH}/${XBPS_FILE}" -_run "xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${WORK_DIR}/${ARCH}" +_run "(cd ${WORK_DIR} && xbps-rindex --add ${ARCH}/${XBPS_FILE})" +_run "(cd ${WORK_DIR} && xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${ARCH})" echo "" # ── Step 5: Commit and push ────────────────────────────────────────────── From 34bfc70bb82e9ebba5c516919e91e6129925a3a0 Mon Sep 17 00:00:00 2001 From: xavierk Date: Tue, 15 Sep 2026 16:51:37 +0530 Subject: [PATCH 9/9] Refresh XBPS package signatures --- scripts/xbps-publish.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/xbps-publish.sh b/scripts/xbps-publish.sh index f55b7ef..ad8555a 100755 --- a/scripts/xbps-publish.sh +++ b/scripts/xbps-publish.sh @@ -120,6 +120,7 @@ echo "" # ── Step 2: Sign package ───────────────────────────────────────────────── echo "--- Sign XBPS package ---" +_run "rm -f ${XBPS_PATH}.sig2" _run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_PATH}" echo ""