Compare commits

...
40 Commits
Author SHA1 Message Date
xavierk 4550dd5111 Keep package directories accessible under restrictive build umasks
Release / release (push) Successful in 1m14s
2026-09-19 11:21:57 +05:30
xavierk e49aac8569 Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots 2026-09-19 11:13:37 +05:30
xavierk 8bac26836a Release Fenris 0.4.0
Release / release (push) Successful in 1m16s
2026-09-18 23:12:14 +05:30
xavierk 88672e12bf test: add edge-case tests for issue #72 acceptance criteria 2026-09-18 17:18:22 +05:30
xavierk cfd71a053b fix: store cross-midnight unattributed bytes once, not twice
The _add_unattributed_bytes function was adding the same cross-hour
delta to both days when the interval spanned midnight, violating the
spec requirement to preserve measured volume once as shared boundary
evidence.

Store the unattributed bytes only on the day where the interval starts
(the earlier day). This ensures the total across both days equals the
actual delta without duplication.

Closes #88 (cross-day delta duplication portion)
2026-09-18 16:54:17 +05:30
xavierk b76067104b feat(projection): add complete observation day gate (issue #94) 2026-09-18 15:23:59 +05:30
xavierk 95cca2e115 Keep local-day history trustworthy after detail expires (#93)
Add query_local_day_history() with evidence-limit metadata to local_day.py,
including LocalDayHistoryEntry dataclass that annotates each summary with
detail_available and derived_from_surviving flags.  Document the 14-day
detail vs indefinite-summary retention policy in both local_day.py and
pruning.py.

Fix query_local_day_summary() to convert SQLite integer booleans to Python
bool, and add deduplication guard to the midnight-spanning hour logic in
derive_local_day_summary().

Add 17 lifecycle tests covering all acceptance criteria:
- AC1: Summaries survive sample pruning, evidence limits visible
- AC2: Boundary anchors preserved before pruning
- AC3: Timezone survives system-timezone change
- AC4: Legacy UTC summaries labelled incomplete
- AC5: Idempotent pruning and repair
- AC6: Volume conservation (no double counting)
- AC7: Real temporary stores
- AC8: User-visible transition from recent to aged
- AC9: Retention policy documented

All 775 tests pass.
2026-09-18 14:27:44 +05:30
xavierkandCommandCodeBot 5df8a12339 Browse activity dates from the keyboard (#92)
Add keyboard-driven date navigation to the TUI:
- [ and ] keys select previous/next day, adjusting the visible range
- g opens a date entry modal for direct date navigation
- t returns to today's live view from any historical browsing
- Background refresh preserves the browsed selection
- Local-day widget shows data for the selected date
- Updated help screen and action legend with new bindings
- DatePickerScreen modal with input validation
- 24 headless tests covering navigation, date entry, browse
  stability, local-day evidence, and constrained widths

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-18 14:17:02 +05:30
xavierk e12f4a574c Plot live drive activity every three minutes (#91)
- Change default collection cadence from 5 minutes to 3 minutes
  (CADENCE_DEFAULT_S=180, systemd OnUnitInactiveSec=3min,
  runit CADENCE=180)
- Update freshness threshold to 450s (2×180 + AccuracySec + 60)
- Add _query_live_graph_data(): queries raw samples from the last
  3 hours and computes interval byte deltas with actual timestamps
- Add LiveActivityGraph widget: vertical bar chart of interval
  volumes with read/write toggle (w key), arrow key inspection,
  and click support
- Wire live graph into TUI layout (full-width row between daily
  graph and drive health), refresh cycle, and CSS grid
- Replace t theme binding with t today/live binding; theme
  selection via preferences file
- Add w binding for read/write toggle on live graph
- Update action legend, help screen, and grid layout for new
  live-activity row
- Add 20 tests covering cadence constants, live query, widget
  rendering, toggle, and TUI integration
- Update all cadence documentation (README, ADR 0003, acceptance
  criteria LC-2, fenris-redesign constants table, CHANGELOG)
2026-09-18 13:17:18 +05:30
xavierk 4f884b4b73 Show trustworthy local-day activity totals (#90)
Add local-day activity summaries derived from UTC hour observations using
the system timezone, with durable storage in a new local_days table.
The collector derives local-day read/write totals after UTC aggregation;
the TUI displays them with timezone, completeness state, and coverage.

Schema: bump SCHEMA_VERSION to 3, add local_days table (migration 2→3
is pure addition, idempotent, preserves newer-schema refusal).
2026-09-18 12:28:23 +05:30
xavierk 366c2f55b7 Publish consistent measured drive activity (#89)
Repair the collection-to-display path so each successful acquisition
publishes correct read/write deltas through the observation store and
visible dashboard.

Fixes:
- derive.py: accumulate bytes_read_delta on same-hour hour_observation
  merge (was silently dropped)
- collector.py: rebuild day_aggregates from hour observations after
  each collection run (previously only populated for cross-hour intervals)
- day_aggregate.py: add persist_day_aggregate upsert helper
- tui.py: query and display both read and write deltas in daily and
  hourly readouts, constrained summaries, and graph data queries

Tests:
- Add 14 integration tests (test_measured_activity.py) exercising the
  full collector→store→reader→display path with real fixtures and
  injected time
- Update constrained-layout assertion to match new W/R format

Closes #89
2026-09-17 17:43:31 +05:30
xavierk 54cd56e4ec Fix XBPS repository setup instructions 2026-09-16 09:48:06 +05:30
xavierk 69e08d9d3a Release Fenris 0.3.7
Release / release (push) Successful in 1m6s
2026-09-16 08:53:38 +05:30
xavierk 714e69be52 Expose XBPS tools during CI setup
Release / release (push) Successful in 1m11s
2026-09-16 08:18:49 +05:30
xavierk ba16413363 Provision XBPS tools in release CI
Release / release (push) Failing after 40s
2026-09-16 08:17:31 +05:30
xavierk dfe6a6a2d0 Fix 0.3.6 release changelog
Release / release (push) Failing after 53s
2026-09-16 08:13:14 +05:30
xavierk 44c57b70dd Release Fenris 0.3.6
Release / release (push) Failing after 6s
2026-09-16 08:05:28 +05:30
xavierk f06424f3b8 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
#	scripts/xbps-publish.sh
2026-09-15 21:19:33 +05:30
xavierk fae72bb07b Document XBPS cache-bypass refresh 2026-09-15 19:53:50 +05:30
xavierk 9d22525403 Fix XBPS repository verification 2026-09-15 19:48:08 +05:30
xavierk a3e6cc3b3c Fix Void package acceptance gaps 2026-09-15 19:43:00 +05:30
xavierk 34bfc70bb8 Refresh XBPS package signatures 2026-09-15 16:51:37 +05:30
xavierk 8b6d4b2447 Index XBPS artifacts in repository 2026-09-15 16:47:35 +05:30
xavierk 72dacab03b Configure XBPS release committer 2026-09-15 16:45:08 +05:30
xavierk cca6804964 Fix XBPS publication artifact paths 2026-09-15 16:44:27 +05:30
xavierk dea2186d6b Prepare XBPS repository publication 2026-09-15 16:43:41 +05:30
xavierk 967ba6964f Route TUI controls through polkit 2026-09-15 15:47:40 +05:30
xavierk efcfd266b7 Clarify TUI monitoring activation 2026-09-15 15:47:40 +05:30
xavierk 1113532c9a Fix Void package lifecycle on host 2026-09-15 15:47:40 +05:30
xavierk 95c75badd5 Route TUI controls through polkit 2026-09-15 15:44:10 +05:30
xavierk 70dbae65fb Clarify TUI monitoring activation 2026-09-15 15:40:29 +05:30
xavierk d119a09b1f Fix Void package lifecycle on host 2026-09-15 15:30:51 +05:30
xavierk fca0724fb4 docs: add Void installation and operations guide 2026-09-15 11:00:02 +05:30
xavierk 1c3037c2f8 Implement independent format gates for release workflow (issue #86)
Add XBPS build and sign steps to CI workflow
Add XBPS publication as independent gate (requires manual trigger)
Track format availability in release notes
Update release-footer.md with XBPS install instructions
Add --available/--withheld arguments to extract_changelog.py
Attach XBPS artifacts to Gitea release
Clean up XBPS signing key material after use
2026-09-15 10:50:38 +05:30
xavierkandCommandCodeBot 2d4cb16a00 Implement independent format gates for release workflow (issue #86)
- Add XBPS build and sign steps to CI workflow
- Add XBPS publication as independent gate (requires manual trigger)
- Track format availability in release notes
- Update release-footer.md with XBPS install instructions
- Add --available/--withheld arguments to extract_changelog.py
- Attach XBPS artifacts to Gitea release
- Clean up XBPS signing key material after use

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 07:47:34 +05:30
xavierkandCommandCodeBot d93238b0f3 Implement XBPS packaging lifecycle for Void Linux (issue #85)
Add native XBPS package support with runit service lifecycle:
- packaging/xbps/install.sh: migration guard, fresh install (dormant),
  upgrade (snapshot, migrate, config preservation)
- packaging/xbps/remove.sh: sanctioned disable, purge (full cleanup)
- Makefile: package-xbps target with dependencies and config files
- tests/test_packaging.py: 5 XBPS-specific tests + updated shared tests

Acceptance criteria addressed:
- Fresh install remains dormant; runit down marker set
- Upgrade snapshots and migrates observation history safely
- Removal performs sanctioned pause and retains history
- Migration guard blocks install over make-install remnants
- Debian/RPM regressions verified via existing shared tests

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 01:33:51 +05:30
xavierkandCommandCodeBot a279c56be5 chore: ignore MagicMock test artifacts
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 01:04:03 +05:30
xavierkandCommandCodeBot e27052d09a Implement runit support for Fenris monitoring (issue #84)
Deliver end-to-end native monitoring path under runit with existing
CLI/TUI controls and truthful status, preserving systemd behavior.

Changes:
- Add init system abstraction layer (src/fenris/init_system.py) that
  detects systemd vs runit and provides unified interface for timer
  control, on-demand collection, and service state queries
- Create runit service files (units/runit/) with completion-relative
  5-minute cadence, 2-minute boot delay, bounded execution (90s),
  no catch-up, and serialized runs via flock
- Update monitor.py to use abstraction layer instead of direct systemctl
- Update status.py to use abstraction layer for service state queries
- Update all packaging scripts (deb, rpm) for init-system-aware setup
- Update Makefile to install runit service files alongside systemd units
- Add 46 tests for init system abstraction layer

Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
Closes #84

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-15 00:15:24 +05:30
xavierkandCommandCodeBot 37a0ed7030 Fix signing key path to avoid conflating auth and signing identities
- Change default from ~/.ssh/id_rsa to ~/.ssh/id_xbps
- Add comment explaining key separation
- Update script documentation to match

Addresses code review finding: default signing key should not be
the user's personal SSH authentication key.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 22:40:57 +05:30
xavierkandCommandCodeBot 985efed906 Implement XBPS proof of concept (issue #83)
Prove signed XBPS installation and immediate updates through Gitea.

Changes:
- Add scripts/xbps-publish.sh for automated XBPS publication
- Add Makefile targets: package-xbps, sign-xbps, xbps-publish
- Add docs/spec/xbps-proof-results.md with acceptance criteria results
- Add docs/adr/0008-native-void-support.md (architecture decision)
- Add docs/spec/native-void-support.md (feature specification)
- Add docs/spec/native-void-tickets.md (implementation tickets)

Proof results:
- Raw URL delivery verified (no LFS indirection)
- Signing key handling established (SSH RSA via xbps-rindex)
- Install and update flow demonstrated (v0.3.5 → v0.3.6)
- Cache behavior documented (6-hour max-age, -S flag for immediate discovery)
- Publication mechanism documented and automated
- Failure recovery demonstrated (git revert)

Repository: https://git.bongbetic.com/xavierk/Fenris-xbps

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 22:37:49 +05:30
86 changed files with 9768 additions and 1577 deletions
+121 -6
View File
@@ -8,6 +8,12 @@ on:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish_xbps:
description: 'Publish XBPS package to distribution repository (requires host acceptance)'
required: false
default: false
type: boolean
# Built-in Gitea token needs write access for release assets and package registry.
permissions:
@@ -58,10 +64,27 @@ jobs:
-o /tmp/nfpm.tar.gz
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
nfpm --version
# Ubuntu does not package the XBPS build tools. Use Void's static
# toolchain, pinned and checksum-verified before it reaches PATH.
XBPS_STATIC_VERSION=0.60.4_1
XBPS_STATIC_ARCHIVE="xbps-static-static-${XBPS_STATIC_VERSION}.x86_64-musl.tar.xz"
XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28
curl --fail --silent --show-error --location \
"https://repo-default.voidlinux.org/static/${XBPS_STATIC_ARCHIVE}" \
-o "/tmp/${XBPS_STATIC_ARCHIVE}"
echo "${XBPS_STATIC_SHA256} /tmp/${XBPS_STATIC_ARCHIVE}" | sha256sum --check --strict
mkdir -p /tmp/xbps-static
tar -xJf "/tmp/${XBPS_STATIC_ARCHIVE}" -C /tmp/xbps-static
export PATH="/tmp/xbps-static/usr/bin:${PATH}"
echo "/tmp/xbps-static/usr/bin" >> "$GITHUB_PATH"
xbps-create --version
- name: Build packages
run: make package
- name: Build XBPS package
run: make package-xbps
- name: Import packaging key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
@@ -87,6 +110,26 @@ jobs:
- name: Sign RPM payload
run: make sign-rpm
- name: Import XBPS signing key
id: import-xbps-key
env:
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${XBPS_SIGNING_KEY}" ]; then
echo "::warning::XBPS_SIGNING_KEY secret not configured; XBPS signing skipped"
echo "xbps_signed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mkdir -p ~/.ssh
printf '%s\n' "${XBPS_SIGNING_KEY}" > ~/.ssh/id_xbps
chmod 600 ~/.ssh/id_xbps
echo "xbps_signed=true" >> "$GITHUB_OUTPUT"
- name: Sign XBPS package
if: steps.import-xbps-key.outputs.xbps_signed == 'true'
run: make sign-xbps
- name: Generate and clearsign SHA256SUMS
run: |
set -euo pipefail
@@ -116,6 +159,7 @@ jobs:
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi
rm -f ~/.ssh/id_xbps
- name: Determine version
id: version
@@ -159,6 +203,54 @@ jobs:
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
esac
- name: Publish XBPS to distribution repository
if: github.event.inputs.publish_xbps == 'true'
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
if [ ! -f "${XBPS_FILE}" ]; then
echo "::error::XBPS package not found: ${XBPS_FILE}"
exit 1
fi
if [ ! -f "${XBPS_FILE}.sig2" ]; then
echo "::error::XBPS signature not found: ${XBPS_FILE}.sig2"
exit 1
fi
bash scripts/xbps-publish.sh --publish
- name: Track format availability
id: formats
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false")
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
echo "xbps_published=${XBPS_PUBLISHED}" >> "$GITHUB_OUTPUT"
# Build format availability summary for release notes
AVAILABLE_FORMATS=""
WITHHELD_FORMATS=""
if [ "${DEB_EXISTS}" = "true" ]; then
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Debian/Ubuntu (deb), "
fi
if [ "${RPM_EXISTS}" = "true" ]; then
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Fedora/openSUSE (rpm), "
fi
if [ "${XBPS_EXISTS}" = "true" ] && [ "${XBPS_PUBLISHED}" = "true" ]; then
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Void Linux (xbps)"
elif [ "${XBPS_EXISTS}" = "true" ]; then
WITHHELD_FORMATS="Void Linux (xbps) — pending host acceptance"
fi
# Remove trailing comma and space
AVAILABLE_FORMATS=$(echo "${AVAILABLE_FORMATS}" | sed 's/, $//')
echo "available_formats=${AVAILABLE_FORMATS}" >> "$GITHUB_OUTPUT"
echo "withheld_formats=${WITHHELD_FORMATS}" >> "$GITHUB_OUTPUT"
- name: Create Gitea release
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
@@ -174,6 +266,18 @@ jobs:
echo "::error::validated release body is missing or empty"
exit 1
fi
# Append format availability to release notes
AVAILABLE_FORMATS="${{ steps.formats.outputs.available_formats }}"
WITHHELD_FORMATS="${{ steps.formats.outputs.withheld_formats }}"
RELEASE_BODY_WITH_FORMATS="${RUNNER_TEMP}/release-body-formats.md"
cp "${RELEASE_BODY}" "${RELEASE_BODY_WITH_FORMATS}"
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
echo "## Package formats" >> "${RELEASE_BODY_WITH_FORMATS}"
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
echo "Available: ${AVAILABLE_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
if [ -n "${WITHHELD_FORMATS}" ]; then
echo "Withheld: ${WITHHELD_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
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}" \
@@ -182,11 +286,11 @@ jobs:
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}")"
--body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")"
;;
404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}")"
--body-file "${RELEASE_BODY_WITH_FORMATS}")"
;;
*)
echo "::error::release lookup failed with HTTP ${EXISTING}"
@@ -214,10 +318,21 @@ jobs:
"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
# Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
ARTIFACTS=(
"dist/fenris_${VERSION}_amd64.deb"
"dist/fenris-${VERSION}-1.x86_64.rpm"
"dist/SHA256SUMS.asc"
)
# Add XBPS artifacts if they exist
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
if [ -f "${XBPS_FILE}" ]; then
ARTIFACTS+=("${XBPS_FILE}")
if [ -f "${XBPS_FILE}.sig2" ]; then
ARTIFACTS+=("${XBPS_FILE}.sig2")
fi
fi
for FILE in "${ARTIFACTS[@]}"; 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"
+2
View File
@@ -18,3 +18,5 @@ json
src/fenris.egg-info/
.pytest_cache/
.venv/
MagicMock*
<MagicMock*
+5
View File
@@ -1,5 +1,10 @@
## Agent skills
## Commit messages
Do not add `Co-authored-by: CommandCodeBot <noreply@commandcode.ai>` or other
CommandCodeBot attribution trailers to commits.
### Issue tracker
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
+52
View File
@@ -9,6 +9,58 @@ backfill releases from before this changelog.
## [Unreleased]
## [0.5.0] - 2026-09-19
### Changed
- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls.
- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection.
### Fixed
- Preserve historical selections and store-fault messages through graph refresh and resize.
- Show hourly drill-down results without overwriting them with a loading placeholder.
- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals.
- Stage package directories with consistent public permissions, avoiding openSUSE RPM conflicts and inaccessible runtime paths when built with a restrictive umask.
## [0.4.0] - 2026-09-18
### Added
- Show local-day read and write totals with their recorded timezone, labelled as totals so far for the current day.
- Open the dashboard on a live three-hour written-volume graph with a read/write toggle and selected-point inspection.
- Browse history by day from the keyboard with `[`, `]`, `g` date entry, and `t` for today and the live view.
### Changed
- Collect every three minutes on systemd and runit, plot live points at their actual timestamps, and retain three-minute detail for 14 days before durable summaries.
- Withhold the endurance outlook until one full local observation day has usable evidence, then show it with categorical confidence.
### Fixed
- Keep midnight-spanning activity once as shared boundary evidence instead of adding it to both days.
- Publish derived hour and day history consistently with each collection run.
## [0.3.7] - 2026-09-16
### Changed
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence.
## [0.3.6] - 2026-09-16
### Changed
- Use sentence case throughout the dashboard and add persistent keyboard and sudo guidance.
- Share read-only status acquisition between the CLI and TUI, preserving unknown monitoring state and store-fault recovery guidance.
- Use one authenticated action path for the CLI and TUI; let valid collection runs finish without the former 30-second dashboard cutoff.
- Remove the unused sparkline and habit-bar rendering path while retaining the interactive history graph.
- Align all package formats on the MIT license and Python 3.10 minimum; include the license text in native packages.
### Fixed
- Correct observation-store directory permissions in native packages, including repair of older runit installations during upgrade.
## [0.3.5] - 2026-09-14
### Added
+8
View File
@@ -40,6 +40,14 @@ _Avoid_: Hourly record, hourly.jsonl entry
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats
**Usage-history window**:
An exact consecutive span of UTC calendar days ending today, shown from day aggregates; a day without trustworthy evidence remains an explicit gap rather than disappearing or being estimated.
_Avoid_: Available records, dataset range
**Unallocated write evidence**:
Writes known to belong to a UTC day but which cannot be assigned honestly to a particular hour; they contribute to that day's total but are never distributed across hourly bars.
_Avoid_: Missing writes, estimated hourly writes
**Controller segment**:
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
_Avoid_: Counter reset handling, drive swap detection
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fenris contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+65 -2
View File
@@ -18,7 +18,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
# Legacy history path (IN-4)
LEGACY_HISTORY := ./data/history.jsonl
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package-xbps package generate-test-key sign-rpm sign-xbps checksums clearsign release release-run release-dry-run xbps-publish xbps-publish-dry-run clean
help:
@echo "Fenris NVMe endurance monitor"
@@ -35,6 +35,8 @@ help:
@echo " package - Build deb + rpm packages"
@echo " package-deb - Build deb package only"
@echo " package-rpm - Build rpm package only"
@echo " package-xbps - Build XBPS package only"
@echo " sign-xbps - Sign XBPS package with signing key"
@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"
@@ -42,6 +44,8 @@ help:
@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 " xbps-publish - Publish XBPS package to distribution repository"
@echo " xbps-publish-dry-run - Dry-run of XBPS publication (prints commands only)"
@echo " clean - Remove build artifacts"
# ─── Pre-install gates ──────────────────────────────────────────────────────
@@ -70,7 +74,7 @@ install: check-python check-smartctl dist/fenris-*.whl
@echo "=== Creating data directory (root-written, group-read) ==="
@sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
@sudo install -d -o root -g fenris -m 2770 $(DATA_DIR)
@echo "=== Installing version-neutral runtime packages ==="
@sudo rm -rf $(VENV_DIR)
@@ -89,6 +93,13 @@ install: check-python check-smartctl dist/fenris-*.whl
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
@sudo systemctl daemon-reload
@echo "=== Installing runit service files (dormant — not enabled) ==="
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
@sudo touch /etc/sv/fenris-collect/down
@echo "=== Installing polkit policy ==="
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
@@ -101,10 +112,14 @@ install: check-python check-smartctl dist/fenris-*.whl
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "=== Install complete ==="
@@ -139,6 +154,11 @@ upgrade: dist/fenris-*.whl
@echo "=== Syncing units against manifest ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
@sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
@@ -154,10 +174,14 @@ upgrade: dist/fenris-*.whl
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
@@ -192,6 +216,9 @@ uninstall:
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
@sudo systemctl daemon-reload
@-sudo rm -f /var/service/fenris-collect 2>/dev/null || true
@-sudo rm -rf /etc/sv/fenris-collect 2>/dev/null || true
@-sudo rm -rf /var/log/fenris-collect 2>/dev/null || true
@echo "=== Removing installed files (preserving config and store) ==="
@-rm -f $(BIN_DIR)/fenris
@@ -257,6 +284,42 @@ package-rpm: stage
package: package-deb package-rpm
@echo "=== Both packages built in dist/ ==="
# ─── XBPS packaging (ADR 0008) ─────────────────────────────────────────────
# XBPS signing key (separate from SSH authentication key)
XBPS_SIGNING_KEY ?= $(HOME)/.ssh/id_xbps
XBPS_REVISION ?= 1
package-xbps: stage
@echo "=== Building XBPS package ==="
cp packaging/xbps/install.sh build/stage/INSTALL
cp packaging/xbps/remove.sh build/stage/REMOVE
install -D -m 0644 packaging/fenris.conf build/stage/etc/fenris/fenris.conf
chmod 755 build/stage/INSTALL build/stage/REMOVE
xbps-create -A x86_64 \
-n fenris-$(FENRIS_VERSION)_$(XBPS_REVISION) \
-s "Fenris NVMe wear monitor" \
-S "NVMe wear monitor with persistent TUI" \
-m "Fenris Packaging <packaging@bongbetic.com>" \
-H "https://git.bongbetic.com/xavierk/Fenris" \
-l "MIT" \
-D "python3>=3.10 smartmontools>=0" \
-F "/etc/fenris/fenris.conf" \
build/stage
@echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps ==="
sign-xbps: package-xbps
@echo "=== Signing XBPS package ==="
xbps-rindex --sign-pkg --privkey $(XBPS_SIGNING_KEY) \
fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps
@echo "=== XBPS package signed ==="
xbps-publish:
bash scripts/xbps-publish.sh --publish
xbps-publish-dry-run:
bash scripts/xbps-publish.sh --dry-run
# ─── GPG key management ─────────────────────────────────────────────────────
generate-test-key:
+117 -11
View File
@@ -2,7 +2,7 @@
*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on the host's native scheduler. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
---
@@ -10,7 +10,7 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
- **Python ≥ 3.10** (verified at install time)
- **smartmontools** (`smartctl` — verified at install time)
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
- **systemd** or **runit**, with a polkit agent (the collector runs as root; elevation is exclusively polkit)
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
@@ -66,6 +66,46 @@ 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).
### Void Linux (XBPS)
Void x86_64 with glibc and runit is the native target. Its signed XBPS channel
is available from the permanent repository below. Add it, refresh its
metadata, and install the released package:
```bash
sudo install -d -m 0755 /etc/xbps.d
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
| sudo tee /etc/xbps.d/fenris.conf
sudo xbps-install -M -S fenris
```
XBPS requires remote repositories to be signed. On the first refresh it
displays the repository signing key embedded in the signed metadata; accept it
only when its RSA SHA256 fingerprint is
`SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`. The public key is also
available at
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`.
For later updates, always refresh first so XBPS fetches the current index:
```bash
sudo xbps-install -M -Syu
```
The `-M` flag bypasses XBPS's on-disk repodata cache. It is required when
checking for a newly published package through Gitea's cached raw-file URL.
The runit service remains dormant after installation. `fenris monitor resume`
creates `/var/service/fenris-collect`; pause removes that link and records a
deliberate disable in the observation history.
Fenris keeps the observation store root-written and readable by the `fenris`
group. Add each TUI user to that group, then start a new login session before
running Fenris:
```bash
sudo usermod -aG fenris "$USER"
```
### Package signature verification
The RPM payload is signed with the Fenris packaging key (RSA 3072).
@@ -84,12 +124,12 @@ The packaging public key is published in-repo — no keyservers. See
### Dormant install
A fresh package install is fully dormant. Units are present but disabled;
nothing runs. The only opt-in is the sanctioned toggle:
A fresh package install is fully dormant. Its native scheduler is present but
disabled; nothing runs. The only opt-in is the sanctioned toggle:
```bash
fenris monitor resume # enable timer + open first monitoring period
fenris monitor pause # close the period, disable timer
fenris monitor resume # enable scheduling + open first monitoring period
fenris monitor pause # close the period, disable scheduling
```
## Development install (make install)
@@ -117,6 +157,7 @@ make purge # also removes /etc/fenris and /var/lib/fenris
```bash
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
sudo dnf upgrade fenris # Fedora
sudo xbps-install -Syu # Void Linux
```
### Development upgrade
@@ -133,6 +174,12 @@ What it does:
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`.
On Void, pause monitoring first, copy the compatible snapshot back to
`/var/lib/fenris/observations.db`, then force-install the matching older
package version. If that version is no longer indexed, add its retained XBPS
archive to a local repository with `xbps-rindex -a` and use
`xbps-install -R <local-repository> -f fenris-<version>`. Installing an older
package over a newer observation store is unsupported.
## Migration from make install
@@ -150,6 +197,7 @@ continuity. Over-installing the package over a `make install` is
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
sudo xbps-remove fenris # preserves config and store
```
### Development removal
@@ -163,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
## Cadence drop-ins
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in:
```bash
sudo systemctl edit fenris-collect.timer
@@ -174,6 +222,19 @@ sudo systemctl edit fenris-collect.timer
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
On Void, Fenris uses its native runit service instead: its initial collection
is delayed by two minutes and later collections run three minutes after the
previous run finishes. Inspect its state and diagnostics with:
```bash
sv status fenris-collect
sudo tail -n 50 /var/log/fenris-collect/current
```
`fenris status` also reports the separate boot-enabled, runtime-active,
collection outcome, and observation-store freshness facts. A failed collection
is retried at the next interval; it never fabricates missing observations.
## CLI reference
| Command | Behavior |
@@ -181,8 +242,8 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
| `fenris` | Opens the TUI (no arguments). |
| `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. |
| `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. |
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. |
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables native scheduling and closes the monitoring period. |
| `fenris monitor resume` | Sanctioned enable — enables native scheduling and opens a monitoring period. No confirmation. |
| `fenris baseline set <json>` | CLI-side validation, then polkit-guarded persistence. |
| `fenris baseline clear` | Remove the endurance baseline. |
| `fenris import <path>` | Idempotent single-transaction legacy import. |
@@ -191,11 +252,56 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
## Reading the dashboard
`fenris` opens the TUI dashboard.
![Chalktone dashboard with a dotted activity plot](assets/dashboard-chalktone.png)
Preview uses synthetic observations, not measurements from a real drive.
The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows
hourly evidence for a selected date, labelled UTC, and **History** shows daily
evidence. Local-day totals keep their recorded timezone. Dotted traces show
measured read/write volumes, not transfer speed; missing evidence breaks the
trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume.
Use the selected-point readout for exact values and evidence state.
- `v` cycles Live / Day / History; the tabs are also clickable.
- `←` / `→` inspect points; `w` switches read/write volume in every view.
- `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live.
- `Tab` / `Shift+Tab` move focus; `z` expands the focused panel, and `z` or
`Esc` restores it. Monitoring status and controls remain visible.
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
survive upgrades. `m` toggles reduced motion.
On smaller terminals, textual summaries and scrollable panels keep evidence
accessible. Pause, resume, collect, disclosures, help, and quit remain available
in the fixed control row.
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does
not need `sudo`. Use `sudo` for package installation and system configuration;
pause, resume, and collect-now actions normally authenticate through polkit.
If the observation store is inaccessible, add your login user to the `fenris`
group with `sudo usermod -aG fenris "$USER"`, then log out and back in.
If polkit authentication is unavailable, quit the dashboard and run only the
required administrative action in your terminal:
```bash
sudo fenris monitor resume # Enable monitoring now and across reboots
sudo fenris monitor pause # Confirm a deliberate monitoring pause
sudo fenris sample # Request one collection run
```
Reopen the dashboard with `fenris` afterward. Press `?` for these instructions
and keyboard controls at any time; use the arrow keys to scroll and `Esc` to close.
The CLI and TUI share the same authenticated action path. Authentication and
waiting for collection have no separate dashboard deadline; the native collector
enforces its 90-second runtime limit. An interrupted or failed action is not
automatically retried—check `fenris status` before retrying.
- **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.
- **Auth banner** — the launch notice explains normal-user startup and polkit authentication, then clears on the first refresh. The `?` help screen remains available.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
## Decision
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
+21
View File
@@ -0,0 +1,21 @@
# 8. Native Void Linux support and XBPS delivery
Status: Accepted — implementation and host acceptance completed; evidence is recorded in [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87).
Fenris will support Void Linux natively with runit and full application feature parity, while retaining its existing Debian/RPM and systemd support. This extends the platform boundary in [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) and the delivery scope in [ADR 0007](0007-package-delivery-amends-0004.md): requiring Void users to replace their init system would not meet the native-support goal.
Delivery will include a Fenris-maintained, signed XBPS repository that users configure once for subsequent installation and updates through XBPS, plus versioned release artifacts and notes on Gitea. All downloads must be served directly by Gitea itself; a separate static HTTP repository, even alongside Gitea, does not satisfy this requirement.
Use the dedicated public Gitea repository `xavierk/Fenris-xbps` with its permanent `stable` branch. Its raw-file URL serves the XBPS index, versioned packages, and package signatures as ordinary Git blobs without LFS. Keeping binaries in a separate repository avoids increasing application source-clone size. This accepts growth in distribution-repository Git history in exchange for publishing index and artifacts together through one branch update, without the generic registry's delete-and-upload index replacement gap.
Serialize publication, commit the signed index and its new artifacts together, and retain older versioned artifacts in the current tree so clients with cached older indexes can still download them. Native XBPS installation and update tests against the actual endpoint are required before release validation. The first release acceptance recorded in issue #87 verified direct artifact delivery, signed metadata, retained packages, and immediate discovery after an explicit memory-synchronized refresh. The Gitea raw endpoint advertises six-hour HTTP caching; users should use `xbps-install -M -S` when looking for updates so XBPS bypasses its on-disk repodata cache.
Immediate availability is required: after successful XBPS publication, an explicit repository refresh against the permanent URL must discover the newly published version without a cache-expiry wait or a URL change. This does not promise automatic installation on client machines. Acceptance must exercise a client that fetched the previous index before publication and verify that refresh retrieves the new index and its signed package afterward. Resolve and document actual client and intermediary cache behavior; if the selected Gitea route cannot meet this requirement, hold XBPS publication and revisit its delivery mechanics rather than silently accepting delayed availability.
Hosting evidence: [Gitea generic registry](https://docs.gitea.com/usage/packages/generic), [raw download routing](https://github.com/go-gitea/gitea/blob/main/routers/web/web.go), [download handler](https://github.com/go-gitea/gitea/blob/main/routers/web/repo/download.go), and [Void repository signing](https://docs.voidlinux.org/xbps/repositories/signing.html). Source inspection and an existing raw-file GET establish feasibility, not end-to-end XBPS validation.
The first supported Void target is x86_64 with glibc, matching the inspected development machine. Release validation must cover installation, real collection, pause/resume, reboot persistence, upgrade, and removal on that machine, preserving existing observation history. Debian/RPM compatibility remains part of the acceptance scope. Additional architectures and musl support are outside this first release.
Package formats have independent publication gates: publish each validated format, and hold only formats that have not passed release validation. A failure or pending validation in XBPS must not prevent a validated Debian or RPM package from shipping, and vice versa. Release notes must identify available formats and those still withheld; publication must not imply validation of a missing format.
After successful native acceptance testing, leave the released XBPS package installed on this machine and monitoring the selected NVMe drive. Preserve the observation history collected during testing. Coordinate the reboot test with the user so it can occur at a suitable interruption point. Issue #87 records that this final state, including reboot persistence, was achieved for the first supported release.
+8
View File
@@ -0,0 +1,8 @@
# One MIT license across source and packages
The native package metadata previously disagreed: deb/rpm declared Proprietary,
while XBPS declared MIT and the repository carried no license text. On
2026-09-16 the maintainer chose MIT for Fenris. The repository now includes the
standard MIT license, and Python, deb, rpm, and XBPS distributions must preserve
that same licensing decision; bundled third-party dependencies retain their own
license notices.
+67
View File
@@ -0,0 +1,67 @@
# Glint dashboard design adoption
Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior.
## Finding
Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction.
The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md).
## Primary-source snapshot
Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`.
- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout.
- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images.
- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies.
- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned.
- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment.
## What can transfer
| Glint pattern and source | Fit for Fenris | Scope implication |
| --- | --- | --- |
| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. |
| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. |
| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. |
| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. |
| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. |
| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. |
| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. |
Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)).
## Fenris ownership and constraints
The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign.
The design must preserve these existing responsibilities and evidence semantics:
- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py).
- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md).
- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md).
- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py).
- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md).
The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained.
## License boundary
Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md).
Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission.
## Interview state
The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence.
No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris.
## Validation scope
Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation.
The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state.
No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result.
+1 -1
View File
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
## Collector lifecycle and privilege boundaries (ADR 0003)
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
+6
View File
@@ -1,5 +1,11 @@
# Fenris dashboard clarity specification
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the
preserved Panes arrangement and standalone heavy quit rail. Identity, continuity,
paused-state explanations, and the distinction between quitting and pausing
remain required; quit now has an explicit entry in the fixed controls.
**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*.
+8 -2
View File
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
| Constant | Value | Defined in |
|---|---|---|
| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 |
| Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 |
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
@@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate".
## 7. Panes TUI
**Presentation amendment (2026-09-19):** The accepted
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this
section's panel arrangement and graph appearance with an activity-first layout,
dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared
evidence, projection, authentication, and monitoring-action contracts still apply.
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
### 7.1 Framework and floor
@@ -479,7 +485,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence);
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence);
- **missed** — between that and 48 h (a contributing fact);
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
- **empty store** — *"no observations yet"* with an enable hint.
+148
View File
@@ -0,0 +1,148 @@
# Glint-inspired Fenris dashboard
Status: accepted on 2026-09-19. The user confirmed the complete design and
additionally requested Glint-style plotted graphs in place of block bars.
## Purpose and reference
Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for
Fenris's terminal dashboard. The selected visual reference is the third README
screenshot, using Chalktone, at upstream commit
`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`:
[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png).
The source and architectural assessment is recorded in
[the research note](../research/glint-dashboard-adoption.md).
## Confirmed user choices
- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable
dashboard builder is outside the selected scope.
- Give the live read/write activity chart the largest opening-screen area,
with endurance outlook and monitoring state visible alongside it.
- Use the muted Chalktone appearance from Glint's third screenshot as the
visual direction.
- Put Live / Day / History views inside one activity panel, keeping both
read/write totals and the selected date visible.
- Expand a focused panel within the dashboard while retaining a fixed strip
for monitoring state, observation freshness, and essential controls.
- Replace block bars with thin dotted time-series plots resembling Glint's
chart. Label volume and time axes, retain exact selected-point readouts,
and leave explicit breaks for missing or incompatible evidence. Joining
adjacent measured points is visual guidance, not additional observations.
## Implementation recommendation
Implement the selected visual and interaction patterns independently in
Fenris's existing Python/Textual presentation layer. Retain the collector,
observation store, shared status/projection contracts, and authenticated control
path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or
porting its implementation into Fenris would require a separate licensing
decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel
and navigation patterns does not require adopting its application architecture.
Use thin borders, compact titles, an explicit focused-panel indicator, and
restrained cream/earth-tone accents. Preserve readable contrast and semantic
state labels; the reference's dim secondary text is not a readability target.
Use Fenris content and identity, without Glint's unrelated clock, weather,
finance, email, or gallery features.
## Existing behavior to preserve
- Activity is measured read/write volume, with both totals accessible and
writes selected initially. A visual stock-chart reference must not turn
interval volume into speed or imply continuity through unknown evidence.
- Preserve the latest-three-hour opening view, date selection, local-day and
timezone labels, available history precision, and historical selection across
refresh under [live drive activity](live-drive-activity.md).
- Preserve the usage-adjusted theoretical lifespan and categorical projection
confidence with contributing facts. Navigation and graph selection cannot
change the projection evidence window or endurance accounting.
- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused,
stale, collection-failure, and store-fault states.
- Keep boot enablement, runtime activity, collection outcome, and observation
freshness distinct. Quit leaves background monitoring running; pause remains
a deliberate disable through the existing control path.
- Preserve keyboard and mouse access, constrained-terminal text/reflow,
high-contrast availability, reduced motion, help, and disclosures.
## Concrete layout and interaction proposal
- A compact identity header above one dashboard workspace.
- A narrow supporting column for the endurance outlook and drive facts;
a wide activity panel receives the remaining workspace. On the normal
dashboard, confidence and monitoring state remain visible beside activity.
- Live starts with the latest three hours; Day exposes the selected local
day's available detail; History exposes the retained longer-term evidence.
Reuse existing measurements and ranges rather than introducing a new data
model. Show selected date/timezone, read/write totals, measurement, and
evidence state wherever applicable.
- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus.
Use `z` to toggle focused-panel zoom and Escape to restore the dashboard
when an input/dialog is not consuming Escape. Preserve selection, date,
range, measurement, and focus across zoom and refresh.
- Keep the bottom status/control area outside the expanding workspace. It
may wrap when required: compactness cannot merge the four service facts or
hide a fault/pause state. Keep quit distinct from pause in wording and
behavior without spending three full rows on a separate heavy quit box.
- Use existing date-navigation and read/write shortcuts, with visible help
updated for tabs, focus, and zoom. Text entry must consume its own keys.
- Use Chalktone-inspired styling as the new default while retaining existing
selectable themes, High Contrast, and reduced motion. Preserve an explicitly
saved theme preference during upgrade.
- Design for 80×24 and larger, with existing text/reflow behavior below that
size. Essential facts and actions must remain accessible; the large-screen
reference does not require squeezing its entire density into small terminals.
These defaults implement the confirmed choices. Date entry and inspection
retain existing evidence precision: hourly/daily UTC evidence is labelled UTC,
while local-day totals keep their recorded timezone. This visual redesign does
not manufacture finer or local-hour precision from coarse UTC evidence.
## Smallest sufficient implementation proof
Render normal and zoomed views at a representative large terminal and 80×24,
plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions
and selection persistence through refresh. Exercise paused, stale, store-fault,
missing-baseline, and incomplete-evidence displays using existing synthetic
stores and headless TUI patterns. Verify quit never invokes monitoring control
and existing action tests still cover the sanctioned helper. UI fixtures are
not evidence of the user's deployed drive state.
## Specification reconciliation
This accepted redesign supersedes conflicting presentation requirements in
[the original Panes specification](fenris-redesign.md#7-panes-tui) and
[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a
supporting endurance panel, the history/live plots share a tabbed activity
panel, and a compact fixed control row replaces the heavy standalone quit
rail. The block-bar requirement is superseded by dotted volume plots. Their
behavioral requirements, including clear quit-versus-pause semantics, remain.
The live-drive-activity specification excludes unrelated redesign from that
earlier task. This is a separate design request, not permission to undo its
accepted data, date-navigation, evidence, or forecast behavior. Existing design
documents and source may differ in implementation status; this assessment is
not proof that every earlier acceptance criterion has shipped.
## Documentation scope
No new domain term has been resolved: panel, focus, zoom, and theme are general
interface concepts and do not belong in the domain glossary. No new ADR is
needed for a reversible presentation change that retains the existing stack,
license, data model, and privilege boundary. Record any later durable
architectural trade-off separately if one emerges.
## Implementation validation
Implemented in Fenris's existing presentation layer with Textual 8.2.8.
The full suite passed 801 tests; 43 packaging/signing checks were skipped for
missing package artifacts or signing tools. After the final incomplete-evidence
fixes, all 81 focused dashboard, history, and plot tests passed. New renderer
and regression files pass configured Ruff checks; affected production files
pass correctness lint and the diff passes whitespace checks. Existing broader
lint warnings were not part of this redesign.
Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and
constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png)
uses synthetic observations. No installation or release was performed.
+66
View File
@@ -0,0 +1,66 @@
# Native Void Linux and XBPS distribution
Status: Approved and published as [Support native Void Linux and signed XBPS distribution through Gitea](https://git.bongbetic.com/xavierk/Fenris/issues/82).
## Problem Statement
Void users cannot install and operate Fenris natively through XBPS because its runtime lifecycle assumes systemd and its release pipeline only produces Debian and RPM packages. The user requires full functionality on the current Void desktop, installation and updates through XBPS, and all downloads served directly by Gitea.
## Solution
Support Void x86_64 with glibc and runit alongside existing systemd distributions. Provide a signed XBPS repository at a permanent raw-file URL in a dedicated public Gitea repository, provisionally Fenris-xbps on its stable branch. Publish versioned assets and notes in the application's Gitea release. Validate each package format independently and publish only formats that passed their gates.
## User Stories
1. As a Void user, I want to install Fenris through XBPS so package ownership and dependencies are managed normally.
2. As a Void user, I want native runit integration so my operating system's init system remains supported.
3. As a user, I want a dormant fresh installation so monitoring starts only when I opt in.
4. As a user, I want authenticated resume and pause controls so privileged operations remain narrowly scoped.
5. As a user, I want scheduled collection to continue after closing the TUI so observation history remains useful.
6. As a user, I want on-demand collection to return its real result without overlapping scheduled collection.
7. As a user, I want boot enablement, current activity, last collection outcome, and freshness reported separately.
8. As a user, I want actionable native diagnostics when collection fails.
9. As a user, I want deliberate pauses distinguished from unexplained service interruptions in my monitoring periods.
10. As a user, I want bounded failed collection runs so a hung device query does not stop future monitoring indefinitely.
11. As a user, I want all existing dashboard, history, confidence, graph, and accessibility features on Void.
12. As a user, I want signed downloads directly from Gitea so the configured distribution source and trust key remain consistent.
13. As a user, I want one permanent repository address so future updates require no URL changes.
14. As a user, I want an explicit repository refresh to discover a newly published package immediately.
15. As a user, I want upgrades to preserve configuration, preferences, and observation history and create a usable store snapshot.
16. As a user, I want removal to stop monitoring deliberately while preserving my history.
17. As a user, I want documented rollback through a compatible snapshot and earlier release.
18. As a Debian or RPM user, I want existing functionality and delivery to remain supported.
19. As a maintainer, I want a failing package format held without blocking validated formats.
20. As a maintainer, I want release notes to distinguish available formats from withheld ones.
21. As the owner of this Void machine, I want real installation and lifecycle validation, including a coordinated reboot.
22. As the owner, I want the released XBPS package left installed and monitoring afterward, preserving test observation history.
## Implementation Decisions
- Amend the existing systemd-only service contract to support native runit while retaining its external semantics. Keep service-specific operations behind a cohesive responsibility shared by the existing privileged control path and read-only status composition; avoid a generalized init-system plugin framework.
- Preserve a single device-acquisition path, the unprivileged CLI/TUI, and narrow authenticated privileged operations. Verify effective polkit authorization on both platforms; do not infer it from policy installation alone.
- Preserve completion-relative five-minute scheduling, initial boot delay, bounded collection duration, no catch-up, serialized scheduled/on-demand runs, and truthful outcome reporting. Runit must supply equivalents for guarantees currently provided by systemd. Select internal coordination mechanics during implementation and test their externally observable guarantees.
- Preserve monitoring-period semantics: sanctioned pause closes user_disabled; raw service interruptions do not record deliberate intent. Preserve separate boot-enabled and runtime-active facts.
- Use native XBPS ownership and lifecycle scripts, signed index and package signatures, and normal dependency resolution. Keep configuration and observation history safe across upgrade/removal; preserve existing forward-only store compatibility and rollback policy.
- Host ordinary Git blobs without LFS in the dedicated Gitea repository. Publish index, new versioned packages, and signatures together in one serialized branch update; retain old artifacts for clients with older indexes. Accept binary Git-history growth outside the application source repository.
- Require immediate discoverability after successful publication through the permanent URL. Test clients that fetched the previous index first. Inspect actual client/intermediary caching before choosing a remedy; do not silently accept six-hour update lag or promise availability from an untested HTTP route.
- Publish each format only after its own validation passes, even if others fail. Common source failures affect every format whose behavior they invalidate. Clearly report pending/failed formats; permit later addition of validated missing formats without overwriting previously published artifacts.
- Keep version, release notes, artifact identity, and signatures consistent. Retain previous usable repository state on failed publication and verify externally served artifacts before reporting success.
- First Void target is x86_64/glibc. Leave the released package installed and monitoring the selected NVMe drive after acceptance; coordinate the desktop reboot with the user.
## Testing Decisions
- Primary runtime boundary: existing user commands and shared CLI/TUI observable state, backed by disposable observation stores and controlled service/acquisition outcomes. Test behavior, not a particular helper layout.
- Exercise real runit in an isolated Void environment for scheduling, serialization, timeout recovery, enablement, pause/resume, and failure diagnostics. Preserve meaningful systemd regression coverage.
- Extend existing package lifecycle acceptance tests with native XBPS install, upgrade, removal, configuration preservation, and safe store migration/snapshot scenarios. Use disposable stores for destructive removal/rollback cases.
- Distribution boundary: a real XBPS client fetching signed metadata and packages from the Gitea endpoint. Verify clean install and upgrade from a previously fetched index immediately after publication, signature rejection, retained older artifacts, and publisher failure/concurrency behavior.
- Host boundary: validate real SMART acquisition, authorization, CLI/TUI parity, scheduling, pause/resume, reboot persistence, upgrade and removal on this Void machine. Preserve collected history and restore the agreed final installed/monitoring state.
- Record actual outcomes, skips, and environment failures. Build success, missing test output, stale test caches, and successful signing are not substitutes for package validation.
## Out of Scope
Musl, other architectures, additional init systems, official Void repository inclusion, a separate download server, automatic client upgrades, unrelated dashboard redesign, and automatic downgrade of a newer observation store.
## Further Notes
The design decisions are recorded in ADR 0008. The dedicated distribution repository and end-to-end XBPS proof are complete; the host acceptance record is [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87). The accepted target is Void x86_64/glibc with runit, with the released package installed and monitoring the selected NVMe drive after the coordinated reboot and lifecycle checks.
+71
View File
@@ -0,0 +1,71 @@
# Native Void implementation tickets
Status: Approved and published as Gitea issues 83–87 with native blocking edges and ready-for-agent labels. Parent specification: https://git.bongbetic.com/xavierk/Fenris/issues/82.
Each issue references the published native Void specification, which includes ADR 0008. Existing Debian/RPM behavior, observation-history preservation, narrowly scoped privilege, and independent publication gates apply throughout.
Published tickets: [1](https://git.bongbetic.com/xavierk/Fenris/issues/83), [2](https://git.bongbetic.com/xavierk/Fenris/issues/84), [3](https://git.bongbetic.com/xavierk/Fenris/issues/85), [4](https://git.bongbetic.com/xavierk/Fenris/issues/86), [5](https://git.bongbetic.com/xavierk/Fenris/issues/87).
## 1. Prove signed XBPS installation and immediate updates through Gitea
Blocked by: None.
Deliver a dedicated public Gitea distribution repository and a repeatable, isolated XBPS-client proof using clearly identified test artifacts, without representing them as a validated Fenris release.
- Verify permanent raw URL delivery of index, package and signature bytes without LFS indirection.
- Establish signing-key handling and trust verification without exposing private keys.
- Demonstrate install and update with a client that fetched the old index immediately before publication.
- Resolve actual cache behavior and verify binary size limits; escalate any hosting change outside the agreed Gitea scope.
- Publish index/artifacts together with serialized updates, preserve older downloadable artifacts, and demonstrate safe failure recovery.
- Record results and the usable publication mechanism for subsequent tickets.
## 2. Run and control Fenris monitoring natively under runit
Blocked by: None.
Deliver an end-to-end native monitoring path with existing CLI/TUI controls and truthful status in an isolated Void environment.
- Scheduled and on-demand collection use the same acquisition path with no overlap and bounded execution.
- Preserve cadence, initial boot delay, recovery after failures, and no catch-up semantics.
- Resume/pause preserve monitoring-period bookkeeping and boot/runtime distinctions; raw service stops do not record deliberate disable.
- Verify effective authentication and actionable native diagnostics, including missing-agent failures.
- Preserve systemd behavior and application feature parity through existing public behavior tests.
## 3. Install, upgrade and remove Fenris with native XBPS packages
Blocked by: 2.
Deliver buildable x86_64/glibc XBPS artifacts with dependency resolution and tested package lifecycle in an isolated Void environment.
- Fresh install remains dormant; native controls enable monitoring afterward.
- Package ownership, configuration preservation, permissions, and group access support real CLI/TUI reads and collector writes.
- Upgrade snapshots and migrates observation history safely without recording a deliberate pause.
- Removal performs sanctioned pause and retains history; reinstall and documented snapshot rollback behave correctly.
- Installation over incompatible unmanaged remnants fails with a useful migration path.
- Capture explicit lifecycle test results and verify Debian/RPM regressions relevant to changed packaging.
## 4. Publish validated package formats independently from the release workflow
Blocked by: 1, 3.
Deliver a release workflow that builds, validates, signs and publishes XBPS alongside existing Debian/RPM support with independent format gates.
- Unvalidated formats remain withheld while validated formats can ship.
- Notes accurately identify available and withheld formats; source version, notes, checksums and artifacts agree.
- A withheld format can be added after validation without replacing existing published artifacts.
- Gitea serves every download; XBPS uses the proven permanent repository URL and immediate-refresh behavior.
- Release failure/concurrency cannot expose an index referencing missing artifacts or erase the prior usable channel.
- Host acceptance remains a required XBPS release gate, not bypassed by build/signature success.
## 5. Validate and release on the user's Void machine
Blocked by: 4.
Deliver recorded host acceptance and the validated XBPS release, ending with Fenris installed and monitoring.
- Recheck host state, identify/configure the intended NVMe drive, and preserve pre-existing data before package lifecycle operations.
- Verify real acquisition, authenticated controls, scheduling, failure reporting, full dashboard behavior, and pause/resume semantics.
- Coordinate and verify reboot persistence; absence of the reboot test leaves that gate pending.
- Verify native upgrade/removal/reinstall with history preservation and immediate update discovery through Gitea.
- Publish only after the XBPS gate passes, then verify downloads and released-package installation.
- Preserve acceptance-test observation history and leave the released package monitoring; document installation, trust setup, diagnostics and rollback for Void users.
+153
View File
@@ -0,0 +1,153 @@
# XBPS Proof of Concept Results (Issue #83)
Status: Complete. All acceptance criteria satisfied.
## Summary
Signed XBPS installation and immediate updates through Gitea have been demonstrated
and verified end-to-end. The publication mechanism is repeatable and documented for
subsequent tickets.
## Acceptance Criteria Results
### 1. Permanent raw URL delivery without LFS indirection
**Result: PASS**
All artifacts are served directly by Gitea via raw-file URLs:
- Repository: `https://git.bongbetic.com/xavierk/Fenris-xbps`
- Raw URL pattern: `https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64/<file>`
Verified files:
- `x86_64-repodata` (1327 bytes) — HTTP 200
- `fenris-0.3.5_1.x86_64.xbps` (731 bytes) — HTTP 200
- `fenris-0.3.5_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
- `fenris-0.3.6_1.x86_64.xbps` (752 bytes) — HTTP 200
- `fenris-0.3.6_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
No LFS indirection detected. Files served as ordinary Git blobs.
### 2. Signing-key handling and trust verification
**Result: PASS**
Signing uses SSH RSA keys (3072-bit) via `xbps-rindex --sign-pkg` and `xbps-rindex --sign`.
The public key is embedded in the repository metadata (`index-meta.plist`) within the
repodata archive. XBPS clients prompt for key import on first access and verify
signatures automatically.
Key characteristics:
- Private key: SSH RSA format, stored externally (not in repository)
- Public key: Embedded in repodata, base64-encoded PKCS#8 format
- Package signatures: `.sig2` files alongside each `.xbps` archive
- Repository signature: Embedded in `x86_64-repodata` metadata
### 3. Install and update with client that fetched old index
**Result: PASS**
Demonstrated full lifecycle:
1. Fresh install of v0.3.5 from repository with only v0.3.5 in index
2. Added v0.3.6 to index and committed to `stable` branch
3. Client performed `xbps-install -Syu` and upgraded from 0.3.5 → 0.3.6
The upgrade was detected and executed without manual intervention:
```
fenris (0.3.5_1 -> 0.3.6_1)
```
### 4. Cache behavior and binary size limits
**Result: PASS (with documented caveat)**
Cache behavior:
- Gitea raw endpoint: `cache-control: public, max-age=21600` (6-hour cache)
- ETag changes on each commit (different file hash)
- Conditional requests with old ETag return 200 (full content), not 304
xbps-install behavior:
- `-M -S` fetches fresh repodata while bypassing the on-disk cache
- The ordinary `-S` path can reuse a cached repodata archive
- No 6-hour delay observed in practice
Binary size limits:
- Test packages: 731–752 bytes (small test artifacts)
- Real packages expected to be <10MB (vendored pure-Python)
- Gitea serves any file size without LFS
**Caveat**: Clients using `xbps-install -Su` or `-Syu` without `-M` may use
cached repodata. Users should use `-M -Syu` for updates when immediate
publication visibility matters.
### 5. Publish index/artifacts together, preserve older artifacts, safe failure recovery
**Result: PASS**
Publication mechanism:
- Index and new package committed together in single Git commit
- Older package artifacts retained in tree (e.g., v0.3.5 alongside v0.3.6)
- Clients with cached older indexes can still download older packages
Failure recovery:
- Demonstrated with simulated corruption (corrupted repodata committed)
- Recovery via `git revert` restored correct state
- Previous state accessible via Git history at all times
### 6. Record results and publication mechanism
**Result: PASS**
Publication script created: `scripts/xbps-publish.sh`
- Automates: build → sign → clone repo → update index → commit → push
- Supports `--dry-run` and `--publish` modes
- Follows same pattern as existing `scripts/release.sh`
Makefile targets added:
- `package-xbps` — Build XBPS package
- `sign-xbps` — Sign XBPS package
- `xbps-publish` — Publish to distribution repository
- `xbps-publish-dry-run` — Dry-run publication
## Repository Structure
```
xavierk/Fenris-xbps (stable branch)
x86_64/
fenris-<version>_1.x86_64.xbps # Package archives
fenris-<version>_1.x86_64.xbps.sig2 # Package signatures
x86_64-repodata # Repository index (zstd-compressed tar)
keys/
fenris-xbps-signing.pub # Public signing key
README.md
```
## Client Configuration
Users add the repository to `/etc/xbps.d/xbps.conf`:
```
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
```
Or make a one-off request without writing the configuration file:
```sh
sudo xbps-install -R https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 -M -S fenris
```
## Publication Mechanism
1. Build: `make package-xbps`
2. Sign: `make sign-xbps` (or `scripts/xbps-publish.sh` handles this)
3. Publish: `make xbps-publish`
4. Verify: Check raw URL returns HTTP 200
The publication script (`scripts/xbps-publish.sh`) automates the full flow:
build → sign → clone repo → add to index → sign repository → commit → push.
## Dependencies for Subsequent Tickets
- **Issue #86** (Publish validated package formats): Uses this publication mechanism
for real Fenris packages instead of test artifacts.
- **Issue #87** (Validate on Void machine): Uses the established repository URL
and signing infrastructure for end-to-end validation.
+2 -2
View File
@@ -7,7 +7,7 @@ description: >
NVMe wear monitor with persistent TUI — observes real-world drive use and
translates it into an understandable endurance outlook.
homepage: https://git.bongbetic.com/xavierk/Fenris
license: Proprietary
license: MIT
depends:
- python3 (>= 3.10)
@@ -38,7 +38,7 @@ contents:
- dst: /var/lib/fenris
type: dir
file_info:
mode: 2750
mode: 02770
group: fenris
scripts:
+24 -2
View File
@@ -12,10 +12,17 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
case "${1:-}" in
configure)
if [ -n "${2:-}" ]; then
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
# Upgrade — snapshot, migration, init-system-aware reload
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
@@ -27,6 +34,7 @@ n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi
if [ "${INIT_SYSTEM}" = "systemd" ]; then
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
@@ -46,10 +54,24 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
rm -f "${OLD_CONTENT}"
done
fi
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
fi
# Fresh install: init-system-specific setup
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
else
# runit: create group, set directory permissions
groupadd -f fenris
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
chmod 02770 "${STORE_DIR}"
# Mark runit service as dormant (down) for fresh install
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
touch /etc/sv/fenris-collect/down
fi
# Ensure log directory exists
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
fi
;;
abort-upgrade|abort-install|disappear)
;;
+14 -1
View File
@@ -5,6 +5,13 @@
# postrm purge (after conffiles and config removed)
set -eu
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
case "${1:-}" in
purge)
rm -rf /etc/fenris
@@ -16,4 +23,10 @@ case "${1:-}" in
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
esac
systemctl daemon-reload 2>/dev/null || true
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemctl daemon-reload 2>/dev/null || true
else
# runit: clean up service directory and log
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
rm -rf /var/log/fenris-collect 2>/dev/null || true
fi
+13
View File
@@ -5,14 +5,27 @@
# prerm upgrade (old version about to be replaced)
set -eu
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
case "${1:-}" in
remove)
# Sanctioned disable — close monitoring period (spec §7)
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
fi
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
else
# runit: remove the service symlink
rm -f /var/service/fenris-collect
touch /etc/sv/fenris-collect/down 2>/dev/null || true
fi
;;
upgrade)
# Never interrupt monitoring on upgrade
+10
View File
@@ -6,6 +6,16 @@ Install Fenris from its package channel after following the [package setup instr
sudo apt update && sudo apt install fenris # Debian / Ubuntu
sudo dnf install fenris # Fedora
sudo zypper install fenris # openSUSE Tumbleweed
sudo xbps-install fenris # Void Linux
```
For Void Linux, configure the XBPS repository first:
```bash
sudo install -d -m 0755 /etc/xbps.d
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
| sudo tee /etc/xbps.d/fenris.conf
sudo xbps-install -M -S fenris
```
## Verify downloads
+29 -1
View File
@@ -8,13 +8,33 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
if [ "$1" -eq 1 ]; then
# Fresh install
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
else
# runit: create group, set directory permissions
groupadd -f fenris
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
chmod 02770 "${STORE_DIR}"
# Mark runit service as dormant (down) for fresh install
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
touch /etc/sv/fenris-collect/down
fi
# Ensure log directory exists
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
fi
elif [ "$1" -ge 2 ]; then
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
# Upgrade — snapshot, migration, init-system-aware reload
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
@@ -26,6 +46,7 @@ n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi
if [ "${INIT_SYSTEM}" = "systemd" ]; then
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
@@ -46,4 +67,11 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
done
# Re-apply placement modes (store dir group access, issue #54)
systemd-tmpfiles --create || true
else
# runit: repair log access when upgrading from an older package
groupadd -f fenris
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
chmod 02770 "${STORE_DIR}"
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
fi
fi
+14 -1
View File
@@ -2,6 +2,13 @@
# RPM %postun — post-uninstall scriptlet (spec §7).
set -eu
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
if [ "$1" -eq 0 ]; then
# Package fully erased — remove config, store, group
rm -rf /etc/fenris
@@ -10,4 +17,10 @@ if [ "$1" -eq 0 ]; then
groupdel fenris 2>/dev/null || true
fi
fi
systemctl daemon-reload 2>/dev/null || true
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemctl daemon-reload 2>/dev/null || true
else
# runit: clean up service directory and log
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
rm -rf /var/log/fenris-collect 2>/dev/null || true
fi
+13
View File
@@ -2,12 +2,25 @@
# RPM %preun — pre-uninstall scriptlet (spec §7).
set -eu
# Detect init system
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
INIT_SYSTEM="systemd"
else
INIT_SYSTEM="runit"
fi
if [ "$1" -eq 0 ]; then
# Package is being erased — sanctioned disable (spec §7)
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
fi
if [ "${INIT_SYSTEM}" = "systemd" ]; then
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
else
# runit: remove the service symlink
rm -f /var/service/fenris-collect
touch /etc/sv/fenris-collect/down 2>/dev/null || true
fi
fi
# On upgrade ($1 -ge 1): do nothing
+18
View File
@@ -14,6 +14,10 @@
# /usr/lib/tmpfiles.d/fenris.conf
set -euo pipefail
# Package directories must be traversable by every runtime user and agree
# with distribution-owned directories, regardless of the builder's umask.
umask 022
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
STAGE_DIR="${REPO_ROOT}/build/stage"
@@ -52,6 +56,14 @@ 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}"
# Package files must be importable by unprivileged Fenris users regardless of
# the builder's umask. pip otherwise preserves a restrictive umask in the
# vendored runtime, which makes the installed CLI fail before it can read the
# observation store.
chmod -R a+rX "${VENDOR_DIR}"
# Ship the application license in every native package.
install -D -m 0644 "${REPO_ROOT}/LICENSE" "${STAGE_DIR}/usr/share/licenses/fenris/LICENSE"
# --- Inject version into wrapper from pyproject.toml ---
# The wrapper has a hardcoded version string; patch it for packaging.
@@ -74,6 +86,12 @@ mkdir -p "${STAGE_DIR}/usr/lib/systemd/system"
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
# --- runit service files ---
echo " Installing runit service files ..."
mkdir -p "${STAGE_DIR}/etc/sv/fenris-collect/log"
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/run" "${STAGE_DIR}/etc/sv/fenris-collect/run"
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/log/run" "${STAGE_DIR}/etc/sv/fenris-collect/log/run"
# --- polkit policy ---
echo " Installing polkit policy ..."
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
# XBPS INSTALL script — post-install and post-upgrade paths.
#
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
#
# Actions: pre (before files extracted), post (after files extracted)
# UPDATE: "yes" on upgrade, "no" on fresh install
set -eu
STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
ACTION="$1"
UPDATE="$4"
case "${ACTION}" in
pre)
# Migration guard — abort if make-install remnants detected
MARKER="/var/lib/fenris/manifest.txt"
if [ -f "${MARKER}" ]; then
echo >&2
echo >&2 "Fenris make-install remnants detected — refusing to install."
echo >&2
echo >&2 "Migrate to the package with:"
echo >&2 " sudo make uninstall # removes make-install files, preserves store + config"
echo >&2 " sudo xbps-install fenris"
echo >&2
echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems"
echo >&2
exit 1
fi
;;
post)
# Keep observation-store access consistent across fresh installs and upgrades.
groupadd -f fenris
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
chmod 02770 "${STORE_DIR}"
if [ "${UPDATE}" = "yes" ]; then
# Upgrade — snapshot, migration, runit-aware reload
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest
from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi
# Ensure log directory exists on upgrade
groupadd -f fenris
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
else
# Fresh install — runit service setup
groupadd -f fenris
# Mark runit service as dormant (down) for fresh install
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
touch /etc/sv/fenris-collect/down
fi
# Ensure log directory exists
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
fi
;;
esac
exit 0
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# XBPS REMOVE script — pre-remove path.
#
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
#
# Actions: pre (before files removed)
set -eu
ACTION="$1"
UPDATE="$4"
case "${ACTION}" in
pre)
# Only a real erase is a sanctioned disable. An upgrade must preserve
# both monitoring intent and the active runit service.
if [ "${UPDATE}" = "no" ]; then
# Close the monitoring period while the store is still available.
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
fi
# Configuration and the observation store are deliberately not
# package-owned. Leave them in place: XBPS does not guarantee a
# post-remove callback before its cleanup action.
# runit: remove the service symlink and mark dormant
rm -f /var/service/fenris-collect
touch /etc/sv/fenris-collect/down 2>/dev/null || true
fi
;;
esac
exit 0
+3 -2
View File
@@ -1,8 +1,9 @@
[project]
name = "fenris"
version = "0.3.5"
version = "0.5.0"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
requires-python = ">=3.10"
license = {file = "LICENSE"}
dependencies = [
"textual>=0.40.0",
]
+1
View File
@@ -9,3 +9,4 @@ mdurl==0.1.2
platformdirs==4.11.7
Pygments==2.21.0
linkify-it-py==2.2.0
typing-extensions==4.16.0
+30
View File
@@ -68,6 +68,20 @@ def assemble_release_body(section: str, footer: str) -> str:
return f"{section}{separator}{footer}"
def format_availability_section(
available: list[str], withheld: list[str]
) -> str:
"""Generate a package formats section for release notes."""
if not available and not withheld:
return ""
lines = ["\n## Package formats\n"]
if available:
lines.append(f"Available: {', '.join(available)}")
if withheld:
lines.append(f"Withheld: {', '.join(withheld)}")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("changelog", type=Path)
@@ -77,6 +91,18 @@ def main(argv: list[str] | None = None) -> int:
type=Path,
help="append this standing release guidance after the extracted section",
)
parser.add_argument(
"--available",
action="append",
default=[],
help="format available for this release (can be repeated)",
)
parser.add_argument(
"--withheld",
action="append",
default=[],
help="format withheld from this release (can be repeated)",
)
args = parser.parse_args(argv)
try:
section = extract_changelog(args.changelog, args.version)
@@ -88,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
f"cannot read release footer {args.footer}: {error.strerror}"
) from error
section = assemble_release_body(section, footer)
if args.available or args.withheld:
formats = format_availability_section(args.available, args.withheld)
if formats:
section = f"{section}\n{formats}"
sys.stdout.write(section)
except ChangelogError as error:
print(f"::error::{error}", file=sys.stderr)
+9 -30
View File
@@ -7,8 +7,6 @@ Subcommands route through fenris-monitor for privileged operations.
Spec: §1.2, §8.4
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
@@ -35,35 +33,16 @@ def add_runtime_packages() -> None:
add_runtime_packages()
def is_root() -> bool:
"""Check if running as root."""
return os.geteuid() == 0
def run_monitor(*args: str) -> None:
"""Run fenris-monitor with the given arguments.
"""Render the shared privileged-action outcome for the CLI."""
from fenris.control import MonitorError, run_monitor as invoke_monitor
If not root, re-exec under pkexec.
"""
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
if is_root():
result = subprocess.run([monitor_cmd] + list(args))
sys.exit(result.returncode)
else:
# Use pkexec to elevate
pkexec = subprocess.run(
["which", "pkexec"], capture_output=True
)
if pkexec.returncode != 0:
print(
"Error: No polkit agent available. "
"Run as root: sudo fenris-monitor ...",
file=sys.stderr,
)
sys.exit(1)
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
sys.exit(result.returncode)
try:
invoke_monitor(*args)
except MonitorError as exc:
print(str(exc), file=sys.stderr)
sys.exit(exc.exit_code)
sys.exit(0)
def cmd_tui(args: argparse.Namespace) -> None:
@@ -143,7 +122,7 @@ def main() -> None:
description="Fenris NVMe endurance monitor",
)
parser.add_argument(
"--version", action="version", version="%(prog)s 0.3.0"
"--version", action="version", version="%(prog)s 0.3.6"
)
subparsers = parser.add_subparsers(dest="command")
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
set -euo pipefail
# Fenris XBPS publication script (issue #83).
# Builds, signs, and publishes XBPS packages to the Fenris-xbps repository.
#
# Usage:
# scripts/xbps-publish.sh --dry-run # Print commands without executing
# scripts/xbps-publish.sh --publish # Execute the full publication flow
#
# Environment:
# XBPS_SIGNING_KEY - Path to SSH RSA private key for XBPS signing
# (default: ~/.ssh/id_xbps)
# SIGNED_BY - Signature identity string
# (default: "Fenris Packaging <packaging@bongbetic.com>")
#
# Spec: native-void-support.md, ADR 0008
# ── Defaults ─────────────────────────────────────────────────────────────
DRY_RUN=false
PUBLISH=false
GITEA_URL="https://git.bongbetic.com"
GITEA_OWNER="xavierk"
GITEA_REPO="Fenris-xbps"
GITEA_BRANCH="stable"
ARCH="x86_64"
XBPS_SIGNING_KEY="${XBPS_SIGNING_KEY:-$HOME/.ssh/id_xbps}"
SIGNED_BY="${SIGNED_BY:-Fenris Packaging <packaging@bongbetic.com>}"
# ── 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 publication flow"
echo ""
echo "Environment:"
echo " XBPS_SIGNING_KEY Path to SSH RSA private key (default: ~/.ssh/id_xbps)"
echo " SIGNED_BY Signature identity (default: Fenris Packaging <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
}
_run() {
if $DRY_RUN; then
echo " $*"
else
eval "$@"
fi
}
# ── Pre-flight checks ───────────────────────────────────────────────────
if [[ ! -f "$XBPS_SIGNING_KEY" ]]; then
echo "ERROR: Signing key not found at $XBPS_SIGNING_KEY" >&2
echo "Generate one with: ssh-keygen -t rsa -b 3072 -f $XBPS_SIGNING_KEY" >&2
exit 1
fi
for tool in xbps-create xbps-rindex git; do
if ! command -v "$tool" &>/dev/null; then
echo "ERROR: Required tool not found: $tool" >&2
exit 1
fi
done
# ── Main ─────────────────────────────────────────────────────────────────
VERSION=$(_version)
REVISION="${XBPS_REVISION:-1}"
PKGVER="fenris-${VERSION}_${REVISION}"
XBPS_FILE="${PKGVER}.${ARCH}.xbps"
SOURCE_DIR=$(pwd)
XBPS_PATH="${SOURCE_DIR}/${XBPS_FILE}"
GIT_USER_NAME=$(git config user.name || true)
GIT_USER_EMAIL=$(git config user.email || true)
if [[ -z "${GIT_USER_NAME}" || -z "${GIT_USER_EMAIL}" ]]; then
echo "ERROR: Configure git user.name and user.email in the source repository before publishing" >&2
exit 1
fi
echo "=== Fenris XBPS Publication v${VERSION} ==="
echo ""
if $DRY_RUN; then
echo "[dry-run] Commands below will be executed in --publish mode."
echo ""
fi
# ── Step 1: Build XBPS package ───────────────────────────────────────────
echo "--- Build XBPS package ---"
_run "make XBPS_REVISION=${REVISION} package-xbps"
echo ""
# ── Step 2: Sign package ─────────────────────────────────────────────────
echo "--- Sign XBPS package ---"
_run "rm -f ${XBPS_PATH}.sig2"
_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_PATH}"
echo ""
# ── Step 3: Clone/update distribution repository ─────────────────────────
echo "--- Prepare distribution repository ---"
WORK_DIR=$(mktemp -d)
_run "git clone ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}.git ${WORK_DIR}"
_run "git -C ${WORK_DIR} config user.name '${GIT_USER_NAME}'"
_run "git -C ${WORK_DIR} config user.email '${GIT_USER_EMAIL}'"
_run "git -C ${WORK_DIR} checkout ${GITEA_BRANCH}"
_run "mkdir -p ${WORK_DIR}/${ARCH}"
echo ""
# ── Step 4: Copy artifacts and update index ──────────────────────────────
echo "--- Update repository index ---"
_run "cp ${XBPS_PATH} ${XBPS_PATH}.sig2 ${WORK_DIR}/${ARCH}/"
_run "(cd ${WORK_DIR} && xbps-rindex --add ${ARCH}/${XBPS_FILE})"
_run "(cd ${WORK_DIR} && xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${ARCH})"
echo ""
# ── Step 5: Commit and push ──────────────────────────────────────────────
echo "--- Commit and push ---"
_run "git -C ${WORK_DIR} add -A"
_run "git -C ${WORK_DIR} commit -m 'Release fenris ${VERSION}'"
_run "git -C ${WORK_DIR} push origin ${GITEA_BRANCH}"
echo ""
# ── Step 6: Verify publication ───────────────────────────────────────────
echo "--- Verify publication ---"
RAW_BASE="${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
if $PUBLISH; then
# Compare every served byte with the artifact that was indexed and pushed.
# A successful HEAD request alone can still hide a stale or incomplete
# publication behind the raw endpoint's cache.
# Repository signatures are embedded in x86_64-repodata by xbps-rindex;
# only package signatures are separate .sig2 files.
for artifact in "${XBPS_FILE}" "${XBPS_FILE}.sig2" "x86_64-repodata"; do
case "${artifact}" in
"${XBPS_FILE}") local_path="${XBPS_PATH}" ;;
"${XBPS_FILE}.sig2") local_path="${XBPS_PATH}.sig2" ;;
*) local_path="${WORK_DIR}/${ARCH}/${artifact}" ;;
esac
downloaded="${WORK_DIR}/.${artifact}.download"
curl --fail --silent --show-error --location \
--output "${downloaded}" "${RAW_BASE}/${artifact}"
cmp -- "${local_path}" "${downloaded}"
rm -f "${downloaded}"
done
else
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.${XBPS_FILE}.download ${RAW_BASE}/${XBPS_FILE}"
_run "cmp -- ${XBPS_PATH} ${WORK_DIR}/.${XBPS_FILE}.download"
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.${XBPS_FILE}.sig2.download ${RAW_BASE}/${XBPS_FILE}.sig2"
_run "cmp -- ${XBPS_PATH}.sig2 ${WORK_DIR}/.${XBPS_FILE}.sig2.download"
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.x86_64-repodata.download ${RAW_BASE}/x86_64-repodata"
_run "cmp -- ${WORK_DIR}/${ARCH}/x86_64-repodata ${WORK_DIR}/.x86_64-repodata.download"
_run "rm -f ${WORK_DIR}/.${XBPS_FILE}.download ${WORK_DIR}/.${XBPS_FILE}.sig2.download ${WORK_DIR}/.x86_64-repodata.download"
fi
echo ""
# ── Cleanup ──────────────────────────────────────────────────────────────
if $PUBLISH; then
rm -rf "${WORK_DIR}"
fi
# ── Done ─────────────────────────────────────────────────────────────────
echo "=== XBPS Publication v${VERSION} complete ==="
echo ""
echo "Summary:"
echo " Package: ${XBPS_FILE}"
echo " Repository: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}"
echo " Branch: ${GITEA_BRANCH}"
echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
echo ""
echo "Client installation:"
echo " echo 'repository=https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}' | sudo tee /etc/xbps.d/fenris.conf"
echo " sudo xbps-install -M -S fenris"
echo ""
echo "Key ceremony: delete the private key after publication."
echo " See docs/install/signing-key-ceremony.md"
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.1"
__version__ = "0.5.0"
+104
View File
@@ -0,0 +1,104 @@
"""A terminal volume plot shared by Fenris's activity views.
Braille provides two by four dots per terminal cell. Only adjacent, complete
measurements are connected; gaps and partial evidence never imply continuity.
The returned columns also place mouse inspection on the plotted time axis.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from rich.text import Text
@dataclass(frozen=True)
class VolumePoint:
time: float
volume: int | None
label: str
state: str = "measured"
def volume_plot(
points: Sequence[VolumePoint], width: int, height: int,
selected: int, colors: Mapping[str, str],
) -> tuple[Text, list[int], str]:
"""Render bounded axes and a dotted trace, without resampling evidence."""
width, height = max(12, width), max(4, height)
axis_width = 8
columns, rows = width - axis_width, height - 2
pixel_width, pixel_height = columns * 2, rows * 4
maximum = max((p.volume or 0 for p in points), default=0)
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
(1e9, "GB") if maximum >= 1e9 else
(1e6, "MB") if maximum >= 1e6 else
(1e3, "KB") if maximum >= 1e3 else (1, "B")
)
ceiling = maximum or scale
start = points[0].time if points else 0
span = (points[-1].time - start) if len(points) > 1 else 0
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
for p in points]
cells = [[0] * columns for _ in range(rows)]
# Unicode braille dot numbering, indexed by y within cell and then x.
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
def dot(x: int, y: int) -> None:
cells[y // 4][x // 2] |= bits[y % 4][x % 2]
previous = None
markers = {}
for index, (point, x) in enumerate(zip(points, xs)):
if point.volume is None:
previous = None
if point.state != "future":
markers[x // 2] = "?"
continue
y = round((1 - point.volume / ceiling) * (pixel_height - 1))
y = min(pixel_height - 1, max(0, y))
if previous is not None and point.state == "measured":
px, py = previous
steps = max(abs(x - px), abs(y - py), 1)
for step in range(steps + 1):
dot(round(px + (x - px) * step / steps),
round(py + (y - py) * step / steps))
dot(x, y)
previous = (x, y) if point.state == "measured" else None
if point.state != "measured":
markers[x // 2] = "~" if point.state == "partial" else "u"
elif point.volume == 0:
markers.setdefault(x // 2, "·")
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
result = Text(no_wrap=True, overflow="crop")
ticks = {0, rows // 2, rows - 1}
for row, values in enumerate(cells):
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
label = f"{value:6.2f}"[-6:] if row in ticks else " "
result.append(label + " │", style=colors["muted"])
for col, value in enumerate(values):
char = chr(0x2800 + value) if value else " "
style = colors["allocated"]
if col == selected_column:
style = "bold " + colors["selection"]
if not value:
char = "┊"
style = colors["muted"]
result.append(char, style=style)
result.append("\n")
result.append(" └", style=colors["muted"])
for col in range(columns):
result.append("▼" if col == selected_column else markers.get(col, "─"),
style=colors["selection"] if col == selected_column else colors["muted"])
result.append("\n" + " " * axis_width)
labels = [" "] * columns
end_of_label = -1
for index in sorted({0, len(points) // 2, len(points) - 1}):
if index < 0 or not points:
continue
label = points[index].label[:columns]
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
if column > end_of_label:
labels[column:column + len(label)] = label
end_of_label = column + len(label)
result.append("".join(labels), style=colors["muted"])
return result, [axis_width + x // 2 for x in xs], unit
+20
View File
@@ -340,6 +340,26 @@ def run_collection(
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
# Rebuild day aggregates from hour observations
from .day_aggregate import derive_all_days, persist_day_aggregate
for agg in derive_all_days(conn):
persist_day_aggregate(conn, agg)
# Derive local-day summary using system timezone (issue #90)
try:
from .tz_util import detect_system_tz
from .local_day import derive_local_day_summary, persist_local_day
tz_name = detect_system_tz()
clock_now = clock.utcnow()
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
if local_summary is not None:
persist_local_day(conn, local_summary)
except Exception:
# Local-day derivation failure must not prevent publication
pass
conn.commit()
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
+56
View File
@@ -0,0 +1,56 @@
"""Terminal-attached invocation of Fenris's fixed privileged operations.
Both human entry points use this module. Authentication has no frontend
deadline; collection runtime is bounded by the native scheduler (ADR 0003).
"""
import os
import shlex
import subprocess
MONITOR_HELPER = "/usr/libexec/fenris/fenris-monitor"
class MonitorError(Exception):
"""An action failed, with a message and exit status for either renderer."""
def __init__(self, message: str, exit_code: int = 1):
super().__init__(message)
self.exit_code = exit_code
def run_monitor(*args: str, helper_path: str = MONITOR_HELPER) -> None:
"""Run one helper operation, inheriting the terminal for authentication.
Never invoke a shell, retry an action, or fall back to sudo automatically.
The helper owns the operation allow-list and privileged state changes.
"""
helper_command = [helper_path, *args]
needs_auth = os.geteuid() != 0
command = ["pkexec", *helper_command] if needs_auth else helper_command
root_hint = (
" If authentication is unavailable, run in your terminal: "
+ shlex.join(["sudo", *helper_command])
) if needs_auth else ""
try:
# The collector owns its 90-second runtime limit. A frontend timeout
# would also count time spent authenticating or waiting for a run.
result = subprocess.run(command)
except FileNotFoundError as exc:
raise MonitorError(
"Command not found: %s.%s" % (exc.filename or command[0], root_hint), 127,
) from exc
except OSError as exc:
raise MonitorError("Cannot run monitoring action: %s.%s" % (exc, root_hint)) from exc
except KeyboardInterrupt as exc:
raise MonitorError(
"Action interrupted. Check fenris status before retrying.", 130,
) from exc
if result.returncode:
exit_code = result.returncode if result.returncode > 0 else 128 - result.returncode
raise MonitorError(
"Action failed (exit %d). Check fenris status before retrying.%s"
% (exit_code, root_hint), exit_code,
)
+38
View File
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
if agg is not None:
results.append(agg)
return results
def persist_day_aggregate(conn: sqlite3.Connection, agg: DayAggregate) -> None:
"""Upsert a derived day aggregate into the day_aggregates table.
Merges attributed bytes from hour observations with any existing
unattributed cross-hour evidence already stored for this day.
Caller must manage transactions and commits.
"""
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?",
(agg.day,),
).fetchone()
if existing is None:
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(agg.day, agg.seconds_active, agg.seconds_idle,
agg.seconds_powered_off, agg.seconds_unknown,
agg.bytes_written_delta, agg.bytes_read_delta,
agg.sample_count, agg.coverage),
)
else:
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 id = ?",
(agg.seconds_active, agg.seconds_idle,
agg.seconds_powered_off, agg.seconds_unknown,
agg.bytes_written_delta, agg.bytes_read_delta,
agg.sample_count, agg.coverage,
existing[0]),
)
+16 -9
View File
@@ -175,7 +175,8 @@ def _upsert_hour_observation(
"""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 = ?",
"SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations WHERE hour = ?",
(hour_key,),
).fetchone()
@@ -198,10 +199,13 @@ def _upsert_hour_observation(
else:
# Merge: accumulate bytes and sample count
new_bw = existing[1] + bw_delta
new_samples = existing[2] + sample_count
new_br = existing[2] + br_delta
new_samples = existing[3] + sample_count
conn.execute(
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
(new_bw, new_samples, existing[0]),
"UPDATE hour_observations "
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
"WHERE id = ?",
(new_bw, new_br, new_samples, existing[0]),
)
conn.commit()
@@ -213,12 +217,15 @@ def _add_unattributed_bytes(
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")
"""Store unattributed byte deltas once as shared boundary evidence.
A cross-midnight interval's delta is preserved on the day where it
STARTS (the earlier day). It is not duplicated into both days;
the spec requires preserving the measured volume once as shared
unallocated boundary evidence (issue #88).
"""
day = prev_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()
+500
View File
@@ -0,0 +1,500 @@
"""Init system abstraction for Fenris monitoring (issue #84).
Detects the active init system (systemd or runit) and provides a unified
interface for timer/collection control and service-state queries. This keeps
the privileged helper and status composition init-system agnostic without
introducing a generalized plugin framework.
Design: ADR 0008 — keep service-specific operations behind a cohesive
responsibility shared by the privileged control path and read-only status
composition.
Runit service layout:
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 })
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
/var/service/fenris-collect — symlink to enable
/etc/sv/fenris-collect/down — marker for dormant install
Runit guarantees:
- Completion-relative 3-minute cadence (sleep 180 after each collect)
- Initial 2-minute boot delay (sleep 120 before first collect)
- Bounded execution (90s timeout via timeout(1))
- No catch-up (service sleeps fixed interval, no Persistent= flag)
- Serialized scheduled runs (runsv does not restart until exit)
- Serialized on-demand (flock serializes fenris-collect execution)
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
"""
import os
import shutil
import subprocess
import sys
from enum import Enum
from pathlib import Path
from typing import Any, Dict, Optional
# ---------------------------------------------------------------------------
# Init system detection
# ---------------------------------------------------------------------------
class InitSystem(Enum):
SYSTEMD = "systemd"
RUNIT = "runit"
FENRIS_SV_DIR = Path("/etc/sv/fenris-collect")
FENRIS_SERVICE_LINK = Path("/var/service/fenris-collect")
FENRIS_LOG_DIR = Path("/var/log/fenris-collect")
COLLECT_TIMEOUT_S = 90
def detect_init_system() -> InitSystem:
"""Detect the active init system.
Checks for systemd first (PID 1 is systemd or /run/systemd/system exists),
then falls back to runit (PID 1 is runsv or /etc/sv exists).
"""
# systemd detection: /run/systemd/system exists when systemd is PID 1
if Path("/run/systemd/system").exists():
return InitSystem.SYSTEMD
# Check PID 1 name
try:
pid1_comm = Path("/proc/1/comm").read_text().strip()
if pid1_comm == "systemd":
return InitSystem.SYSTEMD
if pid1_comm in ("runsv", "runsvdir"):
return InitSystem.RUNIT
except OSError:
pass
# Fallback: check for /etc/sv (Void Linux default)
if Path("/etc/sv").is_dir():
return InitSystem.RUNIT
# Default to systemd (existing behavior)
return InitSystem.SYSTEMD
def get_init_system() -> InitSystem:
"""Get the detected init system (cached)."""
if not hasattr(get_init_system, "_cached"):
get_init_system._cached = detect_init_system()
return get_init_system._cached
def reset_init_system_cache() -> None:
"""Reset the cached init system detection (for testing)."""
if hasattr(get_init_system, "_cached"):
delattr(get_init_system, "_cached")
# ---------------------------------------------------------------------------
# systemd backend
# ---------------------------------------------------------------------------
def _systemd_enable(now: bool) -> None:
"""Enable and optionally start the systemd timer."""
cmd = ["systemctl", "enable"]
if now:
cmd.append("--now")
cmd.append("fenris-collect.timer")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print("Error enabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer enabled" + (" and started" if now else ""))
def _systemd_disable(now: bool) -> None:
"""Disable and optionally stop the systemd timer."""
cmd = ["systemctl", "disable"]
if now:
cmd.append("--now")
cmd.append("fenris-collect.timer")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print("Error disabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer disabled" + (" and stopped" if now else ""))
def _systemd_collect() -> None:
"""Trigger on-demand collection via systemd (blocking)."""
result = subprocess.run(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Collection completed successfully")
else:
print("Collection failed:", result.stderr, file=sys.stderr)
sys.exit(1)
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
"""Query systemctl show for specific properties."""
try:
result = subprocess.run(
["systemctl", "show", unit, "--property=" + ",".join(properties)],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return {}
out = {}
for line in result.stdout.splitlines():
if "=" in line:
key, _, value = line.partition("=")
out[key.strip()] = value.strip()
return out
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return {}
def _systemd_query_state() -> Dict[str, Any]:
"""Query systemd for the four separate service facts."""
from datetime import datetime, timezone
timer_props = _systemctl_show(
"fenris-collect.timer",
"UnitFileState", "ActiveState", "LastTriggerUSec",
)
service_props = _systemctl_show(
"fenris-collect.service",
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
)
boot_enabled_str = timer_props.get("UnitFileState")
boot_enabled = boot_enabled_str == "enabled" if boot_enabled_str else None
active_state = timer_props.get("ActiveState")
timer_active = active_state == "active" if active_state else None
last_collect_ok = None
last_collect_age_s = None
last_collect_reason = None
last_trigger = timer_props.get("LastTriggerUSec", "")
if last_trigger and last_trigger != "n/a":
try:
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
last_collect_age_s = int((now - trigger_dt).total_seconds())
except (ValueError, TypeError):
pass
exec_status = service_props.get("ExecMainStatus", "")
if exec_status:
try:
exit_code = int(exec_status)
last_collect_ok = exit_code == 0
if exit_code != 0:
last_collect_reason = "exit code %d" % exit_code
except (ValueError, TypeError):
pass
return {
"boot_enabled": boot_enabled,
"timer_active": timer_active,
"last_collect_ok": last_collect_ok,
"last_collect_age_s": last_collect_age_s,
"last_collect_reason": last_collect_reason,
}
def _systemd_journal_hint(lines: int = 5) -> Optional[str]:
"""Get the last N journal lines for fenris-collect.service."""
try:
result = subprocess.run(
["journalctl", "-u", "fenris-collect.service",
"--no-pager", "-n", str(lines), "--output=short-iso"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return None
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
# ---------------------------------------------------------------------------
# runit backend
# ---------------------------------------------------------------------------
def _runit_enable(_now: bool) -> None:
"""Enable the runit service by creating a symlink.
runit activates the service immediately when the symlink appears.
If the service is already enabled but stopped (e.g., via `sv stop`),
restart it when `now=True`.
"""
if FENRIS_SERVICE_LINK.exists():
# Already enabled — check if we need to restart
if _now and not _runit_is_running():
# Service is stopped but enabled — restart via sv
try:
subprocess.run(
["sv", "restart", "fenris-collect"],
capture_output=True, text=True, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
print("Service already enabled (idempotent)")
return
# Remove the 'down' file if present (dormant install marker)
down_file = FENRIS_SV_DIR / "down"
if down_file.exists():
down_file.unlink()
FENRIS_SERVICE_LINK.symlink_to(FENRIS_SV_DIR)
print("Service enabled")
def _runit_disable(_now: bool) -> None:
"""Disable the runit service by removing the symlink.
runit stops the service immediately when the symlink is removed.
"""
if not FENRIS_SERVICE_LINK.exists():
print("Service already disabled (idempotent)")
return
FENRIS_SERVICE_LINK.unlink()
# Place 'down' file to mark as intentionally disabled
(FENRIS_SV_DIR / "down").touch()
print("Service disabled")
def _runit_collect() -> None:
"""Trigger on-demand collection via direct execution with flock.
Serializes against the scheduler using the same lock file.
The timeout(1) command enforces bounded execution.
"""
lock_path = Path("/var/lib/fenris/fenris-collect.lock")
collect_script = Path("/usr/libexec/fenris/fenris-collect")
fallback_script = Path(__file__).parent.parent.parent / "src" / "fenris" / "collect.py"
if collect_script.exists():
script = str(collect_script)
elif fallback_script.exists():
script = str(fallback_script)
else:
print("Error: fenris-collect script not found", file=sys.stderr)
sys.exit(1)
try:
result = subprocess.run(
["flock", "--nonblock", str(lock_path),
"timeout", str(COLLECT_TIMEOUT_S), "nice", "ionice", "-c3",
sys.executable, script],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Collection completed successfully")
elif result.returncode == 124:
print("Collection timed out after %ds" % COLLECT_TIMEOUT_S, file=sys.stderr)
sys.exit(1)
else:
# exit code 1 from flock means lock is held (scheduled run in progress)
if result.returncode == 1 and "Resource temporarily unavailable" in result.stderr:
print("Collection already in progress (serialized)", file=sys.stderr)
sys.exit(1)
print("Collection failed:", result.stderr, file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print("Error: flock/timeout not found", file=sys.stderr)
sys.exit(1)
def _runit_is_enabled() -> bool:
"""Check if the runit service is enabled (symlink exists)."""
return FENRIS_SERVICE_LINK.exists()
def _runit_is_running() -> bool:
"""Check if the runit service is currently running.
Looks for a 'supervise/pid' file in the service directory. Void creates
that directory root-only, so unprivileged dashboard reads fall back to
the public process table when they cannot traverse it.
"""
def runsv_process_exists() -> bool:
try:
result = subprocess.run(
["pgrep", "-f", "^runsv fenris-collect$"],
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return False
pid_file = FENRIS_SV_DIR / "supervise" / "pid"
# On Void, Path.exists() is false for an unprivileged process when it
# cannot traverse runit's root-only supervise directory.
if not pid_file.exists():
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
try:
pid = int(pid_file.read_text().strip())
# Check if the process is alive
os.kill(pid, 0)
return True
except PermissionError:
# runsv's supervisor state is root-only on Void. Its process command
# is still observable, which gives the read-only UI the same runtime
# fact without granting it service-control permissions.
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
except (ValueError, OSError):
return False
def _runit_query_state() -> Dict[str, Any]:
"""Query runit for the four separate service facts.
Checks: boot_enabled (symlink), timer_active (running), last_collect
(store-based), freshness (store-based).
"""
from datetime import datetime, timezone
from pathlib import Path
boot_enabled = _runit_is_enabled()
timer_active = _runit_is_running()
last_collect_ok = None
last_collect_age_s = None
last_collect_reason = None
# Try to get last collection info from the store
store_path = Path("/var/lib/fenris/observations.db")
if store_path.exists():
try:
import sqlite3
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
conn.row_factory = sqlite3.Row
# Get newest sample
cursor = conn.execute(
"SELECT ts FROM samples ORDER BY id DESC LIMIT 1"
)
row = cursor.fetchone()
if row and row[0]:
try:
ts = datetime.fromisoformat(row[0])
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
last_collect_age_s = int((now - ts).total_seconds())
last_collect_ok = True
except (ValueError, TypeError):
pass
# Check for failed collections via monitoring_periods
cursor = conn.execute(
"SELECT end_cause FROM monitoring_periods "
"WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1"
)
row = cursor.fetchone()
if row and row[0] and row[0] not in ("user_disabled", None):
last_collect_reason = row[0]
conn.close()
except Exception:
pass
# Check for timeout/exit failures via supervise exit status
if timer_active:
exit_file = FENRIS_SV_DIR / "supervise" / "exit"
if exit_file.exists():
try:
exit_code = int(exit_file.read_text().strip())
if exit_code != 0:
last_collect_ok = False
last_collect_reason = "exit code %d" % exit_code
except (ValueError, OSError):
pass
return {
"boot_enabled": boot_enabled,
"timer_active": timer_active,
"last_collect_ok": last_collect_ok,
"last_collect_age_s": last_collect_age_s,
"last_collect_reason": last_collect_reason,
}
def _runit_journal_hint(lines: int = 5) -> Optional[str]:
"""Get the last N log lines for fenris-collect from runit logging."""
log_current = FENRIS_LOG_DIR / "current"
if not log_current.exists():
return None
try:
# Use tail to get the last N lines
result = subprocess.run(
["tail", "-n", str(lines), str(log_current)],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return None
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
# ---------------------------------------------------------------------------
# Public API — unified interface
# ---------------------------------------------------------------------------
def enable_timer(now: bool) -> None:
"""Enable the collection timer/service."""
init = get_init_system()
if init == InitSystem.SYSTEMD:
_systemd_enable(now)
else:
_runit_enable(now)
def disable_timer(now: bool) -> None:
"""Disable the collection timer/service."""
init = get_init_system()
if init == InitSystem.SYSTEMD:
_systemd_disable(now)
else:
_runit_disable(now)
def collect_now() -> None:
"""Trigger on-demand collection (blocking)."""
init = get_init_system()
if init == InitSystem.SYSTEMD:
_systemd_collect()
else:
_runit_collect()
def query_service_state() -> Dict[str, Any]:
"""Query the four separate service facts."""
init = get_init_system()
if init == InitSystem.SYSTEMD:
return _systemd_query_state()
else:
return _runit_query_state()
def journal_hint(lines: int = 5, unit: str = "fenris-collect.service") -> Optional[str]:
"""Get journal/log hints for diagnostics.
The unit parameter is accepted for backward compatibility with callers
that pass 'fenris-collect.service'. For runit, the unit parameter is
ignored since the log directory is always /var/log/fenris-collect/.
"""
init = get_init_system()
if init == InitSystem.SYSTEMD:
return _systemd_journal_hint(lines)
else:
return _runit_journal_hint(lines)
+379
View File
@@ -0,0 +1,379 @@
"""Local-day activity derivation from UTC hour observations.
Computes durable local-day read/write summaries using the actual local
midnight boundaries, retaining UTC hour/day aggregates for endurance
projections. The collector owns this derivation, preserving the
existing read-only TUI boundary (ADR 0010).
Key contracts:
- An interval wholly attributable to a local day contributes its volume once
- Midnight-spanning intervals are retained once as shared/unallocated evidence
- UTC hour/day aggregates are never modified or deleted
- Migration cannot manufacture local precision from historical UTC data
Retention policy (issue #93):
- Raw three-minute samples are pruned after 14 days (spec §3.4, ST-5).
- Hour observations and day aggregates are retained indefinitely.
- Local-day summaries are persisted at collection time and retained
indefinitely. They survive raw-sample pruning because they depend on
hour observations, not on raw samples.
- After detail expires, aged local summaries remain queryable with their
recorded timezone and UTC boundaries. The evidence-availability flag
on each history entry indicates whether the underlying raw detail is
still present or has been pruned.
- A later timezone change does not rewrite historical day boundaries.
Each summary retains the timezone and offsets recorded at collection.
- No fabricated evidence replaces missing precision. Incomplete or
unavailable dates are labelled as such in the history readout.
"""
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class LocalDaySummary:
"""One local day's aggregated read/write volumes."""
local_date: str # e.g. "2026-09-01"
tz_name: str # e.g. "Asia/Kolkata"
tz_offset: str # e.g. "+05:30"
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
bytes_written: int
bytes_read: int
coverage: float # known / (known + unknown) in UTC hours
sample_count: int
complete: bool # day's UTC range fully covered by hour observations
@dataclass(frozen=True)
class LocalDayHistoryEntry:
"""A local-day summary with evidence-limit metadata for history readout.
Includes the summary data plus flags that indicate whether the
underlying raw three-minute detail is still available or has been
pruned, and whether the summary was derived from surviving evidence
or is a legacy UTC-only aggregate.
"""
local_date: str
tz_name: str
tz_offset: str
utc_start: str
utc_end: str
bytes_written: int
bytes_read: int
coverage: float
sample_count: int
complete: bool
detail_available: bool # True if raw samples for this day are within 14-day retention
derived_from_surviving: bool # True if derived from hour observations, not raw samples
@classmethod
def from_summary(
cls,
summary: dict,
detail_available: bool,
derived_from_surviving: bool = True,
) -> "LocalDayHistoryEntry":
"""Create a history entry from a stored summary dict."""
return cls(
local_date=summary["local_date"],
tz_name=summary["tz_name"],
tz_offset=summary["tz_offset"],
utc_start=summary["utc_start"],
utc_end=summary["utc_end"],
bytes_written=summary["bytes_written"],
bytes_read=summary["bytes_read"],
coverage=summary["coverage"],
sample_count=summary["sample_count"],
complete=summary["complete"],
detail_available=detail_available,
derived_from_surviving=derived_from_surviving,
)
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
"""Compute the UTC time of the local midnight that contains *dt*.
Returns the most recent local midnight in UTC. For example, if *dt*
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
"""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = dt.astimezone(local_tz)
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
return local_midnight.astimezone(timezone.utc)
def derive_local_day_summary(
conn: sqlite3.Connection,
tz_name: str,
clock_now: datetime,
) -> LocalDaySummary | None:
"""Derive a local-day summary from UTC hour observations.
Computes the UTC boundaries of the current local day, queries the
UTC hours overlapping that range, and aggregates read/write volumes.
Midnight-spanning hours are retained once as shared evidence.
Returns None if no hours exist for the local day.
"""
from zoneinfo import ZoneInfo
from .tz_util import get_tz_offset_str
local_tz = ZoneInfo(tz_name)
local_dt = clock_now.astimezone(local_tz)
local_date = local_dt.strftime("%Y-%m-%d")
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
# UTC boundaries of this local day
utc_start = _local_midnight_utc(clock_now, tz_name)
next_local = local_dt + timedelta(days=1)
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
utc_start_iso = utc_start.isoformat()
utc_end_iso = utc_end.isoformat()
# Query UTC hours overlapping the local day
cursor = conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
"FROM hour_observations "
"WHERE hour >= ? AND hour < ? "
"ORDER BY hour",
(utc_start_iso, utc_end_iso),
)
rows = cursor.fetchall()
# Check for a midnight-spanning UTC hour before utc_start.
# When the local midnight falls inside a UTC hour (e.g. UTC+5:30
# where local midnight is 18:30 UTC), the hour 18:00 straddles the
# boundary. The main query (hour >= utc_start) excludes it because
# 18:00 < 18:30, so we must include it separately. This does NOT
# double-count: the hour falls outside the query range by
# construction (issue #93).
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
prev_row = conn.execute(
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations WHERE hour = ?",
(midnight_hour_iso,),
).fetchone()
total_bw = 0
total_br = 0
total_samples = 0
known_seconds = 0
unknown_seconds = 0
hour_count = 0
for row in rows:
total_bw += row[1] or 0
total_br += row[2] or 0
total_samples += row[3] or 0
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
unknown_seconds += row[7] or 0
hour_count += 1
# Include midnight-spanning hour if it exists and is not already
# in the main query results (it won't be, since hour < utc_start).
if prev_row is not None:
# Verify this hour is NOT already counted in the main query
prev_hour_iso = prev_row[0]
already_counted = any(r[0] == prev_hour_iso for r in rows)
if not already_counted:
total_bw += prev_row[1] or 0
total_br += prev_row[2] or 0
total_samples += prev_row[3] or 0
hour_count += 1
total_evidenced = known_seconds + unknown_seconds
# Coverage is known seconds as a share of the full local day,
# not just the hours present — gaps reduce coverage
day_seconds = int((utc_end - utc_start).total_seconds())
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
# Day is complete only if known (usable) seconds cover the full local day.
# Unknown seconds represent gaps without usable observation evidence.
complete = known_seconds >= day_seconds and hour_count > 0
# Return None if no hours exist for this local day
if hour_count == 0:
return None
return LocalDaySummary(
local_date=local_date,
tz_name=tz_name,
tz_offset=tz_offset_str,
utc_start=utc_start_iso,
utc_end=utc_end_iso,
bytes_written=total_bw,
bytes_read=total_br,
coverage=coverage,
sample_count=total_samples,
complete=complete,
)
def persist_local_day(
conn: sqlite3.Connection,
summary: LocalDaySummary,
) -> bool:
"""Upsert a local-day summary. Returns True if newly created."""
existing = conn.execute(
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
(summary.local_date, summary.tz_name),
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(summary.local_date, summary.tz_name, summary.tz_offset,
summary.utc_start, summary.utc_end,
summary.bytes_written, summary.bytes_read,
summary.coverage, summary.sample_count, summary.complete),
)
created = True
else:
conn.execute(
"UPDATE local_days "
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
" bytes_written = ?, bytes_read = ?, "
" coverage = ?, sample_count = ?, complete = ? "
"WHERE id = ?",
(summary.tz_offset, summary.utc_start, summary.utc_end,
summary.bytes_written, summary.bytes_read,
summary.coverage, summary.sample_count, summary.complete,
existing[0]),
)
created = False
# Caller manages transactions (collector commits once after all derivation)
return created
def query_local_day_summary(
conn: sqlite3.Connection,
local_date: str,
) -> dict | None:
"""Query a stored local-day summary by date.
Returns a dict with the summary fields, or None if no entry exists.
"""
row = conn.execute(
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete "
"FROM local_days WHERE local_date = ? "
"ORDER BY id DESC LIMIT 1",
(local_date,),
).fetchone()
if row is None:
return None
return {
"local_date": row[0],
"tz_name": row[1],
"tz_offset": row[2],
"utc_start": row[3],
"utc_end": row[4],
"bytes_written": row[5],
"bytes_read": row[6],
"coverage": row[7],
"sample_count": row[8],
"complete": bool(row[9]),
}
def query_current_local_day(
conn: sqlite3.Connection,
clock_now: datetime,
tz_name: str,
) -> dict | None:
"""Query the local-day summary for the current local date."""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = clock_now.astimezone(local_tz)
local_date = local_dt.strftime("%Y-%m-%d")
return query_local_day_summary(conn, local_date)
def _is_detail_available(
conn: sqlite3.Connection,
utc_start: str,
utc_end: str,
now: datetime,
retention_days: int = 14,
) -> bool:
"""Check if raw samples covering the local-day range are still retained.
Returns True if at least one sample within [utc_start, utc_end] is
younger than retention_days. This is a conservative check; the actual
pruning boundary depends on boundary-anchor logic.
"""
cutoff = now - timedelta(days=retention_days)
cutoff_iso = cutoff.isoformat()
# If any sample in the range is newer than cutoff, detail is available
row = conn.execute(
"SELECT 1 FROM samples WHERE ts >= ? AND ts < ? AND ts >= ? LIMIT 1",
(utc_start, utc_end, cutoff_iso),
).fetchone()
return row is not None
def query_local_day_history(
conn: sqlite3.Connection,
start_date: str,
end_date: str,
now: datetime,
) -> list[LocalDayHistoryEntry]:
"""Query local-day summaries for a date range with evidence-limit metadata.
Returns history entries sorted by local_date, each annotated with
whether the underlying raw detail is still available (within the
14-day retention window) or has been pruned. Timezone information
and UTC boundaries are always present, even after detail expires.
Args:
conn: Connection to the observation store.
start_date: Inclusive start date (e.g. "2026-09-01").
end_date: Inclusive end date (e.g. "2026-09-14").
now: Current UTC time for retention boundary check.
Returns:
List of LocalDayHistoryEntry sorted by local_date.
"""
rows = conn.execute(
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete "
"FROM local_days "
"WHERE local_date >= ? AND local_date <= ? "
"ORDER BY local_date",
(start_date, end_date),
).fetchall()
entries = []
for row in rows:
summary = {
"local_date": row[0],
"tz_name": row[1],
"tz_offset": row[2],
"utc_start": row[3],
"utc_end": row[4],
"bytes_written": row[5],
"bytes_read": row[6],
"coverage": row[7],
"sample_count": row[8],
"complete": row[9],
}
detail_available = _is_detail_available(conn, row[3], row[4], now)
entries.append(
LocalDayHistoryEntry.from_summary(
summary,
detail_available=detail_available,
derived_from_surviving=True,
)
)
return entries
+13 -54
View File
@@ -15,9 +15,7 @@ When run as a script, uses the fenris package from the installed wheel.
import argparse
import json
import os
import subprocess
import sys
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
@@ -39,6 +37,11 @@ from fenris.monitoring_periods import (
close_period,
get_open_period,
)
from fenris.init_system import (
enable_timer,
disable_timer,
collect_now,
)
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
@@ -70,25 +73,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
else:
print("Monitoring period already open (id=%d)" % open_period["id"])
# Enable and start the timer
if args.now:
result = subprocess.run(
["systemctl", "enable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "enable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error enabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer enabled" + (" and started" if args.now else ""))
# Enable the timer (init-system aware)
enable_timer(args.now)
finally:
conn.close()
@@ -117,25 +103,8 @@ def cmd_disable(args: argparse.Namespace) -> None:
else:
print("No open monitoring period (no-op)")
# Disable and stop the timer
if args.now:
result = subprocess.run(
["systemctl", "disable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "disable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error disabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer disabled" + (" and stopped" if args.now else ""))
# Disable the timer (init-system aware)
disable_timer(args.now)
finally:
conn.close()
@@ -143,22 +112,12 @@ def cmd_disable(args: argparse.Namespace) -> None:
def cmd_collect(args: argparse.Namespace) -> None:
"""Trigger on-demand collection.
Starts fenris-collect.service, blocks until exit, reports outcome.
Starts fenris-collect, blocks until exit, reports outcome.
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
which blocks until the oneshot exits; outcome reported synchronously.
Spec §8.7: fenris sample routes through fenris-monitor → collect,
which blocks until the collection exits; outcome reported synchronously.
"""
result = subprocess.run(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Collection completed successfully")
else:
print("Collection failed:", result.stderr, file=sys.stderr)
sys.exit(1)
collect_now()
def cmd_baseline_set(args: argparse.Namespace) -> None:
+3 -3
View File
@@ -19,8 +19,8 @@ from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
DEFAULT_THEME = "chalktone"
DEFAULT_REDUCED_MOTION = False
@@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]:
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
values, returns safe defaults (Chalktone theme, normal motion).
"""
path = get_preference_path()
+49
View File
@@ -483,6 +483,31 @@ def _build_pu_context_line(conn, rate, days, clock_now):
return "Percentage Used: %d%%" % pu
# ---------------------------------------------------------------------------
# Complete observation day gate (issue #94)
# ---------------------------------------------------------------------------
def _has_complete_local_day(conn):
"""Check if at least one complete local observation day exists.
A complete local day is a full midnight-to-midnight calendar day
within a monitoring period that has usable observation evidence.
This is the prerequisite for showing an endurance outlook.
"""
row = conn.execute(
"SELECT 1 FROM local_days WHERE complete = 1 LIMIT 1"
).fetchone()
return row is not None
def _count_complete_local_days(conn):
"""Count the number of complete local observation days."""
row = conn.execute(
"SELECT COUNT(*) FROM local_days WHERE complete = 1"
).fetchone()
return row[0] if row else 0
# ---------------------------------------------------------------------------
# Main projection function
# ---------------------------------------------------------------------------
@@ -499,6 +524,30 @@ def compute_projection(conn, clock_now):
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
facts.extend(baseline_facts)
# --- Complete observation day gate (issue #94) ---
# An endurance outlook requires at least one complete local
# midnight-to-midnight calendar day with usable observation evidence.
has_complete_day = _has_complete_local_day(conn)
if not has_complete_day:
facts.append("waiting for a full local observation day")
return ProjectionResult(
confidence_state=ConfidenceState.UNSUPPORTED,
contributing_facts=facts,
headline_remaining_seconds=None,
scenario_range=None,
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
disclosure_text=list(DISCLOSURES),
baseline_tier=tier,
baseline_label=baseline_label,
regime_days=None,
habit_change_fact=None,
warming_fact=None,
staleness_fact=None,
degraded_identity_fact=None,
zero_rate_fact=None,
qualifying_days_progress=None,
)
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
all_days = _get_all_days(conn)
+6
View File
@@ -3,6 +3,12 @@
Raw samples are pruned opportunistically to 14 days.
Hour observations and day aggregates are retained indefinitely.
Boundary anchors required for successor evidence are retained.
Local-day summaries (local_days table) are never touched by pruning.
They are persisted at collection time from hour observations and survive
raw-sample pruning because they depend on hour observations, not on raw
samples. This is the mechanism that keeps local-day history trustworthy
after detail expires (issue #93).
"""
import sqlite3
from datetime import datetime, timedelta, timezone
+112 -323
View File
@@ -1,8 +1,8 @@
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
Composes from the observation store (read-only) and allow-listed systemctl
Composes from the observation store (read-only) and allow-listed service
properties: projection facts, four separate service facts (boot enablement,
runtime activity, last collect outcome, freshness), and a journalctl hint on
runtime activity, last collect outcome, freshness), and a journal/log hint on
failure or staleness. Never auto-samples, never prompts.
Freshness constants are defined once here and shared with the TUI (§8.9):
@@ -14,23 +14,30 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
"""
import sqlite3
import subprocess
from contextlib import contextmanager
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from .projection import compute_projection, ConfidenceState, DISCLOSURES
from .store import SCHEMA_VERSION
from .init_system import (
query_service_state as _init_query_service_state,
journal_hint as _init_journal_hint,
)
# ---------------------------------------------------------------------------
# Freshness constants (§8.9, §8.2)
# ---------------------------------------------------------------------------
CADENCE_DEFAULT_S = 300 # 5 min
CADENCE_DEFAULT_S = 180 # 3 min
ACCURACY_SEC = 30
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
@@ -95,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
# 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 MissingStore("observation store not found at %s" % store_path)
try:
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
@@ -122,6 +129,10 @@ class StoreFault(Exception):
pass
class MissingStore(StoreFault):
"""No observation history has been created yet."""
class NewerSchema(Exception):
"""Store has a newer user_version (§9.5)."""
def __init__(self, version: int):
@@ -130,97 +141,12 @@ class NewerSchema(Exception):
# ---------------------------------------------------------------------------
# Service state queries (§8.8 — allow-listed systemctl properties)
# Service state queries (§8.8 — init-system agnostic)
# ---------------------------------------------------------------------------
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
"""Query systemctl show for specific properties. Returns empty dict on failure."""
try:
result = subprocess.run(
["systemctl", "show", unit, "--property=" + ",".join(properties)],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return {}
out = {}
for line in result.stdout.splitlines():
if "=" in line:
key, _, value = line.partition("=")
out[key.strip()] = value.strip()
return out
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return {}
def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]:
"""Get the last N journal lines for a unit. Returns None on failure."""
try:
result = subprocess.run(
["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return None
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
def query_service_state() -> Dict[str, Any]:
"""Query systemctl for the four separate service facts (§7.3, LC-9).
Returns dict with keys:
boot_enabled: bool
timer_active: bool
last_collect_ok: Optional[bool]
last_collect_age_s: Optional[int]
last_collect_reason: Optional[str]
"""
timer_props = _systemctl_show(
"fenris-collect.timer",
"UnitFileState", "ActiveState", "LastTriggerUSec",
)
service_props = _systemctl_show(
"fenris-collect.service",
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
)
boot_enabled_str = timer_props.get("UnitFileState", "")
boot_enabled = boot_enabled_str == "enabled"
active_state = timer_props.get("ActiveState", "inactive")
timer_active = active_state == "active"
last_collect_ok = None
last_collect_age_s = None
last_collect_reason = None
last_trigger = timer_props.get("LastTriggerUSec", "")
if last_trigger and last_trigger != "n/a":
try:
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
last_collect_age_s = int((now - trigger_dt).total_seconds())
except (ValueError, TypeError):
pass
exec_status = service_props.get("ExecMainStatus", "")
if exec_status:
try:
exit_code = int(exec_status)
last_collect_ok = exit_code == 0
if exit_code != 0:
last_collect_reason = "exit code %d" % exit_code
except (ValueError, TypeError):
pass
return {
"boot_enabled": boot_enabled,
"timer_active": timer_active,
"last_collect_ok": last_collect_ok,
"last_collect_age_s": last_collect_age_s,
"last_collect_reason": last_collect_reason,
}
# Re-export for backward compatibility with tests that import directly
query_service_state = _init_query_service_state
_journalctl_hint = _init_journal_hint
# ---------------------------------------------------------------------------
@@ -328,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
# Formatting
# ---------------------------------------------------------------------------
def _format_projection(proj, freshness: str, service: Dict[str, Any],
drive_facts: List[str], config_error: Optional[str],
store_fault: Optional[str], newer_schema: Optional[str],
journal_hint: Optional[str],
def _format_projection(proj, freshness: str, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
"""Format the complete status output."""
"""Format projection details; monitoring status has its own renderer."""
lines = []
# --- Store/system fault overrides (§9.4, §9.5) ---
if store_fault:
lines.append("observation store unreadable")
if journal_hint:
lines.append("")
lines.append("Recent journal entries:")
lines.append(journal_hint)
return "\n".join(lines)
if newer_schema:
lines.append("observation store written by a newer Fenris — upgrade Fenris")
return "\n".join(lines)
# --- Configuration error (§8.3) ---
if config_error:
lines.append("configuration error: %s" % config_error)
@@ -359,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
lines.append("no observations yet")
lines.append("")
lines.append("Enable monitoring: fenris monitor resume")
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
@@ -369,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
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)
if proj is None:
lines.append("no projection available")
return "\n".join(lines)
# --- Projection headline ---
@@ -414,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
lines.append(fact)
lines.append("")
# --- Four separate service facts (§7.3, LC-9) ---
_append_service_facts(lines, service)
# --- Journal hint on failure or staleness (§8.8) ---
if journal_hint:
if freshness in ("missed", "stale"):
lines.append("")
lines.append("Recent journal entries:")
lines.append(journal_hint)
return "\n".join(lines)
@@ -468,36 +371,6 @@ def _format_headline(proj) -> str:
return headline
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
"""Append service facts and dashboard-clarity monitoring state."""
boot = "enabled" if service.get("boot_enabled") else "disabled"
activity = "active" if service.get("timer_active") else "inactive"
if service.get("last_collect_ok") is True:
collect = "ok"
elif service.get("last_collect_ok") is False:
collect = "FAILED"
if service.get("last_collect_reason"):
collect += " (%s)" % service["last_collect_reason"]
else:
collect = "unknown"
collect_age = ""
if service.get("last_collect_age_s") is not None:
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
freshness_str = service.get("freshness", "unknown")
freshness_age = ""
if service.get("freshness_age_s") is not None:
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
% (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)
# ---------------------------------------------------------------------------
@@ -512,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
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
if service.get("boot_enabled") is None:
return "monitoring: boot persistence unknown"
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
def deliberate_pause_lines() -> List[str]:
@@ -558,121 +433,92 @@ def format_disclosures() -> str:
# Main status entry point
# ---------------------------------------------------------------------------
@contextmanager
def read_status(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
"""Yield a read-only store snapshot and its composed monitoring status.
Own acquisition, fault classification, and connection lifetime for both
renderers. An absent store is empty; an unreadable or newer store exposes
no connection. Unknown monitoring facts are never coerced to disabled.
"""
from .status_composition import compose_status
clock_now = clock_now or datetime.now(timezone.utc)
service = None
if query_services:
try:
service = query_service_state()
except (OSError, RuntimeError):
pass
conn = None
store_fault = newer_schema = None
try:
try:
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
conn.execute("BEGIN")
except MissingStore:
pass
except (StoreFault, sqlite3.Error) as exc:
store_fault = str(exc)
except NewerSchema as exc:
newer_schema = str(exc)
comp = compose_status(
conn, service, clock_now, store_fault=store_fault,
newer_schema=newer_schema, collecting=collecting,
reduced_motion=reduced_motion,
)
if conn is not None and (comp.store_fault or comp.newer_schema):
conn.close()
conn = None
yield conn, comp
finally:
if conn is not None:
conn.close()
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
query_services: bool = True, query_journal: bool = True) -> str:
"""Render the complete read-only status (§8.8, LC-9).
"""Render CLI status through the shared read-only acquisition path."""
from .status_composition import render_status_cli
This is the single entry point for 'fenris status'. It never auto-samples,
never prompts, and never writes to the store.
"""
if clock_now is None:
clock_now = datetime.now(timezone.utc)
# --- Configuration (§8.3) ---
clock_now = clock_now or datetime.now(timezone.utc)
config_error = None
device = None
try:
config = read_config()
device = config["device"]
except ConfigError as e:
config_error = str(e)
read_config()
except ConfigError as exc:
config_error = str(exc)
# --- Service state ---
service = {}
if query_services:
service = query_service_state()
# --- 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)
# --- Store fault / newer schema short-circuit ---
if store_fault or newer_schema:
journal_hint = None
if query_journal:
journal_hint = _journalctl_hint("fenris-collect.service")
service["freshness"] = "unknown"
service["freshness_age_s"] = None
return _format_projection(
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
)
# --- Freshness grading (§8.9) ---
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
except sqlite3.Error:
newest_ts = None
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_s = None
if newest_ts:
try:
ts = datetime.fromisoformat(newest_ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
pass
service["freshness"] = freshness
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) ---
with read_status(store_path, clock_now, query_services) as (conn, comp):
parts = [render_status_cli(comp)]
if not comp.store_fault and not comp.newer_schema:
drive_facts = []
proj = None
if conn is not None:
try:
drive_facts = _query_drive_facts(conn)
except sqlite3.Error:
pass
# --- Projection (§6 — recomputed on read, never stored) ---
try:
proj = compute_projection(conn, clock_now)
except Exception:
proj = None
# --- Journal hint on failure or staleness (§8.8) ---
journal_hint = None
if query_journal and freshness in ("missed", "stale"):
journal_hint = _journalctl_hint("fenris-collect.service")
# --- Compose output ---
result = _format_projection(
proj, freshness, service, drive_facts, config_error,
None, None, journal_hint, sample_count, day_count,
)
conn.close()
return result
except (sqlite3.Error, ValueError, TypeError):
pass
parts.append(_format_projection(
proj, comp.freshness, drive_facts, config_error,
comp.sample_count, comp.day_count,
))
if query_journal and (
comp.store_fault or comp.last_collect_ok is False
or comp.freshness in ("missed", "stale")
):
hint = _journalctl_hint()
if hint:
parts.append("Recent collector logs:\n" + hint)
return "\n\n".join(part for part in parts if part)
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
@@ -701,65 +547,8 @@ def get_status_composition(
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 monitoring status without retaining the read-only snapshot."""
with read_status(
store_path, clock_now, query_services, collecting, reduced_motion,
) as (_, comp):
return comp
+52 -37
View File
@@ -188,7 +188,7 @@ def _determine_explanation(
) -> str:
"""Determine the explanation line for the status state."""
if store_fault:
return "observation store unreadable — see journal"
return "observation store unreadable — see collector logs"
if newer_schema:
return "observation store written by a newer Fenris — upgrade Fenris"
@@ -200,7 +200,7 @@ def _determine_explanation(
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))
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
return " ".join(parts) if parts else "last run failed"
if state == StatusState.INTERRUPTED:
@@ -211,7 +211,7 @@ def _determine_explanation(
if state == StatusState.STALE:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "last sample %s" % freshness_age_human(freshness_age_s)
return "data is stale"
if state == StatusState.WAITING:
@@ -223,7 +223,7 @@ def _determine_explanation(
if state == StatusState.MONITORING:
if freshness_age_s is not None:
return "last sample %s ago" % freshness_age_human(freshness_age_s)
return "last sample %s" % freshness_age_human(freshness_age_s)
return "monitoring active"
if state == StatusState.UNKNOWN:
@@ -257,7 +257,7 @@ def _determine_collecting_overlay(
def compose_status(
conn: sqlite3.Connection,
conn: Optional[sqlite3.Connection],
service: Optional[Dict[str, Any]],
clock_now: datetime,
store_fault: Optional[str] = None,
@@ -269,14 +269,17 @@ def compose_status(
This is the single entry point consumed by both TUI and CLI.
"""
service_available = service is not None and len(service) > 0
service_available = bool(service) and any(
service.get(key) is not None
for key in ("boot_enabled", "timer_active")
)
# --- 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
boot_enabled = service.get("boot_enabled") if service else None
timer_active = service.get("timer_active") if service else None
last_collect_ok = service.get("last_collect_ok") if service else None
last_collect_age_s = service.get("last_collect_age_s") if service else None
last_collect_reason = service.get("last_collect_reason") if service else None
# --- Freshness from store ---
freshness = "unknown"
@@ -285,7 +288,10 @@ def compose_status(
day_count = 0
deliberately_paused = False
if store_fault is None and newer_schema is None:
if conn is None and store_fault is None and newer_schema is None:
freshness = "empty"
if conn is not None and 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()
@@ -301,28 +307,35 @@ def compose_status(
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
pass
except sqlite3.Error:
freshness = "unknown"
except sqlite3.Error as exc:
store_fault = str(exc)
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
except sqlite3.Error as exc:
store_fault = str(exc)
try:
svc_for_pause = service if service_available else {}
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
except sqlite3.Error:
except sqlite3.Error as exc:
store_fault = str(exc)
if store_fault or newer_schema:
freshness = "unknown"
freshness_age_s = None
sample_count = day_count = 0
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:
if (conn is not None and not store_fault and not newer_schema
and 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(
@@ -428,10 +441,9 @@ def render_status_cli(comp: StatusComposition) -> str:
# 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.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:
@@ -441,10 +453,12 @@ def render_status_cli(comp: StatusComposition) -> str:
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"))
facts.append("boot: %s" % (
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
))
facts.append("timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
))
if facts:
lines.append(" · ".join(facts))
@@ -486,29 +500,30 @@ def render_status_tui(comp: StatusComposition) -> str:
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
lines.append("")
# Separate facts
facts = []
if comp.freshness != "unknown":
facts.append("freshness: %s" % comp.freshness)
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")
facts.append("Last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
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"))
facts.append("Last collect: unknown")
facts.append("Boot: %s" % (
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
))
facts.append("Timer: %s" % (
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
))
if facts:
lines.append(" · ".join(facts))
@@ -516,11 +531,11 @@ def render_status_tui(comp: StatusComposition) -> str:
# Continuity
if comp.continuity:
lines.append("")
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
lines.append(pl[:1].upper() + pl[1:])
return "\n".join(lines)
+46 -1
View File
@@ -12,7 +12,7 @@ from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
# Packaged default placement (spec §8.3). The config may override it, but a
@@ -193,6 +193,26 @@ def _create_schema(conn: sqlite3.Connection):
""")
# Local-day activity summaries derived from UTC hour observations.
# Each row retains its recorded timezone and UTC boundaries so that
# historical summaries survive a system-timezone change (ADR 0010).
conn.execute("""
CREATE TABLE IF NOT EXISTS local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz
tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata"
tz_offset TEXT NOT NULL, -- e.g. "+05:30"
utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start
utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
)
""")
# Metadata table for store state (e.g., legacy import marker)
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
@@ -230,6 +250,31 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
current_version = 2
# Migration 2→3: add local_days table for local-day activity totals
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
if current_version < 3:
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "local_days" not in tables:
conn.execute("""
CREATE TABLE IF NOT EXISTS local_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local_date TEXT NOT NULL,
tz_name TEXT NOT NULL,
tz_offset TEXT NOT NULL,
utc_start TEXT NOT NULL,
utc_end TEXT NOT NULL,
bytes_written INTEGER DEFAULT 0,
bytes_read INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
sample_count INTEGER DEFAULT 0,
complete BOOLEAN DEFAULT 0,
UNIQUE(local_date, tz_name)
)
""")
current_version = 3
def migrate_to_latest(store_path: Path) -> int:
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
+20 -4
View File
@@ -1,6 +1,6 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Chalktone-inspired default plus Amber, Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
@@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme(
# Theme registry
# ---------------------------------------------------------------------------
_CHALKTONE = Theme(
name="fenris-chalktone",
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
warning="#e9bc79", error="#e58d89", success="#acd29c",
foreground="#e0d8c5", background="#202426", surface="#202426",
panel="#252a2c", boost="#333b3d", dark=True,
variables={
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
"border-default": "#606d6a", "muted-text": "#a7b1a9",
},
)
THEMES = {
"chalktone": _CHALKTONE,
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
@@ -138,16 +153,16 @@ THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
Unknown names fall back to Chalktone.
"""
return THEMES.get(name, _AMBER)
return THEMES.get(name, _CHALKTONE)
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.
selection and muted text. Falls back to Chalktone for unknown names.
"""
theme = get_theme(theme_name)
variables = theme.variables or {}
@@ -158,4 +173,5 @@ def get_graph_colors(theme_name: str) -> Dict[str, str]:
"zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"),
"muted": variables.get("muted-text", "#a7b1a9"),
}
+1124 -666
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
"""System timezone detection for local-day activity totals.
Provides timezone detection (TZ env, /etc/localtime) and offset
computation used by local-day derivation. All functions are
stateless and safe to call from the collector and TUI reader.
"""
import os
from datetime import datetime, timezone
from pathlib import Path
def detect_system_tz() -> str:
"""Detect the system timezone name.
Resolution order:
1. ``TZ`` environment variable (may be empty or ":UTC")
2. Symlink target of ``/etc/localtime``
3. Falls back to ``"UTC"``
Returns a POSIX timezone name like ``"Asia/Kolkata"`` or ``"UTC"``.
"""
tz = os.environ.get("TZ", "").strip()
if tz:
return tz.lstrip(":")
localtime = Path("/etc/localtime")
if localtime.is_symlink():
target = os.readlink(str(localtime))
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
if target.startswith(prefix):
return target[len(prefix):]
return target
return "UTC"
def get_tz_offset_str(dt: datetime, tz_name: str) -> str:
"""UTC offset as ``+HH:MM`` or ``-HH:MM`` string for *dt* in *tz_name*."""
from zoneinfo import ZoneInfo
local_tz = ZoneInfo(tz_name)
local_dt = dt.astimezone(local_tz)
offset = local_dt.utcoffset()
total_seconds = int(offset.total_seconds())
sign = "+" if total_seconds >= 0 else "-"
total_seconds = abs(total_seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
return "%s%02d:%02d" % (sign, hours, minutes)
+38 -32
View File
@@ -109,12 +109,40 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
sample_ts="2026-09-30T10:00:00+00:00",
period_start="2026-09-01T00:00:00+00:00",
segment_opened="2026-09-01T00:00:00+00:00",
baseline_kw=None, segment_kw=None):
baseline_kw=None, segment_kw=None,
local_days=True):
if baseline:
_insert_baseline(conn, **(baseline_kw or {}))
if segment:
@@ -125,6 +153,9 @@ def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
if sample_ts:
_insert_sample(conn, sample_ts)
# Issue #94: satisfy the complete-observation-day gate
if local_days and days > 0:
_insert_complete_local_days(conn, "2026-09-29", 1, bw=bw)
# ===================================================================
@@ -217,6 +248,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
conn.close()
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.warming_fact is not None
@@ -245,6 +278,7 @@ class TestCI1StateMatrix:
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
stale_ts = (_clock() - timedelta(days=5)).isoformat()
_insert_sample(conn, stale_ts)
_insert_complete_local_days(conn, "2026-08-30", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.staleness_fact is not None
@@ -259,6 +293,7 @@ class TestCI1StateMatrix:
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")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.degraded_identity_fact is not None
@@ -273,6 +308,7 @@ class TestCI1StateMatrix:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=0)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
assert proj.zero_rate_fact is not None
@@ -461,7 +497,7 @@ class TestCI2Parity:
}
with patch("fenris.status.query_service_state", return_value=service_state), patch(
"fenris.tui.query_service_state", return_value=service_state
"fenris.status.query_service_state", return_value=service_state
):
status = get_status(
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
@@ -502,18 +538,6 @@ class TestCI2Parity:
query_services=True, query_journal=False)
assert "no observations yet" in status.lower()
def test_store_fault_phrase_both_views(self, tmp_path):
status_src = (FENRIS_PKG / "status.py").read_text()
tui_src = (FENRIS_PKG / "tui.py").read_text()
phrase = "observation store unreadable"
assert phrase in status_src
assert phrase.lower() in tui_src.lower()
def test_newer_schema_phrase_both_views(self):
status_src = (FENRIS_PKG / "status.py").read_text()
phrase = "observation store written by a newer Fenris"
assert phrase in status_src
def test_status_never_prompts(self):
status_src = (FENRIS_PKG / "status.py").read_text()
assert "input(" not in status_src
@@ -603,12 +627,6 @@ class TestCI3ProhibitionSet:
table_names.append(m.group(1))
assert "projection" not in [t.lower() for t in table_names]
def test_no_partial_newer_schema_interpretation(self):
"""Readers refuse newer-schema stores. [3.6, 9.5]"""
status_src = (FENRIS_PKG / "status.py").read_text()
assert "NewerSchema" in status_src
assert "upgrade Fenris" in status_src
def test_polkit_authorizes_one_binary(self):
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
@@ -688,18 +706,6 @@ class TestCI4WordingAndDisclosures:
proj_src = (FENRIS_PKG / "projection.py").read_text()
assert phrase in proj_src
def test_store_fault_phrase(self):
phrase = "observation store unreadable"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
tui_src = (FENRIS_PKG / "tui.py").read_text()
assert phrase.lower() in tui_src.lower()
def test_newer_schema_phrase(self):
phrase = "observation store written by a newer Fenris"
status_src = (FENRIS_PKG / "status.py").read_text()
assert phrase in status_src
def test_no_observations_phrase(self):
phrase = "no observations yet"
status_src = (FENRIS_PKG / "status.py").read_text()
+52
View File
@@ -0,0 +1,52 @@
"""Visible geometry and evidence boundaries of the terminal volume plot."""
from fenris.activity_plot import VolumePoint, volume_plot
from fenris.themes import get_graph_colors
def plot(points, selected=-1, width=48, height=10):
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
def test_trace_fits_viewport_and_uses_time_not_sample_index():
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
text, columns, unit = plot(points)
assert unit == "B"
assert len(text.plain.splitlines()) == 10
assert all(len(line) <= 48 for line in text.plain.splitlines())
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
assert not any(c in text.plain for c in "█▒░")
assert "100.00" in text.plain and "0.00" in text.plain
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
text, columns, _ = plot([
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
VolumePoint(2, 100, "00:06"),
])
rows = text.plain.splitlines()
assert "?" in rows[-2]
# No invented intermediate dots on either side of the missing measurement.
for row in rows[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
text, columns, unit = plot([
VolumePoint(0, 1_000_000, "01", "partial"),
VolumePoint(1, 2_000_000, "02", "unallocated"),
VolumePoint(2, 3_000_000, "03"),
])
assert unit == "MB"
assert "~" in text.plain and "u" in text.plain
for row in text.plain.splitlines()[:-2]:
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
text, columns, _ = plot(points, selected=100, width=32, height=6)
assert len(columns) == len(points)
assert text.plain.splitlines()[-2][columns[100]] == "▼"
assert all(len(row) == 32 for row in text.plain.splitlines())
+97
View File
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
assert "sha256sum -c SHA256SUMS" in footer
def test_release_footer_configures_the_xbps_url_as_a_repository():
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
encoding="utf-8"
)
repository = (
"repository=https://git.bongbetic.com/xavierk/Fenris-xbps/"
"raw/branch/stable/x86_64"
)
assert repository in footer
assert "sudo xbps-install -M -S fenris" in footer
assert "sudo xbps-install -S https://git.bongbetic.com" not in footer
@pytest.mark.parametrize(
("changelog", "expected_error"),
[
@@ -209,3 +223,86 @@ def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
"path": "/releases/17",
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
}
def test_format_availability_section_with_both():
extractor = _extractor_module()
result = extractor.format_availability_section(
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)"],
withheld=["Void Linux (xbps) — pending host acceptance"],
)
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result
assert "Withheld: Void Linux (xbps) — pending host acceptance" in result
assert "## Package formats" in result
def test_format_availability_section_available_only():
extractor = _extractor_module()
result = extractor.format_availability_section(
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)", "Void Linux (xbps)"],
withheld=[],
)
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm), Void Linux (xbps)" in result
assert "Withheld" not in result
def test_format_availability_section_empty():
extractor = _extractor_module()
result = extractor.format_availability_section(available=[], withheld=[])
assert result == ""
def test_command_includes_format_availability(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",
)
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
"--available",
"Debian/Ubuntu (deb)",
"--available",
"Fedora/openSUSE (rpm)",
"--withheld",
"Void Linux (xbps)",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "## Package formats" in result.stdout
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result.stdout
assert "Withheld: Void Linux (xbps)" in result.stdout
def test_command_without_format_args_has_no_formats_section(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",
)
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 "## Package formats" not in result.stdout
+2 -2
View File
@@ -154,7 +154,7 @@ class TestSchemaMigration:
# Migrate
steps = migrate_to_latest(db)
assert steps == 1
assert steps == 2 # v1→v2→v3
# Verify data preserved
conn = sqlite3.connect(str(db))
@@ -412,7 +412,7 @@ class TestDisplayStates:
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
from fenris.tui import FenrisTuiApp
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
+1
View File
@@ -248,6 +248,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
# sqlite_sequence is a system table created by AUTOINCREMENT
expected_tables.add("sqlite_sequence")
expected_tables.add("store_metadata")
expected_tables.add("local_days")
assert expected_tables == tables
conn.close()
+258
View File
@@ -0,0 +1,258 @@
"""Complete observation day gate tests (issue #94).
Verifies that the endurance projection is withheld until at least one
complete local calendar day has been observed within a monitoring period.
Seams:
- compute_projection() → gate check via local_days table
- ProjectionResult.contributing_facts → "waiting for a full local observation day"
Acceptance criteria:
- Gate-1: No complete local day → UNSUPPORTED with waiting fact
- Gate-2: One complete local day → Limited confidence (if other conditions met)
- Gate-3: Partial days don't satisfy the gate
- Gate-4: CLI and TUI share the same gate via compute_projection()
"""
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
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_COVERAGE_FLOOR,
)
@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):
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",
"Samsung SSD 970 EVO Plus 1TB", 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"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, "nqn.test", False, "nqn.test", "SN123",
"Samsung SSD 970 EVO Plus 1TB", "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 _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row for testing the gate."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
# Next day
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
# ---------------------------------------------------------------------------
class TestGateNoCompleteDay:
"""Projection is unavailable before any complete local observation day."""
def test_no_local_days_unsupported(self, store):
"""With no local_days entries, projection is UNSUPPORTED."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
# 14 days of UTC data — enough for normal projection, but no local_days
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
assert result.headline_remaining_seconds is None
def test_only_partial_local_days_unsupported(self, store):
"""Partial (incomplete) local days don't satisfy the gate."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Insert only incomplete local days
for i in range(5):
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(store, d, complete=False, coverage=0.3)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
def test_gate_before_warming_check(self, store):
"""Gate fires even when warming would also block — gate has precedence."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
# Only 3 days of data (below warming threshold)
for i in range(3):
d = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Insert one complete local day — but still below warming
_insert_local_day(store, "2026-09-29", complete=True)
result = compute_projection(store, _clock())
# Gate is satisfied (one complete day), but warming blocks Supported
# The key assertion: gate message should NOT appear when gate IS met
assert not any("full local observation day" in f for f in result.contributing_facts)
# ---------------------------------------------------------------------------
# Gate-2: One complete local day → Limited confidence
# ---------------------------------------------------------------------------
class TestGateOneCompleteDay:
"""After one complete local day, projection can proceed with Limited confidence."""
def test_one_complete_day_allows_projection(self, store):
"""With one complete local day and valid baseline/rate, projection is Limited."""
_insert_baseline(store, tbw_tb=1.0, verified=True)
_insert_segment(store)
_open_period(store)
# 14 days of UTC data
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# One complete local day
_insert_local_day(store, "2026-09-29", complete=True)
result = compute_projection(store, _clock())
# Gate satisfied — no "waiting" fact
assert not any("full local observation day" in f for f in result.contributing_facts)
# With only 14 days and other Limited factors, should be Limited or Supported
assert result.confidence_state in (ConfidenceState.LIMITED, ConfidenceState.SUPPORTED)
# Headline should exist (rate > 0, baseline exists)
assert result.headline_remaining_seconds is not None
def test_gate_fact_absent_when_satisfied(self, store):
"""The 'waiting for full day' fact does not appear when gate is met."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_local_day(store, "2026-09-29", complete=True)
result = compute_projection(store, _clock())
assert not any("full local observation day" in f for f in result.contributing_facts)
assert result.confidence_state != ConfidenceState.UNSUPPORTED
# ---------------------------------------------------------------------------
# Gate-3: Partial first day doesn't satisfy the gate
# ---------------------------------------------------------------------------
class TestGatePartialFirstDay:
"""Starting monitoring at noon means the partial first day doesn't count."""
def test_partial_first_day_not_enough(self, store):
"""A single incomplete local day (started at noon) doesn't open the gate."""
_insert_baseline(store)
_insert_segment(store, opened_at="2026-09-29T12:00:00+00:00")
_open_period(store, start="2026-09-29T12:00:00+00:00")
# Only Sep 29 (partial) and Sep 30 (today, partial)
_insert_day(store, "2026-09-29", bw=1024*1024*100, coverage=0.5, samples=12)
_insert_day(store, "2026-09-30", bw=1024*1024*100, coverage=0.5, samples=12)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Only partial local days
_insert_local_day(store, "2026-09-29", complete=False, coverage=0.5)
_insert_local_day(store, "2026-09-30", complete=False, coverage=0.5)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("full local observation day" in f for f in result.contributing_facts)
# ---------------------------------------------------------------------------
# Gate-4: Multiple complete days also satisfy the gate
# ---------------------------------------------------------------------------
class TestGateMultipleCompleteDays:
"""Multiple complete local days satisfy the gate."""
def test_multiple_complete_days_satisfy_gate(self, store):
"""Several complete local days open the gate."""
_insert_baseline(store)
_insert_segment(store)
_open_period(store)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# 7 complete local days
for i in range(7):
d = (datetime(2026, 9, 23) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(store, d, complete=True)
result = compute_projection(store, _clock())
assert not any("full local observation day" in f for f in result.contributing_facts)
assert result.headline_remaining_seconds is not None
+77
View File
@@ -0,0 +1,77 @@
"""The CLI and TUI cross the same terminal-attached action interface."""
import argparse
import runpy
import shlex
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.control import MONITOR_HELPER, MonitorError, run_monitor
from fenris.tui import FenrisTuiApp
@pytest.mark.parametrize("uid", [0, 1000])
@pytest.mark.parametrize("args", [("enable", "--now"), ("disable", "--now"), ("collect",)])
def test_fixed_helper_and_terminal_attachment(uid, args):
with patch("fenris.control.os.geteuid", return_value=uid), \
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
run_monitor(*args)
expected = [MONITOR_HELPER, *args]
if uid:
expected.insert(0, "pkexec")
# Inherited stdin/out/err keep authentication on the user's terminal.
# No frontend deadline can cut short a valid 90-second Collection run.
run.assert_called_once_with(expected)
@pytest.mark.parametrize("code", [1, 126, 127, -15])
def test_failure_is_not_retried_and_root_equivalent_preserves_arguments(code):
args = ("baseline", "set", '{"model": "Drive $(whoami)", "tbw": 100}')
with patch("fenris.control.os.geteuid", return_value=1000), \
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], code)) as run:
with pytest.raises(MonitorError) as failure:
run_monitor(*args)
run.assert_called_once()
assert failure.value.exit_code == (code if code > 0 else 128 - code)
root_command = str(failure.value).split("run in your terminal: ")[1]
assert shlex.split(root_command) == ["sudo", MONITOR_HELPER, *args]
@pytest.mark.parametrize("uid,missing", [(0, MONITOR_HELPER), (1000, "pkexec")])
def test_missing_command_is_identified(uid, missing):
with patch("fenris.control.os.geteuid", return_value=uid), \
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", missing)):
with pytest.raises(MonitorError, match="Command not found") as failure:
run_monitor("collect")
assert missing in str(failure.value)
assert failure.value.exit_code == 127
assert ("sudo" in str(failure.value)) == bool(uid)
def test_interrupt_reports_uncertain_outcome():
with patch("fenris.control.subprocess.run", side_effect=KeyboardInterrupt):
with pytest.raises(MonitorError, match="Check fenris status") as failure:
run_monitor("collect")
assert failure.value.exit_code == 130
def test_cli_and_tui_show_the_same_failure(tmp_path, capsys):
# The launcher may prepend an installed runtime while loading; isolate it.
with patch.object(sys, "path", sys.path.copy()):
cli = runpy.run_path(str(Path(__file__).parent.parent / "scripts" / "fenris"))
with patch("fenris.control.os.geteuid", return_value=1000), \
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 126)):
with pytest.raises(SystemExit) as exit_info:
cli["cmd_monitor_resume"](argparse.Namespace())
assert exit_info.value.code == 126
cli_message = capsys.readouterr().err.strip()
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
with patch.object(app, "suspend"), patch.object(app, "_refresh") as refresh, \
patch.object(app, "notify") as notify:
app.action_resume()
notify.assert_called_once_with(cli_message, severity="error")
refresh.assert_called_once()
+145
View File
@@ -0,0 +1,145 @@
"""Cross-day interval unattributed-byte preservation (issue #88).
Verifies that a cross-hour interval spanning midnight is stored ONCE as
shared boundary evidence, not duplicated into both days.
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
"""
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.collector import run_collection
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
def _make_smartctl(duw: int, dur: int):
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,
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
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
class _Clock:
def __init__(self, initial):
self.now = initial
def utcnow(self):
return self.now
class TestCrossDayUnattributedNoDuplication:
"""Unattributed bytes from a midnight-spanning interval must be stored
once, not duplicated into both days (issue #88)."""
def test_midnight_spanning_interval_not_duplicated(
self, tmp_path, sysfs_tree,
):
"""Two samples spanning midnight: 23:55 UTC day1 → 00:05 UTC day2.
The unattributed bytes should appear once, not in both days.
"""
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
# +100 DUW, +60 DUR across midnight
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
assert r1["ok"]
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
assert r2["ok"]
conn = sqlite3.connect(store)
# Get unattributed bytes for each day
unattr_w_day1 = conn.execute(
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
("2026-09-01",)
).fetchone()
unattr_w_day2 = conn.execute(
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
("2026-09-02",)
).fetchone()
unattr_r_day1 = conn.execute(
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
("2026-09-01",)
).fetchone()
unattr_r_day2 = conn.execute(
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
("2026-09-02",)
).fetchone()
expected_bw = 100 * 512000 # 51200000
expected_br = 60 * 512000 # 30720000
# BUG: Current code adds the SAME bytes to BOTH days.
# After fix: only ONE day should have the unattributed bytes.
# The spec says: preserve once as shared boundary evidence.
# At least one day must have the unattributed bytes
total_unattr_w = (unattr_w_day1[0] if unattr_w_day1 else 0) + (unattr_w_day2[0] if unattr_w_day2 else 0)
total_unattr_r = (unattr_r_day1[0] if unattr_r_day1 else 0) + (unattr_r_day2[0] if unattr_r_day2 else 0)
# The total unattributed bytes across both days must equal
# the actual delta (not double)
assert total_unattr_w == expected_bw, (
f"Unattributed writes across both days should be {expected_bw}, "
f"got {total_unattr_w} (duplication detected)"
)
assert total_unattr_r == expected_br, (
f"Unattributed reads across both days should be {expected_br}, "
f"got {total_unattr_r} (duplication detected)"
)
# Neither day should have MORE than the actual delta
for day_label, uw, ur in [
("day1", unattr_w_day1, unattr_r_day1),
("day2", unattr_w_day2, unattr_r_day2),
]:
if uw is not None:
assert uw[0] <= expected_bw, (
f"{day_label} unattributed writes {uw[0]} exceeds delta {expected_bw}"
)
if ur is not None:
assert ur[0] <= expected_br, (
f"{day_label} unattributed reads {ur[0]} exceeds delta {expected_br}"
)
conn.close()
+176
View File
@@ -0,0 +1,176 @@
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree
import pytest
from test_tui import (
_insert_baseline,
_insert_day,
_insert_local_day,
_insert_segment,
_open_period,
)
from fenris.store import init_store
from fenris.tui import FenrisTuiApp
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
@pytest.fixture
def dashboard(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
class Clock(datetime):
@classmethod
def now(cls, tz=None):
return NOW
monkeypatch.setattr("fenris.tui.datetime", Clock)
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
"last_collect_age_s": 0, "last_collect_reason": None,
})
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
_open_period(conn)
for offset in range(18):
date = (NOW - timedelta(days=offset)).date().isoformat()
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
for index in range(61):
conn.execute(
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
)
conn.execute(
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
)
conn.commit()
conn.close()
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
def visible(app):
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
app = dashboard
async with app.run_test(size=size) as pilot:
app.on_refresh_tick()
await pilot.pause()
assert app.theme == "fenris-chalktone"
graph = app.query_one("#live-activity")
assert app.focused is graph
await pilot.press("left", "w")
selected = str(app.query_one("#live-readout").render())
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
assert "Reads" in str(app.query_one("#live-legend").render())
await pilot.press("z")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#live-readout").render()) == selected
assert app.query_one("#activity-panel").region.width == size[0]
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
assert fact in visible(app)
await pilot.resize_terminal(100, 30)
await pilot.press("escape")
assert str(app.query_one("#live-readout").render()) == selected
assert app._zoomed_panel is None
assert app.query_one("#action-rail").region.bottom <= 30
@pytest.mark.asyncio
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.click("#view-history")
await pilot.press("left", "enter")
assert app.query_one("#activity-tabs").active == "view-day"
assert "2026-09-18" in str(app.query_one("#local-day").render())
# Hour 23 is selected initially; move to the known hour 12.
await pilot.press(*(["left"] * 11))
readout = str(app.query_one("#bar-readout").render())
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
await pilot.press("z", "w")
app.on_refresh_tick()
await pilot.pause()
assert str(app.query_one("#bar-readout").render()) == readout
await pilot.press("g")
await pilot.press(*list("2026-09-17"))
await pilot.press("escape")
assert str(app.query_one("#bar-readout").render()) == readout
assert app._zoomed_panel == "activity-panel"
await pilot.press("escape", "t")
assert app.query_one("#activity-tabs").active == "view-live"
@pytest.mark.asyncio
async def test_mouse_inspection_matches_time_axis(dashboard):
app = dashboard
async with app.run_test(size=(100, 36)) as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
x = graph._point_columns[0]
await pilot.click("#live-render", offset=(x, 1))
text = str(app.query_one("#live-readout").render())
assert "09:00 → 09:03 UTC" in text
@pytest.mark.asyncio
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
calls = []
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
async with dashboard.run_test(size=(80, 24)) as pilot:
await pilot.press("s")
assert dashboard.theme == "fenris-amber"
await pilot.press("q")
assert calls == []
from fenris.preferences import load_preferences
assert load_preferences()["theme"] == "amber"
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
async with dashboard.run_test(size=size) as pilot:
await pilot.click("#view-history")
graph = dashboard.query_one("#usage-history")
graph.set_data([{
"day": "2026-09-18", "total_bytes": 2_000_000_000,
"unallocated_bytes": 2_000_000_000, "is_gap": True,
"is_partial": True,
}])
readout = str(dashboard.query_one("#bar-readout").render())
assert "W 2.000 GB unallocated" in readout
assert "R unavailable" in readout and "gap" in readout
if size[0] >= 80:
plot = str(dashboard.query_one("#bar-render").render())
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
graph.measure = "read"
graph._refresh()
plot = str(dashboard.query_one("#bar-render").render())
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["gap", "future"])
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
async with dashboard.run_test(size=(70, 20)) as pilot:
await pilot.click("#view-day")
graph = dashboard.query_one("#usage-history")
graph.set_hour_data([{
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12",
"is_gap": state == "gap", "is_future": state == "future",
}])
graph._hourly_selected = 0
graph._refresh_hourly()
readout = str(dashboard.query_one("#bar-readout").render())
assert "W unavailable · R unavailable" in readout
assert state in readout and "12:00 UTC" in readout
+692
View File
@@ -0,0 +1,692 @@
"""Tests for the init system abstraction layer (issue #84).
Covers:
- Init system detection (systemd vs runit)
- systemd backend functions (enable, disable, collect, query state)
- runit backend functions (enable, disable, collect, query state)
- Public API dispatching to correct backend
- Edge cases (already enabled/disabled, missing files, timeouts)
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
"""
import os
import sqlite3
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock, PropertyMock
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.init_system import (
InitSystem,
detect_init_system,
get_init_system,
reset_init_system_cache,
_systemd_enable,
_systemd_disable,
_systemd_collect,
_systemd_query_state,
_runit_enable,
_runit_disable,
_runit_collect,
_runit_query_state,
_runit_is_enabled,
_runit_is_running,
enable_timer,
disable_timer,
collect_now,
query_service_state,
journal_hint,
FENRIS_SV_DIR,
FENRIS_SERVICE_LINK,
COLLECT_TIMEOUT_S,
)
from fenris.store import init_store
@pytest.fixture(autouse=True)
def reset_cache():
"""Reset the init system cache before each test."""
reset_init_system_cache()
yield
reset_init_system_cache()
# ---------------------------------------------------------------------------
# Init system detection
# ---------------------------------------------------------------------------
class TestInitSystemDetection:
"""Test init system detection logic."""
def test_detect_systemd_by_run_directory(self):
"""Systemd detected via /run/systemd/system directory."""
with patch("pathlib.Path.exists") as mock_exists:
mock_exists.return_value = True
reset_init_system_cache()
result = detect_init_system()
assert result == InitSystem.SYSTEMD
def test_detect_systemd_by_pid1(self):
"""Systemd detected via PID 1 name."""
original_exists = Path.exists
original_read_text = Path.read_text
def mock_exists(self_path):
if str(self_path) == "/run/systemd/system":
return False
return original_exists(self_path)
def mock_read_text(self_path):
if str(self_path) == "/proc/1/comm":
return "systemd"
return original_read_text(self_path)
with patch("pathlib.Path.exists", mock_exists), \
patch("pathlib.Path.read_text", mock_read_text):
reset_init_system_cache()
result = detect_init_system()
assert result == InitSystem.SYSTEMD
def test_detect_runit_by_pid1(self):
"""Runit detected via PID 1 name."""
original_exists = Path.exists
original_read_text = Path.read_text
def mock_exists(self_path):
if str(self_path) == "/run/systemd/system":
return False
return original_exists(self_path)
def mock_read_text(self_path):
if str(self_path) == "/proc/1/comm":
return "runsv"
return original_read_text(self_path)
with patch("pathlib.Path.exists", mock_exists), \
patch("pathlib.Path.read_text", mock_read_text):
reset_init_system_cache()
result = detect_init_system()
assert result == InitSystem.RUNIT
def test_detect_runit_by_etc_sv(self):
"""Runit detected via /etc/sv directory."""
original_exists = Path.exists
original_read_text = Path.read_text
def mock_exists(self_path):
if str(self_path) == "/run/systemd/system":
return False
if str(self_path) == "/etc/sv":
return True
return original_exists(self_path)
def mock_read_text(self_path):
if str(self_path) == "/proc/1/comm":
raise OSError("no such file")
return original_read_text(self_path)
with patch("pathlib.Path.exists", mock_exists), \
patch("pathlib.Path.read_text", mock_read_text):
reset_init_system_cache()
result = detect_init_system()
assert result == InitSystem.RUNIT
def test_default_to_systemd(self):
"""Default to systemd when no detection matches."""
original_exists = Path.exists
original_read_text = Path.read_text
original_is_dir = Path.is_dir
def mock_exists(self_path):
if str(self_path) == "/run/systemd/system":
return False
return original_exists(self_path)
def mock_is_dir(self_path):
if str(self_path) == "/etc/sv":
return False
return original_is_dir(self_path)
def mock_read_text(self_path):
if str(self_path) == "/proc/1/comm":
raise OSError("no such file")
return original_read_text(self_path)
with patch("pathlib.Path.exists", mock_exists), \
patch("pathlib.Path.is_dir", mock_is_dir), \
patch("pathlib.Path.read_text", mock_read_text):
reset_init_system_cache()
result = detect_init_system()
assert result == InitSystem.SYSTEMD
# ---------------------------------------------------------------------------
# systemd backend
# ---------------------------------------------------------------------------
class TestSystemdEnable:
"""Test systemd enable function."""
def test_enable_now(self):
"""Enable with --now flag."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
_systemd_enable(now=True)
mock_sub.run.assert_called_once_with(
["systemctl", "enable", "--now", "fenris-collect.timer"],
capture_output=True, text=True,
)
def test_enable_without_now(self):
"""Enable without --now flag."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
_systemd_enable(now=False)
mock_sub.run.assert_called_once_with(
["systemctl", "enable", "fenris-collect.timer"],
capture_output=True, text=True,
)
def test_enable_failure_exits(self):
"""Enable failure exits with error."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Unit not found"
)
with pytest.raises(SystemExit) as exc_info:
_systemd_enable(now=True)
assert exc_info.value.code == 1
class TestSystemdDisable:
"""Test systemd disable function."""
def test_disable_now(self):
"""Disable with --now flag."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
_systemd_disable(now=True)
mock_sub.run.assert_called_once_with(
["systemctl", "disable", "--now", "fenris-collect.timer"],
capture_output=True, text=True,
)
def test_disable_without_now(self):
"""Disable without --now flag."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
_systemd_disable(now=False)
mock_sub.run.assert_called_once_with(
["systemctl", "disable", "fenris-collect.timer"],
capture_output=True, text=True,
)
class TestSystemdCollect:
"""Test systemd collect function."""
def test_collect_success(self):
"""Successful collection."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
_systemd_collect()
mock_sub.run.assert_called_once_with(
["systemctl", "start", "fenris-collect.service"],
capture_output=True, text=True,
)
def test_collect_failure_exits(self):
"""Collection failure exits with error."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Unit not found"
)
with pytest.raises(SystemExit) as exc_info:
_systemd_collect()
assert exc_info.value.code == 1
class TestSystemdQueryState:
"""Test systemd query state function."""
def test_query_state_enabled_active(self):
"""Query state for enabled and active timer."""
with patch("fenris.init_system._systemctl_show") as mock_show:
def mock_show_fn(unit, *props):
if unit == "fenris-collect.timer":
return {
"UnitFileState": "enabled",
"ActiveState": "active",
"LastTriggerUSec": "2026-09-01T12:00:00Z",
}
elif unit == "fenris-collect.service":
return {
"ActiveState": "inactive",
"ExecMainStatus": "0",
"ExecMainExitTimestamp": "2026-09-01T12:00:30Z",
}
return {}
mock_show.side_effect = mock_show_fn
result = _systemd_query_state()
assert result["boot_enabled"] is True
assert result["timer_active"] is True
assert result["last_collect_ok"] is True
assert result["last_collect_age_s"] is not None
def test_query_state_disabled_inactive(self):
"""Query state for disabled and inactive timer."""
with patch("fenris.init_system._systemctl_show") as mock_show:
mock_show.return_value = {"UnitFileState": "disabled", "ActiveState": "inactive"}
result = _systemd_query_state()
assert result["boot_enabled"] is False
assert result["timer_active"] is False
assert result["last_collect_ok"] is None
# ---------------------------------------------------------------------------
# runit backend
# ---------------------------------------------------------------------------
class TestRunitEnable:
"""Test runit enable function."""
def test_enable_creates_symlink(self, tmp_path):
"""Enable creates symlink to service directory."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
_runit_enable(_now=True)
assert service_link.exists()
assert service_link.is_symlink()
assert service_link.resolve() == sv_dir
def test_enable_removes_down_file(self, tmp_path):
"""Enable removes the 'down' file if present."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
(sv_dir / "down").touch()
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
_runit_enable(_now=True)
assert not (sv_dir / "down").exists()
def test_enable_idempotent(self, tmp_path):
"""Enable is idempotent when already enabled."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
service_link.symlink_to(sv_dir)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
_runit_enable(_now=True)
assert service_link.exists()
class TestRunitDisable:
"""Test runit disable function."""
def test_disable_removes_symlink(self, tmp_path):
"""Disable removes the service symlink."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
service_link.symlink_to(sv_dir)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
_runit_disable(_now=True)
assert not service_link.exists()
assert (sv_dir / "down").exists()
def test_disable_idempotent(self, tmp_path):
"""Disable is idempotent when already disabled."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
_runit_disable(_now=True)
assert not service_link.exists()
class TestRunitCollect:
"""Test runit collect function."""
def test_collect_uses_flock(self):
"""Collect uses flock for serialization."""
with patch("fenris.init_system.subprocess") as mock_sub, \
patch("fenris.init_system.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_sub.run.return_value = MagicMock(returncode=0)
_runit_collect()
# Verify flock was used
call_args = mock_sub.run.call_args[0][0]
assert "flock" in call_args
def test_collect_timeout_exits(self):
"""Collection timeout exits with error."""
with patch("fenris.init_system.subprocess") as mock_sub, \
patch("fenris.init_system.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_sub.run.return_value = MagicMock(returncode=124)
with pytest.raises(SystemExit) as exc_info:
_runit_collect()
assert exc_info.value.code == 1
class TestRunitQueryState:
"""Test runit query state function."""
def test_query_state_enabled_running(self, tmp_path):
"""Query state for enabled and running service."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
supervise_dir = sv_dir / "supervise"
supervise_dir.mkdir(parents=True)
(supervise_dir / "pid").write_text("12345")
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
service_link.symlink_to(sv_dir)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
patch("os.kill") as mock_kill:
mock_kill.return_value = True # Process exists
result = _runit_query_state()
assert result["boot_enabled"] is True
assert result["timer_active"] is True
def test_query_state_disabled_not_running(self, tmp_path):
"""Query state for disabled and not running service."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
result = _runit_query_state()
assert result["boot_enabled"] is False
assert result["timer_active"] is False
class TestRunitIsEnabled:
"""Test runit is_enabled check."""
def test_is_enabled_true(self, tmp_path):
"""Service is enabled when symlink exists."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
service_link.symlink_to(sv_dir)
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
assert _runit_is_enabled() is True
def test_is_enabled_false(self, tmp_path):
"""Service is disabled when symlink does not exist."""
service_link = tmp_path / "service" / "fenris-collect"
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
assert _runit_is_enabled() is False
class TestRunitIsRunning:
"""Test runit is_running check."""
def test_is_running_true(self, tmp_path):
"""Service is running when PID file exists and process is alive."""
sv_dir = tmp_path / "sv" / "fenris-collect"
supervise_dir = sv_dir / "supervise"
supervise_dir.mkdir(parents=True)
(supervise_dir / "pid").write_text("12345")
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("os.kill") as mock_kill:
mock_kill.return_value = True
assert _runit_is_running() is True
def test_is_running_false_no_pid(self, tmp_path):
"""Service is not running when PID file does not exist."""
sv_dir = tmp_path / "sv" / "fenris-collect"
sv_dir.mkdir(parents=True)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 1
assert _runit_is_running() is False
def test_is_running_false_dead_process(self, tmp_path):
"""Service is not running when process is dead."""
sv_dir = tmp_path / "sv" / "fenris-collect"
supervise_dir = sv_dir / "supervise"
supervise_dir.mkdir(parents=True)
(supervise_dir / "pid").write_text("12345")
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("os.kill", side_effect=OSError("No such process")):
assert _runit_is_running() is False
def test_is_running_uses_process_table_when_supervise_is_unreadable(self, tmp_path):
"""Void keeps runit's supervise directory root-only for normal users."""
sv_dir = tmp_path / "sv" / "fenris-collect"
supervise_dir = sv_dir / "supervise"
supervise_dir.mkdir(parents=True)
pid_file = supervise_dir / "pid"
pid_file.write_text("12345")
service_link = tmp_path / "service" / "fenris-collect"
service_link.parent.mkdir(parents=True)
service_link.symlink_to(sv_dir)
original_read_text = Path.read_text
def deny_pid(path, *args, **kwargs):
if path == pid_file:
raise PermissionError("supervise is root-only")
return original_read_text(path, *args, **kwargs)
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
patch.object(Path, "read_text", autospec=True, side_effect=deny_pid), \
patch("subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
assert _runit_is_running() is True
mock_run.assert_called_once_with(
["pgrep", "-f", "^runsv fenris-collect$"],
capture_output=True,
text=True,
timeout=5,
)
# ---------------------------------------------------------------------------
# Public API dispatching
# ---------------------------------------------------------------------------
class TestPublicAPI:
"""Test public API dispatches to correct backend."""
def test_enable_dispatches_to_systemd(self):
"""enable_timer dispatches to systemd on systemd system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemd_enable") as mock_enable:
enable_timer(now=True)
mock_enable.assert_called_once_with(True)
def test_enable_dispatches_to_runit(self):
"""enable_timer dispatches to runit on runit system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
patch("fenris.init_system._runit_enable") as mock_enable:
enable_timer(now=True)
mock_enable.assert_called_once_with(True)
def test_disable_dispatches_to_systemd(self):
"""disable_timer dispatches to systemd on systemd system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemd_disable") as mock_disable:
disable_timer(now=False)
mock_disable.assert_called_once_with(False)
def test_disable_dispatches_to_runit(self):
"""disable_timer dispatches to runit on runit system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
patch("fenris.init_system._runit_disable") as mock_disable:
disable_timer(now=False)
mock_disable.assert_called_once_with(False)
def test_collect_dispatches_to_systemd(self):
"""collect_now dispatches to systemd on systemd system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemd_collect") as mock_collect:
collect_now()
mock_collect.assert_called_once()
def test_collect_dispatches_to_runit(self):
"""collect_now dispatches to runit on runit system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
patch("fenris.init_system._runit_collect") as mock_collect:
collect_now()
mock_collect.assert_called_once()
def test_query_state_dispatches_to_systemd(self):
"""query_service_state dispatches to systemd on systemd system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemd_query_state") as mock_query:
query_service_state()
mock_query.assert_called_once()
def test_query_state_dispatches_to_runit(self):
"""query_service_state dispatches to runit on runit system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
patch("fenris.init_system._runit_query_state") as mock_query:
query_service_state()
mock_query.assert_called_once()
def test_journal_hint_dispatches_to_systemd(self):
"""journal_hint dispatches to systemd on systemd system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemd_journal_hint") as mock_hint:
journal_hint(lines=3, unit="fenris-collect.service")
mock_hint.assert_called_once_with(3)
def test_journal_hint_dispatches_to_runit(self):
"""journal_hint dispatches to runit on runit system."""
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
patch("fenris.init_system._runit_journal_hint") as mock_hint:
journal_hint(lines=3, unit="fenris-collect.service")
mock_hint.assert_called_once_with(3)
# ---------------------------------------------------------------------------
# Constants and configuration
# ---------------------------------------------------------------------------
class TestConstants:
"""Test constants match spec requirements."""
def test_collect_timeout(self):
"""Collection timeout is 90 seconds (bounded execution)."""
assert COLLECT_TIMEOUT_S == 90
def test_init_system_enum(self):
"""InitSystem enum has systemd and runit variants."""
assert InitSystem.SYSTEMD.value == "systemd"
assert InitSystem.RUNIT.value == "runit"
# ---------------------------------------------------------------------------
# Integration with monitor.py
# ---------------------------------------------------------------------------
class TestMonitorIntegration:
"""Test that monitor.py uses the abstraction layer correctly."""
def test_cmd_enable_uses_init_system(self, tmp_path):
"""cmd_enable uses init_system.enable_timer."""
from fenris.monitor import cmd_enable
from argparse import Namespace
store_path = tmp_path / "observations.db"
init_store(store_path)
args = Namespace(now=True, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
cmd_enable(args)
mock_enable.assert_called_once_with(True)
def test_cmd_disable_uses_init_system(self, tmp_path):
"""cmd_disable uses init_system.disable_timer."""
from fenris.monitor import cmd_disable
from argparse import Namespace
store_path = tmp_path / "observations.db"
init_store(store_path)
args = Namespace(now=True, store_path=store_path)
with patch("fenris.monitor.disable_timer") as mock_disable:
cmd_disable(args)
mock_disable.assert_called_once_with(True)
def test_cmd_collect_uses_init_system(self):
"""cmd_collect uses init_system.collect_now."""
from fenris.monitor import cmd_collect
from argparse import Namespace
args = Namespace()
with patch("fenris.monitor.collect_now") as mock_collect:
cmd_collect(args)
mock_collect.assert_called_once()
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
"""Test edge cases and error handling."""
def test_systemd_enable_failure_produces_stderr(self):
"""Systemd enable failure produces error message on stderr."""
with patch("fenris.init_system.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Permission denied"
)
with pytest.raises(SystemExit):
_systemd_enable(now=True)
def test_runit_collect_missing_script(self):
"""Runit collect exits when fenris-collect script not found."""
with patch("fenris.init_system.Path") as mock_path:
mock_path.return_value.exists.return_value = False
with pytest.raises(SystemExit) as exc_info:
_runit_collect()
assert exc_info.value.code == 1
def test_systemd_query_state_timeout(self):
"""Systemd query state handles subprocess timeout."""
with patch("fenris.init_system._systemctl_show") as mock_show:
mock_show.return_value = {}
result = _systemd_query_state()
assert result["boot_enabled"] is None
assert result["timer_active"] is None
+376
View File
@@ -0,0 +1,376 @@
"""Edge-case tests for issue #72 remaining acceptance criteria.
Covers:
- TPH-#34-36: Pause-crossing intervals
- TPH-#59: Staleness frozen estimate wording
- TPH-#62: Zero-delta evidence in live graph
- TPH-#63: Horizon anchoring at evidence endpoint
- TPH-#64: Burst/habit checks with unknown daily shares
"""
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.derive import derive_hours_from_interval
from fenris.day_aggregate import derive_day
from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS
from fenris.tui import _query_live_graph_data, _query_daily_graph_data
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _clock(year=2026, month=10, day=1, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_sample(conn, ts, bw=512000000000, br=256000000000, segment_id=1):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(ts, "/dev/nvme0n1", 1000000, 500000, 5, bw, br, 8765, segment_id),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False):
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",
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
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",
"Samsung SSD 970 EVO Plus 1TB", 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_day(conn, day, bw=10*1024*1024*1024, br=0, coverage=0.95, samples=24):
"""Insert a day aggregate with ~10GB writes (realistic for projection)."""
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, br, samples, coverage),
)
conn.commit()
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=10*1024*1024*1024,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row for testing the observation day gate."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
active=3600, idle=0, powered_off=0, unknown=0):
known = active + idle + powered_off
coverage = known / 3600.0
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
)
conn.commit()
# ---------------------------------------------------------------------------
# TPH-#34-36: Pause-crossing intervals
# ---------------------------------------------------------------------------
class TestPauseCrossingIntervals:
"""Intervals crossing a pause boundary preserve evidence correctly."""
def test_interval_crossing_pause_preserves_total(self, tmp_path):
"""An interval that spans a pause boundary keeps the full delta
in hour observations; the monitoring-period exclusion happens at
the day-aggregate coverage level."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 10, 1, 11, 0, 0, tzinfo=timezone.utc)
t3 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, t1)
_insert_sample(conn, t1.isoformat(), bw=100000000000)
_insert_sample(conn, t2.isoformat(), bw=200000000000)
prev = {"id": 1, "ts": t1.isoformat(), "bytes_written": 100000000000,
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
"data_units_written": 1000000, "data_units_read": 0}
curr = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
"data_units_written": 2000000, "data_units_read": 0}
derive_hours_from_interval(conn, prev, curr)
close_period(conn, t2, "user_disabled")
ensure_period_open(conn, t3)
_insert_sample(conn, t3.isoformat(), bw=300000000000)
prev2 = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
"data_units_written": 2000000, "data_units_read": 0}
curr2 = {"id": 3, "ts": t3.isoformat(), "bytes_written": 300000000000,
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
"data_units_written": 3000000, "data_units_read": 0}
derive_hours_from_interval(conn, prev2, curr2)
hour11 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour = ?",
("2026-10-01T11:00:00+00:00",)
).fetchone()
assert hour11 is not None
assert hour11[0] == 100000000000
agg = derive_day(conn, "2026-10-01")
assert agg is not None
assert agg.bytes_written_delta == 100000000000
conn.close()
def test_deliberate_pause_vs_raw_stop(self, tmp_path):
"""Deliberate pause closes period with user_disabled;
raw service stop leaves period open (interrupted)."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, t1)
_insert_sample(conn, t1.isoformat())
close_period(conn, t1 + timedelta(hours=1), "user_disabled")
period = conn.execute(
"SELECT ended_at, end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL"
).fetchone()
assert period is not None
assert period[1] == "user_disabled"
t2 = datetime(2026, 10, 1, 14, 0, 0, tzinfo=timezone.utc)
ensure_period_open(conn, t2)
_insert_sample(conn, t2.isoformat())
open_periods = conn.execute(
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
).fetchone()
assert open_periods[0] == 1
conn.close()
# ---------------------------------------------------------------------------
# TPH-#59: Staleness frozen estimate wording
# ---------------------------------------------------------------------------
class TestStalenessFact:
"""Projection includes staleness fact when evidence is old."""
def test_staleness_fact_present_when_old(self, tmp_path):
"""When newest data is ≥48h old, projection includes staleness fact."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024)
_insert_local_day(conn, day, complete=True)
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
proj = compute_projection(conn, clock)
assert proj.staleness_fact is not None
assert "48" in proj.staleness_fact or "old" in proj.staleness_fact
staleness_in_facts = any("old" in f or "48" in f for f in proj.contributing_facts)
assert staleness_in_facts
conn.close()
def test_no_staleness_fact_when_fresh(self, tmp_path):
"""When newest data is recent, no staleness fact."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
for i in range(14):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024)
_insert_local_day(conn, day, complete=True)
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
proj = compute_projection(conn, clock)
assert proj.staleness_fact is None
conn.close()
# ---------------------------------------------------------------------------
# TPH-#62: Zero-delta evidence in live graph
# ---------------------------------------------------------------------------
class TestZeroDeltaEvidence:
"""Zero-delta intervals are distinguished from missing evidence."""
def test_zero_delta_shown_as_zero(self, tmp_path):
"""Two identical readings produce a zero-delta interval."""
conn = init_store(tmp_path / "test.db")
now = _clock()
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
data = _query_live_graph_data(conn, now)
assert len(data) == 1
assert data[0]["bytes_written"] == 0
assert data[0]["is_zero"] is True
assert data[0]["is_gap"] is False
conn.close()
def test_gap_distinguished_from_zero(self, tmp_path):
"""A gap (missed collection) with non-zero delta is distinct from measured zero."""
conn = init_store(tmp_path / "test.db")
now = _clock()
_insert_sample(conn, (now - timedelta(hours=2)).isoformat(), bw=100000000000)
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=200000000000)
data = _query_live_graph_data(conn, now)
assert len(data) == 1
assert data[0]["is_gap"] is True
assert data[0]["is_zero"] is False
assert data[0]["bytes_written"] == 100000000000
# ---------------------------------------------------------------------------
# TPH-#63: Horizon anchoring at evidence endpoint
# ---------------------------------------------------------------------------
class TestHorizonAnchoring:
"""Scenario windows end at the latest evidence endpoint T."""
def test_horizon_rate_uses_evidence_endpoint(self, tmp_path):
"""Horizon rates are computed from the latest evidence endpoint,
not from clock_now."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_insert_baseline(conn)
ensure_period_open(conn, datetime(2026, 9, 10, 0, 0, 0, tzinfo=timezone.utc))
# Insert 28 days of data ending 5 days ago with realistic writes
base = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc)
for i in range(28):
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=10*1024*1024*1024) # ~10GB/day
_insert_local_day(conn, day, complete=True)
clock = datetime(2026, 10, 13, 12, 0, 0, tzinfo=timezone.utc)
proj = compute_projection(conn, clock)
# Scenario range should exist with rates
assert proj.scenario_range is not None
assert len(proj.scenario_range.rates) > 0
for horizon in [7, 28]:
if horizon in proj.scenario_range.rates:
rate = proj.scenario_range.rates[horizon]
assert rate > 0, f"Horizon {horizon} should have positive rate"
conn.close()
# ---------------------------------------------------------------------------
# TPH-#64: Burst/habit checks with unknown daily shares
# ---------------------------------------------------------------------------
class TestBurstChecks:
"""Burst and habit checks handle unknown daily shares correctly."""
def test_habit_change_detected(self, tmp_path):
"""Habit change detection works with clear write rate change."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
_insert_baseline(conn, tbw_tb=10.0)
ensure_period_open(conn, datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc))
# 28 days of normal usage (100 MiB/day)
bw_normal = 100 * 1024 * 1024
for i in range(28):
day = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=bw_normal)
_insert_local_day(conn, day, complete=True, bw=bw_normal)
# 10 days of high usage (300 MiB/day = 3x, triggers habit change)
bw_high = 300 * 1024 * 1024
for i in range(10):
day = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, day, bw=bw_high)
_insert_local_day(conn, day, complete=True, bw=bw_high)
clock = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
proj = compute_projection(conn, clock)
# Should have a habit change fact (3× increase for 10 days)
assert proj.habit_change_fact is not None
assert "usage habit changed" in proj.habit_change_fact
conn.close()
def test_zero_delta_not_treated_as_unknown(self, tmp_path):
"""A measured zero-delta interval is not treated as missing evidence."""
conn = init_store(tmp_path / "test.db")
now = _clock()
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
data = _query_live_graph_data(conn, now)
assert len(data) == 1
assert data[0]["is_zero"] is True
assert data[0]["is_gap"] is False
assert data[0]["bytes_written"] == 0
conn.close()
+35
View File
@@ -86,6 +86,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
conn.commit()
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
class TestHonestQualifyingProgress:
"""Issue #77: Show honest qualifying-day progress and confidence."""
@@ -103,6 +130,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
@@ -125,6 +153,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
@@ -146,6 +175,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should be warming
@@ -164,6 +194,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=0, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Zero rate should be UNSUPPORTED
@@ -185,6 +216,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
@@ -205,6 +237,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should still be warming
@@ -225,6 +258,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
@@ -247,6 +281,7 @@ class TestHonestQualifyingProgress:
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should not be warming
+11 -11
View File
@@ -95,7 +95,7 @@ class TestFenrisIdentity:
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path):
@@ -108,7 +108,7 @@ class TestFenrisIdentity:
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
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path):
@@ -140,7 +140,7 @@ class TestFenrisIdentity:
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 "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
@@ -174,7 +174,7 @@ class TestSingleMakerCredit:
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
@pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
@@ -260,7 +260,7 @@ class TestPreservedBehavior:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
@@ -272,11 +272,11 @@ class TestPreservedBehavior:
@pytest.mark.asyncio
async def test_quit_rail_preserved(self, tmp_path):
"""Separate q QUIT TUI rail preserved."""
"""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
rail = str(app.query_one("#action-rail").render())
assert "q Quit TUI" in rail
@pytest.mark.asyncio
async def test_auth_banner_preserved(self, tmp_path):
@@ -287,11 +287,11 @@ class TestPreservedBehavior:
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower()
assert "polkit" in str(app.query_one("#auth-notice").render()).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()
assert not app.query_one("#auth-notice").display
@pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path):
@@ -307,7 +307,7 @@ class TestPreservedBehavior:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
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,
+23 -16
View File
@@ -93,7 +93,7 @@ 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):
async def test_tui_applies_chalktone_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp
@@ -102,7 +102,7 @@ class TestPresetLoading:
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"
assert app.theme == "fenris-chalktone"
@pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
@@ -171,8 +171,8 @@ class TestKeyBindings:
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."""
async def test_t_goes_to_today(self, tmp_path):
"""Pressing t returns to today's live view (issue #91)."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
@@ -180,14 +180,19 @@ class TestKeyBindings:
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start at amber
assert app.theme == "fenris-amber"
assert app.theme == "fenris-chalktone"
# Press t to cycle
# Enter drill mode on the graph
graph = app.query_one("#usage-history")
graph.view_mode = "hourly"
graph.drill_day = "2026-09-30"
# Press t to go back to today
await pilot.press("t")
await pilot.pause()
# Should be nord or high_contrast now
assert app.theme in ("fenris-nord", "fenris-high-contrast")
# Should be back in daily mode
assert graph.view_mode == "daily"
@pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path):
@@ -226,21 +231,23 @@ class TestPersistence:
async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
from fenris.preferences import save_preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme
# First run: change theme via preferences API
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"
save_preferences(theme="nord", reduced_motion=False)
app1._current_theme_name = "nord"
app1.theme = "fenris-nord"
theme_after = app1.theme
assert theme_after == "fenris-nord"
# 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
assert app2.theme == theme_after
@pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path):
@@ -375,7 +382,7 @@ class TestSafePersistence:
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"
assert app.theme == "fenris-chalktone"
# Dashboard should be functional
headline = str(app.query_one("#headline-band").render())
assert headline is not None
@@ -394,7 +401,7 @@ class TestSafePersistence:
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")
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
# ---------------------------------------------------------------------------
+596
View File
@@ -0,0 +1,596 @@
"""Tests for issue #92: Browse activity dates from the keyboard.
Covers:
- AC92-1: [ and ] select previous/next day, g opens date entry, t returns to today/live
- AC92-2: Date entry accepts valid dates, reports errors, returns on cancel
- AC92-3: Selecting a date obtains correct local-day evidence
- AC92-4: Historical date, measurement, and context remain stable across refresh
- AC92-5: t resolves to today/live, themes through discoverable control
- AC92-6: Drill-down shows available data with distinguishable states
- AC92-7: Keyboard actions verified from normal launch with headless driver
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
"""
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.local_day import (
LocalDaySummary,
persist_local_day,
query_local_day_summary,
)
from fenris.tui import (
FenrisTuiApp,
HistoryGraph,
_RANGE_OPTIONS,
_RANGE_DEFAULT,
)
# ---------------------------------------------------------------------------
# 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"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, False, "nqn.test", "SN123",
"Samsung SSD 970 EVO Plus 1TB", "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 _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
bw=300, br=130, complete=True):
summary = LocalDaySummary(
local_date=local_date,
tz_name=tz_name,
tz_offset=tz_offset,
utc_start=f"{local_date}T00:00:00+00:00",
utc_end=f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00",
bytes_written=bw,
bytes_read=br,
coverage=0.95,
sample_count=24,
complete=complete,
)
persist_local_day(conn, summary)
conn.commit()
def _setup_store(tmp_path, n_days=30, today=None):
"""Create a store with n_days of data and return (conn, clock)."""
if today is None:
today = _clock()
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(n_days):
d = (today - timedelta(days=n_days - 1 - i)).strftime("%Y-%m-%d")
_insert_day(conn, d)
return conn, today
def _make_app(tmp_path, clock=None):
"""Create a FenrisTuiApp with the clock mocked to *clock*.
Returns (app, patcher) - caller must keep patcher alive during the test.
"""
if clock is None:
clock = _clock()
from unittest.mock import patch
import fenris.tui as tui_mod
from datetime import datetime as _real_datetime
class MockDatetime(_real_datetime):
@classmethod
def now(cls, tz=None):
return clock
mock_dt = MockDatetime
patcher = patch.object(tui_mod, "datetime", mock_dt)
patcher.start()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
return app, patcher
# ---------------------------------------------------------------------------
# AC92-1: Keyboard bindings exist
# ---------------------------------------------------------------------------
class TestBindingExistence:
def test_open_square_bracket_binding(self, tmp_path):
"""The [ binding exists for previous day navigation."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "left_square_bracket" in binding_keys
def test_close_square_bracket_binding(self, tmp_path):
"""The ] binding exists for next day navigation."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "right_square_bracket" in binding_keys
def test_g_binding_exists(self, tmp_path):
"""The g binding exists for date entry."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "g" in binding_keys
def test_t_binding_is_today(self, tmp_path):
"""The t binding maps to action_today, not a theme action."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
t_binding = next(b for b in app.BINDINGS if b.key == "t")
assert "today" in t_binding.action.lower()
def test_pause_resume_quit_bindings_exist(self, tmp_path):
"""Existing bindings are preserved."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "p" in binding_keys # pause
assert "r" in binding_keys # resume
assert "q" in binding_keys # quit
assert "d" in binding_keys # disclosures
assert "m" in binding_keys # motion toggle
# ---------------------------------------------------------------------------
# AC92-1: Bracket navigation from normal launch
# ---------------------------------------------------------------------------
class TestBracketNavigation:
@pytest.mark.asyncio
async def test_right_bracket_advances_day(self, tmp_path):
"""The ] key moves to the next (newer) day when not at the end."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Move to an older day first so ] can advance
for _ in range(3):
await pilot.press("left_square_bracket")
day_before = graph._day_data[graph.selected_index].get("day")
await pilot.press("right_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
# ] should move to a newer day (closer to today)
assert day_after > day_before
@pytest.mark.asyncio
async def test_left_bracket_goes_to_previous_day(self, tmp_path):
"""The [ key moves to the previous (older) day."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
day_before = graph._day_data[graph.selected_index].get("day")
await pilot.press("left_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
# [ should move to an older day (further from today)
assert day_after < day_before
@pytest.mark.asyncio
async def test_bracket_navigates_outside_visible_range(self, tmp_path):
"""[ beyond the current range shifts the visible window."""
app_clock = _clock()
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
conn.close()
app, patcher = _make_app(tmp_path, clock=app_clock)
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
assert graph.range_days == _RANGE_DEFAULT
# Navigate to oldest day by pressing [ many times
for _ in range(29):
await pilot.press("left_square_bracket")
# Graph should now show a range that includes the oldest day
assert graph.selected_index >= 0
selected_day = graph._day_data[graph.selected_index].get("day")
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
assert selected_day == oldest_day
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_bracket_at_boundary_does_not_move(self, tmp_path):
"""] at the last day does not move the selection."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
initial_idx = graph.selected_index
await pilot.press("right_square_bracket")
assert graph.selected_index == initial_idx
# ---------------------------------------------------------------------------
# AC92-1: Arrow keys inspect the selected view
# ---------------------------------------------------------------------------
class TestArrowInspection:
@pytest.mark.asyncio
async def test_arrow_keys_still_work_in_daily_view(self, tmp_path):
"""Arrow keys move the selection within the visible daily view."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
graph.focus()
await pilot.pause()
day_before = graph._day_data[graph.selected_index].get("day")
await pilot.press("left")
day_after = graph._day_data[graph.selected_index].get("day")
assert day_after != day_before
@pytest.mark.asyncio
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
"""Arrow keys navigate hours when in hourly drill-down."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill-down
await pilot.press("enter")
assert graph.view_mode == "hourly"
# Arrow keys navigate hours
await pilot.press("left")
assert graph._hourly_selected >= 0
# ---------------------------------------------------------------------------
# AC92-1: t returns to today/live
# ---------------------------------------------------------------------------
class TestTodayBinding:
@pytest.mark.asyncio
async def test_t_returns_to_today_after_browsing(self, tmp_path):
"""The t key returns to today's live view after browsing."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to an older day using [
for _ in range(5):
await pilot.press("left_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
today = clock.strftime("%Y-%m-%d")
assert browsed_day != today
# Press t to return to today
await pilot.press("t")
assert graph.selected_index == len(graph._day_data) - 1
@pytest.mark.asyncio
async def test_t_exits_hourly_drill(self, tmp_path):
"""The t key exits hourly drill-down back to daily view."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill
await pilot.press("enter")
assert graph.view_mode == "hourly"
# t returns to daily
await pilot.press("t")
assert graph.view_mode == "daily"
# ---------------------------------------------------------------------------
# AC92-2: Date entry screen
# ---------------------------------------------------------------------------
class TestDateEntry:
@pytest.mark.asyncio
async def test_g_opens_date_entry(self, tmp_path):
"""The g key opens the date entry screen."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
await pilot.press("g")
await pilot.pause()
# Date picker should be pushed as a screen
assert len(app.screen_stack) > 1
@pytest.mark.asyncio
async def test_valid_date_navigates(self, tmp_path):
"""Entering a valid date navigates to that day."""
app_clock = _clock()
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
conn.close()
app, patcher = _make_app(tmp_path, clock=app_clock)
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
# Simulate the date picker callback directly
app._go_to_date_callback(target_date)
await pilot.pause()
graph = app.query_one("#usage-history")
selected_day = graph._day_data[graph.selected_index].get("day")
assert selected_day == target_date
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_invalid_date_shows_error(self, tmp_path):
"""Entering an invalid date shows an error message."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
await pilot.press("g")
await pilot.pause()
await pilot.press("n", "o", "t", " ", "a", " ", "d", "a", "t", "e")
await pilot.press("enter")
await pilot.pause()
# Should remain on date picker (error shown, not dismissed)
assert len(app.screen_stack) > 1
@pytest.mark.asyncio
async def test_cancel_returns_to_prior(self, tmp_path):
"""Pressing escape in date entry returns to the prior view."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
prior_idx = graph.selected_index
await pilot.press("g")
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
# Should be back to the graph with the same selection
assert graph.selected_index == prior_idx
# ---------------------------------------------------------------------------
# AC92-3: Selecting a date obtains correct local-day evidence
# ---------------------------------------------------------------------------
class TestLocalDayEvidence:
@pytest.mark.asyncio
async def test_bracket_shows_local_day_for_selected_date(self, tmp_path):
"""Navigating with brackets shows the local-day summary for that date."""
app_clock = _clock()
conn, clock = _setup_store(tmp_path, n_days=14, today=app_clock)
# Insert local-day data for a day within the 14-day range
target = (app_clock - timedelta(days=4)).strftime("%Y-%m-%d")
_insert_local_day(conn, target, bw=500, br=200)
conn.close()
app, patcher = _make_app(tmp_path, clock=app_clock)
try:
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Navigate to the target day
for _ in range(4):
await pilot.press("left_square_bracket")
await pilot.pause()
local_day = app.query_one("#local-day")
text = str(local_day.render())
assert target in text
finally:
patcher.stop()
@pytest.mark.asyncio
async def test_date_picker_shows_local_day(self, tmp_path):
"""Picking a date via the date picker shows its local-day summary."""
conn, clock = _setup_store(tmp_path, n_days=14)
target = (clock - timedelta(days=10)).strftime("%Y-%m-%d")
_insert_local_day(conn, target, bw=100, br=50)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
# Simulate the date picker callback directly
app._go_to_date_callback(target)
await pilot.pause()
local_day = app.query_one("#local-day")
text = str(local_day.render())
assert target in text
# ---------------------------------------------------------------------------
# AC92-4: Browsing stability across background refresh
# ---------------------------------------------------------------------------
class TestBrowsingStability:
@pytest.mark.asyncio
async def test_selection_stable_across_refresh(self, tmp_path):
"""Browsing selection is preserved when background refresh fires."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to a specific day
for _ in range(3):
await pilot.press("right_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
# Wait for at least one refresh tick
import asyncio
await asyncio.sleep(0.3)
await pilot.pause()
# Selection should be preserved
assert graph.selected_index >= 0
assert graph._day_data[graph.selected_index].get("day") == browsed_day
@pytest.mark.asyncio
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
"""After navigating away from today, refresh does not jump back."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
# Browse to older day using [
for _ in range(5):
await pilot.press("left_square_bracket")
browsed_day = graph._day_data[graph.selected_index].get("day")
today = clock.strftime("%Y-%m-%d")
assert browsed_day != today
# Wait for refresh
import asyncio
await asyncio.sleep(0.3)
await pilot.pause()
# Should NOT jump back to today
selected_day = graph._day_data[graph.selected_index].get("day")
assert selected_day != today
# ---------------------------------------------------------------------------
# AC92-6: Drill-down shows available data
# ---------------------------------------------------------------------------
class TestDrillDownData:
@pytest.mark.asyncio
async def test_drill_down_shows_loading_then_data(self, tmp_path):
"""Drill-down transitions from loading to actual data."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(100, 40)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
await pilot.press("v", "v")
await pilot.click("#usage-history")
# Enter drill-down
await pilot.press("enter")
assert graph.view_mode == "hourly"
# After drill callback, data should be populated
# (empty since we have no hour_observations)
assert graph.view_mode == "hourly"
# ---------------------------------------------------------------------------
# AC92-8: Constrained width behavior
# ---------------------------------------------------------------------------
class TestConstrainedWidth:
@pytest.mark.asyncio
async def test_bracket_navigation_works_at_80x24(self, tmp_path):
"""Date navigation works at minimum 80x24 terminal size."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
day_before = graph._day_data[graph.selected_index].get("day")
await pilot.press("left_square_bracket")
day_after = graph._day_data[graph.selected_index].get("day")
assert day_after < day_before
@pytest.mark.asyncio
async def test_g_opens_date_entry_at_80x24(self, tmp_path):
"""Date entry opens at minimum terminal size."""
conn, clock = _setup_store(tmp_path, n_days=14)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
await pilot.press("g")
await pilot.pause()
assert len(app.screen_stack) > 1
+606
View File
@@ -0,0 +1,606 @@
"""Lifecycle tests for local-day history after detail expires (issue #93).
Verifies the full lifecycle: raw samples collected, local-day summaries
persisted, raw samples pruned after 14 days, and aged local-day summaries
remain queryable with correct timezone, boundaries, and evidence-limit
metadata.
Seams:
- Write side: collector.run_collection() → local_day derivation → persist
- Pruning: prune_old_samples() removes raw samples but preserves local_days
- Read side: query_local_day_history() returns entries with evidence flags
- Repair: repair_derivation() does not touch existing local_days
"""
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.store import init_store, SCHEMA_VERSION
from fenris.local_day import (
derive_local_day_summary,
persist_local_day,
query_local_day_summary,
query_local_day_history,
LocalDaySummary,
LocalDayHistoryEntry,
_is_detail_available,
)
from fenris.pruning import prune_old_samples, RAW_SAMPLE_RETENTION_DAYS
from fenris.repair import repair_derivation
from fenris.monitoring_periods import ensure_period_open, close_period
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_smartctl(duw: int, dur: 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,
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
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
class _Clock:
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
active=3600, idle=0, powered_off=0, unknown=0):
"""Insert a UTC hour observation."""
known = active + idle + powered_off
coverage = known / 3600.0
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
)
conn.commit()
def _insert_sample(conn, ts_iso, bw=0, br=0, device="/dev/nvme0"):
"""Insert a raw sample."""
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, ?, ?, 0)",
(ts_iso, device, bw, br),
)
conn.commit()
# ---------------------------------------------------------------------------
# AC1: Advance through 14-day boundary, detail expires, summaries remain
# ---------------------------------------------------------------------------
class TestDetailExpiresSummariesRemain:
"""AC1: After 14-day pruning, local-day summaries survive."""
def test_local_days_survive_sample_pruning(self, tmp_path):
"""Local-day summaries persist after raw samples are pruned."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Create local-day summaries for dates spanning the 14-day boundary
# Day 1: 15 days ago (detail should be pruned)
old_summary = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=1000, bytes_read=500,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, old_summary)
# Day 2: 10 days ago (detail should be retained)
recent_summary = LocalDaySummary(
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-21T00:00:00+00:00",
utc_end="2026-09-22T00:00:00+00:00",
bytes_written=2000, bytes_read=800,
coverage=0.9, sample_count=15, complete=True,
)
persist_local_day(conn, recent_summary)
# Insert raw samples (old and recent)
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500, br=200)
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000, br=500)
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
_insert_sample(conn, "2026-09-21T10:05:00+00:00", bw=2000, br=800)
# Run pruning
pruned = prune_old_samples(conn, now, retention_days=14)
# Old samples should be pruned, recent ones retained
assert pruned >= 1
# Local-day summaries must still be queryable
old_result = query_local_day_summary(conn, "2026-09-16")
assert old_result is not None
assert old_result["bytes_written"] == 1000
assert old_result["bytes_read"] == 500
recent_result = query_local_day_summary(conn, "2026-09-21")
assert recent_result is not None
assert recent_result["bytes_written"] == 2000
conn.close()
def test_history_query_shows_evidence_limits(self, tmp_path):
"""History entries annotate detail-availability correctly."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Old summary (detail pruned)
old = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=1000, bytes_read=500,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, old)
# Recent summary (detail available)
recent = LocalDaySummary(
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-21T00:00:00+00:00",
utc_end="2026-09-22T00:00:00+00:00",
bytes_written=2000, bytes_read=800,
coverage=0.9, sample_count=15, complete=True,
)
persist_local_day(conn, recent)
# Insert a recent sample (no old samples → old detail not available)
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
# Query history
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
assert len(entries) == 2
old_entry = next(e for e in entries if e.local_date == "2026-09-16")
assert old_entry.detail_available is False
assert old_entry.bytes_written == 1000
recent_entry = next(e for e in entries if e.local_date == "2026-09-21")
assert recent_entry.detail_available is True
assert recent_entry.bytes_written == 2000
conn.close()
# ---------------------------------------------------------------------------
# AC2: Preserve summaries and boundary evidence before pruning
# ---------------------------------------------------------------------------
class TestBoundaryPreservation:
"""AC2: Summaries and boundary evidence preserved before pruning."""
def test_boundary_anchor_not_pruned_when_needed(self, tmp_path):
"""Raw samples needed as boundary anchors are retained."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before cutoff, recent sample after cutoff
# The old sample is a boundary anchor
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
pruned = prune_old_samples(conn, now, retention_days=14)
# Boundary anchor should be retained
cursor = conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-16T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
conn.close()
# ---------------------------------------------------------------------------
# AC3: Aged summaries retain timezone after system-timezone change
# ---------------------------------------------------------------------------
class TestTimezonePreservation:
"""AC3: Historical summaries retain recorded timezone after TZ change."""
def test_timezone_survives_pruning(self, tmp_path):
"""Old summary in Asia/Kolkata retains its timezone after pruning."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Old summary in Asia/Kolkata
old = LocalDaySummary(
local_date="2026-09-16", tz_name="Asia/Kolkata", tz_offset="+05:30",
utc_start="2026-09-15T18:30:00+00:00",
utc_end="2026-09-16T18:30:00+00:00",
bytes_written=3000, bytes_read=1200,
coverage=0.7, sample_count=8, complete=False,
)
persist_local_day(conn, old)
# Prune
prune_old_samples(conn, now, retention_days=14)
# Summary must retain its timezone
result = query_local_day_summary(conn, "2026-09-16")
assert result is not None
assert result["tz_name"] == "Asia/Kolkata"
assert result["tz_offset"] == "+05:30"
# UTC boundaries are preserved (not rewritten)
assert result["utc_start"] == "2026-09-15T18:30:00+00:00"
assert result["utc_end"] == "2026-09-16T18:30:00+00:00"
conn.close()
def test_history_entry_shows_tz_after_pruning(self, tmp_path):
"""History entry for aged summary shows correct timezone info."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
old = LocalDaySummary(
local_date="2026-09-16", tz_name="US/Eastern", tz_offset="-04:00",
utc_start="2026-09-16T04:00:00+00:00",
utc_end="2026-09-17T04:00:00+00:00",
bytes_written=5000, bytes_read=2000,
coverage=0.6, sample_count=5, complete=False,
)
persist_local_day(conn, old)
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
assert len(entries) == 1
entry = entries[0]
assert entry.tz_name == "US/Eastern"
assert entry.tz_offset == "-04:00"
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
assert entry.detail_available is False
conn.close()
# ---------------------------------------------------------------------------
# AC4: Legacy UTC summaries without local-day total remain incomplete
# ---------------------------------------------------------------------------
class TestLegacyDataHandling:
"""AC4: Legacy UTC summaries that can't establish local-day total."""
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
"""Legacy summary without full evidence is labelled incomplete."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Simulate a legacy summary that can't establish a full local-day total
legacy = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=500, bytes_read=200,
coverage=0.3, sample_count=3, complete=False,
)
persist_local_day(conn, legacy)
result = query_local_day_summary(conn, "2026-09-16")
assert result is not None
assert result["complete"] is False
assert result["coverage"] == pytest.approx(0.3, abs=0.01)
conn.close()
# ---------------------------------------------------------------------------
# AC5: Migration, pruning, and repair are interruption-safe and idempotent
# ---------------------------------------------------------------------------
class TestIdempotencySafety:
"""AC5: Operations are idempotent and interruption-safe."""
def test_repeated_pruning_no_side_effects(self, tmp_path):
"""Running pruning multiple times produces the same result."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Create a local-day summary
summary = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=1000, bytes_read=500,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, summary)
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500)
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000)
# Prune twice
prune_old_samples(conn, now, retention_days=14)
prune_old_samples(conn, now, retention_days=14)
# Local-day summary unchanged
result = query_local_day_summary(conn, "2026-09-16")
assert result is not None
assert result["bytes_written"] == 1000
conn.close()
def test_repair_does_not_touch_local_days(self, tmp_path):
"""Repair derivation does not modify existing local_days entries."""
conn = init_store(tmp_path / "obs.db")
# Create a local-day summary
summary = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=1000, bytes_read=500,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, summary)
# Run repair (should not touch local_days)
result = repair_derivation(conn)
assert result.ok is True
# Local-day summary unchanged
stored = query_local_day_summary(conn, "2026-09-16")
assert stored is not None
assert stored["bytes_written"] == 1000
assert stored["bytes_read"] == 500
conn.close()
# ---------------------------------------------------------------------------
# AC6: Volume conservation — no double counting of midnight-spanning bytes
# ---------------------------------------------------------------------------
class TestVolumeConservation:
"""AC6: Summary volume plus unallocated evidence conserves each delta once."""
def test_midnight_spanning_not_double_counted(self, tmp_path):
"""Midnight-spanning interval appears once, not in both days."""
conn = init_store(tmp_path / "obs.db")
# Insert hour observations that span midnight
# UTC hour 2026-09-16T23:00 contains midnight boundary
_insert_hour(conn, "2026-09-16T23:00:00+00:00", bw=100, br=50)
_insert_hour(conn, "2026-09-17T00:00:00+00:00", bw=200, br=80)
# Derive summaries for both days
clock_16 = datetime(2026, 9, 16, 12, 0, 0, tzinfo=timezone.utc)
summary_16 = derive_local_day_summary(conn, "UTC", clock_16)
assert summary_16 is not None
clock_17 = datetime(2026, 9, 17, 12, 0, 0, tzinfo=timezone.utc)
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
assert summary_17 is not None
# Each day should only have its own hour's bytes
# Day 16 has the 23:00 hour (100 bw)
# Day 17 has the 00:00 hour (200 bw)
assert summary_16.bytes_written == 100
assert summary_17.bytes_written == 200
# Total is conserved: 100 + 200 = 300
assert summary_16.bytes_written + summary_17.bytes_written == 300
conn.close()
# ---------------------------------------------------------------------------
# AC7: Use existing retention/repair entry points with real stores
# ---------------------------------------------------------------------------
class TestExistingEntryPoints:
"""AC7: Use existing entry points with real temporary stores."""
def test_prune_with_real_temp_store(self, tmp_path):
"""Pruning works with a real temporary store."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Insert old and recent samples
for days_ago in range(1, 30):
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(conn, ts, bw=days_ago * 100)
pruned = prune_old_samples(conn, now, retention_days=14)
assert pruned == 15
cursor = conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 14
conn.close()
def test_repair_with_real_temp_store(self, tmp_path):
"""Repair works with a real temporary store."""
conn = init_store(tmp_path / "obs.db")
# Insert samples that need interval derivation
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=100)
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=200)
result = repair_derivation(conn)
assert result.ok is True
conn.close()
# ---------------------------------------------------------------------------
# AC8: User-visible transition from recent detail to older summaries
# ---------------------------------------------------------------------------
class TestUserVisibleTransition:
"""AC8: Transition from recent detail to aged summaries."""
def test_recent_vs_aged_history_entries(self, tmp_path):
"""Recent entries show detail_available=True, aged show False."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Create summaries for recent and aged dates
for days_ago, bw in [(5, 1000), (10, 2000), (15, 3000)]:
date = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
utc_start = (now - timedelta(days=days_ago)).replace(
hour=0, minute=0, second=0, microsecond=0
).isoformat()
utc_end = (now - timedelta(days=days_ago - 1)).replace(
hour=0, minute=0, second=0, microsecond=0
).isoformat()
summary = LocalDaySummary(
local_date=date, tz_name="UTC", tz_offset="+00:00",
utc_start=utc_start, utc_end=utc_end,
bytes_written=bw, bytes_read=bw // 2,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, summary)
# Insert a recent sample (5 days ago)
_insert_sample(conn, (now - timedelta(days=5)).isoformat(), bw=500)
# Query full range
start = (now - timedelta(days=15)).strftime("%Y-%m-%d")
end = (now - timedelta(days=5)).strftime("%Y-%m-%d")
entries = query_local_day_history(conn, start, end, now)
assert len(entries) == 3
# Recent entry has detail
recent = next(e for e in entries if e.local_date == (now - timedelta(days=5)).strftime("%Y-%m-%d"))
assert recent.detail_available is True
# Aged entries do not have detail
aged_10 = next(e for e in entries if e.local_date == (now - timedelta(days=10)).strftime("%Y-%m-%d"))
assert aged_10.detail_available is False
aged_15 = next(e for e in entries if e.local_date == (now - timedelta(days=15)).strftime("%Y-%m-%d"))
assert aged_15.detail_available is False
conn.close()
# ---------------------------------------------------------------------------
# AC9: Document the 14-day detail vs indefinite-summary policy
# ---------------------------------------------------------------------------
class TestRetentionPolicyDocumentation:
"""AC9: Module docstring documents the retention policy."""
def test_module_has_retention_policy_doc(self):
"""The local_day module documents the 14-day vs indefinite policy."""
import fenris.local_day as ld
docstring = ld.__doc__
assert "14 day" in docstring.lower() or "fourteen" in docstring.lower()
assert "indefinite" in docstring.lower()
assert "retention" in docstring.lower()
def test_pruning_module_documents_local_days_preservation(self):
"""The pruning module documents that local_days are preserved."""
import fenris.pruning as pr
docstring = pr.__doc__
assert "local_days" in docstring.lower() or "local day" in docstring.lower()
assert "preserved" in docstring.lower() or "never" in docstring.lower()
# ---------------------------------------------------------------------------
# Evidence-limit visibility
# ---------------------------------------------------------------------------
class TestEvidenceLimitVisibility:
"""History entries expose evidence-availability metadata."""
def test_detail_available_flag_reflects_samples(self, tmp_path):
"""detail_available is True only when samples exist within retention."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
# Summary for a date with no samples
no_samples = LocalDaySummary(
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-16T00:00:00+00:00",
utc_end="2026-09-17T00:00:00+00:00",
bytes_written=500, bytes_read=200,
coverage=0.4, sample_count=3, complete=False,
)
persist_local_day(conn, no_samples)
# Summary for a date with a recent sample
with_sample = LocalDaySummary(
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-21T00:00:00+00:00",
utc_end="2026-09-22T00:00:00+00:00",
bytes_written=1000, bytes_read=400,
coverage=0.9, sample_count=10, complete=True,
)
persist_local_day(conn, with_sample)
_insert_sample(conn, "2026-09-21T12:00:00+00:00", bw=500)
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
no_samples_entry = next(e for e in entries if e.local_date == "2026-09-16")
assert no_samples_entry.detail_available is False
with_sample_entry = next(e for e in entries if e.local_date == "2026-09-21")
assert with_sample_entry.detail_available is True
conn.close()
def test_empty_history_range_returns_empty_list(self, tmp_path):
"""Querying an empty date range returns an empty list."""
conn = init_store(tmp_path / "obs.db")
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
entries = query_local_day_history(conn, "2026-01-01", "2026-01-31", now)
assert entries == []
conn.close()
def test_history_entry_from_summary_factory(self, tmp_path):
"""LocalDayHistoryEntry.from_summary creates entry from dict."""
summary = {
"local_date": "2026-09-16",
"tz_name": "UTC",
"tz_offset": "+00:00",
"utc_start": "2026-09-16T00:00:00+00:00",
"utc_end": "2026-09-17T00:00:00+00:00",
"bytes_written": 1000,
"bytes_read": 500,
"coverage": 0.8,
"sample_count": 10,
"complete": True,
}
entry = LocalDayHistoryEntry.from_summary(
summary, detail_available=True, derived_from_surviving=True
)
assert entry.local_date == "2026-09-16"
assert entry.detail_available is True
assert entry.derived_from_surviving is True
assert entry.bytes_written == 1000
+324
View File
@@ -0,0 +1,324 @@
"""Tests for live activity graph (issue #91).
Covers:
- Live interval volume query from raw samples
- LiveActivityGraph widget rendering, toggle, and inspection
- Three-minute cadence constants
- TUI integration with live graph
"""
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
from fenris.tui import (
FenrisTuiApp,
LiveActivityGraph,
_query_live_graph_data,
LIVE_WINDOW_H,
_query_daily_graph_data,
_RANGE_OPTIONS,
)
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
# ---------------------------------------------------------------------------
# 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_sample(conn, ts, bw=512000000000, br=256000000000,
segment_id=None, 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, segment_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Cadence constants (issue #91 AC1)
# ---------------------------------------------------------------------------
class TestCadenceConstants:
def test_cadence_default_is_3_minutes(self):
"""Default collection cadence is 3 minutes (180 s)."""
assert CADENCE_DEFAULT_S == 180
def test_fresh_threshold_uses_3min_cadence(self):
"""Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s."""
expected = 2 * 180 + ACCURACY_SEC + 60
assert FRESH_THRESHOLD_S == expected
def test_live_window_is_3_hours(self):
"""Live graph window is 3 hours."""
assert LIVE_WINDOW_H == 3
# ---------------------------------------------------------------------------
# Live graph query (issue #91 AC2-5)
# ---------------------------------------------------------------------------
class TestQueryLiveData:
def test_empty_store_returns_empty(self, tmp_path):
"""No samples → empty list."""
conn = init_store(tmp_path / "test.db")
now = _clock()
result = _query_live_graph_data(conn, now)
assert result == []
conn.close()
def test_single_sample_returns_empty(self, tmp_path):
"""One sample (no pair) → empty list."""
conn = init_store(tmp_path / "test.db")
now = _clock()
_insert_sample(conn, now.isoformat())
result = _query_live_graph_data(conn, now)
assert result == []
conn.close()
def test_two_samples_in_window(self, tmp_path):
"""Two samples within 3h window produce one interval."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=3)).isoformat()
t2 = (now - timedelta(minutes=0)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 500
assert result[0]["bytes_read"] == 200
assert result[0]["elapsed_s"] == 180
assert result[0]["is_gap"] is False
assert result[0]["is_zero"] is False
conn.close()
def test_samples_outside_window_ignored(self, tmp_path):
"""Samples older than 3h are excluded."""
conn = init_store(tmp_path / "test.db")
now = _clock()
old = (now - timedelta(hours=4)).isoformat()
recent = (now - timedelta(minutes=1)).isoformat()
_insert_sample(conn, old, bw=1000, br=500)
_insert_sample(conn, recent, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 0
conn.close()
def test_gap_detection(self, tmp_path):
"""Interval > 3× cadence is flagged as a gap."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=13)).isoformat()
t2 = (now - timedelta(minutes=3)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1500, br=700)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
def test_zero_interval(self, tmp_path):
"""No byte delta → is_zero flag."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=3)).isoformat()
t2 = (now - timedelta(minutes=0)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=1000, br=500)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 0
assert result[0]["bytes_read"] == 0
assert result[0]["is_zero"] is True
conn.close()
def test_segment_boundary_resets_counters(self, tmp_path):
"""Counter discontinuity across segments → zero delta."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn, identity_key="key_a")
_insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00",
identity_key="key_b")
now = _clock()
t1 = (now - timedelta(minutes=6)).isoformat()
t2 = (now - timedelta(minutes=3)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500, segment_id=1)
_insert_sample(conn, t2, bw=500, br=200, segment_id=2)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["bytes_written"] == 0
assert result[0]["bytes_read"] == 0
assert result[0]["is_segment_boundary"] is True
conn.close()
def test_actual_timestamps_used(self, tmp_path):
"""Points use actual sample timestamps, not 3-min spacing."""
conn = init_store(tmp_path / "test.db")
now = _clock()
t1 = (now - timedelta(minutes=5)).isoformat()
t2 = (now - timedelta(minutes=1)).isoformat()
_insert_sample(conn, t1, bw=1000, br=500)
_insert_sample(conn, t2, bw=2000, br=800)
result = _query_live_graph_data(conn, now)
assert len(result) == 1
assert result[0]["start_ts"] == t1
assert result[0]["end_ts"] == t2
assert result[0]["elapsed_s"] == 240
conn.close()
def test_multiple_intervals_ordered_oldest_first(self, tmp_path):
"""Multiple intervals are returned in chronological order."""
conn = init_store(tmp_path / "test.db")
now = _clock()
for i in range(5):
ts = (now - timedelta(minutes=15 - i * 3)).isoformat()
_insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1))
result = _query_live_graph_data(conn, now)
assert len(result) == 4
for i in range(len(result) - 1):
assert result[i]["end_ts"] <= result[i + 1]["start_ts"]
conn.close()
# ---------------------------------------------------------------------------
# LiveActivityGraph widget (issue #91 AC3)
# ---------------------------------------------------------------------------
class TestLiveActivityGraph:
@pytest.mark.asyncio
async def test_toggle_measure(self, tmp_path):
"""Toggle switches between written and read."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
assert graph.measure == "written"
graph.toggle_measure()
assert graph.measure == "read"
graph.toggle_measure()
assert graph.measure == "written"
@pytest.mark.asyncio
async def test_empty_data_renders_awaiting(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
graph.set_data([])
await pilot.pause()
render = str(graph.query_one("#live-render").render())
assert "Awaiting intervals" in render
@pytest.mark.asyncio
async def test_data_renders_header(self, tmp_path):
"""Data updates the header with interval count."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
data = [
{
"start_ts": "2026-09-30T11:57:00+00:00",
"end_ts": "2026-09-30T12:00:00+00:00",
"start_label": "11:57",
"end_label": "12:00",
"bytes_written": 500000000,
"bytes_read": 200000000,
"elapsed_s": 180,
"is_gap": False,
"is_zero": False,
"is_segment_boundary": False,
},
]
graph.set_data(data)
await pilot.pause()
header = str(graph.query_one("#live-header").render())
assert "1 intervals" in header
assert "w W/R" in str(app.query_one("#activity-tools").render())
# ---------------------------------------------------------------------------
# TUI integration (issue #91 AC2, AC6-9)
# ---------------------------------------------------------------------------
class TestTUILiveIntegration:
@pytest.mark.asyncio
async def test_live_graph_exists_in_compose(self, tmp_path):
"""Live activity graph is composed in the TUI."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
assert app.query_one("#live-activity") is not None
assert isinstance(app.query_one("#live-activity"), LiveActivityGraph)
@pytest.mark.asyncio
async def test_live_graph_has_border_title(self, tmp_path):
"""Live activity graph has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
live = app.query_one("#live-activity")
assert live.border_title == "Live activity"
@pytest.mark.asyncio
async def test_today_action_resets_drill(self, tmp_path):
"""Action today returns from hourly drill to daily view."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#usage-history")
graph.view_mode = "hourly"
graph.drill_day = "2026-09-30"
app.action_today()
await pilot.pause()
assert graph.view_mode == "daily"
@pytest.mark.asyncio
async def test_toggle_measure_binding(self, tmp_path):
"""The w key toggles the live graph measure."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
await pilot.pause()
graph = app.query_one("#live-activity")
assert graph.measure == "written"
await pilot.press("w")
await pilot.pause()
assert graph.measure == "read"
@pytest.mark.asyncio
async def test_constrained_mode_hides_live_graph(self, tmp_path):
"""Constrained terminal hides the live graph."""
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(70, 20)) as pilot:
await pilot.pause()
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
+418
View File
@@ -0,0 +1,418 @@
"""Local-day activity derivation tests (issue #90, ADR 0010).
Verifies that local-day summaries are correctly derived from UTC hour
observations, that the schema migration creates the local_days table,
that timezone boundaries are preserved across system-timezone changes,
and that midnight-spanning intervals are retained as shared evidence
without double counting.
Seams:
- write side: collector.run_collection() → local_day derivation
- read side: local_day.query_current_local_day(), query_local_day_summary()
- persistence: store.init_store() → local_days table, migration 2→3
"""
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.store import init_store, SCHEMA_VERSION
from fenris.local_day import (
derive_local_day_summary,
persist_local_day,
query_local_day_summary,
query_current_local_day,
LocalDaySummary,
)
from fenris.monitoring_periods import ensure_period_open, close_period
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_smartctl(duw: int, dur: 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,
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
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
class _Clock:
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
active=3600, idle=0, powered_off=0, unknown=0):
"""Insert a UTC hour observation."""
known = active + idle + powered_off
coverage = known / 3600.0
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
)
conn.commit()
# ---------------------------------------------------------------------------
# Schema migration: local_days table is created
# ---------------------------------------------------------------------------
class TestSchemaMigration:
"""Store version 3 includes the local_days table."""
def test_fresh_store_has_local_days(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "local_days" in tables
conn.close()
def test_migration_from_v2_creates_local_days(self, tmp_path):
db = tmp_path / "obs.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA user_version=2")
conn.commit()
conn.close()
conn = init_store(db)
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "local_days" in tables
assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
conn.close()
def test_migration_is_idempotent(self, tmp_path):
db = tmp_path / "obs.db"
conn = init_store(db)
conn.close()
# Reopening should not fail
conn = init_store(db)
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
assert count == 0
conn.close()
# ---------------------------------------------------------------------------
# Local-day derivation from UTC hours
# ---------------------------------------------------------------------------
class TestDeriveLocalDay:
"""Derive local-day summaries from UTC hour observations."""
def test_utc_plus_zero(self, tmp_path):
"""UTC timezone: local day boundaries = UTC day boundaries."""
conn = init_store(tmp_path / "obs.db")
ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc))
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50)
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80)
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
summary = derive_local_day_summary(conn, "UTC", clock)
assert summary is not None
assert summary.local_date == "2026-09-01"
assert summary.tz_name == "UTC"
assert summary.tz_offset == "+00:00"
assert summary.bytes_written == 300
assert summary.bytes_read == 130
assert summary.complete is False # not all 24 hours covered
conn.close()
def test_utc_plus_five_thirty(self, tmp_path):
"""Asia/Kolkata half-hour offset: UTC boundaries are offset."""
conn = init_store(tmp_path / "obs.db")
# Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30)
# Insert UTC hours that belong to this local day
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50)
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80)
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
assert summary is not None
assert summary.local_date == "2026-09-01"
assert summary.tz_name == "Asia/Kolkata"
assert summary.tz_offset == "+05:30"
assert summary.bytes_written == 300
assert summary.bytes_read == 130
conn.close()
def test_midnight_spanning_hour_included(self, tmp_path):
"""UTC hour straddling local midnight is included in the local day."""
conn = init_store(tmp_path / "obs.db")
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
# UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight
_insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50)
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80)
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
assert summary is not None
# The midnight-spanning hour (18:00) is included
assert summary.bytes_written == 300
assert summary.bytes_read == 130
conn.close()
def test_no_hours_returns_none(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
summary = derive_local_day_summary(conn, "UTC", clock)
assert summary is None
conn.close()
# ---------------------------------------------------------------------------
# Persistence and querying
# ---------------------------------------------------------------------------
class TestLocalDayPersistence:
"""Local-day summaries persist and can be queried."""
def test_persist_and_query(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
summary = LocalDaySummary(
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-01T00:00:00+00:00",
utc_end="2026-09-02T00:00:00+00:00",
bytes_written=300, bytes_read=130,
coverage=0.5, sample_count=4, complete=False,
)
created = persist_local_day(conn, summary)
assert created is True
# Upsert again — should update, not create
created2 = persist_local_day(conn, summary)
assert created2 is False
result = query_local_day_summary(conn, "2026-09-01")
assert result is not None
assert result["bytes_written"] == 300
assert result["tz_name"] == "UTC"
conn.close()
def test_query_nonexistent_returns_none(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
assert query_local_day_summary(conn, "2026-01-01") is None
conn.close()
def test_query_current_local_day(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
summary = LocalDaySummary(
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-01T00:00:00+00:00",
utc_end="2026-09-02T00:00:00+00:00",
bytes_written=500, bytes_read=200,
coverage=0.8, sample_count=10, complete=True,
)
persist_local_day(conn, summary)
clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc)
result = query_current_local_day(conn, clock, "UTC")
assert result is not None
assert result["bytes_written"] == 500
conn.close()
# ---------------------------------------------------------------------------
# Timezone change handling
# ---------------------------------------------------------------------------
class TestTimezoneChange:
"""Historical summaries retain their recorded timezone."""
def test_old_tz_preserved_after_change(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
# Old summary in UTC
old = LocalDaySummary(
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
utc_start="2026-09-01T00:00:00+00:00",
utc_end="2026-09-02T00:00:00+00:00",
bytes_written=100, bytes_read=50,
coverage=0.5, sample_count=5, complete=False,
)
persist_local_day(conn, old)
# System timezone changes to Asia/Kolkata
# Old summary retains UTC
result = query_local_day_summary(conn, "2026-09-01")
assert result is not None
assert result["tz_name"] == "UTC"
assert result["tz_offset"] == "+00:00"
conn.close()
def test_different_tz_same_date_stored_separately(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
# Two summaries for same date in different timezones
for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]:
summary = LocalDaySummary(
local_date="2026-09-01", tz_name=tz, tz_offset=offset,
utc_start="2026-09-01T00:00:00+00:00",
utc_end="2026-09-02T00:00:00+00:00",
bytes_written=100, bytes_read=50,
coverage=0.5, sample_count=5, complete=False,
)
persist_local_day(conn, summary)
# Both exist independently — query by specific timezone
utc_row = conn.execute(
"SELECT tz_name, bytes_written FROM local_days "
"WHERE local_date = '2026-09-01' AND tz_name = 'UTC'"
).fetchone()
assert utc_row is not None
assert utc_row[0] == "UTC"
assert utc_row[1] == 100
in_row = conn.execute(
"SELECT tz_name, bytes_written FROM local_days "
"WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'"
).fetchone()
assert in_row is not None
assert in_row[0] == "Asia/Kolkata"
conn.close()
# ---------------------------------------------------------------------------
# Coverage and completeness
# ---------------------------------------------------------------------------
class TestLocalDayCompleteness:
"""Complete days vs partial current day."""
def test_incomplete_when_missing_hours(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
_insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50)
clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
summary = derive_local_day_summary(conn, "UTC", clock)
assert summary is not None
assert summary.complete is False
assert summary.coverage < 1.0
conn.close()
def test_coverage_ratio(self, tmp_path):
conn = init_store(tmp_path / "obs.db")
# 2 hours of known data out of 24 total UTC hours in a UTC day
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600)
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600)
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
summary = derive_local_day_summary(conn, "UTC", clock)
assert summary is not None
# 7200 known seconds / 86400 total = ~0.083
assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01)
conn.close()
# ---------------------------------------------------------------------------
# Collector integration
# ---------------------------------------------------------------------------
class TestCollectorIntegration:
"""Collector derives local-day summaries during collection."""
def test_collection_creates_local_day(self, tmp_path, sysfs_tree):
from fenris.collector import run_collection
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
conn = sqlite3.connect(store)
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
assert count >= 1
# Verify the local day has the expected write/read data
row = conn.execute(
"SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1"
).fetchone()
assert row is not None
assert row[0] == 50 * 512000 # writes
assert row[1] == 30 * 512000 # reads
conn.close()
# ---------------------------------------------------------------------------
# DST handling (23/25-hour days)
# ---------------------------------------------------------------------------
class TestDSTHandling:
"""Local days with non-24-hour durations from DST transitions."""
def test_short_day_23_hours(self, tmp_path):
"""Spring forward: local day is 23 hours."""
conn = init_store(tmp_path / "obs.db")
# Simulate a 23-hour day in a timezone with DST
# For simplicity, just verify the summary records the correct UTC range
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
# US/Eastern springs forward on 2026-03-08
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
_insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600)
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
summary = derive_local_day_summary(conn, "US/Eastern", clock)
assert summary is not None
assert summary.local_date == "2026-03-08"
# UTC range should be approximately 23 hours
utc_start = datetime.fromisoformat(summary.utc_start)
utc_end = datetime.fromisoformat(summary.utc_end)
duration = (utc_end - utc_start).total_seconds()
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
conn.close()
+551
View File
@@ -0,0 +1,551 @@
"""Measured drive activity integration tests (issue #89).
Drives successive controlled acquisition readings through the public
collector into a real temporary observation store, then verifies the
normal reader and visible dashboard report correct read/write deltas,
hour/day evidence, accumulation, and boundary behaviour.
Seams:
- write side: run_collection() → observation store
- read side: _query_daily_graph_data(), _query_hourly_graph_data(),
derive_day(), get_status() → observation store
"""
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
from fenris.store import init_store
from fenris.day_aggregate import derive_day
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
"""Build a smartctl fixture with specific DUW/DUR counters."""
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,
"data_units_read": dur, "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_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
class _Clock:
"""Injected clock returning controlled time."""
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
# ---------------------------------------------------------------------------
# AC1: Two successive readings produce correct read/write deltas
# visible through both the hour observations and the TUI query path.
# ---------------------------------------------------------------------------
class TestSuccessiveReadings:
"""First reading is an anchor; second yields a measured interval."""
def test_two_readings_produce_both_rw_deltas(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
# DUW=10000000, DUR=8000000
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
assert r1["ok"]
# +50 DUW, +30 DUR
r2 = run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
assert r2["ok"]
conn = sqlite3.connect(store)
# Hour observation should have both read and write deltas
hour = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta "
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
assert hour is not None
assert hour[0] == 50 * 512000 # writes
assert hour[1] == 30 * 512000 # reads
# TUI graph query should report both
daily = _query_daily_graph_data(conn)
assert len(daily) >= 1
day_entry = daily[-1]
bw_expected = 50 * 512000
br_expected = 30 * 512000
assert day_entry["total_written"] == bw_expected
assert day_entry["total_read"] == br_expected
assert day_entry["allocated_bytes"] == bw_expected
assert day_entry["allocated_read"] == br_expected
conn.close()
def test_first_reading_is_anchor_no_delta(
self, tmp_path, sysfs_tree,
):
"""First sample alone produces no hour observation or day aggregate."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
conn = sqlite3.connect(store)
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM hour_observations").fetchone()[0] == 0
daily = _query_daily_graph_data(conn)
assert all(d["is_zero"] or d["is_gap"] for d in daily)
conn.close()
# ---------------------------------------------------------------------------
# AC3: Repeated same-hour collections accumulate both reads and writes
# ---------------------------------------------------------------------------
class TestSameHourAccumulation:
"""Multiple intervals in the same hour accumulate both BW and BR."""
def test_three_same_hour_readings_accumulate(self, tmp_path, sysfs_tree):
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
# Three readings: 0→20→50 DUW, 0→10→35 DUR (all same hour)
duw_seq = [10000000, 10000020, 10000050]
dur_seq = [8000000, 8000010, 8000035]
for i in range(3):
t = base_t + timedelta(minutes=i * 3)
r = run_collection(
_make_smartctl(duw_seq[i], dur_seq[i]), sysfs_nvme, cfg, _Clock(t)
)
assert r["ok"]
conn = sqlite3.connect(store)
hour = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta, sample_count "
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
assert hour is not None
# 0→20 + 20→50 = 50 DUW delta
assert hour[0] == 50 * 512000
# 0→10 + 10→35 = 35 DUR delta
assert hour[1] == 35 * 512000
# 2 intervals × 2 samples each = 4 sample_count
assert hour[2] == 4
conn.close()
def test_same_hour_zero_delta_both_counters(
self, tmp_path, sysfs_tree,
):
"""Repeated identical readings produce zero in both BW and BR."""
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
for i in range(3):
t = base_t + timedelta(minutes=i * 3)
r = run_collection(
_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t)
)
assert r["ok"]
conn = sqlite3.connect(store)
hour = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta "
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
assert hour is not None
assert hour[0] == 0
assert hour[1] == 0
conn.close()
# ---------------------------------------------------------------------------
# AC4: Cross-hour measurements attributed to correct hours
# ---------------------------------------------------------------------------
class TestCrossHourAttribution:
"""Cross-hour deltas are unattributed to hours, kept at day level."""
def test_cross_hour_unattributed_bytes(
self, tmp_path, sysfs_tree,
):
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
assert r1["ok"]
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
assert r2["ok"]
conn = sqlite3.connect(store)
# Hour 11 and 12 should NOT contain the full cross-hour delta
full_bw = 100 * 512000
full_br = 60 * 512000
for prefix in ("2026-09-01T11%", "2026-09-01T12%"):
row = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta "
"FROM hour_observations WHERE hour LIKE ?", (prefix,)
).fetchone()
if row is not None:
assert row[0] != full_bw, "Hour should not have full cross-hour BW"
assert row[1] != full_br, "Hour should not have full cross-hour BR"
# Day aggregates should have unattributed bytes for both reads and writes
for day in ("2026-09-01",):
agg = derive_day(conn, day)
assert agg is not None
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
unattributed_w = conn.execute(
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
(day,)
).fetchone()
unattributed_r = conn.execute(
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
(day,)
).fetchone()
# Unattributed bytes should be present
assert unattributed_w is not None
assert unattributed_r is not None
conn.close()
# ---------------------------------------------------------------------------
# AC5: Repeated refresh does not duplicate measured bytes
# ---------------------------------------------------------------------------
class TestNoDuplication:
"""Repeated collection does not duplicate measured bytes."""
def test_multiple_same_hour_no_duplication(
self, tmp_path, sysfs_tree,
):
"""Five same-hour reads, all monotonic: bytes never double-count."""
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
# Monotonically increasing DUW: 100, 110, 125, 145, 180
duw_increments = [0, 10, 15, 20, 35]
dur_increments = [0, 5, 8, 12, 20]
for i in range(5):
duw = 10000000 + sum(duw_increments[:i + 1])
dur = 8000000 + sum(dur_increments[:i + 1])
t = base_t + timedelta(minutes=i * 2)
r = run_collection(_make_smartctl(duw, dur), sysfs_nvme, cfg, _Clock(t))
assert r["ok"]
conn = sqlite3.connect(store)
hour = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta "
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
assert hour is not None
# Total should be the cumulative delta across all 5 readings
total_duw_delta = sum(duw_increments)
total_dur_delta = sum(dur_increments)
assert hour[0] == total_duw_delta * 512000
assert hour[1] == total_dur_delta * 512000
conn.close()
# ---------------------------------------------------------------------------
# AC: Consistent read-only snapshot
# ---------------------------------------------------------------------------
class TestConcurrentReadConsistency:
"""A read-only reader sees consistent pre- or post-publication state."""
def test_read_only_sees_consistent_state(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
# Open read-only before second sample
ro_conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
ro_conn.execute("BEGIN")
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
assert count_before == 1
ro_conn.close()
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
# Read-only after second sample sees updated state
ro_conn2 = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
ro_conn2.execute("BEGIN")
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
assert count_after == 2
ro_conn2.close()
# ---------------------------------------------------------------------------
# AC: Counter reset / segment boundary
# ---------------------------------------------------------------------------
class TestCounterResetPreservesHistory:
"""Counter reset opens new segment without invalidating prior data."""
def test_duw_decrease_preserves_hour_data(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
t3 = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
# Sample 1: normal
run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t1))
# Sample 2: counter decrease (reset)
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
# Sample 3: new segment continuation
run_collection(_make_smartctl(10000080, 8000050), sysfs_nvme, cfg, _Clock(t3))
conn = sqlite3.connect(store)
# New segment opened for the reset
seg_count = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
assert seg_count >= 2
# The reset sample (t2) is in a new segment; t3 derives from t2
hour_12 = conn.execute(
"SELECT bytes_written_delta, bytes_read_delta "
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
assert hour_12 is not None
# Hour 12 should have data from at least the valid interval (t2→t3)
# t2→t3: 10000080-10000050=30 DUW, 8000050-8000030=20 DUR
assert hour_12[0] >= 30 * 512000
assert hour_12[1] >= 20 * 512000
conn.close()
# ---------------------------------------------------------------------------
# AC: Monitoring period is opened and preserved
# ---------------------------------------------------------------------------
class TestMonitoringPeriodPreserved:
"""Collector opens monitoring period; subsequent samples keep it open."""
def test_first_sample_opens_period(self, tmp_path, sysfs_tree):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
conn = sqlite3.connect(store)
period = conn.execute(
"SELECT COUNT(*) FROM monitoring_periods"
).fetchone()[0]
conn.close()
assert period == 1
def test_subsequent_samples_keep_one_period(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
conn = sqlite3.connect(store)
period_count = conn.execute(
"SELECT COUNT(*) FROM monitoring_periods"
).fetchone()[0]
conn.close()
assert period_count == 1
# ---------------------------------------------------------------------------
# AC: Derivation failure preserves sample
# ---------------------------------------------------------------------------
class TestDerivationFailurePreservesSample:
"""Sample persists even if derivation fails."""
def test_sample_survives_derivation_failure(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
# Second sample succeeds normally
r2 = run_collection(
_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)
)
assert r2["ok"]
conn = sqlite3.connect(store)
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 2
# ---------------------------------------------------------------------------
# AC: Cross-day UTC boundary
# ---------------------------------------------------------------------------
class TestCrossDayBoundary:
"""Measurements crossing UTC day boundary are conserved."""
def test_cross_day_unattributed_both_days(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
assert r1["ok"]
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
assert r2["ok"]
conn = sqlite3.connect(store)
daily = _query_daily_graph_data(conn)
day_map = {d["day"]: d for d in daily}
# Both days should appear
assert "2026-09-01" in day_map
assert "2026-09-02" in day_map
# The cross-day delta is unattributed at the day level
sep1 = day_map["2026-09-01"]
sep2 = day_map["2026-09-02"]
# Both days may show the unattributed bytes
# (the delta is added to both days' unattributed totals as evidence)
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
total_r = sep1["allocated_read"] + sep1["unallocated_read"]
# Day 1 has at least some recorded data
assert total_w >= 0
assert total_r >= 0
conn.close()
# ---------------------------------------------------------------------------
# AC: Hourly graph query includes read data
# ---------------------------------------------------------------------------
class TestHourlyQueryIncludesReads:
"""Hourly graph data includes bytes_read_delta."""
def test_hourly_query_returns_read_data(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
conn = sqlite3.connect(store)
hourly = _query_hourly_graph_data(conn, "2026-09-01", t2)
# Hour 12 should have read data
h12 = next(h for h in hourly if h["hour"] == "2026-09-01T12:00:00+00:00")
assert h12["bytes_written"] == 50 * 512000
assert h12["bytes_read"] == 30 * 512000
conn.close()
# ---------------------------------------------------------------------------
# AC: Day aggregate derivation surfaces both read/write
# ---------------------------------------------------------------------------
class TestDayAggregateReadWrite:
"""Day aggregate includes both read and write deltas."""
def test_derive_day_shows_both_rw(
self, tmp_path, sysfs_tree,
):
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
store = str(tmp_path / "obs.db")
cfg = {"device": "/dev/nvme0", "store_path": store}
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
conn = sqlite3.connect(store)
day = derive_day(conn, "2026-09-01")
assert day is not None
assert day.bytes_written_delta == 50 * 512000
assert day.bytes_read_delta == 30 * 512000
assert day.sample_count >= 2
conn.close()
+11 -26
View File
@@ -56,8 +56,7 @@ class TestEnableIdempotentMatrix:
"""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)
with patch("fenris.monitor.enable_timer") as mock_enable:
cmd_enable(args)
conn = init_store(store_path)
@@ -74,8 +73,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.enable_timer") as mock_enable:
cmd_enable(args)
# Period should be open
@@ -100,8 +98,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.enable_timer") as mock_enable:
cmd_enable(args)
# Should still have exactly one open period
@@ -125,8 +122,7 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.enable_timer") as mock_enable:
cmd_enable(args)
# Should have a new open period
@@ -155,8 +151,7 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.disable_timer") as mock_disable:
cmd_disable(args)
# Period should be closed with user_disabled
@@ -174,8 +169,7 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.disable_timer") as mock_disable:
cmd_disable(args)
# No periods should exist
@@ -207,28 +201,19 @@ class TestDisableIdempotentMatrix:
class TestCollectTrigger:
"""§8.7: On-demand collection via helper path."""
def test_collect_triggers_systemctl_start(self):
"""Collect starts fenris-collect.service synchronously."""
def test_collect_triggers_init_system(self):
"""Collect triggers init_system.collect_now."""
args = MagicMock()
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
with patch("fenris.monitor.collect_now") as mock_collect:
cmd_collect(args)
mock_sub.run.assert_called_once_with(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
mock_collect.assert_called_once()
def test_collect_failure_exits_nonzero(self):
"""Collect failure exits with nonzero status."""
args = MagicMock()
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Unit not found"
)
with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)):
with pytest.raises(SystemExit) as exc_info:
cmd_collect(args)
assert exc_info.value.code == 1
+43
View File
@@ -0,0 +1,43 @@
"""Package directory permissions must not inherit a builder's private umask."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
@pytest.mark.parametrize("mask", ["077", "022"])
def test_stage_is_publicly_traversable_under_any_builder_umask(tmp_path, mask):
root = Path(__file__).resolve().parent.parent
for directory in ("packaging", "scripts", "src", "units", "polkit"):
shutil.copytree(root / directory, tmp_path / directory,
ignore=shutil.ignore_patterns("__pycache__", "*.egg-info"))
for filename in ("LICENSE", "requirements.txt"):
shutil.copy(root / filename, tmp_path / filename)
(tmp_path / "dist").mkdir()
(tmp_path / "dist/fenris-9.9.9-py3-none-any.whl").touch()
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
# Avoid downloading dependencies: emulate pip's target output while
# exercising the real staging script and its package directory layout.
pip_stub = bin_dir / "python3"
pip_stub.write_text(
f"#!{sys.executable}\n"
"import sys\nfrom pathlib import Path\n"
"target = Path(sys.argv[sys.argv.index('--target') + 1]) / 'fenris'\n"
"target.mkdir(parents=True)\n"
"(target / '__init__.py').write_text('')\n"
)
pip_stub.chmod(0o755)
subprocess.run(
["bash", "-c", f"umask {mask}; exec bash packaging/stage.sh 9.9.9"],
cwd=tmp_path, env={**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"]},
check=True, capture_output=True, text=True,
)
stage = tmp_path / "build/stage"
for directory in (stage, *(p for p in stage.rglob("*") if p.is_dir())):
assert directory.stat().st_mode & 0o777 == 0o755, directory
assert (stage / "usr/bin/fenris").stat().st_mode & 0o777 == 0o755
assert (stage / "opt/fenris/vendor/fenris/__init__.py").stat().st_mode & 0o444 == 0o444
+589 -8
View File
@@ -87,13 +87,38 @@ def _find_package(fmt: str) -> Path:
candidate = DIST_DIR / f"fenris_{version}_amd64.deb"
elif fmt == "rpm":
candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm"
elif fmt == "xbps":
candidate = DIST_DIR / f"fenris-{version}_1.x86_64.xbps"
else:
raise ValueError(f"Unknown format: {fmt}")
if not candidate.exists():
pytest.skip(f"Package not found: {candidate} — run `make package` first")
pytest.skip(f"Package not found: {candidate} — run `make package-xbps` first")
return candidate
def test_runit_logger_uses_accounts_shipped_by_package():
"""The runit logger must use the package's group-only identity.
The package declares a ``fenris`` group for store/log access, not a
``fenris`` service user. Starting the logger with ``fenris:fenris``
therefore leaves the diagnostics supervisor down on a real Void host;
Void's standard ``nobody`` account supplies the unprivileged uid.
"""
logger = (REPO_ROOT / "units" / "runit" / "fenris-collect" / "log" / "run").read_text()
sysusers = (REPO_ROOT / "packaging" / "sysusers.d" / "fenris.conf").read_text()
assert "g fenris -" in sysusers
assert "chpst -u nobody:fenris" in logger
def test_xbps_publisher_verifies_embedded_repository_signature():
"""Publication verification must match XBPS's repository layout."""
publisher = (REPO_ROOT / "scripts" / "xbps-publish.sh").read_text()
assert '"x86_64-repodata"' in publisher
assert '"x86_64-repodata.sig2"' not in publisher
# ---------------------------------------------------------------------------
# Matrix definitions
# ---------------------------------------------------------------------------
@@ -109,7 +134,11 @@ RPM_TARGETS = [
("opensuse/tumbleweed", "rpm"),
]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
XBPS_TARGETS = [
("ghcr.io/void-linux/void-glibc-full:latest", "xbps"),
]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + XBPS_TARGETS
# ---------------------------------------------------------------------------
@@ -143,6 +172,88 @@ def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
""")
def _build_xbps_dockerfile(image: str, pkg_name: str) -> str:
"""Dockerfile for testing xbps installation."""
return textwrap.dedent(f"""\
FROM {image}
RUN xbps-install -Sy python3 smartmontools runit shadow && \\
xbps-remove -O
COPY dist/{pkg_name} /pkg/{pkg_name}
""")
def _xbps_extract_and_install(container: str, pkg_name: str, *, action: str = "install") -> tuple[int, str]:
"""Extract xbps package and run lifecycle scripts in container.
action: "install" (fresh), "upgrade" (UPDATE=yes), "remove", or "purge"
"""
# xbps packages are zstd-compressed tar archives containing:
# ./INSTALL, ./REMOVE, ./files.plist, ./props.plist, ./usr/*, ./etc/*
extract = (
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf -"
)
# Ensure target directories exist for manual extraction
_container_exec(container, "mkdir -p /usr/libexec/fenris /usr/lib/systemd/system "
"/etc/sv/fenris-collect/log /usr/share/polkit-1/actions "
"/usr/lib/sysusers.d /usr/lib/tmpfiles.d /opt/fenris /etc/fenris")
if action == "install":
commands = (
f"{extract} && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
"sh INSTALL post fenris 0.3.5 no '' x86_64"
)
elif action == "upgrade":
commands = (
f"{extract} && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
# Do NOT copy fenris.conf — simulates noreplace behavior
"sh INSTALL post fenris 0.3.5 yes '' x86_64"
)
elif action == "remove":
commands = (
f"{extract} && "
"sh REMOVE pre fenris 0.3.5 no '' x86_64 && "
"rm -f /usr/bin/fenris && "
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
"rm -rf /opt/fenris && "
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
)
elif action == "purge":
commands = (
f"{extract} && "
"sh REMOVE purge fenris 0.3.5 no '' x86_64 && "
"rm -f /usr/bin/fenris && "
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
"rm -rf /opt/fenris && "
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
)
else:
raise ValueError(f"Unknown action: {action}")
return _container_exec(container, commands)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -262,7 +373,8 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
]
assert not owned, f"Store contents owned by RPM (should not be): {owned}"
# Timer is disabled and inactive (dormant)
# Timer is disabled and inactive (dormant) — systemd only
if fmt != "xbps":
rc, out = _container_exec(
container, "systemctl is-enabled fenris-collect.timer 2>/dev/null || echo disabled"
)
@@ -275,6 +387,13 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
assert "inactive" in out.lower() or "dead" in out.lower() or rc != 0, \
f"Timer should be inactive (dormant), got: {out}"
# runit: service is dormant (down marker present, no symlink)
if fmt == "xbps":
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Runit service not marked dormant (down file missing)"
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Runit service symlink should not exist (dormant)"
# No hand-rolled manifest
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
assert rc != 0, "Legacy manifest.txt should not exist in package install"
@@ -347,6 +466,23 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || "
"apt-get install -f -y 2>&1 || true",
)
elif fmt == "xbps":
# XBPS: extract, install files, run INSTALL post with UPDATE=yes
_container_exec(
container,
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
"sh INSTALL post fenris 0.3.5 yes '' x86_64",
)
else:
# RPM: invoke all three scriptlets in upgrade sequence
# preun ($1=1): should be no-op (only daemon-reload)
@@ -735,6 +871,8 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
# Write Dockerfile
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -745,7 +883,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
# Run container
@@ -759,6 +897,10 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
)
try:
if fmt == "xbps":
# XBPS: manually extract and run lifecycle scripts
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
_assert_dormant_layout(container, fmt, version)
finally:
subprocess.run(
@@ -797,6 +939,15 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
# Plant make-install remnant BEFORE installing
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
""")
elif fmt == "xbps":
dockerfile = textwrap.dedent(f"""\
FROM {image}
RUN xbps-install -Suyn void-repo-nonfree && \\
xbps-install -Syun python3 smartmontools runit && \\
xbps-remove -Oy void-repo-nonfree || true
COPY dist/{pkg_name} /pkg/{pkg_name}
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
""")
else:
if image.startswith("opensuse/"):
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
@@ -816,7 +967,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-guard-{os.getpid()}"
@@ -829,6 +980,16 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
)
try:
if fmt == "xbps":
# XBPS: run the INSTALL pre script to check for remnants
rc, out = _container_exec(
container,
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 2>&1 || true",
)
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
f"Migration guard did not trigger: {out}"
else:
_assert_migration_guard(container, fmt, pkg_name)
finally:
subprocess.run(
@@ -858,6 +1019,8 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -867,7 +1030,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
_container_cmd("build", "-t", tag, str(build_dir)),
check=True,
capture_output=True,
timeout=300,
timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-upgrade-{os.getpid()}"
@@ -880,6 +1043,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
)
try:
if fmt == "xbps":
_assert_upgrade_semantics(container, fmt, pkg_name, version)
else:
_assert_upgrade_semantics(container, fmt, pkg_name, version)
# RPM-specific: verify config survives fresh install (noreplace)
@@ -1004,6 +1170,8 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
if fmt == "deb":
dockerfile = _build_deb_dockerfile(image, pkg_name)
elif fmt == "xbps":
dockerfile = _build_xbps_dockerfile(image, pkg_name)
else:
dockerfile = _build_rpm_dockerfile(image, pkg_name)
(build_dir / "Dockerfile").write_text(dockerfile)
@@ -1011,7 +1179,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=300,
check=True, capture_output=True, timeout=600 if fmt == "xbps" else 300,
)
container = f"fenris-upgrade-no-disable-{os.getpid()}"
@@ -1024,7 +1192,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
try:
_setup_store_and_config(container)
if fmt == "deb":
if fmt == "xbps":
# Fresh install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS fresh install failed: {out}"
# Upgrade — should NOT run REMOVE pre (no sanctioned disable)
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
assert rc == 0, f"XBPS upgrade failed: {out}"
elif fmt == "deb":
# Fake older version so dpkg treats reinstall as upgrade
_container_exec(
container,
@@ -1436,3 +1611,409 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
subprocess.run(
_container_cmd("rm", "-f", container), capture_output=True,
)
# ---------------------------------------------------------------------------
# Tests — XBPS/Void Linux lifecycle (issue #85)
# ---------------------------------------------------------------------------
def _assert_runit_dormant_layout(container: str, version: str) -> None:
"""Assert the dormant-install contract for runit-based systems (XBPS)."""
# Version-neutral vendored runtime exists
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
assert "OK" in out, "Bundled runtime not found at /opt/fenris"
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
assert rc == 0, "Fenris runtime package not found"
rc, _ = _container_exec(container, "test ! -e /opt/fenris/bin/python3")
assert rc == 0, "Package must not ship a copied Python interpreter"
# Wrapper on PATH
rc, out = _container_exec(container, "command -v fenris")
assert rc == 0, f"fenris not on PATH: {out}"
# Wrapper is executable
rc, _ = _container_exec(container, "test -x /usr/bin/fenris")
assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris"
# Version string in wrapper
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
assert "OK" in out, f"Version {version} not found in wrapper script"
# CLI runs successfully
rc, out = _container_exec(container, "fenris status")
assert rc == 0, f"Installed CLI cannot run: {out}"
# Helpers in /usr/libexec/fenris
for helper in ("fenris-monitor", "fenris-collect"):
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
assert rc == 0, f"{helper} not found or not executable"
# systemd units present (shipped for dual-init support)
for unit in ("fenris-collect.timer", "fenris-collect.service"):
rc, _ = _container_exec(container, f"test -f /usr/lib/systemd/system/{unit}")
assert rc == 0, f"{unit} not found"
# Polkit policy
rc, _ = _container_exec(
container,
"test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy",
)
assert rc == 0, "Polkit policy not found"
# sysusers and tmpfiles fragments
rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf")
assert rc == 0, "sysusers fragment not found"
rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf")
assert rc == 0, "tmpfiles fragment not found"
# Placeholder-commented config
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
assert rc == 0, "fenris.conf not found"
assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \
"Config does not appear to be placeholder-commented"
# fenris group exists
rc, out = _container_exec(container, "getent group fenris")
assert rc == 0, "fenris group not created"
# Observation store directory
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
assert rc == 0, "Observation store directory not created"
parts = out.strip().split()
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
# runit: service is dormant (down marker present, no symlink)
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Runit service not marked dormant (down file missing)"
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Runit service symlink should not exist (dormant)"
# No hand-rolled manifest
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
assert rc != 0, "Legacy manifest.txt should not exist in package install"
@pytest.mark.slow
def test_xbps_dormant_install(skip_no_docker, version):
"""Install XBPS package in Void container, assert dormant runit layout."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "dist").mkdir(exist_ok=True)
subprocess.run(
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
check=True,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
)
tag = "fenris-test-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-test-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Exercise XBPS itself, including its file ownership and lifecycle hooks.
# The temporary local repository does not need release signatures.
rc, out = _container_exec(
container,
f"mkdir -p /tmp/fenris-repo && cp /pkg/{pkg_name} /tmp/fenris-repo/ && "
f"xbps-rindex -a /tmp/fenris-repo/{pkg_name} && "
"xbps-install -y -R /tmp/fenris-repo fenris",
)
assert rc == 0, f"XBPS install failed: {out}"
rc, out = _container_exec(container, "xbps-query -p pkgver fenris")
assert rc == 0 and out.strip() == f"fenris-{version}_1"
_assert_runit_dormant_layout(container, version)
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_migration_guard(skip_no_docker, version):
"""Assert XBPS install aborts on make-install remnants."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-guard"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "dist").mkdir(exist_ok=True)
subprocess.run(
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
check=True,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
)
tag = "fenris-guard-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-guard-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Plant make-install remnant
_container_exec(container, "mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt")
# Attempt install — should fail with migration pointer
rc, out = _container_exec(container, f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
"sh INSTALL pre fenris 0.3.5 no '' x86_64 2>&1 || true")
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
f"Migration guard did not trigger: {out}"
# Clean up marker
_container_exec(container, "rm -f /var/lib/fenris/manifest.txt")
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_upgrade_semantics(skip_no_docker, version):
"""Assert XBPS upgrade snapshots store, migrates, preserves config."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-upgrade"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "dist").mkdir(exist_ok=True)
subprocess.run(
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
check=True,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
)
tag = "fenris-upgrade-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-upgrade-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Create observation store
_container_exec(
container,
"mkdir -p /var/lib/fenris && "
"python3 -c \""
"import sqlite3; "
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
"c.execute('PRAGMA user_version=1'); "
"c.commit(); c.close()\"",
)
# Fresh install
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS fresh install failed: {out}"
# Modify config
_container_exec(
container,
"echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
)
# Upgrade
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
assert rc == 0, f"XBPS upgrade failed: {out}"
# Criterion 1: snapshot exists
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db.bak")
assert rc == 0, "Observation store snapshot not created on upgrade"
# Store not rebuilt
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db")
assert rc == 0, "Observation store missing after upgrade"
rc, out = _container_exec(container, "stat -c '%a' /var/lib/fenris")
assert rc == 0, "Store directory missing after upgrade"
assert out.strip() == "2770", (
f"Store directory mode changed during upgrade: {out.strip()}"
)
# Criterion 2: config survives
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
assert rc == 0, "Config file missing after upgrade"
assert "nvme0n1" in out, "Modified config not preserved after upgrade"
# Criterion 4: files still exist (no-op removal)
rc, _ = _container_exec(container, "test -x /usr/libexec/fenris/fenris-monitor")
assert rc == 0, "Helper removed during upgrade"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_remove_preserves_config_and_store(skip_no_docker, version):
"""XBPS remove keeps config, store, and group."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-remove"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "dist").mkdir(exist_ok=True)
subprocess.run(
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
check=True,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
)
tag = "fenris-remove-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-remove-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
# Plant store and config
_setup_store_and_config(container)
# Remove (pre action only — preserves config and store)
rc, out = _xbps_extract_and_install(container, pkg_name, action="remove")
assert rc == 0, f"XBPS remove failed: {out}"
# Config survives
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
assert rc == 0, "Config should survive XBPS remove"
# Store survives
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc == 0, "Store directory should survive XBPS remove"
for name in ("observations.db", "observations.db.bak"):
rc, _ = _container_exec(container, f"test -f /var/lib/fenris/{name}")
assert rc == 0, f"Store file {name} should survive XBPS remove"
# Group survives
rc, _ = _container_exec(container, "getent group fenris")
assert rc == 0, "Group should survive XBPS remove"
# Service symlink removed (dormant marker set)
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
assert rc == 0, "Service symlink should be removed"
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
assert rc == 0, "Down marker should be set after remove"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
@pytest.mark.slow
def test_xbps_purge_removes_everything(skip_no_docker, version):
"""XBPS purge removes config, store, backup, and group."""
pkg = _find_package("xbps")
pkg_name = pkg.name
build_dir = REPO_ROOT / "build" / "test-container-xbps-purge"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "dist").mkdir(exist_ok=True)
subprocess.run(
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
check=True,
)
(build_dir / "Dockerfile").write_text(
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
)
tag = "fenris-purge-xbps"
subprocess.run(
_container_cmd("build", "-t", tag, str(build_dir)),
check=True, capture_output=True, timeout=600,
)
container = f"fenris-purge-xbps-{os.getpid()}"
subprocess.run(
_container_cmd("run", "-d", "--name", container,
"--tmpfs", "/tmp:exec,size=64m",
tag, "sleep", "infinity"),
check=True, capture_output=True,
)
try:
# Install first
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
assert rc == 0, f"XBPS install failed: {out}"
# Plant store and config
_setup_store_and_config(container)
# Purge
rc, out = _xbps_extract_and_install(container, pkg_name, action="purge")
assert rc == 0, f"XBPS purge failed: {out}"
# Config removed
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
assert rc != 0, "Config should be removed by purge"
# Store removed
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
assert rc != 0, "Store directory should be removed by purge"
# Group removed
rc, _ = _container_exec(container, "getent group fenris 2>/dev/null || true")
assert rc != 0, "Group should be removed by purge"
# Service directory removed
rc, _ = _container_exec(container, "test -d /etc/sv/fenris-collect")
assert rc != 0, "Service directory should be removed by purge"
finally:
subprocess.run(
_container_cmd("rm", "-f", container),
capture_output=True,
)
+8 -8
View File
@@ -3,7 +3,7 @@
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Chalktone default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence
@@ -72,9 +72,9 @@ class TestPreferencePath:
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
def test_default_theme_is_chalktone(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
assert DEFAULT_THEME == "chalktone"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
@@ -82,13 +82,13 @@ class TestDefaults:
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
assert VALID_THEMES == {"chalktone", "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["theme"] == "chalktone"
assert prefs["reduced_motion"] is False
@@ -151,7 +151,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
@@ -165,7 +165,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
@@ -218,7 +218,7 @@ class TestSafeFailures:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["theme"] == "chalktone"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
+53
View File
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
class TestPrecedence:
def test_no_baseline_unavailable(self, store):
_insert_segment(store)
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
def test_unavailable_no_baseline(self, store):
_insert_segment(store)
_open_period(store)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
@@ -138,6 +166,7 @@ class TestConfidenceRuleTable:
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no finite projection" in f for f in result.contributing_facts)
@@ -150,12 +179,14 @@ class TestConfidenceRuleTable:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=1024*1024*100)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
def test_limited_degraded_identity(self, store):
_insert_baseline(store, tbw_tb=1.0, verified=True)
_insert_complete_local_days(store, "2026-09-29", 1)
_insert_segment(store, degraded=True)
_open_period(store)
for i in range(20):
@@ -201,6 +232,7 @@ class TestZeroRate:
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert any("no finite projection from this history" in f for f in result.contributing_facts)
@@ -385,6 +417,7 @@ class TestSustainedRegimeRate:
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
@@ -447,6 +480,7 @@ class TestHabitChange:
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw_high)
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-07", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
assert result.habit_change_fact is not None
assert "usage habit changed" in result.habit_change_fact
@@ -468,6 +502,7 @@ class TestHabitChange:
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw_low)
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-07", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
assert result.habit_change_fact is not None
assert "usage habit changed" in result.habit_change_fact
@@ -524,6 +559,7 @@ class TestHabitChange:
d = (datetime(2026, 9, 25) + 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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
@@ -544,6 +580,7 @@ class TestWarmingGate:
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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.warming_fact is not None
assert "warming up" in result.warming_fact
@@ -560,6 +597,7 @@ class TestWarmingGate:
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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.warming_fact is not None
assert "warming up" in result.warming_fact
@@ -589,6 +627,7 @@ class TestWarmingGate:
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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.warming_fact is not None
@@ -602,6 +641,7 @@ class TestWarmingGate:
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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
# Should have warming fact but still render
assert result.warming_fact is not None
@@ -614,6 +654,7 @@ class TestWarmingGate:
_insert_segment(store)
_open_period(store)
_insert_day(store, "2026-09-28", bw=100*1024*1024)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
@@ -626,6 +667,7 @@ class TestWarmingGate:
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
@@ -646,6 +688,7 @@ class TestStalenessDrop:
d = (datetime(2026, 9, 1) + 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)
_insert_complete_local_days(store, "2026-09-29", 1)
# Clock is 3 days after last data → staleness > 48h
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
@@ -661,6 +704,7 @@ class TestStalenessDrop:
d = (datetime(2026, 9, 1) + 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)
_insert_complete_local_days(store, "2026-09-29", 1)
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
assert result.staleness_fact is not None
@@ -703,6 +747,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-19", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
# Prior days exist in store but projection uses current segment
# 5 days in segment → regime_days = 5
@@ -727,6 +772,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-27", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
# Young regime (3 days) → Limited, not enough data for full confidence
assert result.confidence_state == ConfidenceState.LIMITED
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-27", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
# Prior history quarantined; only 3 days in new segment
assert result.regime_days is not None
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
_insert_segment(store, identity_key=None, degraded=True)
_open_period(store)
_insert_day(store, "2026-09-28", bw=100*1024*1024)
_insert_complete_local_days(store, "2026-09-29", 1)
# No baseline → Unavailable
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.UNSUPPORTED
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
d = (datetime(2026, 9, 1) + 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)
_insert_complete_local_days(store, "2026-09-29", 1)
result = compute_projection(store, _clock())
assert result.confidence_state == ConfidenceState.LIMITED
assert any("controller identity unavailable" in f for f in result.contributing_facts)
@@ -802,6 +851,7 @@ class TestDegradedIdentity:
d = (datetime(2026, 9, 1) + 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)
_insert_complete_local_days(store, "2026-09-29", 1)
# Stale clock (>48h)
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
# Prior history quarantined; only 5 days in new segment
assert result.regime_days is not None
@@ -880,6 +931,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
assert result.regime_days is not None
assert result.regime_days <= 5
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
_insert_complete_local_days(store, "2026-09-25", 1)
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
# All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None
+1
View File
@@ -622,6 +622,7 @@ class TestProjectionInStatus:
from fenris.status import get_status
nonexistent = tmp_path / "nonexistent.db"
nonexistent.write_bytes(b"not a SQLite database")
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
+13 -13
View File
@@ -587,7 +587,7 @@ class TestCLIStatusRendering:
newer_schema=None)
cli_text = render_status_cli(comp)
assert "observation store unreadable" in cli_text
assert "see journal" in cli_text.lower()
assert "see collector logs" in cli_text.lower()
conn.close()
@@ -653,7 +653,7 @@ class TestTUIStatusRendering:
comp = compose_status(conn, svc, now, store_fault="observation store unreadable",
newer_schema=None)
tui_text = render_status_tui(comp)
assert "observation store unreadable" in tui_text
assert "Observation store unreadable" in tui_text
conn.close()
@@ -870,7 +870,7 @@ class TestRefreshTiming:
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=10)).isoformat()
ts = (now - timedelta(minutes=4)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
@@ -878,27 +878,27 @@ class TestRefreshTiming:
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 600,
"last_collect_ok": True, "last_collect_age_s": 240,
"last_collect_reason": None,
}
# Sample is 10 min old (600s) → fresh (within 690s threshold)
# Sample is 4 min old (240s) → fresh (within 450s threshold)
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
assert comp1.freshness == "fresh"
assert comp1.state == StatusState.MONITORING
# At now + 1 min → sample is 11 min old (660s) → still fresh
now2 = now + timedelta(minutes=1)
# At now + 3 min → sample is 7 min old (420s) → still fresh
now2 = now + timedelta(minutes=3)
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
assert comp2.freshness == "fresh"
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
now3 = now + timedelta(minutes=2)
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
now3 = now + timedelta(minutes=5)
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
assert comp3.freshness == "missed"
assert comp3.state == StatusState.MONITORING
# At now + 49 hours → sample is 49h 10min old → stale
# At now + 49 hours → sample is 49h 4min old → stale
now4 = now + timedelta(hours=49)
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
assert comp4.freshness == "stale"
@@ -966,7 +966,7 @@ class TestTUIIntegration:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
@@ -992,7 +992,7 @@ class TestTUIIntegration:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
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,
@@ -1019,7 +1019,7 @@ class TestTUIIntegration:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": False, "last_collect_age_s": 60,
"last_collect_reason": "exit code 3",
+150
View File
@@ -0,0 +1,150 @@
"""Exercise shared status acquisition through the CLI and running TUI."""
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.status import get_status, get_status_composition, read_status
from fenris.status_composition import StatusState
from fenris.store import init_store, SCHEMA_VERSION
from fenris.tui import FenrisTuiApp
NOW = datetime(2026, 9, 16, 12, tzinfo=timezone.utc)
ACTIVE = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
DISABLED = {"boot_enabled": False, "timer_active": False, "last_collect_ok": None}
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["missing", "corrupt", "newer", "incomplete", "denied"])
@pytest.mark.parametrize("service", [ACTIVE, DISABLED, {}])
async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
path = tmp_path / "observations.db"
if kind == "corrupt":
path.write_bytes(b"Not a SQLite database")
elif kind == "incomplete":
sqlite3.connect(path).close()
elif kind != "missing":
conn = init_store(path)
if kind == "newer":
conn.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
conn.close()
original_exists = Path.exists
def exists(target):
if kind == "denied" and target == path:
raise PermissionError("Observation store access denied")
return original_exists(target)
before = path.read_bytes() if original_exists(path) else None
with patch("fenris.status.query_service_state", return_value=service), \
patch("fenris.status._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
patch("fenris.tui._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
patch.object(Path, "exists", exists), \
patch("fenris.tui.compute_projection") as projection:
comp = get_status_composition(path, NOW)
cli = get_status(path, NOW).lower()
app = FenrisTuiApp(store_path=path)
async with app.run_test(size=(80, 24)) as pilot:
headline = str(app.query_one("#headline-band").render()).lower()
strip = str(app.query_one("#service-strip").render()).lower()
if kind == "missing":
assert comp.store_fault is None
assert comp.freshness == "empty"
assert "no observations yet" in cli and "no observations yet" in headline
else:
assert comp.state == StatusState.ERROR
assert comp.freshness == "unknown"
phrase = "newer fenris — upgrade fenris" if kind == "newer" else "observation store unreadable"
assert phrase in cli and phrase in headline
if kind != "newer":
assert str(app.query_one("#drive-health").render()) == "[bold]Native collector log[/bold]"
assert not app.query_one("#main-grid").has_class("paused")
await pilot.resize_terminal(60, 18)
await pilot.pause()
assert phrase in str(app.query_one("#live-render").render()).lower()
for text in (cli, strip):
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
if not service:
assert "does not start on next boot" not in text
projection.assert_not_called()
assert (path.read_bytes() if original_exists(path) else None) == before
def test_reader_owns_readonly_snapshot_and_closes_on_error(tmp_path):
path = tmp_path / "observations.db"
writer = init_store(path)
with patch("fenris.status.query_service_state", return_value=ACTIVE) as query:
with pytest.raises(RuntimeError, match="renderer failed"):
with read_status(path, NOW) as (conn, comp):
assert comp.sample_count == 0
assert conn.in_transaction
with pytest.raises(sqlite3.OperationalError, match="readonly"):
conn.execute("DELETE FROM samples")
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-09-16')")
writer.commit()
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
raise RuntimeError("renderer failed")
query.assert_called_once()
with pytest.raises(sqlite3.ProgrammingError, match="closed"):
conn.execute("SELECT 1")
writer.close()
def test_monitoring_query_failure_is_unknown(tmp_path):
path = tmp_path / "observations.db"
init_store(path).close()
with patch("fenris.status.query_service_state", side_effect=OSError("Unavailable")):
comp = get_status_composition(path, NOW)
cli = get_status(path, NOW, query_journal=False)
assert comp.state == StatusState.UNKNOWN
assert comp.boot_enabled is None and comp.timer_active is None
assert "boot: unknown" in cli and "timer: unknown" in cli
assert "does not start on next boot" not in cli
def test_native_systemd_query_failure_is_unknown(tmp_path):
from fenris.init_system import InitSystem
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
patch("fenris.init_system._systemctl_show", return_value={}):
comp = get_status_composition(tmp_path / "missing.db", NOW)
assert comp.state == StatusState.UNKNOWN
assert comp.boot_enabled is None and comp.timer_active is None
@pytest.mark.asyncio
async def test_new_store_fault_clears_paused_views_and_can_recover(tmp_path):
path = tmp_path / "observations.db"
conn = init_store(path)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES ('2026-09-15', '2026-09-16', 'user_disabled')"
)
conn.commit()
conn.close()
with patch("fenris.status.query_service_state", return_value=DISABLED):
app = FenrisTuiApp(store_path=path)
async with app.run_test() as pilot:
assert app.query_one("#main-grid").has_class("paused")
writer = sqlite3.connect(path)
writer.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
writer.close()
app.on_refresh_tick()
await pilot.pause()
assert not app.query_one("#main-grid").has_class("paused")
assert str(app.query_one("#drive-health").render()) == ""
assert "upgrade Fenris" in str(app.query_one("#headline-band").render())
assert app.query_one("#usage-history")._day_data == []
writer = sqlite3.connect(path)
writer.execute("PRAGMA user_version = %d" % SCHEMA_VERSION)
writer.close()
app.on_refresh_tick()
await pilot.pause()
assert app.query_one("#main-grid").has_class("paused")
+9 -9
View File
@@ -2,7 +2,7 @@
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Chalktone is the default; Amber retains its 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
@@ -29,13 +29,13 @@ from fenris.themes import (
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_four_themes_registered(self):
"""All four presets are registered."""
assert len(THEMES) == 4
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
assert THEME_NAMES == {"chalktone", "amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
@@ -44,14 +44,14 @@ class TestThemeRegistry:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
def test_get_theme_invalid_returns_chalktone(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
assert theme.name == "fenris-chalktone"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# Amber theme tests (amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
@@ -179,7 +179,7 @@ class TestGraphColorRoles:
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."""
"""Zero-usage markers use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
+248 -196
View File
@@ -40,23 +40,13 @@ from fenris.status import (
)
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
HistoryGraph,
_format_remaining,
_sparkline,
_habit_bar,
_query_usage_history,
_query_drive_health,
_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,
)
@@ -121,6 +111,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
utc_start=None, utc_end=None, bw=1024*1024*100,
br=0, coverage=0.95, samples=24, complete=True):
"""Insert a local_days row (issue #94 gate prerequisite)."""
if utc_start is None:
utc_start = local_date + "T00:00:00+00:00"
if utc_end is None:
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
conn.execute(
"INSERT INTO local_days "
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
" bytes_written, bytes_read, coverage, sample_count, complete) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(local_date, tz_name, tz_offset, utc_start, utc_end,
bw, br, coverage, samples, complete),
)
conn.commit()
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
"""Insert multiple complete local days to satisfy the issue #94 gate."""
for i in range(count):
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_local_day(conn, d, bw=bw)
# ---------------------------------------------------------------------------
# Unit tests for helpers
# ---------------------------------------------------------------------------
@@ -142,64 +159,6 @@ class TestFormatRemaining:
assert _format_remaining(-100) == "endurance exhausted"
class TestSparkline:
def test_empty(self):
assert _sparkline([]) == ""
def test_single_value(self):
result = _sparkline([100.0])
assert len(result) == 1
def test_multiple_values(self):
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
assert len(result) > 0
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
def test_width_limit(self):
result = _sparkline([1.0] * 100, width=20)
assert len(result) <= 20
class TestHabitBar:
def test_all_active(self):
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
assert "active 100%" in result
def test_mixed(self):
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
assert "active 50%" in result
assert "idle 30%" in result
def test_all_unknown(self):
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
assert "unknown 100%" in result
# ---------------------------------------------------------------------------
# Data query tests
# ---------------------------------------------------------------------------
class TestQueryUsageHistory:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_usage_history(conn)
assert result["num_days"] == 0
assert result["sparkline"] == ""
conn.close()
def test_with_days(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
result = _query_usage_history(conn)
assert result["num_days"] == 14
assert result["sparkline"] != ""
conn.close()
class TestQueryDriveHealth:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
@@ -215,27 +174,6 @@ class TestQueryDriveHealth:
conn.close()
class TestQueryServiceFacts:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
now = _clock()
result = _query_service_facts(conn, now)
assert result["freshness"] == "empty"
conn.close()
def test_fresh_sample(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
result = _query_service_facts(conn, now)
assert result["freshness"] == "fresh"
conn.close()
# ---------------------------------------------------------------------------
# CI-1: Exhaustive state matrix from synthetic stores
# ---------------------------------------------------------------------------
@@ -267,6 +205,7 @@ class TestStateMatrix:
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")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.LIMITED
assert proj.headline_remaining_seconds is not None
@@ -282,6 +221,7 @@ class TestStateMatrix:
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")
_insert_complete_local_days(conn, "2026-09-29", 1)
proj = compute_projection(conn, _clock())
assert proj.confidence_state == ConfidenceState.SUPPORTED
assert proj.headline_remaining_seconds is not None
@@ -397,10 +337,10 @@ class TestDenseScreen:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test() as pilot:
strip = str(app.query_one("#service-strip").render())
assert "boot:" in strip
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
assert "Boot:" in strip
assert "Timer:" in strip
assert "Last collect:" in strip
assert "Freshness:" in strip
# Maker credit now in titlebox only (issue #79)
assert "by Bongbetic" not in strip
@@ -412,22 +352,22 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
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")
rail = str(app.query_one("#action-rail").render())
usage = app.query_one("#activity-panel")
service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail")
quit_rail = app.query_one("#action-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 "r Resume" in str(app.query_one("#action-rail").render())
assert "q quit" not in strip
assert rail == "q QUIT TUI"
assert "q Quit TUI" in rail
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
@@ -446,11 +386,11 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
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,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test(size=(80, 24)) as pilot:
@@ -459,35 +399,29 @@ class TestDenseScreen:
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(
assert paused_banner.region.height >= 2
assert paused_banner.region.y < app.query_one("#activity-panel").region.y
assert app.query_one("#activity-panel").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 "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
assert "q Quit TUI" in footer_text
await pilot.press("q")
assert not app.is_running
subprocess_run.assert_not_called()
@@ -509,11 +443,11 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
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,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test() as pilot:
@@ -539,28 +473,28 @@ class TestDenseScreen:
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
auth_notice = "privileged actions will prompt for authentication (polkit)"
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
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
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
assert auth_notice in str(app.query_one("#auth-notice").render())
# 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())
assert not app.query_one("#auth-notice").display
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
assert not app.query_one("#auth-notice").display
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())
assert auth_notice in str(fresh_app.query_one("#auth-notice").render())
# ---------------------------------------------------------------------------
@@ -595,15 +529,66 @@ class TestDisclosuresAndGreeting:
# ---------------------------------------------------------------------------
class TestFirstRun:
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
with patch.object(app, "_run_helper") as run_helper:
async with app.run_test(size=size) as pilot:
app.on_refresh_tick()
await pilot.press("?")
help_text = str(app.screen.query_one("#help-text").render())
assert "The dashboard does not need sudo" in help_text
assert "sudo fenris monitor resume" in help_text
assert "sudo fenris monitor pause" in help_text
assert "sudo fenris sample" in help_text
assert 'sudo usermod -aG fenris "$USER"' in help_text
assert "log out and back in" in help_text
await pilot.press("end")
visible = " ".join("".join(
ElementTree.fromstring(app.export_screenshot()).itertext()
).split())
assert "Esc Close" in visible
await pilot.press("escape")
assert len(app.screen_stack) == 1
run_helper.assert_not_called()
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
with patch("fenris.control.os.geteuid", return_value=1000), \
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
patch.object(app, "notify") as notify:
app.action_resume()
message = notify.call_args.args[0]
assert "Command not found: pkexec" in message
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
def test_unprivileged_resume_uses_polkit(self, tmp_path):
"""TUI controls use the same authenticated path as the CLI."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
with patch("fenris.control.os.geteuid", return_value=1000), \
patch("fenris.control.subprocess.run") as run, \
patch.object(app, "suspend"), \
patch.object(app, "_refresh"):
run.return_value.returncode = 0
app._run_helper("enable", ["--now"])
run.assert_called_once_with(
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
)
@pytest.mark.asyncio
async def test_first_run_prompt(self, tmp_path):
"""First-run prompt enables timer and opens first period."""
"""First-run prompt makes the keyboard action and boot effect explicit."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
strip = str(app.query_one("#service-strip").render())
assert "no observations yet" in headline.lower()
# The enable hint should mention resume
assert "resume" in headline.lower()
assert "r resume — enable monitoring and future boots" in headline.lower()
assert "r resume" in str(app.query_one("#action-rail").render()).lower()
# ---------------------------------------------------------------------------
@@ -803,12 +788,29 @@ class TestQueryDailyGraphData:
assert result[0]["is_gap"] is True
conn.close()
def test_window_is_consecutive_utc_days_with_explicit_gaps(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_day(conn, "2026-09-14", bw=500)
result = _query_daily_graph_data(
conn, datetime(2026, 9, 16, 12, tzinfo=timezone.utc),
)
assert len(result) == 90
assert result[-3]["day"] == "2026-09-14"
assert result[-2]["day"] == "2026-09-15"
assert result[-2]["is_gap"] is True
assert result[-1]["day"] == "2026-09-16"
assert result[-1]["is_partial"] 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 == []
result = _query_hourly_graph_data(
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
)
assert len(result) == 24
assert all(item["is_gap"] for item in result)
conn.close()
def test_with_hours(self, tmp_path):
@@ -827,22 +829,38 @@ class TestQueryHourlyGraphData:
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"
result = _query_hourly_graph_data(
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
)
assert len(result) == 24
assert result[12]["local_label"] == "12"
assert result[12]["bytes_written"] == 1000
conn.close()
def test_current_hour_is_partial_and_future_hours_are_not_gaps(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(
conn, "2026-09-20",
datetime(2026, 9, 20, 14, 30, tzinfo=timezone.utc),
)
assert result[13]["is_gap"] is True
assert result[14]["is_partial"] is True
assert result[14]["is_gap"] is True
assert result[15]["is_future"] is True
assert result[15]["is_gap"] is False
conn.close()
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# HistoryGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
class TestHistoryGraph:
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()
graph = HistoryGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
@@ -854,14 +872,14 @@ class TestDailyBarGraph:
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
graph = HistoryGraph()
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()
graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
@@ -872,7 +890,7 @@ class TestDailyBarGraph:
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
graph = HistoryGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
@@ -880,13 +898,24 @@ class TestDailyBarGraph:
graph._trim_to_range()
assert len(graph._day_data) == 7
def test_scale_includes_allocated_and_unallocated_writes(self):
graph = HistoryGraph()
graph._all_day_data = [{
"day": "2026-09-16",
"total_bytes": 800,
"allocated_bytes": 500,
"unallocated_bytes": 300,
}]
graph._trim_to_range()
assert graph._max_bytes == 800
# ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75)
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
"""Headless tests for the interactive history plot."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
@@ -902,13 +931,21 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert isinstance(graph, HistoryGraph)
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
assert "Unalloc" in legend
assert "Zero" in legend
range_label = str(app.query_one("#bar-range").render())
assert "14 days" in range_label
assert "UTC" in range_label
plotted = str(app.query_one("#bar-render").render())
assert "Writes (" in legend
assert "UTC" in range_label
assert "─" in plotted
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
@@ -924,25 +961,22 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
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
# The newest day is selected by default; left moves backward.
initial = graph.selected_index
await pilot.press("left")
await pilot.pause()
assert graph.selected_index == 0
assert graph.selected_index == initial - 1
# Right returns to the newest day.
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == initial
@pytest.mark.asyncio
async def test_range_switching(self, tmp_path):
@@ -958,6 +992,7 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
@@ -973,17 +1008,17 @@ class TestBarGraphTUI:
assert graph.range_days == 7
assert len(graph._day_data) == 7
# Press 3 for 28-day range
# Press 3 for 30-day range
await pilot.press("3")
await pilot.pause()
assert graph.range_days == 28
assert len(graph._day_data) == 28
assert graph.range_days == 30
assert len(graph._day_data) == 30
# Press 4 for 90-day range (only 30 days available)
# Exact windows retain gap slots instead of compressing time.
await pilot.press("4")
await pilot.pause()
assert graph.range_days == 90
assert len(graph._day_data) == 30
assert len(graph._day_data) == 90
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
@@ -999,17 +1034,19 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# No selection initially
# Newest day is selected initially.
readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower()
assert "UTC" in readout
assert "GB" in readout
# Select first bar
await pilot.press("right")
# Moving left updates the selected-day readout.
await pilot.press("left")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
@@ -1034,15 +1071,14 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
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
# The newest day is selected automatically.
assert graph.selected_index == len(graph._day_data) - 1
assert graph.view_mode == "daily"
# Enter drill-down
@@ -1052,6 +1088,14 @@ class TestBarGraphTUI:
assert graph.drill_day is not None
assert len(graph._hour_data) == 24
# Five-minute data refreshes retain the selected hour.
graph._hourly_selected = 12
selected_hour = graph._hour_data[12]["hour"]
app._refresh()
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
@@ -1063,8 +1107,9 @@ class TestBarGraphTUI:
"""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:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert isinstance(graph, HistoryGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@@ -1073,8 +1118,9 @@ class TestBarGraphTUI:
"""Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
graph = app.query_one("#usage-history")
assert graph.border_title == "usage history"
assert app.query_one("#activity-panel").border_title == "Drive activity"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
@@ -1090,13 +1136,11 @@ class TestBarGraphTUI:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
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
for meaning in ("Writes", "Unalloc", "Gap", "Zero", "Partial"):
assert meaning in legend
# ---------------------------------------------------------------------------
@@ -1121,6 +1165,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
@@ -1129,8 +1174,8 @@ class TestConstrainedLayout:
# 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"
summary = app.query_one("#bar-render")
assert "Graph needs" not in str(summary.render())
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
@@ -1147,17 +1192,18 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# 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 = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "Graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
assert "GB" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
@@ -1174,12 +1220,13 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot:
await pilot.press("v", "v")
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "Graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
@@ -1196,11 +1243,12 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Select a day through the visible graph while constrained.
await pilot.press(*(["left"] * 8))
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
@@ -1234,15 +1282,14 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.press("v", "v")
# 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
assert graph.selected_index == len(graph._day_data) - 1
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
@@ -1254,9 +1301,9 @@ class TestConstrainedLayout:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "Graph needs ≥80×24" in summary_text
# Selected day context should survive
assert "2026-09" in summary_text
@@ -1265,10 +1312,11 @@ class TestConstrainedLayout:
"""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:
await pilot.press("v", "v")
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary = app.query_one("#bar-render")
summary_text = str(summary.render())
assert "graph needs ≥80×24" in summary_text
assert "Graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@pytest.mark.asyncio
@@ -1286,25 +1334,26 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# 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
assert app.query_one("#action-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
strip = str(app.query_one("#action-rail").render())
assert "p Pause" in strip
assert "r Resume" in strip
assert "q Quit TUI" in strip
# Quit rail should be visible
rail = str(app.query_one("#quit-rail").render())
assert "QUIT" in rail
rail = str(app.query_one("#action-rail").render())
assert "Quit" in rail
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
@@ -1319,6 +1368,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
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()
@@ -1338,6 +1388,7 @@ class TestConstrainedLayout:
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
await pilot.press("v", "v")
# Quit should still work
await pilot.press("q")
assert not app.is_running
@@ -1347,6 +1398,7 @@ class TestConstrainedLayout:
"""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:
await pilot.press("v", "v")
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
@@ -1366,8 +1418,8 @@ class TestConstrainedLayout:
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
"""HistoryGraph._is_constrained checks terminal width."""
graph = HistoryGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
+1 -1
View File
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
OnUnitInactiveSec=3min
AccuracySec=30s
Persistent=no
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# fenris-collect runit log script — service output to /var/log/fenris-collect/
#
# Runit automatically pipes the service's stdout/stderr to this logger's stdin.
# The logger writes to runit's log directory for diagnostics.
#
# Spec: §8.8 (actionable native diagnostics)
# The package creates the fenris group for shared diagnostics access; it does
# not create a service user. Use Void's standard unprivileged account while
# giving the logger the declared group identity.
exec chpst -u nobody:fenris \
svlogd -tt /var/log/fenris-collect/
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# fenris-collect runit run script — periodic NVMe collection scheduler.
#
# Runit service layout:
# /etc/sv/fenris-collect/run — this script (scheduler)
# /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
# /var/service/fenris-collect — symlink to enable
# /etc/sv/fenris-collect/down — marker for dormant install
#
# Guarantees (must match systemd timer semantics):
# - Initial 2-minute boot delay (sleep 120 before first collect)
# - Completion-relative 3-minute cadence (sleep 180 after collect)
# - Bounded execution (timeout 90s on each collect)
# - No catch-up (fixed sleep interval, no Persistent=)
# - Serialized runs (runsv does not restart until exit)
# - Serialized on-demand (flock serializes fenris-collect execution)
#
# Spec: §8.4, §8.5, §8.6, ADR 0008
set -eu
COLLECT_TIMEOUT=90
BOOT_DELAY=120
CADENCE=180
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
# Ensure lock directory exists
mkdir -p "$(dirname "$LOCK_FILE")"
# Initial boot delay: sleep before first collection
sleep "$BOOT_DELAY"
# Collection loop: collect, then sleep for cadence
while true; do
flock --nonblock "$LOCK_FILE" \
timeout "$COLLECT_TIMEOUT" nice ionice -c3 \
/usr/libexec/fenris/fenris-collect \
2>&1 || true
sleep "$CADENCE"
done