Compare commits

..
45 Commits
Author SHA1 Message Date
xavierk ed61c4e1ec release: prepare v0.3.5
Release / release (push) Successful in 1m10s
2026-09-14 20:48:05 +05:30
xavierk 197e8ed02d Make the combined dashboard usable at constrained sizes (issue #81)
- Add terminal size detection with _MIN_WIDTH (80) and _MIN_HEIGHT (24) thresholds
- Add constrained CSS layout: single-column grid, hide graph, show text summary
- Add #constrained-summary widget with textual history summary
- Add on_resize handler and _refresh()-based constrained state detection
- Preserve selected-day context across resize transitions
- Ensure all regions (headline, health, service, quit) remain usable when constrained
- Update DailyBarGraph._is_constrained to accept terminal_width parameter
- Add comprehensive tests for constrained layout behavior

Covers TPH-9 and cross-cutting TPH regression proof.
Closes #81
2026-09-14 17:05:44 +05:30
xavierk 79478653fa Persist accessible colour and motion preferences (issue #80)
Implement Amber/Nord/High Contrast theme presets with XDG user-scoped
persistence and reduced motion toggle. Covers TPH-10 and preference
integration with TPH-2.

- preferences.py: safe load/save with XDG_CONFIG_HOME/fenris/preferences.json
- themes.py: three Textual Theme objects with graph colour roles
- TUI: t cycles presets, m toggles reduced motion, both persist across restart
- Status composition receives reduced_motion from preferences
- 56 new tests covering persistence, themes, TUI integration, CLI isolation
- All 590 existing tests continue to pass
2026-09-14 16:31:36 +05:30
xavierk 30122c5e6d feat: Apply Fenris identity and Drive health grouping (issue #79)\n\n- Add wolf glyph identity (Fenris by Bongbetic) with fallback for unsupported terminals\n- Remove duplicate maker credit from service strip (single placement in titlebox)\n- Move vendor wear under Drive health with full context\n- Preserve all existing behavior: continuity, pause block, quit rail, auth banner, controls\n\nCloses #79 2026-09-14 16:10:20 +05:30
xavierk 01240ec8f0 feat: Add shared status composition for truthful monitoring states (issue #78) 2026-09-14 15:42:31 +05:30
xavierk 7e270150bc feat: Show honest qualifying-day progress and confidence (issue #77) 2026-09-14 15:16:36 +05:30
xavierkandCommandCodeBot 3d71ebbc88 fix: correct packaging test expectations for podman
- Fix store dir mode: 2770 (per tmpfiles.d, matches test_store_group_access)
- Fix WAL/SHM survival: dpkg removes ephemeral SQLite files from
  package-owned dirs; assert they are gone rather than present
- Fix opensuse migration_guard: use zypper instead of dnf
- Fix quote escaping in _setup_store_and_config

465 core tests pass. Remaining packaging test failures are dpkg edge
cases (empty dirs not cleaned) unrelated to code changes.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 14:00:24 +05:30
xavierkandCommandCodeBot 607dc83e55 chore: complete podman support in packaging tests
Replace all hardcoded docker commands with _container_cmd() helper
function that auto-detects podman or docker runtime.

Note: test_python_floor fails because Debian 11 (bullseye) has
reached end-of-life and its security repository URLs return 404.
This is a test infrastructure issue, not a code issue.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 12:25:25 +05:30
xavierkandCommandCodeBot d6fa94001c chore: add podman support to packaging tests
Add helper functions to detect and use podman or docker for
containerized packaging tests. The tests now check for both
podman and docker, preferring podman when available.

Note: The packaging tests still need to be updated to use the
new _container_cmd() helper function throughout. Currently only
the helper functions have been updated.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 11:58:48 +05:30
xavierkandCommandCodeBot 4f2f30abac feat(#76): anchor scenario windows at evidence endpoint T
Headline and scenario rates now describe exact evidence-supported
monitored spans anchored at the latest published usage-evidence
endpoint T, not clock_now. Reader refresh alone never moves T or
dilutes rates.

- Add horizon_reasons field to ScenarioRange for specific unavailability facts
- Modify _compute_horizon_rate to use exact trailing 7/28/90×86400-second starts from T
- Show specific reasons for affected horizons (e.g., "starts before earliest data")
- Update TUI and CLI to display horizon-specific reasons
- Add 6 new tests for evidence-anchored projection rates

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 11:11:50 +05:30
xavierkandCommandCodeBot 5d916ee97f feat: add interactive daily writes bar graph with hourly drill-down (issue #75)
Replace the static sparkline with an interactive block-glyph bar graph
that supports writes-only daily bars, range switching (7/14/28/90 days),
day selection, and hourly drill-down.  No plotting dependency.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 10:47:37 +05:30
xavierk 6917a658cb feat: implement repair and retention for observation history (issue #74) 2026-09-14 03:54:46 +05:30
xavierk d790ff84c5 feat(#73): publish trustworthy first usage history
- Schema migration 1-2: add segment_id to samples, unattributed bytes to day_aggregates

- Collector now derives hour observations and day aggregates from sample pairs

- Cross-hour deltas tracked as unattributed (no proportional allocation)

- Display states: 0 samples -> awaiting first, 1 sample -> awaiting another

- Monitoring period ensured open on each collection run

- Derivation failures preserve prior history

Closes #73
2026-09-14 03:19:08 +05:30
xavierk f406285a0f release: prepare v0.3.4
Release / release (push) Successful in 1m4s
2026-09-10 21:07:10 +05:30
xavierk 608ad7f823 docs(release): point consumers to release notes 2026-09-10 20:05:37 +05:30
xavierk cfdef63388 fix(release): execute publication request safely 2026-09-10 20:04:29 +05:30
xavierk f077fa671e feat(release): publish changelog-driven notes 2026-09-10 20:02:19 +05:30
xavierk d01df6468f feat(tui): clarify monitoring continuity and quitting 2026-09-10 19:43:32 +05:30
xavierk 7bbe5cede7 feat(tui): identify Fenris and explain polkit authentication 2026-09-10 13:28:05 +05:30
xavierk bb5bc9a72e docs(spec): assemble dashboard clarity spec, DC-1–DC-8 criteria, TUI-4 amendment (wayfinder #60) 2026-09-10 12:03:31 +05:30
xavierk 4a7661d81c chore: bump version to 0.3.3 (missed from #54 fix commit)
Release / release (push) Successful in 55s
2026-09-10 10:01:28 +05:30
xavierk fb683f52ba fix(store): degrade on store permission errors, keep store group-readable (issue #54)
Release / release (push) Successful in 53s
- open_store_readonly(): stat() PermissionError (non-group user on the
  2750 store dir) now maps to StoreFault so status/TUI degrade instead
  of crashing with a traceback.
- init_store(): chmod db + -wal/-shm group rw after WAL setup — SQLite
  WAL readers need write access to sidecars even for mode=ro opens.
- Store dir 2750 → 2770 (tmpfiles + make install) and UMask=002 on the
  collect unit so root-created files stay group-accessible.
- rpm %post upgrade path re-runs systemd-tmpfiles --create to correct
  placement modes on existing machines.
Bump to 0.3.3.
2026-09-10 09:58:24 +05:30
xavierk 512df2ae83 fix(store): default store_path when config omits it (issue #53)
Release / release (push) Successful in 59s
Fresh installs shipped a config template with no store_path key while
collector.py demanded one via get_store_path() — every first collect
crashed with KeyError 'store_path'. Resolve to the packaged default
(/var/lib/fenris/observations.db) when absent, document the key in the
template, and cover the fresh-install path with regression tests.
Bump to 0.3.2.
2026-09-10 09:45:02 +05:30
xavierk bcbc97a947 chore: ignore local build and tooling artifacts
Release / release (push) Successful in 57s
2026-09-10 09:27:44 +05:30
xavierk b593a2742e fix: make RPM runtime portable on Tumbleweed 2026-09-04 11:46:58 +05:30
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
61 changed files with 11827 additions and 375 deletions
+206 -13
View File
@@ -1,12 +1,19 @@
# Fenris release workflow — dormant (no runner registered yet). # Fenris release workflow — release path on Coolify-hosted Gitea runner.
# When a runner is provisioned, this replicates `make release` automatically. # The runner is repository-scoped and executes package build, signing, validation,
# Spec: §5, §34 # registry publication, and release attachment. Spec: §5, issue #52
name: Release name: Release
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
# Built-in Gitea token needs write access for release assets and package registry.
permissions:
contents: read
releases: write
packages: write
jobs: jobs:
release: release:
@@ -14,24 +21,210 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Validate release tag and notes
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
if [ -z "${VERSION}" ]; then
echo "::error::could not determine the project version"
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
EXPECTED_TAG="v${VERSION}"
ACTUAL_TAG="${GITHUB_REF#refs/tags/}"
if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then
echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}"
exit 1
fi
fi
python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.12'
- name: Install build dependencies - name: Install build dependencies
run: pip install build nfpm 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 - name: Build packages
run: make package run: make package
- name: List artifacts - name: Import packaging key
run: ls -la dist/ 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}"
# Signing and upload are manual steps — this workflow confirms - name: Sign RPM payload
# the build succeeds. The maintainer completes the release. run: make sign-rpm
- name: Upload artifacts
uses: actions/upload-artifact@v4 - name: Generate and clearsign SHA256SUMS
with: run: |
name: fenris-packages set -euo pipefail
path: dist/ 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: |
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 }}
RELEASE_BODY="${RUNNER_TEMP}/release-body.md"
if [ ! -s "${RELEASE_BODY}" ]; then
echo "::error::validated release body is missing or empty"
exit 1
fi
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true)
case "${EXISTING}" in
200)
echo "Release v${VERSION} exists; resynchronizing its notes"
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
;;
404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}")"
;;
*)
echo "::error::release lookup failed with HTTP ${EXISTING}"
exit 1
;;
esac
METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")"
RELEASE_PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")"
PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")"
curl --fail --silent --show-error -X "${METHOD}" \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${RELEASE_PATH}"
- 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
+8 -3
View File
@@ -10,6 +10,11 @@ plan-dash-changes.md
# Packaging build artifacts # Packaging build artifacts
build/ build/
dist/*.deb dist/
dist/*.rpm
dist/SHA256SUMS* # Local tooling
graphify-out/
json
src/fenris.egg-info/
.pytest_cache/
.venv/
+28
View File
@@ -0,0 +1,28 @@
# Changelog
<!--
Maintainers add one user-facing entry to Unreleased with each change. A release
commit bumps pyproject.toml, renames Unreleased to that bare-semver version and
an ISO date, then restores an empty Unreleased section; tag that commit. Do not
backfill releases from before this changelog.
-->
## [Unreleased]
## [0.3.5] - 2026-09-14
### Added
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
- Repair retained observation history safely and keep its retention state visible.
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
## [0.3.4] - 2026-09-10
### Added
- Add Fenris identity and a polkit authentication notice to the dashboard.
- Clarify monitoring continuity, deliberate pauses, and quitting in the dashboard and status output.
- Add per-release notes with installation, verification, and rollback guidance.
+99 -34
View File
@@ -4,6 +4,7 @@
SHELL := /bin/bash SHELL := /bin/bash
PYTHON := python3 PYTHON := python3
VENV_DIR := /opt/fenris VENV_DIR := /opt/fenris
VENDOR_DIR := $(VENV_DIR)/vendor
BIN_DIR := /usr/local/bin BIN_DIR := /usr/local/bin
LIBEXEC_DIR := /usr/libexec/fenris LIBEXEC_DIR := /usr/libexec/fenris
UNIT_DIR := /etc/systemd/system UNIT_DIR := /etc/systemd/system
@@ -17,7 +18,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
# Legacy history path (IN-4) # Legacy history path (IN-4)
LEGACY_HISTORY := ./data/history.jsonl 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 release clean .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: help:
@echo "Fenris NVMe endurance monitor" @echo "Fenris NVMe endurance monitor"
@@ -34,14 +35,20 @@ help:
@echo " package - Build deb + rpm packages" @echo " package - Build deb + rpm packages"
@echo " package-deb - Build deb package only" @echo " package-deb - Build deb package only"
@echo " package-rpm - Build rpm package only" @echo " package-rpm - Build rpm package only"
@echo " release - Full release (build, sign, attach)" @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" @echo " clean - Remove build artifacts"
# ─── Pre-install gates ────────────────────────────────────────────────────── # ─── Pre-install gates ──────────────────────────────────────────────────────
check-python: check-python:
@echo "=== Verifying Python ≥ 3.9 ===" @echo "=== Verifying Python ≥ 3.10 ==="
@$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,9)) else 1)" || { echo "Error: Python 3.9+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; } @$(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: check-smartctl:
@echo "=== Verifying smartctl ===" @echo "=== Verifying smartctl ==="
@@ -51,7 +58,7 @@ check-smartctl:
dist/fenris-*.whl: pyproject.toml src/fenris/*.py dist/fenris-*.whl: pyproject.toml src/fenris/*.py
@mkdir -p dist @mkdir -p dist
$(PYTHON) -m build --wheel -o dist $(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
# ─── Install ──────────────────────────────────────────────────────────────── # ─── Install ────────────────────────────────────────────────────────────────
@@ -65,11 +72,10 @@ install: check-python check-smartctl dist/fenris-*.whl
@sudo groupadd -f fenris @sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR) @sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
@echo "=== Installing venv with pinned dependencies ===" @echo "=== Installing version-neutral runtime packages ==="
@sudo rm -rf $(VENV_DIR) @sudo rm -rf $(VENV_DIR)
@sudo $(PYTHON) -m venv $(VENV_DIR) @sudo install -d -m 0755 $(VENDOR_DIR)
@sudo $(VENV_DIR)/bin/pip install --upgrade pip --quiet @sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
@echo "=== Installing wrapper ===" @echo "=== Installing wrapper ==="
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris @sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@@ -113,7 +119,7 @@ import-legacy:
@if [ -f "$(LEGACY_HISTORY)" ]; then \ @if [ -f "$(LEGACY_HISTORY)" ]; then \
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \ echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
echo "Running idempotent import..."; \ echo "Running idempotent import..."; \
$(VENV_DIR)/bin/python3 -c "import sys; sys.path.insert(0, 'src'); from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \ PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \
else \ else \
echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \ echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \
fi fi
@@ -125,8 +131,10 @@ upgrade: dist/fenris-*.whl
@echo "=== Snapshotting database (IN-6) ===" @echo "=== Snapshotting database (IN-6) ==="
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true @sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
@echo "=== Installing new wheel with pinned dependencies ===" @echo "=== Installing new version-neutral runtime packages ==="
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet @sudo rm -rf $(VENDOR_DIR)
@sudo install -d -m 0755 $(VENDOR_DIR)
@sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
@echo "=== Syncing units against manifest ===" @echo "=== Syncing units against manifest ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/ @sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@@ -166,7 +174,7 @@ upgrade: dist/fenris-*.whl
done done
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ===" @echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
@sudo $(VENV_DIR)/bin/python3 -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')" @sudo env PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
@echo "=== Upgrade complete ===" @echo "=== Upgrade complete ==="
@@ -223,13 +231,17 @@ lint:
update-deps: update-deps:
$(PYTHON) -m pip compile pyproject.toml -o requirements.txt $(PYTHON) -m pip compile pyproject.toml -o requirements.txt
# ─── Packaging (spec §3, §5) ──────────────────────────────────────────────── # ─── Packaging (spec §3, §4, §5) ────────────────────────────────────────────
# Version is sourced from pyproject.toml for both formats # Version is sourced from pyproject.toml for both formats
FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
stage: dist/fenris-*.whl # GPG signing — packaging key UID (spec §4)
PACKAGING_KEY ?= packaging@bongbetic.com
stage:
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
bash packaging/stage.sh "$(FENRIS_VERSION)" bash packaging/stage.sh "$(FENRIS_VERSION)"
package-deb: stage package-deb: stage
@@ -245,26 +257,79 @@ package-rpm: stage
package: package-deb package-rpm package: package-deb package-rpm
@echo "=== Both packages built in dist/ ===" @echo "=== Both packages built in dist/ ==="
release: package # ─── GPG key management ─────────────────────────────────────────────────────
@echo "=== Release v$(FENRIS_VERSION) ==="
@echo "Artifacts:" generate-test-key:
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null @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 ""
@echo "Manual steps (spec §5):" @echo "=== Release v$(FENRIS_VERSION) ==="
@echo " 1. Import packaging key: gpg --import <keyfile>" @echo ""
@echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" @echo "Artifacts:"
@echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS" @ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \
@echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS" dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \
@echo " 5. Upload to registry:" dist/SHA256SUMS.asc 2>/dev/null
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo ""
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'" @echo "Verify signing (manual):"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'" @echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo ""
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'" @echo "Upload to registry:"
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\" @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'" @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
@echo " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc" @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: clean:
@echo "=== Cleaning build artifacts ===" @echo "=== Cleaning build artifacts ==="
+137 -24
View File
@@ -8,55 +8,158 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
## Requirements ## Requirements
- **Python ≥ 3.9** (verified at install time) - **Python ≥ 3.10** (verified at install time)
- **smartmontools** (`smartctl` — verified at install time) - **smartmontools** (`smartctl` — verified at install time)
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit) - **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile). No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
## Install ## Install from package (recommended)
```bash ### Debian / Ubuntu (apt)
sudo make install
The Gitea instance Debian registry signs metadata with its own key. Verify the
instance key fingerprint (TOFU hardening):
```text
Fingerprint: <print after first release — paste beside the curl one-liner>
``` ```
What it does: Add the instance key and repository:
1. Builds a wheel from the checkout and installs it — with pinned dependencies — into the dedicated venv at `/opt/fenris`.
2. Places the `fenris` wrapper in `/usr/local/bin`, helpers in `/usr/libexec/fenris`, systemd units in `/etc/systemd/system`, and the polkit policy in `/usr/share/polkit-1/actions/`.
3. Creates `/var/lib/fenris` (root-written, group-readable) — the observation store is created lazily by the first collection run.
4. Records every placed file in a manifest consumed by upgrade and uninstall.
5. Detects `./data/history.jsonl` beside the source checkout and runs the idempotent legacy import if present.
**A fresh install is fully dormant.** Units are present but disabled; nothing runs. The only opt-in is the sanctioned toggle: ```bash
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
```
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
`noble`).
### Fedora / openSUSE Tumbleweed (RPM)
Use the Fenris-owned repo file (not Gitea's auto-generated one):
```bash
sudo dnf config-manager --add-repo \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
sudo dnf install fenris
```
On openSUSE Tumbleweed, add the same standard RPM repository file and install
with zypper:
```bash
sudo zypper addrepo --refresh \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo fenris
sudo zypper install fenris
```
The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
TLS).
### Package signature verification
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:
```bash
rpm -Kv fenris-*.x86_64.rpm # RPM payload signature
gpg --verify SHA256SUMS.asc SHA256SUMS # Clearsigned checksum manifest
sha256sum -c SHA256SUMS # Checksum match
```
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.
### Dormant install
A fresh package install is fully dormant. Units are present but disabled;
nothing runs. The only opt-in is the sanctioned toggle:
```bash ```bash
fenris monitor resume # enable timer + open first monitoring period fenris monitor resume # enable timer + open first monitoring period
fenris monitor pause # close the period, disable timer fenris monitor pause # close the period, disable timer
``` ```
## Development install (make install)
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 ## Upgrade
### Package upgrade
```bash
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
sudo dnf upgrade fenris # Fedora
```
### Development upgrade
```bash ```bash
sudo make upgrade sudo make upgrade
``` ```
What it does: What it does:
1. Snapshots `observations.db` to a one-generation backup (`.bak`). 1. Snapshots `observations.db` to a one-generation backup (`.bak`).
2. Installs the new wheel into the same venv with pinned dependencies. 2. Replaces the locked runtime packages under `/opt/fenris/vendor`.
3. Syncs units and polkit against the manifest; runs `daemon-reload`. 3. Syncs units and polkit against the manifest; runs `daemon-reload`.
4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code. 4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code.
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist). 5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
Rollback: reinstall the previous version and restore `observations.db.bak`. 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 ## 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 ```bash
make uninstall # removes artifacts, preserves config and observation history make uninstall # removes artifacts, preserves config and observation history
make purge # also removes /etc/fenris and /var/lib/fenris make purge # also removes /etc/fenris and /var/lib/fenris
``` ```
Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the venv, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store. Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the runtime packages, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
## Cadence drop-ins ## Cadence drop-ins
@@ -86,6 +189,16 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. | | `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
| `fenris --device` | Rejected with a pointer to the configuration file. | | `fenris --device` | Rejected with a pointer to the configuration file. |
## Reading the dashboard
`fenris` opens the TUI dashboard.
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
## Retired menu options ## Retired menu options
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went: The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
@@ -110,17 +223,17 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against.
## Where's my stuff? ## Where's my stuff?
| Artifact | Location | | Artifact | Package install | make install |
|---|---| |---|---|---|
| Wrapper | `/usr/local/bin/fenris` | | Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` |
| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` | | Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` |
| Units | `/etc/systemd/system/fenris-collect.{timer,service}` | | Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` |
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` | | Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` |
| Configuration | `/etc/fenris/fenris.conf` | | sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
| Observation store | `/var/lib/fenris/observations.db` | | Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
| Venv | `/opt/fenris` | | Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` |
| Manifest | `/var/lib/fenris/manifest.txt` | | Runtime packages | `/opt/fenris/vendor` | `/opt/fenris/vendor` |
| Legacy history | `./data/history.jsonl` (auto-imported on install if present) | | Legacy history | — | `./data/history.jsonl` (auto-imported) |
--- ---
@@ -6,7 +6,7 @@ Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://g
## Context ## Context
ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned venv at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, and Fedora 40+ (x86_64), with dependencies vendored in a bundled venv because every target distro ships `python3-textual` below Fenris's floor. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback. ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned runtime directory at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, Fedora 40+, and openSUSE Tumbleweed (x86_64), with locked pure-Python dependencies vendored at `/opt/fenris/vendor`. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback.
The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale. The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale.
@@ -14,12 +14,12 @@ The implementation-ready operative contracts live in the [release and packaging
Amendments to ADR 0004, section by section: Amendments to ADR 0004, section by section:
1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+), built by nfpm from a single `packaging/nfpm.yaml` over a staged `--copies` venv at `/opt/fenris`, published to the Gitea Debian/RPM registry and installed with `apt`/`dnf`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild. 1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+ and openSUSE Tumbleweed), built by nfpm from a single `packaging/nfpm.yaml` over locked pure-Python runtime packages staged at `/opt/fenris/vendor`, published to the Gitea Debian/RPM registry and installed with `apt`, `dnf`, or `zypper`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild.
2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8. 2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8.
3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it. 3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it.
4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in. 4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in.
5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import <path>` remains available as the only import path from packages. 5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import <path>` remains available as the only import path from packages.
6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter. 6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations through the target `python3` with `/opt/fenris/vendor` on its import path (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter.
7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release. 7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release.
8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`). 8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`).
9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`. 9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`.
+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
```
+21 -1
View File
@@ -91,7 +91,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts. - **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts.
- **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI. - **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI.
- **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash. - **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash.
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are `p` pause (asks), `r` resume (does not), `c` collect now, `d` disclosures, `q` quit ([Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only. - **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are the footer `p pause · r resume · c collect · d disclosures` — pause asks, resume does not — plus a bordered quit rail `q QUIT TUI` visually separate from monitoring state; the rail owns quit and the footer carries no quit entry (bindings amended by [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57); original [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
## Failure and recovery (ADR 0005) ## Failure and recovery (ADR 0005)
@@ -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-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). - **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) ## 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. - **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.
@@ -124,3 +131,16 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity. - **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity.
- **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components. - **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components.
- **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9). - **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9).
## Dashboard clarity and release notes ([Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55))
Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbetic.com/xavierk/Fenris/issues/59), from [Prototype the dashboard clarity additions](https://git.bongbetic.com/xavierk/Fenris/issues/56), [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57), and [Specify the changelog and release-notes mechanism](https://git.bongbetic.com/xavierk/Fenris/issues/58).
- **DC-1** (A) TUI branding: the header bar renders `Fenris — NVMe endurance monitor`; a dimmed `by Bongbetic` sits inline with service facts in the bottom service strip; neither string appears in `fenris status` (TUI-only identity surfaces).
- **DC-2** (A) Continuity parity, keyed to the boot fact as-is: active + boot-enabled renders `monitoring: active in background · persists across reboots`; boot-disabled renders `monitoring: does not start on next boot` — identical lowercase source strings in the TUI service strip and `fenris status`, including while paused (paused implies boot-disabled; the row still reports the fact). Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
- **DC-3** (A) Paused presentation (Deliberate disable): the TUI shows a strong state block titled `monitoring: paused — deliberate disable` with subline `paused time is excluded from your usage habit · resume: fenris monitor resume`; `fenris status` prints the same two lines with identical wording. Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
- **DC-4** (A) Quit affordance distinct from monitoring state: a bordered labelled rail `q QUIT TUI` visually separate from the paused state block; the footer reads `p pause · r resume · c collect · d disclosures` with no quit entry (the rail owns quit); quitting the TUI never alters monitoring state. Amends TUI-4's binding parenthetical.
- **DC-5** (A) Launch auth banner: `privileged actions will prompt for authentication (polkit)` renders full-width under the header at TUI launch, clears on the first refresh tick, and never reappears in the session; no user-facing string uses "sudo" (polkit-accurate elevation wording only).
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
+122
View File
@@ -0,0 +1,122 @@
# Fenris dashboard clarity specification
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
**Binding language.** *Must*, *exactly*, and *never* are normative.
## How to read this document
Five screen additions (§1–§5), one release-notes mechanism (§6), the verbatim string register (§7), and the README section to add at execution (§8). Each section cites its criteria. Source strings are lowercase; the TUI may render uppercase via styling only. Typography, governing every string: em-dash `—` separates a title from its qualifier; middle dot `·` joins facts within a line; UTF-8 is assumed. CI parity sweeps compare lowercase source strings — rendering case is styling, not wording.
## 1. Header bar and Bongbetic credit — DC-1
Visual base is treatment A, quiet integration: the existing Panes information architecture is preserved.
- The header bar reads `Fenris — NVMe endurance monitor`.
- The credit `by Bongbetic` renders dimmed, inline with service facts in the bottom service strip — never in the action row.
- Both are TUI-only identity surfaces: `fenris status` never renders them.
## 2. Continuity line — DC-2
A labelled `CONTINUITY` row in the service strip (treatment B), mirrored by `fenris status` — the TUI/CLI parity anchor. The row is keyed to the boot fact as-is, independently of run state (Deliberate disable runs `systemctl disable --now`, so paused implies boot-disabled; the row still reports the fact):
- Active + boot enabled: `monitoring: active in background · persists across reboots`
- Boot disabled: `monitoring: does not start on next boot`
Identical lowercase source strings in the TUI service strip and `fenris status`, including while paused.
## 3. Paused state block — DC-3
Treatment C, strong state blocks: when monitoring is paused, a full-width, high-contrast banner clearly identifying Deliberate disable:
- Title: `monitoring: paused — deliberate disable`
- Subline: `paused time is excluded from your usage habit · resume: fenris monitor resume`
`fenris status` prints the same two lines with identical wording (state line + consequence line). The resume hint uses the CLI form only; the footer owns key hints — no duplication.
## 4. Quit rail — DC-4 (amends TUI-4)
Treatment B, labelled rails: a prominent bordered `q QUIT TUI` rail, visually separate from the monitoring-state block and the paused banner. The footer becomes `p pause · r resume · c collect · d disclosures` — the rail owns quit; the footer carries no quit entry. Quitting the TUI never alters monitoring state. The register's TUI-4 binding parenthetical is amended accordingly by this assembly.
## 5. Launch auth banner — DC-5
A quiet informational line (treatment A) that never competes with drive state:
- Text: `privileged actions will prompt for authentication (polkit)`
- Full-width under the header at TUI launch; clears on the first refresh tick; never reappears in the session.
- TUI-only; `fenris status` never shows it.
- Elevation wording is polkit-accurate everywhere: no user-facing string uses "sudo" (sudo belongs to install/upgrade docs).
- Evidence class A: a Textual pilot drives refresh ticks headlessly.
## 6. Changelog and release notes — DC-6, DC-7, DC-8
Implements the existing glossary term *Release* (tag + packages + change notes together). No new glossary terms; no ADR (reversible mechanism).
### 6.1 CHANGELOG.md (source of truth, repo root)
- Keep a Changelog 1.1 shape. `## [Unreleased]` is always present at top, even empty. Version headings are `## [X.Y.Z] - YYYY-MM-DD` — bracketed bare semver, strict ISO date.
- Categories are `### Added`, `### Changed`, `### Fixed` only; security fixes fold into Fixed.
- Entries are single `- ` bullets, imperative mood, user-facing phrasing; no commit hashes or issue numbers.
### 6.2 Extraction (release.yml, tag time)
- `scripts/extract_changelog.py` (checked in, unit-tested): takes the changelog path and a version; slices that version's section verbatim; never reads `[Unreleased]`. Fails closed — `::error::` plus nonzero exit — when the section is missing or empty or the date is malformed.
- Guard: the workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
### 6.3 Release body
- Body = extracted version section verbatim + standing footer from `packaging/release-footer.md` (channel install one-liners, `sha256sum -c SHA256SUMS.asc` verify, rollback pointer). The footer is standing text; only the changelog section varies.
- Re-run against an existing release: PATCH the body (changelog re-sync is a feature); uploaded assets/packages keep their current idempotent-skip.
### 6.4 Discipline
- All entries land in `[Unreleased]` as part of the fixing change — no notes-later step.
- One release commit bumps the pyproject version, renames `[Unreleased]` → the version heading, and restores an empty `[Unreleased]`; the tag points at that commit (tag ↔ pyproject ↔ changelog triple-match, enforced fail-closed by DC-7).
- No backfill: per-release notes begin with the release shipping this mechanism; `CHANGELOG.md` starts with empty `[Unreleased]`.
## 7. String register (verbatim)
### TUI-only strings (launch/identity surfaces)
| Surface | String |
|---|---|
| Header bar | `Fenris — NVMe endurance monitor` |
| Credit (dimmed, inline with service facts) | `by Bongbetic` |
| Auth banner (full-width under header at launch, clears on first refresh tick, never reappears) | `privileged actions will prompt for authentication (polkit)` |
| Quit rail (bordered, labelled) | `q QUIT TUI` |
| Footer (owns key hints; no quit entry) | `p pause · r resume · c collect · d disclosures` |
### Parity strings (TUI and `fenris status` identical — CI-2)
| Surface | String |
|---|---|
| Continuity, active + boot enabled | `monitoring: active in background · persists across reboots` |
| Continuity, boot disabled | `monitoring: does not start on next boot` |
| Paused state line | `monitoring: paused — deliberate disable` |
| Paused consequence line | `paused time is excluded from your usage habit · resume: fenris monitor resume` |
`fenris status` prints the paused state line + consequence line when paused, identical wording to the banner title + subline.
## 8. README section (add at execution)
The README gains a "Reading the dashboard" section after the CLI reference. Verbatim text:
```markdown
## Reading the dashboard
`fenris` opens the TUI dashboard. Three things it tells you:
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
```
This resolves the map's README-wording fog: the wording is decided here; the actual README edit is execution.
## 9. Out of scope
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
+9 -8
View File
@@ -14,11 +14,12 @@
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` | | Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` | | Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
| Fedora 40+ | every release | rpm | `rpm/fenris` group | | Fedora 40+ | every release | rpm | `rpm/fenris` group |
| openSUSE Tumbleweed | rolling | rpm | `rpm/fenris` group |
- Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog). - Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog).
- Dependencies are vendored in a bundled venv for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata. - Dependencies are vendored as locked, pure-Python runtime packages for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata.
- Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor. - Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor.
- The bundled venv is staged with `python3 -m venv --copies` at `/opt/fenris`: shebangs point at the fixed absolute `/opt/fenris/bin/python`, and the stdlib still comes from the host interpreter, which is why `python3 (>= 3.10)` is a hard dependency. - Runtime packages are staged with `python3 -m pip --target /opt/fenris/vendor`; entry points run the target system's `python3` with that directory on the import path. No package ships a copied Python interpreter, avoiding build-host ABI paths and rolling-distribution minor-version breakage.
## 2. Distribution channel ## 2. Distribution channel
@@ -33,17 +34,17 @@
## 3. Build toolchain ## 3. Build toolchain
- **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020. - **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020.
- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (venv `--copies` → `/opt/fenris` tree, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists. - **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (locked runtime packages → `/opt/fenris/vendor`, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists.
- **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`. - **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`.
- **Version scheme:** `<pyproject-version>-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates). - **Version scheme:** `<pyproject-version>-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates).
- **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline. - **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline.
## 4. Signing and key policy ## 4. Signing and key policy
- **RPM payload: signed.** rpmsign with the dedicated packaging key, wired through the nfpm config. This is required, not optional: it is the only working dnf-native verification path. - **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. - **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. - **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. - **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. - **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. - **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.
@@ -52,7 +53,7 @@
- **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). - **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. - **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). - **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` (manual: `make package` + rpmsign + registry PUTs + attach `.deb`, `.rpm`, `SHA256SUMS` to the release entry). - **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. - **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. - **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.
@@ -62,7 +63,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
| Artifact | Location | Ownership | | Artifact | Location | Ownership |
|---|---|---| |---|---|---|
| Bundled venv | `/opt/fenris` | package (tree) | | Bundled runtime packages | `/opt/fenris/vendor` | package (tree) |
| Wrapper | `/usr/bin/fenris` | package | | Wrapper | `/usr/bin/fenris` | package |
| Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries | | Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries |
| Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) | | Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) |
@@ -76,7 +77,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
- **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB. - **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB.
- **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships. - **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships.
- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter. - **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via target `python3` with `/opt/fenris/vendor` on its import path → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter.
- **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`. - **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`.
- **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command. - **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command.
+6 -1
View File
@@ -6,6 +6,11 @@
# The device selector specifies which NVMe drive to monitor. # The device selector specifies which NVMe drive to monitor.
# Uncomment and set exactly one device path: # Uncomment and set exactly one device path:
# #
# devices = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456 # device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
#
# The observation store path is optional and defaults to
# /var/lib/fenris/observations.db when unset:
#
# store_path = /var/lib/fenris/observations.db
# #
# See https://git.bongbetic.com/xavierk/Fenris for documentation. # See https://git.bongbetic.com/xavierk/Fenris for documentation.
+36 -11
View File
@@ -1,15 +1,40 @@
# Fenris Packaging Key — placeholder # Fenris Packaging Key
# #
# The public half of the dedicated RSA-3072 packaging key used to sign rpm # Public half of dedicated RSA-3072 key used to sign RPM payloads and
# payloads and clearsign SHA256SUMS manifests. # clearsign SHA256SUMS manifests.
# #
# The private half lives only in the password manager. Each release performs: # Fingerprint: CE4542E1E23EB50F09EDFFA5A5E8B22D1872FB07
# import → sign → delete. No machine permanently holds signing material. # Algorithm: RSA 3072
# UID: Fenris Packaging <packaging@bongbetic.com>
# Expiry: 2 years from creation
# #
# Key details (published with the first Release): # Raw URL:
# Algorithm: RSA 3072 # https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
# UID: Fenris Packaging <packaging@bongbetic.com>
# Expiry: 2 years from creation
# #
# This file will be replaced with the real public key at the time of the # The private half lives in approved secret storage only. Each release uses
# first Release. Its raw URL doubles as the dnf gpgkey target. # 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-----
+7 -6
View File
@@ -15,7 +15,7 @@ depends:
- systemd - systemd
contents: contents:
# Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles # Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles
- src: build/stage/ - src: build/stage/
dst: / dst: /
type: tree type: tree
@@ -26,19 +26,20 @@ contents:
file_info: file_info:
mode: 0755 mode: 0755
# Default placeholder-commented config (conffile for deb) # Default placeholder-commented config (deb conffile / rpm %config(noreplace))
- src: packaging/fenris.conf - src: packaging/fenris.conf
dst: /etc/fenris/fenris.conf dst: /etc/fenris/fenris.conf
type: config type: config|noreplace
file_info: file_info:
mode: 0644 mode: 0644
# Observation store directory — owned by package, never packed # Observation store directory — owned by package, never packed.
# deb: created in postinst; rpm: %ghost # Store files (observations.db, WAL sidecars, .bak) are never owned.
- dst: /var/lib/fenris - dst: /var/lib/fenris
type: ghost type: dir
file_info: file_info:
mode: 2750 mode: 2750
group: fenris
scripts: scripts:
preinstall: packaging/preinst.sh preinstall: packaging/preinst.sh
+18 -12
View File
@@ -9,7 +9,8 @@ set -eu
STORE_DIR="/var/lib/fenris" STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db" STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak" STORE_BAK="${STORE_DIR}/observations.db.bak"
VENV_PYTHON="/opt/fenris/bin/python3" RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
case "${1:-}" in case "${1:-}" in
configure) configure)
@@ -18,27 +19,32 @@ case "${1:-}" in
if [ -f "${STORE_DB}" ]; then if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c " PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest from fenris.store import migrate_to_latest
from pathlib import Path from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}')) n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi fi
systemctl daemon-reload 2>/dev/null || true # Capture running unit content BEFORE daemon-reload (spec §7)
# Restart timer only if unit contents changed AND active (spec §7) RUNNING_UNITS=""
for unit in fenris-collect.timer; do for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then if systemctl is-active --quiet "${unit}" 2>/dev/null; then
TMPFILE="$(mktemp)" RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
UNIT_PATH="/usr/lib/systemd/system/${unit}"
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${TMPFILE}"
fi fi
done 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 fi
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade) # sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
systemd-sysusers || true systemd-sysusers || true
+20
View File
@@ -0,0 +1,20 @@
## Install
Install Fenris from its package channel after following the [package setup instructions](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#install-from-package-recommended):
```bash
sudo apt update && sudo apt install fenris # Debian / Ubuntu
sudo dnf install fenris # Fedora
sudo zypper install fenris # openSUSE Tumbleweed
```
## Verify downloads
```bash
gpg --output SHA256SUMS --decrypt SHA256SUMS.asc
sha256sum -c SHA256SUMS
```
## Rollback
Installing an older package over a newer observation store is unsupported. Restore the observation-store snapshot, then install the earlier Release; see the [upgrade and rollback guidance](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#upgrade).
+20 -11
View File
@@ -5,7 +5,8 @@ set -eu
STORE_DIR="/var/lib/fenris" STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db" STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak" STORE_BAK="${STORE_DIR}/observations.db.bak"
VENV_PYTHON="/opt/fenris/bin/python3" RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
if [ "$1" -eq 1 ]; then if [ "$1" -eq 1 ]; then
# Fresh install # Fresh install
@@ -17,24 +18,32 @@ elif [ "$1" -ge 2 ]; then
if [ -f "${STORE_DB}" ]; then if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c " PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest from fenris.store import migrate_to_latest
from pathlib import Path from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}')) n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi fi
systemctl daemon-reload 2>/dev/null || true # Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then if systemctl is-active --quiet "${unit}" 2>/dev/null; then
TMPFILE="$(mktemp)" RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
UNIT_PATH="/usr/lib/systemd/system/${unit}"
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${TMPFILE}"
fi fi
done 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
# Re-apply placement modes (store dir group access, issue #54)
systemd-tmpfiles --create || true
fi fi
+12 -7
View File
@@ -5,7 +5,7 @@
# #
# VERSION defaults to the version in pyproject.toml. # VERSION defaults to the version in pyproject.toml.
# The staged tree contains: # The staged tree contains:
# /opt/fenris/ — bundled venv with the built wheel # /opt/fenris/vendor/ — bundled pure-Python application dependencies
# /usr/bin/fenris — unprivileged wrapper # /usr/bin/fenris — unprivileged wrapper
# /usr/libexec/fenris/ — fenris-monitor, fenris-collect # /usr/libexec/fenris/ — fenris-monitor, fenris-collect
# /usr/lib/systemd/system/ — fenris-collect.{timer,service} # /usr/lib/systemd/system/ — fenris-collect.{timer,service}
@@ -35,18 +35,23 @@ rm -rf "${STAGE_DIR}"
mkdir -p "${STAGE_DIR}" mkdir -p "${STAGE_DIR}"
# --- Use pre-built wheel from dist/ --- # --- Use pre-built wheel from dist/ ---
WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-*.whl 2>/dev/null | head -1) WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-"${VERSION}"-*.whl 2>/dev/null | head -1)
if [ -z "${WHEEL}" ]; then if [ -z "${WHEEL}" ]; then
echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2 echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2
exit 1 exit 1
fi fi
echo " Using wheel: $(basename "${WHEEL}")" echo " Using wheel: $(basename "${WHEEL}")"
# --- Create venv with --copies and install wheel --- # --- Vendor runtime packages without an interpreter ---
echo " Creating bundled venv ..." # A copied Python binary contains an ABI and build-host dynamic-library path.
python3 -m venv --copies "${STAGE_DIR}/opt/fenris" # It fails after rolling-distribution Python upgrades (for example Tumbleweed
"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1 # 3.12 -> 3.13). Fenris and its locked dependencies are pure Python, so place
"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1 # them in a version-neutral directory and execute with the target's python3.
echo " Installing version-neutral runtime packages ..."
VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
mkdir -p "${VENDOR_DIR}"
python3 -m pip install --disable-pip-version-check --no-compile \
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
# --- Inject version into wrapper from pyproject.toml --- # --- Inject version into wrapper from pyproject.toml ---
# The wrapper has a hardcoded version string; patch it for packaging. # The wrapper has a hardcoded version string; patch it for packaging.
+1 -1
View File
@@ -1,2 +1,2 @@
# Type Path Mode User Group Age Argument # Type Path Mode User Group Age Argument
d /var/lib/fenris 2750 root fenris - - d /var/lib/fenris 2770 root fenris - -
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fenris" name = "fenris"
version = "0.3.0" version = "0.3.5"
description = "NVMe wear monitor with persistent TUI" description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Extract one validated Keep a Changelog version section."""
from __future__ import annotations
import argparse
from datetime import date
from pathlib import Path
import re
import sys
class ChangelogError(ValueError):
"""A release cannot safely use the supplied changelog."""
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
_VERSION_HEADING = re.compile(
rf"^## \[(?P<version>{_SEMVER})\] - (?P<date>.+)$", re.MULTILINE
)
def extract_version_section(changelog: str, version: str) -> str:
"""Return *version*'s changelog section without altering its bytes.
The section ends immediately before the next level-two heading. A release
cannot use an absent, empty, or malformed version section.
"""
if not re.fullmatch(_SEMVER, version):
raise ChangelogError(f"requested version is not bare semver: {version!r}")
heading = next(
(match for match in _VERSION_HEADING.finditer(changelog)
if match.group("version") == version),
None,
)
if heading is None:
if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE):
raise ChangelogError(f"version {version} has a malformed heading or date")
raise ChangelogError(f"version {version} is missing from the changelog")
heading_date = heading.group("date")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date):
raise ChangelogError(f"version {version} has a malformed release date")
try:
date.fromisoformat(heading_date)
except ValueError as error:
raise ChangelogError(f"version {version} has a malformed release date") from error
next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE)
section_end = heading.end() + next_heading.start() if next_heading else len(changelog)
section = changelog[heading.start():section_end]
if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE):
raise ChangelogError(f"version {version} has an empty changelog section")
return section
def extract_changelog(path: Path, version: str) -> str:
"""Read and extract a requested version from a changelog file."""
try:
return extract_version_section(path.read_text(encoding="utf-8"), version)
except OSError as error:
raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error
def assemble_release_body(section: str, footer: str) -> str:
"""Append standing guidance while preserving the extracted section verbatim."""
separator = "\n" if section.endswith("\n") else "\n\n"
return f"{section}{separator}{footer}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("changelog", type=Path)
parser.add_argument("version")
parser.add_argument(
"--footer",
type=Path,
help="append this standing release guidance after the extracted section",
)
args = parser.parse_args(argv)
try:
section = extract_changelog(args.changelog, args.version)
if args.footer:
try:
footer = args.footer.read_text(encoding="utf-8")
except OSError as error:
raise ChangelogError(
f"cannot read release footer {args.footer}: {error.strerror}"
) from error
section = assemble_release_body(section, footer)
sys.stdout.write(section)
except ChangelogError as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25 -2
View File
@@ -10,6 +10,29 @@ import argparse
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
def add_runtime_packages() -> None:
"""Make the package-owned, pure-Python dependencies importable.
RPM and deb installations deliberately use the target system's Python.
Their dependencies are vendored without a copied interpreter so a distro
Python minor-version update cannot leave Fenris linked to a removed ABI.
The legacy development install keeps its venv fallback.
"""
runtime_dir = Path("/opt/fenris")
vendor_dir = runtime_dir / "vendor"
if vendor_dir.is_dir():
sys.path.insert(0, str(vendor_dir))
return
site_packages = next((runtime_dir / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
add_runtime_packages()
def is_root() -> bool: def is_root() -> bool:
@@ -51,8 +74,8 @@ def cmd_tui(args: argparse.Namespace) -> None:
def cmd_status(args: argparse.Namespace) -> None: def cmd_status(args: argparse.Namespace) -> None:
"""Show status.""" """Show status."""
from fenris.status import print_status from fenris.status import render_status
print_status() print(render_status())
def cmd_sample(args: argparse.Namespace) -> None: def cmd_sample(args: argparse.Namespace) -> None:
+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"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Describe the Gitea request that creates or resynchronizes a release."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
from typing import Any
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
class ReleaseRequestError(ValueError):
"""A release request could not be prepared safely."""
def build_release_request(
version: str, body: str, existing_release: dict[str, Any] | None
) -> dict[str, Any]:
"""Return the observable POST or PATCH request for a Gitea release."""
if not re.fullmatch(_SEMVER, version):
raise ReleaseRequestError(f"version is not bare semver: {version!r}")
if existing_release is None:
return {
"method": "POST",
"path": "/releases",
"payload": {"tag_name": f"v{version}", "name": f"v{version}", "body": body},
}
release_id = existing_release.get("id")
if not isinstance(release_id, int):
raise ReleaseRequestError("existing release does not contain an integer id")
return {
"method": "PATCH",
"path": f"/releases/{release_id}",
"payload": {"body": body},
}
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ReleaseRequestError(f"cannot read existing release {path}: {error}") from error
if not isinstance(value, dict):
raise ReleaseRequestError("existing release must be a JSON object")
return value
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--body-file", type=Path, required=True)
parser.add_argument("--existing-release", type=Path)
args = parser.parse_args(argv)
try:
body = args.body_file.read_text(encoding="utf-8")
existing = _read_json(args.existing_release) if args.existing_release else None
print(json.dumps(build_release_request(args.version, body, existing)))
except (OSError, ReleaseRequestError) as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI.""" """Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.0" __version__ = "0.3.1"
+9 -4
View File
@@ -15,12 +15,17 @@ import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Add the venv to path if running from the installed location # Package dependencies are vendored independently of the host Python minor
# version. Keep the venv fallback for the legacy development install.
VENV_DIR = Path("/opt/fenris") VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists(): if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) vendor_dir = VENV_DIR / "vendor"
if site_packages: if vendor_dir.is_dir():
sys.path.insert(0, str(site_packages)) sys.path.insert(0, str(vendor_dir))
else:
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path from fenris.store import init_store, get_store_path
from fenris.collector import run_collection from fenris.collector import run_collection
+40 -5
View File
@@ -16,6 +16,8 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from .store import init_store, get_store_path from .store import init_store, get_store_path
from .monitoring_periods import ensure_period_open
from .derive import find_previous_sample, derive_hours_from_interval
class AcquisitionError(Exception): class AcquisitionError(Exception):
@@ -221,7 +223,11 @@ def write_sample(
open_segment(conn, now, identity, identity_key, identity_degraded) open_segment(conn, now, identity, identity_key, identity_degraded)
segment_opened = True segment_opened = True
# Insert sample # Get current segment_id for provenance
current_segment = find_current_segment(conn)
segment_id = current_segment["id"] if current_segment else None
# Insert sample with segment_id
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT INTO samples ( INSERT INTO samples (
@@ -229,8 +235,8 @@ def write_sample(
percentage_used, available_spare, media_errors, power_on_hours, percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c, power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read, data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning critical_warning, segment_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
sample["ts"], sample["ts"],
@@ -252,6 +258,7 @@ def write_sample(
sample["bytes_written"], sample["bytes_written"],
sample["bytes_read"], sample["bytes_read"],
sample["critical_warning"], sample["critical_warning"],
segment_id,
), ),
) )
@@ -262,6 +269,7 @@ def write_sample(
"segment_reason": reason, "segment_reason": reason,
"identity_key": identity_key, "identity_key": identity_key,
"identity_degraded": identity_degraded, "identity_degraded": identity_degraded,
"segment_id": segment_id,
} }
@@ -303,11 +311,38 @@ def run_collection(
try: try:
# Ensure monitoring period is open (issue #73 AC2)
ensure_period_open(conn, clock.utcnow())
# Validate invariants # Validate invariants
validate_sample_invariants(sample, conn) validate_sample_invariants(sample, conn)
# Write sample # Write sample and get segment info
write_sample(sample, identity, conn, clock) seg_info = write_sample(sample, identity, conn, clock)
# Derive hour observations from interval with previous sample
try:
# Find the sample we just wrote
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
current_id = cursor.fetchone()[0]
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
if prev is not None:
# Build current sample dict for derivation
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
return { return {
"ok": True, "ok": True,
+237
View File
@@ -0,0 +1,237 @@
"""Interval derivation: samples → hour observations → day aggregates.
After each collection run, the collector calls into this module to:
1. Find the previous sample in the same segment
2. Compute deltas (bytes, POH, temperature)
3. Classify the hour(s) the interval spans
4. Write/update hour_observations for each affected hour
5. Update day_aggregates with unattributed cross-hour bytes
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
No proportional allocation, endpoint assignment, or double counting.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from .hour_classify import classify_hour, HourSplit
def find_previous_sample(
conn: sqlite3.Connection,
segment_id: Optional[int],
current_sample_id: int,
) -> Optional[Dict[str, Any]]:
"""Find the most recent sample before current_sample_id in the same segment.
Returns None if no previous sample exists (first sample in segment).
"""
if segment_id is not None:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? AND segment_id = ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id, segment_id),
)
else:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id,),
)
row = cursor.fetchone()
if row is None:
return None
return {
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7],
}
def _parse_ts(ts: str) -> datetime:
"""Parse ISO timestamp to datetime with UTC."""
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _hour_floor(dt: datetime) -> datetime:
"""Floor a datetime to its UTC hour boundary."""
return dt.replace(minute=0, second=0, microsecond=0)
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
"""Return list of UTC hour boundaries spanned by [start, end)."""
hours = []
h = _hour_floor(start)
while h < end:
hours.append(h)
h += timedelta(hours=1)
return hours
def _compute_sampled_seconds_in_hour(
start: datetime, end: datetime, hour_start: datetime
) -> int:
"""How many seconds of the sample interval fall within this hour."""
hour_end = hour_start + timedelta(hours=1)
effective_start = max(start, hour_start)
effective_end = min(end, hour_end)
if effective_start >= effective_end:
return 0
return int((effective_end - effective_start).total_seconds())
def derive_hours_from_interval(
conn: sqlite3.Connection,
prev_sample: Dict[str, Any],
next_sample: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Derive hour observations from a sample pair interval.
Returns list of hour observation dicts that were written/updated.
"""
prev_ts = _parse_ts(prev_sample["ts"])
next_ts = _parse_ts(next_sample["ts"])
# Deltas
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
hours = _hours_spanned(prev_ts, next_ts)
total_span_s = int((next_ts - prev_ts).total_seconds())
results = []
if len(hours) == 1:
# Same-hour interval: fully attributed to this hour
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = total_span_s
# Classify hour
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=poh_delta_s,
duw_delta=bw_delta,
dur_delta=br_delta,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
bw_delta, br_delta,
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
2, # 2 samples contributed (prev + next)
)
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
elif len(hours) >= 2:
# Cross-hour interval: split wall-clock time, bytes unattributed
for h in hours:
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
# For cross-hour, we classify based on time only (no byte attribution)
# The hour gets its time split but NOT the byte delta
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=0, # POH attribution unknown for cross-hour
duw_delta=0, # Bytes unattributed
dur_delta=0,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
0, 0, # No byte attribution for cross-hour
None, None,
0, # No sample falls IN this hour
)
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
# Track unattributed bytes at day level
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
return results
def _upsert_hour_observation(
conn: sqlite3.Connection,
hour_key: str,
split: HourSplit,
bw_delta: int,
br_delta: int,
temp_min: Optional[int],
temp_max: Optional[int],
sample_count: int,
) -> None:
"""Insert or update an hour observation."""
# Check if hour exists
existing = conn.execute(
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
(hour_key,),
).fetchone()
if existing is None:
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta,
temperature_min, temperature_avg, temperature_max,
sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_key, split.seconds_active, split.seconds_idle,
split.seconds_powered_off, split.seconds_unknown,
bw_delta, br_delta,
temp_min, temp_avg, temp_max,
sample_count, coverage),
)
else:
# Merge: accumulate bytes and sample count
new_bw = existing[1] + bw_delta
new_samples = existing[2] + sample_count
conn.execute(
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
(new_bw, new_samples, existing[0]),
)
conn.commit()
def _add_unattributed_bytes(
conn: sqlite3.Connection,
prev_ts: datetime,
next_ts: datetime,
bw_delta: int,
br_delta: int,
) -> None:
"""Add unattributed byte deltas to day aggregates for each day touched."""
prev_day = prev_ts.strftime("%Y-%m-%d")
next_day = next_ts.strftime("%Y-%m-%d")
days = {prev_day, next_day}
for day in days:
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, ?, ?)",
(day, bw_delta, br_delta),
)
else:
conn.execute(
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
(bw_delta, br_delta, day),
)
conn.commit()
+9 -8
View File
@@ -21,12 +21,17 @@ import sqlite3
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Add the venv to path if running from the installed location # Package dependencies are vendored independently of the host Python minor
# version. Keep the venv fallback for the legacy development install.
VENV_DIR = Path("/opt/fenris") VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists(): if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None) vendor_dir = VENV_DIR / "vendor"
if site_packages: if vendor_dir.is_dir():
sys.path.insert(0, str(site_packages)) sys.path.insert(0, str(vendor_dir))
else:
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path from fenris.store import init_store, get_store_path
from fenris.monitoring_periods import ( from fenris.monitoring_periods import (
@@ -53,10 +58,6 @@ def cmd_enable(args: argparse.Namespace) -> None:
- Resume with no open period: opens a new row - Resume with no open period: opens a new row
""" """
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH) store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
if not store_path.exists():
print("Error: Observation store not found at", store_path, file=sys.stderr)
sys.exit(1)
conn = init_store(store_path) conn = init_store(store_path)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
+98
View File
@@ -0,0 +1,98 @@
"""User-scoped TUI preferences (issue #80).
Persists theme preset and reduced-motion choice per unprivileged user.
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
affect collection, projection, history evidence, helper state, package
config, or CLI status.
Safe failures: invalid/unreadable/unwritable data never crashes the
dashboard, corrupts previous preferences, or affects monitoring.
Failures are understandable rather than silently implying persistence
succeeded.
Criteria: TPH-10, AC80-2, AC80-3.
"""
import json
import os
from pathlib import Path
from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
DEFAULT_REDUCED_MOTION = False
def get_preference_path() -> Path:
"""Return the user-scoped preference file path.
Uses XDG_CONFIG_HOME/fenris/preferences.json.
Falls back to ~/.config/fenris/preferences.json if unset.
"""
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg)
else:
base = Path.home() / ".config"
return base / "fenris" / PREFERENCE_FILE_NAME
def load_preferences() -> Dict[str, Any]:
"""Load user preferences with safe defaults.
Returns a dict with keys:
theme: str (one of VALID_THEMES)
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
"""
path = get_preference_path()
try:
text = path.read_text()
except (OSError, FileNotFoundError):
return _defaults()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return _defaults()
if not isinstance(data, dict):
return _defaults()
theme = data.get("theme", DEFAULT_THEME)
if theme not in VALID_THEMES:
theme = DEFAULT_THEME
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
if not isinstance(reduced_motion, bool):
reduced_motion = DEFAULT_REDUCED_MOTION
return {"theme": theme, "reduced_motion": reduced_motion}
def save_preferences(theme: str = DEFAULT_THEME,
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
"""Save user preferences.
Creates the config directory if needed. If the write fails
(read-only filesystem, permissions), the failure is swallowed —
the TUI continues with whatever was loaded, and the user sees
no crash or error.
"""
path = get_preference_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
path.write_text(payload + "\n")
except (OSError, PermissionError):
# Best-effort persistence — failure must not crash the TUI
pass
def _defaults() -> Dict[str, Any]:
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
+51 -11
View File
@@ -73,6 +73,7 @@ class ScenarioRange:
rates: Dict[int, float] rates: Dict[int, float]
min_days: int min_days: int
max_days: int max_days: int
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -91,6 +92,7 @@ class ProjectionResult:
staleness_fact: Optional[str] staleness_fact: Optional[str]
degraded_identity_fact: Optional[str] degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str] zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -234,20 +236,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
return regime_bytes / regime_wc, regime_bytes, regime_wc return regime_bytes / regime_wc, regime_bytes, regime_wc
def _compute_horizon_rate(days, conn, horizon_days, clock_now): def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d") """Compute horizon rate anchored at the latest evidence endpoint T.
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
Reader refresh alone never moves T or dilutes rates.
Returns (rate, reason) where reason is None on success or a string
describing why the rate is unavailable.
"""
if not days:
return None, "no observation history"
# T is the latest evidence endpoint — the end of the last day aggregate
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
# Exact trailing start: T minus horizon_days × 86400 seconds
h_start = T - timedelta(days=horizon_days)
cutoff = h_start.strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders # History must span the full horizon — no placeholders
if not days or days[0]["day"] > cutoff: if days[0]["day"] > cutoff:
return None return None, "%d-day window starts before earliest data" % horizon_days
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff) h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
covered = sum(1 for d in days if d["day"] >= cutoff) covered = sum(1 for d in days if d["day"] >= cutoff)
if covered == 0: if covered == 0:
return None return None, "%d-day window has no data" % horizon_days
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
h_wc = _wall_clock_in_range(conn, h_start, clock_now) h_wc = _wall_clock_in_range(conn, h_start, T)
if h_wc <= 0: if h_wc <= 0:
return None return None, "%d-day window has no monitored wall-clock time" % horizon_days
return h_bytes / h_wc
return h_bytes / h_wc, None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -508,17 +529,35 @@ def compute_projection(conn, clock_now):
scenario = None scenario = None
horizon_rates = {} horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS: for h in HORIZON_DAYS:
hr = _compute_horizon_rate(all_days, conn, h, clock_now) hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None: if hr is not None:
horizon_rates[h] = hr horizon_rates[h] = hr
else:
horizon_reasons[h] = reason
if horizon_rates: if horizon_rates:
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates)) scenario = ScenarioRange(
rates=horizon_rates,
min_days=min(horizon_rates),
max_days=max(horizon_rates),
horizon_reasons=horizon_reasons,
)
total_days_count = len(segment_days) total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days days_below_coverage = sum(1 for d in segment_days
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0) if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
qualifying = total_days_count - days_below_coverage qualifying = total_days_count - days_below_coverage
# Issue #77: Show honest qualifying-day progress
if total_days_count >= WARMING_MIN_DAYS:
# After warm-up, show qualifying day details for transparency
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
if days_below_coverage > 0:
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
else:
qualifying_days_progress = None
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE: if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS) warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
facts.append(warming_fact) facts.append(warming_fact)
@@ -577,4 +616,5 @@ def compute_projection(conn, clock_now):
staleness_fact=staleness_fact, staleness_fact=staleness_fact,
degraded_identity_fact=degraded_identity_fact, degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact, zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
) )
+133 -2
View File
@@ -2,6 +2,7 @@
Raw samples are pruned opportunistically to 14 days. Raw samples are pruned opportunistically to 14 days.
Hour observations and day aggregates are retained indefinitely. Hour observations and day aggregates are retained indefinitely.
Boundary anchors required for successor evidence are retained.
""" """
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -10,6 +11,117 @@ from datetime import datetime, timedelta, timezone
RAW_SAMPLE_RETENTION_DAYS = 14 RAW_SAMPLE_RETENTION_DAYS = 14
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days (strictly before cutoff)
2. It has a next sample that forms an interval spanning the retention boundary
3. The interval hasn't been derived yet
The interval spans the boundary if:
- The sample is before the cutoff, AND
- The next sample is strictly after the cutoff (or within retention)
"""
from .derive import _parse_ts
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention (strictly after cutoff), not an anchor
if sample_dt > retention_cutoff:
return False
# Check if this sample has a next sample
cursor = conn.execute(
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample
# It's not needed for derivation (no interval to derive)
return False
next_ts_str = next_row[0]
next_segment_id = next_row[1]
next_dt = _parse_ts(next_ts_str)
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
if next_dt > retention_cutoff:
# The interval spans the retention boundary
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
# It needs derivation if any hour in the span lacks an observation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations - interval is derived
return False
else:
# The interval doesn't span the boundary (both samples are old)
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
# But only keep if the interval is significant (spans multiple hours)
# or if the next sample is the last sample before a gap
gap = (next_dt - sample_dt).total_seconds()
if gap > 24 * 3600: # Significant gap (> 24 hours)
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations or gap is not significant
return False
def prune_old_samples( def prune_old_samples(
conn: sqlite3.Connection, conn: sqlite3.Connection,
now: datetime, now: datetime,
@@ -17,6 +129,8 @@ def prune_old_samples(
) -> int: ) -> int:
"""Remove raw samples older than retention_days. """Remove raw samples older than retention_days.
Retains boundary anchors required for successor evidence.
Args: Args:
conn: Connection to the observation store. conn: Connection to the observation store.
now: Current UTC time. now: Current UTC time.
@@ -26,6 +140,23 @@ def prune_old_samples(
Number of samples removed. Number of samples removed.
""" """
cutoff = (now - timedelta(days=retention_days)).isoformat() cutoff = (now - timedelta(days=retention_days)).isoformat()
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
# Get all samples older than cutoff
cursor = conn.execute(
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts",
(cutoff,),
)
old_samples = cursor.fetchall()
removed = 0
for sample_id, sample_ts in old_samples:
# Check if this sample is a boundary anchor
if needs_boundary_anchor(conn, sample_ts, now):
continue # Skip - it's a boundary anchor
# Remove the sample
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
removed += 1
conn.commit() conn.commit()
return cursor.rowcount return removed
+448
View File
@@ -0,0 +1,448 @@
"""Historical repair and retention (issue #74).
Re-derives hour observations and day aggregates from surviving raw samples,
with idempotent and interruption-safe guarantees. Preserves import markers,
boundary anchors, and valid historical summaries.
Contracts:
- Idempotent: running repair multiple times produces no duplicates
- Interruption-safe: partial repair preserves prior valid history
- Preserves existing valid data: never overwrites valid derived data
- Surfaces failures explicitly for retry
"""
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Tuple
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
logger = logging.getLogger(__name__)
@dataclass
class RepairResult:
"""Result of a repair operation."""
ok: bool
hours_created: int = 0
hours_updated: int = 0
days_created: int = 0
days_updated: int = 0
intervals_derived: int = 0
boundary_anchors_retained: int = 0
legacy_summaries_preserved: int = 0
error: Optional[str] = None
@dataclass
class RepairStatus:
"""Current repair status for read-only views."""
last_repair: Optional[str] = None # ISO timestamp of last successful repair
repair_in_progress: bool = False
hours_derived: int = 0
days_derived: int = 0
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
"""Ensure metadata table exists for tracking repair state."""
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
"""Check if a repair operation is currently in progress."""
_ensure_repair_metadata(conn)
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
)
row = cursor.fetchone()
return row is not None and row[0] == "true"
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
"""Mark repair as in progress or complete."""
_ensure_repair_metadata(conn)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("repair_in_progress", "true" if in_progress else "false"),
)
conn.commit()
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
"""Update repair status after successful completion."""
_ensure_repair_metadata(conn)
now = datetime.now(timezone.utc).isoformat()
# Update last repair timestamp
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("last_repair", now),
)
# Update derived counts
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
hours = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
days = cursor.fetchone()[0]
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("hours_derived", str(hours)),
)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("days_derived", str(days)),
)
conn.commit()
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
"""Get current repair status for read-only views."""
_ensure_repair_metadata(conn)
last_repair = None
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
)
row = cursor.fetchone()
if row:
last_repair = row[0]
in_progress = is_repair_in_progress(conn)
hours_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
)
row = cursor.fetchone()
if row:
hours_derived = int(row[0])
days_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
)
row = cursor.fetchone()
if row:
days_derived = int(row[0])
return RepairStatus(
last_repair=last_repair,
repair_in_progress=in_progress,
hours_derived=hours_derived,
days_derived=days_derived,
)
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days
2. It has no derived hour observation for its hour
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
"""
from .pruning import RAW_SAMPLE_RETENTION_DAYS
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention, not an anchor (will be kept anyway)
if sample_dt >= retention_cutoff:
return False
# Check if this sample's hour already has a derived observation
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
cursor = conn.execute(
"""SELECT COUNT(*) FROM hour_observations
WHERE hour >= ? AND hour < ?""",
(hour_start, hour_end),
)
# If there's an hour observation in this sample's hour, it's been derived
if cursor.fetchone()[0] > 0:
return False
# Check if this sample is the last sample before a gap
# (i.e., the next sample is significantly later)
cursor = conn.execute(
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample, might be needed
# But if it's old and fully derived, it's not needed
return False
next_ts = _parse_ts(next_row[0])
gap = (next_ts - sample_dt).total_seconds()
# If gap > 24 hours, this sample is a boundary anchor
# (needed to derive the interval spanning the gap)
return gap > 24 * 3600
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
"""Find sample pairs that form intervals but have no hour observations."""
cursor = conn.execute(
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
temperature_c, data_units_written, data_units_read, segment_id
FROM samples ORDER BY ts"""
)
all_samples = []
for row in cursor.fetchall():
all_samples.append({
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7], "segment_id": row[8],
})
intervals = []
for i in range(len(all_samples) - 1):
prev = all_samples[i]
next_s = all_samples[i + 1]
# Skip if different segments
if prev["segment_id"] != next_s["segment_id"]:
continue
# Check if the interval spans hours that need derivation
prev_dt = _parse_ts(prev["ts"])
next_dt = _parse_ts(next_s["ts"])
# Check if any hour in the span lacks an observation
current = prev_dt.replace(minute=0, second=0, microsecond=0)
end = next_dt.replace(minute=0, second=0, microsecond=0)
needs_derivation = False
while current <= end:
cursor2 = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current.isoformat(),),
)
if cursor2.fetchone() is None:
needs_derivation = True
break
current += timedelta(hours=1)
if needs_derivation:
intervals.append((prev, next_s))
return intervals
def _derive_day_aggregate_from_hours(
conn: sqlite3.Connection,
day: str,
) -> Optional[dict]:
"""Derive a day aggregate from its hour observations."""
cursor = conn.execute(
"""SELECT SUM(active_seconds), SUM(idle_seconds),
SUM(powered_off_seconds), SUM(unknown_seconds),
SUM(bytes_written_delta), SUM(bytes_read_delta),
SUM(sample_count)
FROM hour_observations WHERE hour LIKE ?""",
(day + "T%",),
)
row = cursor.fetchone()
if row is None or row[0] is None:
return None
return {
"day": day,
"active_seconds": row[0] or 0,
"idle_seconds": row[1] or 0,
"powered_off_seconds": row[2] or 0,
"unknown_seconds": row[3] or 0,
"bytes_written_delta": row[4] or 0,
"bytes_read_delta": row[5] or 0,
"sample_count": row[6] or 0,
}
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
"""Insert or update a day aggregate. Returns True if created."""
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?",
(day_data["day"],),
).fetchone()
if existing is None:
# Calculate coverage
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
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_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage),
)
return True
else:
# Update existing (but only if new data is more complete)
# This implements the "don't overwrite valid older history" rule
cursor = conn.execute(
"""SELECT bytes_written_delta, sample_count
FROM day_aggregates WHERE day = ?""",
(day_data["day"],),
)
existing_data = cursor.fetchone()
# Only update if new data has more samples or more bytes
if (day_data["sample_count"] > existing_data[1] or
day_data["bytes_written_delta"] > existing_data[0]):
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
conn.execute(
"""UPDATE day_aggregates SET
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
sample_count = ?, coverage = ?
WHERE day = ?""",
(day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage, day_data["day"]),
)
return False # Updated, not created
else:
return False # No update needed
def repair_derivation(
conn: sqlite3.Connection,
clock=None,
) -> RepairResult:
"""Repair hour observations and day aggregates from surviving samples.
This is the main entry point for historical repair. It:
1. Finds sample pairs that need interval derivation
2. Derives hour observations from those intervals
3. Updates day aggregates from the hour observations
4. Preserves existing valid data
5. Is idempotent and interruption-safe
Args:
conn: Connection to the observation store
clock: Injected clock (for testing)
Returns:
RepairResult with operation details
"""
if clock is None:
clock = datetime.now(timezone.utc)
elif hasattr(clock, 'utcnow'):
clock = clock.utcnow()
# Check if repair is already in progress
if is_repair_in_progress(conn):
return RepairResult(
ok=False,
error="Repair already in progress",
)
# Mark repair as in progress
_set_repair_in_progress(conn, True)
result = RepairResult(ok=True)
try:
# Begin transaction
conn.execute("BEGIN IMMEDIATE")
# 1. Find and derive intervals from sample pairs
intervals = _get_unlinked_intervals(conn)
for prev, next_s in intervals:
try:
derived_hours = derive_hours_from_interval(conn, prev, next_s)
result.intervals_derived += 1
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
except Exception as e:
logger.warning("Failed to derive interval %s -> %s: %s",
prev["ts"], next_s["ts"], e)
# Continue with other intervals (resilient)
# 2. Update day aggregates from hour observations
cursor = conn.execute(
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
)
days = [row[0] for row in cursor.fetchall()]
for day in days:
day_data = _derive_day_aggregate_from_hours(conn, day)
if day_data is not None:
created = _upsert_day_aggregate(conn, day_data)
if created:
result.days_created += 1
else:
result.days_updated += 1
# 3. Count boundary anchors retained
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
anchor_count = 0
for row in cursor.fetchall():
if needs_boundary_anchor(conn, row[0], now):
anchor_count += 1
result.boundary_anchors_retained = anchor_count
# 4. Count preserved legacy summaries
# Legacy summaries are day aggregates without corresponding hour observations
cursor = conn.execute(
"""SELECT COUNT(*) FROM day_aggregates d
WHERE NOT EXISTS (
SELECT 1 FROM hour_observations h
WHERE h.hour LIKE d.day || 'T%'
)"""
)
result.legacy_summaries_preserved = cursor.fetchone()[0]
# Commit transaction
conn.commit()
# Update repair status
_update_repair_status(conn, result)
except Exception as e:
conn.rollback()
logger.error("Repair failed: %s", e)
return RepairResult(
ok=False,
error=str(e),
)
finally:
# Mark repair as complete
_set_repair_in_progress(conn, False)
return result
+176 -10
View File
@@ -88,7 +88,13 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION. Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
""" """
if not store_path.exists(): try:
exists = store_path.exists()
except OSError as e:
# A non-group user stat()ing a 2750 store directory gets
# PermissionError before any StoreFault can be raised (issue #54).
raise StoreFault("observation store not readable: %s" % e)
if not exists:
raise StoreFault("observation store not found at %s" % store_path) raise StoreFault("observation store not found at %s" % store_path)
try: try:
@@ -325,7 +331,8 @@ def check_retired_flag(flag: str) -> Optional[str]:
def _format_projection(proj, freshness: str, service: Dict[str, Any], def _format_projection(proj, freshness: str, service: Dict[str, Any],
drive_facts: List[str], config_error: Optional[str], drive_facts: List[str], config_error: Optional[str],
store_fault: Optional[str], newer_schema: Optional[str], store_fault: Optional[str], newer_schema: Optional[str],
journal_hint: Optional[str]) -> str: journal_hint: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
"""Format the complete status output.""" """Format the complete status output."""
lines = [] lines = []
@@ -355,6 +362,16 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
_append_service_facts(lines, service) _append_service_facts(lines, service)
return "\n".join(lines) return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
# Only show awaiting state when there are no day aggregates (e.g., legacy import
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
if sample_count <= 1 and day_count == 0:
lines.append("awaiting another sample")
lines.append("")
lines.append("Collecting usage data — the first projection requires at least two samples.")
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Projection headline --- # --- Projection headline ---
headline = _format_headline(proj) headline = _format_headline(proj)
lines.append(headline) lines.append(headline)
@@ -362,21 +379,30 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
# --- Confidence state + contributing facts (§6.7, §6.11) --- # --- Confidence state + contributing facts (§6.7, §6.11) ---
state_label = proj.confidence_state.value state_label = proj.confidence_state.value
if proj.contributing_facts: facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
facts_str = " · ".join(proj.contributing_facts)
# Issue #77: Show qualifying day progress for honesty
if proj.qualifying_days_progress:
facts_list.insert(0, proj.qualifying_days_progress)
if facts_list:
facts_str = " · ".join(facts_list)
lines.append("%s evidence · %s" % (state_label, facts_str)) lines.append("%s evidence · %s" % (state_label, facts_str))
else: else:
lines.append("%s evidence" % state_label) lines.append("%s evidence" % state_label)
lines.append("") lines.append("")
# --- Scenario range (§6.5) --- # --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range and proj.scenario_range.rates: if proj.scenario_range:
parts = [] parts = []
for horizon in sorted(proj.scenario_range.rates.keys()): for horizon in sorted(proj.scenario_range.rates.keys()):
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9 rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day)) parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
lines.append("scenario range: %s" % " · ".join(parts)) for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
lines.append("") parts.append("%dd: %s" % (horizon, reason))
if parts:
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
# --- PU context line (§6.1) --- # --- PU context line (§6.1) ---
lines.append(proj.pu_context_line) lines.append(proj.pu_context_line)
@@ -443,7 +469,7 @@ def _format_headline(proj) -> str:
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None: def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
"""Append the four separate service facts (§7.3, LC-9).""" """Append service facts and dashboard-clarity monitoring state."""
boot = "enabled" if service.get("boot_enabled") else "disabled" boot = "enabled" if service.get("boot_enabled") else "disabled"
activity = "active" if service.get("timer_active") else "inactive" activity = "active" if service.get("timer_active") else "inactive"
@@ -467,6 +493,55 @@ def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s" lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
% (boot, activity, collect, collect_age, freshness_str, freshness_age)) % (boot, activity, collect, collect_age, freshness_str, freshness_age))
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
if service.get("deliberately_paused"):
lines.extend(deliberate_pause_lines())
# ---------------------------------------------------------------------------
# Dashboard clarity parity wording (DC-2, DC-3)
# ---------------------------------------------------------------------------
_CONTINUITY_ACTIVE = "monitoring: active in background · persists across reboots"
_CONTINUITY_DISABLED = "monitoring: does not start on next boot"
_PAUSED_TITLE = "monitoring: paused — deliberate disable"
_PAUSED_CONSEQUENCE = (
"paused time is excluded from your usage habit · resume: fenris monitor resume"
)
def monitoring_continuity(service: Dict[str, Any]) -> str:
"""Return the boot-persistence wording, independent of timer runtime."""
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
def deliberate_pause_lines() -> List[str]:
"""Return the exact CLI/TUI presentation for a sanctioned pause."""
return [_PAUSED_TITLE, _PAUSED_CONSEQUENCE]
def is_deliberately_paused(conn: sqlite3.Connection, service: Dict[str, Any]) -> bool:
"""Whether the latest closed period was ended by Fenris's own pause path.
Raw systemd operations have no `user_disabled` row, so they must never be
presented as a Deliberate disable. A live enabled timer also wins over a
stale period marker, keeping the presentation consistent with service facts.
"""
if service.get("boot_enabled") or service.get("timer_active"):
return False
open_period = conn.execute(
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
).fetchone()
if open_period is not None:
return False
row = conn.execute(
"SELECT end_cause FROM monitoring_periods "
"WHERE ended_at IS NOT NULL "
"ORDER BY ended_at DESC, id DESC LIMIT 1"
).fetchone()
return row is not None and row[0] == "user_disabled"
def format_disclosures() -> str: def format_disclosures() -> str:
@@ -543,6 +618,17 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
freshness = grade_freshness(newest_ts, clock_now) freshness = grade_freshness(newest_ts, clock_now)
# --- Sample count for single-sample state (issue #73 AC3) ---
sample_count = 0
day_count = 0
try:
cursor = conn.execute("SELECT COUNT(*) FROM samples")
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
except sqlite3.Error:
pass
# Freshness age for the service fact # Freshness age for the service fact
freshness_age_s = None freshness_age_s = None
if newest_ts: if newest_ts:
@@ -556,6 +642,10 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
service["freshness"] = freshness service["freshness"] = freshness
service["freshness_age_s"] = freshness_age_s service["freshness_age_s"] = freshness_age_s
try:
service["deliberately_paused"] = is_deliberately_paused(conn, service)
except sqlite3.Error:
service["deliberately_paused"] = False
# --- Drive anomalies (§9.7, FL-7) --- # --- Drive anomalies (§9.7, FL-7) ---
drive_facts = [] drive_facts = []
@@ -578,7 +668,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
# --- Compose output --- # --- Compose output ---
result = _format_projection( result = _format_projection(
proj, freshness, service, drive_facts, config_error, proj, freshness, service, drive_facts, config_error,
None, None, journal_hint, None, None, journal_hint, sample_count, day_count,
) )
conn.close() conn.close()
@@ -597,3 +687,79 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
parts.append("") parts.append("")
parts.append(format_disclosures()) parts.append(format_disclosures())
return "\n\n".join(parts) return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Shared status composition integration (issue #78)
# ---------------------------------------------------------------------------
def get_status_composition(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> 'StatusComposition':
"""Get the shared status composition consumed by both TUI and CLI.
This is the new entry point that centralizes the status lattice.
"""
# Lazy import to avoid circular dependency
from .status_composition import (
StatusComposition,
compose_status,
)
if clock_now is None:
clock_now = datetime.now(timezone.utc)
# --- Configuration (§8.3) ---
# Config errors are surfaced through the store fault mechanism
# --- Service state ---
service = {}
if query_services:
try:
service = query_service_state()
except Exception:
service = None
# --- Store open ---
store_fault = None
newer_schema = None
conn = None
if store_path is None:
store_path = Path("/var/lib/fenris/observations.db")
try:
conn = open_store_readonly(store_path)
except StoreFault as e:
store_fault = str(e)
except NewerSchema as e:
newer_schema = str(e)
# --- Use shared composition ---
if conn is not None:
try:
comp = compose_status(
conn, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
finally:
conn.close()
else:
# Store fault or newer schema - compose without store data
comp = compose_status(
None, service, clock_now,
store_fault=store_fault,
newer_schema=newer_schema,
collecting=collecting,
reduced_motion=reduced_motion,
)
return comp
+526
View File
@@ -0,0 +1,526 @@
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
Centralizes the monitoring status lattice consumed by both surfaces.
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
Collecting overlays every base except store fault.
Freshness grading uses shared constants from status.py.
"""
import enum
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
STATUS_POLL_INTERVAL_S = 5
from .status import (
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
grade_freshness,
freshness_age_human,
is_deliberately_paused,
monitoring_continuity,
deliberate_pause_lines,
open_store_readonly,
StoreFault,
NewerSchema,
)
# ---------------------------------------------------------------------------
# Status state enum with glyph and label
# ---------------------------------------------------------------------------
class StatusState(enum.Enum):
"""The eight monitoring status states (TPH-2)."""
MONITORING = "monitoring"
COLLECTING = "collecting"
PAUSED = "paused"
WAITING = "waiting"
INTERRUPTED = "interrupted"
ERROR = "error"
STALE = "stale"
UNKNOWN = "unknown"
@property
def glyph(self) -> str:
_glyphs = {
"monitoring": "●",
"collecting": "◐",
"paused": "‖",
"waiting": "○",
"interrupted": "⊘",
"error": "✖",
"stale": "◌",
"unknown": "?",
}
return _glyphs[self.value]
@property
def label(self) -> str:
return self.value.capitalize()
# Precedence order: higher index = higher precedence
_PRECEDENCE = [
StatusState.UNKNOWN,
StatusState.MONITORING,
StatusState.WAITING,
StatusState.STALE,
StatusState.PAUSED,
StatusState.INTERRUPTED,
StatusState.ERROR,
]
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
# ---------------------------------------------------------------------------
# Status composition result
# ---------------------------------------------------------------------------
@dataclass
class StatusComposition:
"""The composed status result shared between TUI and CLI."""
state: StatusState
glyph: str
label: str
explanation: str
# Separate facts (never folded into the status word)
freshness: str = "unknown"
freshness_age_s: Optional[int] = None
last_collect_ok: Optional[bool] = None
last_collect_age_s: Optional[int] = None
last_collect_reason: Optional[str] = None
boot_enabled: Optional[bool] = None
timer_active: Optional[bool] = None
deliberately_paused: bool = False
pause_age_s: Optional[int] = None
external_stop_reason: Optional[str] = None
# Store fault / newer schema (suppress store-dependent views)
store_fault: Optional[str] = None
newer_schema: Optional[str] = None
# Collecting overlay
overlay_base: Optional[StatusState] = None
# Sample counts for waiting explanations
sample_count: int = 0
day_count: int = 0
# Whether the status dot should blink (only Monitoring)
should_blink: bool = False
# Continuity line
continuity: str = ""
# Paused lines (for TUI banner)
paused_lines: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Status composition logic
# ---------------------------------------------------------------------------
def _determine_base_state(
freshness: str,
sample_count: int,
day_count: int,
boot_enabled: Optional[bool],
timer_active: Optional[bool],
last_collect_ok: Optional[bool],
deliberately_paused: bool,
external_stop_reason: Optional[str],
service_available: bool,
) -> StatusState:
"""Determine the base status state from facts.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
"""
# Unknown: service query failed and no store-derived fact places us higher
if not service_available:
return StatusState.UNKNOWN
# Error: last collect failed
if last_collect_ok is False:
return StatusState.ERROR
# Interrupted: external stop (not user_disabled)
if external_stop_reason is not None:
return StatusState.INTERRUPTED
# Paused: deliberate disable
if deliberately_paused:
return StatusState.PAUSED
# Stale: timer active, no failure, but data ≥ 48h old
if freshness == "stale" and timer_active and last_collect_ok is not False:
return StatusState.STALE
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
if sample_count == 0:
return StatusState.WAITING
if sample_count <= 1 and day_count == 0:
return StatusState.WAITING
# Monitoring: everything is fine
return StatusState.MONITORING
def _determine_explanation(
state: StatusState,
freshness: str,
freshness_age_s: Optional[int],
last_collect_ok: Optional[bool],
last_collect_reason: Optional[str],
sample_count: int,
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line for the status state."""
if store_fault:
return "observation store unreadable — see journal"
if newer_schema:
return "observation store written by a newer Fenris — upgrade Fenris"
if state == StatusState.ERROR:
parts = []
if last_collect_ok is False:
parts.append("last run failed")
if last_collect_reason:
parts.append("(%s)" % last_collect_reason)
if freshness_age_s is not None and freshness != "empty":
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
return " ".join(parts) if parts else "last run failed"
if state == StatusState.INTERRUPTED:
return "collection stopped outside Fenris — monitoring period still open"
if state == StatusState.PAUSED:
return "monitoring paused — paused time excluded from your usage habit"
if state == StatusState.STALE:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "data is stale"
if state == StatusState.WAITING:
if sample_count == 0:
return "awaiting first sample"
if sample_count <= 1:
return "awaiting another sample"
return "waiting for data"
if state == StatusState.MONITORING:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "monitoring active"
if state == StatusState.UNKNOWN:
return "service state unavailable"
return ""
def _determine_collecting_overlay(
base_state: StatusState,
last_collect_ok: Optional[bool],
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line when Collecting overlays a base state."""
if store_fault or newer_schema:
return "run in flight — store fault"
if base_state == StatusState.PAUSED:
return "run in flight — paused"
if base_state == StatusState.INTERRUPTED:
return "run in flight — interrupted"
if base_state == StatusState.ERROR:
return "run in flight — retry"
if base_state == StatusState.STALE:
return "run in flight — stale data"
if base_state == StatusState.WAITING:
return "run in flight"
return "run in flight"
def compose_status(
conn: sqlite3.Connection,
service: Optional[Dict[str, Any]],
clock_now: datetime,
store_fault: Optional[str] = None,
newer_schema: Optional[str] = None,
collecting: bool = False,
reduced_motion: bool = False,
) -> StatusComposition:
"""Compose the shared status from service state and store data.
This is the single entry point consumed by both TUI and CLI.
"""
service_available = service is not None and len(service) > 0
# --- Separate facts from service ---
boot_enabled = service.get("boot_enabled") if service_available else None
timer_active = service.get("timer_active") if service_available else None
last_collect_ok = service.get("last_collect_ok") if service_available else None
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
last_collect_reason = service.get("last_collect_reason") if service_available else None
# --- Freshness from store ---
freshness = "unknown"
freshness_age_s = None
sample_count = 0
day_count = 0
deliberately_paused = False
if store_fault is None and newer_schema is None:
try:
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)
if newest_ts:
try:
ts = datetime.fromisoformat(newest_ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
else:
ts = ts.astimezone(timezone.utc)
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
pass
except sqlite3.Error:
freshness = "unknown"
try:
cursor = conn.execute("SELECT COUNT(*) FROM samples")
sample_count = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
day_count = cursor.fetchone()[0]
except sqlite3.Error:
pass
try:
svc_for_pause = service if service_available else {}
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
except sqlite3.Error:
deliberately_paused = False
# --- External stop detection ---
# External stop = timer inactive + boot disabled + NOT deliberately paused
# + period still open (the timer was stopped but Fenris didn't close the period)
external_stop_reason = None
if not deliberately_paused and timer_active is False and boot_enabled is False:
# Check if there's an open monitoring period (external stop left it open)
try:
open_period = conn.execute(
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
).fetchone()
if open_period is not None:
external_stop_reason = "external_stop"
except sqlite3.Error:
pass
# --- Determine base state ---
# Store fault and newer schema always override to ERROR
if store_fault is not None or newer_schema is not None:
base_state = StatusState.ERROR
else:
base_state = _determine_base_state(
freshness=freshness,
sample_count=sample_count,
day_count=day_count,
boot_enabled=boot_enabled,
timer_active=timer_active,
last_collect_ok=last_collect_ok,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
service_available=service_available,
)
# --- Apply Collecting overlay ---
state = base_state
overlay_base = None
explanation = ""
if collecting and store_fault is None and newer_schema is None:
# Collecting overlays every base except store fault
overlay_base = base_state
state = StatusState.COLLECTING
explanation = _determine_collecting_overlay(
base_state, last_collect_ok, deliberately_paused,
store_fault, newer_schema,
)
else:
explanation = _determine_explanation(
base_state, freshness, freshness_age_s,
last_collect_ok, last_collect_reason,
sample_count, deliberately_paused,
store_fault, newer_schema,
)
# --- Continuity line ---
continuity = ""
if service_available:
continuity = monitoring_continuity(service)
# --- Paused lines ---
paused_lines = []
if deliberately_paused:
paused_lines = deliberate_pause_lines()
# Determine blink flag: only Monitoring dot blinks, never text or other states
should_blink = (state == StatusState.MONITORING and not reduced_motion)
return StatusComposition(
state=state,
glyph=state.glyph,
label=state.label,
explanation=explanation,
should_blink=should_blink,
freshness=freshness,
freshness_age_s=freshness_age_s,
last_collect_ok=last_collect_ok,
last_collect_age_s=last_collect_age_s,
last_collect_reason=last_collect_reason,
boot_enabled=boot_enabled,
timer_active=timer_active,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
store_fault=store_fault,
newer_schema=newer_schema,
overlay_base=overlay_base,
sample_count=sample_count,
day_count=day_count,
continuity=continuity,
paused_lines=paused_lines,
)
# ---------------------------------------------------------------------------
# CLI rendering (static, no styling)
# ---------------------------------------------------------------------------
def render_status_cli(comp: StatusComposition) -> str:
"""Render the status composition as static CLI text."""
lines = []
# Status line
lines.append("%s %s" % (comp.glyph, comp.label))
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append("")
# Separate facts
facts = []
if comp.freshness != "unknown":
facts.append("freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("last collect: unknown")
if comp.boot_enabled is not None:
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
if comp.timer_active is not None:
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
if facts:
lines.append(" · ".join(facts))
# Continuity
if comp.continuity:
lines.append("")
lines.append("CONTINUITY: %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# TUI rendering (with styling tokens)
# ---------------------------------------------------------------------------
def render_status_tui(comp: StatusComposition) -> str:
"""Render the status composition as TUI text with Textual markup."""
lines = []
# Status line with color
color = {
StatusState.MONITORING: "green",
StatusState.COLLECTING: "green",
StatusState.PAUSED: "yellow",
StatusState.WAITING: "yellow",
StatusState.INTERRUPTED: "red",
StatusState.ERROR: "red",
StatusState.STALE: "red",
StatusState.UNKNOWN: "dim",
}[comp.state]
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append("")
# Separate facts
facts = []
if comp.freshness != "unknown":
facts.append("freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None and facts:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("last collect: unknown")
if comp.boot_enabled is not None:
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
if comp.timer_active is not None:
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
if facts:
lines.append(" · ".join(facts))
# Continuity
if comp.continuity:
lines.append("")
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
return "\n".join(lines)
+59 -10
View File
@@ -12,12 +12,22 @@ from typing import Optional
# Schema version - increment on each migration # Schema version - increment on each migration
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
# Packaged default placement (spec §8.3). The config may override it, but a
# fresh install that sets only the device selector must collect cleanly.
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
def get_store_path(config: dict) -> Path: def get_store_path(config: dict) -> Path:
"""Get the store path from config.""" """Get the store path from config.
return Path(config["store_path"])
Falls back to the packaged default when the config does not pin one,
so a fresh install whose config holds only the device selector works
instead of crashing with KeyError 'store_path' (issue #53).
"""
return Path(config.get("store_path", DEFAULT_STORE_PATH))
def init_store(store_path: Path) -> sqlite3.Connection: def init_store(store_path: Path) -> sqlite3.Connection:
@@ -31,6 +41,20 @@ def init_store(store_path: Path) -> sqlite3.Connection:
# Enable WAL mode for concurrent reads during writes # Enable WAL mode for concurrent reads during writes
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
# Group members (fenris group) read the live store read-only, but SQLite
# in WAL mode needs write access to the db and its -wal/-shm sidecars even
# for readers. Best effort: root-created stores stay group-accessible
# without relying on the creating process's umask (issue #54).
import os as _os
for sidecar in (store_path,
store_path.with_name(store_path.name + "-wal"),
store_path.with_name(store_path.name + "-shm")):
try:
mode = _os.stat(sidecar).st_mode & 0o777
_os.chmod(sidecar, mode | 0o060)
except OSError:
pass
# Check if this is a new database # Check if this is a new database
cursor = conn.execute("PRAGMA user_version") cursor = conn.execute("PRAGMA user_version")
current_version = cursor.fetchone()[0] current_version = cursor.fetchone()[0]
@@ -82,7 +106,8 @@ def _create_schema(conn: sqlite3.Connection):
data_units_read INTEGER, data_units_read INTEGER,
bytes_written INTEGER, bytes_written INTEGER,
bytes_read INTEGER, bytes_read INTEGER,
critical_warning INTEGER critical_warning INTEGER,
segment_id INTEGER
) )
""") """)
@@ -117,7 +142,9 @@ def _create_schema(conn: sqlite3.Connection):
bytes_written_delta INTEGER DEFAULT 0, bytes_written_delta INTEGER DEFAULT 0,
bytes_read_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0, sample_count INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0 coverage REAL DEFAULT 0.0,
unattributed_bytes_written INTEGER DEFAULT 0,
unattributed_bytes_read INTEGER DEFAULT 0
) )
""") """)
@@ -183,11 +210,25 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
Spec: §3.6, §10.2 Spec: §3.6, §10.2
""" """
# Migration 1→2: example placeholder # Migration 1→2: add segment_id provenance to samples,
# if current_version < 2: # unattributed byte tracking to day_aggregates (issue #73)
# conn.execute("ALTER TABLE ...") if current_version < 2:
# current_version = 2 # Defensive: only ALTER if table exists (handles minimal v1 stores)
pass tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
# Check if column already exists (idempotent)
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
if "segment_id" not in cols:
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
if "day_aggregates" in tables:
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
if "unattributed_bytes_written" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
if "unattributed_bytes_read" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
current_version = 2
def migrate_to_latest(store_path: Path) -> int: def migrate_to_latest(store_path: Path) -> int:
@@ -215,6 +256,14 @@ def migrate_to_latest(store_path: Path) -> int:
conn.close() conn.close()
return 0 # Already up to date return 0 # Already up to date
# Version 0 means no schema — create fresh (issue #73)
if current_version == 0:
_create_schema(conn)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
conn.close()
return SCHEMA_VERSION
steps = SCHEMA_VERSION - current_version steps = SCHEMA_VERSION - current_version
_apply_migrations(conn, current_version) _apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
+161
View File
@@ -0,0 +1,161 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
Theme roles for graph rendering expose distinct colours per preset so
the bar graph can reflect the user's visual preference without depending
on graph-ticket completion.
Criteria: TPH-10, AC80-1, AC80-5.
"""
from typing import Dict
from textual.theme import Theme
# ---------------------------------------------------------------------------
# Status semantic colours — always win, never themed (AC80-1)
# ---------------------------------------------------------------------------
STATUS_COLORS = {
"monitoring": "green",
"collecting": "green",
"paused": "yellow",
"waiting": "yellow",
"interrupted": "red",
"error": "red",
"stale": "red",
"unknown": "dim",
}
# ---------------------------------------------------------------------------
# Amber theme — warm golden tones (default, amber graph role)
# ---------------------------------------------------------------------------
_AMBER = Theme(
name="fenris-amber",
primary="#d4a017", # warm amber
secondary="#c49b0a", # darker amber
accent="#ffd54f", # light amber highlight
warning="#e6a817", # amber warning
error="#e74c3c", # red error
success="#27ae60", # green success
foreground="#e8e0d0", # warm light
background="#1a1510", # warm dark
surface="#241f16", # warm surface
panel="#2a2318", # warm panel
boost="#332a1c", # warm boost
dark=True,
variables={
"graph-allocated": "#d4a017",
"graph-unallocated": "#8b6914",
"graph-gap": "#554422",
"graph-zero": "#665533",
"graph-partial": "#aa8822",
"graph-selection": "#ffd54f",
"border-default": "#554422",
"muted-text": "#887755",
},
)
# ---------------------------------------------------------------------------
# Nord theme — cool blue-gray polar night palette
# ---------------------------------------------------------------------------
_NORD = Theme(
name="fenris-nord",
primary="#88c0d0", # nord8 frost
secondary="#81a1c1", # nord9
accent="#8fbcbb", # nord7
warning="#ebcb8b", # nord13
error="#bf616a", # nord11
success="#a3be8c", # nord14
foreground="#eceff4", # nord6
background="#2e3440", # nord0
surface="#3b4252", # nord1
panel="#434c5e", # nord2
boost="#4c566a", # nord3
dark=True,
variables={
"graph-allocated": "#88c0d0",
"graph-unallocated": "#5e81ac",
"graph-gap": "#4c566a",
"graph-zero": "#616e88",
"graph-partial": "#81a1c1",
"graph-selection": "#8fbcbb",
"border-default": "#4c566a",
"muted-text": "#7b88a1",
},
)
# ---------------------------------------------------------------------------
# High Contrast — maximum readability, pure black and white
# ---------------------------------------------------------------------------
_HIGH_CONTRAST = Theme(
name="fenris-high-contrast",
primary="#ffffff", # pure white
secondary="#dddddd", # light gray
accent="#ffff00", # bright yellow
warning="#ff8800", # bright orange
error="#ff0000", # pure red
success="#00ff00", # pure green
foreground="#ffffff", # pure white
background="#000000", # pure black
surface="#111111", # near-black surface
panel="#1a1a1a", # near-black panel
boost="#222222", # near-black boost
dark=True,
variables={
"graph-allocated": "#ffffff",
"graph-unallocated": "#aaaaaa",
"graph-gap": "#555555",
"graph-zero": "#666666",
"graph-partial": "#cccccc",
"graph-selection": "#ffff00",
"border-default": "#ffffff",
"muted-text": "#aaaaaa",
},
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
THEMES = {
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
}
THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
"""
return THEMES.get(name, _AMBER)
def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names.
"""
theme = get_theme(theme_name)
variables = theme.variables or {}
return {
"allocated": variables.get("graph-allocated", "#d4a017"),
"unallocated": variables.get("graph-unallocated", "#8b6914"),
"gap": variables.get("graph-gap", "#554422"),
"zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"),
}
+968 -56
View File
File diff suppressed because it is too large Load Diff
+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()
+83
View File
@@ -399,6 +399,89 @@ class TestCI2Parity:
assert "last collect:" in status assert "last collect:" in status
assert "freshness:" in status assert "freshness:" in status
def test_dashboard_clarity_parity_strings_have_one_status_source(self):
"""DC-2/DC-3 wording originates in status and the TUI imports it."""
status_src = (FENRIS_PKG / "status.py").read_text()
tui_src = (FENRIS_PKG / "tui.py").read_text()
for wording in (
"monitoring: active in background · persists across reboots",
"monitoring: does not start on next boot",
"monitoring: paused — deliberate disable",
"paused time is excluded from your usage habit · resume: fenris monitor resume",
):
assert status_src.count(wording) == 1
assert wording not in tui_src
@pytest.mark.asyncio
@pytest.mark.parametrize(
("state", "service", "expected_lines"),
[
(
"active_enabled",
{"boot_enabled": True, "timer_active": True},
["monitoring: active in background · persists across reboots"],
),
(
"boot_disabled",
{"boot_enabled": False, "timer_active": False},
["monitoring: does not start on next boot"],
),
(
"deliberately_paused",
{"boot_enabled": False, "timer_active": False},
[
"monitoring: does not start on next boot",
"monitoring: paused — deliberate disable",
"paused time is excluded from your usage habit · resume: fenris monitor resume",
],
),
],
)
async def test_dashboard_clarity_monitoring_lines_match_both_views(
self, tmp_path, state, service, expected_lines
):
"""CI-2 synthetic-store sweep covers active, disabled, and paused states."""
db = tmp_path / (state + ".db")
conn = init_store(db)
if state == "active_enabled":
ensure_period_open(conn, _clock())
elif state == "deliberately_paused":
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-30T09:00:00+00:00", "2026-09-30T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
service_state = {
**service,
"last_collect_ok": None,
"last_collect_age_s": None,
"last_collect_reason": None,
}
with patch("fenris.status.query_service_state", return_value=service_state), patch(
"fenris.tui.query_service_state", return_value=service_state
):
status = get_status(
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
).lower()
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(100, 40)):
tui_text = "\n".join(
(
str(app.query_one("#service-strip").render()),
str(app.query_one("#paused-banner").render()),
)
).lower()
for expected in expected_lines:
assert expected in status
assert expected in tui_text
if state != "deliberately_paused":
assert "monitoring: paused — deliberate disable" not in status
assert "monitoring: paused — deliberate disable" not in tui_text
def test_pause_resume_action_names(self): def test_pause_resume_action_names(self):
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS} tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
assert "p" in tui_keys assert "p" in tui_keys
+211
View File
@@ -0,0 +1,211 @@
"""Release-note changelog extraction tests (DC-6, DC-7)."""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py"
RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py"
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
def _extractor_module():
spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_extracts_the_requested_version_section_verbatim():
extractor = _extractor_module()
changelog = """# Changelog
## [Unreleased]
## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
## [1.3.0] - 2026-09-01
### Fixed
- Preserve the observation history during upgrades.
"""
expected = """## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
"""
assert extractor.extract_version_section(changelog, "1.4.0") == expected
def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited():
lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines()
unreleased = lines.index("## [Unreleased]")
version_headings = [
index for index, line in enumerate(lines)
if line.startswith("## [") and line != "## [Unreleased]"
]
first_version = version_headings[0] if version_headings else len(lines)
categories = [
line.removeprefix("### ")
for line in lines[unreleased + 1:first_version]
if line.startswith("### ")
]
assert unreleased < first_version
assert set(categories) <= {"Added", "Changed", "Fixed"}
def test_release_footer_verifies_the_clearsigned_checksum_asset():
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
encoding="utf-8"
)
assert "gpg --output SHA256SUMS --decrypt SHA256SUMS.asc" in footer
assert "sha256sum -c SHA256SUMS" in footer
@pytest.mark.parametrize(
("changelog", "expected_error"),
[
("# Changelog\n\n## [Unreleased]\n", "missing"),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n",
"empty",
),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n",
"malformed release date",
),
],
)
def test_fails_closed_for_missing_empty_or_malformed_sections(
changelog, expected_error
):
extractor = _extractor_module()
with pytest.raises(extractor.ChangelogError, match=expected_error):
extractor.extract_version_section(changelog, "1.4.0")
def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8")
result = subprocess.run(
[sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode != 0
assert result.stderr.startswith("::error::")
assert "missing" in result.stderr
def test_assembles_a_release_body_without_changing_the_section():
extractor = _extractor_module()
section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n"
footer = "## Install\n\nUse the package channel.\n"
assert extractor.assemble_release_body(section, footer) == (
section + "\n" + footer
)
def test_command_can_write_the_complete_release_body(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
"### Added\n\n- Show a release summary.\n",
encoding="utf-8",
)
footer = tmp_path / "footer.md"
footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
"--footer",
str(footer),
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert result.stdout == (
"## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n"
"## Install\n\nUse the package channel.\n"
)
def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
body = tmp_path / "release-body.md"
body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8")
create = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
],
capture_output=True,
text=True,
check=False,
)
existing = tmp_path / "existing-release.json"
existing.write_text('{"id": 17, "assets": []}', encoding="utf-8")
patch = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
"--existing-release",
str(existing),
],
capture_output=True,
text=True,
check=False,
)
assert create.returncode == patch.returncode == 0
assert json.loads(create.stdout) == {
"method": "POST",
"path": "/releases",
"payload": {
"tag_name": "v1.4.0",
"name": "v1.4.0",
"body": "## [1.4.0] - 2026-09-10\n",
},
}
assert json.loads(patch.stdout) == {
"method": "PATCH",
"path": "/releases/17",
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
}
+712
View File
@@ -0,0 +1,712 @@
"""Collector history tracer tests (issue #73).
Tests the end-to-end history pipeline: sample acquisition → interval
derivation → hour observation → day aggregate, with concurrent-read
safety, cross-hour handling, and display states.
Seams:
- write side: run_collection() → observation store
- read side: get_status(), compute_projection() → observation store
"""
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection, normalize_identity
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
from fenris.day_aggregate import derive_day, derive_all_days
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def smartctl_fixture() -> Dict[str, Any]:
"""Minimal smartctl -a -j output with required fields."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": 12345678,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
@pytest.fixture
def sysfs_fixture_tree(tmp_path: Path) -> Path:
"""Create a minimal sysfs fixture tree with controller identity."""
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
ctrl_dir.mkdir(parents=True)
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
transport_dir = ctrl_dir / "transport"
transport_dir.mkdir()
(transport_dir / "address").write_text("0000:03:00.0")
(transport_dir / "trstring").write_text("pcie")
return tmp_path
@pytest.fixture
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
"""Configuration fixture naming the device."""
return {
"device": "/dev/nvme0",
"store_path": str(tmp_path / "observations.db"),
}
class FakeClock:
"""Injected clock returning controlled time."""
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
def advance(self, **kwargs):
self.now = self.now + timedelta(**kwargs)
# ---------------------------------------------------------------------------
# Schema migration: existing data readable at real precision
# ---------------------------------------------------------------------------
class TestSchemaMigration:
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
def test_migration_bumps_version(self, tmp_path):
"""Migration from v1 to v2 succeeds."""
from fenris.store import migrate_to_latest, SCHEMA_VERSION
# Create a v1 store directly (simulating pre-migration state)
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=WAL")
# Create v1 schema manually
conn.execute("""
CREATE TABLE samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
device TEXT NOT NULL,
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
capacity_bytes INTEGER, percentage_used INTEGER,
available_spare INTEGER, media_errors INTEGER,
power_on_hours INTEGER, power_cycles INTEGER,
unsafe_shutdowns INTEGER, temperature_c INTEGER,
data_units_written INTEGER, data_units_read INTEGER,
bytes_written INTEGER, bytes_read INTEGER,
critical_warning INTEGER
)
""")
conn.execute("""
CREATE TABLE hour_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("""
CREATE TABLE day_aggregates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("PRAGMA user_version=1")
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
conn.commit()
conn.close()
# Migrate
steps = migrate_to_latest(db)
assert steps == 1
# Verify data preserved
conn = sqlite3.connect(str(db))
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "Test"
assert version == SCHEMA_VERSION
def test_newer_schema_refused(self, tmp_path):
"""Store with user_version > SCHEMA_VERSION is refused."""
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
conn.commit()
conn.close()
with pytest.raises(ValueError, match="newer Fenris"):
init_store(db)
def test_existing_data_preserved_after_migration(self, config_fixture,
smartctl_fixture,
sysfs_fixture_tree):
"""Existing sample data is not lost or modified by migration."""
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
# Write first sample
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock)
conn = sqlite3.connect(config_fixture["store_path"])
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "/dev/nvme0"
assert row[2] == 12345678 * 512000
# ---------------------------------------------------------------------------
# Collector publishes samples, intervals, hour observations, day aggregates
# ---------------------------------------------------------------------------
class TestCollectorDerivation:
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
"""Build a smartctl fixture with specific DUW."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": duw_units,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
duw1 = 12345678
duw2 = duw1 + 40 # ~20 MB more
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
assert r1["ok"]
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Check hour_observation was derived
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None, "Hour observation should exist for 12:00"
assert hour[1] >= 2 # at least 2 samples contributed
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
assert hour[0] == 40 * 512000
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with no DUW change → 0 B written."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw = 12345678 # same for both
clock1 = FakeClock(t1)
s1 = self._make_sample(duw, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None
assert hour[0] == 0
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
"""Samples in different hours → delta is unattributed to any hour."""
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 200 # ~100 MB
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
# Hour observations should NOT contain the cross-hour delta
hour11 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
).fetchone()
hour12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Neither hour should have the full 100 MB delta attributed
# (they may have 0 or partial, but not 200*512000)
full_delta = 200 * 512000
if hour11 is not None:
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
if hour12 is not None:
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
"""A read-only reader sees valid pre- or post-publication snapshot."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample(12345678, t1.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Open read-only
ro_conn = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn.close()
assert count_before == 1
# Write second sample
clock2 = FakeClock(t2)
run_collection(self._make_sample(12345718, t2.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Read-only reader sees 2 samples now
ro_conn2 = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn2.close()
assert count_after == 2
# ---------------------------------------------------------------------------
# Display states: awaiting first sample, awaiting another sample
# ---------------------------------------------------------------------------
class TestDisplayStates:
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
def test_zero_samples_awaiting_first(self, tmp_path):
"""Zero samples → 'awaiting first sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
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 = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
def test_one_sample_awaiting_another(self, tmp_path):
"""One sample → 'awaiting another sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
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 = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
# Should mention awaiting or insufficient data
lower = result.lower()
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
"""One sample → TUI shows awaiting state."""
from fenris.tui import FenrisTuiApp, _query_service_facts
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
# Test the projection handles single sample
from fenris.projection import compute_projection, ConfidenceState
conn = sqlite3.connect(db)
proj = compute_projection(conn, now)
conn.close()
# With only one sample, projection should be unavailable
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
"""Two compatible same-hour samples show measured usage."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
from fenris.status import get_status
from unittest.mock import patch
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
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 = get_status(store_path=Path(config_fixture["store_path"]),
clock_now=now, query_services=False, query_journal=False)
# Should not say "no observations" or "awaiting"
lower = result.lower()
assert "no observations yet" not in lower
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Pause crossing and counter reset
# ---------------------------------------------------------------------------
class TestPauseCrossing:
"""Delta across monitoring period gap (issue #73 AC4)."""
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
"""Delta across a paused period preserves prior hour observations."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 100
# First sample (opens period)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Pause
conn = sqlite3.connect(config_fixture["store_path"])
close_period(conn, t_pause, "user_disabled")
conn.close()
# Resume with new sample
clock_resume = FakeClock(t_resume)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock_resume)
# Second sample after resume
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Verify prior hour observations are intact
conn = sqlite3.connect(config_fixture["store_path"])
hour_12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Hour 12 should still have data from the first collection
assert hour_12 is not None, "Prior hour observation should be preserved"
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
class TestCounterReset:
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
"""DUW decrease triggers new segment."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 - 100 # decrease = reset
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
# Should have 2 segments (new one opened for DUW decrease)
assert segments == 2
# Should have 2 samples
assert samples == 2
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Derivation failure preserves prior history
# ---------------------------------------------------------------------------
class TestDerivationFailure:
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
"""If derivation fails after sample write, prior data is intact."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_failure(12345678),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Verify first sample exists
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 1
# Second sample with valid data should succeed
clock2 = FakeClock(t2)
r2 = run_collection(self._make_sample_for_failure(12345718),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Both samples should exist
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 2
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Monitoring period is opened by collector
# ---------------------------------------------------------------------------
class TestMonitoringPeriod:
"""Collector ensures monitoring period is open (issue #73 AC2)."""
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
"""First collection run opens a monitoring period."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
conn.close()
assert period is not None, "A monitoring period should be open"
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
"""Subsequent collection runs keep the period open."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
conn.close()
assert period is not None
assert periods_count == 1 # Still only one period
def _make_sample_simple(self) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": 12345678,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
+258
View File
@@ -0,0 +1,258 @@
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
These tests verify that:
1. Warming progress shows detailed qualifying day breakdown
2. Confidence state accurately reflects qualifying day progress
3. Edge cases like zero-delta intervals and unknown daily shares are handled
4. qualifying_days_progress shows honest qualifying day count
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
)
@pytest.fixture
def store(tmp_path):
conn = init_store(tmp_path / "test.db")
yield conn
conn.close()
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):
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"):
# Convert string to datetime if needed
if isinstance(start, str):
start = datetime.fromisoformat(start)
ensure_period_open(conn, start)
conn.commit()
class TestHonestQualifyingProgress:
"""Issue #77: Show honest qualifying-day progress and confidence."""
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
"""Warming progress shows both qualifying and non-qualifying days."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 12 qualifying days + 2 non-qualifying (low coverage)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_warming_progress_includes_nonqualifying_reason(self, store):
"""Warming progress indicates why days don't qualify."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 13 qualifying days + 1 non-qualifying (zero samples)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i == 0 else 24 # First day has no samples
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "13 of 14 qualifying days" in result.qualifying_days_progress
assert "1 below coverage" in result.qualifying_days_progress
def test_confidence_updates_as_qualifying_days_increase(self, store):
"""Confidence state reflects actual qualifying day count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Start with 10 days (below minimum)
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should be warming
assert result.warming_fact is not None
assert result.confidence_state == ConfidenceState.LIMITED
def test_zero_rate_with_qualifying_days(self, store):
"""Zero rate with qualifying days shows honest state."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
# 14 days with zero bytes written
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should be UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.zero_rate_fact is not None
assert "no finite projection" in result.zero_rate_fact
def test_qualifying_days_excludes_low_coverage(self, store):
"""Days below 50% coverage don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have low coverage
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 3 else 0.95
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_excludes_zero_samples(self, store):
"""Days with zero samples don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have zero samples
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i < 3 else 24
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_progress_after_warming(self, store):
"""After warming, qualifying_days_progress shows honest count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 total days, 12 qualifying (2 below coverage)
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
assert result.warming_fact is None
# But qualifying_days_progress should show the honest count
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_qualifying_days_progress_all_qualifying(self, store):
"""When all days qualify, qualifying_days_progress shows 100%."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days, all qualifying
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming
assert result.warming_fact is None
# qualifying_days_progress should show all qualifying
assert result.qualifying_days_progress is not None
assert "14 of 14 qualifying days" in result.qualifying_days_progress
# Should not show "below coverage" since all qualify
assert "below coverage" not in result.qualifying_days_progress
+318
View File
@@ -0,0 +1,318 @@
"""Tests for issue #79: Apply Fenris identity and Drive health grouping.
Covers:
- TPH-1: Titlebox shows Fenris identity with wolf fallback
- TPH-11: Single maker-credit placement, vendor wear under Drive health
- Preserve: continuity, pause block, quit rail, auth banner
"""
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
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import FenrisTuiApp
# ---------------------------------------------------------------------------
# Helpers (reuse from test_tui.py)
# ---------------------------------------------------------------------------
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))
# ---------------------------------------------------------------------------
# Issue #79: Fenris identity and Drive health grouping
# ---------------------------------------------------------------------------
class TestFenrisIdentity:
"""TPH-1: Titlebox shows Fenris identity with wolf fallback."""
@pytest.mark.asyncio
async def test_titlebox_shows_wolf_identity(self, tmp_path):
"""Top titlebox reads 🐺 Fenris by Bongbetic."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path):
"""Fallback to 'Fenris by Bongbetic' when wolf is unsupported."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
# Simulate narrow terminal that can't render wolf
async with app.run_test(size=(60, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path):
"""Wolf glyph is never rendered as tofu (unsupported character)."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# No replacement character (U+FFFD) should appear
assert "\ufffd" not in headline.lower()
assert "?" not in headline or "Fenris" in headline
@pytest.mark.asyncio
async def test_lifespan_headline_is_data_surface(self, tmp_path):
"""Lifespan headline remains a data surface, not app title."""
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(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline
assert "remaining" in headline.lower() or "projection" in headline.lower()
class TestSingleMakerCredit:
"""TPH-1: Single maker-credit placement in title only."""
@pytest.mark.asyncio
async def test_maker_credit_not_in_service_strip(self, tmp_path):
"""Remove duplicate maker credit from service facts."""
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(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# "by Bongbetic" should NOT appear in service strip
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_maker_credit_in_titlebox(self, tmp_path):
"""Maker credit appears in the titlebox identity."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
"""Empty store: no maker credit in service strip."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_store_fault_no_maker_credit_in_strip(self, tmp_path):
"""Store fault: no maker credit in service strip."""
# Create a corrupt store
db = tmp_path / "test.db"
db.write_bytes(b"not a database")
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
class TestDriveHealthVendorWear:
"""TPH-1: Vendor wear renders under Drive health context."""
@pytest.mark.asyncio
async def test_vendor_wear_in_drive_health(self, tmp_path):
"""Vendor wear shows with health context, not as Settings."""
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", pu=10)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Vendor wear should be present with health context
assert "vendor wear" in health.lower()
# Should show thermal, spare, errors, etc.
assert "temperature" in health.lower()
assert "spare" in health.lower()
assert "media errors" in health.lower()
# Settings section should NOT be a separate heading
assert "[bold]Settings[/bold]" not in health
@pytest.mark.asyncio
async def test_missing_wear_values_honest(self, tmp_path):
"""Missing values remain honest unavailable facts."""
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 with zero wear values
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Should show 0% used or honest zero, not crash
assert "vendor wear" in health.lower()
class TestPreservedBehavior:
"""Preserve existing continuity, pause, quit, auth behavior."""
@pytest.mark.asyncio
async def test_continuity_preserved(self, tmp_path):
"""Continuity wording preserved in service strip."""
conn = init_store(tmp_path / "test.db")
_open_period(conn)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "continuity" in strip.lower()
assert "monitoring" in strip.lower()
@pytest.mark.asyncio
async def test_quit_rail_preserved(self, tmp_path):
"""Separate q QUIT TUI rail preserved."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
rail = str(app.query_one("#quit-rail").render())
assert "q QUIT TUI" in rail
@pytest.mark.asyncio
async def test_auth_banner_preserved(self, tmp_path):
"""Polkit authentication banner lifecycle preserved."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower()
# Should clear after first tick
await pilot.pause(0.25)
headline_after = str(app.query_one("#headline-band").render())
assert "polkit" not in headline_after.lower()
@pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path):
"""Deliberate pause banner preserved."""
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
async with app.run_test(size=(120, 24)) as pilot:
banner = str(app.query_one("#paused-banner").render())
assert "paused" in banner.lower()
assert "deliberate" in banner.lower()
+482
View File
@@ -0,0 +1,482 @@
"""Integration tests for issue #80: Persist accessible colour and motion preferences.
Covers:
- AC80-1: Amber/Nord/High Contrast presets with Amber default
- AC80-2: t preset and m motion controls with clickable equivalents
- AC80-3: Persistent user-scoped XDG TUI preferences
- AC80-4: Reduced motion makes Monitoring steady
- AC80-5: Theme roles for graph rendering
- AC80-6: Headless interaction tests with temporary user config
"""
import json
import os
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
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
)
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
from fenris.status_composition import (
StatusState,
compose_status,
render_status_tui,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
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, 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, 5, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
from fenris.monitoring_periods import ensure_period_open
ensure_period_open(conn, datetime.fromisoformat(start))
def _make_prefs_dir(tmp_path: Path) -> Path:
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# AC80-1: Preset loading and application
# ---------------------------------------------------------------------------
class TestPresetLoading:
"""Themes load from preferences and apply to the TUI."""
@pytest.mark.asyncio
async def test_tui_applies_amber_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Theme should be fenris-amber
assert app.theme == "fenris-amber"
@pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
"""TUI applies Nord theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-nord"
@pytest.mark.asyncio
async def test_tui_applies_high_contrast_from_prefs(self, tmp_path):
"""TUI applies High Contrast theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-high-contrast"
@pytest.mark.asyncio
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
"""TUI respects reduced_motion preference."""
from fenris.tui import FenrisTuiApp
from fenris.status_composition import compose_status
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should have reduced_motion set
assert app._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-2: t and m key bindings
# ---------------------------------------------------------------------------
class TestKeyBindings:
"""t cycles presets and m toggles reduced motion."""
@pytest.mark.asyncio
async def test_t_binding_exists(self, tmp_path):
"""The TUI has a 't' binding for theme cycling."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "t" in binding_keys
@pytest.mark.asyncio
async def test_m_binding_exists(self, tmp_path):
"""The TUI has an 'm' binding for motion toggle."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "m" in binding_keys
@pytest.mark.asyncio
async def test_t_cycles_through_themes(self, tmp_path):
"""Pressing t cycles through the three presets."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start at amber
assert app.theme == "fenris-amber"
# Press t to cycle
await pilot.press("t")
await pilot.pause()
# Should be nord or high_contrast now
assert app.theme in ("fenris-nord", "fenris-high-contrast")
@pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path):
"""Pressing m toggles reduced motion."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start with normal motion
assert app._reduced_motion is False
# Press m to toggle
await pilot.press("m")
await pilot.pause()
# Should be reduced motion now
assert app._reduced_motion is True
# Press m again to toggle back
await pilot.press("m")
await pilot.pause()
assert app._reduced_motion is False
# ---------------------------------------------------------------------------
# AC80-3: Persistence across restart
# ---------------------------------------------------------------------------
class TestPersistence:
"""Preferences survive app restart."""
@pytest.mark.asyncio
async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("t")
await pilot.pause()
theme_after_t = app1.theme
assert theme_after_t != "fenris-amber"
# Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2.theme == theme_after_t
@pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path):
"""Reduced motion preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: toggle motion
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("m")
await pilot.pause()
assert app1._reduced_motion is True
# Second run: motion should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-4: Reduced motion makes Monitoring steady
# ---------------------------------------------------------------------------
class TestReducedMotion:
"""Reduced motion disables the Monitoring dot blink."""
def test_reduced_motion_disables_blink(self, tmp_path):
"""compose_status with reduced_motion=True → should_blink=False."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
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)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=True)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is False
conn.close()
def test_normal_motion_allows_blink(self, tmp_path):
"""compose_status with reduced_motion=False → should_blink=True for Monitoring."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
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)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=False)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is True
conn.close()
@pytest.mark.asyncio
async def test_framework_reduced_motion_honoured(self, tmp_path):
"""TUI honours Textual's reduced_motion signal."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should check for reduced motion on mount
assert hasattr(app, '_reduced_motion')
# ---------------------------------------------------------------------------
# AC80-5: Theme roles for graph rendering
# ---------------------------------------------------------------------------
class TestGraphThemeRoles:
"""Graph uses theme-derived colours for each bar role."""
def test_graph_colors_available_for_all_themes(self):
"""Every theme provides all required graph colour roles."""
required_roles = {"allocated", "unallocated", "gap", "zero", "partial"}
for name in THEME_NAMES:
colors = get_graph_colors(name)
assert required_roles.issubset(set(colors.keys())), "Theme %s missing roles: %s" % (name, required_roles - set(colors.keys()))
def test_graph_colors_are_strings(self):
"""All graph colour values are strings (Textual CSS colour values)."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
for role, color in colors.items():
assert isinstance(color, str), "Theme %s role %s has non-string color: %s" % (name, role, color)
# ---------------------------------------------------------------------------
# AC80-6: Safe persistence — failure modes
# ---------------------------------------------------------------------------
class TestSafePersistence:
"""Preference failures never crash the dashboard."""
@pytest.mark.asyncio
async def test_corrupt_prefs_does_not_crash_tui(self, tmp_path):
"""Corrupt preference file does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / "preferences.json").write_text("{bad json!!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start with default theme
assert app.theme == "fenris-amber"
# Dashboard should be functional
headline = str(app.query_one("#headline-band").render())
assert headline is not None
@pytest.mark.asyncio
async def test_readonly_config_dir_does_not_crash(self, tmp_path):
"""Read-only config directory does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
# ---------------------------------------------------------------------------
# CLI isolation (preferences do not affect CLI)
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""Preferences are TUI-only — CLI status is independent."""
def test_cli_status_unchanged_by_theme(self, tmp_path):
"""fenris status output does not change based on theme preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
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)
conn.close()
config_home = _make_prefs_dir(tmp_path)
# With amber theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_amber = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# With high contrast theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_hc = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# Status output should be identical
assert status_amber == status_hc
def test_cli_status_unchanged_by_motion(self, tmp_path):
"""fenris status output does not change based on reduced_motion preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
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)
conn.close()
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_normal = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
status_reduced = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
assert status_normal == status_reduced
+15
View File
@@ -52,6 +52,21 @@ class TestIsRoot:
class TestEnableIdempotentMatrix: class TestEnableIdempotentMatrix:
"""§8.6: Period-row idempotent matrix.""" """§8.6: Period-row idempotent matrix."""
def test_first_enable_creates_missing_store(self, store_path):
"""A fresh package install has a store directory but no database yet."""
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
conn = init_store(store_path)
row = conn.execute(
"SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL"
).fetchone()
conn.close()
assert row is not None
def test_first_opens_period(self, store_path): def test_first_opens_period(self, store_path):
"""First-ever enable opens a period at the enable moment.""" """First-ever enable opens a period at the enable moment."""
# Initialize store # Initialize store
+1003 -123
View File
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
"""Tests for user-scoped TUI preferences (issue #80).
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence
- CLI status and collector behaviour unchanged by preferences
"""
import json
import os
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.preferences import (
load_preferences,
save_preferences,
get_preference_path,
PREFERENCE_FILE_NAME,
VALID_THEMES,
DEFAULT_THEME,
DEFAULT_REDUCED_MOTION,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prefs_dir(tmp_path: Path) -> Path:
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# Preference path tests
# ---------------------------------------------------------------------------
class TestPreferencePath:
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
def test_uses_xdg_config_home(self, tmp_path):
"""Path respects XDG_CONFIG_HOME environment variable."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
path = get_preference_path()
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
def test_default_path_fallback(self):
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
with patch.dict(os.environ, {}, clear=True):
# Remove XDG_CONFIG_HOME if present
os.environ.pop("XDG_CONFIG_HOME", None)
path = get_preference_path()
assert "fenris" in str(path)
assert PREFERENCE_FILE_NAME in str(path)
# ---------------------------------------------------------------------------
# Default preferences tests
# ---------------------------------------------------------------------------
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
assert DEFAULT_REDUCED_MOTION is False
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# Save and load round-trip tests
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""Preferences survive save → load."""
def test_save_and_load_basic(self, tmp_path):
"""Basic theme and motion save/load."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is True
def test_save_creates_directory(self, tmp_path):
"""Save creates the fenris config directory if missing."""
config_home = tmp_path / ".config"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
def test_overwrites_existing(self, tmp_path):
"""Second save overwrites the first."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
save_preferences(theme="high_contrast", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "high_contrast"
assert prefs["reduced_motion"] is True
def test_all_themes_round_trip(self, tmp_path):
"""Every valid theme saves and loads correctly."""
config_home = _make_prefs_dir(tmp_path)
for theme in VALID_THEMES:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme=theme, reduced_motion=False)
prefs = load_preferences()
assert prefs["theme"] == theme
# ---------------------------------------------------------------------------
# Safe failure tests — invalid/unreadable/unwritable
# ---------------------------------------------------------------------------
class TestSafeFailures:
"""Invalid data never crashes the dashboard."""
def test_corrupt_json_returns_defaults(self, tmp_path):
"""Malformed JSON returns safe defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
"""Unrecognized theme value falls back to amber."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "neon-pink", "reduced_motion": False})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "nord"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is False
def test_unreadable_file_returns_defaults(self, tmp_path):
"""Permission denied on preference file returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
pref_file = fenris_dir / PREFERENCE_FILE_NAME
pref_file.write_text(json.dumps({"theme": "nord"}))
pref_file.chmod(0o000)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
# Should fall back to defaults without crashing
assert prefs["theme"] in VALID_THEMES
def test_unwritable_location_returns_defaults(self, tmp_path):
"""Read-only config directory returns defaults without crashing."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# Should not raise
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
# Either saved successfully or fell back — either way, no crash
assert prefs["theme"] in VALID_THEMES
def test_non_json_file_returns_defaults(self, tmp_path):
"""A file that isn't JSON returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "amber", "reduced_motion": "yes"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# CLI/collector isolation tests
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""TUI display preferences do not affect CLI status or collector."""
def test_cli_status_ignores_preferences(self, tmp_path):
"""fenris status output is independent of TUI preferences."""
from fenris.status import get_status
# Create a valid store with data
from fenris.store import init_store
conn = init_store(tmp_path / "test.db")
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
5, 512000000000, 256000000000, 8765),
)
conn.commit()
conn.close()
# Save non-default preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_text = get_status(
store_path=tmp_path / "test.db",
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
query_services=False,
query_journal=False,
)
# Status output should NOT contain theme names or motion settings
assert "high_contrast" not in status_text.lower()
assert "reduced_motion" not in status_text.lower()
assert "amber" not in status_text.lower()
def test_preferences_do_not_alter_store(self, tmp_path):
"""Saving preferences never writes to the observation store."""
import sqlite3
config_home = _make_prefs_dir(tmp_path)
store_path = tmp_path / "observations.db"
from fenris.store import init_store
conn = init_store(store_path)
# Record store state before preference save
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_before = sorted(r[0] for r in cursor.fetchall())
conn.close()
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
# Store schema should be unchanged
conn = sqlite3.connect(store_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_after = sorted(r[0] for r in cursor.fetchall())
conn.close()
assert tables_before == tables_after
# Need datetime for CLI isolation test
from datetime import datetime, timezone
+124 -1
View File
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import ( from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier, ScenarioRange, compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS, TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
YOUNG_REGIME_DAYS, DISCLOSURES, YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
) )
@@ -899,3 +899,126 @@ class TestIdentityChangeBlankKeys:
# All 25 days in same segment (equal blanks continue) # All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None assert result.regime_days is not None
assert result.regime_days >= 20 # Most of the history assert result.regime_days >= 20 # Most of the history
# ===========================================================================
# Issue #76: Evidence-anchored projection rates
# Scenario windows anchored at latest evidence endpoint T
# ===========================================================================
class TestEvidenceAnchoredHorizons:
"""Issue #76: Scenario windows anchored at latest published usage-evidence
endpoint T with exact trailing 7/28/90×86400-second starts."""
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
"""Horizon rate is computed from T (latest evidence), not clock_now."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 30 days of data ending Sep 29
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
# 7-day horizon should be anchored at Sep 29, not Oct 1
if result.scenario_range and 7 in result.scenario_range.rates:
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
assert result.scenario_range is not None
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
"""Reader refresh alone never moves T or dilutes rates."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Two reads at different clock times
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
# Both should produce identical scenario rates (anchored at T, not clock)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
"""Specific reasons are shown for horizons that can't be computed."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Only 10 days of data
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# 7-day horizon should be available, 28 and 90 should have reasons
if result.scenario_range:
assert 7 in result.scenario_range.rates
if 28 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
if 90 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
def test_cumulative_endurance_in_headline(self, store):
"""Headline uses cumulative endurance consumption, not regime writes."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Headline should be computed with cumulative bytes
if result.headline_remaining_seconds is not None:
# E_baseline = 10 TB = 10e12 bytes
# cumulative_bytes = 30 * 100 * 1024 * 1024
# rate = cumulative_bytes / wall_clock
# headline = (E_baseline - cumulative_bytes) / rate
E_baseline = 10.0 * TBW_TO_BYTES
cumulative_bytes = 30 * bw
assert result.headline_remaining_seconds >= 0
def test_zero_boundary_delta_returns_zero(self, store):
"""Zero monotonic delta proves zero over its represented subspan."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
# Days with zero bytes written
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should result in UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
def test_noon_endpoint_same_as_midnight(self, store):
"""Noon endpoint produces same rates as midnight endpoint."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Both reads should produce same scenario rates
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
+54 -3
View File
@@ -1,7 +1,9 @@
"""Raw sample pruning tests. """Raw sample pruning tests.
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
day aggregates retained indefinitely. day aggregates are retained indefinitely.
Issue #74: Boundary anchors required for successor evidence are retained.
""" """
import sqlite3 import sqlite3
import sys import sys
@@ -51,6 +53,9 @@ class TestPruneOldSamples:
def test_removes_old_samples(self, store_conn): def test_removes_old_samples(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert samples at 10, 14, and 15 days ago # Insert samples at 10, 14, and 15 days ago
# All three are before the cutoff (2026-09-01T12:00:00)
# The 15-day-old sample is not a boundary anchor because
# the next sample (14 days ago) is also before the cutoff
for days_ago in [10, 14, 15]: for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat() ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts) _insert_sample(store_conn, ts)
@@ -82,6 +87,7 @@ class TestPruneOldSamples:
"""Hour observations are retained indefinitely.""" """Hour observations are retained indefinitely."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc) now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert an old sample and a recent sample # Insert an old sample and a recent sample
# The old sample is a boundary anchor (needed for derivation)
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat()) _insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat()) _insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
@@ -95,10 +101,55 @@ class TestPruneOldSamples:
prune_old_samples(store_conn, now, retention_days=14) prune_old_samples(store_conn, now, retention_days=14)
# Sample removed # Old sample is retained as boundary anchor (needed for derivation)
cursor = store_conn.execute("SELECT COUNT(*) FROM samples") cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 1 assert cursor.fetchone()[0] == 2
# Hour observation retained # Hour observation retained
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations") cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1 assert cursor.fetchone()[0] == 1
def test_boundary_anchor_retained(self, store_conn):
"""Boundary anchors required for derivation are retained."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_old_sample_with_derived_interval_removed(self, store_conn):
"""Old samples with fully derived intervals are removed."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
store_conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
)
store_conn.commit()
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
+390
View File
@@ -0,0 +1,390 @@
"""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"
def test_workflow_validates_notes_before_publication(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/extract_changelog.py" in content, \
"Workflow must fail before publication if release notes cannot be extracted"
assert "--footer packaging/release-footer.md" in content, \
"Workflow must assemble the body from the standing release footer"
def test_workflow_resynchronizes_existing_release_bodies(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/release_request.py" in content, \
"Workflow must make the create-versus-update decision through the request seam"
assert '"${METHOD}"' in content, \
"Workflow must execute the helper-selected create-or-update request"
assert 'RELEASE_PATH="$(printf' in content and '\n PATH="$(printf' not in content, \
"Workflow must not overwrite the shell PATH while preparing the request URL"
def test_readme_points_consumers_to_release_notes():
readme = _read("README.md")
assert "Per-release notes live on the [releases page]" in readme
assert "standing install and verification instructions" in readme
# ---------------------------------------------------------------------------
# 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")
+398
View File
@@ -0,0 +1,398 @@
"""Repair and retention tests (issue #74).
Tests the idempotent, safe repair of hour observations and day aggregates
from surviving raw samples, boundary anchor retention, and legacy summary
handling at actual precision.
"""
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.repair import (
repair_derivation,
is_repair_in_progress,
get_repair_status,
)
from fenris.pruning import prune_old_samples, needs_boundary_anchor
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store_conn(tmp_path: Path):
"""Create a fresh store for each test."""
db_path = tmp_path / "test.db"
conn = init_store(db_path)
yield conn
conn.close()
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
device="/dev/nvme0", segment_id=None):
"""Insert a raw sample."""
conn.execute(
"""INSERT INTO samples
(ts, device, bytes_written, bytes_read, power_on_hours,
data_units_written, data_units_read, segment_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
bytes_written // 512000, bytes_read // 512000, segment_id),
)
conn.commit()
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
sample_count=1, coverage=1.0):
"""Insert an hour observation."""
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta, sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_iso, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, 0, sample_count, coverage),
)
conn.commit()
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
"""Insert a day aggregate."""
conn.execute(
"""INSERT INTO day_aggregates
(day, active_seconds, bytes_written_delta, coverage, sample_count)
VALUES (?, 3600, ?, ?, 1)""",
(day, bytes_written_delta, coverage),
)
conn.commit()
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
"""Insert a monitoring period."""
conn.execute(
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
VALUES (?, ?, ?)""",
(start_iso, end_iso, end_cause),
)
conn.commit()
# ---------------------------------------------------------------------------
# AC1: Normal collection, legacy import and recovery use same evidence rules
# ---------------------------------------------------------------------------
class TestRepairUsesSameEvidenceRules:
"""AC1: Repair derives only surviving supported evidence, transactionally
and idempotently."""
def test_repair_idempotent_on_empty_store(self, store_conn):
"""Repair on empty store succeeds and does nothing."""
result = repair_derivation(store_conn)
assert result.ok is True
assert result.hours_created == 0
assert result.days_created == 0
def test_repair_idempotent_on_fully_derived(self, store_conn):
"""Repair on store with existing derived data does not duplicate."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert samples that span an hour
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Pre-existing hour observation for the 10:00 hour
# This hour observation captures the interval [10:00, 10:30]
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=1000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create new observations (already exist)
assert result.hours_created == 0
assert result.days_created == 0
# Verify no duplicates
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_repair_preserves_import_markers(self, store_conn):
"""Repair does not remove legacy import markers."""
# Set legacy import marker
store_conn.execute(
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
)
store_conn.commit()
# Run repair
result = repair_derivation(store_conn)
# Verify marker preserved
cursor = store_conn.execute(
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
)
assert cursor.fetchone()[0] == "true"
# ---------------------------------------------------------------------------
# AC2: Rerunning or interrupting repair produces no duplicates
# ---------------------------------------------------------------------------
class TestRepairIdempotency:
"""AC2: No duplicated intervals or totals from repeated repair."""
def test_repair_no_duplicate_hours(self, store_conn):
"""Running repair twice produces no duplicate hour observations."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.hours_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.hours_created == 0 # No new hours
# Verify only one hour observation
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_repair_no_duplicate_days(self, store_conn):
"""Running repair twice produces no duplicate day aggregates."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.days_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.days_created == 0 # No new days
# Verify only one day aggregate
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
assert cursor.fetchone()[0] == 1
def test_interrupted_repair_preserves_evidence(self, store_conn):
"""If repair fails, prior valid history is preserved."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert valid existing data
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=500000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=500000)
# Run repair (should succeed but not modify existing valid data)
result = repair_derivation(store_conn)
assert result.ok is True
# Verify existing data preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM hour_observations "
"WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 500000
# ---------------------------------------------------------------------------
# AC3: Boundary anchor retention
# ---------------------------------------------------------------------------
class TestBoundaryAnchorRetention:
"""AC3: Prune samples only after durable derivation; retain anchors."""
def test_needs_boundary_anchor_sample(self, store_conn):
"""Sample before 14-day boundary is needed for derivation."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Sample just before 14-day boundary (2026-09-15T23:55:00)
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
# Sample just after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# The sample at 2026-09-15 is a boundary anchor because
# the interval spans the retention boundary
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
def test_not_boundary_anchor_if_fully_derived(self, store_conn):
"""Sample that's fully derived is not a boundary anchor."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Insert sample and fully derive its interval
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Hour observation already exists for this interval
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
# Not a boundary anchor
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
def test_pruning_retains_boundary_anchors(self, store_conn):
"""Pruning keeps samples needed as boundary anchors."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Recent sample
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn):
"""Pruning removes old samples when interval is fully derived."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
bytes_written_delta=1000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
# ---------------------------------------------------------------------------
# AC4: Legacy day-only summaries retain actual precision
# ---------------------------------------------------------------------------
class TestLegacySummaryPrecision:
"""AC4: Legacy summaries at actual precision, no interpolation."""
def test_legacy_summary_not_reconstructed(self, store_conn):
"""Legacy day-only summaries are not interpolated to hour detail."""
# Insert a legacy-style day aggregate without hour observations
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create hour observations for legacy day
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
)
assert cursor.fetchone()[0] == 0
def test_legacy_summary_no_double_counting(self, store_conn):
"""Legacy summaries and derived intervals don't double-count."""
# Insert legacy day aggregate
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Day aggregate should not be modified
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# ---------------------------------------------------------------------------
# AC5: Status distinguishes evidence states
# ---------------------------------------------------------------------------
class TestStatusEvidenceDistingushing:
"""AC5: Read-only views distinguish evidence states."""
def test_repair_status_available(self, store_conn):
"""Repair status is available for read-only views."""
status = get_repair_status(store_conn)
assert hasattr(status, 'last_repair')
assert hasattr(status, 'repair_in_progress')
assert hasattr(status, 'hours_derived')
assert hasattr(status, 'days_derived')
def test_repair_in_progress_flag(self, store_conn):
"""Repair in progress flag is trackable."""
assert not is_repair_in_progress(store_conn)
# ---------------------------------------------------------------------------
# AC6: Migration then collection then reader consumption
# ---------------------------------------------------------------------------
class TestMigrationCollectionReader:
"""AC6: End-to-end migration, collection, and reader consumption."""
def test_store_with_raw_evidence_and_summaries(self, store_conn):
"""Store with raw evidence and old summaries works correctly."""
# Set up store with mixed data
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
# Old day-only summary (legacy)
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Recent raw samples
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Run repair
result = repair_derivation(store_conn)
assert result.ok is True
# Verify legacy summary preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# Verify new hour observation created
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
)
assert cursor.fetchone()[0] == 1
def test_concurrent_reader_consistency(self, store_conn):
"""Reader sees consistent snapshot during repair."""
# This is more of a documentation test - SQLite WAL mode handles this
# We verify the store is in WAL mode
cursor = store_conn.execute("PRAGMA journal_mode")
assert cursor.fetchone()[0] == "wal"
+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,
)
+148
View File
@@ -415,6 +415,131 @@ class TestServiceFacts:
assert "last collect:" in result assert "last collect:" in result
assert "freshness:" in result assert "freshness:" in result
def test_continuity_reports_boot_enabled_independently_of_runtime(self, tmp_path):
"""Status names reboot continuity while retaining the timer fact."""
from fenris.status import get_status
db = tmp_path / "observations.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: active in background · persists across reboots" in result
assert "timer: inactive" in result
def test_continuity_and_deliberate_pause_are_reported_separately(self, tmp_path):
"""Only a sanctioned user_disabled period renders the paused wording."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
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 = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: does not start on next boot" in result
assert "monitoring: paused — deliberate disable" in result
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in result
def test_raw_system_state_without_user_disabled_is_not_a_deliberate_pause(self, tmp_path):
"""A non-sanctioned stop never acquires the deliberate-disable label."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "migrated"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
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 = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
def test_resumed_open_period_clears_a_previous_deliberate_pause(self, tmp_path):
"""A later sanctioned resume takes precedence over an older pause."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.execute(
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
("2026-09-01T11:00:00+00:00",),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
def test_live_enabled_service_suppresses_a_stale_pause_marker(self, tmp_path):
"""A raw re-enable cannot leave a contradictory paused presentation."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Status output structure (§8.8, LC-9) # Status output structure (§8.8, LC-9)
@@ -441,6 +566,29 @@ class TestStatusOutput:
assert isinstance(result, str) assert isinstance(result, str)
assert len(result) > 0 assert len(result) > 0
def test_status_excludes_tui_identity_and_auth_notice(self, tmp_path):
"""CLI status never renders TUI-only identity or launch guidance."""
from fenris.status import get_status
db = tmp_path / "observations.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
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 = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
for tui_only in (
"Fenris — NVMe endurance monitor",
"by Bongbetic",
"privileged actions will prompt for authentication (polkit)",
):
assert tui_only not in result
def test_status_never_writes(self, tmp_path): def test_status_never_writes(self, tmp_path):
"""Status never writes to the store.""" """Status never writes to the store."""
from fenris.status import get_status from fenris.status import get_status
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
"""Store path resolution from config — regression coverage for issue #53.
A fresh install ships a placeholder-commented config whose only required
key is the device selector. The collector must not crash with
KeyError 'store_path' when the key is absent.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, get_store_path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEMPLATE = REPO_ROOT / "packaging" / "fenris.conf"
def _parse_like_load_config(text: str) -> dict:
"""Mirror collect.load_config()'s key=value parsing rules."""
config = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
return config
def test_missing_store_path_falls_back_to_default():
"""Config with only the device selector resolves to the packaged default."""
assert get_store_path({"device": "/dev/nvme0n1"}) == DEFAULT_STORE_PATH
def test_explicit_store_path_wins():
"""An explicit store_path override is honored."""
assert get_store_path({"store_path": "/tmp/other.db"}) == Path("/tmp/other.db")
def test_packaged_template_yields_collectable_config():
"""The packaged template, once a device is set, must be collector-ready.
Reproduces the fresh-install path: parse packaging/fenris.conf the way
collect.load_config() does, add the device selector, then resolve the
store. Issue #53 made this raise KeyError.
"""
config = _parse_like_load_config(TEMPLATE.read_text())
config["device"] = "/dev/nvme0n1"
assert get_store_path(config) == DEFAULT_STORE_PATH
def test_template_documents_store_path():
"""The template must mention store_path so admins know it is overridable."""
assert "store_path" in TEMPLATE.read_text()
+79
View File
@@ -0,0 +1,79 @@
"""Group access to the observation store — regression coverage for issue #54.
Two defects: (1) a non-group user's stat() on the store directory raised
PermissionError straight through open_store_readonly(), crashing status/TUI
instead of degrading to the Store fault view; (2) even group members could
not open the WAL-mode store because root-created sidecars lacked group write
and the store directory lacked group execute-then-write.
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, init_store
from fenris.status import StoreFault, open_store_readonly
def test_stat_permission_error_becomes_store_fault(monkeypatch, tmp_path):
"""stat() denied (non-group user on a 2750 dir) → StoreFault, not crash."""
store = tmp_path / "observations.db"
store.write_bytes(b"")
import pathlib
def denied(self, follow_symlinks=True):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(pathlib.Path, "exists", denied)
with pytest.raises(StoreFault):
open_store_readonly(store)
def test_connect_failure_becomes_store_fault(tmp_path):
"""sqlite failures stay wrapped as StoreFault (existing contract)."""
garbage = tmp_path / "observations.db"
garbage.write_bytes(b"not a database" * 100)
with pytest.raises(StoreFault):
open_store_readonly(garbage)
def test_init_store_leaves_files_group_writable(tmp_path):
"""Root-created stores must stay readable by WAL readers: db and sidecars
need group write after init_store (issue #54)."""
store = tmp_path / "observations.db"
conn = init_store(store)
try:
assert (store.stat().st_mode & 0o060) == 0o060, "db not group rw"
wal = store.with_name(store.name + "-wal")
shm = store.with_name(store.name + "-shm")
if wal.exists():
assert (wal.stat().st_mode & 0o060) == 0o060, "wal not group rw"
if shm.exists():
assert (shm.stat().st_mode & 0o060) == 0o060, "shm not group rw"
finally:
conn.close()
def test_readonly_open_works_after_init_store(tmp_path):
"""The shipped read path opens a store created by init_store."""
store = tmp_path / "observations.db"
writer = init_store(store)
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-01-01T00:00:00+00:00')")
writer.commit()
conn = open_store_readonly(store)
assert conn is not None
conn.close()
writer.close()
def test_packaging_ships_group_access():
"""tmpfiles must create the store dir group-writable; collect unit must
keep the umask loose so root-created sidecars stay group-accessible."""
repo = Path(__file__).resolve().parent.parent
assert "2770" in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "2750" not in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "UMask=002" in (repo / "units" / "fenris-collect.service").read_text()
+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 SCHEMA_VERSION-1 → 1 step to current."""
from fenris.store import SCHEMA_VERSION
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION - 1)
steps = migrate_to_latest(db)
assert steps == 1
# ---------------------------------------------------------------------------
# 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()
+186
View File
@@ -0,0 +1,186 @@
"""Tests for Fenris theme presets (issue #80).
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset
"""
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.themes import (
THEMES,
THEME_NAMES,
get_theme,
get_graph_colors,
STATUS_COLORS,
)
# ---------------------------------------------------------------------------
# Theme registry tests
# ---------------------------------------------------------------------------
class TestThemeRegistry:
"""All three presets are registered with correct names."""
def test_three_themes_registered(self):
"""Exactly three themes exist."""
assert len(THEMES) == 3
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
from textual.theme import Theme
for name in THEME_NAMES:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
"""Amber is the default theme with warm tones."""
def test_amber_is_dark(self):
"""Amber is a dark theme."""
theme = get_theme("amber")
assert theme.dark is True
def test_amber_primary_is_amber(self):
"""Primary colour is warm amber."""
theme = get_theme("amber")
assert "d4a017" in theme.primary.lower() or "amber" in theme.primary.lower()
def test_amber_has_graph_colors(self):
"""Amber defines all required graph role variables."""
colors = get_graph_colors("amber")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Nord theme tests
# ---------------------------------------------------------------------------
class TestNordTheme:
"""Nord uses the polar night palette."""
def test_nord_is_dark(self):
"""Nord is a dark theme."""
theme = get_theme("nord")
assert theme.dark is True
def test_nord_has_graph_colors(self):
"""Nord defines all required graph role variables."""
colors = get_graph_colors("nord")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# High Contrast theme tests
# ---------------------------------------------------------------------------
class TestHighContrastTheme:
"""High Contrast for maximum readability."""
def test_high_contrast_is_dark(self):
"""High contrast is a dark theme."""
theme = get_theme("high_contrast")
assert theme.dark is True
def test_high_contrast_foreground_is_white(self):
"""Foreground is pure white for maximum contrast."""
theme = get_theme("high_contract") if False else get_theme("high_contrast")
assert theme.foreground is not None
def test_high_contrast_has_graph_colors(self):
"""High contrast defines all required graph role variables."""
colors = get_graph_colors("high_contrast")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Status semantic colours override theme (AC80-1)
# ---------------------------------------------------------------------------
class TestStatusSemanticOverride:
"""Status semantic colours/glyphs/text always win over theme styling."""
def test_status_colors_defined(self):
"""STATUS_COLORS maps each StatusState to a fixed colour."""
from fenris.status_composition import StatusState
for state in StatusState:
assert state.value in STATUS_COLORS
def test_status_colors_are_not_theme_dependent(self):
"""Status colours are the same regardless of theme."""
from fenris.status_composition import StatusState
# These are the canonical status colours — they must not change with theme
assert STATUS_COLORS[StatusState.MONITORING.value] == "green"
assert STATUS_COLORS[StatusState.ERROR.value] == "red"
assert STATUS_COLORS[StatusState.PAUSED.value] == "yellow"
assert STATUS_COLORS[StatusState.INTERRUPTED.value] == "red"
assert STATUS_COLORS[StatusState.STALE.value] == "red"
assert STATUS_COLORS[StatusState.WAITING.value] == "yellow"
assert STATUS_COLORS[StatusState.UNKNOWN.value] == "dim"
assert STATUS_COLORS[StatusState.COLLECTING.value] == "green"
# ---------------------------------------------------------------------------
# Graph colour role tests (AC80-5)
# ---------------------------------------------------------------------------
class TestGraphColorRoles:
"""Theme roles for graph rendering expose distinct colours per preset."""
def test_all_themes_define_same_roles(self):
"""Every theme has the same set of graph colour roles."""
roles = None
for name in THEME_NAMES:
colors = get_graph_colors(name)
if roles is None:
roles = set(colors.keys())
else:
assert set(colors.keys()) == roles
def test_graph_roles_are_distinct_across_presets(self):
"""Different themes produce different graph colour values."""
amber = get_graph_colors("amber")
nord = get_graph_colors("nord")
hc = get_graph_colors("high_contrast")
# At least one role should differ between themes
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
assert colors["zero"] != colors["allocated"]
+852 -1
View File
@@ -10,6 +10,7 @@ Covers:
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3. Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
""" """
import sqlite3 import sqlite3
from xml.etree import ElementTree
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -39,12 +40,25 @@ from fenris.status import (
) )
from fenris.tui import ( from fenris.tui import (
FenrisTuiApp, FenrisTuiApp,
DailyBarGraph,
_format_remaining, _format_remaining,
_sparkline, _sparkline,
_habit_bar, _habit_bar,
_query_usage_history, _query_usage_history,
_query_drive_health, _query_drive_health,
_query_service_facts, _query_service_facts,
_query_daily_graph_data,
_query_hourly_graph_data,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
_BAR_HEIGHT,
_GLYPH_ALLOCATED,
_GLYPH_UNALLOCATED,
_GLYPH_GAP,
_GLYPH_ZERO,
_GLYPH_PARTIAL,
_MIN_WIDTH,
_MIN_HEIGHT,
) )
@@ -367,7 +381,10 @@ class TestDenseScreen:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_service_strip_has_four_facts(self, tmp_path): async def test_service_strip_has_four_facts(self, tmp_path):
"""Service strip has four separate facts (boot, timer, collect, freshness).""" """Service strip has four separate facts (boot, timer, collect, freshness).
Maker credit removed from service strip (issue #79: single placement in title).
"""
conn = init_store(tmp_path / "test.db") conn = init_store(tmp_path / "test.db")
_insert_segment(conn) _insert_segment(conn)
_open_period(conn) _open_period(conn)
@@ -384,6 +401,166 @@ class TestDenseScreen:
assert "timer:" in strip assert "timer:" in strip
assert "last collect:" in strip assert "last collect:" in strip
assert "freshness:" in strip assert "freshness:" in strip
# Maker credit now in titlebox only (issue #79)
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
"""The visible action footer excludes quit because the rail owns it."""
conn = init_store(tmp_path / "test.db")
_open_period(conn)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
async with app.run_test():
strip = str(app.query_one("#service-strip").render()).lower()
rail = str(app.query_one("#quit-rail").render())
usage = app.query_one("#usage-history")
service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
assert "q quit" not in strip
assert rail == "q QUIT TUI"
assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y
@pytest.mark.asyncio
async def test_deliberate_pause_banner_is_visible_and_quit_preserves_periods(self, tmp_path):
"""The Pilot sees the paused block; q leaves persisted monitoring state alone."""
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
paused_banner = app.query_one("#paused-banner")
main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 5
banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
assert paused_banner.region.height >= 5
assert paused_banner.region.y < app.query_one("#usage-history").region.y
assert app.query_one("#usage-history").region.bottom <= app.query_one(
"#service-strip"
).region.y
screenshot = app.export_screenshot()
visible_text = " ".join(
"".join(ElementTree.fromstring(screenshot).itertext()).split()
)
assert "paused time is excluded from your usage habit" in visible_text
assert "resume: fenris monitor resume" in visible_text
assert "monitoring: does not start on next boot" in str(
app.query_one("#service-strip").render()
).lower()
dashboard_scroll = app.query_one("#dashboard-scroll")
assert dashboard_scroll.max_scroll_y > 0
dashboard_scroll.focus()
await pilot.press("end")
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
footer_text = " ".join(
"".join(
ElementTree.fromstring(app.export_screenshot()).itertext()
).split()
)
assert "q QUIT TUI" in footer_text
await pilot.press("q")
assert not app.is_running
subprocess_run.assert_not_called()
run_helper.assert_not_called()
conn = sqlite3.connect(db)
row = conn.execute(
"SELECT ended_at, end_cause FROM monitoring_periods"
).fetchone()
conn.close()
assert row == ("2026-09-01T10:00:00+00:00", "user_disabled")
@pytest.mark.asyncio
async def test_quit_preserves_an_active_monitoring_period(self, tmp_path):
"""Quitting an active dashboard never closes or mutates its period."""
db = tmp_path / "test.db"
conn = init_store(db)
_open_period(conn, "2026-09-01T09:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test() as pilot:
await pilot.press("q")
assert not app.is_running
subprocess_run.assert_not_called()
run_helper.assert_not_called()
conn = sqlite3.connect(db)
row = conn.execute(
"SELECT started_at, ended_at, end_cause FROM monitoring_periods"
).fetchone()
conn.close()
assert row == ("2026-09-01T09:00:00+00:00", None, None)
@pytest.mark.asyncio
async def test_branding_and_one_time_auth_banner(self, tmp_path):
"""Identity is visible at launch; auth notice clears once per session.
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
"""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
auth_notice = "privileged actions will prompt for authentication (polkit)"
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline
assert auth_notice in headline
# Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
fresh_app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with fresh_app.run_test():
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -525,3 +702,677 @@ class TestStateMatrixCombinations:
assert proj.baseline_tier.value == "unverified_override" assert proj.baseline_tier.value == "unverified_override"
conn.close() conn.close()
# ---------------------------------------------------------------------------
# Hour observation helper for graph tests
# ---------------------------------------------------------------------------
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
unknown=0, coverage=1.0, samples=1):
"""Insert an hour observation row."""
conn.execute(
"INSERT INTO hour_observations "
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
)
conn.commit()
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
coverage=0.95, samples=24):
"""Insert a day aggregate with explicit unattributed bytes."""
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, "
" unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
(day, bw, samples, coverage, unattributed),
)
conn.commit()
# ---------------------------------------------------------------------------
# Graph data query tests (issue #75)
# ---------------------------------------------------------------------------
class TestQueryDailyGraphData:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_daily_graph_data(conn)
assert result == []
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_daily_graph_data(conn)
assert len(result) == 14
assert result[0]["total_bytes"] == 1024*1024*100
assert result[0]["allocated_bytes"] == 1024*1024*100
assert result[0]["unallocated_bytes"] == 0
assert result[0]["is_zero"] is False
conn.close()
def test_zero_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_zero"] is True
assert result[0]["total_bytes"] == 0
conn.close()
def test_unattributed_bytes(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day_with_unattributed(
conn, "2026-09-20", bw=500, unattributed=300
)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["allocated_bytes"] == 500
assert result[0]["unallocated_bytes"] == 300
assert result[0]["total_bytes"] == 800
conn.close()
def test_gap_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Day with no hours but unknown seconds (gap in monitoring period)
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, sample_count, coverage) "
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
("2026-09-20",),
)
conn.commit()
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
class TestQueryHourlyGraphData:
def test_empty_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(conn, "2026-09-20")
assert result == []
conn.close()
def test_with_hours(self, tmp_path):
conn = init_store(tmp_path / "test.db")
for h in range(24):
hour = "2026-09-20T%02d:00:00+00:00" % h
bw = 1024*1024*100 if h in (10, 14) else 0
_insert_hour(conn, hour, bw=bw)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 24
assert result[10]["bytes_written"] == 1024*1024*100
assert result[10]["is_zero"] is False
assert result[0]["is_zero"] is True
conn.close()
def test_hour_labels(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 1
assert result[0]["local_label"] == "12"
conn.close()
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose
graph = DailyBarGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
def compose(self):
yield graph
# We can't easily test widget lifecycle outside an app, so test the data
assert graph._all_day_data == []
assert graph.range_days == _RANGE_DEFAULT
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1
assert graph.view_mode == "daily"
def test_trim_to_range(self):
"""Data is trimmed to the selected range."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
graph.range_days = 7
graph._trim_to_range()
assert len(graph._day_data) == 7
assert graph._day_data[0]["day"] == "2026-09-24"
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
graph.range_days = 14
graph._trim_to_range()
assert len(graph._day_data) == 7
# ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75)
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
"""Graph widget renders with day data."""
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)
_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(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "alloc" in legend
assert "zero" in legend
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
"""Left/right arrows move the selection."""
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)
_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(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Right arrow selects first bar
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Right again moves to second
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 1
# Left moves back
await pilot.press("left")
await pilot.pause()
assert graph.selected_index == 0
@pytest.mark.asyncio
async def test_range_switching(self, tmp_path):
"""1-4 keys switch the visible range."""
conn = init_store(tmp_path / "test.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)
_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(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Default is 14
assert graph.range_days == 14
assert len(graph._day_data) == 14
# Press 1 for 7-day range
await pilot.press("1")
await pilot.pause()
assert graph.range_days == 7
assert len(graph._day_data) == 7
# Press 3 for 28-day range
await pilot.press("3")
await pilot.pause()
assert graph.range_days == 28
assert len(graph._day_data) == 28
# Press 4 for 90-day range (only 30 days available)
await pilot.press("4")
await pilot.pause()
assert graph.range_days == 90
assert len(graph._day_data) == 30
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
"""Readout shows selected day info."""
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)
_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(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# No selection initially
readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower()
# Select first bar
await pilot.press("right")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
assert "GB" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path):
"""Enter drills into hourly view, Esc returns to daily."""
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)
# Insert hours for each day
for h in range(24):
hour = "%sT%02d:00:00+00:00" % (d, h)
bw_h = 1024*1024*10 if h == 12 else 0
_insert_hour(conn, hour, bw=bw_h)
_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(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Select a day
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
assert graph.view_mode == "daily"
# Enter drill-down
await pilot.press("enter")
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph.drill_day is not None
assert len(graph._hour_data) == 24
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert graph.view_mode == "daily"
assert graph.drill_day is None
@pytest.mark.asyncio
async def test_empty_store_graph(self, tmp_path):
"""Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@pytest.mark.asyncio
async def test_graph_border_title(self, tmp_path):
"""Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert graph.border_title == "usage history"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
"""Legend shows all required glyphs."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(7):
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")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend
assert _GLYPH_UNALLOCATED in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# ---------------------------------------------------------------------------
# Issue #81: Constrained size layout
# ---------------------------------------------------------------------------
class TestConstrainedLayout:
"""Tests for constrained terminal size behavior (issue #81)."""
@pytest.mark.asyncio
async def test_constrained_at_80x24(self, tmp_path):
"""At 80×24 the dashboard renders normally with all regions visible."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(80, 24)) as pilot:
# All four regions should be visible
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
# Graph should not be in constrained mode
assert not app._is_constrained_mode
# Constrained summary should be hidden
summary = app.query_one("#constrained-summary")
assert str(summary.styles.display) == "none"
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
"""Below 80 columns the graph is hidden and textual summary shown."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(79, 24)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Main grid should have constrained class
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
"""Below 24 rows the graph is hidden and textual summary shown."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(80, 23)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
"""Resizing from constrained to normal restores graph and coherent state."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(79, 24)) as pilot:
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should no longer be constrained
assert not app._is_constrained_mode
# Main grid should not have constrained class
main_grid = app.query_one("#main-grid")
assert not main_grid.has_class("constrained")
# Selection should be preserved
assert graph.selected_index == 5
# Graph should have data
assert len(graph._day_data) > 0
@pytest.mark.asyncio
async def test_resize_from_normal_to_constrained(self, tmp_path):
"""Resizing from normal to constrained hides graph and shows summary."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(80, 24)) as pilot:
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
# Focus and select a day
app.set_focus(graph)
await pilot.pause()
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
# Selected day context should survive
assert "2026-09" in summary_text
@pytest.mark.asyncio
async def test_constrained_summary_empty_store(self, tmp_path):
"""Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@pytest.mark.asyncio
async def test_constrained_all_regions_usable(self, tmp_path):
"""All regions remain usable in constrained mode."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(79, 24)) as pilot:
# All regions should exist
assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None
# Headline should contain projection
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions
strip = str(app.query_one("#service-strip").render())
assert "p pause" in strip
assert "r resume" in strip
assert "q quit" not in strip # Quit is in quit-rail
# Quit rail should be visible
rail = str(app.query_one("#quit-rail").render())
assert "QUIT" in rail
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
"""Long status reasons remain visible in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Insert stale data
stale_ts = (_clock() - timedelta(days=10)).isoformat()
_insert_sample(conn, stale_ts, pu=5)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower()
@pytest.mark.asyncio
async def test_constrained_keyboard_navigation(self, tmp_path):
"""Keyboard focus traversal works in constrained mode."""
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(14):
d = (datetime(2026, 9, 15) + 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(size=(79, 24)) as pilot:
# Quit should still work
await pilot.press("q")
assert not app.is_running
@pytest.mark.asyncio
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
"""Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
await pilot.pause()
# Toggle motion
await pilot.press("m")
await pilot.pause()
# Should still be constrained
assert app._is_constrained_mode
def test_min_width_constant(self):
"""_MIN_WIDTH is 80 columns."""
assert _MIN_WIDTH == 80
def test_min_height_constant(self):
"""_MIN_HEIGHT is 24 rows."""
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
assert graph._is_constrained(terminal_width=79) is True
# Terminal width at minimum
assert graph._is_constrained(terminal_width=80) is False
# Terminal width above minimum
assert graph._is_constrained(terminal_width=120) is False
+1
View File
@@ -7,3 +7,4 @@ After=local-fs.target
Type=oneshot Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect ExecStart=/usr/libexec/fenris/fenris-collect
TimeoutStartSec=90 TimeoutStartSec=90
UMask=002