Compare commits

...
29 Commits
Author SHA1 Message Date
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
xavierk bdcd321f4c ci: make release publication idempotent
Release / release (push) Successful in 1m12s
2026-09-03 19:51:55 +05:30
xavierk 25ead13ab9 ci: remove unsupported artifact upload 2026-09-03 19:41:04 +05:30
xavierk be9ce01ebf ci: use Gitea-compatible package token name
Release / release (push) Failing after 1m9s
2026-09-03 19:23:44 +05:30
xavierk 122c9f327e ci: use PAT for package publication 2026-09-03 19:12:47 +05:30
xavierk 460dde4aad ci: verify clearsigned checksum manifest correctly 2026-09-03 19:08:01 +05:30
xavierk ba270d7812 ci: preserve signed RPM for checksum validation 2026-09-03 19:06:05 +05:30
xavierk 2aed923043 ci: install RPM GPG signer dependency 2026-09-03 19:02:03 +05:30
xavierk 230c686e82 signing: publish packaging public key 2026-09-03 17:39:31 +05:30
xavierk 38ecfc2093 ci: validate signatures and use Gitea job token 2026-09-03 17:04:20 +05:30
xavierk f1ba8bdccc ci: add controlled release dispatch 2026-09-03 16:51:21 +05:30
xavierk e794310a76 ci: make Gitea release runner workflow executable 2026-09-03 16:49:54 +05:30
xavierkandCommandCodeBot 1e2ddfb928 fix(packaging): address review findings for #44
- nfpm.yaml: type:config → config_noreplace (RPM noreplace semantics)
- nfpm.yaml: type:ghost → type:dir for /var/lib/fenris (deb compatibility)
- postinst.sh/rpm/post.sh: fix timer restart — capture running unit before
  daemon-reload so the diff actually detects changes
- README: fix Python floor to ≥3.10 (was ≥3.9, inconsistent with Makefile)
- signing-key-ceremony.md: fix stale claim about nfpm signing RPMs
  (actual path is post-build rpmsign)
