fix: make RPM runtime portable on Tumbleweed

This commit is contained in:
xavierk
2026-09-04 11:46:58 +05:30
parent bdcd321f4c
commit b593a2742e
16 changed files with 146 additions and 71 deletions
+13 -10
View File
@@ -4,6 +4,7 @@
SHELL := /bin/bash SHELL := /bin/bash
PYTHON := python3 PYTHON := python3
VENV_DIR := /opt/fenris VENV_DIR := /opt/fenris
VENDOR_DIR := $(VENV_DIR)/vendor
BIN_DIR := /usr/local/bin BIN_DIR := /usr/local/bin
LIBEXEC_DIR := /usr/libexec/fenris LIBEXEC_DIR := /usr/libexec/fenris
UNIT_DIR := /etc/systemd/system UNIT_DIR := /etc/systemd/system
@@ -57,7 +58,7 @@ check-smartctl:
dist/fenris-*.whl: pyproject.toml src/fenris/*.py dist/fenris-*.whl: pyproject.toml src/fenris/*.py
@mkdir -p dist @mkdir -p dist
$(PYTHON) -m build --wheel -o dist $(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
# ─── Install ──────────────────────────────────────────────────────────────── # ─── Install ────────────────────────────────────────────────────────────────
@@ -71,11 +72,10 @@ install: check-python check-smartctl dist/fenris-*.whl
@sudo groupadd -f fenris @sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR) @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 rm -rf $(VENV_DIR)
@sudo $(PYTHON) -m venv $(VENV_DIR) @sudo install -d -m 0755 $(VENDOR_DIR)
@sudo $(VENV_DIR)/bin/pip install --upgrade pip --quiet @sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
@echo "=== Installing wrapper ===" @echo "=== Installing wrapper ==="
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris @sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@@ -119,7 +119,7 @@ import-legacy:
@if [ -f "$(LEGACY_HISTORY)" ]; then \ @if [ -f "$(LEGACY_HISTORY)" ]; then \
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \ echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
echo "Running idempotent import..."; \ 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 \ else \
echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \ echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \
fi fi
@@ -131,8 +131,10 @@ upgrade: dist/fenris-*.whl
@echo "=== Snapshotting database (IN-6) ===" @echo "=== Snapshotting database (IN-6) ==="
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true @sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
@echo "=== Installing new wheel with pinned dependencies ===" @echo "=== Installing new version-neutral runtime packages ==="
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet @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 ===" @echo "=== Syncing units against manifest ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@@ -172,7 +174,7 @@ upgrade: dist/fenris-*.whl
done done
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ===" @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 ===" @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) # GPG signing — packaging key UID (spec §4)
PACKAGING_KEY ?= packaging@bongbetic.com PACKAGING_KEY ?= packaging@bongbetic.com
stage: dist/fenris-*.whl stage:
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
bash packaging/stage.sh "$(FENRIS_VERSION)" bash packaging/stage.sh "$(FENRIS_VERSION)"
package-deb: stage package-deb: stage
+15 -5
View File
@@ -42,7 +42,7 @@ sudo apt update && sudo apt install fenris
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
`noble`). `noble`).
### Fedora (dnf) ### Fedora / openSUSE Tumbleweed (RPM)
Use the Fenris-owned repo file (not Gitea's auto-generated one): 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 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 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 from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
TLS). TLS).
@@ -91,7 +100,8 @@ For contributors building from source:
sudo make install 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. and places helpers, units, and the polkit policy. Units are dormant by default.
```bash ```bash
@@ -117,7 +127,7 @@ sudo make upgrade
What it does: What it does:
1. Snapshots `observations.db` to a one-generation backup (`.bak`). 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`. 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. 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). 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 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 ## 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 | | sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
| Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` | | Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
| Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` | | 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) | | Legacy history | — | `./data/history.jsonl` (auto-imported) |
--- ---
@@ -6,7 +6,7 @@ Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://g
## Context ## 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. 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: 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 `<pyproject-version>-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 `<pyproject-version>-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. 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. 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. 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 <path>` remains available as the only import path from packages. 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 <path>` 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. 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`). 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`. 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`.
+6 -5
View File
@@ -14,11 +14,12 @@
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` | | Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` | | Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
| Fedora 40+ | every release | rpm | `rpm/fenris` group | | 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). - 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. - 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 ## 2. Distribution channel
@@ -33,7 +34,7 @@
## 3. Build toolchain ## 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. - **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_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`. - **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`.
- **Version scheme:** `<pyproject-version>-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). - **Version scheme:** `<pyproject-version>-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. - **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 | | Artifact | Location | Ownership |
|---|---|---| |---|---|---|
| Bundled venv | `/opt/fenris` | package (tree) | | Bundled runtime packages | `/opt/fenris/vendor` | package (tree) |
| Wrapper | `/usr/bin/fenris` | package | | Wrapper | `/usr/bin/fenris` | package |
| Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries | | 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) | | 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. - **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 (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`. - **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. - **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.
+1 -1
View File
@@ -6,6 +6,6 @@
# The device selector specifies which NVMe drive to monitor. # The device selector specifies which NVMe drive to monitor.
# Uncomment and set exactly one device path: # 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. # See https://git.bongbetic.com/xavierk/Fenris for documentation.
+1 -1
View File
@@ -15,7 +15,7 @@ depends:
- systemd - systemd
contents: contents:
# Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles # Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles
- src: build/stage/ - src: build/stage/
dst: / dst: /
type: tree type: tree
+4 -3
View File
@@ -9,7 +9,8 @@ set -eu
STORE_DIR="/var/lib/fenris" STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db" STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak" 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 case "${1:-}" in
configure) configure)
@@ -18,8 +19,8 @@ case "${1:-}" in
if [ -f "${STORE_DB}" ]; then if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c " PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest from fenris.store import migrate_to_latest
from pathlib import Path from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}')) n = migrate_to_latest(Path('${STORE_DB}'))
+4 -3
View File
@@ -5,7 +5,8 @@ set -eu
STORE_DIR="/var/lib/fenris" STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db" STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak" 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 if [ "$1" -eq 1 ]; then
# Fresh install # Fresh install
@@ -17,8 +18,8 @@ elif [ "$1" -ge 2 ]; then
if [ -f "${STORE_DB}" ]; then if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c " PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest from fenris.store import migrate_to_latest
from pathlib import Path from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}')) n = migrate_to_latest(Path('${STORE_DB}'))
+12 -7
View File
@@ -5,7 +5,7 @@
# #
# VERSION defaults to the version in pyproject.toml. # VERSION defaults to the version in pyproject.toml.
# The staged tree contains: # 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/bin/fenris — unprivileged wrapper
# /usr/libexec/fenris/ — fenris-monitor, fenris-collect # /usr/libexec/fenris/ — fenris-monitor, fenris-collect
# /usr/lib/systemd/system/ — fenris-collect.{timer,service} # /usr/lib/systemd/system/ — fenris-collect.{timer,service}
@@ -35,18 +35,23 @@ rm -rf "${STAGE_DIR}"
mkdir -p "${STAGE_DIR}" mkdir -p "${STAGE_DIR}"
# --- Use pre-built wheel from dist/ --- # --- 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 if [ -z "${WHEEL}" ]; then
echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2 echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2
exit 1 exit 1
fi fi
echo " Using wheel: $(basename "${WHEEL}")" echo " Using wheel: $(basename "${WHEEL}")"
# --- Create venv with --copies and install wheel --- # --- Vendor runtime packages without an interpreter ---
echo " Creating bundled venv ..." # A copied Python binary contains an ABI and build-host dynamic-library path.
python3 -m venv --copies "${STAGE_DIR}/opt/fenris" # It fails after rolling-distribution Python upgrades (for example Tumbleweed
"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1 # 3.12 -> 3.13). Fenris and its locked dependencies are pure Python, so place
"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1 # 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 --- # --- Inject version into wrapper from pyproject.toml ---
# The wrapper has a hardcoded version string; patch it for packaging. # The wrapper has a hardcoded version string; patch it for packaging.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fenris" name = "fenris"
version = "0.3.0" version = "0.3.1"
description = "NVMe wear monitor with persistent TUI" description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
+25 -2
View File
@@ -10,6 +10,29 @@ import argparse
import os import os
import subprocess import subprocess
import sys 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: def is_root() -> bool:
@@ -51,8 +74,8 @@ def cmd_tui(args: argparse.Namespace) -> None:
def cmd_status(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None:
"""Show status.""" """Show status."""
from fenris.status import print_status from fenris.status import render_status
print_status() print(render_status())
def cmd_sample(args: argparse.Namespace) -> None: def cmd_sample(args: argparse.Namespace) -> None:
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI.""" """Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.0" __version__ = "0.3.1"
+9 -4
View File
@@ -15,12 +15,17 @@ import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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") VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists(): if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) vendor_dir = VENV_DIR / "vendor"
if site_packages: if vendor_dir.is_dir():
sys.path.insert(0, str(site_packages)) 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.store import init_store, get_store_path
from fenris.collector import run_collection from fenris.collector import run_collection
+9 -8
View File
@@ -21,12 +21,17 @@ import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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") VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists(): if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) vendor_dir = VENV_DIR / "vendor"
if site_packages: if vendor_dir.is_dir():
sys.path.insert(0, str(site_packages)) 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.store import init_store, get_store_path
from fenris.monitoring_periods import ( 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 - Resume with no open period: opens a new row
""" """
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH) 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) conn = init_store(store_path)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
+15
View File
@@ -52,6 +52,21 @@ class TestIsRoot:
class TestEnableIdempotentMatrix: class TestEnableIdempotentMatrix:
"""§8.6: Period-row idempotent matrix.""" """§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): def test_first_opens_period(self, store_path):
"""First-ever enable opens a period at the enable moment.""" """First-ever enable opens a period at the enable moment."""
# Initialize store # Initialize store
+27 -17
View File
@@ -78,6 +78,7 @@ DEB_TARGETS = [
RPM_TARGETS = [ RPM_TARGETS = [
("fedora:40", "rpm"), ("fedora:40", "rpm"),
("opensuse/tumbleweed", "rpm"),
] ]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS 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: def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
"""Dockerfile for testing rpm installation.""" """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"""\ return textwrap.dedent(f"""\
FROM {image} FROM {image}
RUN dnf install -y --setopt=install_weak_deps=False \ RUN {install_command}
python3 smartmontools systemd dbus && \
dnf clean all
COPY dist/{pkg_name} /pkg/{pkg_name} COPY dist/{pkg_name} /pkg/{pkg_name}
RUN rpm -ivh /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: def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
"""Assert the dormant-install contract (spec §6, §7).""" """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") 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/vendor/fenris/__init__.py")
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc == 0, "Fenris runtime package not found"
assert rc == 0, "Venv Python binary 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 # Wrapper on PATH
rc, out = _container_exec(container, "command -v fenris") 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") 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" 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 # Helpers in /usr/libexec/fenris
for helper in ("fenris-monitor", "fenris-collect"): for helper in ("fenris-monitor", "fenris-collect"):
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}") 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 4. Removal scripts are no-ops during upgrade
5. Downgrade refusal via forward-only version check (tested at Python level) 5. Downgrade refusal via forward-only version check (tested at Python level)
""" """
# Create a fake observation store using system Python # Create a fake observation store using the target system Python.
# (venv Python may not execute if host shared libs differ)
_container_exec( _container_exec(
container, container,
"mkdir -p /var/lib/fenris && " "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) # Modify the config file (simulate hand-edited device selector)
_container_exec( _container_exec(
container, container,
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf", "echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
) )
# Record original config hash # Record original config hash
rc, original_hash = _container_exec( rc, original_hash = _container_exec(
@@ -447,8 +458,8 @@ def _assert_package_files_removed(container: str) -> None:
) )
assert rc != 0, "Helper should be removed" assert rc != 0, "Helper should be removed"
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") rc, _ = _container_exec(container, "test -d /opt/fenris/vendor")
assert rc != 0, "Venv Python should be removed" assert rc != 0, "Vendored runtime packages should be removed"
def _assert_deb_remove_preserves(container: str) -> None: 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 "python3" in out, f"python3 dependency not declared: {out}"
assert "3.10" in out, f"Python 3.10 floor not in dependency: {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 # Verify the version-neutral bundled runtime exists.
# interpreter — that's tested separately by the dormant-install test) rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") assert rc == 0, "Bundled runtime package not found"
assert rc == 0, "Bundled venv Python binary not found"
# fenris on PATH # fenris on PATH
rc, _ = _container_exec(container, "command -v fenris") rc, _ = _container_exec(container, "command -v fenris")