Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714e69be52 | ||
|
|
ba16413363 | ||
|
|
dfe6a6a2d0 | ||
|
|
44c57b70dd | ||
|
|
f06424f3b8 | ||
|
|
fae72bb07b | ||
|
|
9d22525403 | ||
|
|
a3e6cc3b3c | ||
|
|
34bfc70bb8 | ||
|
|
8b6d4b2447 | ||
|
|
72dacab03b | ||
|
|
cca6804964 | ||
|
|
dea2186d6b | ||
|
|
967ba6964f | ||
|
|
efcfd266b7 | ||
|
|
1113532c9a | ||
|
|
95c75badd5 | ||
|
|
70dbae65fb | ||
|
|
d119a09b1f | ||
|
|
fca0724fb4 | ||
|
|
1c3037c2f8 | ||
|
|
2d4cb16a00 | ||
|
|
d93238b0f3 | ||
|
|
a279c56be5 | ||
|
|
e27052d09a | ||
|
|
37a0ed7030 | ||
|
|
985efed906 |
@@ -8,6 +8,12 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_xbps:
|
||||
description: 'Publish XBPS package to distribution repository (requires host acceptance)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
# Built-in Gitea token needs write access for release assets and package registry.
|
||||
permissions:
|
||||
@@ -58,10 +64,27 @@ jobs:
|
||||
-o /tmp/nfpm.tar.gz
|
||||
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
||||
nfpm --version
|
||||
# Ubuntu does not package the XBPS build tools. Use Void's static
|
||||
# toolchain, pinned and checksum-verified before it reaches PATH.
|
||||
XBPS_STATIC_VERSION=0.60.4_1
|
||||
XBPS_STATIC_ARCHIVE="xbps-static-static-${XBPS_STATIC_VERSION}.x86_64-musl.tar.xz"
|
||||
XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://repo-default.voidlinux.org/static/${XBPS_STATIC_ARCHIVE}" \
|
||||
-o "/tmp/${XBPS_STATIC_ARCHIVE}"
|
||||
echo "${XBPS_STATIC_SHA256} /tmp/${XBPS_STATIC_ARCHIVE}" | sha256sum --check --strict
|
||||
mkdir -p /tmp/xbps-static
|
||||
tar -xJf "/tmp/${XBPS_STATIC_ARCHIVE}" -C /tmp/xbps-static
|
||||
export PATH="/tmp/xbps-static/usr/bin:${PATH}"
|
||||
echo "/tmp/xbps-static/usr/bin" >> "$GITHUB_PATH"
|
||||
xbps-create --version
|
||||
|
||||
- name: Build packages
|
||||
run: make package
|
||||
|
||||
- name: Build XBPS package
|
||||
run: make package-xbps
|
||||
|
||||
- name: Import packaging key
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
@@ -87,6 +110,26 @@ jobs:
|
||||
- name: Sign RPM payload
|
||||
run: make sign-rpm
|
||||
|
||||
- name: Import XBPS signing key
|
||||
id: import-xbps-key
|
||||
env:
|
||||
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${XBPS_SIGNING_KEY}" ]; then
|
||||
echo "::warning::XBPS_SIGNING_KEY secret not configured; XBPS signing skipped"
|
||||
echo "xbps_signed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${XBPS_SIGNING_KEY}" > ~/.ssh/id_xbps
|
||||
chmod 600 ~/.ssh/id_xbps
|
||||
echo "xbps_signed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Sign XBPS package
|
||||
if: steps.import-xbps-key.outputs.xbps_signed == 'true'
|
||||
run: make sign-xbps
|
||||
|
||||
- name: Generate and clearsign SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -116,6 +159,7 @@ jobs:
|
||||
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
|
||||
gpg --batch --yes --delete-keys "${FINGERPRINT}"
|
||||
fi
|
||||
rm -f ~/.ssh/id_xbps
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
@@ -159,6 +203,54 @@ jobs:
|
||||
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Publish XBPS to distribution repository
|
||||
if: github.event.inputs.publish_xbps == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
||||
if [ ! -f "${XBPS_FILE}" ]; then
|
||||
echo "::error::XBPS package not found: ${XBPS_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${XBPS_FILE}.sig2" ]; then
|
||||
echo "::error::XBPS signature not found: ${XBPS_FILE}.sig2"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/xbps-publish.sh --publish
|
||||
|
||||
- name: Track format availability
|
||||
id: formats
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
|
||||
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
|
||||
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
|
||||
XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false")
|
||||
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||
echo "xbps_published=${XBPS_PUBLISHED}" >> "$GITHUB_OUTPUT"
|
||||
# Build format availability summary for release notes
|
||||
AVAILABLE_FORMATS=""
|
||||
WITHHELD_FORMATS=""
|
||||
if [ "${DEB_EXISTS}" = "true" ]; then
|
||||
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Debian/Ubuntu (deb), "
|
||||
fi
|
||||
if [ "${RPM_EXISTS}" = "true" ]; then
|
||||
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Fedora/openSUSE (rpm), "
|
||||
fi
|
||||
if [ "${XBPS_EXISTS}" = "true" ] && [ "${XBPS_PUBLISHED}" = "true" ]; then
|
||||
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Void Linux (xbps)"
|
||||
elif [ "${XBPS_EXISTS}" = "true" ]; then
|
||||
WITHHELD_FORMATS="Void Linux (xbps) — pending host acceptance"
|
||||
fi
|
||||
# Remove trailing comma and space
|
||||
AVAILABLE_FORMATS=$(echo "${AVAILABLE_FORMATS}" | sed 's/, $//')
|
||||
echo "available_formats=${AVAILABLE_FORMATS}" >> "$GITHUB_OUTPUT"
|
||||
echo "withheld_formats=${WITHHELD_FORMATS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||
@@ -174,6 +266,18 @@ jobs:
|
||||
echo "::error::validated release body is missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
# Append format availability to release notes
|
||||
AVAILABLE_FORMATS="${{ steps.formats.outputs.available_formats }}"
|
||||
WITHHELD_FORMATS="${{ steps.formats.outputs.withheld_formats }}"
|
||||
RELEASE_BODY_WITH_FORMATS="${RUNNER_TEMP}/release-body-formats.md"
|
||||
cp "${RELEASE_BODY}" "${RELEASE_BODY_WITH_FORMATS}"
|
||||
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||
echo "## Package formats" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||
echo "Available: ${AVAILABLE_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||
if [ -n "${WITHHELD_FORMATS}" ]; then
|
||||
echo "Withheld: ${WITHHELD_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||
fi
|
||||
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
|
||||
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
|
||||
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||
@@ -182,11 +286,11 @@ jobs:
|
||||
200)
|
||||
echo "Release v${VERSION} exists; resynchronizing its notes"
|
||||
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
|
||||
--body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")"
|
||||
;;
|
||||
404)
|
||||
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||
--body-file "${RELEASE_BODY}")"
|
||||
--body-file "${RELEASE_BODY_WITH_FORMATS}")"
|
||||
;;
|
||||
*)
|
||||
echo "::error::release lookup failed with HTTP ${EXISTING}"
|
||||
@@ -214,10 +318,21 @@ jobs:
|
||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
||||
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
# Attach deb, rpm, and clearsigned checksums once.
|
||||
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
|
||||
"dist/fenris-${VERSION}-1.x86_64.rpm" \
|
||||
"dist/SHA256SUMS.asc"; do
|
||||
# Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
|
||||
ARTIFACTS=(
|
||||
"dist/fenris_${VERSION}_amd64.deb"
|
||||
"dist/fenris-${VERSION}-1.x86_64.rpm"
|
||||
"dist/SHA256SUMS.asc"
|
||||
)
|
||||
# Add XBPS artifacts if they exist
|
||||
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
||||
if [ -f "${XBPS_FILE}" ]; then
|
||||
ARTIFACTS+=("${XBPS_FILE}")
|
||||
if [ -f "${XBPS_FILE}.sig2" ]; then
|
||||
ARTIFACTS+=("${XBPS_FILE}.sig2")
|
||||
fi
|
||||
fi
|
||||
for FILE in "${ARTIFACTS[@]}"; do
|
||||
ASSET_NAME="${FILE##*/}"
|
||||
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
|
||||
echo "${ASSET_NAME}: already attached"
|
||||
|
||||
@@ -18,3 +18,5 @@ json
|
||||
src/fenris.egg-info/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
MagicMock*
|
||||
<MagicMock*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
## Agent skills
|
||||
|
||||
## Commit messages
|
||||
|
||||
Do not add `Co-authored-by: CommandCodeBot <noreply@commandcode.ai>` or other
|
||||
CommandCodeBot attribution trailers to commits.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
@@ -9,6 +9,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Fenris contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -18,7 +18,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
|
||||
# Legacy history path (IN-4)
|
||||
LEGACY_HISTORY := ./data/history.jsonl
|
||||
|
||||
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
|
||||
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package-xbps package generate-test-key sign-rpm sign-xbps checksums clearsign release release-run release-dry-run xbps-publish xbps-publish-dry-run clean
|
||||
|
||||
help:
|
||||
@echo "Fenris NVMe endurance monitor"
|
||||
@@ -35,6 +35,8 @@ help:
|
||||
@echo " package - Build deb + rpm packages"
|
||||
@echo " package-deb - Build deb package only"
|
||||
@echo " package-rpm - Build rpm package only"
|
||||
@echo " package-xbps - Build XBPS package only"
|
||||
@echo " sign-xbps - Sign XBPS package with signing key"
|
||||
@echo " generate-test-key - Create throwaway GPG key for CI/testing"
|
||||
@echo " sign-rpm - Sign RPM payload with packaging key"
|
||||
@echo " checksums - Generate SHA256SUMS manifest"
|
||||
@@ -42,6 +44,8 @@ help:
|
||||
@echo " release - Full release (build, sign, checksum, print upload steps)"
|
||||
@echo " release-run - Execute the full release flow via scripts/release.sh"
|
||||
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
|
||||
@echo " xbps-publish - Publish XBPS package to distribution repository"
|
||||
@echo " xbps-publish-dry-run - Dry-run of XBPS publication (prints commands only)"
|
||||
@echo " clean - Remove build artifacts"
|
||||
|
||||
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
||||
@@ -70,7 +74,7 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
|
||||
@echo "=== Creating data directory (root-written, group-read) ==="
|
||||
@sudo groupadd -f fenris
|
||||
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
|
||||
@sudo install -d -o root -g fenris -m 2770 $(DATA_DIR)
|
||||
|
||||
@echo "=== Installing version-neutral runtime packages ==="
|
||||
@sudo rm -rf $(VENV_DIR)
|
||||
@@ -89,6 +93,13 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||
@sudo systemctl daemon-reload
|
||||
|
||||
@echo "=== Installing runit service files (dormant — not enabled) ==="
|
||||
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
|
||||
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||
@sudo touch /etc/sv/fenris-collect/down
|
||||
|
||||
@echo "=== Installing polkit policy ==="
|
||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||
|
||||
@@ -101,10 +112,14 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
|
||||
@echo "=== Install complete ==="
|
||||
@@ -139,6 +154,11 @@ upgrade: dist/fenris-*.whl
|
||||
@echo "=== Syncing units against manifest ==="
|
||||
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||
@sudo groupadd -f fenris
|
||||
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
|
||||
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
||||
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
|
||||
@@ -154,10 +174,14 @@ upgrade: dist/fenris-*.whl
|
||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
|
||||
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
|
||||
@@ -192,6 +216,9 @@ uninstall:
|
||||
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||
@sudo systemctl daemon-reload
|
||||
@-sudo rm -f /var/service/fenris-collect 2>/dev/null || true
|
||||
@-sudo rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||
@-sudo rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||
|
||||
@echo "=== Removing installed files (preserving config and store) ==="
|
||||
@-rm -f $(BIN_DIR)/fenris
|
||||
@@ -257,6 +284,42 @@ package-rpm: stage
|
||||
package: package-deb package-rpm
|
||||
@echo "=== Both packages built in dist/ ==="
|
||||
|
||||
# ─── XBPS packaging (ADR 0008) ─────────────────────────────────────────────
|
||||
|
||||
# XBPS signing key (separate from SSH authentication key)
|
||||
XBPS_SIGNING_KEY ?= $(HOME)/.ssh/id_xbps
|
||||
XBPS_REVISION ?= 1
|
||||
|
||||
package-xbps: stage
|
||||
@echo "=== Building XBPS package ==="
|
||||
cp packaging/xbps/install.sh build/stage/INSTALL
|
||||
cp packaging/xbps/remove.sh build/stage/REMOVE
|
||||
install -D -m 0644 packaging/fenris.conf build/stage/etc/fenris/fenris.conf
|
||||
chmod 755 build/stage/INSTALL build/stage/REMOVE
|
||||
xbps-create -A x86_64 \
|
||||
-n fenris-$(FENRIS_VERSION)_$(XBPS_REVISION) \
|
||||
-s "Fenris NVMe wear monitor" \
|
||||
-S "NVMe wear monitor with persistent TUI" \
|
||||
-m "Fenris Packaging <packaging@bongbetic.com>" \
|
||||
-H "https://git.bongbetic.com/xavierk/Fenris" \
|
||||
-l "MIT" \
|
||||
-D "python3>=3.10 smartmontools>=0" \
|
||||
-F "/etc/fenris/fenris.conf" \
|
||||
build/stage
|
||||
@echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps ==="
|
||||
|
||||
sign-xbps: package-xbps
|
||||
@echo "=== Signing XBPS package ==="
|
||||
xbps-rindex --sign-pkg --privkey $(XBPS_SIGNING_KEY) \
|
||||
fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps
|
||||
@echo "=== XBPS package signed ==="
|
||||
|
||||
xbps-publish:
|
||||
bash scripts/xbps-publish.sh --publish
|
||||
|
||||
xbps-publish-dry-run:
|
||||
bash scripts/xbps-publish.sh --dry-run
|
||||
|
||||
# ─── GPG key management ─────────────────────────────────────────────────────
|
||||
|
||||
generate-test-key:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
|
||||
|
||||
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
|
||||
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on the host's native scheduler. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,7 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
|
||||
|
||||
- **Python ≥ 3.10** (verified at install time)
|
||||
- **smartmontools** (`smartctl` — verified at install time)
|
||||
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
|
||||
- **systemd** or **runit**, with a polkit agent (the collector runs as root; elevation is exclusively polkit)
|
||||
|
||||
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
||||
|
||||
@@ -66,6 +66,46 @@ The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded
|
||||
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
|
||||
TLS).
|
||||
|
||||
### Void Linux (XBPS)
|
||||
|
||||
Void x86_64 with glibc and runit is the native target. Its signed XBPS channel
|
||||
is available from the permanent repository below. Add it, refresh its
|
||||
metadata, and install the released package:
|
||||
|
||||
```bash
|
||||
sudo install -d -m 0755 /etc/xbps.d
|
||||
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
|
||||
| sudo tee /etc/xbps.d/fenris.conf
|
||||
sudo xbps-install -M -S fenris
|
||||
```
|
||||
|
||||
XBPS requires remote repositories to be signed. On the first refresh it
|
||||
displays the repository signing key embedded in the signed metadata; accept it
|
||||
only when its RSA SHA256 fingerprint is
|
||||
`SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`. The public key is also
|
||||
available at
|
||||
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`.
|
||||
For later updates, always refresh first so XBPS fetches the current index:
|
||||
|
||||
```bash
|
||||
sudo xbps-install -M -Syu
|
||||
```
|
||||
|
||||
The `-M` flag bypasses XBPS's on-disk repodata cache. It is required when
|
||||
checking for a newly published package through Gitea's cached raw-file URL.
|
||||
|
||||
The runit service remains dormant after installation. `fenris monitor resume`
|
||||
creates `/var/service/fenris-collect`; pause removes that link and records a
|
||||
deliberate disable in the observation history.
|
||||
|
||||
Fenris keeps the observation store root-written and readable by the `fenris`
|
||||
group. Add each TUI user to that group, then start a new login session before
|
||||
running Fenris:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG fenris "$USER"
|
||||
```
|
||||
|
||||
### Package signature verification
|
||||
|
||||
The RPM payload is signed with the Fenris packaging key (RSA 3072).
|
||||
@@ -84,12 +124,12 @@ The packaging public key is published in-repo — no keyservers. See
|
||||
|
||||
### Dormant install
|
||||
|
||||
A fresh package install is fully dormant. Units are present but disabled;
|
||||
nothing runs. The only opt-in is the sanctioned toggle:
|
||||
A fresh package install is fully dormant. Its native scheduler is present but
|
||||
disabled; nothing runs. The only opt-in is the sanctioned toggle:
|
||||
|
||||
```bash
|
||||
fenris monitor resume # enable timer + open first monitoring period
|
||||
fenris monitor pause # close the period, disable timer
|
||||
fenris monitor resume # enable scheduling + open first monitoring period
|
||||
fenris monitor pause # close the period, disable scheduling
|
||||
```
|
||||
|
||||
## Development install (make install)
|
||||
@@ -117,6 +157,7 @@ make purge # also removes /etc/fenris and /var/lib/fenris
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
||||
sudo dnf upgrade fenris # Fedora
|
||||
sudo xbps-install -Syu # Void Linux
|
||||
```
|
||||
|
||||
### Development upgrade
|
||||
@@ -133,6 +174,12 @@ What it does:
|
||||
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
|
||||
|
||||
Rollback: reinstall the previous version and restore `observations.db.bak`.
|
||||
On Void, pause monitoring first, copy the compatible snapshot back to
|
||||
`/var/lib/fenris/observations.db`, then force-install the matching older
|
||||
package version. If that version is no longer indexed, add its retained XBPS
|
||||
archive to a local repository with `xbps-rindex -a` and use
|
||||
`xbps-install -R <local-repository> -f fenris-<version>`. Installing an older
|
||||
package over a newer observation store is unsupported.
|
||||
|
||||
## Migration from make install
|
||||
|
||||
@@ -150,6 +197,7 @@ continuity. Over-installing the package over a `make install` is
|
||||
sudo apt remove fenris # preserves config and store
|
||||
sudo apt purge fenris # also removes config and store
|
||||
sudo dnf remove fenris # preserves config and store
|
||||
sudo xbps-remove fenris # preserves config and store
|
||||
```
|
||||
|
||||
### Development removal
|
||||
@@ -174,6 +222,19 @@ sudo systemctl edit fenris-collect.timer
|
||||
|
||||
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
||||
|
||||
On Void, Fenris uses its native runit service instead: its initial collection
|
||||
is delayed by two minutes and later collections run 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 |
|
||||
@@ -181,8 +242,8 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
||||
| `fenris` | Opens the TUI (no arguments). |
|
||||
| `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. |
|
||||
| `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. |
|
||||
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. |
|
||||
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
|
||||
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables native scheduling and closes the monitoring period. |
|
||||
| `fenris monitor resume` | Sanctioned enable — enables native scheduling and opens a monitoring period. No confirmation. |
|
||||
| `fenris baseline set <json>` | CLI-side validation, then polkit-guarded persistence. |
|
||||
| `fenris baseline clear` | Remove the endurance baseline. |
|
||||
| `fenris import <path>` | Idempotent single-transaction legacy import. |
|
||||
@@ -191,11 +252,33 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
||||
|
||||
## Reading the dashboard
|
||||
|
||||
`fenris` opens the TUI 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.
|
||||
|
||||
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||
- **Auth banner** — the launch notice explains normal-user startup and polkit authentication, then clears on the first refresh. The `?` help screen remains available.
|
||||
|
||||
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 8. Native Void Linux support and XBPS delivery
|
||||
|
||||
Status: Accepted — implementation and host acceptance completed; evidence is recorded in [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87).
|
||||
|
||||
Fenris will support Void Linux natively with runit and full application feature parity, while retaining its existing Debian/RPM and systemd support. This extends the platform boundary in [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) and the delivery scope in [ADR 0007](0007-package-delivery-amends-0004.md): requiring Void users to replace their init system would not meet the native-support goal.
|
||||
|
||||
Delivery will include a Fenris-maintained, signed XBPS repository that users configure once for subsequent installation and updates through XBPS, plus versioned release artifacts and notes on Gitea. All downloads must be served directly by Gitea itself; a separate static HTTP repository, even alongside Gitea, does not satisfy this requirement.
|
||||
|
||||
Use the dedicated public Gitea repository `xavierk/Fenris-xbps` with its permanent `stable` branch. Its raw-file URL serves the XBPS index, versioned packages, and package signatures as ordinary Git blobs without LFS. Keeping binaries in a separate repository avoids increasing application source-clone size. This accepts growth in distribution-repository Git history in exchange for publishing index and artifacts together through one branch update, without the generic registry's delete-and-upload index replacement gap.
|
||||
|
||||
Serialize publication, commit the signed index and its new artifacts together, and retain older versioned artifacts in the current tree so clients with cached older indexes can still download them. Native XBPS installation and update tests against the actual endpoint are required before release validation. The first release acceptance recorded in issue #87 verified direct artifact delivery, signed metadata, retained packages, and immediate discovery after an explicit memory-synchronized refresh. The Gitea raw endpoint advertises six-hour HTTP caching; users should use `xbps-install -M -S` when looking for updates so XBPS bypasses its on-disk repodata cache.
|
||||
|
||||
Immediate availability is required: after successful XBPS publication, an explicit repository refresh against the permanent URL must discover the newly published version without a cache-expiry wait or a URL change. This does not promise automatic installation on client machines. Acceptance must exercise a client that fetched the previous index before publication and verify that refresh retrieves the new index and its signed package afterward. Resolve and document actual client and intermediary cache behavior; if the selected Gitea route cannot meet this requirement, hold XBPS publication and revisit its delivery mechanics rather than silently accepting delayed availability.
|
||||
|
||||
Hosting evidence: [Gitea generic registry](https://docs.gitea.com/usage/packages/generic), [raw download routing](https://github.com/go-gitea/gitea/blob/main/routers/web/web.go), [download handler](https://github.com/go-gitea/gitea/blob/main/routers/web/repo/download.go), and [Void repository signing](https://docs.voidlinux.org/xbps/repositories/signing.html). Source inspection and an existing raw-file GET establish feasibility, not end-to-end XBPS validation.
|
||||
|
||||
The first supported Void target is x86_64 with glibc, matching the inspected development machine. Release validation must cover installation, real collection, pause/resume, reboot persistence, upgrade, and removal on that machine, preserving existing observation history. Debian/RPM compatibility remains part of the acceptance scope. Additional architectures and musl support are outside this first release.
|
||||
|
||||
Package formats have independent publication gates: publish each validated format, and hold only formats that have not passed release validation. A failure or pending validation in XBPS must not prevent a validated Debian or RPM package from shipping, and vice versa. Release notes must identify available formats and those still withheld; publication must not imply validation of a missing format.
|
||||
|
||||
After successful native acceptance testing, leave the released XBPS package installed on this machine and monitoring the selected NVMe drive. Preserve the observation history collected during testing. Coordinate the reboot test with the user so it can occur at a suitable interruption point. Issue #87 records that this final state, including reboot persistence, was achieved for the first supported release.
|
||||
@@ -0,0 +1,8 @@
|
||||
# One MIT license across source and packages
|
||||
|
||||
The native package metadata previously disagreed: deb/rpm declared Proprietary,
|
||||
while XBPS declared MIT and the repository carried no license text. On
|
||||
2026-09-16 the maintainer chose MIT for Fenris. The repository now includes the
|
||||
standard MIT license, and Python, deb, rpm, and XBPS distributions must preserve
|
||||
that same licensing decision; bundled third-party dependencies retain their own
|
||||
license notices.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Native Void Linux and XBPS distribution
|
||||
|
||||
Status: Approved and published as [Support native Void Linux and signed XBPS distribution through Gitea](https://git.bongbetic.com/xavierk/Fenris/issues/82).
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Void users cannot install and operate Fenris natively through XBPS because its runtime lifecycle assumes systemd and its release pipeline only produces Debian and RPM packages. The user requires full functionality on the current Void desktop, installation and updates through XBPS, and all downloads served directly by Gitea.
|
||||
|
||||
## Solution
|
||||
|
||||
Support Void x86_64 with glibc and runit alongside existing systemd distributions. Provide a signed XBPS repository at a permanent raw-file URL in a dedicated public Gitea repository, provisionally Fenris-xbps on its stable branch. Publish versioned assets and notes in the application's Gitea release. Validate each package format independently and publish only formats that passed their gates.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a Void user, I want to install Fenris through XBPS so package ownership and dependencies are managed normally.
|
||||
2. As a Void user, I want native runit integration so my operating system's init system remains supported.
|
||||
3. As a user, I want a dormant fresh installation so monitoring starts only when I opt in.
|
||||
4. As a user, I want authenticated resume and pause controls so privileged operations remain narrowly scoped.
|
||||
5. As a user, I want scheduled collection to continue after closing the TUI so observation history remains useful.
|
||||
6. As a user, I want on-demand collection to return its real result without overlapping scheduled collection.
|
||||
7. As a user, I want boot enablement, current activity, last collection outcome, and freshness reported separately.
|
||||
8. As a user, I want actionable native diagnostics when collection fails.
|
||||
9. As a user, I want deliberate pauses distinguished from unexplained service interruptions in my monitoring periods.
|
||||
10. As a user, I want bounded failed collection runs so a hung device query does not stop future monitoring indefinitely.
|
||||
11. As a user, I want all existing dashboard, history, confidence, graph, and accessibility features on Void.
|
||||
12. As a user, I want signed downloads directly from Gitea so the configured distribution source and trust key remain consistent.
|
||||
13. As a user, I want one permanent repository address so future updates require no URL changes.
|
||||
14. As a user, I want an explicit repository refresh to discover a newly published package immediately.
|
||||
15. As a user, I want upgrades to preserve configuration, preferences, and observation history and create a usable store snapshot.
|
||||
16. As a user, I want removal to stop monitoring deliberately while preserving my history.
|
||||
17. As a user, I want documented rollback through a compatible snapshot and earlier release.
|
||||
18. As a Debian or RPM user, I want existing functionality and delivery to remain supported.
|
||||
19. As a maintainer, I want a failing package format held without blocking validated formats.
|
||||
20. As a maintainer, I want release notes to distinguish available formats from withheld ones.
|
||||
21. As the owner of this Void machine, I want real installation and lifecycle validation, including a coordinated reboot.
|
||||
22. As the owner, I want the released XBPS package left installed and monitoring afterward, preserving test observation history.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
- Amend the existing systemd-only service contract to support native runit while retaining its external semantics. Keep service-specific operations behind a cohesive responsibility shared by the existing privileged control path and read-only status composition; avoid a generalized init-system plugin framework.
|
||||
- Preserve a single device-acquisition path, the unprivileged CLI/TUI, and narrow authenticated privileged operations. Verify effective polkit authorization on both platforms; do not infer it from policy installation alone.
|
||||
- Preserve completion-relative five-minute scheduling, initial boot delay, bounded collection duration, no catch-up, serialized scheduled/on-demand runs, and truthful outcome reporting. Runit must supply equivalents for guarantees currently provided by systemd. Select internal coordination mechanics during implementation and test their externally observable guarantees.
|
||||
- Preserve monitoring-period semantics: sanctioned pause closes user_disabled; raw service interruptions do not record deliberate intent. Preserve separate boot-enabled and runtime-active facts.
|
||||
- Use native XBPS ownership and lifecycle scripts, signed index and package signatures, and normal dependency resolution. Keep configuration and observation history safe across upgrade/removal; preserve existing forward-only store compatibility and rollback policy.
|
||||
- Host ordinary Git blobs without LFS in the dedicated Gitea repository. Publish index, new versioned packages, and signatures together in one serialized branch update; retain old artifacts for clients with older indexes. Accept binary Git-history growth outside the application source repository.
|
||||
- Require immediate discoverability after successful publication through the permanent URL. Test clients that fetched the previous index first. Inspect actual client/intermediary caching before choosing a remedy; do not silently accept six-hour update lag or promise availability from an untested HTTP route.
|
||||
- Publish each format only after its own validation passes, even if others fail. Common source failures affect every format whose behavior they invalidate. Clearly report pending/failed formats; permit later addition of validated missing formats without overwriting previously published artifacts.
|
||||
- Keep version, release notes, artifact identity, and signatures consistent. Retain previous usable repository state on failed publication and verify externally served artifacts before reporting success.
|
||||
- First Void target is x86_64/glibc. Leave the released package installed and monitoring the selected NVMe drive after acceptance; coordinate the desktop reboot with the user.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- Primary runtime boundary: existing user commands and shared CLI/TUI observable state, backed by disposable observation stores and controlled service/acquisition outcomes. Test behavior, not a particular helper layout.
|
||||
- Exercise real runit in an isolated Void environment for scheduling, serialization, timeout recovery, enablement, pause/resume, and failure diagnostics. Preserve meaningful systemd regression coverage.
|
||||
- Extend existing package lifecycle acceptance tests with native XBPS install, upgrade, removal, configuration preservation, and safe store migration/snapshot scenarios. Use disposable stores for destructive removal/rollback cases.
|
||||
- Distribution boundary: a real XBPS client fetching signed metadata and packages from the Gitea endpoint. Verify clean install and upgrade from a previously fetched index immediately after publication, signature rejection, retained older artifacts, and publisher failure/concurrency behavior.
|
||||
- Host boundary: validate real SMART acquisition, authorization, CLI/TUI parity, scheduling, pause/resume, reboot persistence, upgrade and removal on this Void machine. Preserve collected history and restore the agreed final installed/monitoring state.
|
||||
- Record actual outcomes, skips, and environment failures. Build success, missing test output, stale test caches, and successful signing are not substitutes for package validation.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Musl, other architectures, additional init systems, official Void repository inclusion, a separate download server, automatic client upgrades, unrelated dashboard redesign, and automatic downgrade of a newer observation store.
|
||||
|
||||
## Further Notes
|
||||
|
||||
The design decisions are recorded in ADR 0008. The dedicated distribution repository and end-to-end XBPS proof are complete; the host acceptance record is [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87). The accepted target is Void x86_64/glibc with runit, with the released package installed and monitoring the selected NVMe drive after the coordinated reboot and lifecycle checks.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Native Void implementation tickets
|
||||
|
||||
Status: Approved and published as Gitea issues 83–87 with native blocking edges and ready-for-agent labels. Parent specification: https://git.bongbetic.com/xavierk/Fenris/issues/82.
|
||||
|
||||
Each issue references the published native Void specification, which includes ADR 0008. Existing Debian/RPM behavior, observation-history preservation, narrowly scoped privilege, and independent publication gates apply throughout.
|
||||
|
||||
Published tickets: [1](https://git.bongbetic.com/xavierk/Fenris/issues/83), [2](https://git.bongbetic.com/xavierk/Fenris/issues/84), [3](https://git.bongbetic.com/xavierk/Fenris/issues/85), [4](https://git.bongbetic.com/xavierk/Fenris/issues/86), [5](https://git.bongbetic.com/xavierk/Fenris/issues/87).
|
||||
|
||||
## 1. Prove signed XBPS installation and immediate updates through Gitea
|
||||
|
||||
Blocked by: None.
|
||||
|
||||
Deliver a dedicated public Gitea distribution repository and a repeatable, isolated XBPS-client proof using clearly identified test artifacts, without representing them as a validated Fenris release.
|
||||
|
||||
- Verify permanent raw URL delivery of index, package and signature bytes without LFS indirection.
|
||||
- Establish signing-key handling and trust verification without exposing private keys.
|
||||
- Demonstrate install and update with a client that fetched the old index immediately before publication.
|
||||
- Resolve actual cache behavior and verify binary size limits; escalate any hosting change outside the agreed Gitea scope.
|
||||
- Publish index/artifacts together with serialized updates, preserve older downloadable artifacts, and demonstrate safe failure recovery.
|
||||
- Record results and the usable publication mechanism for subsequent tickets.
|
||||
|
||||
## 2. Run and control Fenris monitoring natively under runit
|
||||
|
||||
Blocked by: None.
|
||||
|
||||
Deliver an end-to-end native monitoring path with existing CLI/TUI controls and truthful status in an isolated Void environment.
|
||||
|
||||
- Scheduled and on-demand collection use the same acquisition path with no overlap and bounded execution.
|
||||
- Preserve cadence, initial boot delay, recovery after failures, and no catch-up semantics.
|
||||
- Resume/pause preserve monitoring-period bookkeeping and boot/runtime distinctions; raw service stops do not record deliberate disable.
|
||||
- Verify effective authentication and actionable native diagnostics, including missing-agent failures.
|
||||
- Preserve systemd behavior and application feature parity through existing public behavior tests.
|
||||
|
||||
## 3. Install, upgrade and remove Fenris with native XBPS packages
|
||||
|
||||
Blocked by: 2.
|
||||
|
||||
Deliver buildable x86_64/glibc XBPS artifacts with dependency resolution and tested package lifecycle in an isolated Void environment.
|
||||
|
||||
- Fresh install remains dormant; native controls enable monitoring afterward.
|
||||
- Package ownership, configuration preservation, permissions, and group access support real CLI/TUI reads and collector writes.
|
||||
- Upgrade snapshots and migrates observation history safely without recording a deliberate pause.
|
||||
- Removal performs sanctioned pause and retains history; reinstall and documented snapshot rollback behave correctly.
|
||||
- Installation over incompatible unmanaged remnants fails with a useful migration path.
|
||||
- Capture explicit lifecycle test results and verify Debian/RPM regressions relevant to changed packaging.
|
||||
|
||||
## 4. Publish validated package formats independently from the release workflow
|
||||
|
||||
Blocked by: 1, 3.
|
||||
|
||||
Deliver a release workflow that builds, validates, signs and publishes XBPS alongside existing Debian/RPM support with independent format gates.
|
||||
|
||||
- Unvalidated formats remain withheld while validated formats can ship.
|
||||
- Notes accurately identify available and withheld formats; source version, notes, checksums and artifacts agree.
|
||||
- A withheld format can be added after validation without replacing existing published artifacts.
|
||||
- Gitea serves every download; XBPS uses the proven permanent repository URL and immediate-refresh behavior.
|
||||
- Release failure/concurrency cannot expose an index referencing missing artifacts or erase the prior usable channel.
|
||||
- Host acceptance remains a required XBPS release gate, not bypassed by build/signature success.
|
||||
|
||||
## 5. Validate and release on the user's Void machine
|
||||
|
||||
Blocked by: 4.
|
||||
|
||||
Deliver recorded host acceptance and the validated XBPS release, ending with Fenris installed and monitoring.
|
||||
|
||||
- Recheck host state, identify/configure the intended NVMe drive, and preserve pre-existing data before package lifecycle operations.
|
||||
- Verify real acquisition, authenticated controls, scheduling, failure reporting, full dashboard behavior, and pause/resume semantics.
|
||||
- Coordinate and verify reboot persistence; absence of the reboot test leaves that gate pending.
|
||||
- Verify native upgrade/removal/reinstall with history preservation and immediate update discovery through Gitea.
|
||||
- Publish only after the XBPS gate passes, then verify downloads and released-package installation.
|
||||
- Preserve acceptance-test observation history and leave the released package monitoring; document installation, trust setup, diagnostics and rollback for Void users.
|
||||
@@ -0,0 +1,153 @@
|
||||
# XBPS Proof of Concept Results (Issue #83)
|
||||
|
||||
Status: Complete. All acceptance criteria satisfied.
|
||||
|
||||
## Summary
|
||||
|
||||
Signed XBPS installation and immediate updates through Gitea have been demonstrated
|
||||
and verified end-to-end. The publication mechanism is repeatable and documented for
|
||||
subsequent tickets.
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
### 1. Permanent raw URL delivery without LFS indirection
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
All artifacts are served directly by Gitea via raw-file URLs:
|
||||
|
||||
- Repository: `https://git.bongbetic.com/xavierk/Fenris-xbps`
|
||||
- Raw URL pattern: `https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64/<file>`
|
||||
|
||||
Verified files:
|
||||
- `x86_64-repodata` (1327 bytes) — HTTP 200
|
||||
- `fenris-0.3.5_1.x86_64.xbps` (731 bytes) — HTTP 200
|
||||
- `fenris-0.3.5_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
|
||||
- `fenris-0.3.6_1.x86_64.xbps` (752 bytes) — HTTP 200
|
||||
- `fenris-0.3.6_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
|
||||
|
||||
No LFS indirection detected. Files served as ordinary Git blobs.
|
||||
|
||||
### 2. Signing-key handling and trust verification
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
Signing uses SSH RSA keys (3072-bit) via `xbps-rindex --sign-pkg` and `xbps-rindex --sign`.
|
||||
The public key is embedded in the repository metadata (`index-meta.plist`) within the
|
||||
repodata archive. XBPS clients prompt for key import on first access and verify
|
||||
signatures automatically.
|
||||
|
||||
Key characteristics:
|
||||
- Private key: SSH RSA format, stored externally (not in repository)
|
||||
- Public key: Embedded in repodata, base64-encoded PKCS#8 format
|
||||
- Package signatures: `.sig2` files alongside each `.xbps` archive
|
||||
- Repository signature: Embedded in `x86_64-repodata` metadata
|
||||
|
||||
### 3. Install and update with client that fetched old index
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
Demonstrated full lifecycle:
|
||||
|
||||
1. Fresh install of v0.3.5 from repository with only v0.3.5 in index
|
||||
2. Added v0.3.6 to index and committed to `stable` branch
|
||||
3. Client performed `xbps-install -Syu` and upgraded from 0.3.5 → 0.3.6
|
||||
|
||||
The upgrade was detected and executed without manual intervention:
|
||||
```
|
||||
fenris (0.3.5_1 -> 0.3.6_1)
|
||||
```
|
||||
|
||||
### 4. Cache behavior and binary size limits
|
||||
|
||||
**Result: PASS (with documented caveat)**
|
||||
|
||||
Cache behavior:
|
||||
- Gitea raw endpoint: `cache-control: public, max-age=21600` (6-hour cache)
|
||||
- ETag changes on each commit (different file hash)
|
||||
- Conditional requests with old ETag return 200 (full content), not 304
|
||||
|
||||
xbps-install behavior:
|
||||
- `-M -S` fetches fresh repodata while bypassing the on-disk cache
|
||||
- The ordinary `-S` path can reuse a cached repodata archive
|
||||
- No 6-hour delay observed in practice
|
||||
|
||||
Binary size limits:
|
||||
- Test packages: 731–752 bytes (small test artifacts)
|
||||
- Real packages expected to be <10MB (vendored pure-Python)
|
||||
- Gitea serves any file size without LFS
|
||||
|
||||
**Caveat**: Clients using `xbps-install -Su` or `-Syu` without `-M` may use
|
||||
cached repodata. Users should use `-M -Syu` for updates when immediate
|
||||
publication visibility matters.
|
||||
|
||||
### 5. Publish index/artifacts together, preserve older artifacts, safe failure recovery
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
Publication mechanism:
|
||||
- Index and new package committed together in single Git commit
|
||||
- Older package artifacts retained in tree (e.g., v0.3.5 alongside v0.3.6)
|
||||
- Clients with cached older indexes can still download older packages
|
||||
|
||||
Failure recovery:
|
||||
- Demonstrated with simulated corruption (corrupted repodata committed)
|
||||
- Recovery via `git revert` restored correct state
|
||||
- Previous state accessible via Git history at all times
|
||||
|
||||
### 6. Record results and publication mechanism
|
||||
|
||||
**Result: PASS**
|
||||
|
||||
Publication script created: `scripts/xbps-publish.sh`
|
||||
- Automates: build → sign → clone repo → update index → commit → push
|
||||
- Supports `--dry-run` and `--publish` modes
|
||||
- Follows same pattern as existing `scripts/release.sh`
|
||||
|
||||
Makefile targets added:
|
||||
- `package-xbps` — Build XBPS package
|
||||
- `sign-xbps` — Sign XBPS package
|
||||
- `xbps-publish` — Publish to distribution repository
|
||||
- `xbps-publish-dry-run` — Dry-run publication
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
xavierk/Fenris-xbps (stable branch)
|
||||
x86_64/
|
||||
fenris-<version>_1.x86_64.xbps # Package archives
|
||||
fenris-<version>_1.x86_64.xbps.sig2 # Package signatures
|
||||
x86_64-repodata # Repository index (zstd-compressed tar)
|
||||
keys/
|
||||
fenris-xbps-signing.pub # Public signing key
|
||||
README.md
|
||||
```
|
||||
|
||||
## Client Configuration
|
||||
|
||||
Users add the repository to `/etc/xbps.d/xbps.conf`:
|
||||
```
|
||||
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||
```
|
||||
|
||||
Or 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
@@ -7,7 +7,7 @@ description: >
|
||||
NVMe wear monitor with persistent TUI — observes real-world drive use and
|
||||
translates it into an understandable endurance outlook.
|
||||
homepage: https://git.bongbetic.com/xavierk/Fenris
|
||||
license: Proprietary
|
||||
license: MIT
|
||||
|
||||
depends:
|
||||
- python3 (>= 3.10)
|
||||
@@ -38,7 +38,7 @@ contents:
|
||||
- dst: /var/lib/fenris
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 2750
|
||||
mode: 02770
|
||||
group: fenris
|
||||
|
||||
scripts:
|
||||
|
||||
+24
-2
@@ -12,10 +12,17 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||
RUNTIME_PYTHON="/usr/bin/python3"
|
||||
VENDOR_DIR="/opt/fenris/vendor"
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
configure)
|
||||
if [ -n "${2:-}" ]; then
|
||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
||||
# Upgrade — snapshot, migration, init-system-aware reload
|
||||
if [ -f "${STORE_DB}" ]; then
|
||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||
fi
|
||||
@@ -27,6 +34,7 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||
RUNNING_UNITS=""
|
||||
for unit in fenris-collect.timer; do
|
||||
@@ -46,10 +54,24 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
rm -f "${OLD_CONTENT}"
|
||||
done
|
||||
fi
|
||||
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
||||
fi
|
||||
# Fresh install: init-system-specific setup
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemd-sysusers || true
|
||||
systemd-tmpfiles --create || true
|
||||
systemctl daemon-reload || true
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
abort-upgrade|abort-install|disappear)
|
||||
;;
|
||||
|
||||
+14
-1
@@ -5,6 +5,13 @@
|
||||
# postrm purge (after conffiles and config removed)
|
||||
set -eu
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
purge)
|
||||
rm -rf /etc/fenris
|
||||
@@ -16,4 +23,10 @@ case "${1:-}" in
|
||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||
;;
|
||||
esac
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
else
|
||||
# runit: clean up service directory and log
|
||||
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -5,14 +5,27 @@
|
||||
# prerm upgrade (old version about to be replaced)
|
||||
set -eu
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
remove)
|
||||
# Sanctioned disable — close monitoring period (spec §7)
|
||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||
fi
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||
else
|
||||
# runit: remove the service symlink
|
||||
rm -f /var/service/fenris-collect
|
||||
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
upgrade)
|
||||
# Never interrupt monitoring on upgrade
|
||||
|
||||
@@ -6,6 +6,13 @@ 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
|
||||
|
||||
+29
-1
@@ -8,13 +8,33 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||
RUNTIME_PYTHON="/usr/bin/python3"
|
||||
VENDOR_DIR="/opt/fenris/vendor"
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
if [ "$1" -eq 1 ]; then
|
||||
# Fresh install
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemd-sysusers || true
|
||||
systemd-tmpfiles --create || true
|
||||
systemctl daemon-reload || true
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
elif [ "$1" -ge 2 ]; then
|
||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
||||
# Upgrade — snapshot, migration, init-system-aware reload
|
||||
if [ -f "${STORE_DB}" ]; then
|
||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||
fi
|
||||
@@ -26,6 +46,7 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||
RUNNING_UNITS=""
|
||||
for unit in fenris-collect.timer; do
|
||||
@@ -46,4 +67,11 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
done
|
||||
# Re-apply placement modes (store dir group access, issue #54)
|
||||
systemd-tmpfiles --create || true
|
||||
else
|
||||
# runit: repair log access when upgrading from an older package
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
+14
-1
@@ -2,6 +2,13 @@
|
||||
# RPM %postun — post-uninstall scriptlet (spec §7).
|
||||
set -eu
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
if [ "$1" -eq 0 ]; then
|
||||
# Package fully erased — remove config, store, group
|
||||
rm -rf /etc/fenris
|
||||
@@ -10,4 +17,10 @@ if [ "$1" -eq 0 ]; then
|
||||
groupdel fenris 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
else
|
||||
# runit: clean up service directory and log
|
||||
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
||||
set -eu
|
||||
|
||||
# Detect init system
|
||||
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||
INIT_SYSTEM="systemd"
|
||||
else
|
||||
INIT_SYSTEM="runit"
|
||||
fi
|
||||
|
||||
if [ "$1" -eq 0 ]; then
|
||||
# Package is being erased — sanctioned disable (spec §7)
|
||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||
fi
|
||||
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||
else
|
||||
# runit: remove the service symlink
|
||||
rm -f /var/service/fenris-collect
|
||||
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
# On upgrade ($1 -ge 1): do nothing
|
||||
|
||||
@@ -52,6 +52,14 @@ VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
|
||||
mkdir -p "${VENDOR_DIR}"
|
||||
python3 -m pip install --disable-pip-version-check --no-compile \
|
||||
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
||||
# Package files must be importable by unprivileged Fenris users regardless of
|
||||
# the builder's umask. pip otherwise preserves a restrictive umask in the
|
||||
# vendored runtime, which makes the installed CLI fail before it can read the
|
||||
# observation store.
|
||||
chmod -R a+rX "${VENDOR_DIR}"
|
||||
|
||||
# Ship the application license in every native package.
|
||||
install -D -m 0644 "${REPO_ROOT}/LICENSE" "${STAGE_DIR}/usr/share/licenses/fenris/LICENSE"
|
||||
|
||||
# --- Inject version into wrapper from pyproject.toml ---
|
||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||
@@ -74,6 +82,12 @@ mkdir -p "${STAGE_DIR}/usr/lib/systemd/system"
|
||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||
|
||||
# --- runit service files ---
|
||||
echo " Installing runit service files ..."
|
||||
mkdir -p "${STAGE_DIR}/etc/sv/fenris-collect/log"
|
||||
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/run" "${STAGE_DIR}/etc/sv/fenris-collect/run"
|
||||
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/log/run" "${STAGE_DIR}/etc/sv/fenris-collect/log/run"
|
||||
|
||||
# --- polkit policy ---
|
||||
echo " Installing polkit policy ..."
|
||||
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
# XBPS INSTALL script — post-install and post-upgrade paths.
|
||||
#
|
||||
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
||||
#
|
||||
# Actions: pre (before files extracted), post (after files extracted)
|
||||
# UPDATE: "yes" on upgrade, "no" on fresh install
|
||||
set -eu
|
||||
|
||||
STORE_DIR="/var/lib/fenris"
|
||||
STORE_DB="${STORE_DIR}/observations.db"
|
||||
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||
RUNTIME_PYTHON="/usr/bin/python3"
|
||||
VENDOR_DIR="/opt/fenris/vendor"
|
||||
|
||||
ACTION="$1"
|
||||
UPDATE="$4"
|
||||
|
||||
case "${ACTION}" in
|
||||
pre)
|
||||
# Migration guard — abort if make-install remnants detected
|
||||
MARKER="/var/lib/fenris/manifest.txt"
|
||||
if [ -f "${MARKER}" ]; then
|
||||
echo >&2
|
||||
echo >&2 "Fenris make-install remnants detected — refusing to install."
|
||||
echo >&2
|
||||
echo >&2 "Migrate to the package with:"
|
||||
echo >&2 " sudo make uninstall # removes make-install files, preserves store + config"
|
||||
echo >&2 " sudo xbps-install fenris"
|
||||
echo >&2
|
||||
echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems"
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
# Keep observation-store access consistent across fresh installs and upgrades.
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
if [ "${UPDATE}" = "yes" ]; then
|
||||
# Upgrade — snapshot, migration, runit-aware reload
|
||||
if [ -f "${STORE_DB}" ]; then
|
||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||
fi
|
||||
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
|
||||
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
|
||||
from fenris.store import migrate_to_latest
|
||||
from pathlib import Path
|
||||
n = migrate_to_latest(Path('${STORE_DB}'))
|
||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
# Ensure log directory exists on upgrade
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
else
|
||||
# Fresh install — runit service setup
|
||||
groupadd -f fenris
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# XBPS REMOVE script — pre-remove path.
|
||||
#
|
||||
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
||||
#
|
||||
# Actions: pre (before files removed)
|
||||
set -eu
|
||||
|
||||
ACTION="$1"
|
||||
UPDATE="$4"
|
||||
case "${ACTION}" in
|
||||
pre)
|
||||
# Only a real erase is a sanctioned disable. An upgrade must preserve
|
||||
# both monitoring intent and the active runit service.
|
||||
if [ "${UPDATE}" = "no" ]; then
|
||||
# Close the monitoring period while the store is still available.
|
||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||
fi
|
||||
# Configuration and the observation store are deliberately not
|
||||
# package-owned. Leave them in place: XBPS does not guarantee a
|
||||
# post-remove callback before its cleanup action.
|
||||
# runit: remove the service symlink and mark dormant
|
||||
rm -f /var/service/fenris-collect
|
||||
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "fenris"
|
||||
version = "0.3.5"
|
||||
version = "0.3.6"
|
||||
description = "NVMe wear monitor with persistent TUI"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"textual>=0.40.0",
|
||||
]
|
||||
|
||||
@@ -9,3 +9,4 @@ mdurl==0.1.2
|
||||
platformdirs==4.11.7
|
||||
Pygments==2.21.0
|
||||
linkify-it-py==2.2.0
|
||||
typing-extensions==4.16.0
|
||||
|
||||
@@ -68,6 +68,20 @@ def assemble_release_body(section: str, footer: str) -> str:
|
||||
return f"{section}{separator}{footer}"
|
||||
|
||||
|
||||
def format_availability_section(
|
||||
available: list[str], withheld: list[str]
|
||||
) -> str:
|
||||
"""Generate a package formats section for release notes."""
|
||||
if not available and not withheld:
|
||||
return ""
|
||||
lines = ["\n## Package formats\n"]
|
||||
if available:
|
||||
lines.append(f"Available: {', '.join(available)}")
|
||||
if withheld:
|
||||
lines.append(f"Withheld: {', '.join(withheld)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("changelog", type=Path)
|
||||
@@ -77,6 +91,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
type=Path,
|
||||
help="append this standing release guidance after the extracted section",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--available",
|
||||
action="append",
|
||||
default=[],
|
||||
help="format available for this release (can be repeated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--withheld",
|
||||
action="append",
|
||||
default=[],
|
||||
help="format withheld from this release (can be repeated)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
section = extract_changelog(args.changelog, args.version)
|
||||
@@ -88,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
f"cannot read release footer {args.footer}: {error.strerror}"
|
||||
) from error
|
||||
section = assemble_release_body(section, footer)
|
||||
if args.available or args.withheld:
|
||||
formats = format_availability_section(args.available, args.withheld)
|
||||
if formats:
|
||||
section = f"{section}\n{formats}"
|
||||
sys.stdout.write(section)
|
||||
except ChangelogError as error:
|
||||
print(f"::error::{error}", file=sys.stderr)
|
||||
|
||||
+9
-30
@@ -7,8 +7,6 @@ Subcommands route through fenris-monitor for privileged operations.
|
||||
Spec: §1.2, §8.4
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,35 +33,16 @@ def add_runtime_packages() -> None:
|
||||
add_runtime_packages()
|
||||
|
||||
|
||||
def is_root() -> bool:
|
||||
"""Check if running as root."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run_monitor(*args: str) -> None:
|
||||
"""Run fenris-monitor with the given arguments.
|
||||
"""Render the shared privileged-action outcome for the CLI."""
|
||||
from fenris.control import MonitorError, run_monitor as invoke_monitor
|
||||
|
||||
If not root, re-exec under pkexec.
|
||||
"""
|
||||
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
if is_root():
|
||||
result = subprocess.run([monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
else:
|
||||
# Use pkexec to elevate
|
||||
pkexec = subprocess.run(
|
||||
["which", "pkexec"], capture_output=True
|
||||
)
|
||||
if pkexec.returncode != 0:
|
||||
print(
|
||||
"Error: No polkit agent available. "
|
||||
"Run as root: sudo fenris-monitor ...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
try:
|
||||
invoke_monitor(*args)
|
||||
except MonitorError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(exc.exit_code)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def cmd_tui(args: argparse.Namespace) -> None:
|
||||
@@ -143,7 +122,7 @@ def main() -> None:
|
||||
description="Fenris NVMe endurance monitor",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version="%(prog)s 0.3.0"
|
||||
"--version", action="version", version="%(prog)s 0.3.6"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
#!/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,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.6"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Terminal-attached invocation of Fenris's fixed privileged operations.
|
||||
|
||||
Both human entry points use this module. Authentication has no frontend
|
||||
deadline; collection runtime is bounded by the native scheduler (ADR 0003).
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
MONITOR_HELPER = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
|
||||
class MonitorError(Exception):
|
||||
"""An action failed, with a message and exit status for either renderer."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1):
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def run_monitor(*args: str, helper_path: str = MONITOR_HELPER) -> None:
|
||||
"""Run one helper operation, inheriting the terminal for authentication.
|
||||
|
||||
Never invoke a shell, retry an action, or fall back to sudo automatically.
|
||||
The helper owns the operation allow-list and privileged state changes.
|
||||
"""
|
||||
helper_command = [helper_path, *args]
|
||||
needs_auth = os.geteuid() != 0
|
||||
command = ["pkexec", *helper_command] if needs_auth else helper_command
|
||||
root_hint = (
|
||||
" If authentication is unavailable, run in your terminal: "
|
||||
+ shlex.join(["sudo", *helper_command])
|
||||
) if needs_auth else ""
|
||||
|
||||
try:
|
||||
# The collector owns its 90-second runtime limit. A frontend timeout
|
||||
# would also count time spent authenticating or waiting for a run.
|
||||
result = subprocess.run(command)
|
||||
except FileNotFoundError as exc:
|
||||
raise MonitorError(
|
||||
"Command not found: %s.%s" % (exc.filename or command[0], root_hint), 127,
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise MonitorError("Cannot run monitoring action: %s.%s" % (exc, root_hint)) from exc
|
||||
except KeyboardInterrupt as exc:
|
||||
raise MonitorError(
|
||||
"Action interrupted. Check fenris status before retrying.", 130,
|
||||
) from exc
|
||||
|
||||
if result.returncode:
|
||||
exit_code = result.returncode if result.returncode > 0 else 128 - result.returncode
|
||||
raise MonitorError(
|
||||
"Action failed (exit %d). Check fenris status before retrying.%s"
|
||||
% (exit_code, root_hint), exit_code,
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Init system abstraction for Fenris monitoring (issue #84).
|
||||
|
||||
Detects the active init system (systemd or runit) and provides a unified
|
||||
interface for timer/collection control and service-state queries. This keeps
|
||||
the privileged helper and status composition init-system agnostic without
|
||||
introducing a generalized plugin framework.
|
||||
|
||||
Design: ADR 0008 — keep service-specific operations behind a cohesive
|
||||
responsibility shared by the privileged control path and read-only status
|
||||
composition.
|
||||
|
||||
Runit service layout:
|
||||
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 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)
|
||||
+13
-54
@@ -15,9 +15,7 @@ When run as a script, uses the fenris package from the installed wheel.
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -39,6 +37,11 @@ from fenris.monitoring_periods import (
|
||||
close_period,
|
||||
get_open_period,
|
||||
)
|
||||
from fenris.init_system import (
|
||||
enable_timer,
|
||||
disable_timer,
|
||||
collect_now,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
||||
@@ -70,25 +73,8 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
||||
else:
|
||||
print("Monitoring period already open (id=%d)" % open_period["id"])
|
||||
|
||||
# Enable and start the timer
|
||||
if args.now:
|
||||
result = subprocess.run(
|
||||
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["systemctl", "enable", "fenris-collect.timer"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("Error enabling timer:", result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("Timer enabled" + (" and started" if args.now else ""))
|
||||
# Enable the timer (init-system aware)
|
||||
enable_timer(args.now)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -117,25 +103,8 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
||||
else:
|
||||
print("No open monitoring period (no-op)")
|
||||
|
||||
# Disable and stop the timer
|
||||
if args.now:
|
||||
result = subprocess.run(
|
||||
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["systemctl", "disable", "fenris-collect.timer"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("Error disabling timer:", result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("Timer disabled" + (" and stopped" if args.now else ""))
|
||||
# Disable the timer (init-system aware)
|
||||
disable_timer(args.now)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -143,22 +112,12 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
||||
def cmd_collect(args: argparse.Namespace) -> None:
|
||||
"""Trigger on-demand collection.
|
||||
|
||||
Starts fenris-collect.service, blocks until exit, reports outcome.
|
||||
Starts fenris-collect, blocks until exit, reports outcome.
|
||||
|
||||
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
|
||||
which blocks until the oneshot exits; outcome reported synchronously.
|
||||
Spec §8.7: fenris sample routes through fenris-monitor → collect,
|
||||
which blocks until the collection exits; outcome reported synchronously.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["systemctl", "start", "fenris-collect.service"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("Collection completed successfully")
|
||||
else:
|
||||
print("Collection failed:", result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
collect_now()
|
||||
|
||||
|
||||
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
||||
|
||||
+110
-321
@@ -1,8 +1,8 @@
|
||||
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
|
||||
|
||||
Composes from the observation store (read-only) and allow-listed systemctl
|
||||
Composes from the observation store (read-only) and allow-listed service
|
||||
properties: projection facts, four separate service facts (boot enablement,
|
||||
runtime activity, last collect outcome, freshness), and a journalctl hint on
|
||||
runtime activity, last collect outcome, freshness), and a journal/log hint on
|
||||
failure or staleness. Never auto-samples, never prompts.
|
||||
|
||||
Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||
@@ -14,14 +14,21 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||
"""
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from contextlib import contextmanager
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .status_composition import StatusComposition
|
||||
|
||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||
from .store import SCHEMA_VERSION
|
||||
from .init_system import (
|
||||
query_service_state as _init_query_service_state,
|
||||
journal_hint as _init_journal_hint,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
||||
# PermissionError before any StoreFault can be raised (issue #54).
|
||||
raise StoreFault("observation store not readable: %s" % e)
|
||||
if not exists:
|
||||
raise StoreFault("observation store not found at %s" % store_path)
|
||||
raise MissingStore("observation store not found at %s" % store_path)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||
@@ -122,6 +129,10 @@ class StoreFault(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingStore(StoreFault):
|
||||
"""No observation history has been created yet."""
|
||||
|
||||
|
||||
class NewerSchema(Exception):
|
||||
"""Store has a newer user_version (§9.5)."""
|
||||
def __init__(self, version: int):
|
||||
@@ -130,97 +141,12 @@ class NewerSchema(Exception):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service state queries (§8.8 — allow-listed systemctl properties)
|
||||
# Service state queries (§8.8 — init-system agnostic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
|
||||
"""Query systemctl show for specific properties. Returns empty dict on failure."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "show", unit, "--property=" + ",".join(properties)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
out = {}
|
||||
for line in result.stdout.splitlines():
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
out[key.strip()] = value.strip()
|
||||
return out
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]:
|
||||
"""Get the last N journal lines for a unit. Returns None on failure."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def query_service_state() -> Dict[str, Any]:
|
||||
"""Query systemctl for the four separate service facts (§7.3, LC-9).
|
||||
|
||||
Returns dict with keys:
|
||||
boot_enabled: bool
|
||||
timer_active: bool
|
||||
last_collect_ok: Optional[bool]
|
||||
last_collect_age_s: Optional[int]
|
||||
last_collect_reason: Optional[str]
|
||||
"""
|
||||
timer_props = _systemctl_show(
|
||||
"fenris-collect.timer",
|
||||
"UnitFileState", "ActiveState", "LastTriggerUSec",
|
||||
)
|
||||
service_props = _systemctl_show(
|
||||
"fenris-collect.service",
|
||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||
)
|
||||
|
||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
||||
boot_enabled = boot_enabled_str == "enabled"
|
||||
|
||||
active_state = timer_props.get("ActiveState", "inactive")
|
||||
timer_active = active_state == "active"
|
||||
|
||||
last_collect_ok = None
|
||||
last_collect_age_s = None
|
||||
last_collect_reason = None
|
||||
|
||||
last_trigger = timer_props.get("LastTriggerUSec", "")
|
||||
if last_trigger and last_trigger != "n/a":
|
||||
try:
|
||||
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
last_collect_age_s = int((now - trigger_dt).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
exec_status = service_props.get("ExecMainStatus", "")
|
||||
if exec_status:
|
||||
try:
|
||||
exit_code = int(exec_status)
|
||||
last_collect_ok = exit_code == 0
|
||||
if exit_code != 0:
|
||||
last_collect_reason = "exit code %d" % exit_code
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"boot_enabled": boot_enabled,
|
||||
"timer_active": timer_active,
|
||||
"last_collect_ok": last_collect_ok,
|
||||
"last_collect_age_s": last_collect_age_s,
|
||||
"last_collect_reason": last_collect_reason,
|
||||
}
|
||||
# Re-export for backward compatibility with tests that import directly
|
||||
query_service_state = _init_query_service_state
|
||||
_journalctl_hint = _init_journal_hint
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -328,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
||||
# Formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
drive_facts: List[str], config_error: Optional[str],
|
||||
store_fault: Optional[str], newer_schema: Optional[str],
|
||||
journal_hint: Optional[str],
|
||||
def _format_projection(proj, freshness: str, drive_facts: List[str],
|
||||
config_error: Optional[str],
|
||||
sample_count: int = 0, day_count: int = 0) -> str:
|
||||
"""Format the complete status output."""
|
||||
"""Format projection details; monitoring status has its own renderer."""
|
||||
lines = []
|
||||
|
||||
# --- Store/system fault overrides (§9.4, §9.5) ---
|
||||
if store_fault:
|
||||
lines.append("observation store unreadable")
|
||||
if journal_hint:
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
return "\n".join(lines)
|
||||
|
||||
if newer_schema:
|
||||
lines.append("observation store written by a newer Fenris — upgrade Fenris")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Configuration error (§8.3) ---
|
||||
if config_error:
|
||||
lines.append("configuration error: %s" % config_error)
|
||||
@@ -359,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("no observations yet")
|
||||
lines.append("")
|
||||
lines.append("Enable monitoring: fenris monitor resume")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||
@@ -369,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("awaiting another sample")
|
||||
lines.append("")
|
||||
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
if proj is None:
|
||||
lines.append("no projection available")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Projection headline ---
|
||||
@@ -414,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append(fact)
|
||||
lines.append("")
|
||||
|
||||
# --- Four separate service facts (§7.3, LC-9) ---
|
||||
_append_service_facts(lines, service)
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
if journal_hint:
|
||||
if freshness in ("missed", "stale"):
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -468,36 +371,6 @@ def _format_headline(proj) -> str:
|
||||
return headline
|
||||
|
||||
|
||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
"""Append service facts and dashboard-clarity monitoring state."""
|
||||
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
||||
activity = "active" if service.get("timer_active") else "inactive"
|
||||
|
||||
if service.get("last_collect_ok") is True:
|
||||
collect = "ok"
|
||||
elif service.get("last_collect_ok") is False:
|
||||
collect = "FAILED"
|
||||
if service.get("last_collect_reason"):
|
||||
collect += " (%s)" % service["last_collect_reason"]
|
||||
else:
|
||||
collect = "unknown"
|
||||
|
||||
collect_age = ""
|
||||
if service.get("last_collect_age_s") is not None:
|
||||
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
|
||||
|
||||
freshness_str = service.get("freshness", "unknown")
|
||||
freshness_age = ""
|
||||
if service.get("freshness_age_s") is not None:
|
||||
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
|
||||
|
||||
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
||||
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
||||
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
|
||||
if service.get("deliberately_paused"):
|
||||
lines.extend(deliberate_pause_lines())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -512,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
|
||||
|
||||
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||
"""Return the boot-persistence wording, independent of timer runtime."""
|
||||
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
|
||||
if service.get("boot_enabled") is None:
|
||||
return "monitoring: boot persistence unknown"
|
||||
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
|
||||
|
||||
|
||||
def deliberate_pause_lines() -> List[str]:
|
||||
@@ -558,121 +433,92 @@ def format_disclosures() -> str:
|
||||
# Main status entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def read_status(
|
||||
store_path: Optional[Path] = None,
|
||||
clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True,
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
|
||||
"""Yield a read-only store snapshot and its composed monitoring status.
|
||||
|
||||
Own acquisition, fault classification, and connection lifetime for both
|
||||
renderers. An absent store is empty; an unreadable or newer store exposes
|
||||
no connection. Unknown monitoring facts are never coerced to disabled.
|
||||
"""
|
||||
from .status_composition import compose_status
|
||||
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
service = None
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
conn = None
|
||||
store_fault = newer_schema = None
|
||||
try:
|
||||
try:
|
||||
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
|
||||
conn.execute("BEGIN")
|
||||
except MissingStore:
|
||||
pass
|
||||
except (StoreFault, sqlite3.Error) as exc:
|
||||
store_fault = str(exc)
|
||||
except NewerSchema as exc:
|
||||
newer_schema = str(exc)
|
||||
|
||||
comp = compose_status(
|
||||
conn, service, clock_now, store_fault=store_fault,
|
||||
newer_schema=newer_schema, collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
if conn is not None and (comp.store_fault or comp.newer_schema):
|
||||
conn.close()
|
||||
conn = None
|
||||
yield conn, comp
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render the complete read-only status (§8.8, LC-9).
|
||||
"""Render CLI status through the shared read-only acquisition path."""
|
||||
from .status_composition import render_status_cli
|
||||
|
||||
This is the single entry point for 'fenris status'. It never auto-samples,
|
||||
never prompts, and never writes to the store.
|
||||
"""
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
config_error = None
|
||||
device = None
|
||||
try:
|
||||
config = read_config()
|
||||
device = config["device"]
|
||||
except ConfigError as e:
|
||||
config_error = str(e)
|
||||
read_config()
|
||||
except ConfigError as exc:
|
||||
config_error = str(exc)
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
service = query_service_state()
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Store fault / newer schema short-circuit ---
|
||||
if store_fault or newer_schema:
|
||||
journal_hint = None
|
||||
if query_journal:
|
||||
journal_hint = _journalctl_hint("fenris-collect.service")
|
||||
service["freshness"] = "unknown"
|
||||
service["freshness_age_s"] = None
|
||||
return _format_projection(
|
||||
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
|
||||
)
|
||||
|
||||
# --- Freshness grading (§8.9) ---
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
except sqlite3.Error:
|
||||
newest_ts = None
|
||||
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# Freshness age for the service fact
|
||||
freshness_age_s = None
|
||||
if newest_ts:
|
||||
try:
|
||||
ts = datetime.fromisoformat(newest_ts)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
service["freshness"] = freshness
|
||||
service["freshness_age_s"] = freshness_age_s
|
||||
try:
|
||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||
except sqlite3.Error:
|
||||
service["deliberately_paused"] = False
|
||||
|
||||
# --- Drive anomalies (§9.7, FL-7) ---
|
||||
with read_status(store_path, clock_now, query_services) as (conn, comp):
|
||||
parts = [render_status_cli(comp)]
|
||||
if not comp.store_fault and not comp.newer_schema:
|
||||
drive_facts = []
|
||||
proj = None
|
||||
if conn is not None:
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# --- Projection (§6 — recomputed on read, never stored) ---
|
||||
try:
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except Exception:
|
||||
proj = None
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
journal_hint = None
|
||||
if query_journal and freshness in ("missed", "stale"):
|
||||
journal_hint = _journalctl_hint("fenris-collect.service")
|
||||
|
||||
# --- Compose output ---
|
||||
result = _format_projection(
|
||||
proj, freshness, service, drive_facts, config_error,
|
||||
None, None, journal_hint, sample_count, day_count,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
except (sqlite3.Error, ValueError, TypeError):
|
||||
pass
|
||||
parts.append(_format_projection(
|
||||
proj, comp.freshness, drive_facts, config_error,
|
||||
comp.sample_count, comp.day_count,
|
||||
))
|
||||
if query_journal and (
|
||||
comp.store_fault or comp.last_collect_ok is False
|
||||
or comp.freshness in ("missed", "stale")
|
||||
):
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
parts.append("Recent collector logs:\n" + hint)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
@@ -701,65 +547,8 @@ def get_status_composition(
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> 'StatusComposition':
|
||||
"""Get the shared status composition consumed by both TUI and CLI.
|
||||
|
||||
This is the new entry point that centralizes the status lattice.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency
|
||||
from .status_composition import (
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
)
|
||||
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
# Config errors are surfaced through the store fault mechanism
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except Exception:
|
||||
service = None
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Use shared composition ---
|
||||
if conn is not None:
|
||||
try:
|
||||
comp = compose_status(
|
||||
conn, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# Store fault or newer schema - compose without store data
|
||||
comp = compose_status(
|
||||
None, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
|
||||
"""Return monitoring status without retaining the read-only snapshot."""
|
||||
with read_status(
|
||||
store_path, clock_now, query_services, collecting, reduced_motion,
|
||||
) as (_, comp):
|
||||
return comp
|
||||
|
||||
@@ -188,7 +188,7 @@ def _determine_explanation(
|
||||
) -> str:
|
||||
"""Determine the explanation line for the status state."""
|
||||
if store_fault:
|
||||
return "observation store unreadable — see journal"
|
||||
return "observation store unreadable — see collector logs"
|
||||
|
||||
if newer_schema:
|
||||
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||
@@ -200,7 +200,7 @@ def _determine_explanation(
|
||||
if last_collect_reason:
|
||||
parts.append("(%s)" % last_collect_reason)
|
||||
if freshness_age_s is not None and freshness != "empty":
|
||||
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
|
||||
return " ".join(parts) if parts else "last run failed"
|
||||
|
||||
if state == StatusState.INTERRUPTED:
|
||||
@@ -211,7 +211,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.STALE:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "data is stale"
|
||||
|
||||
if state == StatusState.WAITING:
|
||||
@@ -223,7 +223,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.MONITORING:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "monitoring active"
|
||||
|
||||
if state == StatusState.UNKNOWN:
|
||||
@@ -257,7 +257,7 @@ def _determine_collecting_overlay(
|
||||
|
||||
|
||||
def compose_status(
|
||||
conn: sqlite3.Connection,
|
||||
conn: Optional[sqlite3.Connection],
|
||||
service: Optional[Dict[str, Any]],
|
||||
clock_now: datetime,
|
||||
store_fault: Optional[str] = None,
|
||||
@@ -269,14 +269,17 @@ def compose_status(
|
||||
|
||||
This is the single entry point consumed by both TUI and CLI.
|
||||
"""
|
||||
service_available = service is not None and len(service) > 0
|
||||
service_available = bool(service) and any(
|
||||
service.get(key) is not None
|
||||
for key in ("boot_enabled", "timer_active")
|
||||
)
|
||||
|
||||
# --- Separate facts from service ---
|
||||
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||
timer_active = service.get("timer_active") if service_available else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||
boot_enabled = service.get("boot_enabled") if service else None
|
||||
timer_active = service.get("timer_active") if service else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service else None
|
||||
|
||||
# --- Freshness from store ---
|
||||
freshness = "unknown"
|
||||
@@ -285,7 +288,10 @@ def compose_status(
|
||||
day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
if store_fault is None and newer_schema is None:
|
||||
if conn is None and store_fault is None and newer_schema is None:
|
||||
freshness = "empty"
|
||||
|
||||
if conn is not None and store_fault is None and newer_schema is None:
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
@@ -301,28 +307,35 @@ def compose_status(
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except sqlite3.Error:
|
||||
freshness = "unknown"
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
svc_for_pause = service if service_available else {}
|
||||
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||
except sqlite3.Error:
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
if store_fault or newer_schema:
|
||||
freshness = "unknown"
|
||||
freshness_age_s = None
|
||||
sample_count = day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
# --- External stop detection ---
|
||||
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||
external_stop_reason = None
|
||||
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||
if (conn is not None and not store_fault and not newer_schema
|
||||
and not deliberately_paused and timer_active is False and boot_enabled is False):
|
||||
# Check if there's an open monitoring period (external stop left it open)
|
||||
try:
|
||||
open_period = conn.execute(
|
||||
@@ -428,10 +441,9 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
@@ -441,10 +453,12 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -486,29 +500,30 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
|
||||
# Explanation
|
||||
if comp.explanation:
|
||||
lines.append(comp.explanation)
|
||||
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
facts.append("Freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
facts.append("Last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
collect_str = "last collect: FAILED"
|
||||
collect_str = "Last collect: failed"
|
||||
if comp.last_collect_reason:
|
||||
collect_str += " (%s)" % comp.last_collect_reason
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("Last collect: unknown")
|
||||
facts.append("Boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("Timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -516,11 +531,11 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
# Continuity
|
||||
if comp.continuity:
|
||||
lines.append("")
|
||||
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
|
||||
|
||||
# Deliberate pause
|
||||
if comp.paused_lines:
|
||||
for pl in comp.paused_lines:
|
||||
lines.append(pl)
|
||||
lines.append(pl[:1].upper() + pl[1:])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
+149
-318
@@ -15,12 +15,12 @@ Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
@@ -38,27 +38,19 @@ from .status import (
|
||||
CADENCE_DEFAULT_S,
|
||||
FRESH_THRESHOLD_S,
|
||||
STALENESS_THRESHOLD_S,
|
||||
ConfigError,
|
||||
NewerSchema,
|
||||
StoreFault,
|
||||
format_disclosures,
|
||||
freshness_age_human,
|
||||
grade_freshness,
|
||||
deliberate_pause_lines,
|
||||
is_deliberately_paused,
|
||||
monitoring_continuity,
|
||||
open_store_readonly,
|
||||
query_service_state,
|
||||
read_config,
|
||||
read_status,
|
||||
_journalctl_hint,
|
||||
)
|
||||
from .monitoring_periods import get_open_period
|
||||
from .status_composition import (
|
||||
StatusState,
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
render_status_tui,
|
||||
STATUS_POLL_INTERVAL_S,
|
||||
)
|
||||
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
||||
from .preferences import load_preferences, save_preferences
|
||||
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
|
||||
@@ -67,6 +59,12 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||
_ACTION_LEGEND = (
|
||||
"p Pause · " + _RESUME_HINT + "\n"
|
||||
"c Collect now · t Theme · m Motion · d Disclosures · ? Help"
|
||||
)
|
||||
|
||||
def _format_remaining(seconds: float) -> str:
|
||||
"""Format remaining lifespan as human-readable string."""
|
||||
if seconds <= 0:
|
||||
@@ -85,43 +83,6 @@ def _format_remaining(seconds: float) -> str:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _sparkline(values: List[float], width: int = 40) -> str:
|
||||
"""Render a sparkline from daily bytes-written values."""
|
||||
if not values:
|
||||
return ""
|
||||
mx = max(values) or 1.0
|
||||
blocks = " ▁▂▃▄▅▆▇█"
|
||||
step = max(1, len(values) // width or 1)
|
||||
picked = values[-width * step :][::step][-width:]
|
||||
return "".join(
|
||||
blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))]
|
||||
for v in picked
|
||||
)
|
||||
|
||||
|
||||
def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str:
|
||||
"""Render the habit-split bar with legend."""
|
||||
total = a + i + o + u or 1.0
|
||||
segs = [
|
||||
("a", a, "#33ff33"),
|
||||
("i", i, "#ffff33"),
|
||||
("o", o, "#33ffff"),
|
||||
("?", u, "#ff33ff"),
|
||||
]
|
||||
parts = []
|
||||
for label, v, _color in segs:
|
||||
n = max(1 if v > 0 else 0, round(v / total * width))
|
||||
parts.append(label * n)
|
||||
bar = "".join(parts)
|
||||
legend = " active %d%% · idle %d%% · powered-off %d%% · unknown %d%%" % (
|
||||
round(a / total * 100),
|
||||
round(i / total * 100),
|
||||
round(o / total * 100),
|
||||
round(u / total * 100),
|
||||
)
|
||||
return bar + "\n" + legend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity and wolf glyph detection (issue #79)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -268,8 +229,8 @@ class DailyBarGraph(Widget):
|
||||
self._render_readout()
|
||||
|
||||
def _show_empty(self) -> None:
|
||||
self.query_one("#bar-range").update("[dim]usage history[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]awaiting first sample[/dim]")
|
||||
self.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]Awaiting first sample[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
@@ -290,9 +251,9 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _show_constrained_summary(self) -> None:
|
||||
"""Textual fallback for terminals below 80×24."""
|
||||
self.query_one("#bar-range").update("[dim]usage history[/dim]")
|
||||
self.query_one("#bar-range").update("[dim]Usage history[/dim]")
|
||||
if not self._day_data:
|
||||
self.query_one("#bar-render").update("[dim]graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]Graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
return
|
||||
@@ -304,7 +265,7 @@ class DailyBarGraph(Widget):
|
||||
first = self._day_data[0].get("local_label", "")
|
||||
last = self._day_data[-1].get("local_label", "")
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
@@ -318,7 +279,7 @@ class DailyBarGraph(Widget):
|
||||
% (day.get("local_label", ""), day.get("total_bytes", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select[/dim]")
|
||||
|
||||
def _show_constrained_hourly_summary(self) -> None:
|
||||
"""Textual fallback for hourly view when terminal is too small."""
|
||||
@@ -328,7 +289,7 @@ class DailyBarGraph(Widget):
|
||||
)
|
||||
n = len(self._hour_data)
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d hours · %d with writes · %.3f GB total" % (n, hours_with_data, total_bytes / 1e9)
|
||||
)
|
||||
self.query_one("#bar-legend").update("")
|
||||
@@ -340,7 +301,7 @@ class DailyBarGraph(Widget):
|
||||
% (h.get("local_label", ""), h.get("bytes_written", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Re-render when terminal size changes."""
|
||||
@@ -356,9 +317,9 @@ class DailyBarGraph(Widget):
|
||||
parts.append("[bold]%d[/bold]" % r)
|
||||
else:
|
||||
parts.append(str(r))
|
||||
label = "range: " + " / ".join(parts)
|
||||
label = "Range: " + " / ".join(parts)
|
||||
if self.view_mode == "hourly":
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 esc back" % (self.drill_day or "")
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 Esc Back" % (self.drill_day or "")
|
||||
self.query_one("#bar-range").update(label)
|
||||
|
||||
def _render_bars(self) -> None:
|
||||
@@ -430,7 +391,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _render_legend(self) -> None:
|
||||
legend = (
|
||||
"%s alloc %s unalloc %s gap %s zero %s partial"
|
||||
"%s Alloc %s Unalloc %s Gap %s Zero %s Partial"
|
||||
% (
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
@@ -444,7 +405,7 @@ class DailyBarGraph(Widget):
|
||||
def _render_readout(self) -> None:
|
||||
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
|
||||
self.query_one("#bar-readout").update(
|
||||
"[dim]\u2190 \u2192 select \u00b7 1-4 range \u00b7 Enter drill[/dim]"
|
||||
"[dim]\u2190 \u2192 Select \u00b7 1-4 Range \u00b7 Enter Hourly view[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -467,7 +428,7 @@ class DailyBarGraph(Widget):
|
||||
]
|
||||
if unallocated > 0:
|
||||
parts.append(
|
||||
" allocated %.3f GB \u00b7 unallocated %.3f GB"
|
||||
" Allocated %.3f GB \u00b7 unallocated %.3f GB"
|
||||
% (allocated / 1e9, unallocated / 1e9)
|
||||
)
|
||||
self.query_one("#bar-readout").update("\n".join(parts))
|
||||
@@ -476,7 +437,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
def _refresh_hourly(self) -> None:
|
||||
if not self._hour_data:
|
||||
self.query_one("#bar-render").update("[dim]no hourly data[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]No hourly data[/dim]")
|
||||
return
|
||||
|
||||
self._render_range()
|
||||
@@ -527,7 +488,7 @@ class DailyBarGraph(Widget):
|
||||
|
||||
self.query_one("#bar-render").update("\n".join(lines))
|
||||
self.query_one("#bar-legend").update(
|
||||
"%s writes \u00b7 %s zero" % (_GLYPH_ALLOCATED, _GLYPH_ZERO)
|
||||
"%s Writes \u00b7 %s Zero" % (_GLYPH_ALLOCATED, _GLYPH_ZERO)
|
||||
)
|
||||
|
||||
# Hourly readout
|
||||
@@ -542,7 +503,7 @@ class DailyBarGraph(Widget):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 Select hour[/dim]")
|
||||
|
||||
# -- Event handling --
|
||||
|
||||
@@ -748,51 +709,6 @@ def _query_hourly_graph_data(
|
||||
# Data queries for TUI regions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _query_usage_history(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Query usage-history data for the left pane."""
|
||||
cursor = conn.execute(
|
||||
"SELECT day, active_seconds, idle_seconds, powered_off_seconds, "
|
||||
"unknown_seconds, bytes_written_delta, coverage "
|
||||
"FROM day_aggregates ORDER BY day"
|
||||
)
|
||||
days = cursor.fetchall()
|
||||
if not days:
|
||||
return {
|
||||
"sparkline": "",
|
||||
"habit_bar": "",
|
||||
"num_days": 0,
|
||||
"min_gb": 0,
|
||||
"max_gb": 0,
|
||||
"habit_change": False,
|
||||
"gap_days": [],
|
||||
"day_labels": [],
|
||||
}
|
||||
|
||||
bw_values = [d[5] for d in days]
|
||||
total_a = sum(d[1] for d in days)
|
||||
total_i = sum(d[2] for d in days)
|
||||
total_o = sum(d[3] for d in days)
|
||||
total_u = sum(d[4] for d in days)
|
||||
total = total_a + total_i + total_o + total_u or 1
|
||||
|
||||
spark = _sparkline([b / 1e9 for b in bw_values]) # Convert to GB for display
|
||||
bar = _habit_bar(total_a / total, total_i / total, total_o / total, total_u / total)
|
||||
|
||||
min_gb = min(bw_values) / 1e9 if bw_values else 0
|
||||
max_gb = max(bw_values) / 1e9 if bw_values else 0
|
||||
|
||||
return {
|
||||
"sparkline": spark,
|
||||
"habit_bar": bar,
|
||||
"num_days": len(days),
|
||||
"min_gb": min_gb,
|
||||
"max_gb": max_gb,
|
||||
"habit_change": False,
|
||||
"gap_days": [],
|
||||
"day_labels": [d[0] for d in days],
|
||||
}
|
||||
|
||||
|
||||
def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Query drive health data for the right pane."""
|
||||
cursor = conn.execute(
|
||||
@@ -834,41 +750,6 @@ def _query_drive_health(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[str, Any]:
|
||||
"""Query service facts for the bottom strip."""
|
||||
# Get freshness
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# Get monitoring period
|
||||
period = get_open_period(conn)
|
||||
period_info = "no monitoring period"
|
||||
if period:
|
||||
period_info = "open since %s" % period["started_at"][:10]
|
||||
|
||||
# Get service state
|
||||
try:
|
||||
svc = query_service_state()
|
||||
except Exception:
|
||||
svc = {
|
||||
"boot_enabled": False,
|
||||
"timer_active": False,
|
||||
"last_collect_ok": None,
|
||||
"last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"freshness": freshness,
|
||||
"freshness_age_s": None,
|
||||
"period": period_info,
|
||||
"deliberately_paused": is_deliberately_paused(conn, svc),
|
||||
**svc,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal screens
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -888,7 +769,7 @@ class ConfirmPause(ModalScreen[bool]):
|
||||
"This closes the current monitoring period.\n"
|
||||
"Paused time is [bold]excluded[/bold] from your usage habit\n"
|
||||
"(powered-off time would still count).\n\n"
|
||||
"[dim]y pause · n cancel[/dim]",
|
||||
"[dim]y Pause · n Cancel[/dim]",
|
||||
id="confirm-text",
|
||||
)
|
||||
|
||||
@@ -909,7 +790,44 @@ class DisclosuresScreen(ModalScreen[None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
text = format_disclosures()
|
||||
yield Static(text + "\n\n[dim]esc to close[/dim]", id="disc-text")
|
||||
with VerticalScroll():
|
||||
yield Static(text + "\n\n[dim]Esc Close[/dim]", id="disc-text")
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss()
|
||||
|
||||
|
||||
class HelpScreen(ModalScreen[None]):
|
||||
"""Persistent keyboard and privilege guidance."""
|
||||
|
||||
BINDINGS = [Binding("escape", "close", "Close"), Binding("?", "close", "Close")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with VerticalScroll():
|
||||
yield Static(
|
||||
"[bold]Using Fenris[/bold]\n\n"
|
||||
"Open the dashboard with [bold]fenris[/bold] as your normal user.\n"
|
||||
"The dashboard does not need sudo. Pause, resume, and collect now\n"
|
||||
"authenticate through polkit when needed.\n\n"
|
||||
"[bold]When to use sudo[/bold]\n"
|
||||
"Use sudo for installation and system configuration.\n"
|
||||
"For observation store access, an administrator can run:\n"
|
||||
' sudo usermod -aG fenris "$USER"\n'
|
||||
"Then log out and back in before opening Fenris.\n\n"
|
||||
"If polkit authentication is unavailable, quit with q and run\n"
|
||||
"only the required action in your terminal:\n"
|
||||
" sudo fenris monitor resume\n"
|
||||
" sudo fenris monitor pause\n"
|
||||
" sudo fenris sample\n"
|
||||
"Then reopen the dashboard with fenris.\n\n"
|
||||
"[bold]Keyboard controls[/bold]\n"
|
||||
+ _ACTION_LEGEND + "\n"
|
||||
"q Quit the dashboard — monitoring continues\n"
|
||||
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
||||
"Enter Show hours · Esc Return to days\n\n"
|
||||
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
||||
id="help-text",
|
||||
)
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss()
|
||||
@@ -930,12 +848,12 @@ class FenrisTuiApp(App):
|
||||
layout: grid;
|
||||
grid-size: 2 4;
|
||||
grid-columns: 3fr 2fr;
|
||||
grid-rows: 8 10 7 3;
|
||||
grid-rows: auto 10 auto 3;
|
||||
height: auto;
|
||||
}
|
||||
#main-grid.paused {
|
||||
grid-size: 2 5;
|
||||
grid-rows: 8 5 10 7 3;
|
||||
grid-rows: auto auto 10 auto 3;
|
||||
}
|
||||
#main-grid.constrained {
|
||||
grid-size: 1 4;
|
||||
@@ -960,15 +878,16 @@ class FenrisTuiApp(App):
|
||||
min-height: 3;
|
||||
}
|
||||
#dashboard-scroll { height: 1fr; }
|
||||
#headline-band { column-span: 2; }
|
||||
#headline-band { column-span: 2; height: auto; min-height: 8; }
|
||||
#paused-banner {
|
||||
column-span: 2;
|
||||
display: none;
|
||||
background: $error 20%;
|
||||
color: $text;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
min-height: 5;
|
||||
}
|
||||
#service-strip { column-span: 2; height: 100%; }
|
||||
#service-strip { column-span: 2; height: auto; }
|
||||
#quit-rail {
|
||||
column-span: 2;
|
||||
border: heavy $accent;
|
||||
@@ -978,15 +897,17 @@ class FenrisTuiApp(App):
|
||||
.pane { border: round #555555; padding: 0 1; height: 100%; }
|
||||
#confirm-text { padding: 1 2; }
|
||||
#disc-text { padding: 1 2; }
|
||||
#help-text { padding: 1 2; }
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("p", "pause", "Pause", show=False),
|
||||
Binding("r", "resume", "Resume", show=False),
|
||||
Binding("c", "collect", "Collect Now", show=False),
|
||||
Binding("c", "collect", "Collect now", show=False),
|
||||
Binding("d", "disclose", "Disclosures", show=False),
|
||||
Binding("t", "toggle_theme", "Theme", show=False),
|
||||
Binding("m", "toggle_motion", "Motion", show=False),
|
||||
Binding("?", "help", "Help", show=False),
|
||||
Binding("q", "quit", "Quit", show=False),
|
||||
]
|
||||
|
||||
@@ -1006,10 +927,9 @@ class FenrisTuiApp(App):
|
||||
super().__init__(**kwargs)
|
||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||
self.config_path = config_path
|
||||
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
|
||||
self.helper_path = helper_path or MONITOR_HELPER
|
||||
self.refresh_interval_s = refresh_interval_s
|
||||
self._show_auth_notice = True
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# Register all Fenris themes
|
||||
@@ -1028,7 +948,7 @@ class FenrisTuiApp(App):
|
||||
yield Static("", id="constrained-summary", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
yield Static("q QUIT TUI", id="quit-rail")
|
||||
yield Static("q Quit TUI", id="quit-rail")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set border titles, apply theme, and render initial state."""
|
||||
@@ -1036,34 +956,19 @@ class FenrisTuiApp(App):
|
||||
# Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast)
|
||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
||||
|
||||
self.query_one("#headline-band").border_title = "headline"
|
||||
self.query_one("#usage-history").border_title = "usage history"
|
||||
self.query_one("#constrained-summary").border_title = "usage history"
|
||||
self.query_one("#drive-health").border_title = "drive"
|
||||
self.query_one("#service-strip").border_title = "service + actions"
|
||||
self.query_one("#headline-band").border_title = "Headline"
|
||||
self.query_one("#usage-history").border_title = "Usage history"
|
||||
self.query_one("#constrained-summary").border_title = "Usage history"
|
||||
self.query_one("#drive-health").border_title = "Drive"
|
||||
self.query_one("#service-strip").border_title = "Monitoring and actions"
|
||||
self._refresh_timer = self.set_interval(
|
||||
self.refresh_interval_s, self.on_refresh_tick
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Handle terminal resize to switch between constrained and normal layout (issue #81)."""
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
|
||||
# Re-render constrained summary when entering constrained mode
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
# Re-render full layout when leaving constrained mode
|
||||
elif not self._is_constrained_mode and was_constrained:
|
||||
self._refresh()
|
||||
"""Re-render with the new terminal size after Textual applies it."""
|
||||
self.call_after_refresh(self._refresh)
|
||||
|
||||
def _update_constrained_summary(self) -> None:
|
||||
"""Update the textual summary shown when terminal is too small for graph."""
|
||||
@@ -1071,7 +976,7 @@ class FenrisTuiApp(App):
|
||||
summary = self.query_one("#constrained-summary")
|
||||
|
||||
if not graph._day_data:
|
||||
summary.update("[dim]graph needs ≥80×24[/dim]\nawaiting first sample")
|
||||
summary.update("[dim]Graph needs ≥80×24[/dim]\nAwaiting first sample")
|
||||
return
|
||||
|
||||
# Summarise visible days as text (issue #81 AC2)
|
||||
@@ -1081,7 +986,7 @@ class FenrisTuiApp(App):
|
||||
first = graph._day_data[0].get("local_label", "")
|
||||
last = graph._day_data[-1].get("local_label", "")
|
||||
text = (
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
"[dim]Graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
@@ -1110,7 +1015,7 @@ class FenrisTuiApp(App):
|
||||
identity = _IDENTITY_FALLBACK
|
||||
lines = ["[bold]%s[/bold]" % identity]
|
||||
if self._show_auth_notice:
|
||||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||
lines.append("[dim]Open with fenris (no sudo). Actions authenticate via polkit. ? Help[/dim]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _render_headline(self, body: str = "") -> None:
|
||||
@@ -1120,94 +1025,67 @@ class FenrisTuiApp(App):
|
||||
text += "\n\n" + body
|
||||
self.query_one("#headline-band").update(text)
|
||||
|
||||
def _open_store(self) -> Optional[sqlite3.Connection]:
|
||||
"""Open store read-only, handling faults."""
|
||||
try:
|
||||
return open_store_readonly(self.store_path)
|
||||
except (StoreFault, NewerSchema):
|
||||
return None
|
||||
|
||||
def _refresh(self) -> None:
|
||||
"""Refresh all four regions from store data."""
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
# Also update constrained state on each refresh (issue #81)
|
||||
try:
|
||||
width, height = self.size
|
||||
was_constrained = self._is_constrained_mode
|
||||
self._is_constrained_mode = width < _MIN_WIDTH or height < _MIN_HEIGHT
|
||||
main_grid = self.query_one("#main-grid")
|
||||
if self._is_constrained_mode:
|
||||
main_grid.add_class("constrained")
|
||||
else:
|
||||
main_grid.remove_class("constrained")
|
||||
if self._is_constrained_mode and not was_constrained:
|
||||
self._update_constrained_summary()
|
||||
except Exception:
|
||||
pass
|
||||
conn = self._open_store()
|
||||
|
||||
with read_status(
|
||||
self.store_path, self._clock_now, reduced_motion=self._reduced_motion,
|
||||
) as (conn, comp):
|
||||
if conn is None:
|
||||
self._render_empty_or_fault()
|
||||
return
|
||||
|
||||
try:
|
||||
self._conn = conn
|
||||
self._render_all_regions(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
self._conn = None
|
||||
|
||||
def _render_empty_or_fault(self) -> None:
|
||||
"""Render empty store greeting or store fault."""
|
||||
self._hide_paused_banner()
|
||||
if not self.store_path.exists():
|
||||
# Empty store — greeting with enable hint (IN-3)
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n"
|
||||
"Enable monitoring: fenris monitor resume"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nawaiting first sample"
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self._render_empty_or_fault(comp)
|
||||
else:
|
||||
self._render_all_regions(conn, comp)
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
% monitoring_continuity({"boot_enabled": False})
|
||||
"%s\n%s" % (render_status_tui(comp), _ACTION_LEGEND)
|
||||
)
|
||||
self._render_paused_banner(comp)
|
||||
|
||||
def _render_empty_or_fault(self, comp: StatusComposition) -> None:
|
||||
"""Render empty history or the shared store-fault classification."""
|
||||
if comp.newer_schema or comp.store_fault:
|
||||
message = comp.explanation[:1].upper() + comp.explanation[1:]
|
||||
self._render_headline("[bold red]%s[/bold red]" % message)
|
||||
if comp.store_fault:
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
self.query_one("#drive-health").update(Text(hint))
|
||||
else:
|
||||
self.query_one("#drive-health").update(
|
||||
"Check observation store permissions and collector logs. ? Help"
|
||||
)
|
||||
else:
|
||||
# Store fault (FL-4)
|
||||
self.query_one("#drive-health").update("")
|
||||
else:
|
||||
message = "Awaiting first sample"
|
||||
self._render_headline(
|
||||
"[bold red]Observation store unreadable[/bold red]\n"
|
||||
"Check journalctl -u fenris-collect.service"
|
||||
)
|
||||
self.query_one("#usage-history").set_data([])
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\nobservation store unreadable"
|
||||
"[bold]No observations yet[/bold]\n\n[bold]%s[/bold]" % _RESUME_HINT
|
||||
)
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
|
||||
graph = self.query_one("#usage-history")
|
||||
graph.set_data([])
|
||||
if comp.store_fault or comp.newer_schema:
|
||||
self.query_one("#bar-render").update(message)
|
||||
if self._is_constrained_mode:
|
||||
self.query_one("#constrained-summary").update(
|
||||
"[dim]Graph needs ≥80×24[/dim]\n" + message
|
||||
)
|
||||
|
||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||
def _render_all_regions(self, conn: sqlite3.Connection, comp: StatusComposition) -> None:
|
||||
"""Render all four regions from live store data."""
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
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 Exception:
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
|
||||
# --- Headline band (§7.2) ---
|
||||
if sample_count <= 1 and day_count == 0:
|
||||
if comp.sample_count == 0 and comp.day_count == 0:
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n[bold]%s[/bold]" % _RESUME_HINT
|
||||
)
|
||||
elif comp.sample_count <= 1 and comp.day_count == 0:
|
||||
# Single sample: awaiting another sample
|
||||
self._render_headline(
|
||||
"[bold]Awaiting another sample[/bold]\n\n"
|
||||
@@ -1247,10 +1125,10 @@ class FenrisTuiApp(App):
|
||||
# Vendor wear grouped under Drive health with context
|
||||
health_text = (
|
||||
"[bold]Drive health[/bold] · %s\n"
|
||||
" temperature %d°C · spare %d%%\n"
|
||||
" media errors %d · unsafe shutdowns %d\n"
|
||||
" power-on %d h · %d cycles · %s\n"
|
||||
" vendor wear: %d%% used · %.1f TB written"
|
||||
" Temperature %d°C · spare %d%%\n"
|
||||
" Media errors %d · unsafe shutdowns %d\n"
|
||||
" Power-on %d h · %d cycles · %s\n"
|
||||
" Vendor wear: %d%% used · %.1f TB written"
|
||||
) % (
|
||||
health["model"],
|
||||
health["temp"],
|
||||
@@ -1265,32 +1143,6 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
self.query_one("#drive-health").update(health_text)
|
||||
|
||||
# --- Service strip (§7.2 bottom) ---
|
||||
try:
|
||||
svc = _query_service_facts(conn, self._clock_now)
|
||||
# Use shared status composition
|
||||
comp = compose_status(
|
||||
conn, svc, self._clock_now,
|
||||
store_fault=None,
|
||||
newer_schema=None,
|
||||
reduced_motion=self._reduced_motion,
|
||||
)
|
||||
# Render using the shared composition with TUI styling
|
||||
status_text = render_status_tui(comp)
|
||||
# Add TUI-only actions
|
||||
self.query_one("#service-strip").update(
|
||||
"%s\n"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
% status_text
|
||||
)
|
||||
self._render_paused_banner(comp)
|
||||
except Exception:
|
||||
self._hide_paused_banner()
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||
"p pause · r resume · c collect · t theme · m motion · d disclosures"
|
||||
)
|
||||
|
||||
def _render_paused_banner(self, comp) -> None:
|
||||
"""Show the high-contrast Deliberate disable block only when sanctioned.
|
||||
|
||||
@@ -1308,7 +1160,7 @@ class FenrisTuiApp(App):
|
||||
if is_paused and paused_lines:
|
||||
banner.update(
|
||||
"[bold black on red]%s[/bold black on red]\n%s"
|
||||
% tuple(paused_lines)
|
||||
% tuple(line[:1].upper() + line[1:] for line in paused_lines)
|
||||
)
|
||||
banner.styles.display = "block"
|
||||
main_grid = self.query_one("#main-grid")
|
||||
@@ -1327,16 +1179,12 @@ class FenrisTuiApp(App):
|
||||
def _on_graph_drill(self, day: str) -> None:
|
||||
"""Load hourly data when the graph enters drill-down mode."""
|
||||
graph = self.query_one("#usage-history")
|
||||
try:
|
||||
conn = self._open_store()
|
||||
if conn is not None:
|
||||
try:
|
||||
hour_data = _query_hourly_graph_data(conn, day)
|
||||
graph.set_hour_data(hour_data)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
graph.set_hour_data([])
|
||||
with read_status(
|
||||
self.store_path, self._clock_now, query_services=False,
|
||||
) as (conn, _):
|
||||
graph.set_hour_data(
|
||||
_query_hourly_graph_data(conn, day) if conn is not None else []
|
||||
)
|
||||
|
||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||
"""Format the lifespan headline (spec §6.11)."""
|
||||
@@ -1353,7 +1201,7 @@ class FenrisTuiApp(App):
|
||||
regime = " · sustained regime: %d days" % proj.regime_days
|
||||
return (
|
||||
"[bold]Usage-adjusted theoretical lifespan: [white]%s remaining[/white][/bold]"
|
||||
"\n if current habits continue%s" % (remaining, regime)
|
||||
"\n If current habits continue%s" % (remaining, regime)
|
||||
)
|
||||
|
||||
def _format_confidence(self, proj: ProjectionResult) -> str:
|
||||
@@ -1373,7 +1221,7 @@ class FenrisTuiApp(App):
|
||||
color,
|
||||
proj.confidence_state.value,
|
||||
color,
|
||||
facts,
|
||||
facts[:1].upper() + facts[1:],
|
||||
)
|
||||
|
||||
def _format_scenario(self, proj: ProjectionResult) -> str:
|
||||
@@ -1432,45 +1280,28 @@ class FenrisTuiApp(App):
|
||||
|
||||
def action_collect(self) -> None:
|
||||
"""Collect now — synchronous outcome (spec §8.7, LC-8)."""
|
||||
self._run_helper("collect", blocking=True)
|
||||
self._run_helper("collect")
|
||||
|
||||
def action_disclose(self) -> None:
|
||||
"""Show disclosures (spec §6.11, CI-4)."""
|
||||
self.push_screen(DisclosuresScreen())
|
||||
|
||||
def action_help(self) -> None:
|
||||
"""Show keyboard controls and sudo guidance."""
|
||||
self.push_screen(HelpScreen())
|
||||
|
||||
def _run_helper(
|
||||
self,
|
||||
operation: str,
|
||||
extra_args: Optional[List[str]] = None,
|
||||
blocking: bool = False,
|
||||
) -> None:
|
||||
"""Run fenris-monitor as terminal-attached subprocess (LC-6, LC-8).
|
||||
|
||||
The TUI suspends, polkit agent prompts on real terminal, control returns.
|
||||
"""
|
||||
cmd = [self.helper_path, operation]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
"""Suspend the TUI while the shared control module owns the action."""
|
||||
try:
|
||||
with self.suspend():
|
||||
proc = subprocess.run(cmd, timeout=30)
|
||||
if proc.returncode != 0:
|
||||
self.notify(
|
||||
"Operation failed (exit %d)" % proc.returncode,
|
||||
severity="error",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.notify(
|
||||
"Helper not found: %s" % self.helper_path,
|
||||
severity="error",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.notify("Operation timed out", severity="error")
|
||||
except Exception as e:
|
||||
self.notify("Error: %s" % e, severity="error")
|
||||
|
||||
# Refresh after action
|
||||
run_monitor(operation, *(extra_args or []), helper_path=self.helper_path)
|
||||
except MonitorError as exc:
|
||||
self.notify(str(exc), severity="error")
|
||||
finally:
|
||||
self._refresh()
|
||||
|
||||
|
||||
|
||||
@@ -461,7 +461,7 @@ class TestCI2Parity:
|
||||
}
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value=service_state), patch(
|
||||
"fenris.tui.query_service_state", return_value=service_state
|
||||
"fenris.status.query_service_state", return_value=service_state
|
||||
):
|
||||
status = get_status(
|
||||
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||
@@ -502,18 +502,6 @@ class TestCI2Parity:
|
||||
query_services=True, query_journal=False)
|
||||
assert "no observations yet" in status.lower()
|
||||
|
||||
def test_store_fault_phrase_both_views(self, tmp_path):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
phrase = "observation store unreadable"
|
||||
assert phrase in status_src
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase_both_views(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
assert phrase in status_src
|
||||
|
||||
def test_status_never_prompts(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "input(" not in status_src
|
||||
@@ -603,12 +591,6 @@ class TestCI3ProhibitionSet:
|
||||
table_names.append(m.group(1))
|
||||
assert "projection" not in [t.lower() for t in table_names]
|
||||
|
||||
def test_no_partial_newer_schema_interpretation(self):
|
||||
"""Readers refuse newer-schema stores. [3.6, 9.5]"""
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "NewerSchema" in status_src
|
||||
assert "upgrade Fenris" in status_src
|
||||
|
||||
def test_polkit_authorizes_one_binary(self):
|
||||
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
|
||||
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
|
||||
@@ -688,18 +670,6 @@ class TestCI4WordingAndDisclosures:
|
||||
proj_src = (FENRIS_PKG / "projection.py").read_text()
|
||||
assert phrase in proj_src
|
||||
|
||||
def test_store_fault_phrase(self):
|
||||
phrase = "observation store unreadable"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase(self):
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
|
||||
def test_no_observations_phrase(self):
|
||||
phrase = "no observations yet"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
|
||||
@@ -209,3 +209,86 @@ def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
|
||||
"path": "/releases/17",
|
||||
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
|
||||
}
|
||||
|
||||
|
||||
def test_format_availability_section_with_both():
|
||||
extractor = _extractor_module()
|
||||
result = extractor.format_availability_section(
|
||||
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)"],
|
||||
withheld=["Void Linux (xbps) — pending host acceptance"],
|
||||
)
|
||||
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result
|
||||
assert "Withheld: Void Linux (xbps) — pending host acceptance" in result
|
||||
assert "## Package formats" in result
|
||||
|
||||
|
||||
def test_format_availability_section_available_only():
|
||||
extractor = _extractor_module()
|
||||
result = extractor.format_availability_section(
|
||||
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)", "Void Linux (xbps)"],
|
||||
withheld=[],
|
||||
)
|
||||
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm), Void Linux (xbps)" in result
|
||||
assert "Withheld" not in result
|
||||
|
||||
|
||||
def test_format_availability_section_empty():
|
||||
extractor = _extractor_module()
|
||||
result = extractor.format_availability_section(available=[], withheld=[])
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_command_includes_format_availability(tmp_path):
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
|
||||
"### Added\n\n- Show a release summary.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(EXTRACTOR_PATH),
|
||||
str(changelog),
|
||||
"1.4.0",
|
||||
"--available",
|
||||
"Debian/Ubuntu (deb)",
|
||||
"--available",
|
||||
"Fedora/openSUSE (rpm)",
|
||||
"--withheld",
|
||||
"Void Linux (xbps)",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "## Package formats" in result.stdout
|
||||
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result.stdout
|
||||
assert "Withheld: Void Linux (xbps)" in result.stdout
|
||||
|
||||
|
||||
def test_command_without_format_args_has_no_formats_section(tmp_path):
|
||||
changelog = tmp_path / "CHANGELOG.md"
|
||||
changelog.write_text(
|
||||
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
|
||||
"### Added\n\n- Show a release summary.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(EXTRACTOR_PATH),
|
||||
str(changelog),
|
||||
"1.4.0",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "## Package formats" not in result.stdout
|
||||
|
||||
@@ -412,7 +412,7 @@ class TestDisplayStates:
|
||||
|
||||
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||
"""One sample → TUI shows awaiting state."""
|
||||
from fenris.tui import FenrisTuiApp, _query_service_facts
|
||||
from fenris.tui import FenrisTuiApp
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""The CLI and TUI cross the same terminal-attached action interface."""
|
||||
import argparse
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from fenris.control import MONITOR_HELPER, MonitorError, run_monitor
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid", [0, 1000])
|
||||
@pytest.mark.parametrize("args", [("enable", "--now"), ("disable", "--now"), ("collect",)])
|
||||
def test_fixed_helper_and_terminal_attachment(uid, args):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
run_monitor(*args)
|
||||
expected = [MONITOR_HELPER, *args]
|
||||
if uid:
|
||||
expected.insert(0, "pkexec")
|
||||
# Inherited stdin/out/err keep authentication on the user's terminal.
|
||||
# No frontend deadline can cut short a valid 90-second Collection run.
|
||||
run.assert_called_once_with(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [1, 126, 127, -15])
|
||||
def test_failure_is_not_retried_and_root_equivalent_preserves_arguments(code):
|
||||
args = ("baseline", "set", '{"model": "Drive $(whoami)", "tbw": 100}')
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], code)) as run:
|
||||
with pytest.raises(MonitorError) as failure:
|
||||
run_monitor(*args)
|
||||
run.assert_called_once()
|
||||
assert failure.value.exit_code == (code if code > 0 else 128 - code)
|
||||
root_command = str(failure.value).split("run in your terminal: ")[1]
|
||||
assert shlex.split(root_command) == ["sudo", MONITOR_HELPER, *args]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid,missing", [(0, MONITOR_HELPER), (1000, "pkexec")])
|
||||
def test_missing_command_is_identified(uid, missing):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", missing)):
|
||||
with pytest.raises(MonitorError, match="Command not found") as failure:
|
||||
run_monitor("collect")
|
||||
assert missing in str(failure.value)
|
||||
assert failure.value.exit_code == 127
|
||||
assert ("sudo" in str(failure.value)) == bool(uid)
|
||||
|
||||
|
||||
def test_interrupt_reports_uncertain_outcome():
|
||||
with patch("fenris.control.subprocess.run", side_effect=KeyboardInterrupt):
|
||||
with pytest.raises(MonitorError, match="Check fenris status") as failure:
|
||||
run_monitor("collect")
|
||||
assert failure.value.exit_code == 130
|
||||
|
||||
|
||||
def test_cli_and_tui_show_the_same_failure(tmp_path, capsys):
|
||||
# The launcher may prepend an installed runtime while loading; isolate it.
|
||||
with patch.object(sys, "path", sys.path.copy()):
|
||||
cli = runpy.run_path(str(Path(__file__).parent.parent / "scripts" / "fenris"))
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 126)):
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli["cmd_monitor_resume"](argparse.Namespace())
|
||||
assert exit_info.value.code == 126
|
||||
cli_message = capsys.readouterr().err.strip()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "suspend"), patch.object(app, "_refresh") as refresh, \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
notify.assert_called_once_with(cli_message, severity="error")
|
||||
refresh.assert_called_once()
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Tests for the init system abstraction layer (issue #84).
|
||||
|
||||
Covers:
|
||||
- Init system detection (systemd vs runit)
|
||||
- systemd backend functions (enable, disable, collect, query state)
|
||||
- runit backend functions (enable, disable, collect, query state)
|
||||
- Public API dispatching to correct backend
|
||||
- Edge cases (already enabled/disabled, missing files, timeouts)
|
||||
|
||||
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.init_system import (
|
||||
InitSystem,
|
||||
detect_init_system,
|
||||
get_init_system,
|
||||
reset_init_system_cache,
|
||||
_systemd_enable,
|
||||
_systemd_disable,
|
||||
_systemd_collect,
|
||||
_systemd_query_state,
|
||||
_runit_enable,
|
||||
_runit_disable,
|
||||
_runit_collect,
|
||||
_runit_query_state,
|
||||
_runit_is_enabled,
|
||||
_runit_is_running,
|
||||
enable_timer,
|
||||
disable_timer,
|
||||
collect_now,
|
||||
query_service_state,
|
||||
journal_hint,
|
||||
FENRIS_SV_DIR,
|
||||
FENRIS_SERVICE_LINK,
|
||||
COLLECT_TIMEOUT_S,
|
||||
)
|
||||
from fenris.store import init_store
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_cache():
|
||||
"""Reset the init system cache before each test."""
|
||||
reset_init_system_cache()
|
||||
yield
|
||||
reset_init_system_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init system detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInitSystemDetection:
|
||||
"""Test init system detection logic."""
|
||||
|
||||
def test_detect_systemd_by_run_directory(self):
|
||||
"""Systemd detected via /run/systemd/system directory."""
|
||||
with patch("pathlib.Path.exists") as mock_exists:
|
||||
mock_exists.return_value = True
|
||||
reset_init_system_cache()
|
||||
result = detect_init_system()
|
||||
assert result == InitSystem.SYSTEMD
|
||||
|
||||
def test_detect_systemd_by_pid1(self):
|
||||
"""Systemd detected via PID 1 name."""
|
||||
original_exists = Path.exists
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def mock_exists(self_path):
|
||||
if str(self_path) == "/run/systemd/system":
|
||||
return False
|
||||
return original_exists(self_path)
|
||||
|
||||
def mock_read_text(self_path):
|
||||
if str(self_path) == "/proc/1/comm":
|
||||
return "systemd"
|
||||
return original_read_text(self_path)
|
||||
|
||||
with patch("pathlib.Path.exists", mock_exists), \
|
||||
patch("pathlib.Path.read_text", mock_read_text):
|
||||
reset_init_system_cache()
|
||||
result = detect_init_system()
|
||||
assert result == InitSystem.SYSTEMD
|
||||
|
||||
def test_detect_runit_by_pid1(self):
|
||||
"""Runit detected via PID 1 name."""
|
||||
original_exists = Path.exists
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def mock_exists(self_path):
|
||||
if str(self_path) == "/run/systemd/system":
|
||||
return False
|
||||
return original_exists(self_path)
|
||||
|
||||
def mock_read_text(self_path):
|
||||
if str(self_path) == "/proc/1/comm":
|
||||
return "runsv"
|
||||
return original_read_text(self_path)
|
||||
|
||||
with patch("pathlib.Path.exists", mock_exists), \
|
||||
patch("pathlib.Path.read_text", mock_read_text):
|
||||
reset_init_system_cache()
|
||||
result = detect_init_system()
|
||||
assert result == InitSystem.RUNIT
|
||||
|
||||
def test_detect_runit_by_etc_sv(self):
|
||||
"""Runit detected via /etc/sv directory."""
|
||||
original_exists = Path.exists
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def mock_exists(self_path):
|
||||
if str(self_path) == "/run/systemd/system":
|
||||
return False
|
||||
if str(self_path) == "/etc/sv":
|
||||
return True
|
||||
return original_exists(self_path)
|
||||
|
||||
def mock_read_text(self_path):
|
||||
if str(self_path) == "/proc/1/comm":
|
||||
raise OSError("no such file")
|
||||
return original_read_text(self_path)
|
||||
|
||||
with patch("pathlib.Path.exists", mock_exists), \
|
||||
patch("pathlib.Path.read_text", mock_read_text):
|
||||
reset_init_system_cache()
|
||||
result = detect_init_system()
|
||||
assert result == InitSystem.RUNIT
|
||||
|
||||
def test_default_to_systemd(self):
|
||||
"""Default to systemd when no detection matches."""
|
||||
original_exists = Path.exists
|
||||
original_read_text = Path.read_text
|
||||
original_is_dir = Path.is_dir
|
||||
|
||||
def mock_exists(self_path):
|
||||
if str(self_path) == "/run/systemd/system":
|
||||
return False
|
||||
return original_exists(self_path)
|
||||
|
||||
def mock_is_dir(self_path):
|
||||
if str(self_path) == "/etc/sv":
|
||||
return False
|
||||
return original_is_dir(self_path)
|
||||
|
||||
def mock_read_text(self_path):
|
||||
if str(self_path) == "/proc/1/comm":
|
||||
raise OSError("no such file")
|
||||
return original_read_text(self_path)
|
||||
|
||||
with patch("pathlib.Path.exists", mock_exists), \
|
||||
patch("pathlib.Path.is_dir", mock_is_dir), \
|
||||
patch("pathlib.Path.read_text", mock_read_text):
|
||||
reset_init_system_cache()
|
||||
result = detect_init_system()
|
||||
assert result == InitSystem.SYSTEMD
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# systemd backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSystemdEnable:
|
||||
"""Test systemd enable function."""
|
||||
|
||||
def test_enable_now(self):
|
||||
"""Enable with --now flag."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_systemd_enable(now=True)
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def test_enable_without_now(self):
|
||||
"""Enable without --now flag."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_systemd_enable(now=False)
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "enable", "fenris-collect.timer"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def test_enable_failure_exits(self):
|
||||
"""Enable failure exits with error."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(
|
||||
returncode=1, stderr="Unit not found"
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_systemd_enable(now=True)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestSystemdDisable:
|
||||
"""Test systemd disable function."""
|
||||
|
||||
def test_disable_now(self):
|
||||
"""Disable with --now flag."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_systemd_disable(now=True)
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def test_disable_without_now(self):
|
||||
"""Disable without --now flag."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_systemd_disable(now=False)
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "disable", "fenris-collect.timer"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
|
||||
class TestSystemdCollect:
|
||||
"""Test systemd collect function."""
|
||||
|
||||
def test_collect_success(self):
|
||||
"""Successful collection."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_systemd_collect()
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "start", "fenris-collect.service"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def test_collect_failure_exits(self):
|
||||
"""Collection failure exits with error."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(
|
||||
returncode=1, stderr="Unit not found"
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_systemd_collect()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestSystemdQueryState:
|
||||
"""Test systemd query state function."""
|
||||
|
||||
def test_query_state_enabled_active(self):
|
||||
"""Query state for enabled and active timer."""
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
def mock_show_fn(unit, *props):
|
||||
if unit == "fenris-collect.timer":
|
||||
return {
|
||||
"UnitFileState": "enabled",
|
||||
"ActiveState": "active",
|
||||
"LastTriggerUSec": "2026-09-01T12:00:00Z",
|
||||
}
|
||||
elif unit == "fenris-collect.service":
|
||||
return {
|
||||
"ActiveState": "inactive",
|
||||
"ExecMainStatus": "0",
|
||||
"ExecMainExitTimestamp": "2026-09-01T12:00:30Z",
|
||||
}
|
||||
return {}
|
||||
mock_show.side_effect = mock_show_fn
|
||||
|
||||
result = _systemd_query_state()
|
||||
|
||||
assert result["boot_enabled"] is True
|
||||
assert result["timer_active"] is True
|
||||
assert result["last_collect_ok"] is True
|
||||
assert result["last_collect_age_s"] is not None
|
||||
|
||||
def test_query_state_disabled_inactive(self):
|
||||
"""Query state for disabled and inactive timer."""
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {"UnitFileState": "disabled", "ActiveState": "inactive"}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
assert result["last_collect_ok"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runit backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunitEnable:
|
||||
"""Test runit enable function."""
|
||||
|
||||
def test_enable_creates_symlink(self, tmp_path):
|
||||
"""Enable creates symlink to service directory."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
_runit_enable(_now=True)
|
||||
assert service_link.exists()
|
||||
assert service_link.is_symlink()
|
||||
assert service_link.resolve() == sv_dir
|
||||
|
||||
def test_enable_removes_down_file(self, tmp_path):
|
||||
"""Enable removes the 'down' file if present."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
(sv_dir / "down").touch()
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
_runit_enable(_now=True)
|
||||
assert not (sv_dir / "down").exists()
|
||||
|
||||
def test_enable_idempotent(self, tmp_path):
|
||||
"""Enable is idempotent when already enabled."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
service_link.symlink_to(sv_dir)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
_runit_enable(_now=True)
|
||||
assert service_link.exists()
|
||||
|
||||
|
||||
class TestRunitDisable:
|
||||
"""Test runit disable function."""
|
||||
|
||||
def test_disable_removes_symlink(self, tmp_path):
|
||||
"""Disable removes the service symlink."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
service_link.symlink_to(sv_dir)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
_runit_disable(_now=True)
|
||||
assert not service_link.exists()
|
||||
assert (sv_dir / "down").exists()
|
||||
|
||||
def test_disable_idempotent(self, tmp_path):
|
||||
"""Disable is idempotent when already disabled."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
_runit_disable(_now=True)
|
||||
assert not service_link.exists()
|
||||
|
||||
|
||||
class TestRunitCollect:
|
||||
"""Test runit collect function."""
|
||||
|
||||
def test_collect_uses_flock(self):
|
||||
"""Collect uses flock for serialization."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||
patch("fenris.init_system.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
_runit_collect()
|
||||
# Verify flock was used
|
||||
call_args = mock_sub.run.call_args[0][0]
|
||||
assert "flock" in call_args
|
||||
|
||||
def test_collect_timeout_exits(self):
|
||||
"""Collection timeout exits with error."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||
patch("fenris.init_system.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_sub.run.return_value = MagicMock(returncode=124)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_runit_collect()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestRunitQueryState:
|
||||
"""Test runit query state function."""
|
||||
|
||||
def test_query_state_enabled_running(self, tmp_path):
|
||||
"""Query state for enabled and running service."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
supervise_dir = sv_dir / "supervise"
|
||||
supervise_dir.mkdir(parents=True)
|
||||
(supervise_dir / "pid").write_text("12345")
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
service_link.symlink_to(sv_dir)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||
patch("os.kill") as mock_kill:
|
||||
mock_kill.return_value = True # Process exists
|
||||
result = _runit_query_state()
|
||||
assert result["boot_enabled"] is True
|
||||
assert result["timer_active"] is True
|
||||
|
||||
def test_query_state_disabled_not_running(self, tmp_path):
|
||||
"""Query state for disabled and not running service."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
result = _runit_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
|
||||
|
||||
class TestRunitIsEnabled:
|
||||
"""Test runit is_enabled check."""
|
||||
|
||||
def test_is_enabled_true(self, tmp_path):
|
||||
"""Service is enabled when symlink exists."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
service_link.symlink_to(sv_dir)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
assert _runit_is_enabled() is True
|
||||
|
||||
def test_is_enabled_false(self, tmp_path):
|
||||
"""Service is disabled when symlink does not exist."""
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||
assert _runit_is_enabled() is False
|
||||
|
||||
|
||||
class TestRunitIsRunning:
|
||||
"""Test runit is_running check."""
|
||||
|
||||
def test_is_running_true(self, tmp_path):
|
||||
"""Service is running when PID file exists and process is alive."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
supervise_dir = sv_dir / "supervise"
|
||||
supervise_dir.mkdir(parents=True)
|
||||
(supervise_dir / "pid").write_text("12345")
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("os.kill") as mock_kill:
|
||||
mock_kill.return_value = True
|
||||
assert _runit_is_running() is True
|
||||
|
||||
def test_is_running_false_no_pid(self, tmp_path):
|
||||
"""Service is not running when PID file does not exist."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
sv_dir.mkdir(parents=True)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.returncode = 1
|
||||
assert _runit_is_running() is False
|
||||
|
||||
def test_is_running_false_dead_process(self, tmp_path):
|
||||
"""Service is not running when process is dead."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
supervise_dir = sv_dir / "supervise"
|
||||
supervise_dir.mkdir(parents=True)
|
||||
(supervise_dir / "pid").write_text("12345")
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("os.kill", side_effect=OSError("No such process")):
|
||||
assert _runit_is_running() is False
|
||||
|
||||
def test_is_running_uses_process_table_when_supervise_is_unreadable(self, tmp_path):
|
||||
"""Void keeps runit's supervise directory root-only for normal users."""
|
||||
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||
supervise_dir = sv_dir / "supervise"
|
||||
supervise_dir.mkdir(parents=True)
|
||||
pid_file = supervise_dir / "pid"
|
||||
pid_file.write_text("12345")
|
||||
service_link = tmp_path / "service" / "fenris-collect"
|
||||
service_link.parent.mkdir(parents=True)
|
||||
service_link.symlink_to(sv_dir)
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def deny_pid(path, *args, **kwargs):
|
||||
if path == pid_file:
|
||||
raise PermissionError("supervise is root-only")
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||
patch.object(Path, "read_text", autospec=True, side_effect=deny_pid), \
|
||||
patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value.returncode = 0
|
||||
assert _runit_is_running() is True
|
||||
mock_run.assert_called_once_with(
|
||||
["pgrep", "-f", "^runsv fenris-collect$"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API dispatching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublicAPI:
|
||||
"""Test public API dispatches to correct backend."""
|
||||
|
||||
def test_enable_dispatches_to_systemd(self):
|
||||
"""enable_timer dispatches to systemd on systemd system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemd_enable") as mock_enable:
|
||||
enable_timer(now=True)
|
||||
mock_enable.assert_called_once_with(True)
|
||||
|
||||
def test_enable_dispatches_to_runit(self):
|
||||
"""enable_timer dispatches to runit on runit system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||
patch("fenris.init_system._runit_enable") as mock_enable:
|
||||
enable_timer(now=True)
|
||||
mock_enable.assert_called_once_with(True)
|
||||
|
||||
def test_disable_dispatches_to_systemd(self):
|
||||
"""disable_timer dispatches to systemd on systemd system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemd_disable") as mock_disable:
|
||||
disable_timer(now=False)
|
||||
mock_disable.assert_called_once_with(False)
|
||||
|
||||
def test_disable_dispatches_to_runit(self):
|
||||
"""disable_timer dispatches to runit on runit system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||
patch("fenris.init_system._runit_disable") as mock_disable:
|
||||
disable_timer(now=False)
|
||||
mock_disable.assert_called_once_with(False)
|
||||
|
||||
def test_collect_dispatches_to_systemd(self):
|
||||
"""collect_now dispatches to systemd on systemd system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemd_collect") as mock_collect:
|
||||
collect_now()
|
||||
mock_collect.assert_called_once()
|
||||
|
||||
def test_collect_dispatches_to_runit(self):
|
||||
"""collect_now dispatches to runit on runit system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||
patch("fenris.init_system._runit_collect") as mock_collect:
|
||||
collect_now()
|
||||
mock_collect.assert_called_once()
|
||||
|
||||
def test_query_state_dispatches_to_systemd(self):
|
||||
"""query_service_state dispatches to systemd on systemd system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemd_query_state") as mock_query:
|
||||
query_service_state()
|
||||
mock_query.assert_called_once()
|
||||
|
||||
def test_query_state_dispatches_to_runit(self):
|
||||
"""query_service_state dispatches to runit on runit system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||
patch("fenris.init_system._runit_query_state") as mock_query:
|
||||
query_service_state()
|
||||
mock_query.assert_called_once()
|
||||
|
||||
def test_journal_hint_dispatches_to_systemd(self):
|
||||
"""journal_hint dispatches to systemd on systemd system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemd_journal_hint") as mock_hint:
|
||||
journal_hint(lines=3, unit="fenris-collect.service")
|
||||
mock_hint.assert_called_once_with(3)
|
||||
|
||||
def test_journal_hint_dispatches_to_runit(self):
|
||||
"""journal_hint dispatches to runit on runit system."""
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||
patch("fenris.init_system._runit_journal_hint") as mock_hint:
|
||||
journal_hint(lines=3, unit="fenris-collect.service")
|
||||
mock_hint.assert_called_once_with(3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants and configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConstants:
|
||||
"""Test constants match spec requirements."""
|
||||
|
||||
def test_collect_timeout(self):
|
||||
"""Collection timeout is 90 seconds (bounded execution)."""
|
||||
assert COLLECT_TIMEOUT_S == 90
|
||||
|
||||
def test_init_system_enum(self):
|
||||
"""InitSystem enum has systemd and runit variants."""
|
||||
assert InitSystem.SYSTEMD.value == "systemd"
|
||||
assert InitSystem.RUNIT.value == "runit"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration with monitor.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMonitorIntegration:
|
||||
"""Test that monitor.py uses the abstraction layer correctly."""
|
||||
|
||||
def test_cmd_enable_uses_init_system(self, tmp_path):
|
||||
"""cmd_enable uses init_system.enable_timer."""
|
||||
from fenris.monitor import cmd_enable
|
||||
from argparse import Namespace
|
||||
|
||||
store_path = tmp_path / "observations.db"
|
||||
init_store(store_path)
|
||||
args = Namespace(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||
cmd_enable(args)
|
||||
mock_enable.assert_called_once_with(True)
|
||||
|
||||
def test_cmd_disable_uses_init_system(self, tmp_path):
|
||||
"""cmd_disable uses init_system.disable_timer."""
|
||||
from fenris.monitor import cmd_disable
|
||||
from argparse import Namespace
|
||||
|
||||
store_path = tmp_path / "observations.db"
|
||||
init_store(store_path)
|
||||
args = Namespace(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||
cmd_disable(args)
|
||||
mock_disable.assert_called_once_with(True)
|
||||
|
||||
def test_cmd_collect_uses_init_system(self):
|
||||
"""cmd_collect uses init_system.collect_now."""
|
||||
from fenris.monitor import cmd_collect
|
||||
from argparse import Namespace
|
||||
|
||||
args = Namespace()
|
||||
|
||||
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||
cmd_collect(args)
|
||||
mock_collect.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_systemd_enable_failure_produces_stderr(self):
|
||||
"""Systemd enable failure produces error message on stderr."""
|
||||
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(
|
||||
returncode=1, stderr="Permission denied"
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
_systemd_enable(now=True)
|
||||
|
||||
def test_runit_collect_missing_script(self):
|
||||
"""Runit collect exits when fenris-collect script not found."""
|
||||
with patch("fenris.init_system.Path") as mock_path:
|
||||
mock_path.return_value.exists.return_value = False
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_runit_collect()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_systemd_query_state_timeout(self):
|
||||
"""Systemd query state handles subprocess timeout."""
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is None
|
||||
assert result["timer_active"] is None
|
||||
@@ -260,7 +260,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -272,11 +272,11 @@ class TestPreservedBehavior:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_rail_preserved(self, tmp_path):
|
||||
"""Separate q QUIT TUI rail preserved."""
|
||||
"""Separate q Quit TUI rail preserved."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "q QUIT TUI" in rail
|
||||
assert "q Quit TUI" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_banner_preserved(self, tmp_path):
|
||||
@@ -307,7 +307,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
|
||||
+11
-26
@@ -56,8 +56,7 @@ class TestEnableIdempotentMatrix:
|
||||
"""A fresh package install has a store directory but no database yet."""
|
||||
args = MagicMock(now=False, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||
cmd_enable(args)
|
||||
|
||||
conn = init_store(store_path)
|
||||
@@ -74,8 +73,7 @@ class TestEnableIdempotentMatrix:
|
||||
|
||||
args = MagicMock(now=False, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||
cmd_enable(args)
|
||||
|
||||
# Period should be open
|
||||
@@ -100,8 +98,7 @@ class TestEnableIdempotentMatrix:
|
||||
|
||||
args = MagicMock(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||
cmd_enable(args)
|
||||
|
||||
# Should still have exactly one open period
|
||||
@@ -125,8 +122,7 @@ class TestEnableIdempotentMatrix:
|
||||
|
||||
args = MagicMock(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||
cmd_enable(args)
|
||||
|
||||
# Should have a new open period
|
||||
@@ -155,8 +151,7 @@ class TestDisableIdempotentMatrix:
|
||||
|
||||
args = MagicMock(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||
cmd_disable(args)
|
||||
|
||||
# Period should be closed with user_disabled
|
||||
@@ -174,8 +169,7 @@ class TestDisableIdempotentMatrix:
|
||||
|
||||
args = MagicMock(now=True, store_path=store_path)
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||
cmd_disable(args)
|
||||
|
||||
# No periods should exist
|
||||
@@ -207,28 +201,19 @@ class TestDisableIdempotentMatrix:
|
||||
class TestCollectTrigger:
|
||||
"""§8.7: On-demand collection via helper path."""
|
||||
|
||||
def test_collect_triggers_systemctl_start(self):
|
||||
"""Collect starts fenris-collect.service synchronously."""
|
||||
def test_collect_triggers_init_system(self):
|
||||
"""Collect triggers init_system.collect_now."""
|
||||
args = MagicMock()
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||
cmd_collect(args)
|
||||
|
||||
mock_sub.run.assert_called_once_with(
|
||||
["systemctl", "start", "fenris-collect.service"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
mock_collect.assert_called_once()
|
||||
|
||||
def test_collect_failure_exits_nonzero(self):
|
||||
"""Collect failure exits with nonzero status."""
|
||||
args = MagicMock()
|
||||
|
||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||
mock_sub.run.return_value = MagicMock(
|
||||
returncode=1, stderr="Unit not found"
|
||||
)
|
||||
with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_collect(args)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
+589
-8
@@ -87,13 +87,38 @@ def _find_package(fmt: str) -> Path:
|
||||
candidate = DIST_DIR / f"fenris_{version}_amd64.deb"
|
||||
elif fmt == "rpm":
|
||||
candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm"
|
||||
elif fmt == "xbps":
|
||||
candidate = DIST_DIR / f"fenris-{version}_1.x86_64.xbps"
|
||||
else:
|
||||
raise ValueError(f"Unknown format: {fmt}")
|
||||
if not candidate.exists():
|
||||
pytest.skip(f"Package not found: {candidate} — run `make package` first")
|
||||
pytest.skip(f"Package not found: {candidate} — run `make package-xbps` first")
|
||||
return candidate
|
||||
|
||||
|
||||
def test_runit_logger_uses_accounts_shipped_by_package():
|
||||
"""The runit logger must use the package's group-only identity.
|
||||
|
||||
The package declares a ``fenris`` group for store/log access, not a
|
||||
``fenris`` service user. Starting the logger with ``fenris:fenris``
|
||||
therefore leaves the diagnostics supervisor down on a real Void host;
|
||||
Void's standard ``nobody`` account supplies the unprivileged uid.
|
||||
"""
|
||||
logger = (REPO_ROOT / "units" / "runit" / "fenris-collect" / "log" / "run").read_text()
|
||||
sysusers = (REPO_ROOT / "packaging" / "sysusers.d" / "fenris.conf").read_text()
|
||||
|
||||
assert "g fenris -" in sysusers
|
||||
assert "chpst -u nobody:fenris" in logger
|
||||
|
||||
|
||||
def test_xbps_publisher_verifies_embedded_repository_signature():
|
||||
"""Publication verification must match XBPS's repository layout."""
|
||||
publisher = (REPO_ROOT / "scripts" / "xbps-publish.sh").read_text()
|
||||
|
||||
assert '"x86_64-repodata"' in publisher
|
||||
assert '"x86_64-repodata.sig2"' not in publisher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matrix definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -109,7 +134,11 @@ RPM_TARGETS = [
|
||||
("opensuse/tumbleweed", "rpm"),
|
||||
]
|
||||
|
||||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
|
||||
XBPS_TARGETS = [
|
||||
("ghcr.io/void-linux/void-glibc-full:latest", "xbps"),
|
||||
]
|
||||
|
||||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + XBPS_TARGETS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -143,6 +172,88 @@ def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
|
||||
""")
|
||||
|
||||
|
||||
def _build_xbps_dockerfile(image: str, pkg_name: str) -> str:
|
||||
"""Dockerfile for testing xbps installation."""
|
||||
return textwrap.dedent(f"""\
|
||||
FROM {image}
|
||||
RUN xbps-install -Sy python3 smartmontools runit shadow && \\
|
||||
xbps-remove -O
|
||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||
""")
|
||||
|
||||
|
||||
def _xbps_extract_and_install(container: str, pkg_name: str, *, action: str = "install") -> tuple[int, str]:
|
||||
"""Extract xbps package and run lifecycle scripts in container.
|
||||
|
||||
action: "install" (fresh), "upgrade" (UPDATE=yes), "remove", or "purge"
|
||||
"""
|
||||
# xbps packages are zstd-compressed tar archives containing:
|
||||
# ./INSTALL, ./REMOVE, ./files.plist, ./props.plist, ./usr/*, ./etc/*
|
||||
extract = (
|
||||
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf -"
|
||||
)
|
||||
# Ensure target directories exist for manual extraction
|
||||
_container_exec(container, "mkdir -p /usr/libexec/fenris /usr/lib/systemd/system "
|
||||
"/etc/sv/fenris-collect/log /usr/share/polkit-1/actions "
|
||||
"/usr/lib/sysusers.d /usr/lib/tmpfiles.d /opt/fenris /etc/fenris")
|
||||
if action == "install":
|
||||
commands = (
|
||||
f"{extract} && "
|
||||
"sh INSTALL pre fenris 0.3.5 no '' x86_64 && "
|
||||
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
|
||||
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
|
||||
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
|
||||
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
|
||||
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
|
||||
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
|
||||
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
|
||||
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
|
||||
"sh INSTALL post fenris 0.3.5 no '' x86_64"
|
||||
)
|
||||
elif action == "upgrade":
|
||||
commands = (
|
||||
f"{extract} && "
|
||||
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
|
||||
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
|
||||
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
|
||||
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
|
||||
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
|
||||
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
|
||||
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
|
||||
# Do NOT copy fenris.conf — simulates noreplace behavior
|
||||
"sh INSTALL post fenris 0.3.5 yes '' x86_64"
|
||||
)
|
||||
elif action == "remove":
|
||||
commands = (
|
||||
f"{extract} && "
|
||||
"sh REMOVE pre fenris 0.3.5 no '' x86_64 && "
|
||||
"rm -f /usr/bin/fenris && "
|
||||
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
|
||||
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
|
||||
"rm -rf /opt/fenris && "
|
||||
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
|
||||
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
|
||||
)
|
||||
elif action == "purge":
|
||||
commands = (
|
||||
f"{extract} && "
|
||||
"sh REMOVE purge fenris 0.3.5 no '' x86_64 && "
|
||||
"rm -f /usr/bin/fenris && "
|
||||
"rm -f /usr/libexec/fenris/fenris-monitor /usr/libexec/fenris/fenris-collect && "
|
||||
"rm -f /usr/lib/systemd/system/fenris-collect.timer /usr/lib/systemd/system/fenris-collect.service && "
|
||||
"rm -rf /opt/fenris && "
|
||||
"rm -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy && "
|
||||
"rm -f /usr/lib/sysusers.d/fenris.conf /usr/lib/tmpfiles.d/fenris.conf"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
return _container_exec(container, commands)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -262,7 +373,8 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
||||
]
|
||||
assert not owned, f"Store contents owned by RPM (should not be): {owned}"
|
||||
|
||||
# Timer is disabled and inactive (dormant)
|
||||
# Timer is disabled and inactive (dormant) — systemd only
|
||||
if fmt != "xbps":
|
||||
rc, out = _container_exec(
|
||||
container, "systemctl is-enabled fenris-collect.timer 2>/dev/null || echo disabled"
|
||||
)
|
||||
@@ -275,6 +387,13 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
||||
assert "inactive" in out.lower() or "dead" in out.lower() or rc != 0, \
|
||||
f"Timer should be inactive (dormant), got: {out}"
|
||||
|
||||
# runit: service is dormant (down marker present, no symlink)
|
||||
if fmt == "xbps":
|
||||
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
|
||||
assert rc == 0, "Runit service not marked dormant (down file missing)"
|
||||
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
|
||||
assert rc == 0, "Runit service symlink should not exist (dormant)"
|
||||
|
||||
# No hand-rolled manifest
|
||||
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
|
||||
assert rc != 0, "Legacy manifest.txt should not exist in package install"
|
||||
@@ -347,6 +466,23 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
|
||||
f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || "
|
||||
"apt-get install -f -y 2>&1 || true",
|
||||
)
|
||||
elif fmt == "xbps":
|
||||
# XBPS: extract, install files, run INSTALL post with UPDATE=yes
|
||||
_container_exec(
|
||||
container,
|
||||
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
|
||||
"cp usr/bin/* /usr/bin/ 2>/dev/null || true && "
|
||||
"cp usr/libexec/fenris/* /usr/libexec/fenris/ 2>/dev/null || true && "
|
||||
"cp -r usr/lib/systemd/* /usr/lib/systemd/ 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/run /etc/sv/fenris-collect/run 2>/dev/null || true && "
|
||||
"cp etc/sv/fenris-collect/log/run /etc/sv/fenris-collect/log/run 2>/dev/null || true && "
|
||||
"cp usr/share/polkit-1/actions/* /usr/share/polkit-1/actions/ 2>/dev/null || true && "
|
||||
"cp usr/lib/sysusers.d/* /usr/lib/sysusers.d/ 2>/dev/null || true && "
|
||||
"cp usr/lib/tmpfiles.d/* /usr/lib/tmpfiles.d/ 2>/dev/null || true && "
|
||||
"cp -r opt/fenris/* /opt/fenris/ 2>/dev/null || true && "
|
||||
"cp etc/fenris/fenris.conf /etc/fenris/fenris.conf 2>/dev/null || true && "
|
||||
"sh INSTALL post fenris 0.3.5 yes '' x86_64",
|
||||
)
|
||||
else:
|
||||
# RPM: invoke all three scriptlets in upgrade sequence
|
||||
# preun ($1=1): should be no-op (only daemon-reload)
|
||||
@@ -735,6 +871,8 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
# Write Dockerfile
|
||||
if fmt == "deb":
|
||||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||||
elif fmt == "xbps":
|
||||
dockerfile = _build_xbps_dockerfile(image, pkg_name)
|
||||
else:
|
||||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||||
@@ -745,7 +883,7 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
timeout=600 if fmt == "xbps" else 300,
|
||||
)
|
||||
|
||||
# Run container
|
||||
@@ -759,6 +897,10 @@ def test_dormant_install(skip_no_docker, image, fmt, version):
|
||||
)
|
||||
|
||||
try:
|
||||
if fmt == "xbps":
|
||||
# XBPS: manually extract and run lifecycle scripts
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
_assert_dormant_layout(container, fmt, version)
|
||||
finally:
|
||||
subprocess.run(
|
||||
@@ -797,6 +939,15 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
||||
# Plant make-install remnant BEFORE installing
|
||||
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
||||
""")
|
||||
elif fmt == "xbps":
|
||||
dockerfile = textwrap.dedent(f"""\
|
||||
FROM {image}
|
||||
RUN xbps-install -Suyn void-repo-nonfree && \\
|
||||
xbps-install -Syun python3 smartmontools runit && \\
|
||||
xbps-remove -Oy void-repo-nonfree || true
|
||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||
RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt
|
||||
""")
|
||||
else:
|
||||
if image.startswith("opensuse/"):
|
||||
install_command = "zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
|
||||
@@ -816,7 +967,7 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
timeout=600 if fmt == "xbps" else 300,
|
||||
)
|
||||
|
||||
container = f"fenris-guard-{os.getpid()}"
|
||||
@@ -829,6 +980,16 @@ def test_migration_guard(skip_no_docker, image, fmt, version):
|
||||
)
|
||||
|
||||
try:
|
||||
if fmt == "xbps":
|
||||
# XBPS: run the INSTALL pre script to check for remnants
|
||||
rc, out = _container_exec(
|
||||
container,
|
||||
f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
|
||||
"sh INSTALL pre fenris 0.3.5 no '' x86_64 2>&1 || true",
|
||||
)
|
||||
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||||
f"Migration guard did not trigger: {out}"
|
||||
else:
|
||||
_assert_migration_guard(container, fmt, pkg_name)
|
||||
finally:
|
||||
subprocess.run(
|
||||
@@ -858,6 +1019,8 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||||
|
||||
if fmt == "deb":
|
||||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||||
elif fmt == "xbps":
|
||||
dockerfile = _build_xbps_dockerfile(image, pkg_name)
|
||||
else:
|
||||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||||
@@ -867,7 +1030,7 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
timeout=600 if fmt == "xbps" else 300,
|
||||
)
|
||||
|
||||
container = f"fenris-upgrade-{os.getpid()}"
|
||||
@@ -880,6 +1043,9 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
||||
)
|
||||
|
||||
try:
|
||||
if fmt == "xbps":
|
||||
_assert_upgrade_semantics(container, fmt, pkg_name, version)
|
||||
else:
|
||||
_assert_upgrade_semantics(container, fmt, pkg_name, version)
|
||||
|
||||
# RPM-specific: verify config survives fresh install (noreplace)
|
||||
@@ -1004,6 +1170,8 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||
|
||||
if fmt == "deb":
|
||||
dockerfile = _build_deb_dockerfile(image, pkg_name)
|
||||
elif fmt == "xbps":
|
||||
dockerfile = _build_xbps_dockerfile(image, pkg_name)
|
||||
else:
|
||||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||||
(build_dir / "Dockerfile").write_text(dockerfile)
|
||||
@@ -1011,7 +1179,7 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=300,
|
||||
check=True, capture_output=True, timeout=600 if fmt == "xbps" else 300,
|
||||
)
|
||||
|
||||
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
||||
@@ -1024,7 +1192,14 @@ def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||
try:
|
||||
_setup_store_and_config(container)
|
||||
|
||||
if fmt == "deb":
|
||||
if fmt == "xbps":
|
||||
# Fresh install first
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
assert rc == 0, f"XBPS fresh install failed: {out}"
|
||||
# Upgrade — should NOT run REMOVE pre (no sanctioned disable)
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
|
||||
assert rc == 0, f"XBPS upgrade failed: {out}"
|
||||
elif fmt == "deb":
|
||||
# Fake older version so dpkg treats reinstall as upgrade
|
||||
_container_exec(
|
||||
container,
|
||||
@@ -1436,3 +1611,409 @@ def test_no_move_continuity_rpm(skip_no_docker, version):
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container), capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — XBPS/Void Linux lifecycle (issue #85)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assert_runit_dormant_layout(container: str, version: str) -> None:
|
||||
"""Assert the dormant-install contract for runit-based systems (XBPS)."""
|
||||
# Version-neutral vendored runtime exists
|
||||
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
|
||||
assert "OK" in out, "Bundled runtime not found at /opt/fenris"
|
||||
|
||||
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
|
||||
assert rc == 0, "Fenris runtime package not found"
|
||||
|
||||
rc, _ = _container_exec(container, "test ! -e /opt/fenris/bin/python3")
|
||||
assert rc == 0, "Package must not ship a copied Python interpreter"
|
||||
|
||||
# Wrapper on PATH
|
||||
rc, out = _container_exec(container, "command -v fenris")
|
||||
assert rc == 0, f"fenris not on PATH: {out}"
|
||||
|
||||
# Wrapper is executable
|
||||
rc, _ = _container_exec(container, "test -x /usr/bin/fenris")
|
||||
assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris"
|
||||
|
||||
# Version string in wrapper
|
||||
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
|
||||
assert "OK" in out, f"Version {version} not found in wrapper script"
|
||||
|
||||
# CLI runs successfully
|
||||
rc, out = _container_exec(container, "fenris status")
|
||||
assert rc == 0, f"Installed CLI cannot run: {out}"
|
||||
|
||||
# Helpers in /usr/libexec/fenris
|
||||
for helper in ("fenris-monitor", "fenris-collect"):
|
||||
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
|
||||
assert rc == 0, f"{helper} not found or not executable"
|
||||
|
||||
# systemd units present (shipped for dual-init support)
|
||||
for unit in ("fenris-collect.timer", "fenris-collect.service"):
|
||||
rc, _ = _container_exec(container, f"test -f /usr/lib/systemd/system/{unit}")
|
||||
assert rc == 0, f"{unit} not found"
|
||||
|
||||
# Polkit policy
|
||||
rc, _ = _container_exec(
|
||||
container,
|
||||
"test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy",
|
||||
)
|
||||
assert rc == 0, "Polkit policy not found"
|
||||
|
||||
# sysusers and tmpfiles fragments
|
||||
rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf")
|
||||
assert rc == 0, "sysusers fragment not found"
|
||||
rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf")
|
||||
assert rc == 0, "tmpfiles fragment not found"
|
||||
|
||||
# Placeholder-commented config
|
||||
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
|
||||
assert rc == 0, "fenris.conf not found"
|
||||
assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \
|
||||
"Config does not appear to be placeholder-commented"
|
||||
|
||||
# fenris group exists
|
||||
rc, out = _container_exec(container, "getent group fenris")
|
||||
assert rc == 0, "fenris group not created"
|
||||
|
||||
# Observation store directory
|
||||
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
||||
assert rc == 0, "Observation store directory not created"
|
||||
parts = out.strip().split()
|
||||
assert parts[0] == "2770", f"Store dir mode: expected 2770, got {parts[0]}"
|
||||
assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}"
|
||||
assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}"
|
||||
|
||||
# runit: service is dormant (down marker present, no symlink)
|
||||
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
|
||||
assert rc == 0, "Runit service not marked dormant (down file missing)"
|
||||
|
||||
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
|
||||
assert rc == 0, "Runit service symlink should not exist (dormant)"
|
||||
|
||||
# No hand-rolled manifest
|
||||
rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt")
|
||||
assert rc != 0, "Legacy manifest.txt should not exist in package install"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_xbps_dormant_install(skip_no_docker, version):
|
||||
"""Install XBPS package in Void container, assert dormant runit layout."""
|
||||
pkg = _find_package("xbps")
|
||||
pkg_name = pkg.name
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "test-container-xbps"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "dist").mkdir(exist_ok=True)
|
||||
subprocess.run(
|
||||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-test-xbps"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=600,
|
||||
)
|
||||
|
||||
container = f"fenris-test-xbps-{os.getpid()}"
|
||||
subprocess.run(
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Exercise XBPS itself, including its file ownership and lifecycle hooks.
|
||||
# The temporary local repository does not need release signatures.
|
||||
rc, out = _container_exec(
|
||||
container,
|
||||
f"mkdir -p /tmp/fenris-repo && cp /pkg/{pkg_name} /tmp/fenris-repo/ && "
|
||||
f"xbps-rindex -a /tmp/fenris-repo/{pkg_name} && "
|
||||
"xbps-install -y -R /tmp/fenris-repo fenris",
|
||||
)
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
rc, out = _container_exec(container, "xbps-query -p pkgver fenris")
|
||||
assert rc == 0 and out.strip() == f"fenris-{version}_1"
|
||||
_assert_runit_dormant_layout(container, version)
|
||||
finally:
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_xbps_migration_guard(skip_no_docker, version):
|
||||
"""Assert XBPS install aborts on make-install remnants."""
|
||||
pkg = _find_package("xbps")
|
||||
pkg_name = pkg.name
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "test-container-xbps-guard"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "dist").mkdir(exist_ok=True)
|
||||
subprocess.run(
|
||||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-guard-xbps"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=600,
|
||||
)
|
||||
|
||||
container = f"fenris-guard-xbps-{os.getpid()}"
|
||||
subprocess.run(
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Plant make-install remnant
|
||||
_container_exec(container, "mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt")
|
||||
# Attempt install — should fail with migration pointer
|
||||
rc, out = _container_exec(container, f"cd /tmp && zstd -d < /pkg/{pkg_name} | tar xf - && "
|
||||
"sh INSTALL pre fenris 0.3.5 no '' x86_64 2>&1 || true")
|
||||
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||||
f"Migration guard did not trigger: {out}"
|
||||
# Clean up marker
|
||||
_container_exec(container, "rm -f /var/lib/fenris/manifest.txt")
|
||||
finally:
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_xbps_upgrade_semantics(skip_no_docker, version):
|
||||
"""Assert XBPS upgrade snapshots store, migrates, preserves config."""
|
||||
pkg = _find_package("xbps")
|
||||
pkg_name = pkg.name
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "test-container-xbps-upgrade"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "dist").mkdir(exist_ok=True)
|
||||
subprocess.run(
|
||||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-upgrade-xbps"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=600,
|
||||
)
|
||||
|
||||
container = f"fenris-upgrade-xbps-{os.getpid()}"
|
||||
subprocess.run(
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Create observation store
|
||||
_container_exec(
|
||||
container,
|
||||
"mkdir -p /var/lib/fenris && "
|
||||
"python3 -c \""
|
||||
"import sqlite3; "
|
||||
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||
"c.execute('PRAGMA user_version=1'); "
|
||||
"c.commit(); c.close()\"",
|
||||
)
|
||||
|
||||
# Fresh install
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
assert rc == 0, f"XBPS fresh install failed: {out}"
|
||||
|
||||
# Modify config
|
||||
_container_exec(
|
||||
container,
|
||||
"echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||
)
|
||||
|
||||
# Upgrade
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="upgrade")
|
||||
assert rc == 0, f"XBPS upgrade failed: {out}"
|
||||
|
||||
# Criterion 1: snapshot exists
|
||||
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db.bak")
|
||||
assert rc == 0, "Observation store snapshot not created on upgrade"
|
||||
|
||||
# Store not rebuilt
|
||||
rc, _ = _container_exec(container, "test -f /var/lib/fenris/observations.db")
|
||||
assert rc == 0, "Observation store missing after upgrade"
|
||||
|
||||
rc, out = _container_exec(container, "stat -c '%a' /var/lib/fenris")
|
||||
assert rc == 0, "Store directory missing after upgrade"
|
||||
assert out.strip() == "2770", (
|
||||
f"Store directory mode changed during upgrade: {out.strip()}"
|
||||
)
|
||||
|
||||
# Criterion 2: config survives
|
||||
rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf")
|
||||
assert rc == 0, "Config file missing after upgrade"
|
||||
assert "nvme0n1" in out, "Modified config not preserved after upgrade"
|
||||
|
||||
# Criterion 4: files still exist (no-op removal)
|
||||
rc, _ = _container_exec(container, "test -x /usr/libexec/fenris/fenris-monitor")
|
||||
assert rc == 0, "Helper removed during upgrade"
|
||||
finally:
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_xbps_remove_preserves_config_and_store(skip_no_docker, version):
|
||||
"""XBPS remove keeps config, store, and group."""
|
||||
pkg = _find_package("xbps")
|
||||
pkg_name = pkg.name
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "test-container-xbps-remove"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "dist").mkdir(exist_ok=True)
|
||||
subprocess.run(
|
||||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-remove-xbps"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=600,
|
||||
)
|
||||
|
||||
container = f"fenris-remove-xbps-{os.getpid()}"
|
||||
subprocess.run(
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Install first
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
|
||||
# Plant store and config
|
||||
_setup_store_and_config(container)
|
||||
|
||||
# Remove (pre action only — preserves config and store)
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="remove")
|
||||
assert rc == 0, f"XBPS remove failed: {out}"
|
||||
|
||||
# Config survives
|
||||
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
|
||||
assert rc == 0, "Config should survive XBPS remove"
|
||||
|
||||
# Store survives
|
||||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||
assert rc == 0, "Store directory should survive XBPS remove"
|
||||
|
||||
for name in ("observations.db", "observations.db.bak"):
|
||||
rc, _ = _container_exec(container, f"test -f /var/lib/fenris/{name}")
|
||||
assert rc == 0, f"Store file {name} should survive XBPS remove"
|
||||
|
||||
# Group survives
|
||||
rc, _ = _container_exec(container, "getent group fenris")
|
||||
assert rc == 0, "Group should survive XBPS remove"
|
||||
|
||||
# Service symlink removed (dormant marker set)
|
||||
rc, _ = _container_exec(container, "test ! -e /var/service/fenris-collect")
|
||||
assert rc == 0, "Service symlink should be removed"
|
||||
rc, _ = _container_exec(container, "test -f /etc/sv/fenris-collect/down")
|
||||
assert rc == 0, "Down marker should be set after remove"
|
||||
finally:
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_xbps_purge_removes_everything(skip_no_docker, version):
|
||||
"""XBPS purge removes config, store, backup, and group."""
|
||||
pkg = _find_package("xbps")
|
||||
pkg_name = pkg.name
|
||||
|
||||
build_dir = REPO_ROOT / "build" / "test-container-xbps-purge"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "dist").mkdir(exist_ok=True)
|
||||
subprocess.run(
|
||||
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-purge-xbps"
|
||||
subprocess.run(
|
||||
_container_cmd("build", "-t", tag, str(build_dir)),
|
||||
check=True, capture_output=True, timeout=600,
|
||||
)
|
||||
|
||||
container = f"fenris-purge-xbps-{os.getpid()}"
|
||||
subprocess.run(
|
||||
_container_cmd("run", "-d", "--name", container,
|
||||
"--tmpfs", "/tmp:exec,size=64m",
|
||||
tag, "sleep", "infinity"),
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Install first
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
|
||||
# Plant store and config
|
||||
_setup_store_and_config(container)
|
||||
|
||||
# Purge
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="purge")
|
||||
assert rc == 0, f"XBPS purge failed: {out}"
|
||||
|
||||
# Config removed
|
||||
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
|
||||
assert rc != 0, "Config should be removed by purge"
|
||||
|
||||
# Store removed
|
||||
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||
assert rc != 0, "Store directory should be removed by purge"
|
||||
|
||||
# Group removed
|
||||
rc, _ = _container_exec(container, "getent group fenris 2>/dev/null || true")
|
||||
assert rc != 0, "Group should be removed by purge"
|
||||
|
||||
# Service directory removed
|
||||
rc, _ = _container_exec(container, "test -d /etc/sv/fenris-collect")
|
||||
assert rc != 0, "Service directory should be removed by purge"
|
||||
finally:
|
||||
subprocess.run(
|
||||
_container_cmd("rm", "-f", container),
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
@@ -622,6 +622,7 @@ class TestProjectionInStatus:
|
||||
from fenris.status import get_status
|
||||
|
||||
nonexistent = tmp_path / "nonexistent.db"
|
||||
nonexistent.write_bytes(b"not a SQLite database")
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
|
||||
@@ -587,7 +587,7 @@ class TestCLIStatusRendering:
|
||||
newer_schema=None)
|
||||
cli_text = render_status_cli(comp)
|
||||
assert "observation store unreadable" in cli_text
|
||||
assert "see journal" in cli_text.lower()
|
||||
assert "see collector logs" in cli_text.lower()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ class TestTUIStatusRendering:
|
||||
comp = compose_status(conn, svc, now, store_fault="observation store unreadable",
|
||||
newer_schema=None)
|
||||
tui_text = render_status_tui(comp)
|
||||
assert "observation store unreadable" in tui_text
|
||||
assert "Observation store unreadable" in tui_text
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -966,7 +966,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||
"last_collect_reason": None,
|
||||
@@ -992,7 +992,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
@@ -1019,7 +1019,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": False, "last_collect_age_s": 60,
|
||||
"last_collect_reason": "exit code 3",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Exercise shared status acquisition through the CLI and running TUI."""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.status import get_status, get_status_composition, read_status
|
||||
from fenris.status_composition import StatusState
|
||||
from fenris.store import init_store, SCHEMA_VERSION
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 16, 12, tzinfo=timezone.utc)
|
||||
ACTIVE = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
|
||||
DISABLED = {"boot_enabled": False, "timer_active": False, "last_collect_ok": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["missing", "corrupt", "newer", "incomplete", "denied"])
|
||||
@pytest.mark.parametrize("service", [ACTIVE, DISABLED, {}])
|
||||
async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
|
||||
path = tmp_path / "observations.db"
|
||||
if kind == "corrupt":
|
||||
path.write_bytes(b"Not a SQLite database")
|
||||
elif kind == "incomplete":
|
||||
sqlite3.connect(path).close()
|
||||
elif kind != "missing":
|
||||
conn = init_store(path)
|
||||
if kind == "newer":
|
||||
conn.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
|
||||
conn.close()
|
||||
original_exists = Path.exists
|
||||
|
||||
def exists(target):
|
||||
if kind == "denied" and target == path:
|
||||
raise PermissionError("Observation store access denied")
|
||||
return original_exists(target)
|
||||
|
||||
before = path.read_bytes() if original_exists(path) else None
|
||||
with patch("fenris.status.query_service_state", return_value=service), \
|
||||
patch("fenris.status._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch("fenris.tui._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch.object(Path, "exists", exists), \
|
||||
patch("fenris.tui.compute_projection") as projection:
|
||||
comp = get_status_composition(path, NOW)
|
||||
cli = get_status(path, NOW).lower()
|
||||
app = FenrisTuiApp(store_path=path)
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render()).lower()
|
||||
strip = str(app.query_one("#service-strip").render()).lower()
|
||||
if kind == "missing":
|
||||
assert comp.store_fault is None
|
||||
assert comp.freshness == "empty"
|
||||
assert "no observations yet" in cli and "no observations yet" in headline
|
||||
else:
|
||||
assert comp.state == StatusState.ERROR
|
||||
assert comp.freshness == "unknown"
|
||||
phrase = "newer fenris — upgrade fenris" if kind == "newer" else "observation store unreadable"
|
||||
assert phrase in cli and phrase in headline
|
||||
if kind != "newer":
|
||||
assert str(app.query_one("#drive-health").render()) == "[bold]Native collector log[/bold]"
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
await pilot.resize_terminal(60, 18)
|
||||
await pilot.pause()
|
||||
assert phrase in str(app.query_one("#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")
|
||||
+78
-110
@@ -42,11 +42,7 @@ from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
_format_remaining,
|
||||
_sparkline,
|
||||
_habit_bar,
|
||||
_query_usage_history,
|
||||
_query_drive_health,
|
||||
_query_service_facts,
|
||||
_query_daily_graph_data,
|
||||
_query_hourly_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
@@ -142,64 +138,6 @@ class TestFormatRemaining:
|
||||
assert _format_remaining(-100) == "endurance exhausted"
|
||||
|
||||
|
||||
class TestSparkline:
|
||||
def test_empty(self):
|
||||
assert _sparkline([]) == ""
|
||||
|
||||
def test_single_value(self):
|
||||
result = _sparkline([100.0])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multiple_values(self):
|
||||
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
assert len(result) > 0
|
||||
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
|
||||
|
||||
def test_width_limit(self):
|
||||
result = _sparkline([1.0] * 100, width=20)
|
||||
assert len(result) <= 20
|
||||
|
||||
|
||||
class TestHabitBar:
|
||||
def test_all_active(self):
|
||||
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
|
||||
assert "active 100%" in result
|
||||
|
||||
def test_mixed(self):
|
||||
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
|
||||
assert "active 50%" in result
|
||||
assert "idle 30%" in result
|
||||
|
||||
def test_all_unknown(self):
|
||||
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
|
||||
assert "unknown 100%" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data query tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryUsageHistory:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 0
|
||||
assert result["sparkline"] == ""
|
||||
conn.close()
|
||||
|
||||
def test_with_days(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 14
|
||||
assert result["sparkline"] != ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryDriveHealth:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
@@ -215,27 +153,6 @@ class TestQueryDriveHealth:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryServiceFacts:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "empty"
|
||||
conn.close()
|
||||
|
||||
def test_fresh_sample(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=2)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "fresh"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI-1: Exhaustive state matrix from synthetic stores
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -397,10 +314,10 @@ class TestDenseScreen:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "boot:" in strip
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" in strip
|
||||
assert "freshness:" in strip
|
||||
assert "Boot:" in strip
|
||||
assert "Timer:" in strip
|
||||
assert "Last collect:" in strip
|
||||
assert "Freshness:" in strip
|
||||
# Maker credit now in titlebox only (issue #79)
|
||||
assert "by Bongbetic" not in strip
|
||||
|
||||
@@ -412,7 +329,7 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -425,9 +342,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 "p pause · r resume · c collect · t theme · m motion · d disclosures" in strip
|
||||
assert "r resume — enable monitoring and future boots" 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
|
||||
@@ -446,11 +363,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
@@ -472,7 +389,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()
|
||||
@@ -487,7 +404,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()
|
||||
@@ -509,11 +426,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test() as pilot:
|
||||
@@ -539,7 +456,7 @@ class TestDenseScreen:
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
@@ -595,15 +512,66 @@ class TestDisclosuresAndGreeting:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFirstRun:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
|
||||
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "_run_helper") as run_helper:
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.on_refresh_tick()
|
||||
await pilot.press("?")
|
||||
help_text = str(app.screen.query_one("#help-text").render())
|
||||
assert "The dashboard does not need sudo" in help_text
|
||||
assert "sudo fenris monitor resume" in help_text
|
||||
assert "sudo fenris monitor pause" in help_text
|
||||
assert "sudo fenris sample" in help_text
|
||||
assert 'sudo usermod -aG fenris "$USER"' in help_text
|
||||
assert "log out and back in" in help_text
|
||||
await pilot.press("end")
|
||||
visible = " ".join("".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split())
|
||||
assert "Esc Close" in visible
|
||||
await pilot.press("escape")
|
||||
assert len(app.screen_stack) == 1
|
||||
run_helper.assert_not_called()
|
||||
|
||||
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
|
||||
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
message = notify.call_args.args[0]
|
||||
assert "Command not found: pkexec" in message
|
||||
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
|
||||
|
||||
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||||
"""TUI controls use the same authenticated path as the CLI."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run") as run, \
|
||||
patch.object(app, "suspend"), \
|
||||
patch.object(app, "_refresh"):
|
||||
run.return_value.returncode = 0
|
||||
app._run_helper("enable", ["--now"])
|
||||
|
||||
run.assert_called_once_with(
|
||||
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_run_prompt(self, tmp_path):
|
||||
"""First-run prompt enables timer and opens first period."""
|
||||
"""First-run prompt makes the keyboard action and boot effect explicit."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "no observations yet" in headline.lower()
|
||||
# The enable hint should mention resume
|
||||
assert "resume" in headline.lower()
|
||||
assert "r resume — enable monitoring and future boots" in headline.lower()
|
||||
assert "r resume — enable monitoring and future boots" in strip.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -907,8 +875,8 @@ class TestBarGraphTUI:
|
||||
assert len(graph._day_data) > 0
|
||||
# Legend should be visible
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert "alloc" in legend
|
||||
assert "zero" in legend
|
||||
assert "Alloc" in legend
|
||||
assert "Zero" in legend
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
@@ -1074,7 +1042,7 @@ class TestBarGraphTUI:
|
||||
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"
|
||||
assert graph.border_title == "Usage history"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
@@ -1155,7 +1123,7 @@ class TestConstrainedLayout:
|
||||
# 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 "Graph needs ≥80×24" in summary_text
|
||||
assert "days" in summary_text
|
||||
assert "GB total" in summary_text
|
||||
|
||||
@@ -1179,7 +1147,7 @@ class TestConstrainedLayout:
|
||||
# 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 "Graph needs ≥80×24" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resize_from_constrained_to_normal(self, tmp_path):
|
||||
@@ -1256,7 +1224,7 @@ class TestConstrainedLayout:
|
||||
# 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
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
# Selected day context should survive
|
||||
assert "2026-09" in summary_text
|
||||
|
||||
@@ -1268,7 +1236,7 @@ class TestConstrainedLayout:
|
||||
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 "Graph needs ≥80×24" in summary_text
|
||||
assert "awaiting" in summary_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1298,13 +1266,13 @@ class TestConstrainedLayout:
|
||||
|
||||
# Service strip should have actions
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "p pause" in strip
|
||||
assert "r resume" in strip
|
||||
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
|
||||
assert "Quit" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# fenris-collect runit log script — service output to /var/log/fenris-collect/
|
||||
#
|
||||
# Runit automatically pipes the service's stdout/stderr to this logger's stdin.
|
||||
# The logger writes to runit's log directory for diagnostics.
|
||||
#
|
||||
# Spec: §8.8 (actionable native diagnostics)
|
||||
# The package creates the fenris group for shared diagnostics access; it does
|
||||
# not create a service user. Use Void's standard unprivileged account while
|
||||
# giving the logger the declared group identity.
|
||||
exec chpst -u nobody:fenris \
|
||||
svlogd -tt /var/log/fenris-collect/
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# fenris-collect runit run script — periodic NVMe collection scheduler.
|
||||
#
|
||||
# Runit service layout:
|
||||
# /etc/sv/fenris-collect/run — this script (scheduler)
|
||||
# /etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||
# /var/service/fenris-collect — symlink to enable
|
||||
# /etc/sv/fenris-collect/down — marker for dormant install
|
||||
#
|
||||
# Guarantees (must match systemd timer semantics):
|
||||
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
||||
# - Completion-relative 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
|
||||
Reference in New Issue
Block a user