- tests/conftest.py: extract shared _get_version() and _read() helpers
- tests: wire up to shared conftest helpers
- release.yml: extract VERSION once via GITHUB_OUTPUT step

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 15:25:35 +05:30
xavierkandCommandCodeBot 120d80b28c release: one-command build, sign, publish, and attach — plus dormant workflow (#52)
Implements the full release flow: a single script builds both deb and rpm
packages, signs the RPM payload, generates and clearsigns SHA256SUMS, uploads
to the Gitea package registry (deb to bookworm/jammy/noble pools, rpm to the
fenris group), creates a Gitea release entry with notes, and attaches all
artifacts.

Key changes:
- scripts/release.sh: new release script with --dry-run and --publish modes
- tests/test_release.py: 32 structural tests (dry-run output, filenames,
  revision bumping, bare tag prevention, CI workflow, Makefile targets)
- Makefile: added release-run and release-dry-run targets
- .gitea/workflows/release.yml: extended dormant workflow with signing,
  upload, release creation, and artifact attachment (idempotent re-runs)
- docs/install/signing-key-ceremony.md: added one-time live probe section
  documenting throwaway package publish, apt/dnf verification, and cleanup

Acceptance criteria met:
- One release command performs build, sign, publish, and attach
- Dry-run mode prints every command; tests assert output without network
- Revision bumping on 409 (same-version rebuilds increment release number)
- Dormant CI workflow replicates the flow (queues harmlessly without runner)
- Live probe documented with throwaway package end-to-end
- No bare tags: release API creates tag atomically with release entry

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 14:44:49 +05:30
xavierkandCommandCodeBot d8fa6df072 signing: rpm payload signing, key publication, consumer repo setup for #51
Implement the signing and consumer-repo trust infrastructure:

- Makefile: add generate-test-key, sign-rpm, checksums, clearsign targets;
  make release now automates the full build→sign→checksum→clearsign flow
- Key ceremony: document the import→sign→delete lifecycle, key rotation
  outline, and private-key-in-password-manager policy
- Public key: update placeholder with raw URL, algorithm, and ceremony ref
- Consumer docs: README now covers apt signed-by keyring flow, dnf repo
  file setup, signature verification commands, and migration runbook link
- Release spec: updated to reference ceremony doc and rpmsign workflow
- Tests: 36 structural signing tests (nfpm config, Makefile targets,
  repo file, key publication, ceremony doc, consumer docs, spec refs)
  plus throwaway-key RPM signature and clearsign mechanics; no network
  or real key required

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 14:14:55 +05:30
xavierkandCommandCodeBot c45b07003a docs(migration): add make-install-to-package runbook and no-move continuity tests for #50
Migration runbook at docs/install/migrate-from-makeinstall.md covers the
mandatory remove-then-install path, why over-install is forbidden, no-move
continuity guarantees, and reset-to-dormant expectations.

Acceptance criteria MG-1 through MG-4 added to the install criteria section.

Containerized tests verify no-move continuity: existing group makes sysusers
a no-op, existing store dir makes tmpfiles a no-op, hand-edited config
survives as a non-database file, and store schema is caught up by the
upgrade-path migration. Dead code from a prior merge removed.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 12:56:43 +05:30
xavierkandCommandCodeBot 1873886b2f test(packaging): expand removal semantics tests for #49
Replace the thin test_removal_semantics with comprehensive per-operation
tests covering all five acceptance criteria:

- deb remove keeps config, store (DB + WAL sidecars + backup), and group
- deb purge removes config, store, backup, and group
- rpm erase preserves modified config as .rpmsave
- rpm erase removes unmodified config
- store files never deleted except by purge
- dedicated test: sanctioned disable never runs on upgrade (parametrized
  across all deb + rpm targets)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 11:15:16 +05:30
xavierkandCommandCodeBot c91ca10df7 test(upgrade): add migration unit tests and enhance packaging upgrade tests for #48
Add comprehensive test coverage for upgrade semantics:
- 12 Python unit tests for store migration (forward-only, downgrade
  refusal, idempotent behavior) across migrate_to_latest, init_store,
  and open_store_readonly
- Enhanced packaging upgrade test to verify all five acceptance
  criteria: snapshot before migration, store not rebuilt, config
  survival, timer/removal no-ops during upgrade
- RPM-specific test for config file preservation (noreplace conffile)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:58:38 +05:30
xavierkandCommandCodeBot e9d6881e38 fix(testing): add Python 3.10 floor test and fix Makefile version gate for #47
Add test_python_floor to the containerized packaging matrix:
- Sub-check 1: Ubuntu 22.04 (Python 3.10) installs successfully,
  confirming the floor is met on the oldest supported deb target.
- Sub-check 2: Debian 11 (Python 3.9) fails to configure due to
  unmet python3 (>= 3.10) dependency, verifying clean failure below floor.

Fix Makefile check-python gate to enforce Python >= 3.10, matching the
nfpm depends declaration.

Full compatibility matrix is now green: 17 packaging tests (dormant
install, migration guard, upgrade semantics, removal semantics, and
Python floor) pass across all four targets (Debian 12, Ubuntu 22.04,
Ubuntu 24.04, Fedora 40).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:43:35 +05:30
xavierkandCommandCodeBot b2243a85f7 fix(packaging): add RPM ownership assertions, ghost group fix, and conffile check for #46
- Fix nfpm.yaml ghost directory to include `group: fenris` so RPM metadata
  matches the tmpfiles.d-created ownership (root:fenris 2750)
- Add RPM-native ownership assertions: store dir reported as package-owned
  via `rpm -qf`, store contents verified as never owned by the package
- Add RPM conffile assertion: `rpm -qc` verifies fenris.conf is listed
- Unify store dir stat assertion across both formats (deb and rpm both
  assert mode 2750 root:fenris)
- Remove unused `distro` parameter from `_find_package()`

All 16 packaging tests pass across the full matrix (3 deb + 1 rpm × 4 scenarios).
All 289 non-packaging tests pass.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:18:04 +05:30
xavierkandCommandCodeBot f1e1c0eebc fix(testing): fix containerized packaging tests for #45
- Copy packages to /pkg/ instead of /tmp/ to avoid tmpfs masking in
  docker run --tmpfs /tmp, which hid packages needed at runtime by the
  migration guard and upgrade tests
- Add version faking for deb upgrade test: sed the dpkg status to show
  version 0.2.0 so dpkg -i treats the reinstall as an upgrade and
  postinst receives the old-version argument
- For RPM upgrade test: extract and manually invoke the post scriptlet
  with $1=2 (upgrade arguments), since faking a different version in
  the binary RPM database is not practical
- Parameterize migration guard and upgrade assertions with pkg_name
  (and version) instead of hardcoding filenames

All 16 packaging tests now pass across debian:bookworm, ubuntu:22.04,
ubuntu:24.04, and fedora:40.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 02:59:55 +05:30
xavierkandCommandCodeBot 8fa86c3bf8 fix(packaging): address code review findings
- Upgrade path restarts only fenris-collect.timer, not fenris-collect.service
  (spec §7: "a running oneshot finishes on its old interpreter")
- Remove redundant deb depends override in nfpm.yaml (top-level is sufficient)
- Remove common.sh sourcing — scripts are self-contained to avoid path
  dependency when dpkg/rpm run them from /var/lib/dpkg/info/

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 02:39:34 +05:30
xavierkandCommandCodeBot babc8eeeb1 feat(packaging): nfpm-based deb + rpm build infrastructure (spec §3-7, ADR 0007)
Implement the packaging configuration, staging script, maintainer scripts,
and container test harness for building native deb and rpm packages.

Core files:
- packaging/nfpm.yaml: single source of truth for both formats
- packaging/stage.sh: builds staged tree (venv, wrapper, helpers, units, polkit, sysusers, tmpfiles)
- packaging/fenris.conf: placeholder-commented default configuration
- packaging/postinst.sh, prerm.sh, postrm.sh: POSIX-compatible deb maintainer scripts
- packaging/rpm/post.sh, preun.sh, postun.sh: RPM scriptlets
- packaging/sysusers.d/fenris.conf, tmpfiles.d/fenris.conf: systemd fragments
- packaging/fenris.repo: dnf consumer setup
- packaging/keys/fenris-packaging.asc: public key placeholder

Build targets added to Makefile: stage, package-deb, package-rpm, package, release, clean.
Container test harness in tests/test_packaging.py covering dormant install,
migration guard, upgrade semantics, and removal semantics across the
compatibility matrix (Debian 12, Ubuntu 22.04/24.04, Fedora 40).
Dormant CI workflow at .gitea/workflows/release.yml.

All 289 existing tests pass without regression.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 02:36:08 +05:30
xavierk b005049733 docs: release & packaging spec + ADR 0007 amending 0004 (map #33, task #42)
- docs/spec/release-packaging.md: decision-complete spec — compat matrix,
  Gitea 1.27.1 registry channel, nfpm toolchain, signing/key policy,
  release mechanics, package layout/ownership, maintainer-script
  contracts, initial config, make-install migration runbook.
- docs/adr/0007: package delivery amends ADR 0004 (delivery/ownership
  only; runtime semantics inherited verbatim). 0004 status updated.
- docs/research/: toolchain, gitea-registry, obs findings merged from
  research branches (assets of map tickets #34/#35/#36).
- CONTEXT.md: Release + Rollback glossary terms (ticket #43).
2026-09-03 01:44:37 +05:30
xavierk 2b05267690 feat: Fenris persistent TUI monitoring redesign
Implement the complete redesign per fenris-redesign spec:

- Observation store: SQLite WAL mode, six entities, schema versioning
- Collector: smartctl acquisition, sysfs identity, normalization
- Projection: sustained regime rate, habit change, confidence states
- Panes TUI: Textual keyboard-first layout with four normative regions
- Status CLI: read-only composition with four service facts
- Monitor helper: polkit-guarded toggle, collect, baseline ops
- Legacy migration: idempotent single-transaction import
- Hour classification, day aggregates, monitoring periods
- Pruning, segmentation, drive health facts

Cross-cutting acceptance sweep (CI-1 through CI-4):
- 59 tests covering state matrix, TUI/CLI parity, prohibition set,
  required wording and six disclosures
- Full suite: 289 tests, all green

Issues #20, #32 closed.
2026-09-02 11:48:08 +05:30
xavierk 1b141206b1 feat(install): deliver make install (#30) 2026-09-02 11:04:16 +05:30
xavierk bc9b2f8940 feat: ship fenris-monitor helper, polkit policy, and systemd units (#29) 2026-09-02 10:27:56 +05:30
xavierk a2f7b6232b feat(tui): Panes TUI on Textual (issue #28)
Implements keyboard-first Panes TUI per spec section 7:

- One dense screen: headline band, usage-history, drive-health, service strip

- Bindings p/r/c/d/q with pause-asks/resume-doesnt asymmetry

- Privileged actions via terminal-attached fenris-monitor subprocess

- Disclosures view, empty-store greeting, first-run opt-in

- 35 headless tests: CI-1 state matrix, TUI-1/TUI-4 layout, CI-4, IN-3

Blocker #27 resolved. Closes #28
2026-09-01 23:54:50 +05:30
50 changed files with 7541 additions and 1310 deletions
+187
View File
@@ -0,0 +1,187 @@
# Fenris release workflow — release path on Coolify-hosted Gitea runner.
# The runner is repository-scoped and executes package build, signing, validation,
# registry publication, and release attachment. Spec: §5, issue #52
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
# Built-in Gitea token needs write access for release assets and package registry.
permissions:
contents: read
releases: write
packages: write
jobs:
release:
runs-on: [self-hosted]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y gnupg2 rpm python3-venv
python3 -m venv /tmp/fenris-ci
/tmp/fenris-ci/bin/pip install --quiet build
echo "/tmp/fenris-ci/bin" >> "$GITHUB_PATH"
NFPM_VERSION=2.47.0
curl --fail --silent --show-error --location \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
-o /tmp/nfpm.tar.gz
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
nfpm --version
- name: Build packages
run: make package
- name: Import packaging key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
set -euo pipefail
if [ -z "${GPG_PRIVATE_KEY}" ]; then
echo "::error::GPG_PRIVATE_KEY repository secret is not configured"
exit 1
fi
printf '%s\n' "${GPG_PRIVATE_KEY}" | gpg --batch --import
SECRET_FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' | awk -F: '$1 == "fpr" { print $10; exit }')"
PUBLIC_FINGERPRINT="$(gpg --batch --show-keys --with-colons packaging/keys/fenris-packaging.asc | awk -F: '$1 == "fpr" { print $10; exit }')"
if [ -z "${PUBLIC_FINGERPRINT}" ]; then
echo "::error::packaging/keys/fenris-packaging.asc has no OpenPGP key"
exit 1
fi
if [ "${SECRET_FINGERPRINT}" != "${PUBLIC_FINGERPRINT}" ]; then
echo "::error::packaging public key does not match imported private key"
exit 1
fi
echo "Packaging key fingerprint verified: ${PUBLIC_FINGERPRINT}"
- name: Sign RPM payload
run: make sign-rpm
- name: Generate and clearsign SHA256SUMS
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
cd dist
sha256sum "fenris_${VERSION}_amd64.deb" \
"fenris-${VERSION}-1.x86_64.rpm" > SHA256SUMS
gpg --batch --yes --clearsign --local-user packaging@bongbetic.com SHA256SUMS
- name: Validate signatures and checksums
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
RPM="fenris-${VERSION}-1.x86_64.rpm"
RPM_VERIFY="$(rpm -Kv "dist/${RPM}" 2>&1)"
printf '%s\n' "${RPM_VERIFY}"
printf '%s\n' "${RPM_VERIFY}" | grep -Eiq 'signature.*: *ok'
gpg --batch --verify dist/SHA256SUMS.asc
(cd dist && sha256sum -c SHA256SUMS)
- name: Remove packaging key material
if: always()
run: |
set +e
FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }')"
if [ -n "${FINGERPRINT}" ]; then
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi
- name: Determine version
id: version
run: echo "version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" >> "$GITHUB_OUTPUT"
- name: Upload deb packages to registry
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
DEB="fenris_${VERSION}_amd64.deb"
for CODENAME in bookworm jammy noble; do
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
-T "dist/${DEB}" -o /dev/null -w '%{http_code}' \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload" || true)
case "${STATUS}" in
200|201|204) echo "Debian ${CODENAME}: uploaded" ;;
409) echo "Debian ${CODENAME}: already exists, kept existing package" ;;
*) echo "::error::Debian ${CODENAME} upload failed with HTTP ${STATUS}"; exit 1 ;;
esac
done
- name: Upload RPM to registry
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
RPM="fenris-${VERSION}-1.x86_64.rpm"
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
-T "dist/${RPM}" -o /dev/null -w '%{http_code}' \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload" || true)
case "${STATUS}" in
200|201|204) echo "RPM: uploaded" ;;
409) echo "RPM: already exists, kept existing package" ;;
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
esac
- name: Create Gitea release
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
# Check if release already exists (idempotent re-runs)
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
if [ "$EXISTING" = "200" ]; then
echo "Release v${VERSION} already exists, skipping creation"
else
curl --fail -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
fi
- name: Attach artifacts to release
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
# Get release ID for this tag
RELEASE_JSON=$(curl --fail --silent --show-error \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach deb, rpm, and clearsigned checksums once.
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
"dist/fenris-${VERSION}-1.x86_64.rpm" \
"dist/SHA256SUMS.asc"; do
ASSET_NAME="${FILE##*/}"
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
echo "${ASSET_NAME}: already attached"
else
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
fi
done
+11
View File
@@ -7,3 +7,14 @@ data/fenris.log
data/history.jsonl
data/hourly.jsonl
plan-dash-changes.md
# Packaging build artifacts
build/
dist/
# Local tooling
graphify-out/
json
src/fenris.egg-info/
.pytest_cache/
.venv/
+8
View File
@@ -80,6 +80,14 @@ _Avoid_: Uptime, sample count
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
_Avoid_: Poll, daemon tick
**Release**:
A published version of Fenris: a version tag, its packages in the channel, and its human-readable change notes, all together; a bare tag is not one.
_Avoid_: Tag, upload, build
**Rollback**:
Returning to an earlier release by restoring an observation-store snapshot and then installing that release; installing an older package over a newer store is unsupported.
_Avoid_: Downgrade, version pinning (as a promise)
**Deliberate disable**:
A monitoring pause made through Fenris's own control path, closing the monitoring period so the paused time is excluded from the usage habit.
_Avoid_: Manual stop, service stop
+336
View File
@@ -0,0 +1,336 @@
# Fenris Makefile
# Spec: §10.1-10.6
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
POLKIT_DIR := /usr/share/polkit-1/actions
CONF_DIR := /etc/fenris
DATA_DIR := /var/lib/fenris
# Placement manifest
MANIFEST := $(DATA_DIR)/manifest.txt
# Legacy history path (IN-4)
LEGACY_HISTORY := ./data/history.jsonl
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
help:
@echo "Fenris NVMe endurance monitor"
@echo ""
@echo "Targets:"
@echo " install - Install Fenris (builds wheel, installs to /opt/fenris)"
@echo " upgrade - Upgrade Fenris (reinstall wheel, sync units)"
@echo " uninstall - Uninstall Fenris (preserves config and store)"
@echo " purge - Remove everything including config and store"
@echo " test - Run tests"
@echo " lint - Run linter"
@echo " update-deps - Update dependency pins"
@echo " stage - Stage packaging tree for nfpm"
@echo " package - Build deb + rpm packages"
@echo " package-deb - Build deb package only"
@echo " package-rpm - Build rpm package only"
@echo " generate-test-key - Create throwaway GPG key for CI/testing"
@echo " sign-rpm - Sign RPM payload with packaging key"
@echo " checksums - Generate SHA256SUMS manifest"
@echo " clearsign - Clearsign SHA256SUMS with packaging key"
@echo " release - Full release (build, sign, checksum, print upload steps)"
@echo " release-run - Execute the full release flow via scripts/release.sh"
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
@echo " clean - Remove build artifacts"
# ─── Pre-install gates ──────────────────────────────────────────────────────
check-python:
@echo "=== Verifying Python ≥ 3.10 ==="
@$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,10)) else 1)" || { echo "Error: Python 3.10+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; }
check-smartctl:
@echo "=== Verifying smartctl ==="
@smartctl --version 2>/dev/null | head -1 || { echo "Error: smartctl not found (install smartmontools)"; exit 1; }
# ─── Build ──────────────────────────────────────────────────────────────────
dist/fenris-*.whl: pyproject.toml src/fenris/*.py
@mkdir -p dist
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
# ─── Install ────────────────────────────────────────────────────────────────
install: check-python check-smartctl dist/fenris-*.whl
@echo "=== Creating directories ==="
@sudo mkdir -p $(LIBEXEC_DIR)
@sudo mkdir -p $(CONF_DIR)
@sudo mkdir -p $(POLKIT_DIR)
@echo "=== Creating data directory (root-written, group-read) ==="
@sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
@echo "=== Installing version-neutral runtime packages ==="
@sudo rm -rf $(VENV_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 "=== Installing wrapper ==="
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@echo "=== Installing helpers ==="
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
@sudo install -m 0755 src/fenris/collect.py $(LIBEXEC_DIR)/fenris-collect
@echo "=== Installing systemd units (dormant — not enabled/started) ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
@sudo systemctl daemon-reload
@echo "=== Installing polkit policy ==="
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
@echo "=== Recording manifest (IN-2, IN-10) ==="
@echo "# Fenris placement manifest — do not edit" | sudo tee $(MANIFEST) > /dev/null
@echo "# Generated by: sudo make install" | sudo tee -a $(MANIFEST) > /dev/null
@echo "# Timestamp: $$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(BIN_DIR)/fenris" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(LIBEXEC_DIR)/fenris-monitor" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "=== Install complete ==="
@echo "Units installed but NOT enabled or started (dormant — IN-3)."
@echo "To start monitoring: fenris monitor resume"
@$(MAKE) --no-print-directory import-legacy
# ─── Legacy import (IN-4, ST-7) ────────────────────────────────────────────
import-legacy:
@if [ -f "$(LEGACY_HISTORY)" ]; then \
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
echo "Running idempotent import..."; \
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
# ─── Upgrade ────────────────────────────────────────────────────────────────
upgrade: dist/fenris-*.whl
@echo "=== Upgrading Fenris ==="
@echo "=== Snapshotting database (IN-6) ==="
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
@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)/
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
@sudo install -m 0755 src/fenris/collect.py $(LIBEXEC_DIR)/fenris-collect
@sudo systemctl daemon-reload
@echo "=== Updating manifest ==="
@echo "# Fenris placement manifest — do not edit" | sudo tee $(MANIFEST) > /dev/null
@echo "# Generated by: sudo make upgrade" | sudo tee -a $(MANIFEST) > /dev/null
@echo "# Timestamp: $$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(BIN_DIR)/fenris" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(LIBEXEC_DIR)/fenris-monitor" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
@for unit in fenris-collect.timer fenris-collect.service; do \
TMPFILE=$$(mktemp); \
sudo systemctl cat $$unit > $$TMPFILE 2>/dev/null || true; \
if ! diff -q $$TMPFILE $(UNIT_DIR)/$$unit > /dev/null 2>&1; then \
if systemctl is-active --quiet $$unit; then \
echo " $$unit changed and active — restarting"; \
sudo systemctl restart $$unit; \
fi; \
fi; \
rm -f $$TMPFILE; \
done
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
@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 ==="
# ─── Uninstall (IN-7) ──────────────────────────────────────────────────────
uninstall:
@echo "=== Uninstalling Fenris ==="
@if [ -x $(LIBEXEC_DIR)/fenris-monitor ]; then \
echo "=== Performing sanctioned disable (§10.4) ==="; \
sudo $(LIBEXEC_DIR)/fenris-monitor disable --now || true; \
fi
@echo "=== Stopping units ==="
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
@sudo systemctl daemon-reload
@echo "=== Removing installed files (preserving config and store) ==="
@-rm -f $(BIN_DIR)/fenris
@-rm -f $(LIBEXEC_DIR)/fenris-monitor
@-rm -f $(LIBEXEC_DIR)/fenris-collect
@-rmdir $(LIBEXEC_DIR) 2>/dev/null || true
@-rm -f $(UNIT_DIR)/fenris-collect.timer
@-rm -f $(UNIT_DIR)/fenris-collect.service
@-rm -f $(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy
@sudo rm -rf $(VENV_DIR)
@-sudo rm -f $(MANIFEST)
@echo "=== Uninstall complete ==="
@echo "Config preserved at $(CONF_DIR)"
@echo "Store preserved at $(DATA_DIR)"
# ─── Purge ──────────────────────────────────────────────────────────────────
purge: uninstall
@echo "=== Purging Fenris ==="
@-sudo rm -rf $(CONF_DIR)
@-sudo rm -rf $(DATA_DIR)
@echo "=== Purge complete ==="
# ─── Test ───────────────────────────────────────────────────────────────────
test:
$(PYTHON) -m pytest tests/ -v
# ─── Lint ───────────────────────────────────────────────────────────────────
lint:
$(PYTHON) -m ruff check src/ tests/
# ─── Dependencies (IN-8) ───────────────────────────────────────────────────
update-deps:
$(PYTHON) -m pip compile pyproject.toml -o requirements.txt
# ─── Packaging (spec §3, §4, §5) ────────────────────────────────────────────
# Version is sourced from pyproject.toml for both formats
FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
# GPG signing — packaging key UID (spec §4)
PACKAGING_KEY ?= packaging@bongbetic.com
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
@echo "=== Building deb package ==="
VERSION="$(FENRIS_VERSION)" nfpm pkg -f packaging/nfpm.yaml -p deb -t dist/
@echo "=== deb package built: dist/fenris_$(FENRIS_VERSION)_amd64.deb ==="
package-rpm: stage
@echo "=== Building rpm package ==="
VERSION="$(FENRIS_VERSION)" nfpm pkg -f packaging/nfpm.yaml -p rpm -t dist/
@echo "=== rpm package built: dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm ==="
package: package-deb package-rpm
@echo "=== Both packages built in dist/ ==="
# ─── GPG key management ─────────────────────────────────────────────────────
generate-test-key:
@echo "=== Generating throwaway test GPG key ==="
@echo "This key is for CI/testing only — never use for real releases."
printf '%%no-protection\nKey-Type: RSA\nKey-Length: 3072\nName-Real: Fenris Packaging (TESTING ONLY)\nName-Email: packaging-test@bongbetic.com\nExpire-Date: 0\n%%commit\n' | \
gpg --batch --gen-key
@echo "=== Test key created. Fingerprint: ==="
@gpg --fingerprint packaging-test@bongbetic.com
# ─── Signing ────────────────────────────────────────────────────────────────
sign-rpm: package-rpm
@echo "=== Signing RPM payload ==="
@rpm --import packaging/keys/fenris-packaging.asc 2>/dev/null || true
rpmsign --addsign --define "_gpg_name $(PACKAGING_KEY)" \
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
@echo "=== RPM signed ==="
@rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
checksums: package
@echo "=== Generating SHA256SUMS ==="
cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb \
fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS
@echo "=== SHA256SUMS written ==="
@cat dist/SHA256SUMS
clearsign: checksums
@echo "=== Clearsigning SHA256SUMS ==="
gpg --batch --yes --clearsign --local-user $(PACKAGING_KEY) \
dist/SHA256SUMS
@echo "=== SHA256SUMS.asc written ==="
# ─── Release (spec §5) ──────────────────────────────────────────────────────
release: package sign-rpm clearsign
@echo ""
@echo "=== Release v$(FENRIS_VERSION) ==="
@echo ""
@echo "Artifacts:"
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \
dist/SHA256SUMS.asc 2>/dev/null
@echo ""
@echo "Verify signing (manual):"
@echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
@echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS"
@echo ""
@echo "Upload to registry:"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'"
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'"
@echo ""
@echo "Create Gitea release with notes and attach:"
@echo " dist/fenris_$(FENRIS_VERSION)_amd64.deb"
@echo " dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
@echo " dist/SHA256SUMS.asc"
@echo ""
@echo "Key ceremony: delete the private key after upload."
@echo " See docs/install/signing-key-ceremony.md"
# ─── Automated release flow (issue #52) ──────────────────────────────────────
release-run:
bash scripts/release.sh --publish
release-dry-run:
bash scripts/release.sh --dry-run
clean:
@echo "=== Cleaning build artifacts ==="
rm -rf build/stage dist/fenris-*.deb dist/fenris-*.rpm dist/SHA256SUMS*
+187 -115
View File
@@ -1,157 +1,229 @@
<p align="center">
<picture>
<source srcset="assets/bongbetic-brand/wordmark-light.png" media="(prefers-color-scheme: dark)">
<img src="assets/bongbetic-brand/wordmark-dark.png" alt="Bongbetic" width="260">
</picture>
<br>
<sub>crafted with stubborn curiosity by <a href="https://bongbetic.com">Bongbetic</a></sub>
</p>
# Fenris 🐺
<p align="center">
<img src="assets/bongbetic-brand/b_glyph.svg" width="48" alt="Fenris glyph">
</p>
*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
<h1 align="center">Fenris 🐺 — Your SSD's Tell-All Diary</h1>
<p align="center">
<em>Your NVMe drive has been keeping secrets. Fenris makes it confess — in real time.</em>
<br>
<em>How much did you write today? How long until it taps out? No fairy dust — just your actual bytes.</em>
</p>
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
---
Fenris is a tiny, stubborn daemon that eavesdrops on your NVMe drive's SMART gossip, writes it down every few minutes, and serves you a live dashboard that actually means something. Not "vibes" — **real GB written in the last 24 hours, real GB/hour, and a real countdown in hours, days, and years until your drive's endurance runs out**.
## Requirements
> Think of it as a Fitbit for your SSD. Except it doesn't nag you to drink water.
- **Python ≥ 3.10** (verified at install time)
- **smartmontools** (`smartctl` — verified at install time)
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
## What it actually does (no hand-waving)
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
- **Listens** — polls `smartctl -j` on your NVMe device (default every 5 minutes, you pick).
- **Remembers** — appends every sample to `data/history.jsonl` and rolls up per-hour totals into `data/hourly.jsonl` (survives restarts, rebuilds itself if you yank the power).
- **Calculates** — rolling 24-hour window: *exact* bytes written in the last 24h, GB/h, GB/day, implied total TBW from `percentage_used`, remaining TB, and a projected life-remaining breakdown. Warming-up badge until it has 24h of coverage — no fake confidence.
- **Shows off** — dense, live dashboard with wear-over-time + trailing-24h per-hour bars, sticky header, live countdown, and stale warnings if the daemon dozes off.
## Install from package (recommended)
## You need
### Debian / Ubuntu (apt)
- **Python 3.7+**
- **smartmontools** (`smartctl`)
- Root-ish access to read NVMe SMART (passwordless `smartctl` or just run with `sudo` — your call)
The Gitea instance Debian registry signs metadata with its own key. Verify the
instance key fingerprint (TOFU hardening):
### The sudo dance (one time)
```text
Fingerprint: <print after first release — paste beside the curl one-liner>
```
Fenris runs `sudo -n smartctl ...` so it doesn't get stuck asking for a password mid-nap:
Add the instance key and repository:
```bash
sudo visudo
# add this line (swap in your username):
youruser ALL=(root) NOPASSWD: /usr/sbin/smartctl
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/gitea-xavierk.asc \
https://git.bongbetic.com/api/packages/xavierk/debian/repository.key
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] \
https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update && sudo apt install fenris
```
No sudo? Run the whole thing with `sudo` and it'll still behave.
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
`noble`).
## Get it running — 30 seconds
### Fedora / openSUSE Tumbleweed (RPM)
### The cozy way
Use the Fenris-owned repo file (not Gitea's auto-generated one):
```bash
./fenris.sh
# pick 1) Start monitoring → choose device / interval / port → done
sudo dnf config-manager --add-repo \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
sudo dnf install fenris
```
### The no-nonsense way
On openSUSE Tumbleweed, add the same standard RPM repository file and install
with zypper:
```bash
python3 fenris.py start # defaults: /dev/nvme0, every 300s, port 8420
python3 fenris.py start --interval 60 --port 9000 # if you're impatient
python3 fenris.py status # "are we live? how's the drive?"
python3 fenris.py sample # one sneaky sample right now
python3 fenris.py stop # tuck it back in
sudo zypper addrepo --refresh \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo fenris
sudo zypper install fenris
```
Dashboard lives at **http://localhost:8420** (or whatever port you chose).
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).
## The menu, demystified
### Package signature verification
Run `./fenris.sh` and you'll get:
The RPM payload is signed with the Fenris packaging key (RSA 3072).
Verification happens automatically via dnf's `gpgcheck=1`. For manual
verification of downloaded assets:
```
1) Start monitoring (background daemon + dashboard)
2) Stop monitoring
3) Status / current wear stats
4) Take one sample right now
5) Open dashboard URL
---
h) Help / how this works
q) Exit (go touch grass)
```bash
rpm -Kv fenris-*.x86_64.rpm # RPM payload signature
gpg --verify SHA256SUMS.asc SHA256SUMS # Clearsigned checksum manifest
sha256sum -c SHA256SUMS # Checksum match
```
## What Fenris jots down
The packaging public key is published in-repo — no keyservers. See
`packaging/keys/fenris-packaging.asc` and
`docs/install/signing-key-ceremony.md` for key lifecycle details.
| Field | What's the gossip? |
|-------|---------------------|
| `percentage_used` | The drive's own wear-o-meter (0–100%) |
| `bytes_written` / `bytes_read` | Lifetime totals — the receipts |
| `available_spare` | Spare blocks left (%) |
| `media_errors` | Uncorrectable boo-boos |
| `power_on_hours` | How long it's been awake |
| `temperature_c` | Is it sweating? |
| `critical_warning` | NVMe's panic flags |
### Dormant install
Hourly rollups also stash `bytes_written` per hour, `pct_start`/`pct_end`, and temp peaks — so the 24h math stays honest.
A fresh package install is fully dormant. Units are present but disabled;
nothing runs. The only opt-in is the sanctioned toggle:
## The dashboard — what's on screen
```bash
fenris monitor resume # enable timer + open first monitoring period
fenris monitor pause # close the period, disable timer
```
- **Hero card: Projected life remaining** — big, friendly `361 d 2 h` (plus `≈ 361 days · ≈ 8666 hours · ≈ 0.99 years`), backed by `~280 GB/day` and `~101 TB left of ~202 TB total` on the test box.
- **Data written (24h)** — exact GB in the rolling window + coverage (`10.4h of 24h` until warmed up).
- **Write rate** — GB/h and GB/day, live.
- **Wear, spare, temp, errors, power-on** — the usual suspects, with progress bars and polite color-coding.
- **Two charts, side by side:** wear over time + trailing-24h hourly write bars (with a cheeky "now" bar for the current partial hour).
- **Live plumbing:** polling synced to your interval, ETag-cached, countdown to next sample, warming-up + stale banners, pauses when you hide the tab (saves your battery, you're welcome).
## Development install (make install)
**API for the tinkerers:** `GET /api/data` · `/api/hourly` · `/api/summary` · `/api/config` · `/api/status` — all JSON, all friendly.
For contributors building from source:
```bash
sudo make install
```
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
sudo make upgrade # re-sync wheel, units, schema
make uninstall # removes artifacts, preserves config and store
make purge # also removes /etc/fenris and /var/lib/fenris
```
## Upgrade
### Package upgrade
```bash
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
sudo dnf upgrade fenris # Fedora
```
### Development upgrade
```bash
sudo make upgrade
```
What it does:
1. Snapshots `observations.db` to a one-generation backup (`.bak`).
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).
Rollback: reinstall the previous version and restore `observations.db.bak`.
## Migration from make install
If Fenris was previously installed with `sudo make uninstall` first, then
installed from the package, existing config, store, and group survive by path
continuity. Over-installing the package over a `make install` is
**forbidden** — stale units shadow vendor placement. See
[docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md).
## Uninstall and purge
### Package removal
```bash
sudo apt remove fenris # preserves config and store
sudo apt purge fenris # also removes config and store
sudo dnf remove fenris # preserves config and store
```
### Development removal
```bash
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 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
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
```bash
sudo systemctl edit fenris-collect.timer
# Add:
# [Timer]
# OnUnitInactiveSec=10min
```
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
## CLI reference
| Command | Behavior |
|---|---|
| `fenris` | Opens the TUI (no arguments). |
| `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. |
| `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. |
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. |
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
| `fenris baseline set <json>` | CLI-side validation, then polkit-guarded persistence. |
| `fenris baseline clear` | Remove the endurance baseline. |
| `fenris import <path>` | Idempotent single-transaction legacy import. |
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
| `fenris --device` | Rejected with a pointer to the configuration file. |
## Retired menu options
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
| Legacy option | Successor |
|---|---|
| 1) Start monitoring | `fenris monitor resume` |
| 2) Stop monitoring | `fenris monitor pause` |
| 3) Status / current wear stats | `fenris status` |
| 4) Take one sample right now | `fenris sample` |
| 5) Open dashboard URL | Removed — the HTML dashboard and HTTP server are gone; the TUI is the primary interface. |
## Configuration
`/etc/fenris/fenris.conf` holds exactly one key — the device selector:
```
device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_2TB_S6BENS0Txxxxx
```
Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against. The file is re-read every collection run.
## Where's my stuff?
```
fenris/
├── fenris.py # the whole show — daemon + server + math
├── fenris.sh # the cozy menu
├── README.md # hi — you're here
├── assets/bongbetic-brand/ # Bongbetic wordmarks & glyphs (for Gitea + dashboard)
└── data/
├── history.jsonl # raw samples (JSONL, append-only)
├── hourly.jsonl # per-hour rollups (auto-rebuilt on restart)
├── fenris.pid # daemon PID
└── fenris.log # daemon chatter
```
## CLI cheat sheet
```bash
python3 fenris.py start [--device /dev/nvme0] [--interval 300] [--port 8420]
python3 fenris.py stop
python3 fenris.py status
python3 fenris.py sample [--device /dev/nvme0]
python3 fenris.py run # foreground mode — what `start` spawns internally
```
## Oops — troubleshooting without the tears
**"smartctl not found"**
```bash
sudo apt install smartmontools # Debian/Ubuntu
sudo pacman -S smartmontools # Arch — you already knew
```
**"needs root" / permission denied**
Set up the passwordless line above, or just `sudo ./fenris.sh`.
**Dashboard says "stale"**
Daemon napped or crashed. `python3 fenris.py status` will tell you. Kick it again with `start`.
**Only 10 hours of data and it says "preliminary"?**
That's honesty, not a bug. It needs 24h of real writes to give a tight estimate. Let it simmer — the number gets sharper every hour.
| Artifact | Package install | make install |
|---|---|---|
| Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` |
| Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` |
| Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` |
| Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` |
| 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` |
| Runtime packages | `/opt/fenris/vendor` | `/opt/fenris/vendor` |
| Legacy history | — | `./data/history.jsonl` (auto-imported) |
---
@@ -2,7 +2,7 @@
## Status
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1). Amended by [ADR 0007](0007-package-delivery-amends-0004.md): package delivery replaces `make install` as primary; layout/ownership and maintainer-script mechanics per 0007. Runtime semantics (dormant install, polkit-only elevation, snapshot + forward-only migration, one-generation rollback) unchanged.
## Context
@@ -0,0 +1,34 @@
# 7. Package delivery: native deb + rpm packages, amending the installation lifecycle
## Status
Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://git.bongbetic.com/xavierk/Fenris/issues/42) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/33). This ADR **amends [ADR 0004](0004-install-upgrade-removal-lifecycle.md)** on delivery and file ownership only; every runtime semantic of 0004 — dormant install, polkit-only elevation, observation-store snapshot + forward-only migration, one-generation rollback — is inherited verbatim, restated below where the package delivery changes *who* performs it.
## Context
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.
## Decision
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+ 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 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`.
10. **Migration from make-install systems (new).** Remove-then-install via runbook only — no migration script, no auto-clean. preinst/%pre aborts with a pointer to the runbook if make-install remnants are detected (`/var/lib/fenris/manifest.txt` or `/etc/systemd/system/fenris-collect.timer`). Store and config survive by path continuity; the migration resets the system to dormant and the user opts back in with `fenris monitor resume`.
## Consequences
- Package installs, upgrades, and removals carry dpkg/rpm-native semantics; nothing in Fenris's own tooling duplicates them.
- The manifest was 0004's answer to "what did the installer place"; the package database answers it better, and uninstall-keeps-store now holds by package ownership rather than by manifest discipline.
- `make install` and packages are mutually exclusive per machine; over-install is blocked, not repaired (stale `/etc` units would silently shadow vendor units).
- Hand-edited configuration remains the model: the device selector is a root-edited file in every delivery, keeping the polkit surface at exactly one binary.
- Release mechanics — channel, signing, cadence, rollback documentation — are fixed in the [release and packaging specification](../spec/release-packaging.md) and the tickets it cites; this ADR deliberately stops at lifecycle semantics.
+87
View File
@@ -0,0 +1,87 @@
# Migrating from make-install to packages
This runbook covers the transition from a `sudo make install` system to the native deb or rpm package. Packages are the primary delivery; `make install` remains as the dev fallback. The two deliveries are **mutually exclusive** per machine.
## Why over-install is forbidden
Installing a package over a make-install system silently breaks things:
- **Stale admin units shadow vendor units.** `make install` places `fenris-collect.timer` and `fenris-collect.service` in `/etc/systemd/system/`. The package installs them in `/usr/lib/systemd/system/` (vendor placement). Systemd loads admin units first — the stale copy takes precedence, and the package update never reaches the running system.
- **The local wrapper shadows the package wrapper.** `make install` places the `fenris` wrapper at `/usr/local/bin/fenris`. The package places it at `/usr/bin/fenris`. The shell finds `/usr/local/bin` first on PATH — the old checkout-relative wrapper runs instead of the package wrapper.
Neither condition is reversible by reinstalling the package. The only safe path is remove-then-install.
## Pre-migration checklist
1. Confirm no monitoring period is actively running that you want to preserve across the gap:
```
fenris status
```
The migration resets the system to dormant (see [No-move continuity](#no-move-continuity) below). You opt back in with `fenris monitor resume`.
2. If you have hand-edited configuration at `/etc/fenris/fenris.conf`, note it. The config survives the migration in place (see below).
## Remove step
```
sudo make uninstall
```
This performs the **sanctioned disable** (`fenris-monitor disable --now`), closing the current monitoring period as `user_disabled`. It then removes all make-install artifacts: the venv at `/opt/fenris`, the wrapper at `/usr/local/bin/fenris`, the helpers at `/usr/libexec/fenris/`, the units in `/etc/systemd/system/`, and the polkit policy. The placement manifest at `/var/lib/fenris/manifest.txt` is removed.
**What survives the remove:**
- `/var/lib/fenris/observations.db` (and WAL sidecars, `.bak`) — the observation store
- `/var/lib/fenris/` directory itself — root-written, group-read
- `/etc/fenris/fenris.conf` — your hand-written configuration
- The `fenris` system group — created by `groupadd -f` during make-install
- Journal entries — age out naturally
## Install step
```
sudo apt install fenris # Debian/Ubuntu
sudo dnf install fenris # Fedora
```
The package installs into its own layout without touching the surviving store, config, or group.
## No-move continuity
These invariants are verified by the containerized acceptance tests (issue #50):
| Asset | Make-install state | Package post-install | Mechanism |
|---|---|---|---|
| `fenris` group | Exists (`groupadd -f`) | Unchanged | `systemd-sysusers` is a no-op when the group already exists |
| `/var/lib/fenris` directory | Exists (mode 2750, root:fenris) | Unchanged | `systemd-tmpfiles --create` is a no-op when the directory already exists |
| `observations.db` + sidecars | Present from prior monitoring | Unchanged, never owned by the package | Package owns the directory only; store contents are never ghosted |
| `/etc/fenris/fenris.conf` | Hand-edited device selector | Survives in place; package default lands as `.dpkg-new` / `.rpmnew` | dpkg conffile / rpm `%config(noreplace)` semantics |
| Store schema | Version from prior Fenris release | Caught up by the upgrade-path migration | `postinst` / `%post` runs `migrate_to_latest()` on upgrade |
The package detects the make-install system has been removed by the absence of the two markers:
- `/var/lib/fenris/manifest.txt` (the placement manifest)
- `/etc/systemd/system/fenris-collect.timer` (pre-manifest make installs)
If either marker exists, the package installation aborts with a pointer to this runbook.
## Reset-to-dormant
`make uninstall`'s sanctioned disable closes the open monitoring period as `user_disabled`. After the package install, the system is dormant — the timer is installed but disabled, nothing is running, no monitoring period is open.
To resume monitoring:
```
fenris monitor resume
```
This is the sanctioned opt-in. It enables the timer and opens the first monitoring period in one step. The migration costs at most one short sample gap (the interval between `make uninstall` and `fenris monitor resume`), honestly recorded in the endurance timeline.
## Verification
After migration, confirm the package is correctly installed:
```
fenris status
```
The status command should show the dormant state: timer disabled, no active monitoring period, and the observation store intact from the prior make-install system.
+230
View File
@@ -0,0 +1,230 @@
# Signing key ceremony
The Fenris packaging key signs RPM payloads and clearsigns SHA256SUMS manifests.
This document describes the key's lifecycle: creation, per-release use, rotation,
and destruction.
## Key specification
| Property | Value |
|---|---|
| Algorithm | RSA 3072 |
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
| Expiry | 2 years from creation |
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
| Private key storage | Password manager only |
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
| Keyservers | Never — TOFU-over-TLS via raw URL |
## First release: key creation
```bash
# Generate the dedicated RSA-3072 packaging key
gpg --batch --gen-key <<EOF
%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: Fenris Packaging
Name-Email: packaging@bongbetic.com
Expire-Date: 2y
%commit
EOF
# Export the public half — this file is committed to the repo
gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.asc
# Print the fingerprint for docs and release notes
gpg --fingerprint packaging@bongbetic.com
```
Save the **private key** to the password manager immediately:
```bash
gpg --armor --export-secret-keys packaging@bongbetic.com
```
Then **delete the private key from the local keyring** — it must never persist
on any build host:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
The committed `fenris-packaging.asc` must contain the real public key (replace
the placeholder comments).
## Per-release signing flow
Each release performs: **import → sign → delete**. The private key is never
stored on disk longer than the release takes.
### Step 1: Import the private key
Retrieve the private key from the password manager and import it:
```bash
gpg --import /tmp/packaging-key-private.asc
rm /f /tmp/packaging-key-private.asc # Shred if possible
```
### Step 2: Build and sign packages
The Makefile target `make release` handles signing automatically when the
key is in the keyring:
```bash
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
```
Under the hood:
1. `rpmsign --addsign` signs the RPM payload with the packaging key
(invoked by `make sign-rpm`).
2. `sha256sum` generates the checksum manifest.
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
### Step 3: Delete the private key
Immediately after signing:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
Verify the key is gone:
```bash
gpg --list-keys packaging@bongbetic.com
# Should produce: gpg: keyblock resource ...: No such file or directory
```
The entire import → sign → delete cycle should take minutes. The private key
must never be left in any keyring between releases.
## Key rotation (outline)
When the key approaches expiry, or if it is compromised:
1. **Generate a new key** using the same procedure as first release.
2. **Publish the new public key** alongside the old one in-repo:
```text
packaging/keys/fenris-packaging.asc # new key (primary)
packaging/keys/fenris-packaging-previous.asc # old key (one cycle)
```
3. **Sign the next RPM** with the new key.
4. **Update `fenris.repo`** to list both `gpgkey` URLs (dnf accepts multiple):
```ini
gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging-previous.asc
```
5. **Drop the old key** from the repo after one release cycle. Delete
`fenris-packaging-previous.asc` and revert `gpgkey` to the single URL.
## Verification
Consumers verify the RPM payload signature via dnf (gpgcheck=1 in
`fenris.repo` points at the published public key). The SHA256SUMS manifest
verification is manual for downloaded assets:
```bash
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
## One-time live probe
Before the first real release, verify the full registry path end-to-end with a
throwaway package. This confirms apt/dnf metadata generation, signature
verification, and consumer setup work as a real consumer would experience them.
### Setup
```bash
# Create a throwaway package name to avoid polluting fenris metadata
PROBE_NAME="fenris-regtest"
PROBE_VERSION="0.0.1"
```
### Publish
```bash
# Build a throwaway deb and rpm (use the existing nfpm config with a dummy name)
# Or use a pre-built package — the probe tests the registry path, not the build
# Upload deb to all codename pools
for CODENAME in bookworm jammy noble; do
curl --fail -X PUT \
-u "xavierk:${GITEA_TOKEN}" \
-T "dist/${PROBE_NAME}_${PROBE_VERSION}_amd64.deb" \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload"
done
# Upload rpm
curl --fail -X PUT \
-u "xavierk:${GITEA_TOKEN}" \
-T "dist/${PROBE_NAME}-${PROBE_VERSION}-1.x86_64.rpm" \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload"
```
### Verify apt metadata (Debian/Ubuntu consumer perspective)
```bash
# On a Debian/Ubuntu machine:
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL https://git.bongbetic.com/api/packages/xavierk/debian/repository.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-xavierk.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update
apt show ${PROBE_NAME} # metadata present, correct version
apt install --dry-run ${PROBE_NAME} # dependency resolution works
# Verify InRelease signature
apt-key list 2>/dev/null || gpg --no-default-keyring --keyring /etc/apt/keyrings/gitea-xavierk.asc --list-keys
```
### Verify dnf metadata (Fedora consumer perspective)
```bash
# On a Fedora machine:
sudo dnf config-manager --add-repo https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
# Or use Gitea's auto-generated repo for the probe:
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/fenris.repo
dnf info ${PROBE_NAME} # metadata present, correct version
dnf install --assumeno ${PROBE_NAME} # dependency resolution works
# Verify rpm signature
rpm -q --scripts ${PROBE_NAME} # no scripts (throwaway)
```
### Verify checksums and clearsign
```bash
# Download from release assets or local build
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
### Cleanup
```bash
# Delete the throwaway packages from the registry
for CODENAME in bookworm jammy noble; do
curl --fail -X DELETE \
-u "xavierk:${GITEA_TOKEN}" \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/${PROBE_NAME}/${PROBE_VERSION}/amd64"
done
curl --fail -X DELETE \
-u "xavierk:${GITEA_TOKEN}" \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/${PROBE_NAME}/${PROBE_VERSION}/x86_64"
# Remove test source list on consumer machines
sudo rm /etc/apt/sources.list.d/fenris.list
sudo apt update
```
+239
View File
@@ -0,0 +1,239 @@
# Research: deb + rpm packaging toolchain for bundled-venv builds
Issue: #34 (parent plan: #33) — branch `research/toolchain`
Date: 2026-09-03 · target: Fenris 0.3.0, x86_64, Debian 12 / Ubuntu 22.04+24.04 / Fedora 40+
## TL;DR
**Recommended: nfpm** with a build script that stages a `--copies` venv at
`/opt/fenris`. One `nfpm.yaml` is the single source of truth for both formats;
`nfpm pkg -p deb && nfpm pkg -p rpm` (one invocation per format — `-p` takes a
single string, verified in `internal/cmd/package.go`). Actively maintained
(releases v2.47.0, 2026-06-20; repo pushed 2026-08-31). Runner-up: fpm (active,
v1.18.0 gem 2026-08-26), but its "config" is a long CLI invocation per format —
the single source of truth degrades into a shell script. dh-virtualenv is
deb-only and its last upstream release is 2020-10 (effectively dormant);
rpmbuild spec is rpm-only and cannot share file lists with a deb build without
external generation.
## Constraint evidence: distro textual is unusable (mostly)
| Distro | python3-textual | Source |
|---|---|---|
| Debian 12 (bookworm) | **0.1.13-1** | https://packages.debian.org/bookworm/python3-textual |
| Ubuntu 22.04 (jammy) | **0.1.13-1** | https://packages.ubuntu.com/jammy/python3-textual |
| Ubuntu 24.04 (noble) | **0.1.13-1** | https://packages.ubuntu.com/noble/python3-textual |
| Fedora 40 | 0.48.1 | https://src.fedoraproject.org/rpms/python-textual (f40 spec) |
| Fedora 41 / 42 / 43 | 0.69.0 / 1.0.0 / 4.0.0 | same spec, f41–f43 branches |
Fenris declares `textual>=0.40.0` (pyproject) but pins `textual==8.2.8`
(requirements.txt). Debian 12 + Ubuntu 22.04/24.04 are ~1 major era behind even
the *floor*; Fedora 40 technically meets `>=0.40` but not the pin. Verdict
unchanged: **vendor deps inside the package for all targets**; per-format
`depends:` only on `python3 (>= 3.9)`, `smartmontools`, `systemd`.
## Tool-by-tool
### 1. nfpm (goreleaser) — RECOMMENDED
- **Route:** Makefile target builds staging tree → one `nfpm.yaml` →
`nfpm package -p deb` + `nfpm package -p rpm`. (Goreleaser release pipeline
can wrap both later.)
- **Shared assets:** version, description, maintainer, `depends`,
`contents:` file list, `scripts:` all live once in `nfpm.yaml`; per-format
deltas via `overrides: { deb: ..., rpm: ... }` and `packager:`-scoped
content entries (https://nfpm.goreleaser.com/configuration/, source
`www/content/docs/configuration.md`).
- **Prerequisites:** single static Go binary (`go install
github.com/goreleaser/nfpm/v2/cmd/nfpm@latest`, Homebrew, or release
tarball — https://nfpm.goreleaser.com/install/). No toolchain per distro,
no root, no containers required (build same tree for both formats).
- **Venv → file list:** stage with `python3 -m venv --copies staging/opt/fenris
&& staging/opt/fenris/bin/pip install dist/fenris-*.whl`; map in one entry:
`contents: [{ src: staging/opt/fenris/, dst: /opt/fenris, type: tree }]`.
Shebangs point at fixed absolute `/opt/fenris/bin/python` → no relocation
issues. `--copies` avoids symlink-to-/usr breakage. Config file →
`type: config|noreplace` (=%config(noreplace) on rpm, conffile semantics on
deb). `/var/lib/fenris` store → `type: ghost` (rpm: owned-but-not-packed;
deb: ignored → create in `postinstall` script instead).
- **Systemd/polkit/libexec:** plain `contents:` entries —
`/usr/lib/systemd/system/fenris-collect.{service,timer}` (or
`/etc/systemd/system` to match current Makefile), polkit action at
`/usr/share/polkit-1/actions/`, helpers under `/usr/libexec/fenris/`.
`scripts:` supports `postinstall` (deb maintainer script / rpm scriptlet) —
run `systemctl daemon-reload`, create `/var/lib/fenris` root:fenris 2750,
group creation.
- **Upgrade/removal:** deb — dpkg replaces all non-conffile files, conffile
prompts/preserves (`.dpkg-new`) per Debian Policy ch-files
(https://www.debian.org/doc/debian-policy/ch-files.html); removal keeps
conffiles + unowned store; purge cleans. rpm — `rpm -U` replaces,
`%config(noreplace)` keeps local edits as `.rpmnew`; only owned dirs are
removed on erase (nfpm `type: dir` exists precisely to claim ownership —
docs warn not to claim distro-owned dirs).
- **Maintenance:** very active. goreleaser/nfpm, 2.6k stars, last push
2026-08-31, v2.47.0 released 2026-06-20 (GitHub API).
### 2. fpm — viable, weaker single-source-of-truth
- **Route:** staging tree (same as above) then
`fpm -s dir -t deb ... staging/=/ ; fpm -s dir -t rpm ...`.
- **Shared assets:** none declarative — everything is CLI flags
(`-n`, `-v`, `--config-files`, `--deb-systemd`, `--directories`,
`--after-install`, `--rpm-posttrans`, …). Flag list:
https://fpm.readthedocs.io/en/latest/cli-reference.html. The two
invocations *will* drift unless wrapped in a Makefile that shares variables;
the "single source" is then a shell script, not a checked declarative file.
(`--deb-systemd` exists; no rpm-native unit macro — you hand it the unit
file plus `--rpm-posttrans` for daemon-reload.)
- **Prerequisites:** Ruby + gem (`gem install fpm`) or distro package;
building rpm side needs `rpmbuild` present for some features.
- **Venv → file list:** `-s dir` maps a directory into the package verbatim —
same staging-tree trick as nfpm. `--config-files /etc/fenris` marks
conffiles (deb) / %config (rpm).
- **Upgrade/removal:** identical downstream semantics to nfpm (native dpkg/rpm
behavior); differences are only in how metadata/scripts land in the
package.
- **Maintenance:** active — releases v1.16.0 (2024-12), v1.17.0 (2025-10),
v1.18.0 (2026-08-26); gem 1.18.0 on rubygems; ~11.5k stars. But docs are
openly "work in progress" (https://fpm.readthedocs.io/en/latest/).
### 3. dh-virtualenv (Spotify) — deb-only, dorms
- **Route:** debhelper add-on: `debian/rules` with
`dh $@ --with python-virtualenv --buildsystem=python_distutils`;
produces a .deb containing venv at `/opt/venvs/<package>`
(`DH_VIRTUALENV_INSTALL_ROOT` overridable, `--builtin-venv` for `python -m
venv`). Docs: repo `doc/usage.rst`, `doc/tutorial.rst`
(https://github.com/spotify/dh-virtualenv).
- **Shared assets:** none with rpm — it cannot emit .rpm at all. Would still
need a second toolchain for Fedora → fails the criterion outright.
- **Prerequisites:** `build-essential debhelper devscripts equivs` +
`dh-virtualenv` (tutorial.rst); Debian 12 still ships it as
`dh-virtualenv 1.2.2-1.3` (https://packages.debian.org/bookworm/dh-virtualenv).
- **Venv → file list:** automatic — it builds the venv during the debhelper
sequence and rewrites shebangs; the .deb owns the whole venv tree. Least
manual work of all four, for deb alone.
- **Upgrade/removal:** standard dpkg; whole venv tree is package-owned, so
`apt remove` deletes it cleanly; `--pypi-url`/requirements handled by tool.
- **Maintenance:** last upstream release **1.2.2, 2020-10-22** (GitHub tag);
repo last pushed 2024-04-27, RTD docs 404. Effectively dormant upstream —
fine via Debian's own packaging, but risky as strategic dependency.
- **Bonus fact:** PyPI `dh-virtualenv` project now returns 404 — install only
from Debian repo / git.
### 4. rpmbuild spec + vendored venv — rpm-native, no deb
- **Route:** hand-written `fenris.spec`: `%install` stage builds venv into
`%{buildroot}/opt/fenris`, `%files` lists it plus units/polkit/libexec,
`%ghost %attr(2750,root,fenris) /var/lib/fenris`, `%config(noreplace)` for
`/etc/fenris`, `systemd_post/preun` macros for the timer. Reference style:
https://docs.fedoraproject.org/en-US/packaging-guidelines/.
- **Shared assets:** the spec is a second, parallel description of the same
file list — nothing is shared with any deb build without generating one
side from the other (e.g. generate spec + debian/control from a manifest).
Worst single-source-of-truth score.
- **Prerequisites:** `rpm-build`, mock/koji for cleanroots; Fedora toolchain
knowledge; per-distro `Release:`/dist tag handling.
- **Venv → file list:** `%files` line `%{buildroot}/opt/fenris/...` — venv
becomes ordinary payload; shebangs already absolute.
- **Upgrade/removal:** canonical rpm semantics (same as above) plus real
systemd scriptlet macros — the *best-behaved* rpm integration of the four,
at the cost of hand-maintained spec.
- **Maintenance:** rpmbuild itself is maintained forever (part of RPM), but
*your* spec is 100% hand-maintained duplication.
## Comparison matrix
| Criterion | nfpm | fpm | dh-virtualenv | rpmbuild spec |
|---|---|---|---|---|
| deb + rpm from one config | ✅ one YAML (2 invocations) | ⚠️ flags per invocation | ❌ deb only | ❌ rpm only |
| File list shared across formats | ✅ `contents:` | ⚠️ per-invocation args | n/a | ❌ |
| Vendored venv supported | ✅ staging `type: tree` | ✅ `-s dir` | ✅✅ automatic (deb) | ✅ `%files` |
| conffile / %config(noreplace) | ✅ `type: config\|noreplace` | ✅ `--config-files` | ✅ (debhelper) | ✅ `%config(noreplace)` |
| ghost store dir | ✅ `type: ghost` | ⚠️ `--rpm-ghost`? (no deb equiv) | ❌ | ✅ `%ghost` |
| systemd scriptlets | ✅ `scripts:` + macros? (plain scripts) | ✅ `--deb-systemd`, `--rpm-posttrans` | ✅ (deb) | ✅✅ native macros |
| Prereqs on build host | Go binary (or brew/apt tarball) | Ruby gem | debhelper stack | rpm-build + mock |
| Maintenance (2026) | 🟢 active (v2.47.0) | 🟢 active (v1.18.0) | 🔴 dormant since 2020 (Debian carries it) | 🟢 tool yes / 🔴 your spec |
| Risk | young-ish config schema churn | docs thin | dead upstream | duplication forever |
## Proposed pipeline (sketch)
```make
# Makefile additions (build only — install target stays for source installs)
stage: dist/fenris-*.whl
rm -rf build/stage
python3 -m venv --copies build/stage/opt/fenris
build/stage/opt/fenris/bin/pip install --no-compile dist/fenris-*.whl
install -D -m 0755 scripts/fenris build/stage/usr/bin/fenris
install -D -m 0755 src/fenris/monitor.py build/stage/usr/libexec/fenris/fenris-monitor
install -D -m 0755 src/fenris/collect.py build/stage/usr/libexec/fenris/fenris-collect
install -D -m 0644 units/fenris-collect.timer build/stage/usr/lib/systemd/system/fenris-collect.timer
install -D -m 0644 units/fenris-collect.service build/stage/usr/lib/systemd/system/fenris-collect.service
install -D -m 0644 polkit/com.bongbetic.fenris.monitor.policy \
build/stage/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy
package-deb package-rpm: stage
nfpm pkg -f packaging/nfpm.yaml -p deb -t dist/
nfpm pkg -f packaging/nfpm.yaml -p rpm -t dist/
```
```yaml
# packaging/nfpm.yaml (excerpt)
name: fenris
arch: amd64
platform: linux
version: ${VERSION} # env expansion, documented feature
maintainer: Fenris Maintainers <ops@bongbetic.com>
description: SMART drive observation daemon with persistent TUI
homepage: https://git.bongbetic.com/xavierk/Fenris
depends: [smartmontools]
contents:
- src: build/stage/ # everything above
dst: /
type: tree
- dst: /etc/fenris # config dir; ship fenris.conf as config|noreplace
type: dir
- src: packaging/fenris.conf
dst: /etc/fenris/fenris.conf
type: config|noreplace
- dst: /var/lib/fenris # rpm: %ghost ownership; deb: create in postinst
type: ghost
scripts:
postinstall: packaging/postinst.sh # groupadd fenris; install -d -o root -g fenris -m 2750 /var/lib/fenris; systemctl daemon-reload (units shipped dormant)
preremove: packaging/prerm.sh # stop timer if running
overrides:
deb:
depends: [python3 (>= 3.9), smartmontools]
rpm:
depends: [python3 >= 3.9, smartmontools]
```
## Recommendation
Adopt **nfpm + staged `--copies` venv**: closest to single source of truth
(one YAML for both formats), smallest prerequisite surface (one static binary),
actively maintained, and every Fenris constraint (units, polkit, libexec,
`/etc/fenris` conffile, `/var/lib/fenris` ghost/store) has a first-class
mapping. Keep fpm as documented fallback (identical staging tree, works
anywhere Ruby exists). Do not build the release pipeline on dh-virtualenv
(dormant, deb-only) or on a hand-maintained spec file (duplication, deb side
unaddressed).
## Sources
- nfpm config reference: https://nfpm.goreleaser.com/configuration/ (source:
goreleaser/nfpm `www/content/docs/configuration.md`, accessed 2026-09-03)
- nfpm CLI (single `-p`): goreleaser/nfpm `internal/cmd/package.go`
- nfpm releases/status: GitHub API, repo pushed 2026-08-31, v2.47.0 2026-06-20
- fpm README + CLI reference: https://github.com/jordansissel/fpm,
https://fpm.readthedocs.io/en/latest/cli-reference.html; releases v1.18.0
(2026-08-26), gem 1.18.0
- dh-virtualenv docs: `doc/usage.rst`, `doc/tutorial.rst` @ master; tag 1.2.2
dated 2020-10-22 (GitHub commits API); PyPI project 404;
Debian 12 package 1.2.2-1.3 (packages.debian.org)
- Distro textual versions: packages.debian.org, packages.ubuntu.com,
src.fedoraproject.org `python-textual.spec` f40–f43
- Upgrade semantics: Debian Policy ch-files
(https://www.debian.org/doc/debian-policy/ch-files.html); Fedora packaging
guidelines (https://docs.fedoraproject.org/en-US/packaging-guidelines/);
RPM directive behavior quoted in nfpm config docs (%ghost, %config(noreplace))
+116
View File
@@ -0,0 +1,116 @@
# Research: Gitea 1.27 Debian + RPM package registry feasibility
Issue: [Fenris deb + rpm release plan](https://git.bongbetic.com/xavierk/Fenris/issues/33) →
[Research: Gitea 1.27 Debian + RPM package registry feasibility](https://git.bongbetic.com/xavierk/Fenris/issues/35)
Verified 2026-09-03 against live instance `https://git.bongbetic.com` (reports `1.27.1` via `/api/v1/version`)
and primary sources: docs.gitea.com 1.27 Debian/RPM registry pages and Gitea `v1.27.1` source (go-gitea/gitea tag).
**Verdict: feasible.** Every publish/consume path tested live with throwaway packages `fenris-regtest` (all deleted afterward; package list verified empty).
## 1. Publish paths (verified live, HTTP 201)
### Debian (`.deb`)
```bash
curl --user xavierk:$TOKEN --upload-file fenris_0.3.0_amd64.deb \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/{distribution}/{component}/upload"
```
- `distribution` and `component` are free-form path segments chosen at upload time (e.g. `bookworm/main`, `noble/main`). Gitea derives apt suites from what was uploaded — verified: same .deb published to `pool/bookworm/main` and `pool/noble/main` (both 201), both then served in `dists/bookworm/` and `dists/noble/` with correct `Suite:`/`Codename:` headers.
- Republish of identical name+version+distribution+component+architecture → **409 Conflict** (verified). Must delete first.
### RPM (`.rpm`)
```bash
# no group (flat repo)
curl --user xavierk:$TOKEN --upload-file fenris-0.3.0-1.el9.x86_64.rpm \
"https://git.bongbetic.com/api/packages/xavierk/rpm/upload"
# with group (distro tag, nestable)
curl --user xavierk:$TOKEN --upload-file fenris-0.3.0-1.fc40.x86_64.rpm \
"https://git.bongbetic.com/api/packages/xavierk/rpm/el9/upload" # e.g. el9, rocky/el9, fc40
```
- Group = free-form nesting used to partition repos per distro/track. Verified: publish to root group and `el9` group (both 201), duplicate → 409.
- Owner can be the user (`xavierk`) or an org; packages under a public owner are readable anonymously (verified: metadata fetches without auth succeeded).
## 2. Consumer setup (exact commands)
### apt clients
```bash
sudo mkdir -p /etc/apt/keyrings
sudo curl -o /etc/apt/keyrings/gitea-xavierk.asc \
https://git.bongbetic.com/api/packages/xavierk/debian/repository.key
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/gitea.list # one line per distribution
sudo apt update
apt install fenris # or fenris=0.3.0
# private owner variant: https://{user}:{token}@git.bongbetic.com/api/packages/... in the URL
```
### dnf clients
```bash
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/el9.repo
# private owner: add user:token into the baseurl inside /etc/yum.repos.d/gitea-xavierk-el9.repo afterwards
sudo dnf install fenris # or fenris-0.3.0
```
The served `.repo` (verified live) sets `gpgcheck=1` and points `gpgkey` at `…/rpm/repository.key`, so `dnf` auto-imports on first use.
## 3. Metadata signing: native, not passthrough
Gitea **signs generated metadata itself** with per-instance auto-generated PGP keys. Client-side signing config is limited to trusting the served keys.
- Debian: `dists/{suite}/InRelease` is clearsigned; `Release.gpg` detached sig also served. Key (RSA) fetched from `…/debian/repository.key`, uid literally `(Automatically generated Debian Registry Key; created …)`.
- RPM: `repodata/repomd.xml.asc` detached ASCII-armored signature, uid `(RPM Registry)`. Key from `…/rpm/repository.key`.
- Both verified with `gpg --verify` → **Good signature** (keys are self-generated; the "not certified" warning is expected and handled by the signed-by/keyring flow above).
- The apt `Release` also advertises `Acquire-By-Hash: yes` with MD5/SHA1/SHA256/SHA512 indexes of `Packages`/`.gz`/`.xz` (verified live). RPM repomd carries sha256 checksums for `primary/filelists/other.xml.gz`.
There is **no bring-your-own-signing-key config** for these registries in 1.27 — trust anchor is the instance's auto keys. For Fenris this is acceptable; TOFU over TLS via the key URLs above.
## 4. Multi-distro metadata
- Debian: distributions/suites are implicit — whatever `{distribution}` path segments appear on upload become `dists/{distribution}/` trees with `Suite:`/`Codename:` set to the segment. No server-side list to maintain; adding a new distro = upload with new segment + one more `deb …` sources line. Components likewise (`main`, etc.). Architectures come from each `.deb`'s control stanza (index served as `dists/{dist}/{component}/binary-{arch}/Packages`).
- RPM: same via `{group}` path segments (`el9`, `rocky/el9`, …); each group gets its own `repodata/`. No `basearch` filtering — clients pick the group; Gitea publishes whatever RPM arch was uploaded.
## 5. Version retention
- Default: **all versions retained indefinitely**; nothing auto-deletes. Old versions stay installable (`apt install fenris=0.2.9`, `dnf install fenris-0.2.9`).
- Republishing an existing name+version (deb: same dist/component/arch; rpm: same file name in group) → 409; overwrite requires delete-then-upload.
- Optional cleanup rules exist (per owner + package type): `KeepCount`, `KeepPattern`, `RemoveDays`, `RemovePattern`, `MatchFullName` (source: `models/packages/package_cleanup_rule.go`, executed by scheduled `CleanupTask` in `services/packages/cleanup/cleanup.go`). In 1.27.1 they are configurable **only in the web UI** (owner → Packages → Cleanup Rules); no v1 REST route (verified by route table grep of `routers/api/v1/api.go` — probes of `/api/v1/packages/{owner}/cleanuprules…` return 404/409-style errors).
- Deletes: format-specific `DELETE …/debian/pool/{dist}/{component}/{name}/{version}/{arch}` and `DELETE …/rpm/{group}/package/{name}/{version}/{arch}` (both verified, 204). Deleting last file removes the version. Generic fallback: `DELETE /api/v1/packages/{owner}/{type}/{name}/{version}`.
## 6. Release attachment: not supported
Gitea 1.27.1 has **no package↔release linkage**. Release assets (`…/releases/{id}/assets`) are standalone file uploads; the package model has no release field and no route links them (verified against `v1.27.1` source: `routers/api/v1/repo/release_attachment.go`, `models/packages/`). Options for Fenris releases:
1. Publish `.deb`/`.rpm` to the registry (real apt/dnf install UX) and reference the registry URLs in release notes.
2. Additionally upload tarballs/SHA256SUMS as plain release attachments.
3. Generic registry (`PUT /api/packages/{owner}/generic/{name}/{version}/{filename}`) if an untyped artifact store is needed.
## 7. Caveats for the release plan
- Owner choice matters: publish under an **org** (e.g. `fenris`) if multiple maintainers need write; `xavierk` user owner works today (token owner is admin).
- Metadata access follows owner visibility — public owner → anonymous consumers, no token in URLs (current state, verified). Keep owner public for frictionless installs, or embed `user:token` in sources/baseurl.
- apt distro naming should match OS release names (`bookworm`, `trixie`, `noble`) purely for client convention; server accepts anything.
- RPM groups should mirror `$distver` (e.g. `el9`, `fc40`) so `.repo` selection is obvious per target.
## 8. Test log (live, 2026-09-03)
| Step | Result |
|---|---|
| `PUT debian/pool/bookworm/main/upload` | 201 |
| `PUT debian/pool/noble/main/upload` (multi-dist) | 201 |
| `PUT debian` duplicate | 409 (expected) |
| `PUT rpm/upload` (no group) | 201 |
| `PUT rpm/el9/upload` (group) | 201 |
| `PUT rpm` duplicate | 409 (expected) |
| `GET debian/repository.key` / `rpm/repository.key` | PGP public keys (200) |
| `GET dists/bookworm/{Release,InRelease,Packages}` | correct; `gpg --verify` Good signature |
| `GET rpm{,/el9}/repodata/repomd.xml{,.asc}` | 200; Good signature |
| `GET rpm{,/el9}.repo` | generated repo files with `gpgcheck=1` |
| Cleanup-rules REST probes | 404 (not in v1 API — UI only) |
| `DELETE` all four test entries | 204 ×4; package list then empty |
Sources: [docs.gitea.com 1.27 Debian registry](https://docs.gitea.com/1.27/usage/packages/debian), [docs.gitea.com 1.27 RPM registry](https://docs.gitea.com/1.27/usage/packages/rpm), Gitea source tag `v1.27.1` (`routers/api/v1/api.go`, `models/packages/package_cleanup_rule.go`, `services/packages/cleanup/cleanup.go`), live instance `git.bongbetic.com`.
+73
View File
@@ -0,0 +1,73 @@
# Research: OBS as an alternative build + distribution route
Resolves [Research: OBS as alternative build + distribution route](https://git.bongbetic.com/xavierk/Fenris/issues/36) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/33).
- Date: 2026-09-03
- Verdict: **Reject OBS now; ship via the self-hosted Gitea 1.27.1 registry** (deb + rpm), and revisit OBS only if publishing reach becomes a goal.
## Question
Evaluate openSUSE Open Build Service (OBS) as the build + distribution route — deb build quality, vendoring `textual>=0.40` via source services (offline sandbox), signing, publishing reach, account/maintenance cost, build latency — against the Gitea registry on our matrix: Debian 12, Ubuntu 22.04/24.04, Fedora 40+, x86_64.
## Findings
### 1. deb build support quality — real, with quirks
- OBS builds deb via the classic recipe trio: `debian.control`, `debian.rules`, `PACKAGE.dsc` (OBS User Guide §2.3 "Debian: Dsc"). The build phase runs `dpkg-buildpackage` on Debian-based distributions (§25.1.3 "Package Build"); Debian build environments can alternatively use the `debootstrap` build engine (§"Configuration File Syntax", `BuildEngine`).
- Quirk: release numbers are **not** auto-incremented across rebuilds unless the dsc carries `DEBTRANSFORM-RELEASE` (§2.3) — a packaging decision we'd own either way.
- Upstream build deps are available: `dh-virtualenv` and `dh-python` exist in Debian 12 (packages.debian.org, checked 2026-09-03), so the ADR-0004 venv/lockfile design maps onto an OBS dsc without patching the build root.
- All five matrix targets exist as public OBS build roots: `Debian:12`, `Ubuntu:22.04`, `Ubuntu:24.04`, `Fedora:40`, `Fedora:41` — each project `_meta` answered HTTP 200 on build.opensuse.org (checked 2026-09-03).
- Live proof of deb publishing quality: `isv:ownCloud:desktop/Debian_10` on download.opensuse.org serves a proper Debian archive (`Release`, `Release.gpg`, `InRelease` all HTTP 200, checked 2026-09-03).
### 2. Vendoring textual≥0.40 — the offline sandbox forces the same work we already planned
- The build environment has **no network**: "services requiring external network access are likely to fail in [buildtime] mode, because such access is not available if the build workers are running in secure mode (as is always the case at https://build.opensuse.org)" (User Guide §7.2, "Modes of Source Services"); Dockerfile builds likewise run "in a safe build environment without network access" (§29.3).
- Vendoring must therefore happen **before** the build, via source services that run server-side on commit (`default`/`trylocal` modes, §7.2) or via files committed to the package. The standard services are per-file fetchers — `download_url` (§22.1.3), `download_files`, `obs_scm`/`tar`/`set_version` (§8 SCM integration) — there is no "pip resolve" service, so a pinned dependency tree like Fenris's means either N `download_url` entries mirroring the committed lockfile, or simply committing the vendored wheel/sdist tree.
- Conclusion: OBS does not remove the vendoring step; it reproduces ADR-0004's committed-lockfile design with extra XML. Since distro `python3-textual` is 0.1.13 on Debian 12, Ubuntu 22.04 and 24.04 (packages.debian.org / packages.ubuntu.com, checked 2026-09-03) — far below the `>=0.40` floor — vendoring is unavoidable on any route.
### 3. Signing — OBS key, not ours; Gitea deb repo is our key
- OBS signs published repositories with the **instance's** key: one signer per partition "calls an external tool to execute the signing" (User Guide §23 "OBS Architecture", Signer); consumers accept the OBS repo key ("When prompted, accept the GPG key of the download repository", §1.10). A build.opensuse.org user cannot upload a personal signing key. Trust therefore flows to openSUSE infra, and the signature says nothing about Fenris's maintainers.
- Gitea 1.27.1's Debian registry serves apt metadata signed with the Gitea instance's PGP key (`repository.key` endpoint, `signed-by` in sources.list — docs.gitea.com, "Debian Package Registry"), i.e. **our** host and **our** key. The RPM registry serves a `.repo` endpoint but documents no GPG signing of repodata; rpm-file signing stays our choice at build time.
### 4. Publishing reach — OBS wins reach; reach is not our bottleneck
- OBS publishes home-project results to `https://download.opensuse.org/repositories/home:USER/<dist>` (§1.10) and offers generated download pages on software.opensuse.org (§17.4). That is genuine CDN-class reach.
- Caveats from the same docs: branched projects are **not** published by default (§1.10), and the repo is a live view of the project state — no release artefact pinning; deleting the project or flag disables distribution.
- The Gitea route's reach is exactly `git.bongbetic.com` plus whatever the README says — adequate for a named four-distro matrix whose users follow our instructions, and it keeps the release artefact under versioned control on the same host as the source.
### 5. Account and maintenance cost — strictly additive
- Using build.opensuse.org requires an openSUSE account (single sign-on; the web UI's "Sign up!") and work happens in `home:USERNAME` plus permitted subprojects (§"Setting Up Your Home Project for the First Time"; §23 "OBS Concepts" on home projects).
- Day-to-day: `osc` + `_service` XML + dsc/spec recipes maintained in OBS's own package VCS, kept in sync with Fenris's git. The SCM bridge (`scmsync`) does support self-hosted Gitea ("We also support Self-Hosted instances from GitHub, GitLab and Gitea", §8.1.3; setup in §28.1.2 — build descriptions must live in the repo's top level), but it also disables OBS-side workflows (no `_link` merging, limited workflows, §28.1.1).
- No published quota/SLA for the public instance; capacity and availability are a shared commons. The Gitea route needs zero new accounts, zero new artefact formats beyond the two package recipes we must write anyway, and reuses the existing release host.
### 6. Build latency — shared queue vs. deterministic local
- OBS routes every commit through scheduler → dispatcher → shared workers; the dispatcher "tries to assign jobs fairly between the project repositories" using a per-repository load model (§23, Scheduler/Dispatcher). For our five tiny x86_64 jobs this is typically minutes, but there is no documented SLA and the queue is global — worst case is unbounded (estimate; the docs guarantee only fairness, not latency).
- The Gitea route builds wherever `make` runs and publishes with one authenticated `PUT` per artefact (docs.gitea.com: Debian `PUT .../pool/{distribution}/{component}/upload`; RPM `PUT .../rpm/{group}/upload`). Latency = build time, fully under our control.
## Comparison on the 4-distro matrix
| Axis | OBS (build.opensuse.org) | Gitea 1.27.1 registry |
|---|---|---|
| Debian 12 / Ubuntu 22.04/24.04 deb | dsc + dpkg-buildpackage; DEBTRANSFORM-RELEASE quirk | we build the same deb locally, upload via PUT |
| Fedora 40+ rpm | spec + rpmbuild in Fedora roots | same spec built locally, `.repo` grouping (`fedora/40`) |
| Vendoring textual≥0.40 | offline sandbox forces committed vendored tree (no pip service) | same committed vendored tree (ADR-0004 lockfile) |
| Signing | OBS instance key (not ours) | deb repo signed with our key; rpm repodata unsigned |
| Reach | download.opensuse.org CDN + software.o.o pages | our domain only |
| Accounts/infra | new openSUSE account, osc workflow, commons SLA-free | zero new infra |
| Latency | global shared queue, minutes typical, no SLA | deterministic (local build) |
## Recommendation
**Reject OBS as the build + distribution route for Fenris.** The offline sandbox forces the exact vendoring work the Gitea route already requires, so OBS adds cost (account, osc/source-service maintenance, external commons in the release path, queue latency) without removing any; its one real advantage — CDN and software.o.o reach — does not matter for a hobby project whose four target distros are served by one signed apt repo and one rpm repo on the existing Gitea host, under our own key.
Revisit trigger: if Fenris later wants one-click installs via software.opensuse.org, architectures beyond x86_64, or many more distro targets — the deb publishing quality (verified live) and self-hosted-Gitea SCM bridge make OBS a viable amplifier then.
## Sources
- OBS User Guide (openbuildservice.org/help/manuals/obs-user-guide/, PDF): §2.3 Debian: Dsc; §7 Using Source Services (offline buildtime services, modes); §8.1.3 Supported SCMs; §17.4 download pages; §22.1.3 download_url; §23 OBS Architecture (Scheduler/Dispatcher/Signer); §25.1.3 Package Build; §28.1 SCM bridge; §29.3 Dockerfile builds (no network); §1.10 Installing Packages from OBS; "Configuration File Syntax" (BuildEngine, Repotype: debian).
- Live checks (2026-09-03): `Debian:12`/`Ubuntu:22.04`/`Ubuntu:24.04`/`Fedora:40`/`Fedora:41` project `_meta` on build.opensuse.org (all 200); `isv:ownCloud:desktop/Debian_10` `Release`/`Release.gpg`/`InRelease` on download.opensuse.org (all 200).
- packages.debian.org / packages.ubuntu.com (2026-09-03): `python3-textual` 0.1.13 on bookworm, jammy, noble; `dh-virtualenv`, `dh-python` present in bookworm.
- docs.gitea.com, "Debian Package Registry" and "RPM Package Registry" (1.27 line): apt sources with `signed-by` + `repository.key`, `PUT` upload endpoints, `.repo` groups.
+7
View File
@@ -117,6 +117,13 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write.
- **IN-10** (P) Installed artifacts sit only at their fixed locations — units in `/etc/systemd/system`, helpers in `/usr/libexec/fenris`, polkit policy under `/usr/share/polkit-1/actions/`, configuration at `/etc/fenris`, observation store under `/var/lib/fenris` — and every placed file is recorded in the manifest (ADR 0004 §2; ADR 0003 §4).
## Migration from make-install systems (ADR 0007 §10, spec §9)
- **MG-1** (M) The migration runbook is published in the install docs (`docs/install/migrate-from-makeinstall.md`): mandatory remove-then-install steps, why over-install is forbidden (stale admin-directory units silently shadow vendor units; the local wrapper shadows the package wrapper), no-move continuity, and the reset-to-dormant expectation (the user opts back in with the sanctioned resume).
- **MG-2** (A) The install guard is verified across the matrix: either make-install marker (the legacy placement manifest, or a unit file under the admin unit directory) causes an abort with a runbook pointer — never auto-clean. Tested by `test_migration_guard` on all four targets (Debian 12, Ubuntu 22.04, Ubuntu 24.04, Fedora 40).
- **MG-3** (A) No-move continuity is verified in a container seeded with a make-install-shaped system: existing group makes sysusers a no-op, existing store directory makes tmpfiles a no-op, the hand-written configuration survives as a non-database file (package default lands beside it), and the store schema is caught up by the upgrade-path migration. Tested by `test_no_move_continuity_deb` and `test_no_move_continuity_rpm`.
- **MG-4** (M) The migration costs at most one short sample gap, honestly recorded in the endurance timeline: `make uninstall`'s sanctioned disable closes the open period `user_disabled`; after migration the user opts back in with `fenris monitor resume`.
## Collector acquisition path (ADR 0006)
- **AC-1** (P) Each collection run acquires counters and thermal evidence solely from `smartctl -a -j <device>` and controller identity (`subnqn`, `sn`, `mn`, `fr`, `transport`) solely from sysfs; no other acquisition path exists anywhere in the codebase.
+106
View File
@@ -0,0 +1,106 @@
# Fenris release and packaging specification
**Status: decision-complete.** Assembled by [Task: Compose release spec + ADR amending 0004](https://git.bongbetic.com/xavierk/Fenris/issues/42) from the closed tickets of the Wayfinder map [Fenris deb + rpm release plan](https://git.bongbetic.com/xavierk/Fenris/issues/33). This document is normative for the follow-up **execution effort** that builds and publishes packages; no packages are built here.
**Canonical roles.** [ADR 0007](../adr/0007-package-delivery-amends-0004.md) records the lifecycle rationale (amending [ADR 0004](../adr/0004-install-upgrade-removal-lifecycle.md)); this document restates the **operative contracts** — compat matrix, channel, toolchain, signing, release mechanics, package layout, maintainer-script behavior, migration — so the executing effort never needs Wayfinder-ticket access. Runtime semantics come from [ADRs 0001–0006](../adr/) and the [redesign specification](fenris-redesign.md) verbatim; nothing here overrides them. Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Release* and *Rollback*.
**Binding language.** *Must*, *exactly*, and *never* are normative.
## 1. Compatibility matrix
| Target | Version | Format | Registry placement |
|---|---|---|---|
| Debian 12 (bookworm) | — | deb | `debian/pool/bookworm/main` |
| 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 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.
- 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
- **Channel:** the self-hosted Gitea 1.27.1 package registry at `git.bongbetic.com`, owner public for anonymous consumers ([registry research](../research/gitea-package-registry.md); [OBS rejected](../research/obs-route.md)).
- **Single channel.** No stable/testing split — deferred until external users ask to track pre-release builds (map fog). Every published version is retained indefinitely (registry has no REST cleanup; republishing a filename is a 409).
- **deb publication:** one deb artifact PUT to each codename pool — `PUT /api/packages/{owner}/debian/pool/{bookworm|jammy|noble}/main/upload`.
- **rpm publication:** one rpm artifact PUT to the single `fenris` group — `PUT /api/packages/{owner}/rpm/fenris/upload` — serving Fedora 40+ collectively.
- **Consumer setup (install docs, normative):**
- apt: keyring file from `…/debian/repository.key` via `signed-by`, one sources line per distribution, instance Debian Registry Key **fingerprint printed beside the curl one-liner** (TOFU hardening).
- dnf: `dnf config-manager --add-repo <raw-url of packaging/fenris.repo>` — the in-repo, Fenris-owned `.repo` with `gpgkey` pointing at the published packaging key and `repo_gpgcheck=0`. **Gitea's auto-generated `.repo` is never mentioned in docs**: it sets `gpgcheck=1` against the instance auto-key, which never signed our rpm payload — a trap that breaks installs.
## 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 (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.
## 4. Signing and key policy
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
## 5. Release mechanics
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
- **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
## 6. Package layout and ownership
Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm database is the manifest; no `manifest.txt` ships:
| Artifact | Location | Ownership |
|---|---|---|
| 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) |
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` | package |
| sysusers fragment | `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) | package |
| tmpfiles fragment | `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`) | package |
| Configuration | `/etc/fenris/fenris.conf` | package as conffile / `%config(noreplace)` — placeholder-commented default, no active selector |
| Observation store | `/var/lib/fenris/observations.db` (+ WAL, `.bak`) | **never owned, never ghosted** — the package owns the directory only |
## 7. Maintainer-script contracts
- **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 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.
## 8. Initial configuration
- The device selector is **entered by hand**: root edits `/etc/fenris/fenris.conf` (world-readable, exactly one key per [ADR 0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §3). The shipped default is placeholder-commented and carries no active selector — a fresh install reads as a `configuration error`-free dormant system until the first `fenris monitor resume` + edit, exactly the dormant-install contract.
- No configuration verb is added to `fenris-monitor`; the polkit surface stays at one binary. (This resolves the open item from [Package ownership + ADR 0004 amendment](https://git.bongbetic.com/xavierk/Fenris/issues/40): nothing in any delivery writes the selector — `make install` never wrote `fenris.conf` either; hand-editing has been the model since ADR 0003.)
- On upgrade, local edits survive; a changed package default lands as `.dpkg-new` / `.rpmnew`.
## 9. Migration from make-install systems
- **Runbook only** — no migration script, no auto-clean. Population is author machines plus a few testers; store and config survive by path continuity.
- **Remove-then-install, mandatory:** `sudo make uninstall` (preserves store + `/etc/fenris`) → `apt install fenris` / `dnf install fenris`. **Over-install is forbidden:** stale `/etc/systemd/system/fenris-collect.*` silently shadows vendor units (systemd precedence), `/usr/local/bin/fenris` shadows `/usr/bin/fenris` on PATH.
- **No-move continuity:** `/var/lib/fenris` untouched; existing `fenris` group → sysusers no-op; existing dir → tmpfiles no-op; hand-written `fenris.conf` survives (dpkg ships the default as `.dpkg-new`; rpm as `.rpmnew`); store schema caught up by the upgrade-path migration.
- **Reset-to-dormant:** `make uninstall`'s sanctioned disable closes the open period `user_disabled`; after migration the user opts back in with `fenris monitor resume` — one ≤15-min sample gap, honest against the endurance timeline.
- **Mutual exclusion:** package and `make install` never on the same machine. The dev loop is checkout + `make test`; a dev-local install mode stays in map fog.
## 10. Out of scope
- Building and publishing packages (the follow-up execution effort).
- Snap/Flatpak/AppImage/Homebrew, PyPI as an install path.
- Stable/testing channel split, COPR/PPA fallback, arm64 — map fog until demand appears.
---
*Assembled from [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership + ADR 0004 amendment](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path from make-install systems to packages](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence + stable/testing channel split](https://git.bongbetic.com/xavierk/Fenris/issues/43), [Actions runner availability](https://git.bongbetic.com/xavierk/Fenris/issues/37), and the research tickets [deb + rpm packaging toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/34), [Gitea 1.27 package registry feasibility](https://git.bongbetic.com/xavierk/Fenris/issues/35), [OBS route](https://git.bongbetic.com/xavierk/Fenris/issues/36).*
-1061
View File
File diff suppressed because it is too large Load Diff
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env bash
# Fenris — interactive menu for the NVMe wear monitor & dashboard.
# Created by Bongbetic.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY="$SCRIPT_DIR/fenris.py"
PORT_DEFAULT=8420
INTERVAL_DEFAULT=300
banner() {
cat <<'EOF'
_____ _
| __|___ ___ _| |___
| __| -_| | . | _|
|__| |___|_|_|_|___|_|
NVMe wear monitor & live dashboard
Created by Bongbetic
EOF
}
pause() { read -rp "Press Enter to continue..." _; }
detect_device() {
# Query Python's auto-detect for the default device.
python3 -c "import sys; sys.path.insert(0,'$SCRIPT_DIR'); from fenris import detect_device; print(detect_device())" 2>/dev/null || echo /dev/nvme0
}
menu() {
clear
banner
echo
echo " 1) Start monitoring (background daemon + dashboard)"
echo " 2) Stop monitoring"
echo " 3) Status / current wear stats"
echo " 4) Take one sample right now"
echo " 5) Open dashboard URL"
echo " ---"
echo " h) Help / how this works"
echo " q) Exit"
echo
read -rp "Choose an option: " choice
echo
case "$choice" in
1) start_flow ;;
2) python3 "$PY" stop; pause ;;
3) python3 "$PY" status; pause ;;
4) read -rp "Device [default: auto-detect]: " dev
if [ -z "$dev" ]; then python3 "$PY" sample; else python3 "$PY" sample --device "$dev"; fi
pause ;;
5) show_url; pause ;;
h|H) help_text; pause ;;
q|Q) echo "Bye. — Fenris, by Bongbetic"; exit 0 ;;
*) echo "Invalid choice."; pause ;;
esac
}
start_flow() {
read -rp "NVMe device [Enter = auto-detect]: " dev
read -rp "Sample interval in seconds [Enter = ${INTERVAL_DEFAULT}]: " interval
read -rp "Dashboard port [Enter = ${PORT_DEFAULT}]: " port
interval="${interval:-$INTERVAL_DEFAULT}"
port="${port:-$PORT_DEFAULT}"
args=(start --interval "$interval" --port "$port")
if [ -n "${dev:-}" ]; then args+=(--device "$dev"); fi
echo
echo "Note: reading NVMe SMART data needs root."
echo "Fenris runs 'sudo -n smartctl ...' (no-prompt sudo). If this fails,"
echo "either run this menu with sudo, or allow passwordless smartctl via:"
echo " sudo visudo -> youruser ALL=(root) NOPASSWD: /usr/sbin/smartctl"
echo
python3 "$PY" "${args[@]}"
pause
}
show_url() {
if [ -f "$SCRIPT_DIR/data/fenris.pid" ]; then
# Try to read actual port from running process cmdline, else guess default.
local pid port
pid=$(<"$SCRIPT_DIR/data/fenris.pid")
port=$(tr '\0' '\n' < /proc/"$pid"/cmdline 2>/dev/null | grep -A1 -- '--port' | tail -1 || true)
port="${port:-$PORT_DEFAULT}"
echo "Dashboard: http://localhost:${port}"
else
echo "Fenris is not currently running. Start it first (option 1)."
fi
}
help_text() {
cat <<EOF
What Fenris does:
- Periodically reads your NVMe drive's SMART health data (via smartctl),
including "percentage_used" (the drive's own wear indicator), total
bytes written/read, temperature, spare capacity, and error counts.
- Logs every sample to: $SCRIPT_DIR/data/history.jsonl
and per-hour aggregates to: $SCRIPT_DIR/data/hourly.jsonl (GB/hour,
rebuilt from history on restart). Used for the trailing-24h bar chart
and exact rolling-24h write volume.
- Serves a live HTML dashboard (dense layout, interval-synced polling)
with wear-over-time and trailing-24h hourly-write charts, plus a
projected life-remaining estimate in hours/days/years derived from
your actual rolling-24h write rate and implied TBW endurance.
Requirements:
- smartmontools (smartctl) installed.
- Root access to read NVMe SMART logs — either run Fenris via sudo,
or set up passwordless sudo for smartctl (see option 1).
CLI usage (equivalent to this menu):
python3 fenris.py start [--device /dev/nvme0] [--interval 300] [--port 8420]
python3 fenris.py stop
python3 fenris.py status
python3 fenris.py sample [--device /dev/nvme0]
Leave it running in the background (option 1) and check back after a
few days/weeks of normal use — more samples = a more accurate lifespan
estimate.
Fenris — created by Bongbetic.
EOF
}
# Loop instead of recurse to avoid stack overflow.
while true; do menu; done
+11
View File
@@ -0,0 +1,11 @@
# Fenris configuration
#
# This file is managed by the fenris package. Local edits are preserved
# across upgrades; changed defaults appear as .dpkg-new / .rpmnew.
#
# The device selector specifies which NVMe drive to monitor.
# Uncomment and set exactly one device path:
#
# device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
#
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
+7
View File
@@ -0,0 +1,7 @@
[fenris]
name=Fenris NVMe Monitor
baseurl=https://git.bongbetic.com/api/packages/xavierk/rpm/fenris
enabled=1
gpgcheck=1
gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
repo_gpgcheck=0
+40
View File
@@ -0,0 +1,40 @@
# Fenris Packaging Key
#
# Public half of dedicated RSA-3072 key used to sign RPM payloads and
# clearsign SHA256SUMS manifests.
#
# Fingerprint: CE4542E1E23EB50F09EDFFA5A5E8B22D1872FB07
# Algorithm: RSA 3072
# UID: Fenris Packaging <packaging@bongbetic.com>
# Expiry: 2 years from creation
#
# Raw URL:
# https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
#
# The private half lives in approved secret storage only. Each release uses
# import -> sign -> delete. See docs/install/signing-key-ceremony.md.
#
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGqZYxgBDADEyYQhndEEzoETD17vk8/4x2DoXQm9hxW7hiX3TQNSmXORpgjR
NNt0vV/rTptwjmxgkrlevjrYqiBuoXfKJ0WRC16e9+NRnCGwJX5F4sR7jfgS9XbH
pAbLbySll5LfrD6JcPcB4JsSishKkY6X0zHQD0/zrCaOsuNdLj+fLhWDoxjpLFGy
92U7KHwtt87vSmUM4FgAjUY4keVKqIP5pSWIcPEy7z025RytL1JP6z3jBJR7KKD/
MLXd2KTGGaxTIvzgimcvjQYqFxrT2YIRsmhVYzddRyUnYYWgOh9dp5xn9CRM48Lz
klxHI/jI4lRPCQJy0atBpGZk1bRIc9XsBXRWiR9Zwvpjah/r3whkknBFv7C4srMr
Fl0Ml897zwbCsCP/Ejs43SYt+BJ6B2z4lg6KVsG6lypqV8B+gT+E6rJZ/ML6UpS3
2XQBlWDAw3hf0abjZIOQegi0f5igc7TVyDzYYihLOjKRwZuGSHY40w4mohxESLy8
ogdMveFJKoRZvBsAEQEAAbQqRmVucmlzIFBhY2thZ2luZyA8cGFja2FnaW5nQGJv
bmdiZXRpYy5jb20+iQH0BBMBCABeFiEEzkVC4eI+tQ8J7f+lpeiyLRhy+wcFAmqZ
YxgbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMiwyAxsvBAUJA8JnAAULCQgHAgIi
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCl6LItGHL7B7qZC/4yFm3JwuhXuaJ6JvsH
ZNVCAVOywktFbdcfKJYCXayaVsQ0Yc1w/gW6XhYCr4EECfWplnjtta9zPnN61ODD
B8ZIuM9VUOqxpwvBWJnHcnny1FjmbJ0r0NOwmqKMj54cFHEDbVzmVPoshQSukThj
Uz28XXw/JOkeQQaVl6OF3MoLvhLrLWvnqX310Z151dpl1lEA6gYWd1eKau2oIfU4
e6u1JnX6mKWb0WaaEqo1QARXloQTaKV+NiSUavckTn1LXXMxGCFkbtNWYZv7uf+U
WFB8KuaR3u8if7R8Bab7Y0lzmPCCeSkLHXDLq9FyfDdGOj5LyXxxzlVnTTUyRANh
+JwGiokDqUzh3yUdUYnx6pE6+3tcxP+Gp92K/GZXulmPQYhw0sSyqfnK8GAtUVaD
KAnrV7fKZ9jve87NWeb3G0xfQiH9mNSsEmnQVzd/DCuczOf5fMFfHlUNgkI4t4G7
+hTpKrOOubozZwfB23mdM+H9pxwWFN6To85Iy1ge9JKTFTY=
=V4/R
-----END PGP PUBLIC KEY BLOCK-----
+60
View File
@@ -0,0 +1,60 @@
name: fenris
arch: amd64
platform: linux
version: "${VERSION}"
maintainer: Fenris Maintainers <ops@bongbetic.com>
description: >
NVMe wear monitor with persistent TUI — observes real-world drive use and
translates it into an understandable endurance outlook.
homepage: https://git.bongbetic.com/xavierk/Fenris
license: Proprietary
depends:
- python3 (>= 3.10)
- smartmontools
- systemd
contents:
# Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles
- src: build/stage/
dst: /
type: tree
# Configuration directory
- dst: /etc/fenris
type: dir
file_info:
mode: 0755
# Default placeholder-commented config (deb conffile / rpm %config(noreplace))
- src: packaging/fenris.conf
dst: /etc/fenris/fenris.conf
type: config|noreplace
file_info:
mode: 0644
# Observation store directory — owned by package, never packed.
# Store files (observations.db, WAL sidecars, .bak) are never owned.
- dst: /var/lib/fenris
type: dir
file_info:
mode: 2750
group: fenris
scripts:
preinstall: packaging/preinst.sh
postinstall: packaging/postinst.sh
preremove: packaging/prerm.sh
postremove: packaging/postrm.sh
overrides:
rpm:
depends:
- python3 >= 3.10
- smartmontools
- systemd
scripts:
preinstall: packaging/preinst.sh
postinstall: packaging/rpm/post.sh
preremove: packaging/rpm/preun.sh
postremove: packaging/rpm/postun.sh
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# postinst — deb install and upgrade paths (spec §7).
#
# dpkg calls: postinst configure [most-recently-configured-version]
# fresh install: $1 = "configure", $2 = ""
# upgrade: $1 = "configure", $2 = old version
set -eu
STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
case "${1:-}" in
configure)
if [ -n "${2:-}" ]; then
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
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}'))
print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
;;
abort-upgrade|abort-install|disappear)
;;
esac
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# postrm — deb post-removal (spec §7, §9).
#
# dpkg calls: postrm remove (after package files removed)
# postrm purge (after conffiles and config removed)
set -eu
case "${1:-}" in
purge)
rm -rf /etc/fenris
rm -rf /var/lib/fenris
if getent group fenris > /dev/null 2>&1; then
groupdel fenris 2>/dev/null || true
fi
;;
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
esac
systemctl daemon-reload 2>/dev/null || true
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# preinst — abort if make-install remnants detected (spec §7, §9).
set -eu
MARKER1="/var/lib/fenris/manifest.txt"
MARKER2="/etc/systemd/system/fenris-collect.timer"
if [ -f "${MARKER1}" ] || [ -f "${MARKER2}" ]; then
echo >&2
echo >&2 "Fenris make-install remnants detected — refusing to install."
echo >&2
echo >&2 "Migrate to the package with:"
echo >&2 " sudo make uninstall # removes make-install files, preserves store + config"
echo >&2 " sudo apt install fenris # or: sudo dnf install fenris"
echo >&2
echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems"
echo >&2
exit 1
fi
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# prerm — deb pre-removal (spec §7).
#
# dpkg calls: prerm remove (package being removed)
# prerm upgrade (old version about to be replaced)
set -eu
case "${1:-}" in
remove)
# Sanctioned disable — close monitoring period (spec §7)
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
fi
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
;;
upgrade)
# Never interrupt monitoring on upgrade
;;
esac
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# RPM %post — post-install/upgrade scriptlet (spec §7).
set -eu
STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
if [ "$1" -eq 1 ]; then
# Fresh install
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
elif [ "$1" -ge 2 ]; then
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
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}'))
print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# RPM %postun — post-uninstall scriptlet (spec §7).
set -eu
if [ "$1" -eq 0 ]; then
# Package fully erased — remove config, store, group
rm -rf /etc/fenris
rm -rf /var/lib/fenris
if getent group fenris > /dev/null 2>&1; then
groupdel fenris 2>/dev/null || true
fi
fi
systemctl daemon-reload 2>/dev/null || true
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# RPM %preun — pre-uninstall scriptlet (spec §7).
set -eu
if [ "$1" -eq 0 ]; then
# Package is being erased — sanctioned disable (spec §7)
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
fi
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
fi
# On upgrade ($1 -ge 1): do nothing
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Stage a packaging tree at build/stage/ for nfpm consumption.
#
# Usage: packaging/stage.sh [VERSION]
#
# VERSION defaults to the version in pyproject.toml.
# The staged tree contains:
# /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}
# /usr/share/polkit-1/actions/ — polkit policy
# /usr/lib/sysusers.d/fenris.conf
# /usr/lib/tmpfiles.d/fenris.conf
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE_DIR="${REPO_ROOT}/build/stage"
# --- Resolve version ---
if [ -n "${1:-}" ]; then
VERSION="$1"
else
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "${REPO_ROOT}/pyproject.toml")"
fi
if [ -z "${VERSION}" ]; then
echo "Error: could not determine version" >&2
exit 1
fi
echo "Staging fenris ${VERSION} ..."
# --- Clean previous stage ---
rm -rf "${STAGE_DIR}"
mkdir -p "${STAGE_DIR}"
# --- Use pre-built wheel from dist/ ---
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}")"
# --- 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.
WRAPPER_SRC="${REPO_ROOT}/scripts/fenris"
WRAPPER_DST="${STAGE_DIR}/usr/bin/fenris"
mkdir -p "$(dirname "${WRAPPER_DST}")"
sed "s|version=\"%(prog)s [0-9.]*\"|version=\"%(prog)s ${VERSION}\"|g" \
"${WRAPPER_SRC}" > "${WRAPPER_DST}"
chmod 0755 "${WRAPPER_DST}"
# --- Privileged helpers ---
echo " Installing helpers ..."
mkdir -p "${STAGE_DIR}/usr/libexec/fenris"
install -m 0755 "${REPO_ROOT}/src/fenris/monitor.py" "${STAGE_DIR}/usr/libexec/fenris/fenris-monitor"
install -m 0755 "${REPO_ROOT}/src/fenris/collect.py" "${STAGE_DIR}/usr/libexec/fenris/fenris-collect"
# --- systemd units (vendor placement) ---
echo " Installing systemd units ..."
mkdir -p "${STAGE_DIR}/usr/lib/systemd/system"
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
# --- polkit policy ---
echo " Installing polkit policy ..."
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
install -m 0644 "${REPO_ROOT}/polkit/com.bongbetic.fenris.monitor.policy" \
"${STAGE_DIR}/usr/share/polkit-1/actions/"
# --- sysusers and tmpfiles fragments ---
echo " Installing sysusers/tmpfiles fragments ..."
mkdir -p "${STAGE_DIR}/usr/lib/sysusers.d"
install -m 0644 "${REPO_ROOT}/packaging/sysusers.d/fenris.conf" "${STAGE_DIR}/usr/lib/sysusers.d/"
mkdir -p "${STAGE_DIR}/usr/lib/tmpfiles.d"
install -m 0644 "${REPO_ROOT}/packaging/tmpfiles.d/fenris.conf" "${STAGE_DIR}/usr/lib/tmpfiles.d/"
echo "Stage complete: ${STAGE_DIR}"
+3
View File
@@ -0,0 +1,3 @@
# System user/group for Fenris observation store access
# Created by systemd-sysusers during package install
g fenris -
+2
View File
@@ -0,0 +1,2 @@
# Type Path Mode User Group Age Argument
d /var/lib/fenris 2750 root fenris - -
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>bongbetic</vendor>
<vendor_url>https://bongbetic.com</vendor_url>
<action id="com.bongbetic.fenris.monitor">
<description>Fenris Monitor Helper</description>
<message>Authentication is required to manage Fenris monitoring.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_admin</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.imply">org.freedesktop.systemd1.manage-units</annotate>
</action>
</policyconfig>
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.0"
version = "0.3.1"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
@@ -11,6 +11,7 @@ dependencies = [
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-asyncio>=0.20.0",
]
[tool.pytest.ini_options]
+11
View File
@@ -0,0 +1,11 @@
# Fenris dependency lockfile
# Exact pins for reproducible installs (IN-8)
# Refresh with: make update-deps
textual==8.2.8
rich==15.0.0
markdown-it-py==4.2.0
mdit-py-plugins==0.6.1
mdurl==0.1.2
platformdirs==4.11.7
Pygments==2.21.0
linkify-it-py==2.2.0
Executable
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""fenris: unprivileged entry point for the Fenris TUI and CLI.
With no arguments, opens the TUI.
Subcommands route through fenris-monitor for privileged operations.
Spec: §1.2, §8.4
"""
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:
"""Check if running as root."""
return os.geteuid() == 0
def run_monitor(*args: str) -> None:
"""Run fenris-monitor with the given arguments.
If not root, re-exec under pkexec.
"""
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
if is_root():
result = subprocess.run([monitor_cmd] + list(args))
sys.exit(result.returncode)
else:
# Use pkexec to elevate
pkexec = subprocess.run(
["which", "pkexec"], capture_output=True
)
if pkexec.returncode != 0:
print(
"Error: No polkit agent available. "
"Run as root: sudo fenris-monitor ...",
file=sys.stderr,
)
sys.exit(1)
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
sys.exit(result.returncode)
def cmd_tui(args: argparse.Namespace) -> None:
"""Open the TUI."""
from fenris.tui import run_tui
run_tui()
def cmd_status(args: argparse.Namespace) -> None:
"""Show status."""
from fenris.status import render_status
print(render_status())
def cmd_sample(args: argparse.Namespace) -> None:
"""Trigger on-demand collection."""
run_monitor("collect")
def cmd_monitor_pause(args: argparse.Namespace) -> None:
"""Pause monitoring."""
# Pause asks confirmation (§7.4)
if not args.yes:
response = input("Pause monitoring? [y/N] ")
if response.lower() not in ("y", "yes"):
print("Aborted.")
return
run_monitor("disable", "--now")
def cmd_monitor_resume(args: argparse.Namespace) -> None:
"""Resume monitoring."""
# Resume does not ask confirmation (§7.4)
run_monitor("enable", "--now")
def cmd_baseline_set(args: argparse.Namespace) -> None:
"""Set baseline."""
run_monitor("baseline", "set", args.baseline_json)
def cmd_baseline_clear(args: argparse.Namespace) -> None:
"""Clear baseline."""
run_monitor("baseline", "clear")
def cmd_import(args: argparse.Namespace) -> None:
"""Import legacy history."""
# This is a one-off migration, not a privileged operation
print("Legacy import: use fenris-import directly")
def cmd_migrate(args: argparse.Namespace) -> None:
"""Apply forward-only schema migrations (IN-5, IN-6).
Called by 'sudo make upgrade'. Raises on newer-schema store.
"""
from fenris.store import migrate_to_latest
from pathlib import Path
store_path = Path("/var/lib/fenris/observations.db")
if not store_path.exists():
print("No observation store found — nothing to migrate.")
return
steps = migrate_to_latest(store_path)
if steps:
print(f"Migration complete: {steps} step(s) applied.")
else:
print("Schema already current.")
def main() -> None:
parser = argparse.ArgumentParser(
prog="fenris",
description="Fenris NVMe endurance monitor",
)
parser.add_argument(
"--version", action="version", version="%(prog)s 0.3.0"
)
subparsers = parser.add_subparsers(dest="command")
# Default: TUI (no subcommand)
subparsers.add_parser("tui", help="Open the TUI (default)")
# Status
subparsers.add_parser("status", help="Show status")
# Sample (on-demand collection)
subparsers.add_parser("sample", help="Trigger on-demand collection")
# Monitor subcommand
monitor_parser = subparsers.add_parser("monitor", help="Monitor control")
monitor_sub = monitor_parser.add_subparsers(dest="monitor_action")
# monitor pause
pause_parser = monitor_sub.add_parser("pause", help="Pause monitoring")
pause_parser.add_argument(
"-y", "--yes", action="store_true", help="Skip confirmation"
)
pause_parser.set_defaults(func=cmd_monitor_pause)
# monitor resume
resume_parser = monitor_sub.add_parser("resume", help="Resume monitoring")
resume_parser.set_defaults(func=cmd_monitor_resume)
# Baseline subcommand
baseline_parser = subparsers.add_parser("baseline", help="Baseline operations")
baseline_sub = baseline_parser.add_subparsers(dest="baseline_action")
baseline_set = baseline_sub.add_parser("set", help="Set baseline")
baseline_set.add_argument("baseline_json", help="Baseline JSON data")
baseline_set.set_defaults(func=cmd_baseline_set)
baseline_clear = baseline_sub.add_parser("clear", help="Clear baseline")
baseline_clear.set_defaults(func=cmd_baseline_clear)
# Import
import_parser = subparsers.add_parser("import", help="Import legacy history")
import_parser.add_argument("path", help="Path to history.jsonl")
import_parser.set_defaults(func=cmd_import)
# Migrate (IN-5, IN-6) — called by upgrade, not for human use
migrate_parser = subparsers.add_parser("migrate", help=argparse.SUPPRESS)
migrate_parser.set_defaults(func=cmd_migrate)
# Rejected commands
for cmd in ["start", "stop", "run"]:
reject_parser = subparsers.add_parser(cmd, help=argparse.SUPPRESS)
reject_parser.set_defaults(func=lambda a: print(
f"'{cmd}' is not a valid command. "
f"Use 'fenris monitor resume' instead.",
file=sys.stderr,
))
args = parser.parse_args()
if args.command is None or args.command == "tui":
cmd_tui(args)
elif args.command == "status":
cmd_status(args)
elif args.command == "sample":
cmd_sample(args)
elif args.command == "monitor":
if args.monitor_action is None:
monitor_parser.error("a subcommand is required")
args.func(args)
elif args.command == "baseline":
if args.baseline_action is None:
baseline_parser.error("a subcommand is required")
args.func(args)
elif args.command == "import":
cmd_import(args)
elif args.command == "migrate":
cmd_migrate(args)
if __name__ == "__main__":
main()
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
# Fenris one-command release flow (issue #52).
# Builds both packages, signs, uploads to registry, creates release entry,
# and attaches artifacts — or in dry-run mode, prints every command.
#
# Usage:
# scripts/release.sh --dry-run # Print commands without executing
# scripts/release.sh --publish # Execute the full release flow
#
# Environment:
# GITEA_TOKEN - API token for Gitea registry and release API
# PACKAGING_KEY - GPG key UID (default: packaging@bongbetic.com)
#
# Spec: release-packaging.md §5
# ── Defaults ─────────────────────────────────────────────────────────────
DRY_RUN=false
PUBLISH=false
GITEA_URL="https://git.bongbetic.com"
GITEA_OWNER="xavierk"
GITEA_REPO="Fenris"
PACKAGING_KEY="${PACKAGING_KEY:-packaging@bongbetic.com}"
CODENAMES=(bookworm jammy noble)
RPM_GROUP="fenris"
# ── Parse arguments ──────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--publish) PUBLISH=true ;;
--help|-h)
echo "Usage: $0 [--dry-run | --publish]"
echo ""
echo "Modes:"
echo " --dry-run Print commands without executing (default)"
echo " --publish Execute the full release flow"
echo ""
echo "Environment:"
echo " GITEA_TOKEN API token for Gitea registry and release API"
echo " PACKAGING_KEY GPG key UID (default: packaging@bongbetic.com)"
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--dry-run | --publish]" >&2
exit 1
;;
esac
done
if ! $DRY_RUN && ! $PUBLISH; then
DRY_RUN=true
fi
# ── Helpers ──────────────────────────────────────────────────────────────
_version() {
sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml
}
_deb_name() {
local ver="$1"
echo "fenris_${ver}_amd64.deb"
}
_rpm_name() {
local ver="$1" rel="$2"
echo "fenris-${ver}-${rel}.x86_64.rpm"
}
_run() {
if $DRY_RUN; then
echo " $*"
else
eval "$@"
fi
}
# ── Main ─────────────────────────────────────────────────────────────────
VERSION=$(_version)
REVISION=1
DEB=$(_deb_name "$VERSION")
RPM=$(_rpm_name "$VERSION" "$REVISION")
echo "=== Fenris Release v${VERSION} ==="
echo ""
if $DRY_RUN; then
echo "[dry-run] Commands below will be executed in --publish mode."
echo ""
fi
# ── Step 1: Build both formats ──────────────────────────────────────────
echo "--- Build packages ---"
_run "make package"
echo ""
# ── Step 2: Sign RPM payload ────────────────────────────────────────────
echo "--- Sign RPM payload ---"
_run "rpmsign --addsign --define '_gpg_name ${PACKAGING_KEY}' dist/${RPM}"
echo ""
# ── Step 3: Generate and clearsign SHA256SUMS ────────────────────────────
echo "--- Generate SHA256SUMS ---"
_run "cd dist && sha256sum ${DEB} ${RPM} > SHA256SUMS"
echo ""
echo "--- Clearsign SHA256SUMS ---"
_run "gpg --batch --yes --clearsign --local-user ${PACKAGING_KEY} dist/SHA256SUMS"
echo ""
# ── Step 4: Upload to Gitea package registry ─────────────────────────────
echo "--- Upload packages to registry ---"
for codename in "${CODENAMES[@]}"; do
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${DEB} '${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${codename}/main/upload'"
done
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${RPM} '${GITEA_URL}/api/packages/${GITEA_OWNER}/rpm/${RPM_GROUP}/upload'"
echo ""
# ── Step 5: Create Gitea release with notes ─────────────────────────────
echo "--- Create Gitea release ---"
_release_notes="Release v${VERSION}
## Packages
Install via apt (Debian/Ubuntu):
\`\`\`bash
curl --fail -fsSL https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/keys/fenris-packaging.asc | sudo gpg --dearmor -o /etc/apt/keyrings/fenris.asc
echo \"deb [signed-by=/etc/apt/keyrings/fenris.asc] https://git.bongbetic.com/api/packages/${GITEA_OWNER}/debian bookworm main\" | sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update && sudo apt install fenris
\`\`\`
Install via dnf (Fedora):
\`\`\`bash
sudo dnf config-manager --add-repo https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/fenris.repo
sudo dnf install fenris
\`\`\`
## Verification
\`\`\`bash
rpm -Kv fenris-${VERSION}-1.x86_64.rpm
gpg --verify SHA256SUMS.asc SHA256SUMS
\`\`\`
## Artifacts
- \`dist/${DEB}\` (Debian/Ubuntu)
- \`dist/${RPM}\` (Fedora)
- \`dist/SHA256SUMS.asc\` (clearsigned checksums)
See [docs/install/signing-key-ceremony.md](docs/install/signing-key-ceremony.md) for key ceremony details.
See [docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md) for migration from make install."
if $DRY_RUN; then
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -H 'Content-Type: application/json' -d '{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"...\"}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases'"
else
# Create release via Gitea API (creates the tag atomically — no bare tag)
RELEASE_RESPONSE=$(curl --fail -s -X POST \
-u "${GITEA_OWNER}:${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "v${VERSION}" \
--arg name "v${VERSION}" \
--arg body "$_release_notes" \
'{tag_name: $tag, name: $name, body: $body}')" \
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo " Release created: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
fi
echo ""
# ── Step 6: Attach artifacts to release ──────────────────────────────────
echo "--- Attach artifacts to release ---"
for artifact in "dist/${DEB}" "dist/${RPM}" "dist/SHA256SUMS.asc"; do
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -F 'attachment=@${artifact}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID:-0}/assets'"
done
echo ""
# ── Done ─────────────────────────────────────────────────────────────────
echo "=== Release v${VERSION} complete ==="
echo ""
echo "Summary:"
echo " Packages: ${DEB}, ${RPM}"
echo " Checksums: dist/SHA256SUMS.asc"
echo " Registry: deb → bookworm, jammy, noble; rpm → ${RPM_GROUP}"
echo " Release: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
echo ""
echo "Key ceremony: delete the private key after release."
echo " See docs/install/signing-key-ceremony.md"
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.0"
__version__ = "0.3.1"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""fenris-collect: device interrogation and store writes.
This is the root oneshot unit's ExecStart. It reads the device selector
from /etc/fenris/fenris.conf, interrogates the drive via smartctl and sysfs,
and writes the sample to the observation store.
Spec: §8.4, §8.5
When run as a script, uses the fenris package from the installed wheel.
"""
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
# 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():
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
CONFIG_PATH = Path("/etc/fenris/fenris.conf")
def load_config() -> dict:
"""Load configuration from /etc/fenris/fenris.conf.
The file holds exactly one key: the device selector.
Spec §8.3: re-read every run; no reload path.
"""
if not CONFIG_PATH.exists():
raise RuntimeError(f"Configuration file not found: {CONFIG_PATH}")
config = {}
try:
with open(CONFIG_PATH, "r") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
except Exception as e:
raise RuntimeError(f"Failed to read configuration: {e}")
if "device" not in config:
raise RuntimeError("Configuration error: missing 'device' key")
return config
def interrogate_drive(device: str) -> dict:
"""Interrogate the drive via smartctl.
Returns the smartctl JSON output.
Raises RuntimeError on failure.
"""
result = subprocess.run(
["smartctl", "-a", "-j", device],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"smartctl failed for {device}: {result.stderr}"
)
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse smartctl output: {e}")
def find_nvme_sysfs() -> Path | None:
"""Find the NVMe controller sysfs path."""
nvme_ctrl = Path("/sys/class/nvme")
if not nvme_ctrl.exists():
return None
for ctrl in sorted(nvme_ctrl.iterdir()):
if ctrl.name.startswith("nvme"):
return ctrl
return None
def main() -> None:
"""Run one collection cycle."""
try:
config = load_config()
device = config["device"]
# Interrogate the drive
smartctl_data = interrogate_drive(device)
# Find sysfs path
sysfs_path = find_nvme_sysfs()
if sysfs_path is None:
raise RuntimeError("No NVMe controller found in sysfs")
# Inject a simple clock
class SimpleClock:
def utcnow(self):
return datetime.now(timezone.utc)
clock = SimpleClock()
# Run collection
result = run_collection(smartctl_data, sysfs_path, config, clock)
if result["ok"]:
print(f"Collection successful: {result['sample_count']} sample(s)")
sys.exit(0)
else:
print(f"Collection failed: {result['error']}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Collection error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""fenris-monitor: privileged helper for toggle, collect, and baseline operations.
This binary is the ONLY sanctioned control path for:
- enable/disable (toggle) with monitoring-period bookkeeping
- on-demand collection trigger
- baseline set/clear persistence
Polkit authorizes this binary under com.bongbetic.fenris.monitor (auth_admin).
Spec: §8.4, §8.5, §8.6, §8.7
When run as a script, uses the fenris package from the installed wheel.
"""
import argparse
import json
import os
import subprocess
import sys
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
# 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():
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 (
ensure_period_open,
close_period,
get_open_period,
)
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
def is_root() -> bool:
"""Check if running as root."""
return os.geteuid() == 0
def cmd_enable(args: argparse.Namespace) -> None:
"""Enable monitoring: enable timer + open monitoring period.
Idempotent matrix (§8.6):
- First-ever enable: opens a period at the enable moment
- Resume with open period: no-op (gap stays inside as unknown)
- Resume with no open period: opens a new row
"""
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
conn = init_store(store_path)
now = datetime.now(timezone.utc)
try:
# Open monitoring period if none exists (§8.6)
open_period = get_open_period(conn)
if open_period is None:
ensure_period_open(conn, now)
print("Monitoring period opened at", now.isoformat())
else:
print("Monitoring period already open (id=%d)" % open_period["id"])
# Enable and start the timer
if args.now:
result = subprocess.run(
["systemctl", "enable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "enable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error enabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer enabled" + (" and started" if args.now else ""))
finally:
conn.close()
def cmd_disable(args: argparse.Namespace) -> None:
"""Disable monitoring: disable timer + close monitoring period.
Idempotent matrix (§8.6):
- Pause with open period: closes it user_disabled
- Pause otherwise: no-op
"""
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)
try:
# Close monitoring period if open (§8.6)
open_period = get_open_period(conn)
if open_period is not None:
close_period(conn, now, "user_disabled")
print("Monitoring period closed (id=%d)" % open_period["id"])
else:
print("No open monitoring period (no-op)")
# Disable and stop the timer
if args.now:
result = subprocess.run(
["systemctl", "disable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "disable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error disabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer disabled" + (" and stopped" if args.now else ""))
finally:
conn.close()
def cmd_collect(args: argparse.Namespace) -> None:
"""Trigger on-demand collection.
Starts fenris-collect.service, blocks until exit, reports outcome.
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
which blocks until the oneshot exits; outcome reported synchronously.
"""
result = subprocess.run(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Collection completed successfully")
else:
print("Collection failed:", result.stderr, file=sys.stderr)
sys.exit(1)
def cmd_baseline_set(args: argparse.Namespace) -> None:
"""Persist a baseline row after CLI-side validation.
Spec §8.4: fenris-monitor persists CLI-validated baseline rows.
Spec PR-14: baseline set persists through polkit-guarded helper.
"""
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)
try:
# Parse and validate baseline data
data = json.loads(args.baseline_json)
required_fields = [
"tbw_terabytes",
"source_url",
"document_revision",
"entry_date",
"model_string",
"nominal_capacity_bytes",
]
for field in required_fields:
if field not in data:
print(f"Error: Missing required field: {field}", file=sys.stderr)
sys.exit(1)
# One active row replaced on edit (§6.2)
conn.execute("DELETE FROM endurance_baseline")
conn.execute(
"""
INSERT INTO endurance_baseline (
tbw_terabytes, source_url, document_revision,
entry_date, model_string, nominal_capacity_bytes,
validated_by, verified, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
data["tbw_terabytes"],
data["source_url"],
data["document_revision"],
data["entry_date"],
data["model_string"],
data["nominal_capacity_bytes"],
data.get("validated_by", "user"),
data.get("verified", False),
now.isoformat(),
now.isoformat(),
),
)
conn.commit()
print("Baseline persisted")
finally:
conn.close()
def cmd_baseline_clear(args: argparse.Namespace) -> None:
"""Clear the endurance baseline.
Spec PR-14: baseline clear persists through polkit-guarded helper.
"""
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)
try:
conn.execute("DELETE FROM endurance_baseline")
conn.commit()
print("Baseline cleared")
finally:
conn.close()
def main() -> None:
parser = argparse.ArgumentParser(
prog="fenris-monitor",
description="Fenris privileged helper for toggle, collect, and baseline operations.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# enable/disable
enable_parser = subparsers.add_parser("enable", help="Enable monitoring")
enable_parser.add_argument(
"--now", action="store_true", help="Also start the timer immediately"
)
enable_parser.set_defaults(func=cmd_enable)
disable_parser = subparsers.add_parser("disable", help="Disable monitoring")
disable_parser.add_argument(
"--now", action="store_true", help="Also stop the timer immediately"
)
disable_parser.set_defaults(func=cmd_disable)
# collect
collect_parser = subparsers.add_parser("collect", help="Trigger on-demand collection")
collect_parser.set_defaults(func=cmd_collect)
# baseline
baseline_parser = subparsers.add_parser("baseline", help="Baseline operations")
baseline_sub = baseline_parser.add_subparsers(dest="baseline_action", required=True)
baseline_set = baseline_sub.add_parser("set", help="Persist baseline")
baseline_set.add_argument("baseline_json", help="Baseline JSON data")
baseline_set.set_defaults(func=cmd_baseline_set)
baseline_clear = baseline_sub.add_parser("clear", help="Clear baseline")
baseline_clear.set_defaults(func=cmd_baseline_clear)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+44 -3
View File
@@ -176,12 +176,53 @@ def _create_schema(conn: sqlite3.Connection):
def _apply_migrations(conn: sqlite3.Connection, current_version: int):
"""Apply forward-only migrations from current_version to SCHEMA_VERSION."""
# Future migrations will go here
# For now, just upgrade to current version
"""Apply forward-only migrations from current_version to SCHEMA_VERSION.
Each migration step is a transactional block. Add new steps as sequential
elif branches when SCHEMA_VERSION increases.
Spec: §3.6, §10.2
"""
# Migration 1→2: example placeholder
# if current_version < 2:
# conn.execute("ALTER TABLE ...")
# current_version = 2
pass
def migrate_to_latest(store_path: Path) -> int:
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
Called by the upgrade target (§10.2). Returns the number of migration
steps applied. Raises ValueError on newer-schema store (§3.6, §9.5).
Spec: §3.6, §10.2, §10.3
"""
conn = sqlite3.connect(str(store_path))
conn.execute("PRAGMA journal_mode=WAL")
cursor = conn.execute("PRAGMA user_version")
current_version = cursor.fetchone()[0]
if current_version > SCHEMA_VERSION:
conn.close()
raise ValueError(
f"Observation store written by a newer Fenris (version {current_version}) "
f"— upgrade Fenris"
)
if current_version == SCHEMA_VERSION:
conn.close()
return 0 # Already up to date
steps = SCHEMA_VERSION - current_version
_apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
conn.close()
return steps
def is_store_faulty(store_path: Path) -> bool:
"""Check if the store is present but cannot be read or trusted."""
if not store_path.exists():
+574
View File
@@ -0,0 +1,574 @@
"""Panes TUI: keyboard-first Textual app (spec §7, TUI-1, TUI-4).
One dense screen, four normative regions:
1. Headline band (full width, top)
2. Usage-history pane (left, wider)
3. Drive-health + settings pane (right, narrower)
4. Service strip (full width, bottom)
Bindings: p (pause, asks), r (resume), c (collect now), d (disclosures), q (quit).
Privileged actions route through fenris-monitor as terminal-attached subprocesses
(LC-6, LC-8). The TUI never samples in-process.
Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
"""
from __future__ import annotations
import sqlite3
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Static
from .projection import (
ConfidenceState,
ProjectionResult,
ScenarioRange,
compute_projection,
)
from .status import (
CADENCE_DEFAULT_S,
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
ConfigError,
NewerSchema,
StoreFault,
format_disclosures,
freshness_age_human,
grade_freshness,
open_store_readonly,
query_service_state,
read_config,
)
from .monitoring_periods import get_open_period
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _format_remaining(seconds: float) -> str:
"""Format remaining lifespan as human-readable string."""
if seconds <= 0:
return "endurance exhausted"
years = int(seconds // 31557600)
rem = seconds % 31557600
days = int(rem // 86400)
rem %= 86400
hours = int(rem // 3600)
parts = []
if years:
parts.append("%d yr" % years)
if days or years:
parts.append("%d d" % days)
parts.append("%d h" % hours)
return " ".join(parts)
def _sparkline(values: List[float], width: int = 40) -> str:
"""Render a sparkline from daily bytes-written values."""
if not values:
return ""
mx = max(values) or 1.0
blocks = " ▁▂▃▄▅▆▇█"
step = max(1, len(values) // width or 1)
picked = values[-width * step :][::step][-width:]
return "".join(
blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))]
for v in picked
)
def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str:
"""Render the habit-split bar with legend."""
total = a + i + o + u or 1.0
segs = [
("a", a, "#33ff33"),
("i", i, "#ffff33"),
("o", o, "#33ffff"),
("?", u, "#ff33ff"),
]
parts = []
for label, v, _color in segs:
n = max(1 if v > 0 else 0, round(v / total * width))
parts.append(label * n)
bar = "".join(parts)
legend = " active %d%% · idle %d%% · powered-off %d%% · unknown %d%%" % (
round(a / total * 100),
round(i / total * 100),
round(o / total * 100),
round(u / total * 100),
)
return bar + "\n" + legend
# ---------------------------------------------------------------------------
# Data queries for TUI regions
# ---------------------------------------------------------------------------
def _query_usage_history(conn: sqlite3.Connection) -> Dict[str, Any]:
"""Query usage-history data for the left pane."""
cursor = conn.execute(
"SELECT day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, coverage "
"FROM day_aggregates ORDER BY day"
)
days = cursor.fetchall()
if not days:
return {
"sparkline": "",
"habit_bar": "",
"num_days": 0,
"min_gb": 0,
"max_gb": 0,
"habit_change": False,
"gap_days": [],
"day_labels": [],
}
bw_values = [d[5] for d in days]
total_a = sum(d[1] for d in days)
total_i = sum(d[2] for d in days)
total_o = sum(d[3] for d in days)
total_u = sum(d[4] for d in days)
total = total_a + total_i + total_o + total_u or 1
spark = _sparkline([b / 1e9 for b in bw_values]) # Convert to GB for display
bar = _habit_bar(total_a / total, total_i / total, total_o / total, total_u / total)
min_gb = min(bw_values) / 1e9 if bw_values else 0
max_gb = max(bw_values) / 1e9 if bw_values else 0
return {
"sparkline": spark,
"habit_bar": bar,
"num_days": len(days),
"min_gb": min_gb,
"max_gb": max_gb,
"habit_change": False,
"gap_days": [],
"day_labels": [d[0] for d in days],
}
def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
"""Query drive health data for the right pane."""
cursor = conn.execute(
"SELECT mn, sn, fr, temperature_c, available_spare, media_errors, "
"power_on_hours, power_cycles, unsafe_shutdowns, capacity_bytes, "
"percentage_used, data_units_written "
"FROM samples ORDER BY id DESC LIMIT 1"
)
row = cursor.fetchone()
if row is None:
return {
"model": "unknown",
"temp": 0,
"spare": 0,
"media_errors": 0,
"poh": 0,
"cycles": 0,
"unsafe_shutdowns": 0,
"capacity": "unknown",
"percentage_used": 0,
"written_tb": 0,
}
capacity = row[9]
capacity_str = "%d GB" % (capacity / 1e9) if capacity else "unknown"
written_tb = (row[11] * 512 * 1000) / 1e12 if row[11] else 0 # DUW to TB
return {
"model": row[0] or "unknown",
"temp": row[3] or 0,
"spare": row[4] or 0,
"media_errors": row[5] or 0,
"poh": row[6] or 0,
"cycles": row[7] or 0,
"unsafe_shutdowns": row[8] or 0,
"capacity": capacity_str,
"percentage_used": row[10] or 0,
"written_tb": written_tb,
}
def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[str, Any]:
"""Query service facts for the bottom strip."""
# Get freshness
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
row = cursor.fetchone()
newest_ts = row[0] if row else None
freshness = grade_freshness(newest_ts, clock_now)
# Get monitoring period
period = get_open_period(conn)
period_info = "no monitoring period"
if period:
period_info = "open since %s" % period["started_at"][:10]
# Get service state
try:
svc = query_service_state()
except Exception:
svc = {
"boot_enabled": False,
"timer_active": False,
"last_collect_ok": None,
"last_collect_age_s": None,
"last_collect_reason": None,
}
return {
"freshness": freshness,
"freshness_age_s": None,
"period": period_info,
**svc,
}
# ---------------------------------------------------------------------------
# Modal screens
# ---------------------------------------------------------------------------
class ConfirmPause(ModalScreen[bool]):
"""Pause asks for confirmation (spec §7.4, LC-6)."""
BINDINGS = [
Binding("y", "yes", "Pause"),
Binding("n", "no", "Cancel"),
Binding("escape", "no", "Cancel", show=False),
]
def compose(self) -> ComposeResult:
yield Static(
"[bold]Pause monitoring?[/bold]\n\n"
"This closes the current monitoring period.\n"
"Paused time is [bold]excluded[/bold] from your usage habit\n"
"(powered-off time would still count).\n\n"
"[dim]y pause · n cancel[/dim]",
id="confirm-text",
)
def action_yes(self) -> None:
self.dismiss(True)
def action_no(self) -> None:
self.dismiss(False)
class DisclosuresScreen(ModalScreen[None]):
"""Disclosures view with six verbatim disclosures (spec §6.11, CI-4)."""
BINDINGS = [
Binding("escape", "close", "Close"),
Binding("d", "close", "Close"),
]
def compose(self) -> ComposeResult:
text = format_disclosures()
yield Static(text + "\n\n[dim]esc to close[/dim]", id="disc-text")
def action_close(self) -> None:
self.dismiss()
# ---------------------------------------------------------------------------
# Main TUI App
# ---------------------------------------------------------------------------
class FenrisTuiApp(App):
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
TITLE = "Fenris"
SUB_TITLE = "NVMe endurance monitor"
CSS = """
#main-grid {
layout: grid;
grid-size: 2 3;
grid-columns: 3fr 2fr;
grid-rows: 8 1fr 7;
height: 1fr;
}
#headline-band { column-span: 2; }
#service-strip { column-span: 2; }
.pane { border: round #555555; padding: 0 1; }
#confirm-text { padding: 1 2; }
#disc-text { padding: 1 2; }
"""
BINDINGS = [
Binding("p", "pause", "Pause", show=False),
Binding("r", "resume", "Resume", show=False),
Binding("c", "collect", "Collect Now", show=False),
Binding("d", "disclose", "Disclosures", show=False),
Binding("q", "quit", "Quit", show=False),
]
def __init__(
self,
store_path: Optional[Path] = None,
config_path: Optional[Path] = None,
helper_path: Optional[str] = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
self.config_path = config_path
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
self._conn: Optional[sqlite3.Connection] = None
self._clock_now = datetime.now(timezone.utc)
def compose(self) -> ComposeResult:
with Vertical(id="main-grid"):
yield Static("", id="headline-band", classes="pane")
yield Static("", id="usage-history", classes="pane")
yield Static("", id="drive-health", classes="pane")
yield Static("", id="service-strip", classes="pane")
def on_mount(self) -> None:
"""Set border titles and render initial state."""
self.query_one("#headline-band").border_title = "headline"
self.query_one("#usage-history").border_title = "usage history"
self.query_one("#drive-health").border_title = "drive"
self.query_one("#service-strip").border_title = "service + actions"
self._refresh()
def _open_store(self) -> Optional[sqlite3.Connection]:
"""Open store read-only, handling faults."""
try:
return open_store_readonly(self.store_path)
except (StoreFault, NewerSchema):
return None
def _refresh(self) -> None:
"""Refresh all four regions from store data."""
self._clock_now = datetime.now(timezone.utc)
conn = self._open_store()
if conn is None:
self._render_empty_or_fault()
return
try:
self._conn = conn
self._render_all_regions(conn)
finally:
conn.close()
self._conn = None
def _render_empty_or_fault(self) -> None:
"""Render empty store greeting or store fault."""
if not self.store_path.exists():
# Empty store — greeting with enable hint (IN-3)
self.query_one("#headline-band").update(
"[bold]No observations yet[/bold]\n\n"
"Enable monitoring: fenris monitor resume"
)
self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("")
self.query_one("#service-strip").update(
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
"p pause · r resume · c collect · d disclosures · q quit"
)
else:
# Store fault (FL-4)
self.query_one("#headline-band").update(
"[bold red]Observation store unreadable[/bold red]\n"
"Check journalctl -u fenris-collect.service"
)
self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("")
self.query_one("#service-strip").update(
"p pause · r resume · c collect · d disclosures · q quit"
)
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
"""Render all four regions from live store data."""
# --- Headline band (§7.2) ---
try:
proj = compute_projection(conn, self._clock_now)
headline = self._format_headline(proj)
confidence = self._format_confidence(proj)
scenario = self._format_scenario(proj)
self.query_one("#headline-band").update(
headline + "\n" + confidence + "\n" + scenario
)
except Exception:
self.query_one("#headline-band").update(
"[bold]No projection available[/bold]"
)
# --- Usage-history pane (§7.2 left) ---
history = _query_usage_history(conn)
history_text = "[bold]Usage history[/bold] · %d days · %.1f–%.1f GB/day\n %s\n %s" % (
history["num_days"],
history["min_gb"],
history["max_gb"],
history["sparkline"],
history["habit_bar"],
)
self.query_one("#usage-history").update(history_text)
# --- Drive-health pane (§7.2 right) ---
health = _query_drive_health(conn)
health_text = (
"[bold]Drive health[/bold] · %s\n"
" temperature %d°C · spare %d%%\n"
" media errors %d · unsafe shutdowns %d\n"
" power-on %d h · %d cycles · %s\n\n"
"[bold]Settings[/bold]\n"
" vendor wear: %d%% used · %.1f TB written"
) % (
health["model"],
health["temp"],
health["spare"],
health["media_errors"],
health["unsafe_shutdowns"],
health["poh"],
health["cycles"],
health["capacity"],
health["percentage_used"],
health["written_tb"],
)
self.query_one("#drive-health").update(health_text)
# --- Service strip (§7.2 bottom) ---
try:
svc = _query_service_facts(conn, self._clock_now)
boot = "enabled" if svc.get("boot_enabled") else "disabled"
activity = "active" if svc.get("timer_active") else "inactive"
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
freshness = svc.get("freshness", "unknown")
self.query_one("#service-strip").update(
"boot: %s · timer: %s · last collect: %s · freshness: %s\n"
"%s\n"
"p pause · r resume · c collect · d disclosures · q quit"
% (boot, activity, collect, freshness, svc.get("period", ""))
)
except Exception:
self.query_one("#service-strip").update(
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
"p pause · r resume · c collect · d disclosures · q quit"
)
def _format_headline(self, proj: ProjectionResult) -> str:
"""Format the lifespan headline (spec §6.11)."""
if proj.headline_remaining_seconds is None:
if proj.zero_rate_fact:
return "[bold]Usage-adjusted theoretical lifespan: [red]no finite projection from this history[/red][/bold]"
if proj.warming_fact:
return "[bold]Usage-adjusted theoretical lifespan: [yellow]%s[/yellow][/bold]" % proj.warming_fact
return "[bold]Usage-adjusted theoretical lifespan: [red]no projection available[/red][/bold]"
remaining = _format_remaining(proj.headline_remaining_seconds)
regime = ""
if proj.regime_days:
regime = " · sustained regime: %d days" % proj.regime_days
return (
"[bold]Usage-adjusted theoretical lifespan: [white]%s remaining[/white][/bold]"
"\n if current habits continue%s" % (remaining, regime)
)
def _format_confidence(self, proj: ProjectionResult) -> str:
"""Format confidence state with contributing facts (spec §6.7)."""
color = {
ConfidenceState.SUPPORTED: "green",
ConfidenceState.LIMITED: "yellow",
ConfidenceState.UNSUPPORTED: "red",
}[proj.confidence_state]
facts = " · ".join(proj.contributing_facts[:3]) if proj.contributing_facts else "no facts"
return "[bold]Projection confidence: [%s]%s[/%s][/bold]\n %s" % (
color,
proj.confidence_state.value,
color,
facts,
)
def _format_scenario(self, proj: ProjectionResult) -> str:
"""Format scenario range (spec §6.5)."""
if not proj.scenario_range or not proj.scenario_range.rates:
return ""
parts = []
for horizon in sorted(proj.scenario_range.rates.keys()):
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
return "[bold]Scenario range[/bold] · %s" % " · ".join(parts)
# --- Actions ---
def action_pause(self) -> None:
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
def _pause_confirmed(self, confirmed: bool) -> None:
if not confirmed:
return
# Route through fenris-monitor as terminal-attached subprocess (LC-6)
self._run_helper("disable", ["--now"])
def action_resume(self) -> None:
"""Resume monitoring — no confirmation (spec §7.4, LC-6)."""
self._run_helper("enable", ["--now"])
def action_collect(self) -> None:
"""Collect now — synchronous outcome (spec §8.7, LC-8)."""
self._run_helper("collect", blocking=True)
def action_disclose(self) -> None:
"""Show disclosures (spec §6.11, CI-4)."""
self.push_screen(DisclosuresScreen())
def _run_helper(
self,
operation: str,
extra_args: Optional[List[str]] = None,
blocking: bool = False,
) -> None:
"""Run fenris-monitor as terminal-attached subprocess (LC-6, LC-8).
The TUI suspends, polkit agent prompts on real terminal, control returns.
"""
cmd = [self.helper_path, operation]
if extra_args:
cmd.extend(extra_args)
try:
with self.suspend():
proc = subprocess.run(cmd, timeout=30)
if proc.returncode != 0:
self.notify(
"Operation failed (exit %d)" % proc.returncode,
severity="error",
)
except FileNotFoundError:
self.notify(
"Helper not found: %s" % self.helper_path,
severity="error",
)
except subprocess.TimeoutExpired:
self.notify("Operation timed out", severity="error")
except Exception as e:
self.notify("Error: %s" % e, severity="error")
# Refresh after action
self._refresh()
def run_tui(
store_path: Optional[Path] = None,
helper_path: Optional[str] = None,
) -> None:
"""Entry point for the Fenris TUI."""
app = FenrisTuiApp(
store_path=store_path,
helper_path=helper_path,
)
app.run()
+20
View File
@@ -0,0 +1,20 @@
"""Shared test helpers for Fenris test suite."""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = REPO_ROOT / "pyproject.toml"
def get_version() -> str:
"""Extract version from pyproject.toml."""
for line in VERSION_FILE.read_text().splitlines():
if line.startswith("version"):
return line.split("=")[1].strip().strip('"')
raise RuntimeError("Could not determine version from pyproject.toml")
def read(path: str | Path) -> str:
"""Read a file relative to the repository root."""
return (REPO_ROOT / path).read_text()
+666
View File
@@ -0,0 +1,666 @@
"""Cross-cutting acceptance sweep (issue #32).
Systematic verification of every acceptance criterion that spans multiple
subsystems. Grouped by criterion ID; each test cites its clause.
CI-1 Exhaustive state matrix: confidence × freshness × baseline tier
CI-2 TUI/CLI parity: identical outcomes and wording
CI-3 Prohibition set: automated structural checks
CI-4 Required wording and six disclosures in both views
"""
import re
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open
from fenris.projection import (
compute_projection,
ConfidenceState,
BaselineTier,
DISCLOSURES,
STALENESS_HOURS,
WARMING_MIN_DAYS,
YOUNG_REGIME_DAYS,
)
from fenris.status import (
grade_freshness,
get_status,
render_status,
format_disclosures,
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
CADENCE_DEFAULT_S,
ACCURACY_SEC,
)
from fenris.tui import (
FenrisTuiApp,
_format_remaining,
)
SRC_DIR = Path(__file__).parent.parent / "src"
FENRIS_PKG = SRC_DIR / "fenris"
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB",
source_url="https://example.com/spec",
doc_rev="v1.0", entry_date="2026-01-01",
nominal_cap=1024000000000):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, source_url, doc_rev, entry_date, model, nominal_cap,
"machine_match" if verified else None, verified,
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, "
"powered_off_seconds, unknown_seconds, bytes_written_delta, "
"bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
sample_ts="2026-09-30T10:00:00+00:00",
period_start="2026-09-01T00:00:00+00:00",
segment_opened="2026-09-01T00:00:00+00:00",
baseline_kw=None, segment_kw=None):
if baseline:
_insert_baseline(conn, **(baseline_kw or {}))
if segment:
_insert_segment(conn, opened_at=segment_opened, **(segment_kw or {}))
_open_period(conn, start=period_start)
for i in range(days):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
if sample_ts:
_insert_sample(conn, sample_ts)
# ===================================================================
# CI-1: Exhaustive state matrix
# ===================================================================
class TestCI1StateMatrix:
"""Systematic walk of confidence x freshness x baseline tier combinations."""
def test_no_baseline_unavailable(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.headline_remaining_seconds is None
assert proj.baseline_tier == BaselineTier.NONE
conn.close()
def test_verified_baseline_possible_supported(self, tmp_path):
conn = init_store(tmp_path / "db")
_setup_full_store(conn, baseline_kw=dict(tbw_tb=10.0, verified=True))
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.SUPPORTED
assert proj.baseline_tier == BaselineTier.VERIFIED
assert proj.headline_remaining_seconds is not None
conn.close()
def test_unverified_baseline_possible_limited(self, tmp_path):
conn = init_store(tmp_path / "db")
_setup_full_store(conn, baseline_kw=dict(
tbw_tb=10.0, verified=False, source_url=None))
proj = compute_projection(conn, _clock())
assert proj.baseline_tier == BaselineTier.UNVERIFIED
assert proj.confidence_state != ConfidenceState.SUPPORTED
conn.close()
def test_model_mismatch_unavailable(self, tmp_path):
conn = init_store(tmp_path / "db")
_setup_full_store(conn, baseline_kw=dict(model="Different Model"))
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.baseline_tier == BaselineTier.NONE
conn.close()
def test_fresh_sample_grades_fresh(self, tmp_path):
now = _clock()
ts = (now - timedelta(seconds=FRESH_THRESHOLD_S - 10)).isoformat()
assert grade_freshness(ts, now) == "fresh"
def test_missed_sample_grades_missed(self, tmp_path):
now = _clock()
ts = (now - timedelta(hours=2)).isoformat()
assert grade_freshness(ts, now) == "missed"
def test_stale_sample_grades_stale(self, tmp_path):
now = _clock()
ts = (now - timedelta(hours=49)).isoformat()
assert grade_freshness(ts, now) == "stale"
def test_empty_store_grades_empty(self, tmp_path):
now = _clock()
assert grade_freshness(None, now) == "empty"
def test_unsupported_fresh(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
fresh_ts = (_clock() - timedelta(seconds=60)).isoformat()
_insert_sample(conn, fresh_ts)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert grade_freshness(fresh_ts, _clock()) == "fresh"
conn.close()
def test_limited_young_regime(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=10.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(5):
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
conn.close()
def test_limited_warming(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=10.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.warming_fact is not None
conn.close()
def test_limited_stale_data(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=10.0, verified=True)
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
_open_period(conn, start="2026-08-01T00:00:00+00:00")
for i in range(30):
d = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
stale_ts = (_clock() - timedelta(days=5)).isoformat()
_insert_sample(conn, stale_ts)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.staleness_fact is not None
conn.close()
def test_limited_degraded_identity(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=10.0, verified=True)
_insert_segment(conn, identity_key=None, degraded=True)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.degraded_identity_fact is not None
conn.close()
def test_unsupported_zero_rate(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=10.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=0)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.zero_rate_fact is not None
conn.close()
def test_headline_present_when_projection_exists(self, tmp_path):
conn = init_store(tmp_path / "db")
_setup_full_store(conn, baseline_kw=dict(tbw_tb=10.0, verified=True))
proj = compute_projection(conn, _clock())
assert proj.headline_remaining_seconds is not None
assert proj.headline_remaining_seconds > 0
conn.close()
def test_headline_absent_when_unavailable(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_segment(conn)
_open_period(conn)
proj = compute_projection(conn, _clock())
assert proj.headline_remaining_seconds is None
conn.close()
def test_headline_absent_when_zero_rate(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=0)
proj = compute_projection(conn, _clock())
assert proj.headline_remaining_seconds is None
conn.close()
def test_facts_always_list(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_segment(conn)
_open_period(conn)
proj = compute_projection(conn, _clock())
assert isinstance(proj.contributing_facts, list)
conn.close()
def test_facts_never_empty_for_unavailable(self, tmp_path):
conn = init_store(tmp_path / "db")
_insert_segment(conn)
_open_period(conn)
proj = compute_projection(conn, _clock())
assert len(proj.contributing_facts) > 0
conn.close()
def test_confidence_never_percentage(self, tmp_path):
conn = init_store(tmp_path / "db")
_setup_full_store(conn, baseline_kw=dict(tbw_tb=10.0, verified=True))
proj = compute_projection(conn, _clock())
assert proj.confidence_state in (
ConfidenceState.UNSUPPORTED, ConfidenceState.LIMITED, ConfidenceState.SUPPORTED)
for f in proj.contributing_facts:
if re.match(r"^\\d+%$", f.strip()):
pytest.fail("Bare percentage in facts: %r" % f)
conn.close()
def test_status_renders_same_state_as_projection(self, tmp_path):
db = tmp_path / "observations.db"
conn = init_store(db)
_setup_full_store(conn, baseline_kw=dict(tbw_tb=10.0, verified=True))
conn.close()
now = _clock()
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
status = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "Supported" in status or "supported" in status.lower()
assert "remaining" in status.lower()
# ===================================================================
# CI-2: TUI/CLI parity
# ===================================================================
class TestCI2Parity:
"""Verify TUI and CLI share the same constants, formatting, and wording."""
def test_freshness_constants_shared(self):
from fenris import tui as tui_mod
from fenris import status as status_mod
assert tui_mod.FRESH_THRESHOLD_S == status_mod.FRESH_THRESHOLD_S
assert tui_mod.STALENESS_THRESHOLD_S == status_mod.STALENESS_THRESHOLD_S
def test_grade_freshness_shared(self):
from fenris.tui import grade_freshness as tui_gf
from fenris.status import grade_freshness as status_gf
assert tui_gf is status_gf
def test_disclosures_shared(self):
from fenris.projection import DISCLOSURES as proj_disc
from fenris.status import format_disclosures
output = format_disclosures()
for d in proj_disc:
assert d in output
def test_status_four_facts_match_tui_strip(self, tmp_path):
db = tmp_path / "observations.db"
conn = init_store(db)
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
now = _clock()
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}):
status = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "boot:" in status
assert "timer:" in status
assert "last collect:" in status
assert "freshness:" in status
def test_pause_resume_action_names(self):
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
assert "p" in tui_keys
assert "r" in tui_keys
assert "c" in tui_keys
assert "q" in tui_keys
def test_empty_store_greeting_both_views(self, tmp_path):
db = tmp_path / "observations.db"
init_store(db)
now = _clock()
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
status = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "no observations yet" in status.lower()
def test_store_fault_phrase_both_views(self, tmp_path):
status_src = (FENRIS_PKG / "status.py").read_text()
tui_src = (FENRIS_PKG / "tui.py").read_text()
phrase = "observation store unreadable"
assert phrase in status_src
assert phrase.lower() in tui_src.lower()
def test_newer_schema_phrase_both_views(self):
status_src = (FENRIS_PKG / "status.py").read_text()
phrase = "observation store written by a newer Fenris"
assert phrase in status_src
def test_status_never_prompts(self):
status_src = (FENRIS_PKG / "status.py").read_text()
assert "input(" not in status_src
# ===================================================================
# CI-3: Prohibition set
# ===================================================================
class TestCI3ProhibitionSet:
"""Structural codebase checks for every prohibition clause."""
def _read_all_sources(self):
files = {}
for py in FENRIS_PKG.glob("*.py"):
files[py.name] = py.read_text()
return files
def test_single_acquisition_path(self):
"""Only fenris-collect may interrogate the device. [2.1, 8.7]
collector.py contains the acquisition functions; collect.py is the
fenris-collect entry point that invokes them. No other module may
reference smartctl.
"""
sources = self._read_all_sources()
allowed = {"collector.py", "collect.py"}
for name, text in sources.items():
if name in allowed:
continue
assert "smartctl" not in text, (
"%s must not contain smartctl" % name
)
def test_no_run_surface(self):
"""No /run/fenris coordination surface. [1.2, 3]"""
sources = self._read_all_sources()
for name, text in sources.items():
assert "/run/fenris" not in text, (
"%s references /run/fenris" % name
)
def test_single_config_key(self):
"""Config holds exactly one key: device. [8.3]"""
status_src = (FENRIS_PKG / "status.py").read_text()
in_read_config = False
config_keys = []
for line in status_src.split("\n"):
if "def read_config" in line:
in_read_config = True
elif in_read_config and line.strip().startswith("def "):
break
elif in_read_config and "key ==" in line:
match = re.search(r'key\s*==\s*["\']([^"\']+)["\']', line)
if match:
config_keys.append(match.group(1))
assert "device" in config_keys
assert len(config_keys) == 1, "Found keys: %s" % config_keys
def test_no_alerting_machinery(self):
"""No alerting, notification, or escalation. [9.6]"""
sources = self._read_all_sources()
alert_keywords = ["send_email", "smtp", "webhook", "push_notification"]
for name, text in sources.items():
for kw in alert_keywords:
for line in text.split("\n"):
stripped = line.strip()
if kw in stripped and not stripped.startswith("#"):
pytest.fail(
"%s contains alerting keyword '%s': %s" % (name, kw, stripped)
)
def test_no_synthetic_baselines(self):
"""No synthetic or capacity-derived baseline. [6.1]"""
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert "synthetic" not in proj_src.lower()
def test_no_stored_projections(self):
"""Projections never stored; recomputed on read. [3.7, 6.10]"""
store_src = (FENRIS_PKG / "store.py").read_text()
create_tables = re.findall(r"CREATE TABLE.*?(?=\n\n|$)", store_src, re.DOTALL)
table_names = []
for ct in create_tables:
m = re.search(r"IF NOT EXISTS\s+(\w+)", ct)
if m:
table_names.append(m.group(1))
assert "projection" not in [t.lower() for t in table_names]
def test_no_partial_newer_schema_interpretation(self):
"""Readers refuse newer-schema stores. [3.6, 9.5]"""
status_src = (FENRIS_PKG / "status.py").read_text()
assert "NewerSchema" in status_src
assert "upgrade Fenris" in status_src
def test_polkit_authorizes_one_binary(self):
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
assert "fenris-monitor" in monitor_src or "fenris_monitor" in monitor_src
collect_src = (FENRIS_PKG / "collect.py").read_text()
assert "polkit" not in collect_src.lower()
def test_no_hour_interpolation(self):
"""No absent hour is interpolated or fabricated. [5.3]"""
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert "interpolat" not in proj_src.lower()
assert "fabricat" not in proj_src.lower()
def test_fenris_sh_not_shipped(self):
"""fenris.sh is not shipped. [8.8]"""
repo_root = Path(__file__).parent.parent
assert not (repo_root / "fenris.sh").exists()
# ===================================================================
# CI-4: Required wording and six disclosures
# ===================================================================
class TestCI4WordingAndDisclosures:
"""Verify exact fixed phrases and disclosures in both views."""
def test_exactly_six_disclosures(self):
assert len(DISCLOSURES) == 6
def test_disclosure_1_endurance_not_failure(self):
assert "endurance projection" in DISCLOSURES[0].lower()
assert "hardware-failure" in DISCLOSURES[0].lower() or "failure date" in DISCLOSURES[0].lower()
def test_disclosure_2_vendor_specific(self):
assert "vendor-specific" in DISCLOSURES[1]
assert "255 is saturated" in DISCLOSURES[1]
def test_disclosure_3_warranty_not_failure(self):
assert "warranty" in DISCLOSURES[2].lower() or "endurance threshold" in DISCLOSURES[2].lower()
assert "failure threshold" in DISCLOSURES[2].lower()
def test_disclosure_4_duw_rounding(self):
assert "DUW" in DISCLOSURES[3]
assert "upward-rounded" in DISCLOSURES[3]
assert "NAND" in DISCLOSURES[3]
def test_disclosure_5_quality_depends(self):
assert "baseline provenance" in DISCLOSURES[4]
assert "future workload" in DISCLOSURES[4]
def test_disclosure_6_gaps_and_disabled(self):
assert "Gaps" in DISCLOSURES[5]
assert "deliberately disabled" in DISCLOSURES[5]
def test_disclosures_render_in_status(self):
output = format_disclosures()
assert output.startswith("Disclosures")
for i in range(1, 7):
assert "%d." % i in output
for d in DISCLOSURES:
assert d in output
def test_disclosures_render_in_tui(self):
tui_src = (FENRIS_PKG / "tui.py").read_text()
assert "format_disclosures" in tui_src
def test_zero_rate_phrase(self):
phrase = "no finite projection from this history"
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert phrase in proj_src
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
def test_unavailable_no_baseline_phrase(self):
phrase = "no applicable endurance baseline"
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert phrase in proj_src
def test_store_fault_phrase(self):
phrase = "observation store unreadable"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
tui_src = (FENRIS_PKG / "tui.py").read_text()
assert phrase.lower() in tui_src.lower()
def test_newer_schema_phrase(self):
phrase = "observation store written by a newer Fenris"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
def test_no_observations_phrase(self):
phrase = "no observations yet"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
tui_src = (FENRIS_PKG / "tui.py").read_text()
assert phrase in tui_src.lower()
def test_config_error_phrase(self):
phrase = "configuration error:"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
def test_degraded_identity_phrase(self):
phrase = "controller identity unavailable"
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert phrase in proj_src
phrase2 = "replacement detection relies on write-counter continuity only"
assert phrase2 in proj_src
def test_scenario_range_only_spread(self):
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert "confidence interval" not in proj_src.lower()
def test_no_percentage_in_confidence_rendering(self):
for name in ["projection.py", "tui.py", "status.py"]:
src = (FENRIS_PKG / name).read_text()
assert not re.search(r"\\d+%\\s*confidence", src, re.IGNORECASE), (
"Found XX%% confidence in %s" % name
)
def test_status_disclosures_accessible(self):
db = Path("/tmp/_ci4_test.db")
conn = init_store(db)
conn.close()
now = _clock()
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = render_status(store_path=db, clock_now=now,
query_services=True, query_journal=False,
show_disclosures=True)
assert "Disclosures" in result
assert "1." in result
assert "6." in result
db.unlink(missing_ok=True)
+325
View File
@@ -0,0 +1,325 @@
"""Tests for fenris-monitor helper.
Spec: §8.4, §8.5, §8.6, §8.7
"""
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.monitor import (
cmd_enable,
cmd_disable,
cmd_collect,
cmd_baseline_set,
cmd_baseline_clear,
is_root,
)
from fenris.store import init_store
@pytest.fixture
def tmp_store(tmp_path):
"""Create a temporary observation store."""
store_path = tmp_path / "observations.db"
conn = init_store(store_path)
yield conn
conn.close()
@pytest.fixture
def store_path(tmp_path):
"""Return path to a temporary observation store."""
return tmp_path / "observations.db"
class TestIsRoot:
def test_root_returns_true(self):
with patch("os.geteuid", return_value=0):
assert is_root() is True
def test_non_root_returns_false(self):
with patch("os.geteuid", return_value=1000):
assert is_root() is False
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
init_store(store_path)
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)
# Period should be open
conn = init_store(store_path)
cursor = conn.execute(
"SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL"
)
assert cursor.fetchone() is not None
conn.close()
def test_resume_with_open_period_noop(self, store_path):
"""Resume with open period: no-op (gap stays inside as unknown)."""
# Initialize store and open a period
conn = init_store(store_path)
now = datetime.now(timezone.utc)
conn.execute(
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
(now.isoformat(),),
)
conn.commit()
conn.close()
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
# Should still have exactly one open period
conn = init_store(store_path)
cursor = conn.execute(
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
)
assert cursor.fetchone()[0] == 1
conn.close()
def test_resume_with_no_period_opens_new(self, store_path):
"""Resume with no open period opens a new row."""
# Initialize store and close any existing period
conn = init_store(store_path)
conn.execute(
"UPDATE monitoring_periods SET ended_at = ?, end_cause = ?",
(datetime.now(timezone.utc).isoformat(), "user_disabled"),
)
conn.commit()
conn.close()
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
# Should have a new open period
conn = init_store(store_path)
cursor = conn.execute(
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
)
assert cursor.fetchone()[0] == 1
conn.close()
class TestDisableIdempotentMatrix:
"""§8.6: Period-row idempotent matrix."""
def test_pause_with_open_period_closes_user_disabled(self, store_path):
"""Pause with open period closes it user_disabled."""
# Initialize store and open a period
conn = init_store(store_path)
now = datetime.now(timezone.utc)
conn.execute(
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
(now.isoformat(),),
)
conn.commit()
conn.close()
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_disable(args)
# Period should be closed with user_disabled
conn = init_store(store_path)
cursor = conn.execute(
"SELECT end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL"
)
assert cursor.fetchone()[0] == "user_disabled"
conn.close()
def test_pause_without_open_period_noop(self, store_path):
"""Pause otherwise no-ops."""
# Initialize store
init_store(store_path)
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_disable(args)
# No periods should exist
conn = init_store(store_path)
cursor = conn.execute("SELECT COUNT(*) FROM monitoring_periods")
assert cursor.fetchone()[0] == 0
conn.close()
def test_raw_systemctl_stop_never_records_user_disabled(self, store_path):
"""Raw systemctl stop outside helper never records user_disabled."""
# Initialize store and open a period
conn = init_store(store_path)
now = datetime.now(timezone.utc)
conn.execute(
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
(now.isoformat(),),
)
conn.commit()
# Simulate raw systemctl stop (no monitor involved)
# The period stays open - only the sanctioned path closes it
cursor = conn.execute(
"SELECT end_cause FROM monitoring_periods WHERE ended_at IS NULL"
)
assert cursor.fetchone() is not None # Still open
conn.close()
class TestCollectTrigger:
"""§8.7: On-demand collection via helper path."""
def test_collect_triggers_systemctl_start(self):
"""Collect starts fenris-collect.service synchronously."""
args = MagicMock()
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_collect(args)
mock_sub.run.assert_called_once_with(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
def test_collect_failure_exits_nonzero(self):
"""Collect failure exits with nonzero status."""
args = MagicMock()
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Unit not found"
)
with pytest.raises(SystemExit) as exc_info:
cmd_collect(args)
assert exc_info.value.code == 1
class TestBaselinePersistence:
"""PR-14: Baseline persistence behind polkit-guarded helper."""
def test_baseline_set_persists(self, store_path):
"""baseline set persists the baseline row."""
# Initialize store
init_store(store_path)
args = MagicMock(
store_path=store_path,
baseline_json=json.dumps(
{
"tbw_terabytes": 600,
"source_url": "https://example.com/spec",
"document_revision": "rev1",
"entry_date": "2024-01-01",
"model_string": "Samsung 990 Pro",
"nominal_capacity_bytes": 2000000000000,
}
)
)
cmd_baseline_set(args)
conn = init_store(store_path)
cursor = conn.execute("SELECT * FROM endurance_baseline")
row = cursor.fetchone()
assert row is not None
assert row[1] == 600.0 # tbw_terabytes
conn.close()
def test_baseline_set_replaces_existing(self, store_path):
"""baseline set replaces any existing baseline."""
# Initialize store and insert initial baseline
conn = init_store(store_path)
now = datetime.now(timezone.utc)
conn.execute(
"INSERT INTO endurance_baseline (tbw_terabytes, source_url, "
"document_revision, entry_date, model_string, nominal_capacity_bytes, "
"created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(400, "old", "v1", "2023-01-01", "Old Model", 1000000000000,
now.isoformat(), now.isoformat()),
)
conn.commit()
conn.close()
args = MagicMock(
store_path=store_path,
baseline_json=json.dumps(
{
"tbw_terabytes": 600,
"source_url": "new",
"document_revision": "v2",
"entry_date": "2024-01-01",
"model_string": "New Model",
"nominal_capacity_bytes": 2000000000000,
}
)
)
cmd_baseline_set(args)
conn = init_store(store_path)
cursor = conn.execute("SELECT COUNT(*) FROM endurance_baseline")
assert cursor.fetchone()[0] == 1 # Only one row
conn.close()
def test_baseline_clear_removes(self, store_path):
"""baseline clear removes the baseline."""
# Initialize store and insert baseline
conn = init_store(store_path)
now = datetime.now(timezone.utc)
conn.execute(
"INSERT INTO endurance_baseline (tbw_terabytes, source_url, "
"document_revision, entry_date, model_string, nominal_capacity_bytes, "
"created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(400, "src", "v1", "2024-01-01", "Model", 1000000000000,
now.isoformat(), now.isoformat()),
)
conn.commit()
conn.close()
args = MagicMock(store_path=store_path)
cmd_baseline_clear(args)
conn = init_store(store_path)
cursor = conn.execute("SELECT COUNT(*) FROM endurance_baseline")
assert cursor.fetchone()[0] == 0
conn.close()
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
"""Release flow tests (issue #52).
Tests the one-command release flow: build, sign, publish, and attach — with
dry-run mode that is what the tests assert. All assertions are structural:
dry-run output contains the expected commands without any network or registry
access.
Requirements:
- scripts/release.sh exists and is executable
- No network access required for dry-run tests
- No GPG key or registry token required for dry-run tests
Spec: release-packaging.md §5, issue #52 acceptance criteria
"""
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
RELEASE_SCRIPT = REPO_ROOT / "scripts" / "release.sh"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read(path: str | Path) -> str:
from tests.conftest import read
return read(path)
def _get_version() -> str:
"""Extract version from pyproject.toml."""
from tests.conftest import get_version
return get_version()
def _run_dry_run(*args: str) -> tuple[int, str]:
"""Run the release script in dry-run mode and return (exit_code, stdout)."""
cmd = ["bash", str(RELEASE_SCRIPT), "--dry-run"] + list(args)
r = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
cwd=REPO_ROOT,
)
return r.returncode, r.stdout + r.stderr
def _deb_filename(version: str, release: int = 1) -> str:
"""Expected deb filename for a given version and release."""
return f"fenris_{version}_amd64.deb"
def _rpm_filename(version: str, release: int = 1) -> str:
"""Expected RPM filename for a given version and release."""
return f"fenris-{version}-{release}.x86_64.rpm"
def _registry_upload_deb_url(version: str) -> str:
"""Expected registry upload URL for a deb package."""
return f"debian/pool/bookworm/main/upload"
def _registry_upload_rpm_url() -> str:
"""Expected registry upload URL for an RPM package."""
return "rpm/fenris/upload"
# ---------------------------------------------------------------------------
# Tests — release script existence and permissions
# ---------------------------------------------------------------------------
class TestReleaseScriptExists:
"""Verify the release script is present and executable."""
def test_script_exists(self):
assert RELEASE_SCRIPT.exists(), \
"scripts/release.sh must exist"
def test_script_is_executable(self):
assert RELEASE_SCRIPT.stat().st_mode & 0o111, \
"scripts/release.sh must be executable"
def test_script_has_shebang(self):
first_line = RELEASE_SCRIPT.read_text().splitlines()[0]
assert first_line.startswith("#!/"), \
"scripts/release.sh must have a shebang"
# ---------------------------------------------------------------------------
# Tests — dry-run prints all expected commands
# ---------------------------------------------------------------------------
class TestDryRunCommandPrintout:
"""Verify dry-run prints every command that would execute."""
def test_dry_run_exits_zero(self):
rc, _ = _run_dry_run()
assert rc == 0, "Dry-run must exit zero"
def test_dry_run_prints_make_package(self):
_, output = _run_dry_run()
assert "make" in output.lower() and "package" in output.lower(), \
"Dry-run must print the make package command"
def test_dry_run_prints_rpm_signing(self):
_, output = _run_dry_run()
assert "rpmsign" in output or "sign" in output.lower(), \
"Dry-run must print RPM signing step"
def test_dry_run_prints_sha256sums(self):
_, output = _run_dry_run()
assert "sha256sum" in output, \
"Dry-run must print SHA256SUMS generation"
def test_dry_run_prints_clearsign(self):
_, output = _run_dry_run()
assert "clearsign" in output or "SHA256SUMS.asc" in output, \
"Dry-run must print clearsign step"
def test_dry_run_prints_deb_upload(self):
version = _get_version()
_, output = _run_dry_run()
assert _registry_upload_deb_url(version) in output, \
f"Dry-run must print deb upload URL ({_registry_upload_deb_url(version)})"
def test_dry_run_prints_deb_upload_for_all_codenames(self):
_, output = _run_dry_run()
for codename in ("bookworm", "jammy", "noble"):
assert codename in output, \
f"Dry-run must include upload for {codename}"
def test_dry_run_prints_rpm_upload(self):
_, output = _run_dry_run()
assert _registry_upload_rpm_url() in output, \
f"Dry-run must print RPM upload URL ({_registry_upload_rpm_url()})"
def test_dry_run_prints_release_creation(self):
_, output = _run_dry_run()
assert "release" in output.lower(), \
"Dry-run must print release creation step"
def test_dry_run_prints_attachment_upload(self):
_, output = _run_dry_run()
assert "SHA256SUMS.asc" in output, \
"Dry-run must print SHA256SUMS.asc attachment upload"
def test_dry_run_prints_tag_push(self):
_, output = _run_dry_run()
# The tag is created atomically by the Gitea release API (step 5),
# not by a separate git push. Verify the release creation step is present.
assert "tag_name" in output or "release" in output.lower(), \
"Dry-run must print release creation (which creates the tag)"
def test_dry_run_no_network_calls(self):
"""Dry-run must not execute curl, rpmsign, or any network tools."""
_, output = _run_dry_run()
# The dry-run mode prints a marker at the top; all commands are
# echoed (prefixed by spaces) but never executed. Verify the
# marker is present, confirming we're in dry-run mode.
assert "[dry-run]" in output, \
"Output must contain [dry-run] marker"
# Verify dangerous tools only appear as printed commands (not executed).
# Printed commands are indented; the dry-run section header confirms
# no commands were actually run.
assert "Commands below will be executed" in output, \
"Dry-run must indicate commands are for display only"
# ---------------------------------------------------------------------------
# Tests — dry-run prints correct package filenames
# ---------------------------------------------------------------------------
class TestDryRunFilenames:
"""Verify dry-run uses the correct artifact filenames."""
def test_deb_filename_in_output(self):
version = _get_version()
_, output = _run_dry_run()
expected = _deb_filename(version)
assert expected in output, \
f"Dry-run must reference deb filename {expected}"
def test_rpm_filename_in_output(self):
version = _get_version()
_, output = _run_dry_run()
expected = _rpm_filename(version)
assert expected in output, \
f"Dry-run must reference RPM filename {expected}"
def test_checksums_filename_in_output(self):
_, output = _run_dry_run()
assert "SHA256SUMS" in output, \
"Dry-run must reference SHA256SUMS filename"
# ---------------------------------------------------------------------------
# Tests — dry-run does not create artifacts or tags
# ---------------------------------------------------------------------------
class TestDryRunNoSideEffects:
"""Verify dry-run creates no filesystem or git side effects."""
def test_dry_run_no_git_tag_created(self):
version = _get_version()
tag = f"v{version}"
# Ensure tag doesn't exist before
r = subprocess.run(
["git", "tag", "-l", tag], capture_output=True, text=True,
cwd=REPO_ROOT,
)
pre_tags = r.stdout.strip()
_run_dry_run()
# Verify tag was not created
r = subprocess.run(
["git", "tag", "-l", tag], capture_output=True, text=True,
cwd=REPO_ROOT,
)
post_tags = r.stdout.strip()
assert pre_tags == post_tags, \
f"Dry-run must not create git tag {tag}"
# ---------------------------------------------------------------------------
# Tests — revision bumping (structural: output contains incremented release)
# ---------------------------------------------------------------------------
class TestRevisionBumping:
"""Verify the release script handles revision bumping.
When a version already exists in the registry (HTTP 409), the script
bumps the revision and retries. These tests verify the dry-run output
reflects the correct revision logic — without any network access.
"""
def test_dry_run_starts_at_revision_one(self):
version = _get_version()
_, output = _run_dry_run()
rpm_expected = _rpm_filename(version, 1)
assert rpm_expected in output, \
f"Dry-run must start at release 1: expected {rpm_expected} in output"
def test_revision_bump_changes_rpm_filename(self):
"""When revision is bumped, the RPM filename changes accordingly."""
version = _get_version()
rpm_r1 = _rpm_filename(version, 1)
rpm_r2 = _rpm_filename(version, 2)
# R2 filename must differ from R1
assert rpm_r1 != rpm_r2, \
"R2 filename must differ from R1"
# Both must contain the version
assert version in rpm_r1
assert version in rpm_r2
def test_revision_bump_changes_deb_filename(self):
"""When revision is bumped, the deb filename also changes."""
version = _get_version()
# Deb filename includes release in nfpm naming
deb_r1 = f"fenris_{version}_amd64.deb"
deb_r2 = f"fenris_{version}_amd64.deb"
# For deb, the filename doesn't change with revision (deb uses epoch)
# But the RPM does — this verifies we test RPM revision correctly
rpm_r1 = _rpm_filename(version, 1)
rpm_r2 = _rpm_filename(version, 2)
assert "-1." in rpm_r1, "R1 RPM must contain -1."
assert "-2." in rpm_r2, "R2 RPM must contain -2."
# ---------------------------------------------------------------------------
# Tests — bare tag prevention (structural)
# ---------------------------------------------------------------------------
class TestBareTagPrevention:
"""Verify the flow prevents bare tags.
A bare tag (tag without packages, release entry, notes, and checksums)
must not result from the flow. The script checks for existing bare
tags before proceeding. These tests verify the dry-run doesn't create
any tags.
"""
def test_dry_run_does_not_push_tag(self):
_, output = _run_dry_run()
# The dry-run marker confirms no commands are executed.
# git push appears only as a printed command, never executed.
assert "[dry-run]" in output, \
"Must be in dry-run mode"
# Tag push is printed but the [dry-run] marker confirms nothing ran
assert "Commands below will be executed" in output, \
"Dry-run must indicate commands are for display only"
# ---------------------------------------------------------------------------
# Tests — CI workflow file
# ---------------------------------------------------------------------------
class TestCIWorkflow:
"""Verify the dormant CI workflow is present and correctly structured."""
def test_workflow_file_exists(self):
path = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
assert path.exists(), \
".gitea/workflows/release.yml must exist"
def test_workflow_triggers_on_tags(self):
content = _read(".gitea/workflows/release.yml")
assert "v*" in content, \
"Workflow must trigger on version tags (v*)"
def test_workflow_has_release_step(self):
content = _read(".gitea/workflows/release.yml")
assert "release" in content.lower(), \
"Workflow must have a release step"
def test_workflow_mentions_signing(self):
content = _read(".gitea/workflows/release.yml")
assert "sign" in content.lower(), \
"Workflow must include signing step"
def test_workflow_mentions_upload(self):
content = _read(".gitea/workflows/release.yml")
assert "upload" in content.lower() or "publish" in content.lower(), \
"Workflow must include upload/publish step"
# ---------------------------------------------------------------------------
# Tests — Makefile release targets
# ---------------------------------------------------------------------------
class TestMakefileReleaseTargets:
"""Verify the Makefile exposes release-related targets."""
def _makefile_content(self) -> str:
return _read("Makefile")
def test_release_run_target_exists(self):
content = self._makefile_content()
assert "release-run:" in content, \
"Makefile must have a release-run target"
def test_release_dry_run_target_exists(self):
content = self._makefile_content()
assert "release-dry-run:" in content, \
"Makefile must have a release-dry-run target"
def test_release_run_calls_script(self):
content = self._makefile_content()
assert "release.sh" in content, \
"release-run target must call scripts/release.sh"
def test_release_dry_run_uses_dry_run_flag(self):
content = self._makefile_content()
# Find the release-dry-run target and verify it passes --dry-run
in_target = False
for line in content.splitlines():
if line.startswith("release-dry-run:"):
in_target = True
continue
if in_target and line.strip():
if "--dry-run" in line:
break
if not line.startswith("\t"):
break
else:
pytest.fail("release-dry-run target must pass --dry-run to release.sh")
+480
View File
@@ -0,0 +1,480 @@
"""Signing and consumer-repo structural tests (issue #51).
Verifies that the signing infrastructure, consumer setup docs, and key
publication are correctly wired — without requiring a real GPG key,
network access, or Docker.
All assertions are structural: config keys exist, URLs match, docs
are present, and the Makefile exposes the right targets. A throwaway
test key exercise is included for rpm signature verification mechanics.
"""
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read(path: str | Path) -> str:
from tests.conftest import read
return read(path)
def _gpg_available() -> bool:
try:
r = subprocess.run(
["gpg", "--version"], capture_output=True, timeout=5,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _rpmsign_available() -> bool:
try:
r = subprocess.run(
["rpmsign", "--version"], capture_output=True, timeout=5,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
# ---------------------------------------------------------------------------
# Tests — nfpm.yaml signing configuration
# ---------------------------------------------------------------------------
class TestNfpmSigningConfig:
"""Verify that nfpm.yaml is correctly configured for RPM builds.
Note: RPM signing is done via rpmsign post-build (make sign-rpm),
not through nfpm's built-in signing. This keeps the build unsigned
and the signing step explicit and key-controlled.
"""
def test_rpm_overrides_exist(self):
"""nfpm.yaml must have overrides.rpm for per-format deltas."""
content = _read("packaging/nfpm.yaml")
assert "overrides:" in content, "overrides section missing from nfpm.yaml"
assert "rpm:" in content, "rpm overrides missing from nfpm.yaml"
def test_rpm_scripts_configured(self):
"""RPM must use the dedicated scriptlets, not deb scripts."""
content = _read("packaging/nfpm.yaml")
assert "packaging/rpm/post.sh" in content, \
"RPM postinstall must use rpm/post.sh"
assert "packaging/rpm/preun.sh" in content, \
"RPM preremove must use rpm/preun.sh"
assert "packaging/rpm/postun.sh" in content, \
"RPM postremove must use rpm/postun.sh"
def test_rpm_depends_use_correct_syntax(self):
"""RPM dependencies must use rpm-style version syntax."""
content = _read("packaging/nfpm.yaml")
assert "python3 >= 3.10" in content, \
"RPM depends must use rpm-style version constraint"
# ---------------------------------------------------------------------------
# Tests — Makefile signing targets
# ---------------------------------------------------------------------------
class TestMakefileSigningTargets:
"""Verify that the Makefile exposes signing-related targets."""
def _makefile_content(self) -> str:
return _read("Makefile")
def test_generate_test_key_target(self):
content = self._makefile_content()
assert "generate-test-key:" in content, \
"Makefile must have a generate-test-key target"
assert "packaging-test@bongbetic.com" in content or \
"packaging@bongbetic.com" in content, \
"generate-test-key must reference the packaging key UID"
def test_sign_rpm_target(self):
content = self._makefile_content()
assert "sign-rpm:" in content, \
"Makefile must have a sign-rpm target"
assert "rpmsign" in content, \
"sign-rpm target must use rpmsign"
def test_checksums_target(self):
content = self._makefile_content()
assert "checksums:" in content, \
"Makefile must have a checksums target"
assert "sha256sum" in content, \
"checksums target must use sha256sum"
def test_clearsign_target(self):
content = self._makefile_content()
assert "clearsign:" in content, \
"Makefile must have a clearsign target"
assert "--clearsign" in content, \
"clearsign target must use gpg --clearsign"
def test_release_depends_on_signing(self):
content = self._makefile_content()
for line in content.splitlines():
if line.startswith("release:"):
deps = line.split(":", 1)[1].strip()
assert "sign-rpm" in deps, \
"release target must depend on sign-rpm"
assert "clearsign" in deps, \
"release target must depend on clearsign"
break
else:
pytest.fail("release target not found in Makefile")
def test_packaging_key_uid_defined(self):
content = self._makefile_content()
assert "PACKAGING_KEY" in content, \
"Makefile must define PACKAGING_KEY variable"
def test_release_mentions_key_ceremony(self):
content = self._makefile_content()
assert "signing-key-ceremony.md" in content, \
"release target must reference the key ceremony doc"
# ---------------------------------------------------------------------------
# Tests — fenris.repo configuration
# ---------------------------------------------------------------------------
class TestFenrisRepo:
"""Verify the dnf repo file is correctly configured for Fenris."""
def _repo_content(self) -> str:
return _read("packaging/fenris.repo")
def test_gpgcheck_enabled(self):
content = self._repo_content()
assert "gpgcheck=1" in content, \
"fenris.repo must set gpgcheck=1 for payload verification"
def test_repo_gpgcheck_disabled(self):
content = self._repo_content()
assert "repo_gpgcheck=0" in content, \
"fenris.repo must set repo_gpgcheck=0 (metadata check via TLS)"
def test_gpgkey_points_to_packaging_key(self):
content = self._repo_content()
assert "gpgkey=" in content, \
"fenris.repo must have a gpgkey directive"
assert "fenris-packaging.asc" in content, \
"gpgkey must point at the packaging key"
assert "raw/branch/main" in content, \
"gpgkey must use raw URL for the public key"
def test_baseurl_is_fenris_rpm_group(self):
content = self._repo_content()
assert "rpm/fenris" in content, \
"baseurl must point at the fenris RPM group"
# ---------------------------------------------------------------------------
# Tests — public key publication
# ---------------------------------------------------------------------------
class TestKeyPublication:
"""Verify the public key is published in-repo with correct metadata."""
def test_key_file_exists(self):
key_path = REPO_ROOT / "packaging" / "keys" / "fenris-packaging.asc"
assert key_path.exists(), \
"packaging/keys/fenris-packaging.asc must exist"
def test_key_file_has_raw_url(self):
content = _read("packaging/keys/fenris-packaging.asc")
raw_url = (
"https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/"
"packaging/keys/fenris-packaging.asc"
)
assert raw_url in content, \
"Key file must contain its own raw URL as documentation"
def test_key_file_documents_algorithm(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "RSA 3072" in content or "rsa3072" in content.lower(), \
"Key file must document the algorithm as RSA 3072"
def test_key_file_documents_uid(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "Fenris Packaging" in content, \
"Key file must document the UID"
def test_key_file_documents_expiry(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "2 year" in content or "2-year" in content or "expiry" in content.lower(), \
"Key file must document the expiry policy"
def test_key_file_references_ceremony_doc(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "signing-key-ceremony.md" in content, \
"Key file must reference the key ceremony document"
# ---------------------------------------------------------------------------
# Tests — key ceremony documentation
# ---------------------------------------------------------------------------
class TestKeyCeremonyDoc:
"""Verify the key ceremony document is complete and accurate."""
def _doc_content(self) -> str:
return _read("docs/install/signing-key-ceremony.md")
def test_ceremony_doc_exists(self):
assert (REPO_ROOT / "docs" / "install" / "signing-key-ceremony.md").exists(), \
"docs/install/signing-key-ceremony.md must exist"
def test_documents_key_specification(self):
content = self._doc_content()
assert "RSA 3072" in content, "Must document RSA 3072 algorithm"
assert "Fenris Packaging" in content, "Must document the UID"
assert "packaging@bongbetic.com" in content, "Must document the email"
def test_documents_import_sign_delete(self):
content = self._doc_content()
assert "import" in content.lower(), "Must document import step"
assert "sign" in content.lower(), "Must document sign step"
assert "delete" in content.lower(), "Must document delete step"
def test_documents_rotation_outline(self):
content = self._doc_content()
assert "rotation" in content.lower(), \
"Must document key rotation procedure"
def test_documents_dual_key_approach(self):
content = self._doc_content()
assert "previous" in content.lower() or "old" in content.lower(), \
"Must document old key retention during rotation"
def test_documents_private_key_storage(self):
content = self._doc_content()
assert "password manager" in content.lower(), \
"Must document that private key lives in password manager"
# ---------------------------------------------------------------------------
# Tests — consumer setup documentation
# ---------------------------------------------------------------------------
class TestConsumerDocs:
"""Verify consumer setup docs are present and correctly wired."""
def _readme_content(self) -> str:
return _read("README.md")
def test_apt_signed_by_flow(self):
content = self._readme_content()
assert "signed-by" in content, \
"README must document apt signed-by keyring flow"
assert "keyrings" in content, \
"README must show the keyrings directory"
def test_apt_fingerprint_placeholder(self):
content = self._readme_content()
assert "Fingerprint" in content or "fingerprint" in content, \
"README must include fingerprint placeholder for TOFU hardening"
def test_dnf_repo_flow(self):
content = self._readme_content()
assert "dnf config-manager --add-repo" in content or \
"dnf install" in content, \
"README must document dnf install flow"
assert "fenris.repo" in content, \
"README must reference the Fenris-owned repo file"
def test_no_gitea_auto_repo(self):
"""Gitea's auto-generated .repo must never be referenced in docs."""
content = self._readme_content()
# The Gitea auto-generated repo would have gpgcheck=1 against the
# instance key, which is a trap. Our docs should only reference
# our own fenris.repo file.
assert "auto-generated" not in content.lower() or \
"never" in content.lower(), \
"README must not recommend Gitea's auto-generated .repo"
def test_package_signature_verification(self):
content = self._readme_content()
assert "rpm -K" in content or "rpm --checksig" in content, \
"README must document RPM signature verification"
assert "gpg --verify" in content, \
"README must document GPG verification for SHA256SUMS"
def test_migration_from_make_install(self):
content = self._readme_content()
assert "migrate-from-makeinstall" in content.lower() or \
"migration" in content.lower(), \
"README must reference the migration runbook"
# ---------------------------------------------------------------------------
# Tests — release spec references
# ---------------------------------------------------------------------------
class TestReleaseSpecReferences:
"""Verify the release spec references the ceremony doc and nfpm config."""
def _spec_content(self) -> str:
return _read("docs/spec/release-packaging.md")
def test_spec_references_rpmsign(self):
content = self._spec_content()
assert "rpmsign" in content.lower() or "sign-rpm" in content, \
"Spec must reference rpmsign or make sign-rpm for RPM signing"
def test_spec_references_ceremony_doc(self):
content = self._spec_content()
assert "signing-key-ceremony.md" in content, \
"Spec must reference the key ceremony document"
# ---------------------------------------------------------------------------
# Tests — RPM signature mechanics (throwaway test key, no network)
# ---------------------------------------------------------------------------
class TestRpmSignatureMechanics:
"""Verify RPM signing mechanics using a throwaway test key.
These tests generate a temporary GPG key, build an RPM (or use an
existing one), sign it, and verify the signature — all without
network access. They require gpg and rpmsign to be available.
"""
@pytest.mark.skipif(
not _gpg_available() or not _rpmsign_available(),
reason="gpg or rpmsign not available",
)
def test_throwaway_key_signs_and_verifies(self):
"""Generate a throwaway key, sign a test RPM, verify signature."""
# Find existing RPM
version = None
for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines():
if line.startswith("version"):
version = line.split("=")[1].strip().strip('"')
break
rpm_path = REPO_ROOT / "dist" / f"fenris-{version}-1.x86_64.rpm"
if not rpm_path.exists():
pytest.skip("RPM not built — run `make package-rpm` first")
key_uid = "fenris-test-signing@example.com"
try:
# Generate throwaway key
subprocess.run(
["gpg", "--batch", "--gen-key"],
input=f"""%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: {key_uid}
Name-Email: {key_uid}
Expire-Date: 0
%commit
""",
text=True, check=True, timeout=30,
)
# Copy RPM to temp dir for signing
with tempfile.TemporaryDirectory() as tmpdir:
signed_rpm = Path(tmpdir) / rpm_path.name
signed_rpm.write_bytes(rpm_path.read_bytes())
# Sign the RPM
subprocess.run(
["rpmsign", "--addsign",
"--define", f"_gpg_name {key_uid}",
str(signed_rpm)],
check=True, timeout=30,
)
# Verify the signature exists and has correct format
# (rpm -Kv returns NOKEY if key isn't imported, but the
# signature header is still present and verifiable)
r = subprocess.run(
["rpm", "-Kv", str(signed_rpm)],
capture_output=True, text=True, timeout=10,
)
output = r.stdout + r.stderr
assert "RSA" in output or "rsa" in output.lower(), \
f"RPM must have RSA signature: {output}"
assert "SHA256" in output or "sha256" in output.lower(), \
f"RPM must have SHA256 digest: {output}"
assert "Header V4" in output or "Header" in output, \
f"RPM must have V4 signature header: {output}"
assert "Signature" in output, \
f"RPM must show signature info: {output}"
finally:
# Clean up the test key
subprocess.run(
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
capture_output=True, timeout=5,
)
subprocess.run(
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
capture_output=True, timeout=5,
)
@pytest.mark.skipif(
not _gpg_available(),
reason="gpg not available",
)
def test_clearsign_and_verify(self):
"""Clearsign a test manifest and verify the signature."""
key_uid = "fenris-test-clearsign@example.com"
try:
# Generate throwaway key
subprocess.run(
["gpg", "--batch", "--gen-key"],
input=f"""%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: {key_uid}
Name-Email: {key_uid}
Expire-Date: 0
%commit
""",
text=True, check=True, timeout=30,
)
with tempfile.TemporaryDirectory() as tmpdir:
sums = Path(tmpdir) / "SHA256SUMS"
sums.write_text(
"abc123 fenris_0.3.0_amd64.deb\n"
"def456 fenris-0.3.0-1.x86_64.rpm\n"
)
# Clearsign
subprocess.run(
["gpg", "--batch", "--yes", "--clearsign",
"--local-user", key_uid, str(sums)],
check=True, timeout=10,
)
# Verify (clearsigned file — just one argument to --verify)
r = subprocess.run(
["gpg", "--verify", str(sums.with_suffix(".asc"))],
capture_output=True, text=True, timeout=10,
)
assert r.returncode == 0, \
f"Clearsign verification failed: {r.stderr}"
assert "Good signature" in r.stderr, \
f"Expected Good signature: {r.stderr}"
finally:
subprocess.run(
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
capture_output=True, timeout=5,
)
subprocess.run(
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
capture_output=True, timeout=5,
)
+195
View File
@@ -0,0 +1,195 @@
"""Observation store migration unit tests (issue #48).
Tests the forward-only migration logic that underpins package upgrade
semantics: older stores are migrated, current stores pass through, and
newer stores are refused loudly.
Spec: §3.6, §9.5, §10.2
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import (
SCHEMA_VERSION,
init_store,
migrate_to_latest,
)
from fenris.status import NewerSchema, open_store_readonly
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_store(path: Path, version: int = 0) -> sqlite3.Connection:
"""Create a store at *path* with the given user_version."""
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
if version == 0:
# Fresh DB with no schema — user_version defaults to 0
pass
else:
# Create a minimal schema so the DB is valid, then set version
conn.execute("""
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
device TEXT NOT NULL
)
""")
conn.execute(f"PRAGMA user_version={version}")
conn.commit()
return conn
# ---------------------------------------------------------------------------
# migrate_to_latest
# ---------------------------------------------------------------------------
class TestMigrateToLatest:
"""Forward-only migration via migrate_to_latest()."""
def test_migrates_from_zero(self, tmp_path):
"""Store at user_version=0 → SCHEMA_VERSION (fresh DB)."""
db = tmp_path / "observations.db"
_make_store(db, version=0)
steps = migrate_to_latest(db)
# SCHEMA_VERSION - 0 = SCHEMA_VERSION migration steps
assert steps == SCHEMA_VERSION
# Verify version was bumped
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION
def test_already_current_returns_zero(self, tmp_path):
"""Store already at SCHEMA_VERSION → 0 steps applied."""
db = tmp_path / "observations.db"
conn = _make_store(db, version=SCHEMA_VERSION)
conn.close()
steps = migrate_to_latest(db)
assert steps == 0
def test_refuses_newer_store(self, tmp_path):
"""Store with user_version > SCHEMA_VERSION → ValueError."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError, match="newer Fenris"):
migrate_to_latest(db)
def test_refuses_much_newer_store(self, tmp_path):
"""Store several versions ahead → ValueError."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 5)
with pytest.raises(ValueError, match="newer Fenris"):
migrate_to_latest(db)
def test_store_not_corrupted_on_refusal(self, tmp_path):
"""After refusal, store is unchanged (no silent corruption)."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 2)
with pytest.raises(ValueError):
migrate_to_latest(db)
# Version should be unchanged
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 2
def test_idempotent_on_current(self, tmp_path):
"""Calling migrate_to_latest twice on a current store is safe."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION)
assert migrate_to_latest(db) == 0
assert migrate_to_latest(db) == 0
def test_migrates_intermediate_version(self, tmp_path):
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
db = tmp_path / "observations.db"
_make_store(db, version=1)
# SCHEMA_VERSION is 1, so version 1 is current
steps = migrate_to_latest(db)
assert steps == 0
# ---------------------------------------------------------------------------
# init_store — downgrade refusal
# ---------------------------------------------------------------------------
class TestInitStoreDowngradeRefusal:
"""init_store() refuses newer-schema stores."""
def test_refuses_newer_store(self, tmp_path):
"""init_store raises ValueError on newer-schema store."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError, match="newer Fenris"):
init_store(db)
def test_store_not_corrupted_on_refusal(self, tmp_path):
"""After init_store refusal, store is unchanged."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError):
init_store(db)
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 1
# ---------------------------------------------------------------------------
# open_store_readonly — downgrade refusal
# ---------------------------------------------------------------------------
class TestOpenStoreReadonlyDowngradeRefusal:
"""open_store_readonly() raises NewerSchema on newer-schema stores."""
def test_raises_newer_schema(self, tmp_path):
"""Newer store → NewerSchema exception."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(NewerSchema) as exc_info:
open_store_readonly(db)
assert exc_info.value.version == SCHEMA_VERSION + 1
def test_store_not_corrupted(self, tmp_path):
"""After NewerSchema refusal, store is unchanged."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 3)
with pytest.raises(NewerSchema):
open_store_readonly(db)
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 3
def test_current_store_opens(self, tmp_path):
"""Store at SCHEMA_VERSION opens without error."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION)
conn = open_store_readonly(db)
assert conn is not None
conn.close()
+527
View File
@@ -0,0 +1,527 @@
"""Headless tests for the Panes TUI (issue #28).
Covers:
- CI-1: Exhaustive state matrix from synthetic stores
- TUI-1: One dense keyboard-first screen with four normative regions
- TUI-4: Layout regions normative per register
- CI-4: Six disclosures verbatim, empty-store greeting, first-run opt-in
- IN-3: First-run TUI prompt enables timer and opens first period
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from textual.app import App
from textual.pilot import Pilot
from fenris.store import init_store, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
ConfidenceState,
compute_projection,
DISCLOSURES,
WARMING_MIN_DAYS,
STALENESS_HOURS,
YOUNG_REGIME_DAYS,
)
from fenris.status import (
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
grade_freshness,
)
from fenris.tui import (
FenrisTuiApp,
_format_remaining,
_sparkline,
_habit_bar,
_query_usage_history,
_query_drive_health,
_query_service_facts,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model,
1024000000000, "machine_match" if verified else None, verified,
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Unit tests for helpers
# ---------------------------------------------------------------------------
class TestFormatRemaining:
def test_hours_only(self):
assert _format_remaining(3600) == "1 h"
def test_days_and_hours(self):
assert _format_remaining(86400) == "1 d 0 h"
def test_years(self):
assert _format_remaining(31557600) == "1 yr 0 d 0 h"
def test_zero(self):
assert _format_remaining(0) == "endurance exhausted"
def test_negative(self):
assert _format_remaining(-100) == "endurance exhausted"
class TestSparkline:
def test_empty(self):
assert _sparkline([]) == ""
def test_single_value(self):
result = _sparkline([100.0])
assert len(result) == 1
def test_multiple_values(self):
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
assert len(result) > 0
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
def test_width_limit(self):
result = _sparkline([1.0] * 100, width=20)
assert len(result) <= 20
class TestHabitBar:
def test_all_active(self):
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
assert "active 100%" in result
def test_mixed(self):
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
assert "active 50%" in result
assert "idle 30%" in result
def test_all_unknown(self):
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
assert "unknown 100%" in result
# ---------------------------------------------------------------------------
# Data query tests
# ---------------------------------------------------------------------------
class TestQueryUsageHistory:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_usage_history(conn)
assert result["num_days"] == 0
assert result["sparkline"] == ""
conn.close()
def test_with_days(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
result = _query_usage_history(conn)
assert result["num_days"] == 14
assert result["sparkline"] != ""
conn.close()
class TestQueryDriveHealth:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_drive_health(conn)
assert result["model"] == "unknown"
conn.close()
def test_with_sample(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_sample(conn, "2026-09-30T10:00:00+00:00", pu=10)
result = _query_drive_health(conn)
assert result["percentage_used"] == 10
conn.close()
class TestQueryServiceFacts:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
now = _clock()
result = _query_service_facts(conn, now)
assert result["freshness"] == "empty"
conn.close()
def test_fresh_sample(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
result = _query_service_facts(conn, now)
assert result["freshness"] == "fresh"
conn.close()
# ---------------------------------------------------------------------------
# CI-1: Exhaustive state matrix from synthetic stores
# ---------------------------------------------------------------------------
class TestStateMatrix:
"""TUI renders every realizable combination of confidence state × freshness × baseline tier."""
def test_unsupported_no_baseline(self, tmp_path):
"""No baseline → Unavailable."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.headline_remaining_seconds is None
conn.close()
def test_limited_warming(self, tmp_path):
"""Warming up (< 14 days) → Limited."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.headline_remaining_seconds is not None
conn.close()
def test_supported_full(self, tmp_path):
"""Full data → Supported."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.SUPPORTED
assert proj.headline_remaining_seconds is not None
conn.close()
def test_stale_freshness(self, tmp_path):
"""Stale data: newest day aggregate > 48h old → Limited or Unsupported."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
# Segment opened long ago so days are old
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
_open_period(conn, start="2026-08-01T00:00:00+00:00")
# Days all end on Aug 30 — 31 days before clock (Sept 30)
for i in range(30):
d = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
# Sample also old
stale_ts = (_clock() - timedelta(days=31)).isoformat()
_insert_sample(conn, stale_ts)
proj = compute_projection(conn, _clock())
# Stale data (> 48h since newest day) → not Supported
assert proj.confidence_state != ConfidenceState.SUPPORTED
conn.close()
def test_empty_store_state(self, tmp_path):
"""Empty store → no projection, headline=None."""
conn = init_store(tmp_path / "test.db")
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.headline_remaining_seconds is None
conn.close()
# ---------------------------------------------------------------------------
# TUI-1: One dense keyboard-first screen
# ---------------------------------------------------------------------------
class TestDenseScreen:
"""TUI renders one dense screen with four normative regions."""
@pytest.mark.asyncio
async def test_four_regions_exist(self, tmp_path):
"""All four normative regions are present in the DOM."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
@pytest.mark.asyncio
async def test_headline_contains_projection(self, tmp_path):
"""Headline band shows projection headline."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
@pytest.mark.asyncio
async def test_confidence_rendered_as_evidence(self, tmp_path):
"""Confidence is state + contributing facts, never a percentage."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "projection confidence" in headline.lower()
# Contributing facts shown, never a percentage as confidence
# (percentage in "95% interval coverage" is allowed as a fact, not as confidence)
@pytest.mark.asyncio
async def test_service_strip_has_four_facts(self, tmp_path):
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
strip = str(app.query_one("#service-strip").render())
assert "boot:" in strip
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
# ---------------------------------------------------------------------------
# CI-4: Disclosures, empty-store greeting, first-run
# ---------------------------------------------------------------------------
class TestDisclosuresAndGreeting:
@pytest.mark.asyncio
async def test_disclosures_screen(self, tmp_path):
"""Disclosures view renders six disclosures verbatim."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
await pilot.press("d")
# Modal should be pushed
assert len(app.screen_stack) > 1
disc_text = str(app.screen.query_one("Static").render())
for i in range(1, 7):
assert "%d." % i in disc_text
@pytest.mark.asyncio
async def test_empty_store_greeting(self, tmp_path):
"""Empty store shows 'no observations yet' with enable hint."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "no observations yet" in headline.lower()
assert "enable" in headline.lower() or "resume" in headline.lower()
# ---------------------------------------------------------------------------
# IN-3: First-run opt-in
# ---------------------------------------------------------------------------
class TestFirstRun:
@pytest.mark.asyncio
async def test_first_run_prompt(self, tmp_path):
"""First-run prompt enables timer and opens first period."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "no observations yet" in headline.lower()
# The enable hint should mention resume
assert "resume" in headline.lower()
# ---------------------------------------------------------------------------
# TUI-4: Layout regions normative
# ---------------------------------------------------------------------------
class TestLayoutNormative:
@pytest.mark.asyncio
async def test_no_page_navigation(self, tmp_path):
"""No page navigation keys exist (variant switching was prototype-only)."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# Check that only production bindings exist
binding_keys = {b.key for b in app.BINDINGS}
assert "left" not in binding_keys
assert "right" not in binding_keys
assert "1" not in binding_keys
assert "2" not in binding_keys
assert "3" not in binding_keys
@pytest.mark.asyncio
async def test_pause_resume_asymmetry(self, tmp_path):
"""Pause asks, resume does not (TUI-2)."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Pause should push a confirmation screen
await pilot.press("p")
assert len(app.screen_stack) > 1
# Press n to cancel
await pilot.press("n")
assert len(app.screen_stack) == 1
# ---------------------------------------------------------------------------
# CI-1: State matrix exhaustive combinations
# ---------------------------------------------------------------------------
class TestStateMatrixCombinations:
"""CI-1: confidence × freshness × baseline tier combinations."""
def test_unsupported_with_fresh_data(self, tmp_path):
"""Fresh data but no baseline → Unavailable + fresh."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Sample must be within FRESH_THRESHOLD_S of clock
fresh_ts = (_clock() - timedelta(seconds=FRESH_THRESHOLD_S - 10)).isoformat()
_insert_sample(conn, fresh_ts)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
freshness = grade_freshness(fresh_ts, _clock())
assert freshness == "fresh"
conn.close()
def test_limited_with_stale_data(self, tmp_path):
"""Stale data with baseline → Limited or Unsupported (young regime + stale)."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00")
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
stale_ts = (_clock() - timedelta(days=3)).isoformat()
_insert_sample(conn, stale_ts)
proj = compute_projection(conn, _clock())
assert proj.confidence_state != ConfidenceState.SUPPORTED
freshness = grade_freshness(stale_ts, _clock())
assert freshness == "stale"
conn.close()
def test_supported_with_unverified_baseline(self, tmp_path):
"""Incomplete provenance → unverified baseline tier."""
conn = init_store(tmp_path / "test.db")
# Insert baseline with incomplete provenance (missing source_url)
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(1.0, None, "v1.0", "2026-01-01", "Samsung SSD 970 EVO Plus 1TB",
1024000000000, None, False,
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
conn.commit()
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
proj = compute_projection(conn, _clock())
# Incomplete provenance → UNVERIFIED tier
assert proj.baseline_tier.value == "unverified_override"
conn.close()
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Fenris NVMe collection service
Documentation=https://git.bongbetic.com/xavierk/Fenris
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect
TimeoutStartSec=90
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Fenris collection timer
Documentation=https://git.bongbetic.com/xavierk/Fenris
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=30s
Persistent=no
[Install]
WantedBy=timers.target