Compare commits

..
Author SHA1 Message Date
xavierk 8de9fe4ee1 prototype: titlebox health layout and presets 2026-09-14 02:00:16 +05:30
68 changed files with 1162 additions and 11046 deletions
+6 -121
View File
@@ -8,12 +8,6 @@ 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:
@@ -64,27 +58,10 @@ 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 }}
@@ -110,26 +87,6 @@ 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
@@ -159,7 +116,6 @@ 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
@@ -203,54 +159,6 @@ 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 }}
@@ -266,18 +174,6 @@ 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}" \
@@ -286,11 +182,11 @@ jobs:
200)
echo "Release v${VERSION} exists; resynchronizing its notes"
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")"
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
;;
404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY_WITH_FORMATS}")"
--body-file "${RELEASE_BODY}")"
;;
*)
echo "::error::release lookup failed with HTTP ${EXISTING}"
@@ -318,21 +214,10 @@ 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, 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
# Attach deb, rpm, and clearsigned checksums once.
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
"dist/fenris-${VERSION}-1.x86_64.rpm" \
"dist/SHA256SUMS.asc"; do
ASSET_NAME="${FILE##*/}"
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
echo "${ASSET_NAME}: already attached"
-2
View File
@@ -18,5 +18,3 @@ json
src/fenris.egg-info/
.pytest_cache/
.venv/
MagicMock*
<MagicMock*
-5
View File
@@ -1,10 +1,5 @@
## 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`.
-24
View File
@@ -9,30 +9,6 @@ backfill releases from before this changelog.
## [Unreleased]
## [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
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
- Repair retained observation history safely and keep its retention state visible.
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
## [0.3.4] - 2026-09-10
### Added
-21
View File
@@ -1,21 +0,0 @@
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.
+2 -65
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-xbps package generate-test-key sign-rpm sign-xbps checksums clearsign release release-run release-dry-run xbps-publish xbps-publish-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 generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
help:
@echo "Fenris NVMe endurance monitor"
@@ -35,8 +35,6 @@ 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"
@@ -44,8 +42,6 @@ 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 ──────────────────────────────────────────────────────
@@ -74,7 +70,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 2770 $(DATA_DIR)
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
@echo "=== Installing version-neutral runtime packages ==="
@sudo rm -rf $(VENV_DIR)
@@ -93,13 +89,6 @@ 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)/
@@ -112,14 +101,10 @@ 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 ==="
@@ -154,11 +139,6 @@ 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
@@ -174,14 +154,10 @@ 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) ==="
@@ -216,9 +192,6 @@ 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
@@ -284,42 +257,6 @@ 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:
+10 -93
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 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.
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.
---
@@ -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** or **runit**, with a polkit agent (the collector runs as root; elevation is exclusively polkit)
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
@@ -66,46 +66,6 @@ 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).
@@ -124,12 +84,12 @@ The packaging public key is published in-repo — no keyservers. See
### Dormant install
A fresh package install is fully dormant. Its native scheduler is present but
disabled; nothing runs. The only opt-in is the sanctioned toggle:
A fresh package install is fully dormant. Units are present but disabled;
nothing runs. The only opt-in is the sanctioned toggle:
```bash
fenris monitor resume # enable scheduling + open first monitoring period
fenris monitor pause # close the period, disable scheduling
fenris monitor resume # enable timer + open first monitoring period
fenris monitor pause # close the period, disable timer
```
## Development install (make install)
@@ -157,7 +117,6 @@ 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
@@ -174,12 +133,6 @@ 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
@@ -197,7 +150,6 @@ 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
@@ -222,19 +174,6 @@ 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 five 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 |
@@ -242,8 +181,8 @@ is retried at the next interval; it never fabricates missing observations.
| `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 native scheduling and closes the monitoring period. |
| `fenris monitor resume` | Sanctioned enable — enables native scheduling and opens a monitoring period. No confirmation. |
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. |
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
| `fenris baseline set <json>` | CLI-side validation, then polkit-guarded persistence. |
| `fenris baseline clear` | Remove the endurance baseline. |
| `fenris import <path>` | Idempotent single-transaction legacy import. |
@@ -252,33 +191,11 @@ is retried at the next interval; it never fabricates missing observations.
## Reading the dashboard
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.
`fenris` opens the TUI dashboard.
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — the launch notice explains normal-user startup and polkit authentication, then clears on the first refresh. The `?` help screen remains available.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
-21
View File
@@ -1,21 +0,0 @@
# 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
@@ -1,8 +0,0 @@
# 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.
-66
View File
@@ -1,66 +0,0 @@
# 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
@@ -1,71 +0,0 @@
# 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
@@ -1,153 +0,0 @@
# 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 install directly:
```sh
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
```
## 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: MIT
license: Proprietary
depends:
- python3 (>= 3.10)
@@ -38,7 +38,7 @@ contents:
- dst: /var/lib/fenris
type: dir
file_info:
mode: 02770
mode: 2750
group: fenris
scripts:
+23 -45
View File
@@ -12,17 +12,10 @@ 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, init-system-aware reload
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
@@ -34,44 +27,29 @@ 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
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi
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
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
;;
abort-upgrade|abort-install|disappear)
;;
+1 -14
View File
@@ -5,13 +5,6 @@
# 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
@@ -23,10 +16,4 @@ case "${1:-}" in
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
esac
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
systemctl daemon-reload 2>/dev/null || true
+2 -15
View File
@@ -5,27 +5,14 @@
# 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
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
;;
upgrade)
# Never interrupt monitoring on upgrade
-7
View File
@@ -6,13 +6,6 @@ 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 xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
```
## Verify downloads
+24 -52
View File
@@ -8,33 +8,13 @@ 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
systemd-sysusers || true
systemd-tmpfiles --create || true
systemctl daemon-reload || true
elif [ "$1" -ge 2 ]; then
# Upgrade — snapshot, migration, init-system-aware reload
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
@@ -46,32 +26,24 @@ 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
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
# 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
# Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
fi
done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
# Re-apply placement modes (store dir group access, issue #54)
systemd-tmpfiles --create || true
fi
+1 -14
View File
@@ -2,13 +2,6 @@
# 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
@@ -17,10 +10,4 @@ if [ "$1" -eq 0 ]; then
groupdel fenris 2>/dev/null || true
fi
fi
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
systemctl daemon-reload 2>/dev/null || true
+2 -15
View File
@@ -2,25 +2,12 @@
# 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
systemctl stop fenris-collect.timer 2>/dev/null || true
systemctl disable fenris-collect.timer 2>/dev/null || true
fi
# On upgrade ($1 -ge 1): do nothing
-14
View File
@@ -52,14 +52,6 @@ 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.
@@ -82,12 +74,6 @@ 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
@@ -1,70 +0,0 @@
#!/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
@@ -1,30 +0,0 @@
#!/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
@@ -0,0 +1,67 @@
# PROTOTYPE — titlebox, drive health and colour presets (throwaway)
Answers wayfinder ticket **Approve titlebox, health layout and colour presets**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
What concrete layout and preset contract makes Fenris feel polished while
preserving monitoring clarity and accessibility?
## Run
```sh
./run
# then open http://127.0.0.1:8765/prototype/titlebox-health-presets/
```
Or open `index.html` directly.
## What this prototype asks the human to react to
Use the bottom control bar to switch:
- preset: **Amber**, **Nord**, **High Contrast**;
- status: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale,
Unknown;
- terminal size: 80×24 baseline, 140×40 wide, or a constrained/narrow layout;
- title glyph: wolf emoji or fallback text;
- motion: normal blink or reduced motion.
## Contract under test
1. **Title hierarchy** — the only maker-credit surface is the top titlebox,
`🐺 Fenris by Bongbetic`; the old service-strip credit is removed. The
lifespan headline stays a product data surface, not the app title.
2. **Glyph fallback** — when the wolf is unsupported, explicitly fall back to
`Fenris by Bongbetic` rather than rendering tofu or disturbing border width.
3. **Drive health vs settings** — vendor wear lives in **Drive health** beside
temperature/spare/error facts. **Settings** stays read-only for device,
baseline, retention and display preferences.
4. **Palette roles** — themes style chrome, graph and accent colours; status
semantics (green/amber/red/grey plus glyph+text) outrank the theme and are
never the sole carrier.
5. **Display preferences** — preset and reduced-motion preferences are scoped
to the unprivileged TUI user, not `/etc/fenris/fenris.conf` and not the
observation store. Proposed persistence: `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`.
6. **Reduced motion** — normal Monitoring blinks only the dot at the settled
750 ms cadence; reduced motion renders the same `● Monitoring` state steady.
7. **Constrained terminal** — below 80×24, the graph region hides and leaves a
one-line textual summary plus `graph needs ≥80×24`; status, title, drive
health and action/quit affordances stay visible.
## Framework facts verified
Current Textual docs via Context7 library `/websites/textual_textualize_io`:
- `App.register_theme(theme)` registers a theme; setting `App.theme` activates
it. Textual themes generate CSS variables from base colours.
- `$text`, `$text-muted`, `$text-disabled` and `color: auto` exist to maintain
text legibility against theme backgrounds.
- Mouse events expose coordinates relative to the screen or widget; focusable
widgets can be resolved at coordinates, and Pilot can click widgets/offsets
for automated proof of keyboard+mouse paths.
Local repo lockfile still pins `textual==8.2.8`; this prototype is static HTML
so it deliberately does not depend on the Python environment.
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenris titlebox, health layout and presets prototype</title>
<style>
:root{color-scheme:dark;font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}
*{box-sizing:border-box}body{margin:0;background:#06070a;color:var(--text);font:14px/1.35 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}.note{max-width:1180px;margin:18px auto 8px;padding:0 16px;color:#c0c6d0}.terminal{width:min(var(--term-w),calc(100vw - 32px));min-height:var(--term-h);margin:0 auto 108px;background:var(--bg);border:1px solid var(--line);box-shadow:0 24px 80px #000;border-radius:8px;overflow:hidden}.screen{padding:8px}.titlebox{height:38px;border:2px solid var(--accent);display:flex;align-items:center;justify-content:center;font-weight:900;font-size:17px;letter-spacing:.01em;color:var(--title);background:linear-gradient(180deg,var(--title-bg),transparent)}.titlebox .fallback{display:none}body[data-glyph=plain] .titlebox .emoji{display:none}body[data-glyph=plain] .titlebox .fallback{display:inline}.auth{margin-top:6px;color:var(--muted);border-left:4px solid var(--accent);padding:4px 8px;background:var(--panel-soft)}.grid{display:grid;grid-template-columns:3fr 2fr;gap:8px;margin-top:8px}.pane{border:1px solid var(--line);background:var(--panel);padding:9px 11px;min-height:130px}.headline{grid-column:1/-1;min-height:92px}.pane-title{color:var(--muted);margin:-19px 0 6px -2px;background:var(--panel);width:max-content;padding:0 6px}.big{font-size:18px;font-weight:900}.muted{color:var(--muted)}.accent{color:var(--accent)}.status-line{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:5px}.state{font-weight:900}.state .glyph{display:inline-block;width:2ch;text-align:center}.state.monitoring{color:var(--ok)}body:not(.reduced) .state.monitoring .glyph{animation:blink 1.5s steps(1,end) infinite}.state.collecting{color:var(--ok)}.state.paused,.state.waiting{color:var(--warn)}.state.interrupted,.state.error,.state.stale{color:var(--bad)}.state.unknown{color:var(--neutral)}@keyframes blink{50%{opacity:.18}}.pause-banner{display:none;margin-top:8px;border:2px solid var(--warn);background:var(--warn-bg);color:var(--warn-text);padding:7px 10px;font-weight:900}body[data-state=paused] .pause-banner{display:block}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;margin-top:7px}.fact{border:1px solid var(--line-dim);padding:4px 6px;background:var(--panel-soft)}.fact b{color:var(--muted);font-weight:700}.history{min-height:240px}.range{display:flex;gap:5px;align-items:center;flex-wrap:wrap}.pill{border:1px solid var(--line);padding:2px 7px;border-radius:999px;background:var(--panel-soft);color:var(--text)}.pill.active{border-color:var(--accent);color:var(--accent);font-weight:900}.bars{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;align-items:end;height:82px;margin:8px 0 2px;border-bottom:1px solid var(--line)}.bar{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;min-width:0}.bar span{display:block;width:100%;text-align:center}.allocated{color:var(--graph)}.unalloc{color:var(--unalloc)}.gap{color:var(--gap)}.zero{color:var(--zero)}.partial{border-top:2px dashed var(--select)}.selected{outline:1px solid var(--select);outline-offset:1px}.day-row{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;color:var(--muted);font-size:12px;text-align:center}.marker{color:var(--select);text-align:center;letter-spacing:.5ch}.legend{color:var(--muted);font-size:12px;margin-top:7px}.readout{border-top:1px dashed var(--line);padding-top:7px;margin-top:7px}.health{display:grid;grid-template-rows:auto auto;gap:8px}.subbox{border:1px solid var(--line-dim);padding:7px 8px;background:var(--panel-soft)}.subbox h3{margin:0 0 5px;font-size:14px;color:var(--accent)}.settings-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 9px}.setting-control{color:var(--accent);font-weight:900}.service{margin-top:8px;border:1px solid var(--line);background:var(--panel);display:grid;grid-template-columns:1fr auto;gap:8px;padding:8px 10px}.continuity{margin-top:3px}.actions{display:flex;align-items:center;gap:10px;white-space:nowrap}.quit{border:2px solid var(--text);padding:3px 8px;font-weight:900;color:var(--text);background:var(--quit-bg)}.long-reason{display:none;color:var(--bad);margin-top:5px}body[data-state=error] .long-reason,body[data-state=stale] .long-reason,body[data-state=interrupted] .long-reason{display:block}.narrow-only{display:none}.switcher{position:fixed;z-index:10;bottom:14px;left:50%;transform:translateX(-50%);display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center;max-width:calc(100vw - 20px);background:#f5f7fb;color:#151923;border-radius:999px;padding:8px 11px;box-shadow:0 8px 32px #000}.switcher label{font-weight:800}.switcher select,.switcher button{font:700 13px ui-monospace,monospace;border:1px solid #c8ced9;border-radius:999px;background:white;color:#151923;padding:6px 9px}.switcher button.active{background:#151923;color:white}.switcher .check{display:flex;align-items:center;gap:4px}.switcher input{accent-color:#151923}
body[data-preset=amber]{--bg:#0d0b07;--panel:#17120b;--panel-soft:#20180f;--title-bg:#22170a;--title:#f8e1ad;--text:#f3eadb;--muted:#c2ad8a;--line:#8a6432;--line-dim:#5a4022;--accent:#f0b35a;--graph:#f0b35a;--unalloc:#d38f3f;--gap:#6f604e;--zero:#d8c5a3;--select:#f8e08e;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#3a2c0c;--warn-text:#fff3c7;--quit-bg:#2a1e10;--term-w:960px;--term-h:650px}
body[data-preset=nord]{--bg:#0b1118;--panel:#111827;--panel-soft:#162033;--title-bg:#172235;--title:#e5e9f0;--text:#e5e9f0;--muted:#a9b4c8;--line:#4c566a;--line-dim:#354154;--accent:#88c0d0;--graph:#88c0d0;--unalloc:#d08770;--gap:#677285;--zero:#c5ccd8;--select:#ebcb8b;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#2f2a17;--warn-text:#fff0bd;--quit-bg:#172235;--term-w:960px;--term-h:650px}
body[data-preset=high]{--bg:#000;--panel:#000;--panel-soft:#111;--title-bg:#000;--title:#fff;--text:#fff;--muted:#d8d8d8;--line:#fff;--line-dim:#888;--accent:#00ffff;--graph:#ffff00;--unalloc:#ffaf00;--gap:#9b9b9b;--zero:#fff;--select:#00ffff;--ok:#00ff00;--warn:#ffff00;--bad:#ff4040;--neutral:#fff;--warn-bg:#333300;--warn-text:#fff;--quit-bg:#000;--term-w:960px;--term-h:650px}
body[data-width=wide]{--term-w:1180px;--term-h:760px}.wide-note{display:none}body[data-width=wide] .wide-note{display:inline}
body[data-width=narrow]{--term-w:620px;--term-h:600px;font-size:13px}body[data-width=narrow] .grid{grid-template-columns:1fr}body[data-width=narrow] .headline{grid-column:auto}body[data-width=narrow] .facts{grid-template-columns:repeat(2,minmax(0,1fr))}body[data-width=narrow] .bars,body[data-width=narrow] .day-row,body[data-width=narrow] .marker,body[data-width=narrow] .legend{display:none}body[data-width=narrow] .narrow-only{display:block;border:1px dashed var(--line);padding:8px;margin:8px 0;color:var(--warn)}body[data-width=narrow] .history{min-height:auto}body[data-width=narrow] .service{grid-template-columns:1fr}body[data-width=narrow] .actions{white-space:normal;justify-content:space-between}.sr{position:absolute;left:-10000px}
</style>
</head>
<body data-preset="amber" data-state="monitoring" data-width="normal" data-glyph="emoji">
<p class="note"><b>PROTOTYPE:</b> titlebox, health/settings grouping, theme presets and status accessibility for <i>Approve titlebox, health layout and colour presets</i>. Static throwaway; no production code.</p>
<main class="terminal" aria-label="Fenris terminal mockup">
<div class="screen">
<div class="titlebox" aria-label="Application title"><span class="emoji">🐺 Fenris by Bongbetic</span><span class="fallback">Fenris by Bongbetic</span></div>
<div class="auth">privileged actions will prompt for authentication (polkit) <span class="wide-note">· clears after first refresh</span></div>
<section class="pane headline">
<div class="pane-title">lifespan + monitoring</div>
<div class="big">Usage-adjusted theoretical lifespan: <span class="accent">8 yr 103 d remaining</span></div>
<div>if current habits continue · sustained regime: 46 days · scenario range: 7d 12.4 / 28d 11.1 / 90d 10.7 GB/day</div>
<div class="status-line"><div class="state monitoring" id="stateLine"><span class="glyph">●</span><span id="stateLabel">Monitoring</span></div><div id="stateReason">Last sample: 4 min ago · blink means enabled + fresh, not data refresh</div></div>
<div class="long-reason" id="longReason">last run failed (exit 3) · last good sample 4 min ago · collection stopped outside Fenris details stay visible without changing the status vocabulary</div>
<div class="pause-banner">‖ Paused — monitoring paused · paused time excluded from your usage habit</div>
<div class="facts"><div class="fact"><b>Boot</b><br><span id="bootFact">on</span></div><div class="fact"><b>Runtime</b><br><span id="runtimeFact">timer active</span></div><div class="fact"><b>Last collect</b><br><span id="collectFact">ok · 4 min ago</span></div><div class="fact"><b>Freshness</b><br><span id="freshFact">fresh</span></div></div>
</section>
<div class="grid">
<section class="pane history" tabindex="0" aria-label="Usage history graph pane">
<div class="pane-title">usage history</div>
<div class="range"><b>usage history · Local · UTC+05:30 · Asia/Kolkata</b><span class="pill">7</span><span class="pill active">14</span><span class="pill">28</span><span class="pill">90</span></div>
<div class="narrow-only">14-day write history: 9.2–18.6 GB/day · selected Wed 09 Sep · graph needs ≥80×24</div>
<div class="bars" aria-hidden="true">
<div class="bar"><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span><span class="unalloc">▒</span></div><div class="bar selected"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="gap">░</span><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar partial"><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar partial"><span class="allocated">█</span></div>
</div>
<div class="marker">&nbsp;&nbsp;&nbsp;&nbsp;▼</div>
<div class="day-row"><span>27</span><span>28</span><span>29</span><span>30</span><span>31</span><span>01</span><span>02</span><span>03</span><span>04</span><span>05</span><span>06</span><span>07</span><span>08</span><span>09</span></div>
<div class="legend">legend: <span class="allocated">█ allocated</span> · <span class="unalloc">▒ unallocated</span> · <span class="gap">░ gap/no evidence</span> · <span class="zero">· 0 B</span> · ┄ partial · ▼ selected</div>
<div class="readout"><b>Wed 09 Sep</b> · writes 14.2 GB allocated + 2.0 GB unallocated · 21 evidenced hours · coverage 87% · partial · 13 h elapsed · so far<br><span class="muted">Enter/click drills into hourly detail; Esc/Backspace returns. Footer shows graph keys while graph is focused.</span></div>
</section>
<section class="pane health" tabindex="0" aria-label="Drive health and settings pane">
<div class="subbox"><h3>Drive health</h3><b>Samsung SSD 990 PRO</b><br>temperature 41°C · spare 100%<br>media errors 0 · unsafe shutdowns 2<br>power-on 1,184 h · 46 cycles · 2 TB<br><b>vendor wear</b>: 3% used · 18.4 TB written <span class="muted">(context, not a second projection)</span></div>
<div class="subbox"><h3>Settings</h3><div class="settings-grid"><span>device</span><span>/dev/disk/by-id/nvme-Samsung_990</span><span>baseline</span><span>verified override · 1,200 TBW</span><span>retention</span><span>raw 14 d · intervals/hours/days indefinite</span><span>theme</span><span class="setting-control" id="themeText">Amber preset · user preference</span><span>motion</span><span class="setting-control" id="motionText">normal blink</span></div></div>
</section>
</div>
<section class="service" aria-label="Service strip and actions">
<div><b>CONTINUITY</b> <span id="continuity">monitoring: active in background · persists across reboots</span><div class="continuity muted">p pause · r resume · c collect · d disclosures · graph focus: ←/→ select · Enter drill · 1/2/3/4 ranges · t preset · m motion</div></div>
<div class="actions"><span class="quit">q QUIT TUI</span></div>
</section>
</div>
</main>
<nav class="switcher" aria-label="Prototype controls">
<label>preset <select id="preset"><option value="amber">Amber</option><option value="nord">Nord</option><option value="high">High Contrast</option></select></label>
<label>state <select id="state"><option value="monitoring">Monitoring</option><option value="collecting">Collecting</option><option value="paused">Paused</option><option value="waiting">Waiting</option><option value="interrupted">Interrupted</option><option value="error">Error</option><option value="stale">Stale</option><option value="unknown">Unknown</option></select></label>
<label>size <select id="width"><option value="normal">80×24</option><option value="wide">140×40</option><option value="narrow">narrow</option></select></label>
<button id="glyph">wolf title</button>
<label class="check"><input id="motion" type="checkbox"> reduced motion</label>
</nav>
<script>
const states={
monitoring:{glyph:'●',label:'Monitoring',cls:'monitoring',reason:'Last sample: 4 min ago · blink means enabled + fresh, not data refresh',boot:'on',runtime:'timer active',collect:'ok · 4 min ago',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
collecting:{glyph:'◐',label:'Collecting',cls:'collecting',reason:'run in flight (≤90 s) · underlying state: monitoring',boot:'on',runtime:'service activating',collect:'running now',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
paused:{glyph:'‖',label:'Paused',cls:'paused',reason:'monitoring paused — paused time excluded from your usage habit',boot:'off',runtime:'timer inactive',collect:'ok before pause',fresh:'last sample 49 days ago',cont:'monitoring: does not start on next boot'},
waiting:{glyph:'○',label:'Waiting',cls:'waiting',reason:'awaiting another sample · first graph data is independent of lifespan warm-up',boot:'on',runtime:'timer active',collect:'ok · one sample',fresh:'empty / insufficient pair',cont:'monitoring: active in background · persists across reboots'},
interrupted:{glyph:'⊘',label:'Interrupted',cls:'interrupted',reason:'collection stopped outside Fenris — monitoring period still open',boot:'on',runtime:'timer inactive',collect:'unknown stop',fresh:'last sample 3 days ago',cont:'enabled but not currently collecting'},
error:{glyph:'✖',label:'Error',cls:'error',reason:'last run failed (exit 3) · last good sample 4 min ago',boot:'on',runtime:'timer active',collect:'FAILED exit 3',fresh:'fresh data, failed last run',cont:'monitoring: active in background · persists across reboots'},
stale:{glyph:'◌',label:'Stale',cls:'stale',reason:'last sample 3 days ago · timer active, no failure recorded',boot:'on',runtime:'timer active',collect:'ok then silent',fresh:'stale',cont:'monitoring: active in background · persists across reboots'},
unknown:{glyph:'?',label:'Unknown',cls:'unknown',reason:'service state unavailable',boot:'unknown',runtime:'unknown',collect:'unknown',fresh:'store facts unavailable',cont:'service state unavailable'}
};
function apply(){
const preset=document.getElementById('preset').value; const state=document.getElementById('state').value; const width=document.getElementById('width').value;
document.body.dataset.preset=preset; document.body.dataset.state=state; document.body.dataset.width=width;
const s=states[state]; const line=document.getElementById('stateLine'); line.className='state '+s.cls; line.querySelector('.glyph').textContent=s.glyph; document.getElementById('stateLabel').textContent=s.label; document.getElementById('stateReason').textContent=s.reason;
document.getElementById('bootFact').textContent=s.boot; document.getElementById('runtimeFact').textContent=s.runtime; document.getElementById('collectFact').textContent=s.collect; document.getElementById('freshFact').textContent=s.fresh; document.getElementById('continuity').textContent=s.cont;
document.getElementById('themeText').textContent={amber:'Amber preset · user preference',nord:'Nord preset · user preference',high:'High Contrast preset · user preference'}[preset];
const reduced=document.getElementById('motion').checked; document.body.classList.toggle('reduced',reduced); document.getElementById('motionText').textContent=reduced?'reduced motion · steady status dot':'normal blink';
const params=new URLSearchParams({preset,state,width,glyph:document.body.dataset.glyph,motion:reduced?'reduced':'normal'}); history.replaceState(null,'','?'+params);
}
const params=new URLSearchParams(location.search); for(const id of ['preset','state','width']) if(params.get(id)) document.getElementById(id).value=params.get(id)); if(params.get('glyph')==='plain') document.body.dataset.glyph='plain'; if(params.get('motion')==='reduced') document.getElementById('motion').checked=true;
document.getElementById('glyph').onclick=()=>{document.body.dataset.glyph=document.body.dataset.glyph==='emoji'?'plain':'emoji'; document.getElementById('glyph').textContent=document.body.dataset.glyph==='emoji'?'wolf title':'plain title'; apply()};
for(const id of ['preset','state','width','motion']) document.getElementById(id).addEventListener('change',apply);
apply();
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# PROTOTYPE: static local server; no dependencies or persistence.
set -eu
cd "$(git rev-parse --show-toplevel)"
printf '%s\n' 'Open http://127.0.0.1:8765/prototype/titlebox-health-presets/?preset=amber&state=monitoring'
exec python3 -m http.server 8765 --bind 127.0.0.1
+2 -3
View File
@@ -1,9 +1,8 @@
[project]
name = "fenris"
version = "0.3.6"
version = "0.3.4"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.10"
license = {file = "LICENSE"}
requires-python = ">=3.9"
dependencies = [
"textual>=0.40.0",
]
-1
View File
@@ -9,4 +9,3 @@ 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,20 +68,6 @@ 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)
@@ -91,18 +77,6 @@ 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)
@@ -114,10 +88,6 @@ 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)
+31 -10
View File
@@ -7,6 +7,8 @@ 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
@@ -33,16 +35,35 @@ def add_runtime_packages() -> None:
add_runtime_packages()
def run_monitor(*args: str) -> None:
"""Render the shared privileged-action outcome for the CLI."""
from fenris.control import MonitorError, run_monitor as invoke_monitor
def is_root() -> bool:
"""Check if running as root."""
return os.geteuid() == 0
try:
invoke_monitor(*args)
except MonitorError as exc:
print(str(exc), file=sys.stderr)
sys.exit(exc.exit_code)
sys.exit(0)
def run_monitor(*args: str) -> None:
"""Run fenris-monitor with the given arguments.
If not root, re-exec under pkexec.
"""
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
if is_root():
result = subprocess.run([monitor_cmd] + list(args))
sys.exit(result.returncode)
else:
# Use pkexec to elevate
pkexec = subprocess.run(
["which", "pkexec"], capture_output=True
)
if pkexec.returncode != 0:
print(
"Error: No polkit agent available. "
"Run as root: sudo fenris-monitor ...",
file=sys.stderr,
)
sys.exit(1)
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
sys.exit(result.returncode)
def cmd_tui(args: argparse.Namespace) -> None:
@@ -122,7 +143,7 @@ def main() -> None:
description="Fenris NVMe endurance monitor",
)
parser.add_argument(
"--version", action="version", version="%(prog)s 0.3.6"
"--version", action="version", version="%(prog)s 0.3.0"
)
subparsers = parser.add_subparsers(dest="command")
-207
View File
@@ -1,207 +0,0 @@
#!/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 " sudo xbps-install -S https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
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.6"
__version__ = "0.3.1"
+5 -40
View File
@@ -16,8 +16,6 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from .store import init_store, get_store_path
from .monitoring_periods import ensure_period_open
from .derive import find_previous_sample, derive_hours_from_interval
class AcquisitionError(Exception):
@@ -223,11 +221,7 @@ def write_sample(
open_segment(conn, now, identity, identity_key, identity_degraded)
segment_opened = True
# Get current segment_id for provenance
current_segment = find_current_segment(conn)
segment_id = current_segment["id"] if current_segment else None
# Insert sample with segment_id
# Insert sample
cursor = conn.execute(
"""
INSERT INTO samples (
@@ -235,8 +229,8 @@ def write_sample(
percentage_used, available_spare, media_errors, power_on_hours,
power_cycles, unsafe_shutdowns, temperature_c,
data_units_written, data_units_read, bytes_written, bytes_read,
critical_warning, segment_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
critical_warning
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
sample["ts"],
@@ -258,7 +252,6 @@ def write_sample(
sample["bytes_written"],
sample["bytes_read"],
sample["critical_warning"],
segment_id,
),
)
@@ -269,7 +262,6 @@ def write_sample(
"segment_reason": reason,
"identity_key": identity_key,
"identity_degraded": identity_degraded,
"segment_id": segment_id,
}
@@ -311,38 +303,11 @@ def run_collection(
try:
# Ensure monitoring period is open (issue #73 AC2)
ensure_period_open(conn, clock.utcnow())
# Validate invariants
validate_sample_invariants(sample, conn)
# Write sample and get segment info
seg_info = write_sample(sample, identity, conn, clock)
# Derive hour observations from interval with previous sample
try:
# Find the sample we just wrote
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
current_id = cursor.fetchone()[0]
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
if prev is not None:
# Build current sample dict for derivation
current = {
"id": current_id,
"ts": sample["ts"],
"bytes_written": sample["bytes_written"],
"bytes_read": sample["bytes_read"],
"power_on_hours": sample["power_on_hours"],
"temperature_c": sample["temperature_c"],
"data_units_written": sample["data_units_written"],
"data_units_read": sample["data_units_read"],
}
derive_hours_from_interval(conn, prev, current)
except Exception:
# Derivation failure must not prevent sample persistence (issue #73 AC6)
pass
# Write sample
write_sample(sample, identity, conn, clock)
return {
"ok": True,
-56
View File
@@ -1,56 +0,0 @@
"""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,
)
-237
View File
@@ -1,237 +0,0 @@
"""Interval derivation: samples → hour observations → day aggregates.
After each collection run, the collector calls into this module to:
1. Find the previous sample in the same segment
2. Compute deltas (bytes, POH, temperature)
3. Classify the hour(s) the interval spans
4. Write/update hour_observations for each affected hour
5. Update day_aggregates with unattributed cross-hour bytes
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
No proportional allocation, endpoint assignment, or double counting.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
from .hour_classify import classify_hour, HourSplit
def find_previous_sample(
conn: sqlite3.Connection,
segment_id: Optional[int],
current_sample_id: int,
) -> Optional[Dict[str, Any]]:
"""Find the most recent sample before current_sample_id in the same segment.
Returns None if no previous sample exists (first sample in segment).
"""
if segment_id is not None:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? AND segment_id = ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id, segment_id),
)
else:
cursor = conn.execute(
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
" temperature_c, data_units_written, data_units_read "
"FROM samples WHERE id < ? "
"ORDER BY id DESC LIMIT 1",
(current_sample_id,),
)
row = cursor.fetchone()
if row is None:
return None
return {
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7],
}
def _parse_ts(ts: str) -> datetime:
"""Parse ISO timestamp to datetime with UTC."""
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _hour_floor(dt: datetime) -> datetime:
"""Floor a datetime to its UTC hour boundary."""
return dt.replace(minute=0, second=0, microsecond=0)
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
"""Return list of UTC hour boundaries spanned by [start, end)."""
hours = []
h = _hour_floor(start)
while h < end:
hours.append(h)
h += timedelta(hours=1)
return hours
def _compute_sampled_seconds_in_hour(
start: datetime, end: datetime, hour_start: datetime
) -> int:
"""How many seconds of the sample interval fall within this hour."""
hour_end = hour_start + timedelta(hours=1)
effective_start = max(start, hour_start)
effective_end = min(end, hour_end)
if effective_start >= effective_end:
return 0
return int((effective_end - effective_start).total_seconds())
def derive_hours_from_interval(
conn: sqlite3.Connection,
prev_sample: Dict[str, Any],
next_sample: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Derive hour observations from a sample pair interval.
Returns list of hour observation dicts that were written/updated.
"""
prev_ts = _parse_ts(prev_sample["ts"])
next_ts = _parse_ts(next_sample["ts"])
# Deltas
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
hours = _hours_spanned(prev_ts, next_ts)
total_span_s = int((next_ts - prev_ts).total_seconds())
results = []
if len(hours) == 1:
# Same-hour interval: fully attributed to this hour
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = total_span_s
# Classify hour
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=poh_delta_s,
duw_delta=bw_delta,
dur_delta=br_delta,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
bw_delta, br_delta,
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
2, # 2 samples contributed (prev + next)
)
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
elif len(hours) >= 2:
# Cross-hour interval: split wall-clock time, bytes unattributed
for h in hours:
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
# For cross-hour, we classify based on time only (no byte attribution)
# The hour gets its time split but NOT the byte delta
split = classify_hour(
wall_clock_seconds=3600,
poh_delta=0, # POH attribution unknown for cross-hour
duw_delta=0, # Bytes unattributed
dur_delta=0,
sampled_seconds=sampled_s,
)
_upsert_hour_observation(
conn, hour_key, split,
0, 0, # No byte attribution for cross-hour
None, None,
0, # No sample falls IN this hour
)
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
# Track unattributed bytes at day level
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
return results
def _upsert_hour_observation(
conn: sqlite3.Connection,
hour_key: str,
split: HourSplit,
bw_delta: int,
br_delta: int,
temp_min: Optional[int],
temp_max: Optional[int],
sample_count: int,
) -> None:
"""Insert or update an hour observation."""
# Check if hour exists
existing = conn.execute(
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
(hour_key,),
).fetchone()
if existing is None:
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta,
temperature_min, temperature_avg, temperature_max,
sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_key, split.seconds_active, split.seconds_idle,
split.seconds_powered_off, split.seconds_unknown,
bw_delta, br_delta,
temp_min, temp_avg, temp_max,
sample_count, coverage),
)
else:
# Merge: accumulate bytes and sample count
new_bw = existing[1] + bw_delta
new_samples = existing[2] + sample_count
conn.execute(
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
(new_bw, new_samples, existing[0]),
)
conn.commit()
def _add_unattributed_bytes(
conn: sqlite3.Connection,
prev_ts: datetime,
next_ts: datetime,
bw_delta: int,
br_delta: int,
) -> None:
"""Add unattributed byte deltas to day aggregates for each day touched."""
prev_day = prev_ts.strftime("%Y-%m-%d")
next_day = next_ts.strftime("%Y-%m-%d")
days = {prev_day, next_day}
for day in days:
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
).fetchone()
if existing is None:
conn.execute(
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, ?, ?)",
(day, bw_delta, br_delta),
)
else:
conn.execute(
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
(bw_delta, br_delta, day),
)
conn.commit()
-500
View File
@@ -1,500 +0,0 @@
"""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 300 })
/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 5-minute cadence (sleep 300 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)
+54 -13
View File
@@ -15,7 +15,9 @@ 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
@@ -37,11 +39,6 @@ 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")
@@ -73,8 +70,25 @@ def cmd_enable(args: argparse.Namespace) -> None:
else:
print("Monitoring period already open (id=%d)" % open_period["id"])
# Enable the timer (init-system aware)
enable_timer(args.now)
# Enable and start the timer
if args.now:
result = subprocess.run(
["systemctl", "enable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "enable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error enabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer enabled" + (" and started" if args.now else ""))
finally:
conn.close()
@@ -103,8 +117,25 @@ def cmd_disable(args: argparse.Namespace) -> None:
else:
print("No open monitoring period (no-op)")
# Disable the timer (init-system aware)
disable_timer(args.now)
# Disable and stop the timer
if args.now:
result = subprocess.run(
["systemctl", "disable", "--now", "fenris-collect.timer"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
["systemctl", "disable", "fenris-collect.timer"],
capture_output=True,
text=True,
)
if result.returncode != 0:
print("Error disabling timer:", result.stderr, file=sys.stderr)
sys.exit(1)
print("Timer disabled" + (" and stopped" if args.now else ""))
finally:
conn.close()
@@ -112,12 +143,22 @@ def cmd_disable(args: argparse.Namespace) -> None:
def cmd_collect(args: argparse.Namespace) -> None:
"""Trigger on-demand collection.
Starts fenris-collect, blocks until exit, reports outcome.
Starts fenris-collect.service, blocks until exit, reports outcome.
Spec §8.7: fenris sample routes through fenris-monitor → collect,
which blocks until the collection exits; outcome reported synchronously.
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
which blocks until the oneshot exits; outcome reported synchronously.
"""
collect_now()
result = subprocess.run(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Collection completed successfully")
else:
print("Collection failed:", result.stderr, file=sys.stderr)
sys.exit(1)
def cmd_baseline_set(args: argparse.Namespace) -> None:
-98
View File
@@ -1,98 +0,0 @@
"""User-scoped TUI preferences (issue #80).
Persists theme preset and reduced-motion choice per unprivileged user.
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
affect collection, projection, history evidence, helper state, package
config, or CLI status.
Safe failures: invalid/unreadable/unwritable data never crashes the
dashboard, corrupts previous preferences, or affects monitoring.
Failures are understandable rather than silently implying persistence
succeeded.
Criteria: TPH-10, AC80-2, AC80-3.
"""
import json
import os
from pathlib import Path
from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
DEFAULT_REDUCED_MOTION = False
def get_preference_path() -> Path:
"""Return the user-scoped preference file path.
Uses XDG_CONFIG_HOME/fenris/preferences.json.
Falls back to ~/.config/fenris/preferences.json if unset.
"""
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg)
else:
base = Path.home() / ".config"
return base / "fenris" / PREFERENCE_FILE_NAME
def load_preferences() -> Dict[str, Any]:
"""Load user preferences with safe defaults.
Returns a dict with keys:
theme: str (one of VALID_THEMES)
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
"""
path = get_preference_path()
try:
text = path.read_text()
except (OSError, FileNotFoundError):
return _defaults()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return _defaults()
if not isinstance(data, dict):
return _defaults()
theme = data.get("theme", DEFAULT_THEME)
if theme not in VALID_THEMES:
theme = DEFAULT_THEME
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
if not isinstance(reduced_motion, bool):
reduced_motion = DEFAULT_REDUCED_MOTION
return {"theme": theme, "reduced_motion": reduced_motion}
def save_preferences(theme: str = DEFAULT_THEME,
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
"""Save user preferences.
Creates the config directory if needed. If the write fails
(read-only filesystem, permissions), the failure is swallowed —
the TUI continues with whatever was loaded, and the user sees
no crash or error.
"""
path = get_preference_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
path.write_text(payload + "\n")
except (OSError, PermissionError):
# Best-effort persistence — failure must not crash the TUI
pass
def _defaults() -> Dict[str, Any]:
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
+11 -51
View File
@@ -73,7 +73,6 @@ class ScenarioRange:
rates: Dict[int, float]
min_days: int
max_days: int
horizon_reasons: Dict[int, str] = field(default_factory=dict)
@dataclass(frozen=True)
@@ -92,7 +91,6 @@ class ProjectionResult:
staleness_fact: Optional[str]
degraded_identity_fact: Optional[str]
zero_rate_fact: Optional[str]
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
# ---------------------------------------------------------------------------
@@ -236,39 +234,20 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
return regime_bytes / regime_wc, regime_bytes, regime_wc
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
"""Compute horizon rate anchored at the latest evidence endpoint T.
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
Reader refresh alone never moves T or dilutes rates.
Returns (rate, reason) where reason is None on success or a string
describing why the rate is unavailable.
"""
if not days:
return None, "no observation history"
# T is the latest evidence endpoint — the end of the last day aggregate
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
# Exact trailing start: T minus horizon_days × 86400 seconds
h_start = T - timedelta(days=horizon_days)
cutoff = h_start.strftime("%Y-%m-%d")
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
# History must span the full horizon — no placeholders
if days[0]["day"] > cutoff:
return None, "%d-day window starts before earliest data" % horizon_days
if not days or days[0]["day"] > cutoff:
return None
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
covered = sum(1 for d in days if d["day"] >= cutoff)
if covered == 0:
return None, "%d-day window has no data" % horizon_days
h_wc = _wall_clock_in_range(conn, h_start, T)
return None
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
if h_wc <= 0:
return None, "%d-day window has no monitored wall-clock time" % horizon_days
return h_bytes / h_wc, None
return None
return h_bytes / h_wc
# ---------------------------------------------------------------------------
@@ -529,35 +508,17 @@ def compute_projection(conn, clock_now):
scenario = None
horizon_rates = {}
horizon_reasons = {}
for h in HORIZON_DAYS:
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
if hr is not None:
horizon_rates[h] = hr
else:
horizon_reasons[h] = reason
if horizon_rates:
scenario = ScenarioRange(
rates=horizon_rates,
min_days=min(horizon_rates),
max_days=max(horizon_rates),
horizon_reasons=horizon_reasons,
)
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
total_days_count = len(segment_days)
days_below_coverage = sum(1 for d in segment_days
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
qualifying = total_days_count - days_below_coverage
# Issue #77: Show honest qualifying-day progress
if total_days_count >= WARMING_MIN_DAYS:
# After warm-up, show qualifying day details for transparency
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
if days_below_coverage > 0:
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
else:
qualifying_days_progress = None
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
facts.append(warming_fact)
@@ -616,5 +577,4 @@ def compute_projection(conn, clock_now):
staleness_fact=staleness_fact,
degraded_identity_fact=degraded_identity_fact,
zero_rate_fact=zero_rate_fact,
qualifying_days_progress=qualifying_days_progress,
)
+2 -133
View File
@@ -2,7 +2,6 @@
Raw samples are pruned opportunistically to 14 days.
Hour observations and day aggregates are retained indefinitely.
Boundary anchors required for successor evidence are retained.
"""
import sqlite3
from datetime import datetime, timedelta, timezone
@@ -11,117 +10,6 @@ from datetime import datetime, timedelta, timezone
RAW_SAMPLE_RETENTION_DAYS = 14
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days (strictly before cutoff)
2. It has a next sample that forms an interval spanning the retention boundary
3. The interval hasn't been derived yet
The interval spans the boundary if:
- The sample is before the cutoff, AND
- The next sample is strictly after the cutoff (or within retention)
"""
from .derive import _parse_ts
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention (strictly after cutoff), not an anchor
if sample_dt > retention_cutoff:
return False
# Check if this sample has a next sample
cursor = conn.execute(
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample
# It's not needed for derivation (no interval to derive)
return False
next_ts_str = next_row[0]
next_segment_id = next_row[1]
next_dt = _parse_ts(next_ts_str)
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
if next_dt > retention_cutoff:
# The interval spans the retention boundary
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
# It needs derivation if any hour in the span lacks an observation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations - interval is derived
return False
else:
# The interval doesn't span the boundary (both samples are old)
# Check if the interval needs derivation
# Get current sample's segment_id
cursor = conn.execute(
"SELECT segment_id FROM samples WHERE ts = ?",
(sample_ts,),
)
current_segment_row = cursor.fetchone()
current_segment_id = current_segment_row[0] if current_segment_row else None
# If different segments, no interval to derive
if current_segment_id != next_segment_id:
return False
# Check if the interval [sample_ts, next_ts] needs derivation
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
while current_hour <= end_hour:
cursor = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current_hour.isoformat(),),
)
if cursor.fetchone() is None:
# This hour lacks an observation - interval needs derivation
# But only keep if the interval is significant (spans multiple hours)
# or if the next sample is the last sample before a gap
gap = (next_dt - sample_dt).total_seconds()
if gap > 24 * 3600: # Significant gap (> 24 hours)
return True
current_hour += timedelta(hours=1)
# All hours in the span have observations or gap is not significant
return False
def prune_old_samples(
conn: sqlite3.Connection,
now: datetime,
@@ -129,8 +17,6 @@ def prune_old_samples(
) -> int:
"""Remove raw samples older than retention_days.
Retains boundary anchors required for successor evidence.
Args:
conn: Connection to the observation store.
now: Current UTC time.
@@ -140,23 +26,6 @@ def prune_old_samples(
Number of samples removed.
"""
cutoff = (now - timedelta(days=retention_days)).isoformat()
# Get all samples older than cutoff
cursor = conn.execute(
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts",
(cutoff,),
)
old_samples = cursor.fetchall()
removed = 0
for sample_id, sample_ts in old_samples:
# Check if this sample is a boundary anchor
if needs_boundary_anchor(conn, sample_ts, now):
continue # Skip - it's a boundary anchor
# Remove the sample
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
removed += 1
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
conn.commit()
return removed
return cursor.rowcount
-448
View File
@@ -1,448 +0,0 @@
"""Historical repair and retention (issue #74).
Re-derives hour observations and day aggregates from surviving raw samples,
with idempotent and interruption-safe guarantees. Preserves import markers,
boundary anchors, and valid historical summaries.
Contracts:
- Idempotent: running repair multiple times produces no duplicates
- Interruption-safe: partial repair preserves prior valid history
- Preserves existing valid data: never overwrites valid derived data
- Surfaces failures explicitly for retry
"""
import logging
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Tuple
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
logger = logging.getLogger(__name__)
@dataclass
class RepairResult:
"""Result of a repair operation."""
ok: bool
hours_created: int = 0
hours_updated: int = 0
days_created: int = 0
days_updated: int = 0
intervals_derived: int = 0
boundary_anchors_retained: int = 0
legacy_summaries_preserved: int = 0
error: Optional[str] = None
@dataclass
class RepairStatus:
"""Current repair status for read-only views."""
last_repair: Optional[str] = None # ISO timestamp of last successful repair
repair_in_progress: bool = False
hours_derived: int = 0
days_derived: int = 0
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
"""Ensure metadata table exists for tracking repair state."""
conn.execute("""
CREATE TABLE IF NOT EXISTS store_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
"""Check if a repair operation is currently in progress."""
_ensure_repair_metadata(conn)
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
)
row = cursor.fetchone()
return row is not None and row[0] == "true"
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
"""Mark repair as in progress or complete."""
_ensure_repair_metadata(conn)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("repair_in_progress", "true" if in_progress else "false"),
)
conn.commit()
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
"""Update repair status after successful completion."""
_ensure_repair_metadata(conn)
now = datetime.now(timezone.utc).isoformat()
# Update last repair timestamp
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("last_repair", now),
)
# Update derived counts
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
hours = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
days = cursor.fetchone()[0]
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("hours_derived", str(hours)),
)
conn.execute(
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
("days_derived", str(days)),
)
conn.commit()
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
"""Get current repair status for read-only views."""
_ensure_repair_metadata(conn)
last_repair = None
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
)
row = cursor.fetchone()
if row:
last_repair = row[0]
in_progress = is_repair_in_progress(conn)
hours_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
)
row = cursor.fetchone()
if row:
hours_derived = int(row[0])
days_derived = 0
cursor = conn.execute(
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
)
row = cursor.fetchone()
if row:
days_derived = int(row[0])
return RepairStatus(
last_repair=last_repair,
repair_in_progress=in_progress,
hours_derived=hours_derived,
days_derived=days_derived,
)
def needs_boundary_anchor(
conn: sqlite3.Connection,
sample_ts: str,
now: datetime,
) -> bool:
"""Check if a sample is needed as a boundary anchor for derivation.
A sample is a boundary anchor if:
1. It's older than retention_days
2. It has no derived hour observation for its hour
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
"""
from .pruning import RAW_SAMPLE_RETENTION_DAYS
sample_dt = _parse_ts(sample_ts)
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
# If sample is within retention, not an anchor (will be kept anyway)
if sample_dt >= retention_cutoff:
return False
# Check if this sample's hour already has a derived observation
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
cursor = conn.execute(
"""SELECT COUNT(*) FROM hour_observations
WHERE hour >= ? AND hour < ?""",
(hour_start, hour_end),
)
# If there's an hour observation in this sample's hour, it's been derived
if cursor.fetchone()[0] > 0:
return False
# Check if this sample is the last sample before a gap
# (i.e., the next sample is significantly later)
cursor = conn.execute(
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
(sample_ts,),
)
next_row = cursor.fetchone()
if next_row is None:
# No next sample - this is the last sample, might be needed
# But if it's old and fully derived, it's not needed
return False
next_ts = _parse_ts(next_row[0])
gap = (next_ts - sample_dt).total_seconds()
# If gap > 24 hours, this sample is a boundary anchor
# (needed to derive the interval spanning the gap)
return gap > 24 * 3600
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
"""Find sample pairs that form intervals but have no hour observations."""
cursor = conn.execute(
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
temperature_c, data_units_written, data_units_read, segment_id
FROM samples ORDER BY ts"""
)
all_samples = []
for row in cursor.fetchall():
all_samples.append({
"id": row[0], "ts": row[1], "bytes_written": row[2],
"bytes_read": row[3], "power_on_hours": row[4],
"temperature_c": row[5], "data_units_written": row[6],
"data_units_read": row[7], "segment_id": row[8],
})
intervals = []
for i in range(len(all_samples) - 1):
prev = all_samples[i]
next_s = all_samples[i + 1]
# Skip if different segments
if prev["segment_id"] != next_s["segment_id"]:
continue
# Check if the interval spans hours that need derivation
prev_dt = _parse_ts(prev["ts"])
next_dt = _parse_ts(next_s["ts"])
# Check if any hour in the span lacks an observation
current = prev_dt.replace(minute=0, second=0, microsecond=0)
end = next_dt.replace(minute=0, second=0, microsecond=0)
needs_derivation = False
while current <= end:
cursor2 = conn.execute(
"SELECT id FROM hour_observations WHERE hour = ?",
(current.isoformat(),),
)
if cursor2.fetchone() is None:
needs_derivation = True
break
current += timedelta(hours=1)
if needs_derivation:
intervals.append((prev, next_s))
return intervals
def _derive_day_aggregate_from_hours(
conn: sqlite3.Connection,
day: str,
) -> Optional[dict]:
"""Derive a day aggregate from its hour observations."""
cursor = conn.execute(
"""SELECT SUM(active_seconds), SUM(idle_seconds),
SUM(powered_off_seconds), SUM(unknown_seconds),
SUM(bytes_written_delta), SUM(bytes_read_delta),
SUM(sample_count)
FROM hour_observations WHERE hour LIKE ?""",
(day + "T%",),
)
row = cursor.fetchone()
if row is None or row[0] is None:
return None
return {
"day": day,
"active_seconds": row[0] or 0,
"idle_seconds": row[1] or 0,
"powered_off_seconds": row[2] or 0,
"unknown_seconds": row[3] or 0,
"bytes_written_delta": row[4] or 0,
"bytes_read_delta": row[5] or 0,
"sample_count": row[6] or 0,
}
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
"""Insert or update a day aggregate. Returns True if created."""
existing = conn.execute(
"SELECT id FROM day_aggregates WHERE day = ?",
(day_data["day"],),
).fetchone()
if existing is None:
# Calculate coverage
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
conn.execute(
"""INSERT INTO day_aggregates
(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta, sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(day_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage),
)
return True
else:
# Update existing (but only if new data is more complete)
# This implements the "don't overwrite valid older history" rule
cursor = conn.execute(
"""SELECT bytes_written_delta, sample_count
FROM day_aggregates WHERE day = ?""",
(day_data["day"],),
)
existing_data = cursor.fetchone()
# Only update if new data has more samples or more bytes
if (day_data["sample_count"] > existing_data[1] or
day_data["bytes_written_delta"] > existing_data[0]):
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
conn.execute(
"""UPDATE day_aggregates SET
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
sample_count = ?, coverage = ?
WHERE day = ?""",
(day_data["active_seconds"], day_data["idle_seconds"],
day_data["powered_off_seconds"], day_data["unknown_seconds"],
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
day_data["sample_count"], coverage, day_data["day"]),
)
return False # Updated, not created
else:
return False # No update needed
def repair_derivation(
conn: sqlite3.Connection,
clock=None,
) -> RepairResult:
"""Repair hour observations and day aggregates from surviving samples.
This is the main entry point for historical repair. It:
1. Finds sample pairs that need interval derivation
2. Derives hour observations from those intervals
3. Updates day aggregates from the hour observations
4. Preserves existing valid data
5. Is idempotent and interruption-safe
Args:
conn: Connection to the observation store
clock: Injected clock (for testing)
Returns:
RepairResult with operation details
"""
if clock is None:
clock = datetime.now(timezone.utc)
elif hasattr(clock, 'utcnow'):
clock = clock.utcnow()
# Check if repair is already in progress
if is_repair_in_progress(conn):
return RepairResult(
ok=False,
error="Repair already in progress",
)
# Mark repair as in progress
_set_repair_in_progress(conn, True)
result = RepairResult(ok=True)
try:
# Begin transaction
conn.execute("BEGIN IMMEDIATE")
# 1. Find and derive intervals from sample pairs
intervals = _get_unlinked_intervals(conn)
for prev, next_s in intervals:
try:
derived_hours = derive_hours_from_interval(conn, prev, next_s)
result.intervals_derived += 1
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
except Exception as e:
logger.warning("Failed to derive interval %s -> %s: %s",
prev["ts"], next_s["ts"], e)
# Continue with other intervals (resilient)
# 2. Update day aggregates from hour observations
cursor = conn.execute(
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
)
days = [row[0] for row in cursor.fetchall()]
for day in days:
day_data = _derive_day_aggregate_from_hours(conn, day)
if day_data is not None:
created = _upsert_day_aggregate(conn, day_data)
if created:
result.days_created += 1
else:
result.days_updated += 1
# 3. Count boundary anchors retained
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
anchor_count = 0
for row in cursor.fetchall():
if needs_boundary_anchor(conn, row[0], now):
anchor_count += 1
result.boundary_anchors_retained = anchor_count
# 4. Count preserved legacy summaries
# Legacy summaries are day aggregates without corresponding hour observations
cursor = conn.execute(
"""SELECT COUNT(*) FROM day_aggregates d
WHERE NOT EXISTS (
SELECT 1 FROM hour_observations h
WHERE h.hour LIKE d.day || 'T%'
)"""
)
result.legacy_summaries_preserved = cursor.fetchone()[0]
# Commit transaction
conn.commit()
# Update repair status
_update_repair_status(conn, result)
except Exception as e:
conn.rollback()
logger.error("Repair failed: %s", e)
return RepairResult(
ok=False,
error=str(e),
)
finally:
# Mark repair as complete
_set_repair_in_progress(conn, False)
return result
+258 -154
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 service
Composes from the observation store (read-only) and allow-listed systemctl
properties: projection facts, four separate service facts (boot enablement,
runtime activity, last collect outcome, freshness), and a journal/log hint on
runtime activity, last collect outcome, freshness), and a journalctl hint on
failure or staleness. Never auto-samples, never prompts.
Freshness constants are defined once here and shared with the TUI (§8.9):
@@ -14,21 +14,14 @@ 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
from contextlib import contextmanager
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .status_composition import StatusComposition
from typing import Any, Dict, List, Optional, Tuple
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,
)
# ---------------------------------------------------------------------------
@@ -102,7 +95,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 MissingStore("observation store not found at %s" % store_path)
raise StoreFault("observation store not found at %s" % store_path)
try:
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
@@ -129,10 +122,6 @@ 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):
@@ -141,12 +130,97 @@ class NewerSchema(Exception):
# ---------------------------------------------------------------------------
# Service state queries (§8.8 — init-system agnostic)
# Service state queries (§8.8 — allow-listed systemctl properties)
# ---------------------------------------------------------------------------
# Re-export for backward compatibility with tests that import directly
query_service_state = _init_query_service_state
_journalctl_hint = _init_journal_hint
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,
}
# ---------------------------------------------------------------------------
@@ -254,12 +328,26 @@ def check_retired_flag(flag: str) -> Optional[str]:
# Formatting
# ---------------------------------------------------------------------------
def _format_projection(proj, freshness: str, drive_facts: List[str],
config_error: Optional[str],
sample_count: int = 0, day_count: int = 0) -> str:
"""Format projection details; monitoring status has its own renderer."""
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]) -> str:
"""Format the complete status output."""
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)
@@ -270,19 +358,7 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
lines.append("no observations yet")
lines.append("")
lines.append("Enable monitoring: fenris monitor resume")
return "\n".join(lines)
# --- Single sample: awaiting another sample (issue #73 AC3) ---
# Only show awaiting state when there are no day aggregates (e.g., legacy import
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
if sample_count <= 1 and day_count == 0:
lines.append("awaiting another sample")
lines.append("")
lines.append("Collecting usage data — the first projection requires at least two samples.")
return "\n".join(lines)
if proj is None:
lines.append("no projection available")
_append_service_facts(lines, service)
return "\n".join(lines)
# --- Projection headline ---
@@ -292,30 +368,21 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
# --- Confidence state + contributing facts (§6.7, §6.11) ---
state_label = proj.confidence_state.value
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
# Issue #77: Show qualifying day progress for honesty
if proj.qualifying_days_progress:
facts_list.insert(0, proj.qualifying_days_progress)
if facts_list:
facts_str = " · ".join(facts_list)
if proj.contributing_facts:
facts_str = " · ".join(proj.contributing_facts)
lines.append("%s evidence · %s" % (state_label, facts_str))
else:
lines.append("%s evidence" % state_label)
lines.append("")
# --- Scenario range (§6.5) with horizon reasons ---
if proj.scenario_range:
# --- Scenario range (§6.5) ---
if proj.scenario_range and proj.scenario_range.rates:
parts = []
for horizon in sorted(proj.scenario_range.rates.keys()):
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
parts.append("%dd: %s" % (horizon, reason))
if parts:
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
lines.append("scenario range: %s" % " · ".join(parts))
lines.append("")
# --- PU context line (§6.1) ---
lines.append(proj.pu_context_line)
@@ -327,6 +394,16 @@ def _format_projection(proj, freshness: str, drive_facts: List[str],
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)
@@ -371,6 +448,36 @@ 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)
# ---------------------------------------------------------------------------
@@ -385,9 +492,7 @@ _PAUSED_CONSEQUENCE = (
def monitoring_continuity(service: Dict[str, Any]) -> str:
"""Return the boot-persistence wording, independent of timer runtime."""
if service.get("boot_enabled") is None:
return "monitoring: boot persistence unknown"
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
def deliberate_pause_lines() -> List[str]:
@@ -433,92 +538,110 @@ 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 CLI status through the shared read-only acquisition path."""
from .status_composition import render_status_cli
"""Render the complete read-only status (§8.8, LC-9).
clock_now = clock_now or datetime.now(timezone.utc)
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) ---
config_error = None
device = None
try:
read_config()
except ConfigError as exc:
config_error = str(exc)
config = read_config()
device = config["device"]
except ConfigError as e:
config_error = str(e)
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)
proj = compute_projection(conn, clock_now)
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)
# --- 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)
# 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) ---
drive_facts = []
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,
)
conn.close()
return result
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
@@ -533,22 +656,3 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
parts.append("")
parts.append(format_disclosures())
return "\n\n".join(parts)
# ---------------------------------------------------------------------------
# Shared status composition integration (issue #78)
# ---------------------------------------------------------------------------
def get_status_composition(
store_path: Optional[Path] = None,
clock_now: Optional[datetime] = None,
query_services: bool = True,
collecting: bool = False,
reduced_motion: bool = False,
) -> 'StatusComposition':
"""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
-541
View File
@@ -1,541 +0,0 @@
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
Centralizes the monitoring status lattice consumed by both surfaces.
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
Collecting overlays every base except store fault.
Freshness grading uses shared constants from status.py.
"""
import enum
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
STATUS_POLL_INTERVAL_S = 5
from .status import (
FRESH_THRESHOLD_S,
STALENESS_THRESHOLD_S,
grade_freshness,
freshness_age_human,
is_deliberately_paused,
monitoring_continuity,
deliberate_pause_lines,
open_store_readonly,
StoreFault,
NewerSchema,
)
# ---------------------------------------------------------------------------
# Status state enum with glyph and label
# ---------------------------------------------------------------------------
class StatusState(enum.Enum):
"""The eight monitoring status states (TPH-2)."""
MONITORING = "monitoring"
COLLECTING = "collecting"
PAUSED = "paused"
WAITING = "waiting"
INTERRUPTED = "interrupted"
ERROR = "error"
STALE = "stale"
UNKNOWN = "unknown"
@property
def glyph(self) -> str:
_glyphs = {
"monitoring": "●",
"collecting": "◐",
"paused": "‖",
"waiting": "○",
"interrupted": "⊘",
"error": "✖",
"stale": "◌",
"unknown": "?",
}
return _glyphs[self.value]
@property
def label(self) -> str:
return self.value.capitalize()
# Precedence order: higher index = higher precedence
_PRECEDENCE = [
StatusState.UNKNOWN,
StatusState.MONITORING,
StatusState.WAITING,
StatusState.STALE,
StatusState.PAUSED,
StatusState.INTERRUPTED,
StatusState.ERROR,
]
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
# ---------------------------------------------------------------------------
# Status composition result
# ---------------------------------------------------------------------------
@dataclass
class StatusComposition:
"""The composed status result shared between TUI and CLI."""
state: StatusState
glyph: str
label: str
explanation: str
# Separate facts (never folded into the status word)
freshness: str = "unknown"
freshness_age_s: Optional[int] = None
last_collect_ok: Optional[bool] = None
last_collect_age_s: Optional[int] = None
last_collect_reason: Optional[str] = None
boot_enabled: Optional[bool] = None
timer_active: Optional[bool] = None
deliberately_paused: bool = False
pause_age_s: Optional[int] = None
external_stop_reason: Optional[str] = None
# Store fault / newer schema (suppress store-dependent views)
store_fault: Optional[str] = None
newer_schema: Optional[str] = None
# Collecting overlay
overlay_base: Optional[StatusState] = None
# Sample counts for waiting explanations
sample_count: int = 0
day_count: int = 0
# Whether the status dot should blink (only Monitoring)
should_blink: bool = False
# Continuity line
continuity: str = ""
# Paused lines (for TUI banner)
paused_lines: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Status composition logic
# ---------------------------------------------------------------------------
def _determine_base_state(
freshness: str,
sample_count: int,
day_count: int,
boot_enabled: Optional[bool],
timer_active: Optional[bool],
last_collect_ok: Optional[bool],
deliberately_paused: bool,
external_stop_reason: Optional[str],
service_available: bool,
) -> StatusState:
"""Determine the base status state from facts.
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
"""
# Unknown: service query failed and no store-derived fact places us higher
if not service_available:
return StatusState.UNKNOWN
# Error: last collect failed
if last_collect_ok is False:
return StatusState.ERROR
# Interrupted: external stop (not user_disabled)
if external_stop_reason is not None:
return StatusState.INTERRUPTED
# Paused: deliberate disable
if deliberately_paused:
return StatusState.PAUSED
# Stale: timer active, no failure, but data ≥ 48h old
if freshness == "stale" and timer_active and last_collect_ok is not False:
return StatusState.STALE
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
if sample_count == 0:
return StatusState.WAITING
if sample_count <= 1 and day_count == 0:
return StatusState.WAITING
# Monitoring: everything is fine
return StatusState.MONITORING
def _determine_explanation(
state: StatusState,
freshness: str,
freshness_age_s: Optional[int],
last_collect_ok: Optional[bool],
last_collect_reason: Optional[str],
sample_count: int,
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line for the status state."""
if store_fault:
return "observation store unreadable — see collector logs"
if newer_schema:
return "observation store written by a newer Fenris — upgrade Fenris"
if state == StatusState.ERROR:
parts = []
if last_collect_ok is False:
parts.append("last run failed")
if last_collect_reason:
parts.append("(%s)" % last_collect_reason)
if freshness_age_s is not None and freshness != "empty":
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
return " ".join(parts) if parts else "last run failed"
if state == StatusState.INTERRUPTED:
return "collection stopped outside Fenris — monitoring period still open"
if state == StatusState.PAUSED:
return "monitoring paused — paused time excluded from your usage habit"
if state == StatusState.STALE:
if freshness_age_s is not None:
return "last sample %s" % freshness_age_human(freshness_age_s)
return "data is stale"
if state == StatusState.WAITING:
if sample_count == 0:
return "awaiting first sample"
if sample_count <= 1:
return "awaiting another sample"
return "waiting for data"
if state == StatusState.MONITORING:
if freshness_age_s is not None:
return "last sample %s" % freshness_age_human(freshness_age_s)
return "monitoring active"
if state == StatusState.UNKNOWN:
return "service state unavailable"
return ""
def _determine_collecting_overlay(
base_state: StatusState,
last_collect_ok: Optional[bool],
deliberately_paused: bool,
store_fault: Optional[str],
newer_schema: Optional[str],
) -> str:
"""Determine the explanation line when Collecting overlays a base state."""
if store_fault or newer_schema:
return "run in flight — store fault"
if base_state == StatusState.PAUSED:
return "run in flight — paused"
if base_state == StatusState.INTERRUPTED:
return "run in flight — interrupted"
if base_state == StatusState.ERROR:
return "run in flight — retry"
if base_state == StatusState.STALE:
return "run in flight — stale data"
if base_state == StatusState.WAITING:
return "run in flight"
return "run in flight"
def compose_status(
conn: Optional[sqlite3.Connection],
service: Optional[Dict[str, Any]],
clock_now: datetime,
store_fault: Optional[str] = None,
newer_schema: Optional[str] = None,
collecting: bool = False,
reduced_motion: bool = False,
) -> StatusComposition:
"""Compose the shared status from service state and store data.
This is the single entry point consumed by both TUI and CLI.
"""
service_available = 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 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"
freshness_age_s = None
sample_count = 0
day_count = 0
deliberately_paused = False
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()
newest_ts = row[0] if row else None
freshness = grade_freshness(newest_ts, clock_now)
if newest_ts:
try:
ts = datetime.fromisoformat(newest_ts)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
else:
ts = ts.astimezone(timezone.utc)
freshness_age_s = int((clock_now - ts).total_seconds())
except (ValueError, TypeError):
pass
except sqlite3.Error 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 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 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 (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(
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
).fetchone()
if open_period is not None:
external_stop_reason = "external_stop"
except sqlite3.Error:
pass
# --- Determine base state ---
# Store fault and newer schema always override to ERROR
if store_fault is not None or newer_schema is not None:
base_state = StatusState.ERROR
else:
base_state = _determine_base_state(
freshness=freshness,
sample_count=sample_count,
day_count=day_count,
boot_enabled=boot_enabled,
timer_active=timer_active,
last_collect_ok=last_collect_ok,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
service_available=service_available,
)
# --- Apply Collecting overlay ---
state = base_state
overlay_base = None
explanation = ""
if collecting and store_fault is None and newer_schema is None:
# Collecting overlays every base except store fault
overlay_base = base_state
state = StatusState.COLLECTING
explanation = _determine_collecting_overlay(
base_state, last_collect_ok, deliberately_paused,
store_fault, newer_schema,
)
else:
explanation = _determine_explanation(
base_state, freshness, freshness_age_s,
last_collect_ok, last_collect_reason,
sample_count, deliberately_paused,
store_fault, newer_schema,
)
# --- Continuity line ---
continuity = ""
if service_available:
continuity = monitoring_continuity(service)
# --- Paused lines ---
paused_lines = []
if deliberately_paused:
paused_lines = deliberate_pause_lines()
# Determine blink flag: only Monitoring dot blinks, never text or other states
should_blink = (state == StatusState.MONITORING and not reduced_motion)
return StatusComposition(
state=state,
glyph=state.glyph,
label=state.label,
explanation=explanation,
should_blink=should_blink,
freshness=freshness,
freshness_age_s=freshness_age_s,
last_collect_ok=last_collect_ok,
last_collect_age_s=last_collect_age_s,
last_collect_reason=last_collect_reason,
boot_enabled=boot_enabled,
timer_active=timer_active,
deliberately_paused=deliberately_paused,
external_stop_reason=external_stop_reason,
store_fault=store_fault,
newer_schema=newer_schema,
overlay_base=overlay_base,
sample_count=sample_count,
day_count=day_count,
continuity=continuity,
paused_lines=paused_lines,
)
# ---------------------------------------------------------------------------
# CLI rendering (static, no styling)
# ---------------------------------------------------------------------------
def render_status_cli(comp: StatusComposition) -> str:
"""Render the status composition as static CLI text."""
lines = []
# Status line
lines.append("%s %s" % (comp.glyph, comp.label))
# Explanation
if comp.explanation:
lines.append(comp.explanation)
lines.append("")
# Separate facts
facts = []
facts.append("freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None and facts:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "last collect: FAILED"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("last collect: unknown")
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))
# Continuity
if comp.continuity:
lines.append("")
lines.append("CONTINUITY: %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# TUI rendering (with styling tokens)
# ---------------------------------------------------------------------------
def render_status_tui(comp: StatusComposition) -> str:
"""Render the status composition as TUI text with Textual markup."""
lines = []
# Status line with color
color = {
StatusState.MONITORING: "green",
StatusState.COLLECTING: "green",
StatusState.PAUSED: "yellow",
StatusState.WAITING: "yellow",
StatusState.INTERRUPTED: "red",
StatusState.ERROR: "red",
StatusState.STALE: "red",
StatusState.UNKNOWN: "dim",
}[comp.state]
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
# Explanation
if comp.explanation:
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
lines.append("")
# Separate facts
facts = []
facts.append("Freshness: %s" % comp.freshness)
if comp.freshness_age_s is not None and facts:
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
if comp.last_collect_ok is True:
facts.append("Last collect: ok")
elif comp.last_collect_ok is False:
collect_str = "Last collect: failed"
if comp.last_collect_reason:
collect_str += " (%s)" % comp.last_collect_reason
facts.append(collect_str)
else:
facts.append("Last collect: unknown")
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))
# Continuity
if comp.continuity:
lines.append("")
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
# Deliberate pause
if comp.paused_lines:
for pl in comp.paused_lines:
lines.append(pl[:1].upper() + pl[1:])
return "\n".join(lines)
+8 -33
View File
@@ -12,7 +12,7 @@ from typing import Optional
# Schema version - increment on each migration
SCHEMA_VERSION = 2
SCHEMA_VERSION = 1
# Packaged default placement (spec §8.3). The config may override it, but a
@@ -106,8 +106,7 @@ def _create_schema(conn: sqlite3.Connection):
data_units_read INTEGER,
bytes_written INTEGER,
bytes_read INTEGER,
critical_warning INTEGER,
segment_id INTEGER
critical_warning INTEGER
)
""")
@@ -142,9 +141,7 @@ def _create_schema(conn: sqlite3.Connection):
bytes_written_delta INTEGER DEFAULT 0,
bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0,
coverage REAL DEFAULT 0.0,
unattributed_bytes_written INTEGER DEFAULT 0,
unattributed_bytes_read INTEGER DEFAULT 0
coverage REAL DEFAULT 0.0
)
""")
@@ -210,25 +207,11 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
Spec: §3.6, §10.2
"""
# Migration 1→2: add segment_id provenance to samples,
# unattributed byte tracking to day_aggregates (issue #73)
if current_version < 2:
# Defensive: only ALTER if table exists (handles minimal v1 stores)
tables = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
if "samples" in tables:
# Check if column already exists (idempotent)
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
if "segment_id" not in cols:
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
if "day_aggregates" in tables:
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
if "unattributed_bytes_written" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
if "unattributed_bytes_read" not in cols:
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
current_version = 2
# Migration 1→2: example placeholder
# if current_version < 2:
# conn.execute("ALTER TABLE ...")
# current_version = 2
pass
def migrate_to_latest(store_path: Path) -> int:
@@ -256,14 +239,6 @@ def migrate_to_latest(store_path: Path) -> int:
conn.close()
return 0 # Already up to date
# Version 0 means no schema — create fresh (issue #73)
if current_version == 0:
_create_schema(conn)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
conn.commit()
conn.close()
return SCHEMA_VERSION
steps = SCHEMA_VERSION - current_version
_apply_migrations(conn, current_version)
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
-161
View File
@@ -1,161 +0,0 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
Theme roles for graph rendering expose distinct colours per preset so
the bar graph can reflect the user's visual preference without depending
on graph-ticket completion.
Criteria: TPH-10, AC80-1, AC80-5.
"""
from typing import Dict
from textual.theme import Theme
# ---------------------------------------------------------------------------
# Status semantic colours — always win, never themed (AC80-1)
# ---------------------------------------------------------------------------
STATUS_COLORS = {
"monitoring": "green",
"collecting": "green",
"paused": "yellow",
"waiting": "yellow",
"interrupted": "red",
"error": "red",
"stale": "red",
"unknown": "dim",
}
# ---------------------------------------------------------------------------
# Amber theme — warm golden tones (default, amber graph role)
# ---------------------------------------------------------------------------
_AMBER = Theme(
name="fenris-amber",
primary="#d4a017", # warm amber
secondary="#c49b0a", # darker amber
accent="#ffd54f", # light amber highlight
warning="#e6a817", # amber warning
error="#e74c3c", # red error
success="#27ae60", # green success
foreground="#e8e0d0", # warm light
background="#1a1510", # warm dark
surface="#241f16", # warm surface
panel="#2a2318", # warm panel
boost="#332a1c", # warm boost
dark=True,
variables={
"graph-allocated": "#d4a017",
"graph-unallocated": "#8b6914",
"graph-gap": "#554422",
"graph-zero": "#665533",
"graph-partial": "#aa8822",
"graph-selection": "#ffd54f",
"border-default": "#554422",
"muted-text": "#887755",
},
)
# ---------------------------------------------------------------------------
# Nord theme — cool blue-gray polar night palette
# ---------------------------------------------------------------------------
_NORD = Theme(
name="fenris-nord",
primary="#88c0d0", # nord8 frost
secondary="#81a1c1", # nord9
accent="#8fbcbb", # nord7
warning="#ebcb8b", # nord13
error="#bf616a", # nord11
success="#a3be8c", # nord14
foreground="#eceff4", # nord6
background="#2e3440", # nord0
surface="#3b4252", # nord1
panel="#434c5e", # nord2
boost="#4c566a", # nord3
dark=True,
variables={
"graph-allocated": "#88c0d0",
"graph-unallocated": "#5e81ac",
"graph-gap": "#4c566a",
"graph-zero": "#616e88",
"graph-partial": "#81a1c1",
"graph-selection": "#8fbcbb",
"border-default": "#4c566a",
"muted-text": "#7b88a1",
},
)
# ---------------------------------------------------------------------------
# High Contrast — maximum readability, pure black and white
# ---------------------------------------------------------------------------
_HIGH_CONTRAST = Theme(
name="fenris-high-contrast",
primary="#ffffff", # pure white
secondary="#dddddd", # light gray
accent="#ffff00", # bright yellow
warning="#ff8800", # bright orange
error="#ff0000", # pure red
success="#00ff00", # pure green
foreground="#ffffff", # pure white
background="#000000", # pure black
surface="#111111", # near-black surface
panel="#1a1a1a", # near-black panel
boost="#222222", # near-black boost
dark=True,
variables={
"graph-allocated": "#ffffff",
"graph-unallocated": "#aaaaaa",
"graph-gap": "#555555",
"graph-zero": "#666666",
"graph-partial": "#cccccc",
"graph-selection": "#ffff00",
"border-default": "#ffffff",
"muted-text": "#aaaaaa",
},
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
THEMES = {
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
}
THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
"""
return THEMES.get(name, _AMBER)
def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names.
"""
theme = get_theme(theme_name)
variables = theme.variables or {}
return {
"allocated": variables.get("graph-allocated", "#d4a017"),
"unallocated": variables.get("graph-unallocated", "#8b6914"),
"gap": variables.get("graph-gap", "#554422"),
"zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"),
}
+274 -936
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -461,7 +461,7 @@ class TestCI2Parity:
}
with patch("fenris.status.query_service_state", return_value=service_state), patch(
"fenris.status.query_service_state", return_value=service_state
"fenris.tui.query_service_state", return_value=service_state
):
status = get_status(
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
@@ -502,6 +502,18 @@ 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
@@ -591,6 +603,12 @@ 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()
@@ -670,6 +688,18 @@ 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()
-83
View File
@@ -209,86 +209,3 @@ 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
-712
View File
@@ -1,712 +0,0 @@
"""Collector history tracer tests (issue #73).
Tests the end-to-end history pipeline: sample acquisition → interval
derivation → hour observation → day aggregate, with concurrent-read
safety, cross-hour handling, and display states.
Seams:
- write side: run_collection() → observation store
- read side: get_status(), compute_projection() → observation store
"""
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.collector import run_collection, normalize_identity
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
from fenris.day_aggregate import derive_day, derive_all_days
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def smartctl_fixture() -> Dict[str, Any]:
"""Minimal smartctl -a -j output with required fields."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": 12345678,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
@pytest.fixture
def sysfs_fixture_tree(tmp_path: Path) -> Path:
"""Create a minimal sysfs fixture tree with controller identity."""
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
ctrl_dir.mkdir(parents=True)
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
transport_dir = ctrl_dir / "transport"
transport_dir.mkdir()
(transport_dir / "address").write_text("0000:03:00.0")
(transport_dir / "trstring").write_text("pcie")
return tmp_path
@pytest.fixture
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
"""Configuration fixture naming the device."""
return {
"device": "/dev/nvme0",
"store_path": str(tmp_path / "observations.db"),
}
class FakeClock:
"""Injected clock returning controlled time."""
def __init__(self, initial: datetime):
self.now = initial
def utcnow(self):
return self.now
def advance(self, **kwargs):
self.now = self.now + timedelta(**kwargs)
# ---------------------------------------------------------------------------
# Schema migration: existing data readable at real precision
# ---------------------------------------------------------------------------
class TestSchemaMigration:
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
def test_migration_bumps_version(self, tmp_path):
"""Migration from v1 to v2 succeeds."""
from fenris.store import migrate_to_latest, SCHEMA_VERSION
# Create a v1 store directly (simulating pre-migration state)
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=WAL")
# Create v1 schema manually
conn.execute("""
CREATE TABLE samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
device TEXT NOT NULL,
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
capacity_bytes INTEGER, percentage_used INTEGER,
available_spare INTEGER, media_errors INTEGER,
power_on_hours INTEGER, power_cycles INTEGER,
unsafe_shutdowns INTEGER, temperature_c INTEGER,
data_units_written INTEGER, data_units_read INTEGER,
bytes_written INTEGER, bytes_read INTEGER,
critical_warning INTEGER
)
""")
conn.execute("""
CREATE TABLE hour_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hour TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("""
CREATE TABLE day_aggregates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL UNIQUE,
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
)
""")
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("PRAGMA user_version=1")
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
conn.commit()
conn.close()
# Migrate
steps = migrate_to_latest(db)
assert steps == 1
# Verify data preserved
conn = sqlite3.connect(str(db))
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
version = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "Test"
assert version == SCHEMA_VERSION
def test_newer_schema_refused(self, tmp_path):
"""Store with user_version > SCHEMA_VERSION is refused."""
db = tmp_path / "test.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
conn.commit()
conn.close()
with pytest.raises(ValueError, match="newer Fenris"):
init_store(db)
def test_existing_data_preserved_after_migration(self, config_fixture,
smartctl_fixture,
sysfs_fixture_tree):
"""Existing sample data is not lost or modified by migration."""
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
# Write first sample
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock)
conn = sqlite3.connect(config_fixture["store_path"])
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
conn.close()
assert row[0] == "2026-09-01T12:00:00+00:00"
assert row[1] == "/dev/nvme0"
assert row[2] == 12345678 * 512000
# ---------------------------------------------------------------------------
# Collector publishes samples, intervals, hour observations, day aggregates
# ---------------------------------------------------------------------------
class TestCollectorDerivation:
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
"""Build a smartctl fixture with specific DUW."""
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 35,
"available_spare": 100,
"available_spare_threshold": 10,
"percentage_used": 5,
"data_units_written": duw_units,
"data_units_read": 9876543,
"power_on_hours": 8765,
"power_cycles": 1234,
"unsafe_shutdowns": 5,
"media_errors": 0,
"num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
duw1 = 12345678
duw2 = duw1 + 40 # ~20 MB more
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
assert r1["ok"]
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Check hour_observation was derived
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None, "Hour observation should exist for 12:00"
assert hour[1] >= 2 # at least 2 samples contributed
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
assert hour[0] == 40 * 512000
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
"""Two samples in same hour with no DUW change → 0 B written."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw = 12345678 # same for both
clock1 = FakeClock(t1)
s1 = self._make_sample(duw, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
hour = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
assert hour is not None
assert hour[0] == 0
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
"""Samples in different hours → delta is unattributed to any hour."""
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 200 # ~100 MB
clock1 = FakeClock(t1)
s1 = self._make_sample(duw1, t1.isoformat())
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
s2 = self._make_sample(duw2, t2.isoformat())
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
# Hour observations should NOT contain the cross-hour delta
hour11 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
).fetchone()
hour12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Neither hour should have the full 100 MB delta attributed
# (they may have 0 or partial, but not 200*512000)
full_delta = 200 * 512000
if hour11 is not None:
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
if hour12 is not None:
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
"""A read-only reader sees valid pre- or post-publication snapshot."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample(12345678, t1.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Open read-only
ro_conn = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn.close()
assert count_before == 1
# Write second sample
clock2 = FakeClock(t2)
run_collection(self._make_sample(12345718, t2.isoformat()),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Read-only reader sees 2 samples now
ro_conn2 = sqlite3.connect(
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
)
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ro_conn2.close()
assert count_after == 2
# ---------------------------------------------------------------------------
# Display states: awaiting first sample, awaiting another sample
# ---------------------------------------------------------------------------
class TestDisplayStates:
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
def test_zero_samples_awaiting_first(self, tmp_path):
"""Zero samples → 'awaiting first sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
def test_one_sample_awaiting_another(self, tmp_path):
"""One sample → 'awaiting another sample' state."""
from fenris.status import get_status
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
from unittest.mock import patch
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=False, query_journal=False)
# Should mention awaiting or insufficient data
lower = result.lower()
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
"""One sample → TUI shows awaiting state."""
from fenris.tui import FenrisTuiApp
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
"percentage_used, available_spare, media_errors, power_on_hours, "
"power_cycles, unsafe_shutdowns, temperature_c, "
"data_units_written, data_units_read, bytes_written, bytes_read, "
"critical_warning) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
1000000, 500000, 512000000000, 256000000000, 0),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
# Test the projection handles single sample
from fenris.projection import compute_projection, ConfidenceState
conn = sqlite3.connect(db)
proj = compute_projection(conn, now)
conn.close()
# With only one sample, projection should be unavailable
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
"""Two compatible same-hour samples show measured usage."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
from fenris.status import get_status
from unittest.mock import patch
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=Path(config_fixture["store_path"]),
clock_now=now, query_services=False, query_journal=False)
# Should not say "no observations" or "awaiting"
lower = result.lower()
assert "no observations yet" not in lower
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Pause crossing and counter reset
# ---------------------------------------------------------------------------
class TestPauseCrossing:
"""Delta across monitoring period gap (issue #73 AC4)."""
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
"""Delta across a paused period preserves prior hour observations."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 + 100
# First sample (opens period)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Pause
conn = sqlite3.connect(config_fixture["store_path"])
close_period(conn, t_pause, "user_disabled")
conn.close()
# Resume with new sample
clock_resume = FakeClock(t_resume)
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock_resume)
# Second sample after resume
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
# Verify prior hour observations are intact
conn = sqlite3.connect(config_fixture["store_path"])
hour_12 = conn.execute(
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
).fetchone()
conn.close()
# Hour 12 should still have data from the first collection
assert hour_12 is not None, "Prior hour observation should be preserved"
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
class TestCounterReset:
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
"""DUW decrease triggers new segment."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
duw1 = 12345678
duw2 = duw1 - 100 # decrease = reset
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
# Should have 2 segments (new one opened for DUW decrease)
assert segments == 2
# Should have 2 samples
assert samples == 2
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Derivation failure preserves prior history
# ---------------------------------------------------------------------------
class TestDerivationFailure:
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
"""If derivation fails after sample write, prior data is intact."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_for_failure(12345678),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
# Verify first sample exists
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 1
# Second sample with valid data should succeed
clock2 = FakeClock(t2)
r2 = run_collection(self._make_sample_for_failure(12345718),
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
assert r2["ok"]
# Both samples should exist
conn = sqlite3.connect(config_fixture["store_path"])
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
conn.close()
assert count == 2
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": duw_units,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
# ---------------------------------------------------------------------------
# Monitoring period is opened by collector
# ---------------------------------------------------------------------------
class TestMonitoringPeriod:
"""Collector ensures monitoring period is open (issue #73 AC2)."""
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
"""First collection run opens a monitoring period."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
conn.close()
assert period is not None, "A monitoring period should be open"
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
"""Subsequent collection runs keep the period open."""
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
clock1 = FakeClock(t1)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock1)
clock2 = FakeClock(t2)
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
config_fixture, clock2)
conn = sqlite3.connect(config_fixture["store_path"])
period = get_open_period(conn)
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
conn.close()
assert period is not None
assert periods_count == 1 # Still only one period
def _make_sample_simple(self) -> Dict[str, Any]:
return {
"json_format_version": [1, 0],
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
"nvme_smart_health_information_log": {
"critical_warning": 0, "temperature": 35,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 5, "data_units_written": 12345678,
"data_units_read": 9876543, "power_on_hours": 8765,
"power_cycles": 1234, "unsafe_shutdowns": 5,
"media_errors": 0, "num_err_log_entries": 0,
},
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
"model_name": "Samsung SSD 970 EVO Plus 1TB",
"serial_number": "S4EWNX0N123456",
"firmware_version": "2B2QEXM7",
}
-77
View File
@@ -1,77 +0,0 @@
"""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()
-692
View File
@@ -1,692 +0,0 @@
"""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
-258
View File
@@ -1,258 +0,0 @@
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
These tests verify that:
1. Warming progress shows detailed qualifying day breakdown
2. Confidence state accurately reflects qualifying day progress
3. Edge cases like zero-delta intervals and unknown daily shares are handled
4. qualifying_days_progress shows honest qualifying day count
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier,
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
)
@pytest.fixture
def store(tmp_path):
conn = init_store(tmp_path / "test.db")
yield conn
conn.close()
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True, model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model, 1024000000000,
"machine_match" if verified else None, verified, "2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1", "0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
# Convert string to datetime if needed
if isinstance(start, str):
start = datetime.fromisoformat(start)
ensure_period_open(conn, start)
conn.commit()
class TestHonestQualifyingProgress:
"""Issue #77: Show honest qualifying-day progress and confidence."""
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
"""Warming progress shows both qualifying and non-qualifying days."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 12 qualifying days + 2 non-qualifying (low coverage)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_warming_progress_includes_nonqualifying_reason(self, store):
"""Warming progress indicates why days don't qualify."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 13 qualifying days + 1 non-qualifying (zero samples)
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i == 0 else 24 # First day has no samples
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# After warming, qualifying_days_progress shows honest count
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
assert result.qualifying_days_progress is not None
assert "13 of 14 qualifying days" in result.qualifying_days_progress
assert "1 below coverage" in result.qualifying_days_progress
def test_confidence_updates_as_qualifying_days_increase(self, store):
"""Confidence state reflects actual qualifying day count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Start with 10 days (below minimum)
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should be warming
assert result.warming_fact is not None
assert result.confidence_state == ConfidenceState.LIMITED
def test_zero_rate_with_qualifying_days(self, store):
"""Zero rate with qualifying days shows honest state."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
# 14 days with zero bytes written
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should be UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.zero_rate_fact is not None
assert "no finite projection" in result.zero_rate_fact
def test_qualifying_days_excludes_low_coverage(self, store):
"""Days below 50% coverage don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have low coverage
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 3 else 0.95
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_excludes_zero_samples(self, store):
"""Days with zero samples don't count as qualifying."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days total, but 3 have zero samples
for i in range(14):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
samples = 0 if i < 3 else 24
_insert_day(store, d, bw=bw, samples=samples)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should still be warming
assert result.warming_fact is not None
assert "warming up" in result.warming_fact.lower()
def test_qualifying_days_progress_after_warming(self, store):
"""After warming, qualifying_days_progress shows honest count."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 total days, 12 qualifying (2 below coverage)
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
_insert_day(store, d, bw=bw, coverage=cov)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
assert result.warming_fact is None
# But qualifying_days_progress should show the honest count
assert result.qualifying_days_progress is not None
assert "12 of 14 qualifying days" in result.qualifying_days_progress
assert "2 below coverage" in result.qualifying_days_progress
def test_qualifying_days_progress_all_qualifying(self, store):
"""When all days qualify, qualifying_days_progress shows 100%."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
_open_period(store, start="2026-09-17T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 14 days, all qualifying
for i in range(14):
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw, coverage=0.95)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Should not be warming
assert result.warming_fact is None
# qualifying_days_progress should show all qualifying
assert result.qualifying_days_progress is not None
assert "14 of 14 qualifying days" in result.qualifying_days_progress
# Should not show "below coverage" since all qualify
assert "below coverage" not in result.qualifying_days_progress
-318
View File
@@ -1,318 +0,0 @@
"""Tests for issue #79: Apply Fenris identity and Drive health grouping.
Covers:
- TPH-1: Titlebox shows Fenris identity with wolf fallback
- TPH-11: Single maker-credit placement, vendor wear under Drive health
- Preserve: continuity, pause block, quit rail, auth banner
"""
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.monitoring_periods import ensure_period_open
from fenris.tui import FenrisTuiApp
# ---------------------------------------------------------------------------
# Helpers (reuse from test_tui.py)
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_baseline(conn, tbw_tb=1.0, verified=True,
model="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO endurance_baseline "
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01", model,
1024000000000, "machine_match" if verified else None, verified,
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
conn.commit()
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, pu=5, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
ensure_period_open(conn, datetime.fromisoformat(start))
# ---------------------------------------------------------------------------
# Issue #79: Fenris identity and Drive health grouping
# ---------------------------------------------------------------------------
class TestFenrisIdentity:
"""TPH-1: Titlebox shows Fenris identity with wolf fallback."""
@pytest.mark.asyncio
async def test_titlebox_shows_wolf_identity(self, tmp_path):
"""Top titlebox reads 🐺 Fenris by Bongbetic."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "🐺 Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_titlebox_fallback_without_wolf(self, tmp_path):
"""Fallback to 'Fenris by Bongbetic' when wolf is unsupported."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
# Simulate narrow terminal that can't render wolf
async with app.run_test(size=(60, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Either shows wolf or fallback - both are acceptable
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_wolf_never_shows_tofu(self, tmp_path):
"""Wolf glyph is never rendered as tofu (unsupported character)."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# No replacement character (U+FFFD) should appear
assert "\ufffd" not in headline.lower()
assert "?" not in headline or "Fenris" in headline
@pytest.mark.asyncio
async def test_lifespan_headline_is_data_surface(self, tmp_path):
"""Lifespan headline remains a data surface, not app title."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity appears once, lifespan is separate data
assert "🐺 Fenris by Bongbetic" in headline
assert "remaining" in headline.lower() or "projection" in headline.lower()
class TestSingleMakerCredit:
"""TPH-1: Single maker-credit placement in title only."""
@pytest.mark.asyncio
async def test_maker_credit_not_in_service_strip(self, tmp_path):
"""Remove duplicate maker credit from service facts."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# "by Bongbetic" should NOT appear in service strip
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_maker_credit_in_titlebox(self, tmp_path):
"""Maker credit appears in the titlebox identity."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris by Bongbetic" in headline
@pytest.mark.asyncio
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
"""Empty store: no maker credit in service strip."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
@pytest.mark.asyncio
async def test_store_fault_no_maker_credit_in_strip(self, tmp_path):
"""Store fault: no maker credit in service strip."""
# Create a corrupt store
db = tmp_path / "test.db"
db.write_bytes(b"not a database")
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "by Bongbetic" not in strip
class TestDriveHealthVendorWear:
"""TPH-1: Vendor wear renders under Drive health context."""
@pytest.mark.asyncio
async def test_vendor_wear_in_drive_health(self, tmp_path):
"""Vendor wear shows with health context, not as Settings."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00", pu=10)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Vendor wear should be present with health context
assert "vendor wear" in health.lower()
# Should show thermal, spare, errors, etc.
assert "temperature" in health.lower()
assert "spare" in health.lower()
assert "media errors" in health.lower()
# Settings section should NOT be a separate heading
assert "[bold]Settings[/bold]" not in health
@pytest.mark.asyncio
async def test_missing_wear_values_honest(self, tmp_path):
"""Missing values remain honest unavailable facts."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Insert sample with zero wear values
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(120, 24)) as pilot:
health = str(app.query_one("#drive-health").render())
# Should show 0% used or honest zero, not crash
assert "vendor wear" in health.lower()
class TestPreservedBehavior:
"""Preserve existing continuity, pause, quit, auth behavior."""
@pytest.mark.asyncio
async def test_continuity_preserved(self, tmp_path):
"""Continuity wording preserved in service strip."""
conn = init_store(tmp_path / "test.db")
_open_period(conn)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.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(size=(120, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
assert "continuity" in strip.lower()
assert "monitoring" in strip.lower()
@pytest.mark.asyncio
async def test_quit_rail_preserved(self, tmp_path):
"""Separate q Quit TUI rail preserved."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(120, 24)) as pilot:
rail = str(app.query_one("#quit-rail").render())
assert "q Quit TUI" in rail
@pytest.mark.asyncio
async def test_auth_banner_preserved(self, tmp_path):
"""Polkit authentication banner lifecycle preserved."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with app.run_test(size=(120, 24)) as pilot:
headline = str(app.query_one("#headline-band").render())
assert "polkit" in headline.lower()
# Should clear after first tick
await pilot.pause(0.25)
headline_after = str(app.query_one("#headline-band").render())
assert "polkit" not in headline_after.lower()
@pytest.mark.asyncio
async def test_deliberate_pause_block_preserved(self, tmp_path):
"""Deliberate pause banner preserved."""
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.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,
}):
async with app.run_test(size=(120, 24)) as pilot:
banner = str(app.query_one("#paused-banner").render())
assert "paused" in banner.lower()
assert "deliberate" in banner.lower()
-482
View File
@@ -1,482 +0,0 @@
"""Integration tests for issue #80: Persist accessible colour and motion preferences.
Covers:
- AC80-1: Amber/Nord/High Contrast presets with Amber default
- AC80-2: t preset and m motion controls with clickable equivalents
- AC80-3: Persistent user-scoped XDG TUI preferences
- AC80-4: Reduced motion makes Monitoring steady
- AC80-5: Theme roles for graph rendering
- AC80-6: Headless interaction tests with temporary user config
"""
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
)
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
from fenris.status_composition import (
StatusState,
compose_status,
render_status_tui,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _clock(year=2026, month=9, day=30, hour=12):
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
identity_key="nqn.test", degraded=False,
mn="Samsung SSD 970 EVO Plus 1TB"):
conn.execute(
"INSERT INTO controller_segments "
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
"0x144d", "0x144d", "pcie"),
)
conn.commit()
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
conn.execute(
"INSERT INTO day_aggregates (day, active_seconds, idle_seconds, powered_off_seconds, "
"unknown_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, 3600, 0, 0, 0, bw, 0, samples, coverage),
)
conn.commit()
def _insert_sample(conn, ts, device="/dev/nvme0n1"):
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(ts, device, 1000000, 500000, 5, 512000000000, 256000000000, 8765),
)
conn.commit()
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
from fenris.monitoring_periods import ensure_period_open
ensure_period_open(conn, datetime.fromisoformat(start))
def _make_prefs_dir(tmp_path: Path) -> Path:
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# AC80-1: Preset loading and application
# ---------------------------------------------------------------------------
class TestPresetLoading:
"""Themes load from preferences and apply to the TUI."""
@pytest.mark.asyncio
async def test_tui_applies_amber_theme_by_default(self, tmp_path):
"""TUI starts with the Amber theme when no preferences exist."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Theme should be fenris-amber
assert app.theme == "fenris-amber"
@pytest.mark.asyncio
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
"""TUI applies Nord theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-nord"
@pytest.mark.asyncio
async def test_tui_applies_high_contrast_from_prefs(self, tmp_path):
"""TUI applies High Contrast theme from saved preferences."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=False)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
assert app.theme == "fenris-high-contrast"
@pytest.mark.asyncio
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
"""TUI respects reduced_motion preference."""
from fenris.tui import FenrisTuiApp
from fenris.status_composition import compose_status
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should have reduced_motion set
assert app._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-2: t and m key bindings
# ---------------------------------------------------------------------------
class TestKeyBindings:
"""t cycles presets and m toggles reduced motion."""
@pytest.mark.asyncio
async def test_t_binding_exists(self, tmp_path):
"""The TUI has a 't' binding for theme cycling."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "t" in binding_keys
@pytest.mark.asyncio
async def test_m_binding_exists(self, tmp_path):
"""The TUI has an 'm' binding for motion toggle."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
binding_keys = {b.key for b in app.BINDINGS}
assert "m" in binding_keys
@pytest.mark.asyncio
async def test_t_cycles_through_themes(self, tmp_path):
"""Pressing t cycles through the three presets."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start at amber
assert app.theme == "fenris-amber"
# Press t to cycle
await pilot.press("t")
await pilot.pause()
# Should be nord or high_contrast now
assert app.theme in ("fenris-nord", "fenris-high-contrast")
@pytest.mark.asyncio
async def test_m_toggles_reduced_motion(self, tmp_path):
"""Pressing m toggles reduced motion."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# Start with normal motion
assert app._reduced_motion is False
# Press m to toggle
await pilot.press("m")
await pilot.pause()
# Should be reduced motion now
assert app._reduced_motion is True
# Press m again to toggle back
await pilot.press("m")
await pilot.pause()
assert app._reduced_motion is False
# ---------------------------------------------------------------------------
# AC80-3: Persistence across restart
# ---------------------------------------------------------------------------
class TestPersistence:
"""Preferences survive app restart."""
@pytest.mark.asyncio
async def test_theme_survives_restart(self, tmp_path):
"""Theme preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: change theme
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("t")
await pilot.pause()
theme_after_t = app1.theme
assert theme_after_t != "fenris-amber"
# Second run: theme should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2.theme == theme_after_t
@pytest.mark.asyncio
async def test_reduced_motion_survives_restart(self, tmp_path):
"""Reduced motion preference persists across TUI restart."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# First run: toggle motion
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app1.run_test() as pilot:
await pilot.press("m")
await pilot.pause()
assert app1._reduced_motion is True
# Second run: motion should persist
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app2.run_test() as pilot:
assert app2._reduced_motion is True
# ---------------------------------------------------------------------------
# AC80-4: Reduced motion makes Monitoring steady
# ---------------------------------------------------------------------------
class TestReducedMotion:
"""Reduced motion disables the Monitoring dot blink."""
def test_reduced_motion_disables_blink(self, tmp_path):
"""compose_status with reduced_motion=True → should_blink=False."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=True)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is False
conn.close()
def test_normal_motion_allows_blink(self, tmp_path):
"""compose_status with reduced_motion=False → should_blink=True for Monitoring."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
conn = init_store(tmp_path / "test.db")
svc = {
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 120,
"last_collect_reason": None,
}
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
reduced_motion=False)
assert comp.state == StatusState.MONITORING
assert comp.should_blink is True
conn.close()
@pytest.mark.asyncio
async def test_framework_reduced_motion_honoured(self, tmp_path):
"""TUI honours Textual's reduced_motion signal."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# The app should check for reduced motion on mount
assert hasattr(app, '_reduced_motion')
# ---------------------------------------------------------------------------
# AC80-5: Theme roles for graph rendering
# ---------------------------------------------------------------------------
class TestGraphThemeRoles:
"""Graph uses theme-derived colours for each bar role."""
def test_graph_colors_available_for_all_themes(self):
"""Every theme provides all required graph colour roles."""
required_roles = {"allocated", "unallocated", "gap", "zero", "partial"}
for name in THEME_NAMES:
colors = get_graph_colors(name)
assert required_roles.issubset(set(colors.keys())), "Theme %s missing roles: %s" % (name, required_roles - set(colors.keys()))
def test_graph_colors_are_strings(self):
"""All graph colour values are strings (Textual CSS colour values)."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
for role, color in colors.items():
assert isinstance(color, str), "Theme %s role %s has non-string color: %s" % (name, role, color)
# ---------------------------------------------------------------------------
# AC80-6: Safe persistence — failure modes
# ---------------------------------------------------------------------------
class TestSafePersistence:
"""Preference failures never crash the dashboard."""
@pytest.mark.asyncio
async def test_corrupt_prefs_does_not_crash_tui(self, tmp_path):
"""Corrupt preference file does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / "preferences.json").write_text("{bad json!!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start with default theme
assert app.theme == "fenris-amber"
# Dashboard should be functional
headline = str(app.query_one("#headline-band").render())
assert headline is not None
@pytest.mark.asyncio
async def test_readonly_config_dir_does_not_crash(self, tmp_path):
"""Read-only config directory does not prevent TUI from starting."""
from fenris.tui import FenrisTuiApp
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
# TUI should start without crash
assert app.theme in ("fenris-amber", "fenris-nord", "fenris-high-contrast")
# ---------------------------------------------------------------------------
# CLI isolation (preferences do not affect CLI)
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""Preferences are TUI-only — CLI status is independent."""
def test_cli_status_unchanged_by_theme(self, tmp_path):
"""fenris status output does not change based on theme preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
config_home = _make_prefs_dir(tmp_path)
# With amber theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_amber = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# With high contrast theme
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_hc = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
# Status output should be identical
assert status_amber == status_hc
def test_cli_status_unchanged_by_motion(self, tmp_path):
"""fenris status output does not change based on reduced_motion preference."""
from fenris.status import get_status
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
now = _clock()
ts = (now - timedelta(minutes=2)).isoformat()
_insert_sample(conn, ts)
for i in range(20):
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
conn.close()
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
status_normal = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=True)
status_reduced = get_status(
store_path=tmp_path / "test.db",
clock_now=now,
query_services=False,
query_journal=False,
)
assert status_normal == status_reduced
+26 -11
View File
@@ -56,7 +56,8 @@ 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.enable_timer") as mock_enable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
conn = init_store(store_path)
@@ -73,7 +74,8 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
# Period should be open
@@ -98,7 +100,8 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
# Should still have exactly one open period
@@ -122,7 +125,8 @@ class TestEnableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.enable_timer") as mock_enable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
# Should have a new open period
@@ -151,7 +155,8 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.disable_timer") as mock_disable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_disable(args)
# Period should be closed with user_disabled
@@ -169,7 +174,8 @@ class TestDisableIdempotentMatrix:
args = MagicMock(now=True, store_path=store_path)
with patch("fenris.monitor.disable_timer") as mock_disable:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_disable(args)
# No periods should exist
@@ -201,19 +207,28 @@ class TestDisableIdempotentMatrix:
class TestCollectTrigger:
"""§8.7: On-demand collection via helper path."""
def test_collect_triggers_init_system(self):
"""Collect triggers init_system.collect_now."""
def test_collect_triggers_systemctl_start(self):
"""Collect starts fenris-collect.service synchronously."""
args = MagicMock()
with patch("fenris.monitor.collect_now") as mock_collect:
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_collect(args)
mock_collect.assert_called_once()
mock_sub.run.assert_called_once_with(
["systemctl", "start", "fenris-collect.service"],
capture_output=True,
text=True,
)
def test_collect_failure_exits_nonzero(self):
"""Collect failure exits with nonzero status."""
args = MagicMock()
with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)):
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(
returncode=1, stderr="Unit not found"
)
with pytest.raises(SystemExit) as exc_info:
cmd_collect(args)
assert exc_info.value.code == 1
+111 -719
View File
File diff suppressed because it is too large Load Diff
-301
View File
@@ -1,301 +0,0 @@
"""Tests for user-scoped TUI preferences (issue #80).
Covers:
- Preference load/save with safe defaults
- XDG_CONFIG_HOME user-scoped persistence
- Amber default theme, normal-motion default
- Invalid/unreadable/unwritable preference data does not crash
- Theme presets: Amber, Nord, High Contrast
- Reduced motion preference persistence
- CLI status and collector behaviour unchanged by preferences
"""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.preferences import (
load_preferences,
save_preferences,
get_preference_path,
PREFERENCE_FILE_NAME,
VALID_THEMES,
DEFAULT_THEME,
DEFAULT_REDUCED_MOTION,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_prefs_dir(tmp_path: Path) -> Path:
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
config_home = tmp_path / ".config"
config_home.mkdir(parents=True, exist_ok=True)
return config_home
# ---------------------------------------------------------------------------
# Preference path tests
# ---------------------------------------------------------------------------
class TestPreferencePath:
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
def test_uses_xdg_config_home(self, tmp_path):
"""Path respects XDG_CONFIG_HOME environment variable."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
path = get_preference_path()
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
def test_default_path_fallback(self):
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
with patch.dict(os.environ, {}, clear=True):
# Remove XDG_CONFIG_HOME if present
os.environ.pop("XDG_CONFIG_HOME", None)
path = get_preference_path()
assert "fenris" in str(path)
assert PREFERENCE_FILE_NAME in str(path)
# ---------------------------------------------------------------------------
# Default preferences tests
# ---------------------------------------------------------------------------
class TestDefaults:
"""When no preference file exists, defaults are returned."""
def test_default_theme_is_amber(self):
"""Amber is the default theme preset."""
assert DEFAULT_THEME == "amber"
def test_default_reduced_motion_is_false(self):
"""Normal motion is the default."""
assert DEFAULT_REDUCED_MOTION is False
def test_valid_themes_are_all_presets(self):
"""Three valid presets exist."""
assert VALID_THEMES == {"amber", "nord", "high_contrast"}
def test_load_returns_defaults_when_no_file(self, tmp_path):
"""Missing preference file returns safe defaults."""
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# Save and load round-trip tests
# ---------------------------------------------------------------------------
class TestRoundTrip:
"""Preferences survive save → load."""
def test_save_and_load_basic(self, tmp_path):
"""Basic theme and motion save/load."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is True
def test_save_creates_directory(self, tmp_path):
"""Save creates the fenris config directory if missing."""
config_home = tmp_path / ".config"
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
def test_overwrites_existing(self, tmp_path):
"""Second save overwrites the first."""
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="amber", reduced_motion=False)
save_preferences(theme="high_contrast", reduced_motion=True)
prefs = load_preferences()
assert prefs["theme"] == "high_contrast"
assert prefs["reduced_motion"] is True
def test_all_themes_round_trip(self, tmp_path):
"""Every valid theme saves and loads correctly."""
config_home = _make_prefs_dir(tmp_path)
for theme in VALID_THEMES:
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme=theme, reduced_motion=False)
prefs = load_preferences()
assert prefs["theme"] == theme
# ---------------------------------------------------------------------------
# Safe failure tests — invalid/unreadable/unwritable
# ---------------------------------------------------------------------------
class TestSafeFailures:
"""Invalid data never crashes the dashboard."""
def test_corrupt_json_returns_defaults(self, tmp_path):
"""Malformed JSON returns safe defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
assert prefs["reduced_motion"] is False
def test_unknown_theme_returns_default(self, tmp_path):
"""Unrecognized theme value falls back to amber."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "neon-pink", "reduced_motion": False})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_missing_keys_get_defaults(self, tmp_path):
"""Partial preference file fills in missing keys."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "nord"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "nord"
assert prefs["reduced_motion"] is False
def test_unreadable_file_returns_defaults(self, tmp_path):
"""Permission denied on preference file returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
pref_file = fenris_dir / PREFERENCE_FILE_NAME
pref_file.write_text(json.dumps({"theme": "nord"}))
pref_file.chmod(0o000)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
# Should fall back to defaults without crashing
assert prefs["theme"] in VALID_THEMES
def test_unwritable_location_returns_defaults(self, tmp_path):
"""Read-only config directory returns defaults without crashing."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
fenris_dir.chmod(0o555)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
# Should not raise
save_preferences(theme="nord", reduced_motion=True)
prefs = load_preferences()
# Either saved successfully or fell back — either way, no crash
assert prefs["theme"] in VALID_THEMES
def test_non_json_file_returns_defaults(self, tmp_path):
"""A file that isn't JSON returns defaults."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["theme"] == "amber"
def test_wrong_type_for_reduced_motion(self, tmp_path):
"""Non-boolean reduced_motion falls back to default."""
config_home = _make_prefs_dir(tmp_path)
fenris_dir = config_home / "fenris"
fenris_dir.mkdir(parents=True, exist_ok=True)
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
json.dumps({"theme": "amber", "reduced_motion": "yes"})
)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
prefs = load_preferences()
assert prefs["reduced_motion"] is False
# ---------------------------------------------------------------------------
# CLI/collector isolation tests
# ---------------------------------------------------------------------------
class TestCLIIsolation:
"""TUI display preferences do not affect CLI status or collector."""
def test_cli_status_ignores_preferences(self, tmp_path):
"""fenris status output is independent of TUI preferences."""
from fenris.status import get_status
# Create a valid store with data
from fenris.store import init_store
conn = init_store(tmp_path / "test.db")
conn.execute(
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
"percentage_used, bytes_written, bytes_read, power_on_hours) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
5, 512000000000, 256000000000, 8765),
)
conn.commit()
conn.close()
# Save non-default preferences
config_home = _make_prefs_dir(tmp_path)
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="high_contrast", reduced_motion=True)
status_text = get_status(
store_path=tmp_path / "test.db",
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
query_services=False,
query_journal=False,
)
# Status output should NOT contain theme names or motion settings
assert "high_contrast" not in status_text.lower()
assert "reduced_motion" not in status_text.lower()
assert "amber" not in status_text.lower()
def test_preferences_do_not_alter_store(self, tmp_path):
"""Saving preferences never writes to the observation store."""
import sqlite3
config_home = _make_prefs_dir(tmp_path)
store_path = tmp_path / "observations.db"
from fenris.store import init_store
conn = init_store(store_path)
# Record store state before preference save
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_before = sorted(r[0] for r in cursor.fetchall())
conn.close()
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
save_preferences(theme="nord", reduced_motion=True)
# Store schema should be unchanged
conn = sqlite3.connect(store_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables_after = sorted(r[0] for r in cursor.fetchall())
conn.close()
assert tables_before == tables_after
# Need datetime for CLI isolation test
from datetime import datetime, timezone
+1 -124
View File
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
from fenris.projection import (
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
YOUNG_REGIME_DAYS, DISCLOSURES,
)
@@ -899,126 +899,3 @@ class TestIdentityChangeBlankKeys:
# All 25 days in same segment (equal blanks continue)
assert result.regime_days is not None
assert result.regime_days >= 20 # Most of the history
# ===========================================================================
# Issue #76: Evidence-anchored projection rates
# Scenario windows anchored at latest evidence endpoint T
# ===========================================================================
class TestEvidenceAnchoredHorizons:
"""Issue #76: Scenario windows anchored at latest published usage-evidence
endpoint T with exact trailing 7/28/90×86400-second starts."""
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
"""Horizon rate is computed from T (latest evidence), not clock_now."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
# 30 days of data ending Sep 29
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
result = compute_projection(store, clock)
# 7-day horizon should be anchored at Sep 29, not Oct 1
if result.scenario_range and 7 in result.scenario_range.rates:
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
assert result.scenario_range is not None
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
"""Reader refresh alone never moves T or dilutes rates."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Two reads at different clock times
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
# Both should produce identical scenario rates (anchored at T, not clock)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
"""Specific reasons are shown for horizons that can't be computed."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
_open_period(store, start="2026-09-20T00:00:00+00:00")
bw = 100 * 1024 * 1024
# Only 10 days of data
for i in range(10):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# 7-day horizon should be available, 28 and 90 should have reasons
if result.scenario_range:
assert 7 in result.scenario_range.rates
if 28 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
if 90 in result.scenario_range.horizon_reasons:
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
def test_cumulative_endurance_in_headline(self, store):
"""Headline uses cumulative endurance consumption, not regime writes."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Headline should be computed with cumulative bytes
if result.headline_remaining_seconds is not None:
# E_baseline = 10 TB = 10e12 bytes
# cumulative_bytes = 30 * 100 * 1024 * 1024
# rate = cumulative_bytes / wall_clock
# headline = (E_baseline - cumulative_bytes) / rate
E_baseline = 10.0 * TBW_TO_BYTES
cumulative_bytes = 30 * bw
assert result.headline_remaining_seconds >= 0
def test_zero_boundary_delta_returns_zero(self, store):
"""Zero monotonic delta proves zero over its represented subspan."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
# Days with zero bytes written
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=0)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
result = compute_projection(store, _clock())
# Zero rate should result in UNSUPPORTED
assert result.confidence_state == ConfidenceState.UNSUPPORTED
assert result.headline_remaining_seconds is None
def test_noon_endpoint_same_as_midnight(self, store):
"""Noon endpoint produces same rates as midnight endpoint."""
_insert_baseline(store, tbw_tb=10.0, verified=True)
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
_open_period(store, start="2026-09-01T00:00:00+00:00")
bw = 100 * 1024 * 1024
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(store, d, bw=bw)
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
# Both reads should produce same scenario rates
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
r1 = compute_projection(store, clock1)
r2 = compute_projection(store, clock2)
if r1.scenario_range and r2.scenario_range:
assert r1.scenario_range.rates == r2.scenario_range.rates
+3 -54
View File
@@ -1,9 +1,7 @@
"""Raw sample pruning tests.
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
day aggregates are retained indefinitely.
Issue #74: Boundary anchors required for successor evidence are retained.
day aggregates retained indefinitely.
"""
import sqlite3
import sys
@@ -53,9 +51,6 @@ class TestPruneOldSamples:
def test_removes_old_samples(self, store_conn):
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert samples at 10, 14, and 15 days ago
# All three are before the cutoff (2026-09-01T12:00:00)
# The 15-day-old sample is not a boundary anchor because
# the next sample (14 days ago) is also before the cutoff
for days_ago in [10, 14, 15]:
ts = (now - timedelta(days=days_ago)).isoformat()
_insert_sample(store_conn, ts)
@@ -87,7 +82,6 @@ class TestPruneOldSamples:
"""Hour observations are retained indefinitely."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
# Insert an old sample and a recent sample
# The old sample is a boundary anchor (needed for derivation)
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
@@ -101,55 +95,10 @@ class TestPruneOldSamples:
prune_old_samples(store_conn, now, retention_days=14)
# Old sample is retained as boundary anchor (needed for derivation)
# Sample removed
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
assert cursor.fetchone()[0] == 2
assert cursor.fetchone()[0] == 1
# Hour observation retained
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_boundary_anchor_retained(self, store_conn):
"""Boundary anchors required for derivation are retained."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_old_sample_with_derived_interval_removed(self, store_conn):
"""Old samples with fully derived intervals are removed."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
store_conn.execute(
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
)
store_conn.commit()
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
-398
View File
@@ -1,398 +0,0 @@
"""Repair and retention tests (issue #74).
Tests the idempotent, safe repair of hour observations and day aggregates
from surviving raw samples, boundary anchor retention, and legacy summary
handling at actual precision.
"""
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import init_store
from fenris.repair import (
repair_derivation,
is_repair_in_progress,
get_repair_status,
)
from fenris.pruning import prune_old_samples, needs_boundary_anchor
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def store_conn(tmp_path: Path):
"""Create a fresh store for each test."""
db_path = tmp_path / "test.db"
conn = init_store(db_path)
yield conn
conn.close()
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
device="/dev/nvme0", segment_id=None):
"""Insert a raw sample."""
conn.execute(
"""INSERT INTO samples
(ts, device, bytes_written, bytes_read, power_on_hours,
data_units_written, data_units_read, segment_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
bytes_written // 512000, bytes_read // 512000, segment_id),
)
conn.commit()
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
sample_count=1, coverage=1.0):
"""Insert an hour observation."""
conn.execute(
"""INSERT INTO hour_observations
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, bytes_read_delta, sample_count, coverage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(hour_iso, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
bytes_written_delta, 0, sample_count, coverage),
)
conn.commit()
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
"""Insert a day aggregate."""
conn.execute(
"""INSERT INTO day_aggregates
(day, active_seconds, bytes_written_delta, coverage, sample_count)
VALUES (?, 3600, ?, ?, 1)""",
(day, bytes_written_delta, coverage),
)
conn.commit()
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
"""Insert a monitoring period."""
conn.execute(
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
VALUES (?, ?, ?)""",
(start_iso, end_iso, end_cause),
)
conn.commit()
# ---------------------------------------------------------------------------
# AC1: Normal collection, legacy import and recovery use same evidence rules
# ---------------------------------------------------------------------------
class TestRepairUsesSameEvidenceRules:
"""AC1: Repair derives only surviving supported evidence, transactionally
and idempotently."""
def test_repair_idempotent_on_empty_store(self, store_conn):
"""Repair on empty store succeeds and does nothing."""
result = repair_derivation(store_conn)
assert result.ok is True
assert result.hours_created == 0
assert result.days_created == 0
def test_repair_idempotent_on_fully_derived(self, store_conn):
"""Repair on store with existing derived data does not duplicate."""
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert samples that span an hour
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Pre-existing hour observation for the 10:00 hour
# This hour observation captures the interval [10:00, 10:30]
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=1000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create new observations (already exist)
assert result.hours_created == 0
assert result.days_created == 0
# Verify no duplicates
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_repair_preserves_import_markers(self, store_conn):
"""Repair does not remove legacy import markers."""
# Set legacy import marker
store_conn.execute(
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
)
store_conn.commit()
# Run repair
result = repair_derivation(store_conn)
# Verify marker preserved
cursor = store_conn.execute(
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
)
assert cursor.fetchone()[0] == "true"
# ---------------------------------------------------------------------------
# AC2: Rerunning or interrupting repair produces no duplicates
# ---------------------------------------------------------------------------
class TestRepairIdempotency:
"""AC2: No duplicated intervals or totals from repeated repair."""
def test_repair_no_duplicate_hours(self, store_conn):
"""Running repair twice produces no duplicate hour observations."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.hours_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.hours_created == 0 # No new hours
# Verify only one hour observation
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
assert cursor.fetchone()[0] == 1
def test_repair_no_duplicate_days(self, store_conn):
"""Running repair twice produces no duplicate day aggregates."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# First repair
result1 = repair_derivation(store_conn)
assert result1.days_created == 1
# Second repair
result2 = repair_derivation(store_conn)
assert result2.days_created == 0 # No new days
# Verify only one day aggregate
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
assert cursor.fetchone()[0] == 1
def test_interrupted_repair_preserves_evidence(self, store_conn):
"""If repair fails, prior valid history is preserved."""
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
# Insert valid existing data
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=500000)
_insert_day_aggregate(store_conn, "2026-09-14",
bytes_written_delta=500000)
# Run repair (should succeed but not modify existing valid data)
result = repair_derivation(store_conn)
assert result.ok is True
# Verify existing data preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM hour_observations "
"WHERE hour = '2026-09-14T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 500000
# ---------------------------------------------------------------------------
# AC3: Boundary anchor retention
# ---------------------------------------------------------------------------
class TestBoundaryAnchorRetention:
"""AC3: Prune samples only after durable derivation; retain anchors."""
def test_needs_boundary_anchor_sample(self, store_conn):
"""Sample before 14-day boundary is needed for derivation."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Sample just before 14-day boundary (2026-09-15T23:55:00)
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
# Sample just after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# The sample at 2026-09-15 is a boundary anchor because
# the interval spans the retention boundary
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
def test_not_boundary_anchor_if_fully_derived(self, store_conn):
"""Sample that's fully derived is not a boundary anchor."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Insert sample and fully derive its interval
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Hour observation already exists for this interval
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
bytes_written_delta=1000000)
# Not a boundary anchor
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
def test_pruning_retains_boundary_anchors(self, store_conn):
"""Pruning keeps samples needed as boundary anchors."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample before boundary (2026-09-14T23:55:00)
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
# Sample after boundary (2026-09-16T12:30:00)
# is 13 days and 23.5 hours old (within retention)
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
# Recent sample
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# The boundary anchor should be retained
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
)
assert cursor.fetchone()[0] == 1
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn):
"""Pruning removes old samples when interval is fully derived."""
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
# Old sample with derived interval
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
# Hour observation exists for the interval
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
bytes_written_delta=1000000)
# Run pruning
pruned = prune_old_samples(store_conn, now, retention_days=14)
# Old sample should be removed (interval is derived)
cursor = store_conn.execute(
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
)
assert cursor.fetchone()[0] == 0
# ---------------------------------------------------------------------------
# AC4: Legacy day-only summaries retain actual precision
# ---------------------------------------------------------------------------
class TestLegacySummaryPrecision:
"""AC4: Legacy summaries at actual precision, no interpolation."""
def test_legacy_summary_not_reconstructed(self, store_conn):
"""Legacy day-only summaries are not interpolated to hour detail."""
# Insert a legacy-style day aggregate without hour observations
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Should not create hour observations for legacy day
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
)
assert cursor.fetchone()[0] == 0
def test_legacy_summary_no_double_counting(self, store_conn):
"""Legacy summaries and derived intervals don't double-count."""
# Insert legacy day aggregate
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Run repair
result = repair_derivation(store_conn)
# Day aggregate should not be modified
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# ---------------------------------------------------------------------------
# AC5: Status distinguishes evidence states
# ---------------------------------------------------------------------------
class TestStatusEvidenceDistingushing:
"""AC5: Read-only views distinguish evidence states."""
def test_repair_status_available(self, store_conn):
"""Repair status is available for read-only views."""
status = get_repair_status(store_conn)
assert hasattr(status, 'last_repair')
assert hasattr(status, 'repair_in_progress')
assert hasattr(status, 'hours_derived')
assert hasattr(status, 'days_derived')
def test_repair_in_progress_flag(self, store_conn):
"""Repair in progress flag is trackable."""
assert not is_repair_in_progress(store_conn)
# ---------------------------------------------------------------------------
# AC6: Migration then collection then reader consumption
# ---------------------------------------------------------------------------
class TestMigrationCollectionReader:
"""AC6: End-to-end migration, collection, and reader consumption."""
def test_store_with_raw_evidence_and_summaries(self, store_conn):
"""Store with raw evidence and old summaries works correctly."""
# Set up store with mixed data
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
# Old day-only summary (legacy)
_insert_day_aggregate(store_conn, "2026-08-01",
bytes_written_delta=5000000)
# Recent raw samples
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
# Run repair
result = repair_derivation(store_conn)
assert result.ok is True
# Verify legacy summary preserved
cursor = store_conn.execute(
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
)
assert cursor.fetchone()[0] == 5000000
# Verify new hour observation created
cursor = store_conn.execute(
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
)
assert cursor.fetchone()[0] == 1
def test_concurrent_reader_consistency(self, store_conn):
"""Reader sees consistent snapshot during repair."""
# This is more of a documentation test - SQLite WAL mode handles this
# We verify the store is in WAL mode
cursor = store_conn.execute("PRAGMA journal_mode")
assert cursor.fetchone()[0] == "wal"
-1
View File
@@ -622,7 +622,6 @@ 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={
File diff suppressed because it is too large Load Diff
-150
View File
@@ -1,150 +0,0 @@
"""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("#constrained-summary").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")
+4 -4
View File
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
assert migrate_to_latest(db) == 0
def test_migrates_intermediate_version(self, tmp_path):
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
from fenris.store import SCHEMA_VERSION
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION - 1)
_make_store(db, version=1)
# SCHEMA_VERSION is 1, so version 1 is current
steps = migrate_to_latest(db)
assert steps == 1
assert steps == 0
# ---------------------------------------------------------------------------
-186
View File
@@ -1,186 +0,0 @@
"""Tests for Fenris theme presets (issue #80).
Covers:
- Three valid presets: Amber, Nord, High Contrast
- Amber is the default with amber graph role
- Theme roles for graph rendering (allocated, unallocated, gap, zero, partial)
- Status semantic colours/glyphs/text always win over theme
- Global action reachability and focus contrast in every preset
"""
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.themes import (
THEMES,
THEME_NAMES,
get_theme,
get_graph_colors,
STATUS_COLORS,
)
# ---------------------------------------------------------------------------
# Theme registry tests
# ---------------------------------------------------------------------------
class TestThemeRegistry:
"""All three presets are registered with correct names."""
def test_three_themes_registered(self):
"""Exactly three themes exist."""
assert len(THEMES) == 3
def test_theme_names(self):
"""Theme names are amber, nord, high_contrast."""
assert THEME_NAMES == {"amber", "nord", "high_contrast"}
def test_get_theme_valid(self):
"""get_theme returns a Theme for each valid name."""
from textual.theme import Theme
for name in THEME_NAMES:
theme = get_theme(name)
assert isinstance(theme, Theme)
def test_get_theme_invalid_returns_amber(self):
"""Unknown theme name returns the amber theme."""
theme = get_theme("nonexistent")
assert theme.name == "fenris-amber"
# ---------------------------------------------------------------------------
# Amber theme tests (default, amber graph role)
# ---------------------------------------------------------------------------
class TestAmberTheme:
"""Amber is the default theme with warm tones."""
def test_amber_is_dark(self):
"""Amber is a dark theme."""
theme = get_theme("amber")
assert theme.dark is True
def test_amber_primary_is_amber(self):
"""Primary colour is warm amber."""
theme = get_theme("amber")
assert "d4a017" in theme.primary.lower() or "amber" in theme.primary.lower()
def test_amber_has_graph_colors(self):
"""Amber defines all required graph role variables."""
colors = get_graph_colors("amber")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Nord theme tests
# ---------------------------------------------------------------------------
class TestNordTheme:
"""Nord uses the polar night palette."""
def test_nord_is_dark(self):
"""Nord is a dark theme."""
theme = get_theme("nord")
assert theme.dark is True
def test_nord_has_graph_colors(self):
"""Nord defines all required graph role variables."""
colors = get_graph_colors("nord")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# High Contrast theme tests
# ---------------------------------------------------------------------------
class TestHighContrastTheme:
"""High Contrast for maximum readability."""
def test_high_contrast_is_dark(self):
"""High contrast is a dark theme."""
theme = get_theme("high_contrast")
assert theme.dark is True
def test_high_contrast_foreground_is_white(self):
"""Foreground is pure white for maximum contrast."""
theme = get_theme("high_contract") if False else get_theme("high_contrast")
assert theme.foreground is not None
def test_high_contrast_has_graph_colors(self):
"""High contrast defines all required graph role variables."""
colors = get_graph_colors("high_contrast")
assert "allocated" in colors
assert "unallocated" in colors
assert "gap" in colors
assert "zero" in colors
assert "partial" in colors
# ---------------------------------------------------------------------------
# Status semantic colours override theme (AC80-1)
# ---------------------------------------------------------------------------
class TestStatusSemanticOverride:
"""Status semantic colours/glyphs/text always win over theme styling."""
def test_status_colors_defined(self):
"""STATUS_COLORS maps each StatusState to a fixed colour."""
from fenris.status_composition import StatusState
for state in StatusState:
assert state.value in STATUS_COLORS
def test_status_colors_are_not_theme_dependent(self):
"""Status colours are the same regardless of theme."""
from fenris.status_composition import StatusState
# These are the canonical status colours — they must not change with theme
assert STATUS_COLORS[StatusState.MONITORING.value] == "green"
assert STATUS_COLORS[StatusState.ERROR.value] == "red"
assert STATUS_COLORS[StatusState.PAUSED.value] == "yellow"
assert STATUS_COLORS[StatusState.INTERRUPTED.value] == "red"
assert STATUS_COLORS[StatusState.STALE.value] == "red"
assert STATUS_COLORS[StatusState.WAITING.value] == "yellow"
assert STATUS_COLORS[StatusState.UNKNOWN.value] == "dim"
assert STATUS_COLORS[StatusState.COLLECTING.value] == "green"
# ---------------------------------------------------------------------------
# Graph colour role tests (AC80-5)
# ---------------------------------------------------------------------------
class TestGraphColorRoles:
"""Theme roles for graph rendering expose distinct colours per preset."""
def test_all_themes_define_same_roles(self):
"""Every theme has the same set of graph colour roles."""
roles = None
for name in THEME_NAMES:
colors = get_graph_colors(name)
if roles is None:
roles = set(colors.keys())
else:
assert set(colors.keys()) == roles
def test_graph_roles_are_distinct_across_presets(self):
"""Different themes produce different graph colour values."""
amber = get_graph_colors("amber")
nord = get_graph_colors("nord")
hc = get_graph_colors("high_contrast")
# At least one role should differ between themes
assert amber["allocated"] != nord["allocated"] or amber["allocated"] != hc["allocated"]
def test_zero_role_is_dim(self):
"""Zero-usage bars use a dim/subtle colour in all themes."""
for name in THEME_NAMES:
colors = get_graph_colors(name)
# Zero should be distinct from allocated
assert colors["zero"] != colors["allocated"]
+105 -770
View File
@@ -40,21 +40,12 @@ from fenris.status import (
)
from fenris.tui import (
FenrisTuiApp,
DailyBarGraph,
_format_remaining,
_sparkline,
_habit_bar,
_query_usage_history,
_query_drive_health,
_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,
_query_service_facts,
)
@@ -138,6 +129,64 @@ 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")
@@ -153,6 +202,27 @@ 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
# ---------------------------------------------------------------------------
@@ -298,10 +368,7 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_service_strip_has_four_facts(self, tmp_path):
"""Service strip has four separate facts (boot, timer, collect, freshness).
Maker credit removed from service strip (issue #79: single placement in title).
"""
"""Service strip has four separate facts (boot, timer, collect, freshness)."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
@@ -314,12 +381,11 @@ 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
# Maker credit now in titlebox only (issue #79)
assert "by Bongbetic" not in strip
assert "boot:" in strip
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
assert "by Bongbetic" in strip
@pytest.mark.asyncio
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
@@ -329,7 +395,7 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.status.query_service_state", return_value={
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
@@ -342,9 +408,9 @@ class TestDenseScreen:
quit_rail = app.query_one("#quit-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "r resume — enable monitoring and future boots" in strip
assert "p pause · r resume · c collect · d disclosures" in strip
assert "q quit" not in strip
assert rail == "q Quit TUI"
assert rail == "q QUIT TUI"
assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y
@@ -363,11 +429,11 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.status.query_service_state", return_value={
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test(size=(80, 24)) as pilot:
@@ -389,7 +455,7 @@ class TestDenseScreen:
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()
@@ -404,7 +470,7 @@ class TestDenseScreen:
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()
@@ -426,11 +492,11 @@ class TestDenseScreen:
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.status.query_service_state", return_value={
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test() as pilot:
@@ -448,23 +514,18 @@ class TestDenseScreen:
@pytest.mark.asyncio
async def test_branding_and_one_time_auth_banner(self, tmp_path):
"""Identity is visible at launch; auth notice clears once per session.
Maker credit in titlebox only (issue #79: Fenris by Bongbetic).
"""
"""Identity is visible at launch; auth notice clears once per session."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
auth_notice = "privileged actions will prompt for authentication (polkit)"
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
# Identity now shows Fenris by Bongbetic (issue #79)
assert "Fenris by Bongbetic" in headline
assert "Fenris — NVMe endurance monitor" in headline
assert auth_notice in headline
# Maker credit removed from service strip (issue #79)
assert "by Bongbetic" not in str(app.query_one("#service-strip").render())
assert "by Bongbetic" 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())
@@ -512,66 +573,15 @@ 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 makes the keyboard action and boot effect explicit."""
"""First-run prompt enables timer and opens first period."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
strip = str(app.query_one("#service-strip").render())
assert "no observations yet" in headline.lower()
assert "r resume — enable monitoring and future boots" in headline.lower()
assert "r resume — enable monitoring and future boots" in strip.lower()
# The enable hint should mention resume
assert "resume" in headline.lower()
# ---------------------------------------------------------------------------
@@ -669,678 +679,3 @@ class TestStateMatrixCombinations:
# Incomplete provenance → UNVERIFIED tier
assert proj.baseline_tier.value == "unverified_override"
conn.close()
# ---------------------------------------------------------------------------
# Hour observation helper for graph tests
# ---------------------------------------------------------------------------
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
unknown=0, coverage=1.0, samples=1):
"""Insert an hour observation row."""
conn.execute(
"INSERT INTO hour_observations "
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
)
conn.commit()
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
coverage=0.95, samples=24):
"""Insert a day aggregate with explicit unattributed bytes."""
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, bytes_read_delta, sample_count, coverage, "
" unattributed_bytes_written, unattributed_bytes_read) "
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
(day, bw, samples, coverage, unattributed),
)
conn.commit()
# ---------------------------------------------------------------------------
# Graph data query tests (issue #75)
# ---------------------------------------------------------------------------
class TestQueryDailyGraphData:
def test_empty_store(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_daily_graph_data(conn)
assert result == []
conn.close()
def test_with_days(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
result = _query_daily_graph_data(conn)
assert len(result) == 14
assert result[0]["total_bytes"] == 1024*1024*100
assert result[0]["allocated_bytes"] == 1024*1024*100
assert result[0]["unallocated_bytes"] == 0
assert result[0]["is_zero"] is False
conn.close()
def test_zero_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_zero"] is True
assert result[0]["total_bytes"] == 0
conn.close()
def test_unattributed_bytes(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
_insert_day_with_unattributed(
conn, "2026-09-20", bw=500, unattributed=300
)
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["allocated_bytes"] == 500
assert result[0]["unallocated_bytes"] == 300
assert result[0]["total_bytes"] == 800
conn.close()
def test_gap_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Day with no hours but unknown seconds (gap in monitoring period)
conn.execute(
"INSERT INTO day_aggregates "
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
" bytes_written_delta, sample_count, coverage) "
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
("2026-09-20",),
)
conn.commit()
result = _query_daily_graph_data(conn)
assert len(result) == 1
assert result[0]["is_gap"] is True
conn.close()
class TestQueryHourlyGraphData:
def test_empty_day(self, tmp_path):
conn = init_store(tmp_path / "test.db")
result = _query_hourly_graph_data(conn, "2026-09-20")
assert result == []
conn.close()
def test_with_hours(self, tmp_path):
conn = init_store(tmp_path / "test.db")
for h in range(24):
hour = "2026-09-20T%02d:00:00+00:00" % h
bw = 1024*1024*100 if h in (10, 14) else 0
_insert_hour(conn, hour, bw=bw)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 24
assert result[10]["bytes_written"] == 1024*1024*100
assert result[10]["is_zero"] is False
assert result[0]["is_zero"] is True
conn.close()
def test_hour_labels(self, tmp_path):
conn = init_store(tmp_path / "test.db")
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
result = _query_hourly_graph_data(conn, "2026-09-20")
assert len(result) == 1
assert result[0]["local_label"] == "12"
conn.close()
# ---------------------------------------------------------------------------
# DailyBarGraph widget unit tests (issue #75)
# ---------------------------------------------------------------------------
class TestDailyBarGraph:
def test_empty_data(self, tmp_path):
"""Empty data shows awaiting message."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
# We test the widget directly via the app's compose
graph = DailyBarGraph()
# Simulate setting empty data
from textual.app import App as TextualApp
class _TestApp(TextualApp):
def compose(self):
yield graph
# We can't easily test widget lifecycle outside an app, so test the data
assert graph._all_day_data == []
assert graph.range_days == _RANGE_DEFAULT
def test_range_default(self):
"""Default range is 14 days."""
graph = DailyBarGraph()
assert graph.range_days == _RANGE_DEFAULT
assert graph.selected_index == -1
assert graph.view_mode == "daily"
def test_trim_to_range(self):
"""Data is trimmed to the selected range."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 31)]
graph._all_day_data = data
graph.range_days = 7
graph._trim_to_range()
assert len(graph._day_data) == 7
assert graph._day_data[0]["day"] == "2026-09-24"
def test_range_all_fits(self):
"""When data fits within range, all days are shown."""
graph = DailyBarGraph()
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
for d in range(1, 8)]
graph._all_day_data = data
graph.range_days = 14
graph._trim_to_range()
assert len(graph._day_data) == 7
# ---------------------------------------------------------------------------
# Bar graph headless TUI tests (issue #75)
# ---------------------------------------------------------------------------
class TestBarGraphTUI:
"""Headless tests for the interactive bar graph."""
@pytest.mark.asyncio
async def test_graph_renders_with_data(self, tmp_path):
"""Graph widget renders with day data."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
assert len(graph._day_data) > 0
# Legend should be visible
legend = str(app.query_one("#bar-legend").render())
assert "Alloc" in legend
assert "Zero" in legend
@pytest.mark.asyncio
async def test_arrow_selection(self, tmp_path):
"""Left/right arrows move the selection."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Right arrow selects first bar
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Right again moves to second
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 1
# Left moves back
await pilot.press("left")
await pilot.pause()
assert graph.selected_index == 0
@pytest.mark.asyncio
async def test_range_switching(self, tmp_path):
"""1-4 keys switch the visible range."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(30):
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Default is 14
assert graph.range_days == 14
assert len(graph._day_data) == 14
# Press 1 for 7-day range
await pilot.press("1")
await pilot.pause()
assert graph.range_days == 7
assert len(graph._day_data) == 7
# Press 3 for 28-day range
await pilot.press("3")
await pilot.pause()
assert graph.range_days == 28
assert len(graph._day_data) == 28
# Press 4 for 90-day range (only 30 days available)
await pilot.press("4")
await pilot.pause()
assert graph.range_days == 90
assert len(graph._day_data) == 30
@pytest.mark.asyncio
async def test_readout_updates_on_selection(self, tmp_path):
"""Readout shows selected day info."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# No selection initially
readout = str(app.query_one("#bar-readout").render())
assert "select" in readout.lower()
# Select first bar
await pilot.press("right")
await pilot.pause()
readout = str(app.query_one("#bar-readout").render())
assert "2026-09" in readout
assert "GB" in readout
@pytest.mark.asyncio
async def test_hourly_drill_down_and_back(self, tmp_path):
"""Enter drills into hourly view, Esc returns to daily."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
# Insert hours for each day
for h in range(24):
hour = "%sT%02d:00:00+00:00" % (d, h)
bw_h = 1024*1024*10 if h == 12 else 0
_insert_hour(conn, hour, bw=bw_h)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
app.set_focus(graph)
await pilot.pause()
await pilot.pause()
# Select a day
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
assert graph.view_mode == "daily"
# Enter drill-down
await pilot.press("enter")
await pilot.pause()
assert graph.view_mode == "hourly"
assert graph.drill_day is not None
assert len(graph._hour_data) == 24
# Esc returns to daily
await pilot.press("escape")
await pilot.pause()
assert graph.view_mode == "daily"
assert graph.drill_day is None
@pytest.mark.asyncio
async def test_empty_store_graph(self, tmp_path):
"""Empty store shows awaiting message in graph."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert isinstance(graph, DailyBarGraph)
render = str(app.query_one("#bar-render").render())
assert "awaiting" in render.lower()
@pytest.mark.asyncio
async def test_graph_border_title(self, tmp_path):
"""Graph pane has a border title."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(80, 24)) as pilot:
graph = app.query_one("#usage-history")
assert graph.border_title == "Usage history"
@pytest.mark.asyncio
async def test_glyphs_in_legend(self, tmp_path):
"""Legend shows all required glyphs."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
for i in range(7):
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
legend = str(app.query_one("#bar-legend").render())
assert _GLYPH_ALLOCATED in legend
assert _GLYPH_UNALLOCATED in legend
assert _GLYPH_GAP in legend
assert _GLYPH_ZERO in legend
assert _GLYPH_PARTIAL in legend
# ---------------------------------------------------------------------------
# Issue #81: Constrained size layout
# ---------------------------------------------------------------------------
class TestConstrainedLayout:
"""Tests for constrained terminal size behavior (issue #81)."""
@pytest.mark.asyncio
async def test_constrained_at_80x24(self, tmp_path):
"""At 80×24 the dashboard renders normally with all regions visible."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# All four regions should be visible
assert app.query_one("#headline-band") is not None
assert app.query_one("#usage-history") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
# Graph should not be in constrained mode
assert not app._is_constrained_mode
# Constrained summary should be hidden
summary = app.query_one("#constrained-summary")
assert str(summary.styles.display) == "none"
@pytest.mark.asyncio
async def test_constrained_below_80_width(self, tmp_path):
"""Below 80 columns the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Main grid should have constrained class
main_grid = app.query_one("#main-grid")
assert main_grid.has_class("constrained")
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
assert "days" in summary_text
assert "GB total" in summary_text
@pytest.mark.asyncio
async def test_constrained_below_24_height(self, tmp_path):
"""Below 24 rows the graph is hidden and textual summary shown."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 23)) as pilot:
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should be visible
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
@pytest.mark.asyncio
async def test_resize_from_constrained_to_normal(self, tmp_path):
"""Resizing from constrained to normal restores graph and coherent state."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Start in constrained mode
assert app._is_constrained_mode
graph = app.query_one("#usage-history")
# Select a day while constrained
graph.selected_index = 5
# Resize to normal — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(80, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should no longer be constrained
assert not app._is_constrained_mode
# Main grid should not have constrained class
main_grid = app.query_one("#main-grid")
assert not main_grid.has_class("constrained")
# Selection should be preserved
assert graph.selected_index == 5
# Graph should have data
assert len(graph._day_data) > 0
@pytest.mark.asyncio
async def test_resize_from_normal_to_constrained(self, tmp_path):
"""Resizing from normal to constrained hides graph and shows summary."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(80, 24)) as pilot:
# Start in normal mode
assert not app._is_constrained_mode
graph = app.query_one("#usage-history")
# Focus and select a day
app.set_focus(graph)
await pilot.pause()
await pilot.press("right")
await pilot.pause()
assert graph.selected_index == 0
# Resize to constrained — pilot.resize_terminal changes terminal size
await pilot.resize_terminal(79, 24)
await pilot.pause()
# Trigger refresh so constrained state updates (timer would do this)
app._refresh()
await pilot.pause()
# Should be in constrained mode
assert app._is_constrained_mode
# Constrained summary should show selected day context
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
# Selected day context should survive
assert "2026-09" in summary_text
@pytest.mark.asyncio
async def test_constrained_summary_empty_store(self, tmp_path):
"""Constrained summary shows awaiting message for empty store."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
summary = app.query_one("#constrained-summary")
summary_text = str(summary.render())
assert "Graph needs ≥80×24" in summary_text
assert "awaiting" in summary_text.lower()
@pytest.mark.asyncio
async def test_constrained_all_regions_usable(self, tmp_path):
"""All regions remain usable in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# All regions should exist
assert app.query_one("#headline-band") is not None
assert app.query_one("#drive-health") is not None
assert app.query_one("#service-strip") is not None
assert app.query_one("#quit-rail") is not None
# Headline should contain projection
headline = str(app.query_one("#headline-band").render())
assert "remaining" in headline.lower() or "projection" in headline.lower()
# Service strip should have actions
strip = str(app.query_one("#service-strip").render())
assert "p Pause" in strip
assert "r Resume" in strip
assert "q quit" not in strip # Quit is in quit-rail
# Quit rail should be visible
rail = str(app.query_one("#quit-rail").render())
assert "Quit" in rail
@pytest.mark.asyncio
async def test_constrained_long_reasons_visible(self, tmp_path):
"""Long status reasons remain visible in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_segment(conn)
_open_period(conn)
# Insert stale data
stale_ts = (_clock() - timedelta(days=10)).isoformat()
_insert_sample(conn, stale_ts, pu=5)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
strip = str(app.query_one("#service-strip").render())
# Status should be visible even with long reasons
assert "stale" in strip.lower() or "freshness" in strip.lower()
@pytest.mark.asyncio
async def test_constrained_keyboard_navigation(self, tmp_path):
"""Keyboard focus traversal works in constrained mode."""
conn = init_store(tmp_path / "test.db")
_insert_baseline(conn, tbw_tb=1.0, verified=True)
_insert_segment(conn)
_open_period(conn)
for i in range(14):
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
_insert_day(conn, d, bw=1024*1024*100)
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
async with app.run_test(size=(79, 24)) as pilot:
# Quit should still work
await pilot.press("q")
assert not app.is_running
@pytest.mark.asyncio
async def test_constrained_theme_and_motion_toggle(self, tmp_path):
"""Theme and motion toggles work in constrained mode."""
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
async with app.run_test(size=(79, 24)) as pilot:
assert app._is_constrained_mode
# Toggle theme
await pilot.press("t")
await pilot.pause()
# Toggle motion
await pilot.press("m")
await pilot.pause()
# Should still be constrained
assert app._is_constrained_mode
def test_min_width_constant(self):
"""_MIN_WIDTH is 80 columns."""
assert _MIN_WIDTH == 80
def test_min_height_constant(self):
"""_MIN_HEIGHT is 24 rows."""
assert _MIN_HEIGHT == 24
def test_is_constrained_with_terminal_width(self, tmp_path):
"""DailyBarGraph._is_constrained checks terminal width."""
graph = DailyBarGraph()
# Widget-level check (no terminal width)
assert graph._is_constrained() is False
# Terminal width below minimum
assert graph._is_constrained(terminal_width=79) is True
# Terminal width at minimum
assert graph._is_constrained(terminal_width=80) is False
# Terminal width above minimum
assert graph._is_constrained(terminal_width=120) is False
-12
View File
@@ -1,12 +0,0 @@
#!/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
@@ -1,40 +0,0 @@
#!/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 5-minute cadence (sleep 300 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=300
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