From b593a2742e4e58503d0a7f3f43eb176b98f742f3 Mon Sep 17 00:00:00 2001 From: xavierk Date: Fri, 4 Sep 2026 11:46:58 +0530 Subject: [PATCH] fix: make RPM runtime portable on Tumbleweed --- Makefile | 23 +++++----- README.md | 20 ++++++--- docs/adr/0007-package-delivery-amends-0004.md | 6 +-- docs/spec/release-packaging.md | 11 ++--- packaging/fenris.conf | 2 +- packaging/nfpm.yaml | 2 +- packaging/postinst.sh | 7 +-- packaging/rpm/post.sh | 7 +-- packaging/stage.sh | 19 +++++--- pyproject.toml | 2 +- scripts/fenris | 27 +++++++++++- src/fenris/__init__.py | 2 +- src/fenris/collect.py | 13 ++++-- src/fenris/monitor.py | 17 +++---- tests/test_monitor.py | 15 +++++++ tests/test_packaging.py | 44 ++++++++++++------- 16 files changed, 146 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index 5e44185..f9c72ab 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ SHELL := /bin/bash PYTHON := python3 VENV_DIR := /opt/fenris +VENDOR_DIR := $(VENV_DIR)/vendor BIN_DIR := /usr/local/bin LIBEXEC_DIR := /usr/libexec/fenris UNIT_DIR := /etc/systemd/system @@ -57,7 +58,7 @@ check-smartctl: dist/fenris-*.whl: pyproject.toml src/fenris/*.py @mkdir -p dist - $(PYTHON) -m build --wheel -o dist + $(PYTHON) -m pip wheel --no-deps --wheel-dir dist . # ─── Install ──────────────────────────────────────────────────────────────── @@ -71,11 +72,10 @@ install: check-python check-smartctl dist/fenris-*.whl @sudo groupadd -f fenris @sudo install -d -o root -g fenris -m 2750 $(DATA_DIR) - @echo "=== Installing venv with pinned dependencies ===" + @echo "=== Installing version-neutral runtime packages ===" @sudo rm -rf $(VENV_DIR) - @sudo $(PYTHON) -m venv $(VENV_DIR) - @sudo $(VENV_DIR)/bin/pip install --upgrade pip --quiet - @sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet + @sudo install -d -m 0755 $(VENDOR_DIR) + @sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet @echo "=== Installing wrapper ===" @sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris @@ -119,7 +119,7 @@ import-legacy: @if [ -f "$(LEGACY_HISTORY)" ]; then \ echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \ echo "Running idempotent import..."; \ - $(VENV_DIR)/bin/python3 -c "import sys; sys.path.insert(0, 'src'); from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \ + PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \ else \ echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \ fi @@ -131,8 +131,10 @@ upgrade: dist/fenris-*.whl @echo "=== Snapshotting database (IN-6) ===" @sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true - @echo "=== Installing new wheel with pinned dependencies ===" - @sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet + @echo "=== Installing new version-neutral runtime packages ===" + @sudo rm -rf $(VENDOR_DIR) + @sudo install -d -m 0755 $(VENDOR_DIR) + @sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet @echo "=== Syncing units against manifest ===" @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ @@ -172,7 +174,7 @@ upgrade: dist/fenris-*.whl done @echo "=== Applying forward-only schema migrations (IN-5, IN-6) ===" - @sudo $(VENV_DIR)/bin/python3 -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')" + @sudo env PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')" @echo "=== Upgrade complete ===" @@ -237,8 +239,9 @@ FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) # GPG signing — packaging key UID (spec §4) PACKAGING_KEY ?= packaging@bongbetic.com -stage: dist/fenris-*.whl +stage: @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" + $(PYTHON) -m pip wheel --no-deps --wheel-dir dist . bash packaging/stage.sh "$(FENRIS_VERSION)" package-deb: stage diff --git a/README.md b/README.md index ffac762..39eb768 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ sudo apt update && sudo apt install fenris Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or `noble`). -### Fedora (dnf) +### Fedora / openSUSE Tumbleweed (RPM) Use the Fenris-owned repo file (not Gitea's auto-generated one): @@ -53,6 +53,15 @@ sudo dnf config-manager --add-repo \ sudo dnf install fenris ``` +On openSUSE Tumbleweed, add the same standard RPM repository file and install +with zypper: + +```bash +sudo zypper addrepo --refresh \ + https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo fenris +sudo zypper install fenris +``` + 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). @@ -91,7 +100,8 @@ For contributors building from source: sudo make install ``` -This builds a wheel, installs it into `/opt/fenris` with pinned dependencies, +This builds a wheel, installs its locked pure-Python runtime packages into +`/opt/fenris/vendor`, and places helpers, units, and the polkit policy. Units are dormant by default. ```bash @@ -117,7 +127,7 @@ sudo make upgrade What it does: 1. Snapshots `observations.db` to a one-generation backup (`.bak`). -2. Installs the new wheel into the same venv with pinned dependencies. +2. Replaces the locked runtime packages under `/opt/fenris/vendor`. 3. Syncs units and polkit against the manifest; runs `daemon-reload`. 4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code. 5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist). @@ -149,7 +159,7 @@ make uninstall # removes artifacts, preserves config and observation history make purge # also removes /etc/fenris and /var/lib/fenris ``` -Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the venv, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store. +Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the runtime packages, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store. ## Cadence drop-ins @@ -212,7 +222,7 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against. | sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile | | Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` | | Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` | -| Venv | `/opt/fenris` | `/opt/fenris` | +| Runtime packages | `/opt/fenris/vendor` | `/opt/fenris/vendor` | | Legacy history | — | `./data/history.jsonl` (auto-imported) | --- diff --git a/docs/adr/0007-package-delivery-amends-0004.md b/docs/adr/0007-package-delivery-amends-0004.md index 8a1621c..ce2a2fd 100644 --- a/docs/adr/0007-package-delivery-amends-0004.md +++ b/docs/adr/0007-package-delivery-amends-0004.md @@ -6,7 +6,7 @@ Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://g ## Context -ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned venv at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, and Fedora 40+ (x86_64), with dependencies vendored in a bundled venv because every target distro ships `python3-textual` below Fenris's floor. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback. +ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned runtime directory at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, Fedora 40+, and openSUSE Tumbleweed (x86_64), with locked pure-Python dependencies vendored at `/opt/fenris/vendor`. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback. The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale. @@ -14,12 +14,12 @@ The implementation-ready operative contracts live in the [release and packaging Amendments to ADR 0004, section by section: -1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+), built by nfpm from a single `packaging/nfpm.yaml` over a staged `--copies` venv at `/opt/fenris`, published to the Gitea Debian/RPM registry and installed with `apt`/`dnf`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `-1`, revision bump on rebuild. +1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+ and openSUSE Tumbleweed), built by nfpm from a single `packaging/nfpm.yaml` over locked pure-Python runtime packages staged at `/opt/fenris/vendor`, published to the Gitea Debian/RPM registry and installed with `apt`, `dnf`, or `zypper`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `-1`, revision bump on rebuild. 2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8. 3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it. 4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in. 5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import ` remains available as the only import path from packages. -6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter. +6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations through the target `python3` with `/opt/fenris/vendor` on its import path (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter. 7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release. 8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`). 9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`. diff --git a/docs/spec/release-packaging.md b/docs/spec/release-packaging.md index 9389fbf..d57fac0 100644 --- a/docs/spec/release-packaging.md +++ b/docs/spec/release-packaging.md @@ -14,11 +14,12 @@ | Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` | | Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` | | Fedora 40+ | every release | rpm | `rpm/fenris` group | +| openSUSE Tumbleweed | rolling | rpm | `rpm/fenris` group | - Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog). -- Dependencies are vendored in a bundled venv for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata. +- Dependencies are vendored as locked, pure-Python runtime packages for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata. - Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor. -- The bundled venv is staged with `python3 -m venv --copies` at `/opt/fenris`: shebangs point at the fixed absolute `/opt/fenris/bin/python`, and the stdlib still comes from the host interpreter, which is why `python3 (>= 3.10)` is a hard dependency. +- Runtime packages are staged with `python3 -m pip --target /opt/fenris/vendor`; entry points run the target system's `python3` with that directory on the import path. No package ships a copied Python interpreter, avoiding build-host ABI paths and rolling-distribution minor-version breakage. ## 2. Distribution channel @@ -33,7 +34,7 @@ ## 3. Build toolchain - **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020. -- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (venv `--copies` → `/opt/fenris` tree, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists. +- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (locked runtime packages → `/opt/fenris/vendor`, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists. - **Entry point:** `make package` → `dist/fenris__amd64.deb` + `dist/fenris--1.x86_64.rpm`. - **Version scheme:** `-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates). - **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline. @@ -62,7 +63,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm | Artifact | Location | Ownership | |---|---|---| -| Bundled venv | `/opt/fenris` | package (tree) | +| Bundled runtime packages | `/opt/fenris/vendor` | package (tree) | | Wrapper | `/usr/bin/fenris` | package | | Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries | | Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) | @@ -76,7 +77,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm - **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB. - **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships. -- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter. +- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via target `python3` with `/opt/fenris/vendor` on its import path → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter. - **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`. - **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command. diff --git a/packaging/fenris.conf b/packaging/fenris.conf index 75f76bd..f90a086 100644 --- a/packaging/fenris.conf +++ b/packaging/fenris.conf @@ -6,6 +6,6 @@ # The device selector specifies which NVMe drive to monitor. # Uncomment and set exactly one device path: # -# devices = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456 +# device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456 # # See https://git.bongbetic.com/xavierk/Fenris for documentation. diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 2d5ef14..eef588c 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -15,7 +15,7 @@ depends: - systemd contents: - # Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles + # Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles - src: build/stage/ dst: / type: tree diff --git a/packaging/postinst.sh b/packaging/postinst.sh index 0c0f3f5..a90002a 100755 --- a/packaging/postinst.sh +++ b/packaging/postinst.sh @@ -9,7 +9,8 @@ set -eu STORE_DIR="/var/lib/fenris" STORE_DB="${STORE_DIR}/observations.db" STORE_BAK="${STORE_DIR}/observations.db.bak" -VENV_PYTHON="/opt/fenris/bin/python3" +RUNTIME_PYTHON="/usr/bin/python3" +VENDOR_DIR="/opt/fenris/vendor" case "${1:-}" in configure) @@ -18,8 +19,8 @@ case "${1:-}" in if [ -f "${STORE_DB}" ]; then cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true fi - if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then - "${VENV_PYTHON}" -c " + 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}')) diff --git a/packaging/rpm/post.sh b/packaging/rpm/post.sh index 7ee8bbb..3699e9e 100755 --- a/packaging/rpm/post.sh +++ b/packaging/rpm/post.sh @@ -5,7 +5,8 @@ set -eu STORE_DIR="/var/lib/fenris" STORE_DB="${STORE_DIR}/observations.db" STORE_BAK="${STORE_DIR}/observations.db.bak" -VENV_PYTHON="/opt/fenris/bin/python3" +RUNTIME_PYTHON="/usr/bin/python3" +VENDOR_DIR="/opt/fenris/vendor" if [ "$1" -eq 1 ]; then # Fresh install @@ -17,8 +18,8 @@ elif [ "$1" -ge 2 ]; then if [ -f "${STORE_DB}" ]; then cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true fi - if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then - "${VENV_PYTHON}" -c " + 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}')) diff --git a/packaging/stage.sh b/packaging/stage.sh index 95a755a..977f099 100755 --- a/packaging/stage.sh +++ b/packaging/stage.sh @@ -5,7 +5,7 @@ # # VERSION defaults to the version in pyproject.toml. # The staged tree contains: -# /opt/fenris/ — bundled venv with the built wheel +# /opt/fenris/vendor/ — bundled pure-Python application dependencies # /usr/bin/fenris — unprivileged wrapper # /usr/libexec/fenris/ — fenris-monitor, fenris-collect # /usr/lib/systemd/system/ — fenris-collect.{timer,service} @@ -35,18 +35,23 @@ rm -rf "${STAGE_DIR}" mkdir -p "${STAGE_DIR}" # --- Use pre-built wheel from dist/ --- -WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-*.whl 2>/dev/null | head -1) +WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-"${VERSION}"-*.whl 2>/dev/null | head -1) if [ -z "${WHEEL}" ]; then echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2 exit 1 fi echo " Using wheel: $(basename "${WHEEL}")" -# --- Create venv with --copies and install wheel --- -echo " Creating bundled venv ..." -python3 -m venv --copies "${STAGE_DIR}/opt/fenris" -"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1 -"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1 +# --- Vendor runtime packages without an interpreter --- +# A copied Python binary contains an ABI and build-host dynamic-library path. +# It fails after rolling-distribution Python upgrades (for example Tumbleweed +# 3.12 -> 3.13). Fenris and its locked dependencies are pure Python, so place +# them in a version-neutral directory and execute with the target's python3. +echo " Installing version-neutral runtime packages ..." +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}" # --- Inject version into wrapper from pyproject.toml --- # The wrapper has a hardcoded version string; patch it for packaging. diff --git a/pyproject.toml b/pyproject.toml index e789a50..26bdeff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fenris" -version = "0.3.0" +version = "0.3.1" description = "NVMe wear monitor with persistent TUI" requires-python = ">=3.9" dependencies = [ diff --git a/scripts/fenris b/scripts/fenris index 83f046a..3cdac9b 100755 --- a/scripts/fenris +++ b/scripts/fenris @@ -10,6 +10,29 @@ import argparse import os import subprocess import sys +from pathlib import Path + + +def add_runtime_packages() -> None: + """Make the package-owned, pure-Python dependencies importable. + + RPM and deb installations deliberately use the target system's Python. + Their dependencies are vendored without a copied interpreter so a distro + Python minor-version update cannot leave Fenris linked to a removed ABI. + The legacy development install keeps its venv fallback. + """ + runtime_dir = Path("/opt/fenris") + vendor_dir = runtime_dir / "vendor" + if vendor_dir.is_dir(): + sys.path.insert(0, str(vendor_dir)) + return + + site_packages = next((runtime_dir / "lib").glob("python*/site-packages"), None) + if site_packages: + sys.path.insert(0, str(site_packages)) + + +add_runtime_packages() def is_root() -> bool: @@ -51,8 +74,8 @@ def cmd_tui(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None: """Show status.""" - from fenris.status import print_status - print_status() + from fenris.status import render_status + print(render_status()) def cmd_sample(args: argparse.Namespace) -> None: diff --git a/src/fenris/__init__.py b/src/fenris/__init__.py index 3df3b7e..76e7d3b 100644 --- a/src/fenris/__init__.py +++ b/src/fenris/__init__.py @@ -1,2 +1,2 @@ """Fenris: NVMe wear monitor with persistent TUI.""" -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/fenris/collect.py b/src/fenris/collect.py index 1285d9d..840e3f7 100644 --- a/src/fenris/collect.py +++ b/src/fenris/collect.py @@ -15,12 +15,17 @@ import sys from datetime import datetime, timezone from pathlib import Path -# Add the venv to path if running from the installed location +# Package dependencies are vendored independently of the host Python minor +# version. Keep the venv fallback for the legacy development install. VENV_DIR = Path("/opt/fenris") if VENV_DIR.exists(): - site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) - if site_packages: - sys.path.insert(0, str(site_packages)) + vendor_dir = VENV_DIR / "vendor" + if vendor_dir.is_dir(): + sys.path.insert(0, str(vendor_dir)) + else: + site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) + if site_packages: + sys.path.insert(0, str(site_packages)) from fenris.store import init_store, get_store_path from fenris.collector import run_collection diff --git a/src/fenris/monitor.py b/src/fenris/monitor.py index 1cc0ad8..987f8c2 100644 --- a/src/fenris/monitor.py +++ b/src/fenris/monitor.py @@ -21,12 +21,17 @@ import sqlite3 from datetime import datetime, timezone from pathlib import Path -# Add the venv to path if running from the installed location +# Package dependencies are vendored independently of the host Python minor +# version. Keep the venv fallback for the legacy development install. VENV_DIR = Path("/opt/fenris") if VENV_DIR.exists(): - site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) - if site_packages: - sys.path.insert(0, str(site_packages)) + vendor_dir = VENV_DIR / "vendor" + if vendor_dir.is_dir(): + sys.path.insert(0, str(vendor_dir)) + else: + site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) + if site_packages: + sys.path.insert(0, str(site_packages)) from fenris.store import init_store, get_store_path from fenris.monitoring_periods import ( @@ -53,10 +58,6 @@ def cmd_enable(args: argparse.Namespace) -> None: - Resume with no open period: opens a new row """ store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH) - if not store_path.exists(): - print("Error: Observation store not found at", store_path, file=sys.stderr) - sys.exit(1) - conn = init_store(store_path) now = datetime.now(timezone.utc) diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 48e9217..3571a7c 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -52,6 +52,21 @@ class TestIsRoot: class TestEnableIdempotentMatrix: """§8.6: Period-row idempotent matrix.""" + def test_first_enable_creates_missing_store(self, store_path): + """A fresh package install has a store directory but no database yet.""" + args = MagicMock(now=False, store_path=store_path) + + with patch("fenris.monitor.subprocess") as mock_sub: + mock_sub.run.return_value = MagicMock(returncode=0) + cmd_enable(args) + + conn = init_store(store_path) + row = conn.execute( + "SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL" + ).fetchone() + conn.close() + assert row is not None + def test_first_opens_period(self, store_path): """First-ever enable opens a period at the enable moment.""" # Initialize store diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 3d00bfd..899e15a 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -78,6 +78,7 @@ DEB_TARGETS = [ RPM_TARGETS = [ ("fedora:40", "rpm"), + ("opensuse/tumbleweed", "rpm"), ] ALL_TARGETS = DEB_TARGETS + RPM_TARGETS @@ -101,11 +102,14 @@ def _build_deb_dockerfile(image: str, pkg_name: str) -> str: def _build_rpm_dockerfile(image: str, pkg_name: str) -> str: """Dockerfile for testing rpm installation.""" + install_command = ( + "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all" + if image.startswith("opensuse/") + else "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all" + ) return textwrap.dedent(f"""\ FROM {image} - RUN dnf install -y --setopt=install_weak_deps=False \ - python3 smartmontools systemd dbus && \ - dnf clean all + RUN {install_command} COPY dist/{pkg_name} /pkg/{pkg_name} RUN rpm -ivh /pkg/{pkg_name} """) @@ -132,13 +136,16 @@ def skip_no_docker(): def _assert_dormant_layout(container: str, fmt: str, version: str) -> None: """Assert the dormant-install contract (spec §6, §7).""" - # Venv directory exists with expected structure + # Version-neutral vendored runtime exists. It must not contain a copied + # Python binary tied to the package build host. rc, out = _container_exec(container, "test -d /opt/fenris && echo OK") - assert "OK" in out, "Bundled venv not found at /opt/fenris" + assert "OK" in out, "Bundled runtime not found at /opt/fenris" - # Venv Python binary exists (may not execute if shared libs differ) - rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") - assert rc == 0, "Venv Python binary not found" + 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") @@ -152,6 +159,11 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None: rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK") assert "OK" in out, f"Version {version} not found in wrapper script" + # This catches launcher import-path errors and copied-interpreter ABI + # breakage. Status is read-only and succeeds before monitoring setup. + rc, out = _container_exec(container, "fenris status") + assert rc == 0, f"Installed CLI cannot run: {out}" + # Helpers in /usr/libexec/fenris for helper in ("fenris-monitor", "fenris-collect"): rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}") @@ -284,8 +296,7 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version: 4. Removal scripts are no-ops during upgrade 5. Downgrade refusal via forward-only version check (tested at Python level) """ - # Create a fake observation store using system Python - # (venv Python may not execute if host shared libs differ) + # Create a fake observation store using the target system Python. _container_exec( container, "mkdir -p /var/lib/fenris && " @@ -391,7 +402,7 @@ def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None: # Modify the config file (simulate hand-edited device selector) _container_exec( container, - "echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", + "echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf", ) # Record original config hash rc, original_hash = _container_exec( @@ -447,8 +458,8 @@ def _assert_package_files_removed(container: str) -> None: ) assert rc != 0, "Helper should be removed" - rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") - assert rc != 0, "Venv Python should be removed" + rc, _ = _container_exec(container, "test -d /opt/fenris/vendor") + assert rc != 0, "Vendored runtime packages should be removed" def _assert_deb_remove_preserves(container: str) -> None: @@ -587,10 +598,9 @@ def test_python_floor(skip_no_docker, version): assert "python3" in out, f"python3 dependency not declared: {out}" assert "3.10" in out, f"Python 3.10 floor not in dependency: {out}" - # Verify the bundled venv exists (binary may not execute on the host - # interpreter — that's tested separately by the dormant-install test) - rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") - assert rc == 0, "Bundled venv Python binary not found" + # Verify the version-neutral bundled runtime exists. + rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py") + assert rc == 0, "Bundled runtime package not found" # fenris on PATH rc, _ = _container_exec(container, "command -v fenris")