Compare commits

Author SHA1 Message Date
xavierk 482c2ac72a prototype: explore dashboard clarity treatments 2026-09-10 11:03:47 +05:30
xavierk 4a7661d81c chore: bump version to 0.3.3 (missed from #54 fix commit)
Release / release (push) Successful in 55s
2026-09-10 10:01:28 +05:30
xavierk fb683f52ba fix(store): degrade on store permission errors, keep store group-readable (issue #54)
Release / release (push) Successful in 53s
- open_store_readonly(): stat() PermissionError (non-group user on the
  2750 store dir) now maps to StoreFault so status/TUI degrade instead
  of crashing with a traceback.
- init_store(): chmod db + -wal/-shm group rw after WAL setup — SQLite
  WAL readers need write access to sidecars even for mode=ro opens.
- Store dir 2750 → 2770 (tmpfiles + make install) and UMask=002 on the
  collect unit so root-created files stay group-accessible.
- rpm %post upgrade path re-runs systemd-tmpfiles --create to correct
  placement modes on existing machines.
Bump to 0.3.3.
2026-09-10 09:58:24 +05:30
xavierk 512df2ae83 fix(store): default store_path when config omits it (issue #53)
Release / release (push) Successful in 59s
Fresh installs shipped a config template with no store_path key while
collector.py demanded one via get_store_path() — every first collect
crashed with KeyError 'store_path'. Resolve to the packaged default
(/var/lib/fenris/observations.db) when absent, document the key in the
template, and cover the fresh-install path with regression tests.
Bump to 0.3.2.
2026-09-10 09:45:02 +05:30
xavierk bcbc97a947 chore: ignore local build and tooling artifacts
Release / release (push) Successful in 57s
2026-09-10 09:27:44 +05:30
xavierk b593a2742e fix: make RPM runtime portable on Tumbleweed 2026-09-04 11:46:58 +05:30
26 changed files with 410 additions and 80 deletions
+8 -3
View File
@@ -10,6 +10,11 @@ plan-dash-changes.md
# Packaging build artifacts
build/
dist/*.deb
dist/*.rpm
dist/SHA256SUMS*
dist/
# Local tooling
graphify-out/
json
src/fenris.egg-info/
.pytest_cache/
.venv/
+13 -10
View File
@@ -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
+15 -5
View File
@@ -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) |
---
@@ -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 `<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.
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 <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.
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`.
+6 -5
View File
@@ -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_<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).
- **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.
+6 -1
View File
@@ -6,6 +6,11 @@
# 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
#
# The observation store path is optional and defaults to
# /var/lib/fenris/observations.db when unset:
#
# store_path = /var/lib/fenris/observations.db
#
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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}'))
+6 -3
View File
@@ -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}'))
@@ -43,4 +44,6 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
fi
rm -f "${OLD_CONTENT}"
done
# Re-apply placement modes (store dir group access, issue #54)
systemd-tmpfiles --create || true
fi
+12 -7
View File
@@ -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.
+1 -1
View File
@@ -1,2 +1,2 @@
# Type Path Mode User Group Age Argument
d /var/lib/fenris 2750 root fenris - -
d /var/lib/fenris 2770 root fenris - -
+23
View File
@@ -0,0 +1,23 @@
# Fenris dashboard clarity PROTOTYPE (throwaway)
**Question:** How should five fixed dashboard additions look without redesigning Panes information architecture?
Three treatments of existing dense screen, switchable via `?variant=a|b|c`. Active/paused selector exposes visual distinction between quitting TUI and Deliberate disable.
## Run
```sh
./prototype/dashboard-clarity/run
```
Open <http://127.0.0.1:8765/prototype/dashboard-clarity/?variant=a>.
## Variants
- **A — Quiet integration:** low-noise banner, green persistence line, inverse quit key.
- **B — Labelled rails:** explicit labels, left-edge alerts, separated action row.
- **C — Strong state blocks:** highest contrast paused state, service/action blocks.
Use left/right arrows or buttons. Switch state between `active` and `paused`.
Throwaway artifact for [Prototype the dashboard clarity additions](https://git.bongbetic.com/xavierk/Fenris/issues/56). No production code or final wording.
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenris dashboard clarity prototype</title>
<style>
:root{color-scheme:dark;--bg:#111318;--panel:#191c23;--line:#586071;--text:#e7eaf0;--dim:#a1a8b7;--cyan:#71d6e6;--green:#74d99f;--yellow:#f0cb72;--red:#ff7e86}
*{box-sizing:border-box}body{margin:0;background:#090b0f;color:var(--text);font:15px/1.35 ui-monospace,SFMono-Regular,Consolas,monospace}.note{max-width:1120px;margin:18px auto 8px;color:#adb5c5}.terminal{width:min(1120px,calc(100vw - 32px));min-height:650px;margin:0 auto 100px;background:var(--bg);border:1px solid #343a46;box-shadow:0 20px 60px #000;border-radius:8px;overflow:hidden}.header{height:42px;padding:10px 16px;text-align:center;font-weight:800;background:#272c37}.auth{padding:8px 14px}.grid{display:grid;grid-template-columns:3fr 2fr;gap:8px;padding:8px}.box{border:1px solid var(--line);padding:10px 13px;background:var(--panel);min-height:170px}.headline{grid-column:1/-1;min-height:145px}.title{color:var(--dim);margin:-20px 0 8px 3px;background:var(--panel);width:max-content;padding:0 6px}.big{font-size:20px;font-weight:800}.cyan{color:var(--cyan)}.green{color:var(--green)}.yellow{color:var(--yellow)}.red{color:var(--red)}.dim{color:var(--dim)}.service{margin:0 8px 8px;border:1px solid var(--line);padding:8px 12px;background:var(--panel)}.row{display:flex;justify-content:space-between;gap:18px;align-items:center;flex-wrap:wrap}.quit{font-weight:900}.paused{display:none}.terminal[data-state=paused] .paused{display:block}.terminal[data-state=paused] .active-only{display:none}.switcher{position:fixed;z-index:9;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:8px;align-items:center;background:#f4f6fa;color:#15171b;padding:8px 10px;border-radius:999px;box-shadow:0 5px 24px #000}.switcher button{border:0;border-radius:999px;padding:8px 13px;font:700 13px ui-monospace,monospace;cursor:pointer}.switcher .active{background:#212631;color:white}.switcher select{font:700 13px ui-monospace,monospace;padding:7px;border-radius:6px}.variant{display:none}.variant.active{display:block}
/* A: quiet information hierarchy */
#a .auth{background:#17232a;color:#b8dce4;border-bottom:1px solid #31505b}#a .paused{margin:0 8px;padding:11px 14px;background:#392d15;border:2px solid var(--yellow);color:#ffe4a1;font-weight:800}#a .persist{color:var(--green)}#a .quit{background:#e9edf5;color:#111;padding:4px 10px;border-radius:3px}
/* B: explicit labelled rails */
#b .header{text-align:left;border-bottom:3px solid var(--cyan)}#b .auth{margin:8px;border-left:6px solid var(--cyan);background:#19242b}#b .paused{margin:8px;border-left:8px solid var(--yellow);background:#352b18;padding:10px 14px}.rail{display:grid;grid-template-columns:145px 1fr;gap:7px}.rail b{color:var(--dim)}#b .service{border-width:2px}.actionbar{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px}#b .quit{color:#fff;border:2px solid #fff;padding:3px 9px}
/* C: strongest state separation */
#c .header{background:#e8ecf3;color:#111;font-size:16px}#c .auth{text-align:center;background:#22283a;color:#ceddff}#c .paused{margin:0;background:var(--yellow);color:#17130a;padding:12px 18px;font-size:17px;font-weight:900;text-align:center}#c .service{padding:0;border:0;background:transparent;display:grid;grid-template-columns:1fr auto}.statusblock{border:1px solid var(--line);padding:9px 13px;background:var(--panel)}#c .persist{font-weight:900;color:var(--green)}#c .actions{background:#272d39;padding:10px 13px;display:flex;align-items:center}#c .quit{background:var(--red);color:#190608;padding:6px 12px;border-radius:4px}
</style>
</head>
<body>
<p class="note"><b>PROTOTYPE:</b> same Panes dashboard, three treatments. Switch variant/state below. Nothing here ships.</p>
<main id="a" class="terminal variant" data-state="active">
<div class="header">Fenris — NVMe endurance monitor</div>
<div class="auth">ⓘ Privileged actions will prompt for authentication.</div>
<div class="paused">⏸ MONITORING PAUSED · Deliberate disable · paused time excluded from observed usage habit</div>
<div class="grid"><section class="box headline"><div class="title">headline</div><div class="big">Usage-adjusted theoretical lifespan: <span class="cyan">8 yr 103 d remaining</span></div><p>if current habits continue · sustained regime: 46 days</p><b>Projection confidence: <span class="green">Supported</span></b><p class="dim">46 complete days · 98% coverage · stable write rate</p></section><section class="box"><div class="title">usage history</div><b>Usage history · 46 days · 8.1–14.7 GB/day</b><p class="cyan">▂▃▃▄▅▄▃▃▄▅▆▅▄▃▃▂▃▄▅▄</p><p>active 42% · idle 31% · powered off 27%</p></section><section class="box"><div class="title">drive</div><b>Drive health · Samsung SSD 990 PRO</b><p>temperature 41°C · spare 100%<br>media errors 0 · unsafe shutdowns 2</p><b>Settings</b><p>vendor wear: 3% used · 18.4 TB written</p></section></div>
<div class="service"><div>boot: enabled · timer: active · last collect: ok · freshness: fresh</div><div class="row"><span class="persist active-only">monitoring: active in background · persists across reboots</span><span class="paused">monitoring: paused by Deliberate disable</span><span>Bongbetic · p pause · r resume · c collect · d disclosures · <span class="quit">q QUIT TUI</span></span></div></div>
</main>
<main id="b" class="terminal variant" data-state="active">
<div class="header">FENRIS <span class="dim">— NVMe endurance monitor</span></div>
<div class="auth"><b>AUTHENTICATION</b> · Privileged actions will prompt when used (polkit).</div>
<div class="paused"><b>MONITORING PAUSED</b><br>Deliberate disable closed monitoring period; paused time is excluded from observed usage habit.</div>
<div class="grid"><section class="box headline"><div class="title">headline</div><div class="big">Usage-adjusted theoretical lifespan: <span class="cyan">8 yr 103 d remaining</span></div><p>if current habits continue · sustained regime: 46 days</p><div class="rail"><b>CONFIDENCE</b><span class="green">SUPPORTED</span><b>EVIDENCE</b><span>46 complete days · 98% coverage · stable write rate</span></div></section><section class="box"><div class="title">usage history</div><b>46 days · 8.1–14.7 GB/day</b><p class="cyan">▂▃▃▄▅▄▃▃▄▅▆▅▄▃▃▂▃▄▅▄</p><p>active 42%<br>idle 31%<br>powered off 27%</p></section><section class="box"><div class="title">drive</div><b>Samsung SSD 990 PRO</b><p>41°C · spare 100% · errors 0<br>18.4 TB written · wear 3%</p></section></div>
<div class="service"><div class="rail"><b>SERVICE</b><span>boot enabled · timer active · last collect ok · fresh</span><b>CONTINUITY</b><span class="persist active-only">monitoring active in background · persists across reboots</span><span class="paused">monitoring paused by Deliberate disable</span><b>BY</b><span>Bongbetic</span></div><div class="actionbar">p pause · r resume · c collect now · d disclosures <span style="float:right" class="quit">q · QUIT TUI</span></div></div>
</main>
<main id="c" class="terminal variant" data-state="active">
<div class="header">Fenris — NVMe endurance monitor</div>
<div class="auth">Privileged actions → authentication prompt</div>
<div class="paused">⏸ MONITORING PAUSED — DELIBERATE DISABLE</div>
<div class="grid"><section class="box headline"><div class="title">headline</div><div class="big">Usage-adjusted theoretical lifespan<br><span class="cyan">8 yr 103 d remaining</span></div><p>if current habits continue</p><p><span class="green">● SUPPORTED</span> · 46 complete days · 98% coverage · stable write rate</p></section><section class="box"><div class="title">usage history</div><b>46 days · 8.1–14.7 GB/day</b><p class="cyan">▂▃▃▄▅▄▃▃▄▅▆▅▄▃▃▂▃▄▅▄</p><p>active 42% · idle 31%<br>powered off 27%</p></section><section class="box"><div class="title">drive</div><b>Samsung SSD 990 PRO</b><p>41°C · spare 100% · errors 0<br>18.4 TB written · wear 3%</p></section></div>
<div class="service"><div class="statusblock"><b class="active-only">● COLLECTION ACTIVE</b><b class="paused">■ COLLECTION PAUSED</b><br><span class="persist active-only">Runs in background · survives reboots</span><span class="paused">Deliberate disable · paused time excluded</span><br><span class="dim">boot enabled · timer active · last collect ok · fresh · Bongbetic</span></div><div class="actions">p pause · r resume · c collect · d disclosures &nbsp; <span class="quit">q QUIT TUI</span></div></div>
</main>
<nav class="switcher" aria-label="Prototype controls"><button id="prev">←</button><span id="label"></span><button id="next">→</button><select id="state"><option value="active">active</option><option value="paused">paused</option></select></nav>
<script>
const variants=[['a','A · Quiet integration'],['b','B · Labelled rails'],['c','C · Strong state blocks']];
const params=new URLSearchParams(location.search);let i=Math.max(0,variants.findIndex(v=>v[0]===(params.get('variant')||'a')));
function show(){document.querySelectorAll('.variant').forEach(x=>x.classList.remove('active'));const el=document.getElementById(variants[i][0]);el.classList.add('active');el.dataset.state=document.getElementById('state').value;document.getElementById('label').textContent=variants[i][1];params.set('variant',variants[i][0]);history.replaceState(null,'','?'+params)}
function cycle(n){i=(i+n+variants.length)%variants.length;show()}document.getElementById('prev').onclick=()=>cycle(-1);document.getElementById('next').onclick=()=>cycle(1);document.getElementById('state').onchange=show;addEventListener('keydown',e=>{if(e.key==='ArrowLeft')cycle(-1);if(e.key==='ArrowRight')cycle(1)});show();
</script>
</body></html>
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# PROTOTYPE: static local server; no dependencies or persistence.
set -eu
cd "$(git rev-parse --show-toplevel)"
printf '%s\n' 'Open http://127.0.0.1:8765/prototype/dashboard-clarity/?variant=a'
exec python3 -m http.server 8765 --bind 127.0.0.1
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.0"
version = "0.3.3"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
+25 -2
View File
@@ -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:
+1 -1
View File
@@ -1,2 +1,2 @@
"""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 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
+9 -8
View File
@@ -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)
+7 -1
View File
@@ -88,7 +88,13 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
"""
if not store_path.exists():
try:
exists = store_path.exists()
except OSError as e:
# A non-group user stat()ing a 2750 store directory gets
# PermissionError before any StoreFault can be raised (issue #54).
raise StoreFault("observation store not readable: %s" % e)
if not exists:
raise StoreFault("observation store not found at %s" % store_path)
try:
+28 -4
View File
@@ -15,9 +15,19 @@ from typing import Optional
SCHEMA_VERSION = 1
# Packaged default placement (spec §8.3). The config may override it, but a
# fresh install that sets only the device selector must collect cleanly.
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
def get_store_path(config: dict) -> Path:
"""Get the store path from config."""
return Path(config["store_path"])
"""Get the store path from config.
Falls back to the packaged default when the config does not pin one,
so a fresh install whose config holds only the device selector works
instead of crashing with KeyError 'store_path' (issue #53).
"""
return Path(config.get("store_path", DEFAULT_STORE_PATH))
def init_store(store_path: Path) -> sqlite3.Connection:
@@ -27,10 +37,24 @@ def init_store(store_path: Path) -> sqlite3.Connection:
Returns a connection to the store.
"""
conn = sqlite3.connect(str(store_path))
# Enable WAL mode for concurrent reads during writes
conn.execute("PRAGMA journal_mode=WAL")
# Group members (fenris group) read the live store read-only, but SQLite
# in WAL mode needs write access to the db and its -wal/-shm sidecars even
# for readers. Best effort: root-created stores stay group-accessible
# without relying on the creating process's umask (issue #54).
import os as _os
for sidecar in (store_path,
store_path.with_name(store_path.name + "-wal"),
store_path.with_name(store_path.name + "-shm")):
try:
mode = _os.stat(sidecar).st_mode & 0o777
_os.chmod(sidecar, mode | 0o060)
except OSError:
pass
# Check if this is a new database
cursor = conn.execute("PRAGMA user_version")
current_version = cursor.fetchone()[0]
+15
View File
@@ -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
+27 -17
View File
@@ -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")
+56
View File
@@ -0,0 +1,56 @@
"""Store path resolution from config — regression coverage for issue #53.
A fresh install ships a placeholder-commented config whose only required
key is the device selector. The collector must not crash with
KeyError 'store_path' when the key is absent.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, get_store_path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEMPLATE = REPO_ROOT / "packaging" / "fenris.conf"
def _parse_like_load_config(text: str) -> dict:
"""Mirror collect.load_config()'s key=value parsing rules."""
config = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
return config
def test_missing_store_path_falls_back_to_default():
"""Config with only the device selector resolves to the packaged default."""
assert get_store_path({"device": "/dev/nvme0n1"}) == DEFAULT_STORE_PATH
def test_explicit_store_path_wins():
"""An explicit store_path override is honored."""
assert get_store_path({"store_path": "/tmp/other.db"}) == Path("/tmp/other.db")
def test_packaged_template_yields_collectable_config():
"""The packaged template, once a device is set, must be collector-ready.
Reproduces the fresh-install path: parse packaging/fenris.conf the way
collect.load_config() does, add the device selector, then resolve the
store. Issue #53 made this raise KeyError.
"""
config = _parse_like_load_config(TEMPLATE.read_text())
config["device"] = "/dev/nvme0n1"
assert get_store_path(config) == DEFAULT_STORE_PATH
def test_template_documents_store_path():
"""The template must mention store_path so admins know it is overridable."""
assert "store_path" in TEMPLATE.read_text()
+79
View File
@@ -0,0 +1,79 @@
"""Group access to the observation store — regression coverage for issue #54.
Two defects: (1) a non-group user's stat() on the store directory raised
PermissionError straight through open_store_readonly(), crashing status/TUI
instead of degrading to the Store fault view; (2) even group members could
not open the WAL-mode store because root-created sidecars lacked group write
and the store directory lacked group execute-then-write.
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, init_store
from fenris.status import StoreFault, open_store_readonly
def test_stat_permission_error_becomes_store_fault(monkeypatch, tmp_path):
"""stat() denied (non-group user on a 2750 dir) → StoreFault, not crash."""
store = tmp_path / "observations.db"
store.write_bytes(b"")
import pathlib
def denied(self, follow_symlinks=True):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(pathlib.Path, "exists", denied)
with pytest.raises(StoreFault):
open_store_readonly(store)
def test_connect_failure_becomes_store_fault(tmp_path):
"""sqlite failures stay wrapped as StoreFault (existing contract)."""
garbage = tmp_path / "observations.db"
garbage.write_bytes(b"not a database" * 100)
with pytest.raises(StoreFault):
open_store_readonly(garbage)
def test_init_store_leaves_files_group_writable(tmp_path):
"""Root-created stores must stay readable by WAL readers: db and sidecars
need group write after init_store (issue #54)."""
store = tmp_path / "observations.db"
conn = init_store(store)
try:
assert (store.stat().st_mode & 0o060) == 0o060, "db not group rw"
wal = store.with_name(store.name + "-wal")
shm = store.with_name(store.name + "-shm")
if wal.exists():
assert (wal.stat().st_mode & 0o060) == 0o060, "wal not group rw"
if shm.exists():
assert (shm.stat().st_mode & 0o060) == 0o060, "shm not group rw"
finally:
conn.close()
def test_readonly_open_works_after_init_store(tmp_path):
"""The shipped read path opens a store created by init_store."""
store = tmp_path / "observations.db"
writer = init_store(store)
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-01-01T00:00:00+00:00')")
writer.commit()
conn = open_store_readonly(store)
assert conn is not None
conn.close()
writer.close()
def test_packaging_ships_group_access():
"""tmpfiles must create the store dir group-writable; collect unit must
keep the umask loose so root-created sidecars stay group-accessible."""
repo = Path(__file__).resolve().parent.parent
assert "2770" in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "2750" not in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "UMask=002" in (repo / "units" / "fenris-collect.service").read_text()
+1
View File
@@ -7,3 +7,4 @@ After=local-fs.target
Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect
TimeoutStartSec=90
UMask=002