Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d894ae290f | ||
|
|
f406285a0f | ||
|
|
608ad7f823 | ||
|
|
cfdef63388 | ||
|
|
f077fa671e | ||
|
|
d01df6468f | ||
|
|
7bbe5cede7 | ||
|
|
bb5bc9a72e | ||
|
|
4a7661d81c | ||
|
|
fb683f52ba | ||
|
|
512df2ae83 | ||
|
|
bcbc97a947 | ||
|
|
b593a2742e | ||
|
|
bdcd321f4c | ||
|
|
25ead13ab9 | ||
|
|
be9ce01ebf | ||
|
|
122c9f327e | ||
|
|
460dde4aad | ||
|
|
ba270d7812 | ||
|
|
2aed923043 | ||
|
|
230c686e82 | ||
|
|
38ecfc2093 | ||
|
|
f1ba8bdccc | ||
|
|
e794310a76 | ||
|
|
1e2ddfb928 | ||
|
|
120d80b28c | ||
|
|
d8fa6df072 | ||
|
|
c45b07003a | ||
|
|
1873886b2f | ||
|
|
c91ca10df7 | ||
|
|
e9d6881e38 |
+206
-13
@@ -1,12 +1,19 @@
|
|||||||
# Fenris release workflow — dormant (no runner registered yet).
|
# Fenris release workflow — release path on Coolify-hosted Gitea runner.
|
||||||
# When a runner is provisioned, this replicates `make release` automatically.
|
# The runner is repository-scoped and executes package build, signing, validation,
|
||||||
# Spec: §5, §34
|
# registry publication, and release attachment. Spec: §5, issue #52
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Built-in Gitea token needs write access for release assets and package registry.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -14,24 +21,210 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate release tag and notes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
|
||||||
|
if [ -z "${VERSION}" ]; then
|
||||||
|
echo "::error::could not determine the project version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
|
||||||
|
EXPECTED_TAG="v${VERSION}"
|
||||||
|
ACTUAL_TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then
|
||||||
|
echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \
|
||||||
|
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install build nfpm
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gnupg2 rpm python3-venv
|
||||||
|
python3 -m venv /tmp/fenris-ci
|
||||||
|
/tmp/fenris-ci/bin/pip install --quiet build
|
||||||
|
echo "/tmp/fenris-ci/bin" >> "$GITHUB_PATH"
|
||||||
|
NFPM_VERSION=2.47.0
|
||||||
|
curl --fail --silent --show-error --location \
|
||||||
|
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
|
||||||
|
-o /tmp/nfpm.tar.gz
|
||||||
|
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
||||||
|
nfpm --version
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: make package
|
run: make package
|
||||||
|
|
||||||
- name: List artifacts
|
- name: Import packaging key
|
||||||
run: ls -la dist/
|
env:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GPG_PRIVATE_KEY}" ]; then
|
||||||
|
echo "::error::GPG_PRIVATE_KEY repository secret is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${GPG_PRIVATE_KEY}" | gpg --batch --import
|
||||||
|
SECRET_FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' | awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||||
|
PUBLIC_FINGERPRINT="$(gpg --batch --show-keys --with-colons packaging/keys/fenris-packaging.asc | awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||||
|
if [ -z "${PUBLIC_FINGERPRINT}" ]; then
|
||||||
|
echo "::error::packaging/keys/fenris-packaging.asc has no OpenPGP key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${SECRET_FINGERPRINT}" != "${PUBLIC_FINGERPRINT}" ]; then
|
||||||
|
echo "::error::packaging public key does not match imported private key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Packaging key fingerprint verified: ${PUBLIC_FINGERPRINT}"
|
||||||
|
|
||||||
# Signing and upload are manual steps — this workflow confirms
|
- name: Sign RPM payload
|
||||||
# the build succeeds. The maintainer completes the release.
|
run: make sign-rpm
|
||||||
- name: Upload artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
- name: Generate and clearsign SHA256SUMS
|
||||||
with:
|
run: |
|
||||||
name: fenris-packages
|
set -euo pipefail
|
||||||
path: dist/
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
|
||||||
|
cd dist
|
||||||
|
sha256sum "fenris_${VERSION}_amd64.deb" \
|
||||||
|
"fenris-${VERSION}-1.x86_64.rpm" > SHA256SUMS
|
||||||
|
gpg --batch --yes --clearsign --local-user packaging@bongbetic.com SHA256SUMS
|
||||||
|
|
||||||
|
- name: Validate signatures and checksums
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
|
||||||
|
RPM="fenris-${VERSION}-1.x86_64.rpm"
|
||||||
|
RPM_VERIFY="$(rpm -Kv "dist/${RPM}" 2>&1)"
|
||||||
|
printf '%s\n' "${RPM_VERIFY}"
|
||||||
|
printf '%s\n' "${RPM_VERIFY}" | grep -Eiq 'signature.*: *ok'
|
||||||
|
gpg --batch --verify dist/SHA256SUMS.asc
|
||||||
|
(cd dist && sha256sum -c SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Remove packaging key material
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }')"
|
||||||
|
if [ -n "${FINGERPRINT}" ]; then
|
||||||
|
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
|
||||||
|
gpg --batch --yes --delete-keys "${FINGERPRINT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Determine version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Upload deb packages to registry
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
|
||||||
|
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
DEB="fenris_${VERSION}_amd64.deb"
|
||||||
|
for CODENAME in bookworm jammy noble; do
|
||||||
|
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
|
||||||
|
-T "dist/${DEB}" -o /dev/null -w '%{http_code}' \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload" || true)
|
||||||
|
case "${STATUS}" in
|
||||||
|
200|201|204) echo "Debian ${CODENAME}: uploaded" ;;
|
||||||
|
409) echo "Debian ${CODENAME}: already exists, kept existing package" ;;
|
||||||
|
*) echo "::error::Debian ${CODENAME} upload failed with HTTP ${STATUS}"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload RPM to registry
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
RPM="fenris-${VERSION}-1.x86_64.rpm"
|
||||||
|
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
|
||||||
|
-T "dist/${RPM}" -o /dev/null -w '%{http_code}' \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload" || true)
|
||||||
|
case "${STATUS}" in
|
||||||
|
200|201|204) echo "RPM: uploaded" ;;
|
||||||
|
409) echo "RPM: already exists, kept existing package" ;;
|
||||||
|
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
|
||||||
|
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
RELEASE_BODY="${RUNNER_TEMP}/release-body.md"
|
||||||
|
if [ ! -s "${RELEASE_BODY}" ]; then
|
||||||
|
echo "::error::validated release body is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
|
||||||
|
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true)
|
||||||
|
case "${EXISTING}" in
|
||||||
|
200)
|
||||||
|
echo "Release v${VERSION} exists; resynchronizing its notes"
|
||||||
|
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||||
|
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
|
||||||
|
;;
|
||||||
|
404)
|
||||||
|
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||||
|
--body-file "${RELEASE_BODY}")"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::release lookup failed with HTTP ${EXISTING}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")"
|
||||||
|
RELEASE_PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")"
|
||||||
|
PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")"
|
||||||
|
curl --fail --silent --show-error -X "${METHOD}" \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${RELEASE_PATH}"
|
||||||
|
|
||||||
|
- name: Attach artifacts to release
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
# Get release ID for this tag
|
||||||
|
RELEASE_JSON=$(curl --fail --silent --show-error \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
||||||
|
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
# Attach deb, rpm, and clearsigned checksums once.
|
||||||
|
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
|
||||||
|
"dist/fenris-${VERSION}-1.x86_64.rpm" \
|
||||||
|
"dist/SHA256SUMS.asc"; do
|
||||||
|
ASSET_NAME="${FILE##*/}"
|
||||||
|
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
|
||||||
|
echo "${ASSET_NAME}: already attached"
|
||||||
|
else
|
||||||
|
curl --fail --silent --show-error -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
-F "attachment=@${FILE}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|||||||
+8
-3
@@ -10,6 +10,11 @@ plan-dash-changes.md
|
|||||||
|
|
||||||
# Packaging build artifacts
|
# Packaging build artifacts
|
||||||
build/
|
build/
|
||||||
dist/*.deb
|
dist/
|
||||||
dist/*.rpm
|
|
||||||
dist/SHA256SUMS*
|
# Local tooling
|
||||||
|
graphify-out/
|
||||||
|
json
|
||||||
|
src/fenris.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Maintainers add one user-facing entry to Unreleased with each change. A release
|
||||||
|
commit bumps pyproject.toml, renames Unreleased to that bare-semver version and
|
||||||
|
an ISO date, then restores an empty Unreleased section; tag that commit. Do not
|
||||||
|
backfill releases from before this changelog.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.4] - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add Fenris identity and a polkit authentication notice to the dashboard.
|
||||||
|
- Clarify monitoring continuity, deliberate pauses, and quitting in the dashboard and status output.
|
||||||
|
- Add per-release notes with installation, verification, and rollback guidance.
|
||||||
+23
-3
@@ -32,13 +32,25 @@ _Avoid_: Data directory, history.jsonl, the database (generic)
|
|||||||
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
|
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
|
||||||
_Avoid_: Database error, corruption, broken data
|
_Avoid_: Database error, corruption, broken data
|
||||||
|
|
||||||
|
**Usage interval**:
|
||||||
|
The elapsed span between two compatible counter observations, with a measured usage total whose distribution within that span may be unknown.
|
||||||
|
_Avoid_: Estimated hourly usage, interpolated sample
|
||||||
|
|
||||||
|
**Unallocated usage**:
|
||||||
|
Measured usage whose share in a particular hour, calendar day, or monitoring period cannot be established from the available evidence.
|
||||||
|
_Avoid_: Zero usage, evenly distributed writes
|
||||||
|
|
||||||
**Hour observation**:
|
**Hour observation**:
|
||||||
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
|
The usage-habit evidence for a UTC hour's represented elapsed span: active, idle, powered-off, and unknown time, measured usage, thermal evidence, and coverage. A partial hour does not describe future time.
|
||||||
_Avoid_: Hourly record, hourly.jsonl entry
|
_Avoid_: Hourly record, hourly.jsonl entry
|
||||||
|
|
||||||
**Day aggregate**:
|
**Day aggregate**:
|
||||||
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
|
The UTC-day summary at which usage-habit evidence is judged; distinct from a local display day.
|
||||||
_Avoid_: Daily summary, daily stats
|
_Avoid_: Local daily total, daily stats
|
||||||
|
|
||||||
|
**Local display day**:
|
||||||
|
A calendar day in the user's current system timezone, used to browse observation history; its elapsed length can vary with timezone transitions.
|
||||||
|
_Avoid_: UTC evidence day, fixed 24-hour day
|
||||||
|
|
||||||
**Controller segment**:
|
**Controller segment**:
|
||||||
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
|
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
|
||||||
@@ -76,6 +88,14 @@ _Avoid_: Confidence interval, error bar
|
|||||||
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
|
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
|
||||||
_Avoid_: Uptime, sample count
|
_Avoid_: Uptime, sample count
|
||||||
|
|
||||||
|
**Byte-allocation completeness**:
|
||||||
|
Whether the available evidence establishes all monitored writes attributable to a specified span, without missing counter evidence or unknown boundary shares; distinct from usage-habit classification coverage.
|
||||||
|
_Avoid_: Coverage, estimated allocation
|
||||||
|
|
||||||
|
**Qualifying day**:
|
||||||
|
A UTC date whose represented monitored time meets the coverage requirement for projection evidence. Qualification is provisional while the date is in progress and does not establish byte-allocation completeness.
|
||||||
|
_Avoid_: Completed day, supported day, calibration day
|
||||||
|
|
||||||
**Collection run**:
|
**Collection run**:
|
||||||
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
||||||
_Avoid_: Poll, daemon tick
|
_Avoid_: Poll, daemon tick
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
PYTHON := python3
|
PYTHON := python3
|
||||||
VENV_DIR := /opt/fenris
|
VENV_DIR := /opt/fenris
|
||||||
|
VENDOR_DIR := $(VENV_DIR)/vendor
|
||||||
BIN_DIR := /usr/local/bin
|
BIN_DIR := /usr/local/bin
|
||||||
LIBEXEC_DIR := /usr/libexec/fenris
|
LIBEXEC_DIR := /usr/libexec/fenris
|
||||||
UNIT_DIR := /etc/systemd/system
|
UNIT_DIR := /etc/systemd/system
|
||||||
@@ -17,7 +18,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
|
|||||||
# Legacy history path (IN-4)
|
# Legacy history path (IN-4)
|
||||||
LEGACY_HISTORY := ./data/history.jsonl
|
LEGACY_HISTORY := ./data/history.jsonl
|
||||||
|
|
||||||
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package release clean
|
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Fenris NVMe endurance monitor"
|
@echo "Fenris NVMe endurance monitor"
|
||||||
@@ -34,14 +35,20 @@ help:
|
|||||||
@echo " package - Build deb + rpm packages"
|
@echo " package - Build deb + rpm packages"
|
||||||
@echo " package-deb - Build deb package only"
|
@echo " package-deb - Build deb package only"
|
||||||
@echo " package-rpm - Build rpm package only"
|
@echo " package-rpm - Build rpm package only"
|
||||||
@echo " release - Full release (build, sign, attach)"
|
@echo " generate-test-key - Create throwaway GPG key for CI/testing"
|
||||||
|
@echo " sign-rpm - Sign RPM payload with packaging key"
|
||||||
|
@echo " checksums - Generate SHA256SUMS manifest"
|
||||||
|
@echo " clearsign - Clearsign SHA256SUMS with packaging key"
|
||||||
|
@echo " release - Full release (build, sign, checksum, print upload steps)"
|
||||||
|
@echo " release-run - Execute the full release flow via scripts/release.sh"
|
||||||
|
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
|
||||||
@echo " clean - Remove build artifacts"
|
@echo " clean - Remove build artifacts"
|
||||||
|
|
||||||
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
check-python:
|
check-python:
|
||||||
@echo "=== Verifying Python ≥ 3.9 ==="
|
@echo "=== Verifying Python ≥ 3.10 ==="
|
||||||
@$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,9)) else 1)" || { echo "Error: Python 3.9+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; }
|
@$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,10)) else 1)" || { echo "Error: Python 3.10+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; }
|
||||||
|
|
||||||
check-smartctl:
|
check-smartctl:
|
||||||
@echo "=== Verifying smartctl ==="
|
@echo "=== Verifying smartctl ==="
|
||||||
@@ -51,7 +58,7 @@ check-smartctl:
|
|||||||
|
|
||||||
dist/fenris-*.whl: pyproject.toml src/fenris/*.py
|
dist/fenris-*.whl: pyproject.toml src/fenris/*.py
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
$(PYTHON) -m build --wheel -o dist
|
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
|
||||||
|
|
||||||
# ─── Install ────────────────────────────────────────────────────────────────
|
# ─── Install ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,11 +72,10 @@ install: check-python check-smartctl dist/fenris-*.whl
|
|||||||
@sudo groupadd -f fenris
|
@sudo groupadd -f fenris
|
||||||
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
|
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
|
||||||
|
|
||||||
@echo "=== Installing venv with pinned dependencies ==="
|
@echo "=== Installing version-neutral runtime packages ==="
|
||||||
@sudo rm -rf $(VENV_DIR)
|
@sudo rm -rf $(VENV_DIR)
|
||||||
@sudo $(PYTHON) -m venv $(VENV_DIR)
|
@sudo install -d -m 0755 $(VENDOR_DIR)
|
||||||
@sudo $(VENV_DIR)/bin/pip install --upgrade pip --quiet
|
@sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
|
||||||
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
|
|
||||||
|
|
||||||
@echo "=== Installing wrapper ==="
|
@echo "=== Installing wrapper ==="
|
||||||
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
||||||
@@ -113,7 +119,7 @@ import-legacy:
|
|||||||
@if [ -f "$(LEGACY_HISTORY)" ]; then \
|
@if [ -f "$(LEGACY_HISTORY)" ]; then \
|
||||||
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
|
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
|
||||||
echo "Running idempotent import..."; \
|
echo "Running idempotent import..."; \
|
||||||
$(VENV_DIR)/bin/python3 -c "import sys; sys.path.insert(0, 'src'); from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \
|
PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \
|
||||||
else \
|
else \
|
||||||
echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \
|
echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \
|
||||||
fi
|
fi
|
||||||
@@ -125,8 +131,10 @@ upgrade: dist/fenris-*.whl
|
|||||||
@echo "=== Snapshotting database (IN-6) ==="
|
@echo "=== Snapshotting database (IN-6) ==="
|
||||||
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
|
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
|
||||||
|
|
||||||
@echo "=== Installing new wheel with pinned dependencies ==="
|
@echo "=== Installing new version-neutral runtime packages ==="
|
||||||
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
|
@sudo rm -rf $(VENDOR_DIR)
|
||||||
|
@sudo install -d -m 0755 $(VENDOR_DIR)
|
||||||
|
@sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
|
||||||
|
|
||||||
@echo "=== Syncing units against manifest ==="
|
@echo "=== Syncing units against manifest ==="
|
||||||
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
||||||
@@ -166,7 +174,7 @@ upgrade: dist/fenris-*.whl
|
|||||||
done
|
done
|
||||||
|
|
||||||
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
|
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
|
||||||
@sudo $(VENV_DIR)/bin/python3 -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
|
@sudo env PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
|
||||||
|
|
||||||
@echo "=== Upgrade complete ==="
|
@echo "=== Upgrade complete ==="
|
||||||
|
|
||||||
@@ -223,13 +231,17 @@ lint:
|
|||||||
update-deps:
|
update-deps:
|
||||||
$(PYTHON) -m pip compile pyproject.toml -o requirements.txt
|
$(PYTHON) -m pip compile pyproject.toml -o requirements.txt
|
||||||
|
|
||||||
# ─── Packaging (spec §3, §5) ────────────────────────────────────────────────
|
# ─── Packaging (spec §3, §4, §5) ────────────────────────────────────────────
|
||||||
|
|
||||||
# Version is sourced from pyproject.toml for both formats
|
# Version is sourced from pyproject.toml for both formats
|
||||||
FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
|
FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
|
||||||
|
|
||||||
stage: dist/fenris-*.whl
|
# GPG signing — packaging key UID (spec §4)
|
||||||
|
PACKAGING_KEY ?= packaging@bongbetic.com
|
||||||
|
|
||||||
|
stage:
|
||||||
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
|
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
|
||||||
|
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
|
||||||
bash packaging/stage.sh "$(FENRIS_VERSION)"
|
bash packaging/stage.sh "$(FENRIS_VERSION)"
|
||||||
|
|
||||||
package-deb: stage
|
package-deb: stage
|
||||||
@@ -245,26 +257,79 @@ package-rpm: stage
|
|||||||
package: package-deb package-rpm
|
package: package-deb package-rpm
|
||||||
@echo "=== Both packages built in dist/ ==="
|
@echo "=== Both packages built in dist/ ==="
|
||||||
|
|
||||||
release: package
|
# ─── GPG key management ─────────────────────────────────────────────────────
|
||||||
@echo "=== Release v$(FENRIS_VERSION) ==="
|
|
||||||
@echo "Artifacts:"
|
generate-test-key:
|
||||||
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null
|
@echo "=== Generating throwaway test GPG key ==="
|
||||||
|
@echo "This key is for CI/testing only — never use for real releases."
|
||||||
|
printf '%%no-protection\nKey-Type: RSA\nKey-Length: 3072\nName-Real: Fenris Packaging (TESTING ONLY)\nName-Email: packaging-test@bongbetic.com\nExpire-Date: 0\n%%commit\n' | \
|
||||||
|
gpg --batch --gen-key
|
||||||
|
@echo "=== Test key created. Fingerprint: ==="
|
||||||
|
@gpg --fingerprint packaging-test@bongbetic.com
|
||||||
|
|
||||||
|
# ─── Signing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sign-rpm: package-rpm
|
||||||
|
@echo "=== Signing RPM payload ==="
|
||||||
|
@rpm --import packaging/keys/fenris-packaging.asc 2>/dev/null || true
|
||||||
|
rpmsign --addsign --define "_gpg_name $(PACKAGING_KEY)" \
|
||||||
|
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
|
||||||
|
@echo "=== RPM signed ==="
|
||||||
|
@rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
|
||||||
|
|
||||||
|
checksums: package
|
||||||
|
@echo "=== Generating SHA256SUMS ==="
|
||||||
|
cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb \
|
||||||
|
fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS
|
||||||
|
@echo "=== SHA256SUMS written ==="
|
||||||
|
@cat dist/SHA256SUMS
|
||||||
|
|
||||||
|
clearsign: checksums
|
||||||
|
@echo "=== Clearsigning SHA256SUMS ==="
|
||||||
|
gpg --batch --yes --clearsign --local-user $(PACKAGING_KEY) \
|
||||||
|
dist/SHA256SUMS
|
||||||
|
@echo "=== SHA256SUMS.asc written ==="
|
||||||
|
|
||||||
|
# ─── Release (spec §5) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
release: package sign-rpm clearsign
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Manual steps (spec §5):"
|
@echo "=== Release v$(FENRIS_VERSION) ==="
|
||||||
@echo " 1. Import packaging key: gpg --import <keyfile>"
|
@echo ""
|
||||||
@echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
|
@echo "Artifacts:"
|
||||||
@echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS"
|
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \
|
||||||
@echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS"
|
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \
|
||||||
@echo " 5. Upload to registry:"
|
dist/SHA256SUMS.asc 2>/dev/null
|
||||||
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
@echo ""
|
||||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
|
@echo "Verify signing (manual):"
|
||||||
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
@echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
|
||||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'"
|
@echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS"
|
||||||
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
@echo ""
|
||||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'"
|
@echo "Upload to registry:"
|
||||||
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\"
|
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
||||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'"
|
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
|
||||||
@echo " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc"
|
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
||||||
|
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'"
|
||||||
|
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
||||||
|
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'"
|
||||||
|
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\"
|
||||||
|
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'"
|
||||||
|
@echo ""
|
||||||
|
@echo "Create Gitea release with notes and attach:"
|
||||||
|
@echo " dist/fenris_$(FENRIS_VERSION)_amd64.deb"
|
||||||
|
@echo " dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
|
||||||
|
@echo " dist/SHA256SUMS.asc"
|
||||||
|
@echo ""
|
||||||
|
@echo "Key ceremony: delete the private key after upload."
|
||||||
|
@echo " See docs/install/signing-key-ceremony.md"
|
||||||
|
|
||||||
|
# ─── Automated release flow (issue #52) ──────────────────────────────────────
|
||||||
|
|
||||||
|
release-run:
|
||||||
|
bash scripts/release.sh --publish
|
||||||
|
|
||||||
|
release-dry-run:
|
||||||
|
bash scripts/release.sh --dry-run
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "=== Cleaning build artifacts ==="
|
@echo "=== Cleaning build artifacts ==="
|
||||||
|
|||||||
@@ -8,55 +8,158 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Python ≥ 3.9** (verified at install time)
|
- **Python ≥ 3.10** (verified at install time)
|
||||||
- **smartmontools** (`smartctl` — verified at install time)
|
- **smartmontools** (`smartctl` — verified at install time)
|
||||||
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
|
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
|
||||||
|
|
||||||
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
||||||
|
|
||||||
## Install
|
## Install from package (recommended)
|
||||||
|
|
||||||
```bash
|
### Debian / Ubuntu (apt)
|
||||||
sudo make install
|
|
||||||
|
The Gitea instance Debian registry signs metadata with its own key. Verify the
|
||||||
|
instance key fingerprint (TOFU hardening):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fingerprint: <print after first release — paste beside the curl one-liner>
|
||||||
```
|
```
|
||||||
|
|
||||||
What it does:
|
Add the instance key and repository:
|
||||||
1. Builds a wheel from the checkout and installs it — with pinned dependencies — into the dedicated venv at `/opt/fenris`.
|
|
||||||
2. Places the `fenris` wrapper in `/usr/local/bin`, helpers in `/usr/libexec/fenris`, systemd units in `/etc/systemd/system`, and the polkit policy in `/usr/share/polkit-1/actions/`.
|
|
||||||
3. Creates `/var/lib/fenris` (root-written, group-readable) — the observation store is created lazily by the first collection run.
|
|
||||||
4. Records every placed file in a manifest consumed by upgrade and uninstall.
|
|
||||||
5. Detects `./data/history.jsonl` beside the source checkout and runs the idempotent legacy import if present.
|
|
||||||
|
|
||||||
**A fresh install is fully dormant.** Units are present but disabled; nothing runs. The only opt-in is the sanctioned toggle:
|
```bash
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL -o /etc/apt/keyrings/gitea-xavierk.asc \
|
||||||
|
https://git.bongbetic.com/api/packages/xavierk/debian/repository.key
|
||||||
|
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] \
|
||||||
|
https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/fenris.list
|
||||||
|
|
||||||
|
sudo apt update && sudo apt install fenris
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
|
||||||
|
`noble`).
|
||||||
|
|
||||||
|
### Fedora / openSUSE Tumbleweed (RPM)
|
||||||
|
|
||||||
|
Use the Fenris-owned repo file (not Gitea's auto-generated one):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf config-manager --add-repo \
|
||||||
|
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
|
||||||
|
|
||||||
|
sudo dnf install fenris
|
||||||
|
```
|
||||||
|
|
||||||
|
On openSUSE Tumbleweed, add the same standard RPM repository file and install
|
||||||
|
with zypper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo zypper addrepo --refresh \
|
||||||
|
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo fenris
|
||||||
|
sudo zypper install fenris
|
||||||
|
```
|
||||||
|
|
||||||
|
The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded
|
||||||
|
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
|
||||||
|
TLS).
|
||||||
|
|
||||||
|
### Package signature verification
|
||||||
|
|
||||||
|
The RPM payload is signed with the Fenris packaging key (RSA 3072).
|
||||||
|
Verification happens automatically via dnf's `gpgcheck=1`. For manual
|
||||||
|
verification of downloaded assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rpm -Kv fenris-*.x86_64.rpm # RPM payload signature
|
||||||
|
gpg --verify SHA256SUMS.asc SHA256SUMS # Clearsigned checksum manifest
|
||||||
|
sha256sum -c SHA256SUMS # Checksum match
|
||||||
|
```
|
||||||
|
|
||||||
|
The packaging public key is published in-repo — no keyservers. See
|
||||||
|
`packaging/keys/fenris-packaging.asc` and
|
||||||
|
`docs/install/signing-key-ceremony.md` for key lifecycle details.
|
||||||
|
|
||||||
|
### Dormant install
|
||||||
|
|
||||||
|
A fresh package install is fully dormant. Units are present but disabled;
|
||||||
|
nothing runs. The only opt-in is the sanctioned toggle:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fenris monitor resume # enable timer + open first monitoring period
|
fenris monitor resume # enable timer + open first monitoring period
|
||||||
fenris monitor pause # close the period, disable timer
|
fenris monitor pause # close the period, disable timer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Development install (make install)
|
||||||
|
|
||||||
|
For contributors building from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo make install
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds a wheel, installs its locked pure-Python runtime packages into
|
||||||
|
`/opt/fenris/vendor`,
|
||||||
|
and places helpers, units, and the polkit policy. Units are dormant by default.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo make upgrade # re-sync wheel, units, schema
|
||||||
|
make uninstall # removes artifacts, preserves config and store
|
||||||
|
make purge # also removes /etc/fenris and /var/lib/fenris
|
||||||
|
```
|
||||||
|
|
||||||
## Upgrade
|
## Upgrade
|
||||||
|
|
||||||
|
### Package upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
||||||
|
sudo dnf upgrade fenris # Fedora
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development upgrade
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo make upgrade
|
sudo make upgrade
|
||||||
```
|
```
|
||||||
|
|
||||||
What it does:
|
What it does:
|
||||||
1. Snapshots `observations.db` to a one-generation backup (`.bak`).
|
1. Snapshots `observations.db` to a one-generation backup (`.bak`).
|
||||||
2. Installs the new wheel into the same venv with pinned dependencies.
|
2. Replaces the locked runtime packages under `/opt/fenris/vendor`.
|
||||||
3. Syncs units and polkit against the manifest; runs `daemon-reload`.
|
3. Syncs units and polkit against the manifest; runs `daemon-reload`.
|
||||||
4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code.
|
4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code.
|
||||||
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
|
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
|
||||||
|
|
||||||
Rollback: reinstall the previous version and restore `observations.db.bak`.
|
Rollback: reinstall the previous version and restore `observations.db.bak`.
|
||||||
|
|
||||||
|
## Migration from make install
|
||||||
|
|
||||||
|
If Fenris was previously installed with `sudo make uninstall` first, then
|
||||||
|
installed from the package, existing config, store, and group survive by path
|
||||||
|
continuity. Over-installing the package over a `make install` is
|
||||||
|
**forbidden** — stale units shadow vendor placement. See
|
||||||
|
[docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md).
|
||||||
|
|
||||||
## Uninstall and purge
|
## Uninstall and purge
|
||||||
|
|
||||||
|
### Package removal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove fenris # preserves config and store
|
||||||
|
sudo apt purge fenris # also removes config and store
|
||||||
|
sudo dnf remove fenris # preserves config and store
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development removal
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make uninstall # removes artifacts, preserves config and observation history
|
make uninstall # removes artifacts, preserves config and observation history
|
||||||
make purge # also removes /etc/fenris and /var/lib/fenris
|
make purge # also removes /etc/fenris and /var/lib/fenris
|
||||||
```
|
```
|
||||||
|
|
||||||
Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the venv, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
|
Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the runtime packages, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
|
||||||
|
|
||||||
## Cadence drop-ins
|
## Cadence drop-ins
|
||||||
|
|
||||||
@@ -86,6 +189,16 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
|||||||
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
|
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
|
||||||
| `fenris --device` | Rejected with a pointer to the configuration file. |
|
| `fenris --device` | Rejected with a pointer to the configuration file. |
|
||||||
|
|
||||||
|
## Reading the dashboard
|
||||||
|
|
||||||
|
`fenris` opens the TUI dashboard.
|
||||||
|
|
||||||
|
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||||
|
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||||
|
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||||
|
|
||||||
|
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
|
||||||
|
|
||||||
## Retired menu options
|
## Retired menu options
|
||||||
|
|
||||||
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
|
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
|
||||||
@@ -110,17 +223,17 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against.
|
|||||||
|
|
||||||
## Where's my stuff?
|
## Where's my stuff?
|
||||||
|
|
||||||
| Artifact | Location |
|
| Artifact | Package install | make install |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| Wrapper | `/usr/local/bin/fenris` |
|
| Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` |
|
||||||
| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` |
|
| Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` |
|
||||||
| Units | `/etc/systemd/system/fenris-collect.{timer,service}` |
|
| Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` |
|
||||||
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` |
|
| Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` |
|
||||||
| Configuration | `/etc/fenris/fenris.conf` |
|
| sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
|
||||||
| Observation store | `/var/lib/fenris/observations.db` |
|
| Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
|
||||||
| Venv | `/opt/fenris` |
|
| Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` |
|
||||||
| Manifest | `/var/lib/fenris/manifest.txt` |
|
| Runtime packages | `/opt/fenris/vendor` | `/opt/fenris/vendor` |
|
||||||
| Legacy history | `./data/history.jsonl` (auto-imported on install if present) |
|
| Legacy history | — | `./data/history.jsonl` (auto-imported) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://g
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned venv at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, and Fedora 40+ (x86_64), with dependencies vendored in a bundled venv because every target distro ships `python3-textual` below Fenris's floor. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback.
|
ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned runtime directory at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, Fedora 40+, and openSUSE Tumbleweed (x86_64), with locked pure-Python dependencies vendored at `/opt/fenris/vendor`. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback.
|
||||||
|
|
||||||
The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale.
|
The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale.
|
||||||
|
|
||||||
@@ -14,12 +14,12 @@ The implementation-ready operative contracts live in the [release and packaging
|
|||||||
|
|
||||||
Amendments to ADR 0004, section by section:
|
Amendments to ADR 0004, section by section:
|
||||||
|
|
||||||
1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+), built by nfpm from a single `packaging/nfpm.yaml` over a staged `--copies` venv at `/opt/fenris`, published to the Gitea Debian/RPM registry and installed with `apt`/`dnf`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild.
|
1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+ and openSUSE Tumbleweed), built by nfpm from a single `packaging/nfpm.yaml` over locked pure-Python runtime packages staged at `/opt/fenris/vendor`, published to the Gitea Debian/RPM registry and installed with `apt`, `dnf`, or `zypper`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild.
|
||||||
2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8.
|
2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8.
|
||||||
3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it.
|
3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it.
|
||||||
4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in.
|
4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in.
|
||||||
5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import <path>` remains available as the only import path from packages.
|
5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import <path>` remains available as the only import path from packages.
|
||||||
6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter.
|
6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations through the target `python3` with `/opt/fenris/vendor` on its import path (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter.
|
||||||
7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release.
|
7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release.
|
||||||
8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`).
|
8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`).
|
||||||
9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`.
|
9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Migrating from make-install to packages
|
||||||
|
|
||||||
|
This runbook covers the transition from a `sudo make install` system to the native deb or rpm package. Packages are the primary delivery; `make install` remains as the dev fallback. The two deliveries are **mutually exclusive** per machine.
|
||||||
|
|
||||||
|
## Why over-install is forbidden
|
||||||
|
|
||||||
|
Installing a package over a make-install system silently breaks things:
|
||||||
|
|
||||||
|
- **Stale admin units shadow vendor units.** `make install` places `fenris-collect.timer` and `fenris-collect.service` in `/etc/systemd/system/`. The package installs them in `/usr/lib/systemd/system/` (vendor placement). Systemd loads admin units first — the stale copy takes precedence, and the package update never reaches the running system.
|
||||||
|
- **The local wrapper shadows the package wrapper.** `make install` places the `fenris` wrapper at `/usr/local/bin/fenris`. The package places it at `/usr/bin/fenris`. The shell finds `/usr/local/bin` first on PATH — the old checkout-relative wrapper runs instead of the package wrapper.
|
||||||
|
|
||||||
|
Neither condition is reversible by reinstalling the package. The only safe path is remove-then-install.
|
||||||
|
|
||||||
|
## Pre-migration checklist
|
||||||
|
|
||||||
|
1. Confirm no monitoring period is actively running that you want to preserve across the gap:
|
||||||
|
```
|
||||||
|
fenris status
|
||||||
|
```
|
||||||
|
The migration resets the system to dormant (see [No-move continuity](#no-move-continuity) below). You opt back in with `fenris monitor resume`.
|
||||||
|
|
||||||
|
2. If you have hand-edited configuration at `/etc/fenris/fenris.conf`, note it. The config survives the migration in place (see below).
|
||||||
|
|
||||||
|
## Remove step
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo make uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
This performs the **sanctioned disable** (`fenris-monitor disable --now`), closing the current monitoring period as `user_disabled`. It then removes all make-install artifacts: the venv at `/opt/fenris`, the wrapper at `/usr/local/bin/fenris`, the helpers at `/usr/libexec/fenris/`, the units in `/etc/systemd/system/`, and the polkit policy. The placement manifest at `/var/lib/fenris/manifest.txt` is removed.
|
||||||
|
|
||||||
|
**What survives the remove:**
|
||||||
|
|
||||||
|
- `/var/lib/fenris/observations.db` (and WAL sidecars, `.bak`) — the observation store
|
||||||
|
- `/var/lib/fenris/` directory itself — root-written, group-read
|
||||||
|
- `/etc/fenris/fenris.conf` — your hand-written configuration
|
||||||
|
- The `fenris` system group — created by `groupadd -f` during make-install
|
||||||
|
- Journal entries — age out naturally
|
||||||
|
|
||||||
|
## Install step
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt install fenris # Debian/Ubuntu
|
||||||
|
sudo dnf install fenris # Fedora
|
||||||
|
```
|
||||||
|
|
||||||
|
The package installs into its own layout without touching the surviving store, config, or group.
|
||||||
|
|
||||||
|
## No-move continuity
|
||||||
|
|
||||||
|
These invariants are verified by the containerized acceptance tests (issue #50):
|
||||||
|
|
||||||
|
| Asset | Make-install state | Package post-install | Mechanism |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `fenris` group | Exists (`groupadd -f`) | Unchanged | `systemd-sysusers` is a no-op when the group already exists |
|
||||||
|
| `/var/lib/fenris` directory | Exists (mode 2750, root:fenris) | Unchanged | `systemd-tmpfiles --create` is a no-op when the directory already exists |
|
||||||
|
| `observations.db` + sidecars | Present from prior monitoring | Unchanged, never owned by the package | Package owns the directory only; store contents are never ghosted |
|
||||||
|
| `/etc/fenris/fenris.conf` | Hand-edited device selector | Survives in place; package default lands as `.dpkg-new` / `.rpmnew` | dpkg conffile / rpm `%config(noreplace)` semantics |
|
||||||
|
| Store schema | Version from prior Fenris release | Caught up by the upgrade-path migration | `postinst` / `%post` runs `migrate_to_latest()` on upgrade |
|
||||||
|
|
||||||
|
The package detects the make-install system has been removed by the absence of the two markers:
|
||||||
|
- `/var/lib/fenris/manifest.txt` (the placement manifest)
|
||||||
|
- `/etc/systemd/system/fenris-collect.timer` (pre-manifest make installs)
|
||||||
|
|
||||||
|
If either marker exists, the package installation aborts with a pointer to this runbook.
|
||||||
|
|
||||||
|
## Reset-to-dormant
|
||||||
|
|
||||||
|
`make uninstall`'s sanctioned disable closes the open monitoring period as `user_disabled`. After the package install, the system is dormant — the timer is installed but disabled, nothing is running, no monitoring period is open.
|
||||||
|
|
||||||
|
To resume monitoring:
|
||||||
|
|
||||||
|
```
|
||||||
|
fenris monitor resume
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the sanctioned opt-in. It enables the timer and opens the first monitoring period in one step. The migration costs at most one short sample gap (the interval between `make uninstall` and `fenris monitor resume`), honestly recorded in the endurance timeline.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After migration, confirm the package is correctly installed:
|
||||||
|
|
||||||
|
```
|
||||||
|
fenris status
|
||||||
|
```
|
||||||
|
|
||||||
|
The status command should show the dormant state: timer disabled, no active monitoring period, and the observation store intact from the prior make-install system.
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Signing key ceremony
|
||||||
|
|
||||||
|
The Fenris packaging key signs RPM payloads and clearsigns SHA256SUMS manifests.
|
||||||
|
This document describes the key's lifecycle: creation, per-release use, rotation,
|
||||||
|
and destruction.
|
||||||
|
|
||||||
|
## Key specification
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Algorithm | RSA 3072 |
|
||||||
|
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
||||||
|
| Expiry | 2 years from creation |
|
||||||
|
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
||||||
|
| Private key storage | Password manager only |
|
||||||
|
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
||||||
|
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
||||||
|
|
||||||
|
## First release: key creation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate the dedicated RSA-3072 packaging key
|
||||||
|
gpg --batch --gen-key <<EOF
|
||||||
|
%no-protection
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 3072
|
||||||
|
Name-Real: Fenris Packaging
|
||||||
|
Name-Email: packaging@bongbetic.com
|
||||||
|
Expire-Date: 2y
|
||||||
|
%commit
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Export the public half — this file is committed to the repo
|
||||||
|
gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.asc
|
||||||
|
|
||||||
|
# Print the fingerprint for docs and release notes
|
||||||
|
gpg --fingerprint packaging@bongbetic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the **private key** to the password manager immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --armor --export-secret-keys packaging@bongbetic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Then **delete the private key from the local keyring** — it must never persist
|
||||||
|
on any build host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --delete-secret-keys packaging@bongbetic.com
|
||||||
|
gpg --delete-keys packaging@bongbetic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The committed `fenris-packaging.asc` must contain the real public key (replace
|
||||||
|
the placeholder comments).
|
||||||
|
|
||||||
|
## Per-release signing flow
|
||||||
|
|
||||||
|
Each release performs: **import → sign → delete**. The private key is never
|
||||||
|
stored on disk longer than the release takes.
|
||||||
|
|
||||||
|
### Step 1: Import the private key
|
||||||
|
|
||||||
|
Retrieve the private key from the password manager and import it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --import /tmp/packaging-key-private.asc
|
||||||
|
rm /f /tmp/packaging-key-private.asc # Shred if possible
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Build and sign packages
|
||||||
|
|
||||||
|
The Makefile target `make release` handles signing automatically when the
|
||||||
|
key is in the keyring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood:
|
||||||
|
|
||||||
|
1. `rpmsign --addsign` signs the RPM payload with the packaging key
|
||||||
|
(invoked by `make sign-rpm`).
|
||||||
|
2. `sha256sum` generates the checksum manifest.
|
||||||
|
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
|
||||||
|
|
||||||
|
### Step 3: Delete the private key
|
||||||
|
|
||||||
|
Immediately after signing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --delete-secret-keys packaging@bongbetic.com
|
||||||
|
gpg --delete-keys packaging@bongbetic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the key is gone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --list-keys packaging@bongbetic.com
|
||||||
|
# Should produce: gpg: keyblock resource ...: No such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
|
The entire import → sign → delete cycle should take minutes. The private key
|
||||||
|
must never be left in any keyring between releases.
|
||||||
|
|
||||||
|
## Key rotation (outline)
|
||||||
|
|
||||||
|
When the key approaches expiry, or if it is compromised:
|
||||||
|
|
||||||
|
1. **Generate a new key** using the same procedure as first release.
|
||||||
|
2. **Publish the new public key** alongside the old one in-repo:
|
||||||
|
```text
|
||||||
|
packaging/keys/fenris-packaging.asc # new key (primary)
|
||||||
|
packaging/keys/fenris-packaging-previous.asc # old key (one cycle)
|
||||||
|
```
|
||||||
|
3. **Sign the next RPM** with the new key.
|
||||||
|
4. **Update `fenris.repo`** to list both `gpgkey` URLs (dnf accepts multiple):
|
||||||
|
```ini
|
||||||
|
gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
|
||||||
|
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging-previous.asc
|
||||||
|
```
|
||||||
|
5. **Drop the old key** from the repo after one release cycle. Delete
|
||||||
|
`fenris-packaging-previous.asc` and revert `gpgkey` to the single URL.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Consumers verify the RPM payload signature via dnf (gpgcheck=1 in
|
||||||
|
`fenris.repo` points at the published public key). The SHA256SUMS manifest
|
||||||
|
verification is manual for downloaded assets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-time live probe
|
||||||
|
|
||||||
|
Before the first real release, verify the full registry path end-to-end with a
|
||||||
|
throwaway package. This confirms apt/dnf metadata generation, signature
|
||||||
|
verification, and consumer setup work as a real consumer would experience them.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a throwaway package name to avoid polluting fenris metadata
|
||||||
|
PROBE_NAME="fenris-regtest"
|
||||||
|
PROBE_VERSION="0.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build a throwaway deb and rpm (use the existing nfpm config with a dummy name)
|
||||||
|
# Or use a pre-built package — the probe tests the registry path, not the build
|
||||||
|
|
||||||
|
# Upload deb to all codename pools
|
||||||
|
for CODENAME in bookworm jammy noble; do
|
||||||
|
curl --fail -X PUT \
|
||||||
|
-u "xavierk:${GITEA_TOKEN}" \
|
||||||
|
-T "dist/${PROBE_NAME}_${PROBE_VERSION}_amd64.deb" \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Upload rpm
|
||||||
|
curl --fail -X PUT \
|
||||||
|
-u "xavierk:${GITEA_TOKEN}" \
|
||||||
|
-T "dist/${PROBE_NAME}-${PROBE_VERSION}-1.x86_64.rpm" \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify apt metadata (Debian/Ubuntu consumer perspective)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On a Debian/Ubuntu machine:
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL https://git.bongbetic.com/api/packages/xavierk/debian/repository.key \
|
||||||
|
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-xavierk.asc
|
||||||
|
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/fenris.list
|
||||||
|
|
||||||
|
sudo apt update
|
||||||
|
apt show ${PROBE_NAME} # metadata present, correct version
|
||||||
|
apt install --dry-run ${PROBE_NAME} # dependency resolution works
|
||||||
|
|
||||||
|
# Verify InRelease signature
|
||||||
|
apt-key list 2>/dev/null || gpg --no-default-keyring --keyring /etc/apt/keyrings/gitea-xavierk.asc --list-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify dnf metadata (Fedora consumer perspective)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On a Fedora machine:
|
||||||
|
sudo dnf config-manager --add-repo https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
|
||||||
|
# Or use Gitea's auto-generated repo for the probe:
|
||||||
|
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/fenris.repo
|
||||||
|
|
||||||
|
dnf info ${PROBE_NAME} # metadata present, correct version
|
||||||
|
dnf install --assumeno ${PROBE_NAME} # dependency resolution works
|
||||||
|
|
||||||
|
# Verify rpm signature
|
||||||
|
rpm -q --scripts ${PROBE_NAME} # no scripts (throwaway)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify checksums and clearsign
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download from release assets or local build
|
||||||
|
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Delete the throwaway packages from the registry
|
||||||
|
for CODENAME in bookworm jammy noble; do
|
||||||
|
curl --fail -X DELETE \
|
||||||
|
-u "xavierk:${GITEA_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/${PROBE_NAME}/${PROBE_VERSION}/amd64"
|
||||||
|
done
|
||||||
|
|
||||||
|
curl --fail -X DELETE \
|
||||||
|
-u "xavierk:${GITEA_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/${PROBE_NAME}/${PROBE_VERSION}/x86_64"
|
||||||
|
|
||||||
|
# Remove test source list on consumer machines
|
||||||
|
sudo rm /etc/apt/sources.list.d/fenris.list
|
||||||
|
sudo apt update
|
||||||
|
```
|
||||||
@@ -91,7 +91,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
- **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts.
|
- **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts.
|
||||||
- **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI.
|
- **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI.
|
||||||
- **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash.
|
- **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash.
|
||||||
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are `p` pause (asks), `r` resume (does not), `c` collect now, `d` disclosures, `q` quit ([Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
|
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are the footer `p pause · r resume · c collect · d disclosures` — pause asks, resume does not — plus a bordered quit rail `q QUIT TUI` visually separate from monitoring state; the rail owns quit and the footer carries no quit entry (bindings amended by [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57); original [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
|
||||||
|
|
||||||
## Failure and recovery (ADR 0005)
|
## Failure and recovery (ADR 0005)
|
||||||
|
|
||||||
@@ -117,6 +117,13 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
- **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write.
|
- **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write.
|
||||||
- **IN-10** (P) Installed artifacts sit only at their fixed locations — units in `/etc/systemd/system`, helpers in `/usr/libexec/fenris`, polkit policy under `/usr/share/polkit-1/actions/`, configuration at `/etc/fenris`, observation store under `/var/lib/fenris` — and every placed file is recorded in the manifest (ADR 0004 §2; ADR 0003 §4).
|
- **IN-10** (P) Installed artifacts sit only at their fixed locations — units in `/etc/systemd/system`, helpers in `/usr/libexec/fenris`, polkit policy under `/usr/share/polkit-1/actions/`, configuration at `/etc/fenris`, observation store under `/var/lib/fenris` — and every placed file is recorded in the manifest (ADR 0004 §2; ADR 0003 §4).
|
||||||
|
|
||||||
|
## Migration from make-install systems (ADR 0007 §10, spec §9)
|
||||||
|
|
||||||
|
- **MG-1** (M) The migration runbook is published in the install docs (`docs/install/migrate-from-makeinstall.md`): mandatory remove-then-install steps, why over-install is forbidden (stale admin-directory units silently shadow vendor units; the local wrapper shadows the package wrapper), no-move continuity, and the reset-to-dormant expectation (the user opts back in with the sanctioned resume).
|
||||||
|
- **MG-2** (A) The install guard is verified across the matrix: either make-install marker (the legacy placement manifest, or a unit file under the admin unit directory) causes an abort with a runbook pointer — never auto-clean. Tested by `test_migration_guard` on all four targets (Debian 12, Ubuntu 22.04, Ubuntu 24.04, Fedora 40).
|
||||||
|
- **MG-3** (A) No-move continuity is verified in a container seeded with a make-install-shaped system: existing group makes sysusers a no-op, existing store directory makes tmpfiles a no-op, the hand-written configuration survives as a non-database file (package default lands beside it), and the store schema is caught up by the upgrade-path migration. Tested by `test_no_move_continuity_deb` and `test_no_move_continuity_rpm`.
|
||||||
|
- **MG-4** (M) The migration costs at most one short sample gap, honestly recorded in the endurance timeline: `make uninstall`'s sanctioned disable closes the open period `user_disabled`; after migration the user opts back in with `fenris monitor resume`.
|
||||||
|
|
||||||
## Collector acquisition path (ADR 0006)
|
## Collector acquisition path (ADR 0006)
|
||||||
|
|
||||||
- **AC-1** (P) Each collection run acquires counters and thermal evidence solely from `smartctl -a -j <device>` and controller identity (`subnqn`, `sn`, `mn`, `fr`, `transport`) solely from sysfs; no other acquisition path exists anywhere in the codebase.
|
- **AC-1** (P) Each collection run acquires counters and thermal evidence solely from `smartctl -a -j <device>` and controller identity (`subnqn`, `sn`, `mn`, `fr`, `transport`) solely from sysfs; no other acquisition path exists anywhere in the codebase.
|
||||||
@@ -124,3 +131,32 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
- **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity.
|
- **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity.
|
||||||
- **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components.
|
- **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components.
|
||||||
- **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9).
|
- **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9).
|
||||||
|
|
||||||
|
## Dashboard clarity and release notes ([Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55))
|
||||||
|
|
||||||
|
Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbetic.com/xavierk/Fenris/issues/59), from [Prototype the dashboard clarity additions](https://git.bongbetic.com/xavierk/Fenris/issues/56), [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57), and [Specify the changelog and release-notes mechanism](https://git.bongbetic.com/xavierk/Fenris/issues/58).
|
||||||
|
|
||||||
|
- **DC-1** (A) TUI branding: the header bar renders `Fenris — NVMe endurance monitor`; a dimmed `by Bongbetic` sits inline with service facts in the bottom service strip; neither string appears in `fenris status` (TUI-only identity surfaces).
|
||||||
|
- **DC-2** (A) Continuity parity, keyed to the boot fact as-is: active + boot-enabled renders `monitoring: active in background · persists across reboots`; boot-disabled renders `monitoring: does not start on next boot` — identical lowercase source strings in the TUI service strip and `fenris status`, including while paused (paused implies boot-disabled; the row still reports the fact). Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
|
||||||
|
- **DC-3** (A) Paused presentation (Deliberate disable): the TUI shows a strong state block titled `monitoring: paused — deliberate disable` with subline `paused time is excluded from your usage habit · resume: fenris monitor resume`; `fenris status` prints the same two lines with identical wording. Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
|
||||||
|
- **DC-4** (A) Quit affordance distinct from monitoring state: a bordered labelled rail `q QUIT TUI` visually separate from the paused state block; the footer reads `p pause · r resume · c collect · d disclosures` with no quit entry (the rail owns quit); quitting the TUI never alters monitoring state. Amends TUI-4's binding parenthetical.
|
||||||
|
- **DC-5** (A) Launch auth banner: `privileged actions will prompt for authentication (polkit)` renders full-width under the header at TUI launch, clears on the first refresh tick, and never reappears in the session; no user-facing string uses "sudo" (polkit-accurate elevation wording only).
|
||||||
|
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
|
||||||
|
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
||||||
|
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
|
||||||
|
|
||||||
|
## TUI polish and hourly history ([Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65))
|
||||||
|
|
||||||
|
Proposed by [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70), from the companion specification [`fenris-tui-polish-hourly-history.md`](fenris-tui-polish-hourly-history.md). This section amends the frozen redesign and prior dashboard-clarity criteria without editing their historical source specs. Where these criteria conflict with older TUI/header/history criteria, these newer criteria win. In particular, **TPH-1** supersedes **DC-1**'s header/credit placement; **TPH-2** and **TPH-3** refine **CI-2**, **LC-10**, and **TUI-4** status rendering; **TPH-4** through **TPH-7** refine **ST-5**, **PR-2** through **PR-9**, and **FL-1** through **FL-4** with the approved hourly-history and UTC-accounting contracts.
|
||||||
|
|
||||||
|
- **TPH-1** (A) *Titlebox and maker credit*: the TUI renders a top titlebox exactly `🐺 Fenris by Bongbetic`, falling back exactly to `Fenris by Bongbetic` when the wolf glyph is unsupported or width-unstable; no replacement-box glyph is shown; the old service-strip `by Bongbetic` credit is absent; `fenris status` renders no titlebox. Continuity, paused, quit, auth, parity, and release-notes behavior from **DC-2** through **DC-8** remains unchanged.
|
||||||
|
- **TPH-2** (A) *Status lattice and precedence*: fixture-driven TUI status rendering covers Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, and Unknown with the approved glyphs/text, semantic colours, and reason lines; only Monitoring's dot blinks, never text; reduced motion makes it steady; precedence is Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown, with Collecting as an overlay except over store fault.
|
||||||
|
- **TPH-3** (A/P) *CLI/status parity and timing*: `fenris status` renders the same status vocabulary, glyphs, precedence, and reason lines statically; freshness, last outcome, boot enablement, and collection activity remain separate facts; a lightweight 5 s unit-state poll can move cached freshness boundaries without a store read, while new samples appear only after the normal store refresh; store faults render `observation store unreadable — see journal` and suppress store-dependent views.
|
||||||
|
- **TPH-4** (A) *Warm-up and withheld estimates*: projection warm-up shows `Building evidence — N of 14 days observed · Q qualifying` plus `First lifespan estimate after 12 qualifying days`; the gate is 14 represented UTC dates in the current controller segment with at least 12 qualifying, while Supported separately requires 14 qualifying dates and all existing prerequisites. Missing baseline, unsupported write counters, warm-up, stale evidence, paused days, and unavailable numerators render explicit reason lines, never blank or misleading zero; first graph-data availability is independent of lifespan-estimate availability.
|
||||||
|
- **TPH-5** (A) *Collector-owned history publication and first data*: collection publishes validated sample → usage interval → hour observation/day aggregate results consistently before reporting success; the TUI remains read-only. Zero samples show awaiting-first-sample; one sample shows `Awaiting another sample`; the first compatible sample pair can show measured partial-hour `so far`; measured zero is `0 B`; missing, unsupported, invalid, or unavailable evidence is never converted to zero.
|
||||||
|
- **TPH-6** (A) *Local display days, attribution, gaps, pauses, and repair*: history browsing groups retained evidence by the current system timezone with the timezone label visible, including DST and fractional-offset cases; timestamped usage intervals are retained indefinitely alongside hour/day summaries, while raw samples keep the 14-day policy except needed boundary anchors. Measured interval totals are preserved once; cross-boundary shares render as unallocated usage rather than interpolation or endpoint assignment; gaps remain distinct from zero; future time is not counted; deliberate-disable time is excluded; pause-crossing bytes are not counted as monitored totals; repair is transactional/idempotent and cannot overwrite valid older history with incomplete reconstruction.
|
||||||
|
- **TPH-7** (A) *UTC projection accounting and evaluability*: 7/28/90-day scenario windows end at the latest published usage-evidence endpoint `T` and start exactly 7/28/90 × 86,400 seconds earlier; denominators are monitored wall-clock seconds in the same span; rates are withheld when the monitored numerator cannot be established. Byte-allocation completeness and coverage are independent. Current partial UTC dates can qualify provisionally using elapsed monitored time; habit changes require completed consecutive UTC days with evaluable totals; unknown daily totals block burst/habit checks and Supported confidence; resets/replacements and legacy summaries obey the approved segment and actual-precision rules without changing lifespan math or numeric thresholds.
|
||||||
|
- **TPH-8** (A/M) *Writes-only graph and drill-down*: the TUI renders a writes-only daily bar graph, default 14 days, selectable 7/14/28/90 days, labelled `usage history · Local · UTC±HH:MM · <tz name>`; graph focus supports `←`/`→`, `Enter`, `Esc`/`Backspace`, and `1`/`2`/`3`/`4`, with mouse equivalents for select/drill/back where Textual support is available. Daily bars drill into hourly bars and back. The legend distinguishes allocated `█`, unallocated `▒`, gap `░`, measured zero `·`, partial `┄`, and selection `▼`; selected readout states totals, evidenced hours, unallocated usage, coverage, and partial elapsed facts. Reads graphing is out of scope.
|
||||||
|
- **TPH-9** (A) *Terminal size and graph implementation*: at 80×24 the default range graph and hourly drill-down fit; below 80×24 the graph region hides and shows a one-line textual history summary plus exactly `graph needs ≥80×24`, while titlebox, status reason, drive health, service facts, quit rail/action affordances, and selected-day context survive. The graph uses a custom block-glyph renderable; no new plotting dependency is added for this graph.
|
||||||
|
- **TPH-10** (A/M) *Colour presets, persistence, and reduced motion*: Amber, Nord, and High Contrast presets are available; Amber is the default and keeps the graph amber by default; status semantic colours/glyphs/text outrank theme styling. Preset and reduced-motion choices persist per unprivileged user at `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`, not in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration; missing preferences default to Amber and normal motion; `t preset` and `m motion` controls plus accessible clickable equivalents are available; preferences never affect collection, projection, history evidence, or `fenris status`.
|
||||||
|
- **TPH-11** (A) *Drive health and settings grouping*: vendor wear renders under Drive health with temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context, and remains context rather than a second projection. Settings is read-only and limited to device selector, endurance baseline/provenance, retention facts, and TUI display preferences; no custom colour editor exists.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Fenris dashboard clarity specification
|
||||||
|
|
||||||
|
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
|
||||||
|
|
||||||
|
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
|
||||||
|
|
||||||
|
**Binding language.** *Must*, *exactly*, and *never* are normative.
|
||||||
|
|
||||||
|
## How to read this document
|
||||||
|
|
||||||
|
Five screen additions (§1–§5), one release-notes mechanism (§6), the verbatim string register (§7), and the README section to add at execution (§8). Each section cites its criteria. Source strings are lowercase; the TUI may render uppercase via styling only. Typography, governing every string: em-dash `—` separates a title from its qualifier; middle dot `·` joins facts within a line; UTF-8 is assumed. CI parity sweeps compare lowercase source strings — rendering case is styling, not wording.
|
||||||
|
|
||||||
|
## 1. Header bar and Bongbetic credit — DC-1
|
||||||
|
|
||||||
|
Visual base is treatment A, quiet integration: the existing Panes information architecture is preserved.
|
||||||
|
|
||||||
|
- The header bar reads `Fenris — NVMe endurance monitor`.
|
||||||
|
- The credit `by Bongbetic` renders dimmed, inline with service facts in the bottom service strip — never in the action row.
|
||||||
|
- Both are TUI-only identity surfaces: `fenris status` never renders them.
|
||||||
|
|
||||||
|
## 2. Continuity line — DC-2
|
||||||
|
|
||||||
|
A labelled `CONTINUITY` row in the service strip (treatment B), mirrored by `fenris status` — the TUI/CLI parity anchor. The row is keyed to the boot fact as-is, independently of run state (Deliberate disable runs `systemctl disable --now`, so paused implies boot-disabled; the row still reports the fact):
|
||||||
|
|
||||||
|
- Active + boot enabled: `monitoring: active in background · persists across reboots`
|
||||||
|
- Boot disabled: `monitoring: does not start on next boot`
|
||||||
|
|
||||||
|
Identical lowercase source strings in the TUI service strip and `fenris status`, including while paused.
|
||||||
|
|
||||||
|
## 3. Paused state block — DC-3
|
||||||
|
|
||||||
|
Treatment C, strong state blocks: when monitoring is paused, a full-width, high-contrast banner clearly identifying Deliberate disable:
|
||||||
|
|
||||||
|
- Title: `monitoring: paused — deliberate disable`
|
||||||
|
- Subline: `paused time is excluded from your usage habit · resume: fenris monitor resume`
|
||||||
|
|
||||||
|
`fenris status` prints the same two lines with identical wording (state line + consequence line). The resume hint uses the CLI form only; the footer owns key hints — no duplication.
|
||||||
|
|
||||||
|
## 4. Quit rail — DC-4 (amends TUI-4)
|
||||||
|
|
||||||
|
Treatment B, labelled rails: a prominent bordered `q QUIT TUI` rail, visually separate from the monitoring-state block and the paused banner. The footer becomes `p pause · r resume · c collect · d disclosures` — the rail owns quit; the footer carries no quit entry. Quitting the TUI never alters monitoring state. The register's TUI-4 binding parenthetical is amended accordingly by this assembly.
|
||||||
|
|
||||||
|
## 5. Launch auth banner — DC-5
|
||||||
|
|
||||||
|
A quiet informational line (treatment A) that never competes with drive state:
|
||||||
|
|
||||||
|
- Text: `privileged actions will prompt for authentication (polkit)`
|
||||||
|
- Full-width under the header at TUI launch; clears on the first refresh tick; never reappears in the session.
|
||||||
|
- TUI-only; `fenris status` never shows it.
|
||||||
|
- Elevation wording is polkit-accurate everywhere: no user-facing string uses "sudo" (sudo belongs to install/upgrade docs).
|
||||||
|
- Evidence class A: a Textual pilot drives refresh ticks headlessly.
|
||||||
|
|
||||||
|
## 6. Changelog and release notes — DC-6, DC-7, DC-8
|
||||||
|
|
||||||
|
Implements the existing glossary term *Release* (tag + packages + change notes together). No new glossary terms; no ADR (reversible mechanism).
|
||||||
|
|
||||||
|
### 6.1 CHANGELOG.md (source of truth, repo root)
|
||||||
|
|
||||||
|
- Keep a Changelog 1.1 shape. `## [Unreleased]` is always present at top, even empty. Version headings are `## [X.Y.Z] - YYYY-MM-DD` — bracketed bare semver, strict ISO date.
|
||||||
|
- Categories are `### Added`, `### Changed`, `### Fixed` only; security fixes fold into Fixed.
|
||||||
|
- Entries are single `- ` bullets, imperative mood, user-facing phrasing; no commit hashes or issue numbers.
|
||||||
|
|
||||||
|
### 6.2 Extraction (release.yml, tag time)
|
||||||
|
|
||||||
|
- `scripts/extract_changelog.py` (checked in, unit-tested): takes the changelog path and a version; slices that version's section verbatim; never reads `[Unreleased]`. Fails closed — `::error::` plus nonzero exit — when the section is missing or empty or the date is malformed.
|
||||||
|
- Guard: the workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
||||||
|
|
||||||
|
### 6.3 Release body
|
||||||
|
|
||||||
|
- Body = extracted version section verbatim + standing footer from `packaging/release-footer.md` (channel install one-liners, `sha256sum -c SHA256SUMS.asc` verify, rollback pointer). The footer is standing text; only the changelog section varies.
|
||||||
|
- Re-run against an existing release: PATCH the body (changelog re-sync is a feature); uploaded assets/packages keep their current idempotent-skip.
|
||||||
|
|
||||||
|
### 6.4 Discipline
|
||||||
|
|
||||||
|
- All entries land in `[Unreleased]` as part of the fixing change — no notes-later step.
|
||||||
|
- One release commit bumps the pyproject version, renames `[Unreleased]` → the version heading, and restores an empty `[Unreleased]`; the tag points at that commit (tag ↔ pyproject ↔ changelog triple-match, enforced fail-closed by DC-7).
|
||||||
|
- No backfill: per-release notes begin with the release shipping this mechanism; `CHANGELOG.md` starts with empty `[Unreleased]`.
|
||||||
|
|
||||||
|
## 7. String register (verbatim)
|
||||||
|
|
||||||
|
### TUI-only strings (launch/identity surfaces)
|
||||||
|
|
||||||
|
| Surface | String |
|
||||||
|
|---|---|
|
||||||
|
| Header bar | `Fenris — NVMe endurance monitor` |
|
||||||
|
| Credit (dimmed, inline with service facts) | `by Bongbetic` |
|
||||||
|
| Auth banner (full-width under header at launch, clears on first refresh tick, never reappears) | `privileged actions will prompt for authentication (polkit)` |
|
||||||
|
| Quit rail (bordered, labelled) | `q QUIT TUI` |
|
||||||
|
| Footer (owns key hints; no quit entry) | `p pause · r resume · c collect · d disclosures` |
|
||||||
|
|
||||||
|
### Parity strings (TUI and `fenris status` identical — CI-2)
|
||||||
|
|
||||||
|
| Surface | String |
|
||||||
|
|---|---|
|
||||||
|
| Continuity, active + boot enabled | `monitoring: active in background · persists across reboots` |
|
||||||
|
| Continuity, boot disabled | `monitoring: does not start on next boot` |
|
||||||
|
| Paused state line | `monitoring: paused — deliberate disable` |
|
||||||
|
| Paused consequence line | `paused time is excluded from your usage habit · resume: fenris monitor resume` |
|
||||||
|
|
||||||
|
`fenris status` prints the paused state line + consequence line when paused, identical wording to the banner title + subline.
|
||||||
|
|
||||||
|
## 8. README section (add at execution)
|
||||||
|
|
||||||
|
The README gains a "Reading the dashboard" section after the CLI reference. Verbatim text:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Reading the dashboard
|
||||||
|
|
||||||
|
`fenris` opens the TUI dashboard. Three things it tells you:
|
||||||
|
|
||||||
|
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||||
|
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||||
|
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||||
|
|
||||||
|
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
|
||||||
|
```
|
||||||
|
|
||||||
|
This resolves the map's README-wording fog: the wording is decided here; the actual README edit is execution.
|
||||||
|
|
||||||
|
## 9. Out of scope
|
||||||
|
|
||||||
|
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# Fenris TUI polish and hourly history companion specification
|
||||||
|
|
||||||
|
**Status:** approval-ready draft for [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70). It becomes implementation-ready only when that ticket records human approval. This is a planning asset: no production code, release gate, package, installation change, or runtime diagnosis is made here.
|
||||||
|
|
||||||
|
**Canonical sources.** This companion integrates the closed decisions on [Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65): [Define trustworthy hourly history and first-data availability](https://git.bongbetic.com/xavierk/Fenris/issues/66), [Choose daily graph encoding and hourly drill-down](https://git.bongbetic.com/xavierk/Fenris/issues/68), [Reconcile unallocated usage with UTC projection evidence](https://git.bongbetic.com/xavierk/Fenris/issues/71), [Define monitoring signals and honest warm-up estimates](https://git.bongbetic.com/xavierk/Fenris/issues/67), and [Approve titlebox, health layout and colour presets](https://git.bongbetic.com/xavierk/Fenris/issues/69). Terminology follows [`CONTEXT.md`](../../CONTEXT.md), including *Usage interval*, *Unallocated usage*, *Local display day*, *Byte-allocation completeness*, and *Qualifying day*.
|
||||||
|
|
||||||
|
**Relationship to existing specs.** [`fenris-redesign.md`](fenris-redesign.md) stays frozen. This companion amends it without editing it. [`dashboard-clarity.md`](dashboard-clarity.md) remains binding except where this document explicitly supersedes its header/credit placement. In the tracker, [Implement dashboard clarity and release notes](https://git.bongbetic.com/xavierk/Fenris/issues/61) is currently closed; this companion is a new follow-on handoff, not a rewrite or reopening of that shipped umbrella.
|
||||||
|
|
||||||
|
**Binding language.** *Must*, *exactly*, and *never* are normative.
|
||||||
|
|
||||||
|
## 1. Supersession and preserved requirements
|
||||||
|
|
||||||
|
This companion supersedes only these dashboard-clarity identity placements:
|
||||||
|
|
||||||
|
- the old header `Fenris — NVMe endurance monitor`;
|
||||||
|
- the old dimmed `by Bongbetic` credit in the service strip.
|
||||||
|
|
||||||
|
The new identity contract is the top titlebox in §2. The titlebox is the sole maker-credit surface.
|
||||||
|
|
||||||
|
Everything else from [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55) remains intact unless a more specific clause below amends its placement: the continuity wording, paused block and Deliberate-disable semantics, `q QUIT TUI` rail, footer action ownership, polkit-accurate auth banner, TUI/CLI wording parity where binding, changelog-driven release notes, and the quit-versus-pause distinction. Polkit wording remains polkit-accurate; no new user-facing TUI/status string says "sudo".
|
||||||
|
|
||||||
|
## 2. Product identity, health layout, and settings
|
||||||
|
|
||||||
|
### 2.1 Titlebox
|
||||||
|
|
||||||
|
The TUI's top titlebox must read exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
🐺 Fenris by Bongbetic
|
||||||
|
```
|
||||||
|
|
||||||
|
If the wolf glyph is unsupported or would disturb titlebox width, the fallback is exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fenris by Bongbetic
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not render tofu, replacement boxes, or an unstable emoji-width layout. The lifespan headline remains a drive/projection data surface, not the application title. `fenris status` does not render this titlebox.
|
||||||
|
|
||||||
|
### 2.2 Maker credit
|
||||||
|
|
||||||
|
Remove the duplicate `by Bongbetic` service-strip credit in the polished layout. The titlebox is the only maker-credit surface. This is the explicit amendment to the prior dashboard-clarity header/credit placement; it does not weaken the preserved continuity, pause, quit, auth, parity, or release-notes requirements.
|
||||||
|
|
||||||
|
### 2.3 Drive health and settings grouping
|
||||||
|
|
||||||
|
Move vendor wear under **Drive health**, alongside temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context. Vendor wear remains context, never a second projection.
|
||||||
|
|
||||||
|
**Settings** remains read-only and limited to the device selector, endurance baseline/provenance, retention facts, and TUI display preferences. No custom colour editor is added.
|
||||||
|
|
||||||
|
## 3. Monitoring status, collection activity, and warm-up messaging
|
||||||
|
|
||||||
|
### 3.1 Status lattice
|
||||||
|
|
||||||
|
Every status line carries a glyph and text label. Colour is never the sole carrier. Text never blinks.
|
||||||
|
|
||||||
|
| State | Colour | Glyph | Motion | Exact status / explanation contract |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Monitoring | green | `●` | blink 750 ms on / 750 ms off, status dot only | `● Monitoring` |
|
||||||
|
| Collecting | green | `◐` | steady | `◐ Collecting` — run in flight, bounded by the 90 s collection timeout |
|
||||||
|
| Paused | amber | `‖` | steady | `‖ Paused` — `monitoring paused — paused time excluded from your usage habit` |
|
||||||
|
| Waiting | amber | `○` | steady | `○ Waiting` — `last sample X ago`, `awaiting first sample`, or `awaiting another sample` |
|
||||||
|
| Interrupted | red | `⊘` | steady | `⊘ Interrupted` — `collection stopped outside Fenris — monitoring period still open` |
|
||||||
|
| Error | red | `✖` | steady | `✖ Error` — `last run failed (exit N)` plus `last good sample X ago` when data is still fresh, or `observation store unreadable — see journal` |
|
||||||
|
| Stale | red | `◌` | steady | `◌ Stale` — `last sample X days ago` when the timer is active and no failure is recorded |
|
||||||
|
| Unknown | grey | `?` | steady | `? Unknown` — `service state unavailable` |
|
||||||
|
|
||||||
|
Reduced motion, either from Textual's reduced-motion signal or the user's preference, renders Monitoring as a steady `● Monitoring`. The CLI form is always steady.
|
||||||
|
|
||||||
|
### 3.2 Precedence
|
||||||
|
|
||||||
|
Base-state precedence is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional rules:
|
||||||
|
|
||||||
|
- Paused outranks Stale. A drive paused for 49 days is amber Paused with `last sample 49 days ago` as a fact line, not a stale alarm.
|
||||||
|
- Error outranks Interrupted; the external stop rides in the explanation line when both facts exist.
|
||||||
|
- Unknown is used only when the service query fails and no store-derived fact, such as an open monitoring period, freshness, or deliberate-pause row, places the state higher.
|
||||||
|
- Collecting is an overlay, not a base rung. While the oneshot is in flight it overrides every base state except a store fault. While overlaying Paused, Interrupted, or retry-after-failure, the explanation line names the underlying state: for example, `run in flight — paused` or `run in flight — retry`. It reverts within the 90 s collection timeout to whatever state the outcome earns.
|
||||||
|
|
||||||
|
### 3.3 Separate facts
|
||||||
|
|
||||||
|
Do not fold these facts into the status word:
|
||||||
|
|
||||||
|
- `Last sample: X ago` freshness;
|
||||||
|
- last outcome: ok, failed with exit code, or none;
|
||||||
|
- `Start at boot: on/off` boot enablement;
|
||||||
|
- collection activity, which is represented by the Collecting overlay.
|
||||||
|
|
||||||
|
Blink means exactly "monitoring enabled and data fresh". It never means that collection just succeeded; collection activity is explicit Collecting.
|
||||||
|
|
||||||
|
### 3.4 Timing and refresh
|
||||||
|
|
||||||
|
A lightweight 5 s `systemctl show` poll of the timer and service units drives the status strip. The full store refresh remains at the 5-minute cadence. Status transitions are re-evaluated every poll tick from cached newest-sample timestamp plus current clock, so freshness aging boundaries cross within roughly 5 s without a store read. A new sample requires the normal store refresh.
|
||||||
|
|
||||||
|
The existing constants remain unchanged: fresh is newest sample within 2 × cadence + `AccuracySec` + 60 s, stale is at 48 h, collection timeout is 90 s, and default cadence is 5 min.
|
||||||
|
|
||||||
|
### 3.5 Canonical transitions
|
||||||
|
|
||||||
|
The implementation must make these states directly observable:
|
||||||
|
|
||||||
|
- failed run, data 4 min old: `✖ Error` — `last run failed (exit 3) · last good sample 4 min ago`;
|
||||||
|
- next successful run: `● Monitoring` after the status poll/refresh clears the failure;
|
||||||
|
- failed run with retry in flight: `◐ Collecting` — `run in flight — retry`, then Error or Monitoring;
|
||||||
|
- suspend for 3 h, wake, timer fires: `○ Waiting` → `◐ Collecting` → `● Monitoring`;
|
||||||
|
- external stop with fresh data: `⊘ Interrupted` — `collection stopped outside Fenris`;
|
||||||
|
- external stop 3 days later: still `⊘ Interrupted`, with `last sample 3 days ago` visible;
|
||||||
|
- fresh install with zero samples: `○ Waiting` — `awaiting first sample`;
|
||||||
|
- one sample but no compatible pair: `○ Waiting` — `awaiting another sample`;
|
||||||
|
- unreadable observation store: `✖ Error` — `observation store unreadable — see journal`, with store-dependent views suppressed.
|
||||||
|
|
||||||
|
### 3.6 Warm-up and withheld estimates
|
||||||
|
|
||||||
|
Projection warm-up uses the UTC accounting rules in §5. The progress block during warm-up is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Building evidence — N of 14 days observed · Q qualifying
|
||||||
|
First lifespan estimate after 12 qualifying days
|
||||||
|
```
|
||||||
|
|
||||||
|
`N` counts distinct represented UTC dates in the current controller segment, capped at 14 for the display. `Q` counts dates whose represented monitored span has at least 50% coverage. The gate for the first lifespan estimate is 14 represented UTC dates with at least 12 qualifying; Supported confidence still separately requires at least 14 qualifying dates plus every other confidence prerequisite.
|
||||||
|
|
||||||
|
Do not promise a countdown by hours. Days are the grain, and a provisional day can still fail qualification. Graph-data availability is independent: the graph can render from first usable evidence while the lifespan estimate remains withheld.
|
||||||
|
|
||||||
|
Withheld-estimate reason lines are explicit, never blank and never zero-filled:
|
||||||
|
|
||||||
|
- `No endurance baseline — set a rated TBW to see an estimate`;
|
||||||
|
- `Drive does not report write counters`;
|
||||||
|
- the warm-up progress block above;
|
||||||
|
- stale evidence: show the estimate frozen at the latest published usage-evidence endpoint with `estimate not updating — last sample X ago`;
|
||||||
|
- paused days are excluded from the day count, and the Paused status carries that fact.
|
||||||
|
|
||||||
|
Once warm-up clears, the existing lifespan line and Limited/Supported label from the frozen redesign specification render unchanged. This companion adds the progress block, reason lines, status precedence, and frozen-note behavior; it does not invent a new steady-state projection format.
|
||||||
|
|
||||||
|
### 3.7 CLI parity
|
||||||
|
|
||||||
|
`fenris status` adopts the same status vocabulary, precedence, glyphs, and reason lines, rendered statically. It shows Collecting only if a run is in flight at query time. Display preferences and TUI themes never affect CLI facts.
|
||||||
|
|
||||||
|
## 4. History evidence, local browsing, and publication
|
||||||
|
|
||||||
|
### 4.1 Ownership and publication
|
||||||
|
|
||||||
|
The collector owns sample → usage interval → hour observation/day aggregate derivation. It must publish a consistent validated result before reporting collection success. Readers must not see a new sample advertised as fully derived while dependent history is missing.
|
||||||
|
|
||||||
|
The TUI is read-only. Its next successful refresh sees whatever the collector has durably published, regardless of whether the TUI was running during collection. There is no hourly batch wait and no projection-confidence gate on usage history. Failure preserves prior valid history and remains explicit.
|
||||||
|
|
||||||
|
### 4.2 First visible data
|
||||||
|
|
||||||
|
One successful sample establishes counter, health, and freshness evidence, but not a usage delta; display `Awaiting another sample`. The first usable sample pair may display measured partial-hour usage labelled `so far` when attribution supports it. A usable pair has valid ordered timestamps and supported, nonnegative monotonic counters in the same controller segment; monitored totals additionally require an interval fully inside one monitoring period.
|
||||||
|
|
||||||
|
A cross-hour pair is a real usage interval total, not two invented hour values. Measured zero is visible `0 B`. Missing, unsupported, invalid, or absent evidence is never converted to zero.
|
||||||
|
|
||||||
|
### 4.3 Local display days
|
||||||
|
|
||||||
|
Storage timestamps and projection evidence days remain UTC. History browsing uses the user's current system timezone, visibly labelled. If the system timezone changes, the same retained evidence regroups into the new local display days. Use real calendar boundaries: DST days may be 23 or 25 hours, repeated local hours have distinct offsets, and fractional UTC offsets must work without synthetic splitting.
|
||||||
|
|
||||||
|
### 4.4 Retention and repair
|
||||||
|
|
||||||
|
Retain timestamped usage intervals indefinitely alongside hour observations and day aggregates. Full raw samples retain the 14-day policy, with a boundary-anchor exception: do not prune a raw sample that is still needed to durably derive an unfinished interval/hour/day representation.
|
||||||
|
|
||||||
|
Repair derives only what surviving raw evidence supports, transactionally and idempotently. It preserves original evidence and valid historical summaries. Incomplete reconstruction must not overwrite valid older history. Unsupported historical precision stays unavailable; it is not repaired by interpolation or waiting. A failed repair remains visible and retryable.
|
||||||
|
|
||||||
|
### 4.5 Attribution and gaps
|
||||||
|
|
||||||
|
Preserve measured usage interval totals. Never divide them proportionally across hours or calendar days, never assign them to an endpoint as if timing were observed, and never double-count interval totals and summaries derived from the same evidence.
|
||||||
|
|
||||||
|
An interval entirely inside a local display day and one monitoring period may contribute its total to that local-day total even if individual hour shares are unknown. Otherwise the total is shown separately as unallocated usage. An incomplete allocated subtotal must not be presented as a complete total.
|
||||||
|
|
||||||
|
Missing samples reduce usage-habit coverage where classification is unknown, but they do not erase a compatible measured gap total. Byte-allocation completeness and usage-habit classification coverage are distinct. Do not infer zero writes from missing samples.
|
||||||
|
|
||||||
|
Partial summaries represent elapsed time only. Future time is neither zero nor unknown. Deliberately disabled time is excluded, not an hour state. Intervals crossing a deliberate pause cannot distinguish monitored from paused writes: preserve the original evidence, exclude ambiguous bytes from monitored totals, and explain why. External service stops remain unexplained in-period gaps, not Deliberate disables.
|
||||||
|
|
||||||
|
### 4.6 Controller segments in browsing
|
||||||
|
|
||||||
|
The default history view is the current controller segment. Older segments remain browsable with explicit reset/replacement boundaries. Never form a delta across a controller-segment boundary or silently combine different drives.
|
||||||
|
|
||||||
|
## 5. UTC projection accounting and confidence amendments
|
||||||
|
|
||||||
|
This section amends the projection contract without changing lifespan mathematics, numeric thresholds, or confidence categories.
|
||||||
|
|
||||||
|
### 5.1 Measured totals and requested spans
|
||||||
|
|
||||||
|
For any requested span, count a compatible interval's total exactly once when its complete span is inside the requested span, inside one monitoring period, and has eligible controller provenance. It may supply a complete window total even when individual UTC-hour/day shares are unknown.
|
||||||
|
|
||||||
|
A positive interval crossing a requested boundary cannot supply that window's unknown share. Preserve its measured total separately as unallocated usage. Do not split proportionally, assign to the ending day, silently omit possible bytes, or label an incomplete subtotal as complete.
|
||||||
|
|
||||||
|
Withhold a rate whenever its monitored numerator cannot be established: unresolved boundary shares, missing initial/resume/reset counter support, and legacy eligibility ambiguity are not zero. Do not shorten the requested window or remove unknown monitored seconds merely to obtain a number. A compatible monotonic interval with zero counter delta proves zero writes throughout its represented span, including a requested subspan; that is direct counter evidence, not interpolation.
|
||||||
|
|
||||||
|
### 5.2 Projection endpoints and denominator
|
||||||
|
|
||||||
|
Let `T` be the latest published usage-evidence endpoint. The 7/28/90-day scenario windows end at `T` and start exactly 7/28/90 × 86,400 seconds earlier. Do not round starts to UTC midnight, and do not dilute rates as the TUI read clock advances without new published usage evidence. Evidence age remains a separate fact.
|
||||||
|
|
||||||
|
The default sustained regime is eligible observation history capped at 90 days, ending at `T`. A detected habit-change regime starts at the first divergence day's UTC midnight. Any unresolved share at those boundaries invokes the unavailable-rate rule; there is no silent fallback to a more convenient start.
|
||||||
|
|
||||||
|
For the chosen span, the rate denominator is wall-clock seconds inside monitoring periods, including powered-off and unknown time, excluding deliberate-disable time. Numerator and denominator describe the same requested span. Pause-crossing intervals cannot establish which writes were monitored and therefore cannot supply affected monitored totals. No bridge crosses controller-segment boundaries.
|
||||||
|
|
||||||
|
### 5.3 UTC evidence dates, warm-up, and confidence
|
||||||
|
|
||||||
|
Projection evidence remains UTC; local graph regrouping never changes projection eligibility. Coverage uses known-classified seconds divided by represented elapsed monitored seconds, excluding deliberately disabled and future time.
|
||||||
|
|
||||||
|
A qualifying day is a distinct UTC date whose represented monitored span has coverage at least 50%. A current partial date counts provisionally and can lose qualification as unknown time accumulates. Count a date once, not once per hour, interval, or controller fragment. Entirely paused dates have no represented monitored span and do not count.
|
||||||
|
|
||||||
|
Warm-up clears when the current controller segment has at least 14 distinct represented UTC dates, at least 12 of which qualify. Supported confidence still requires at least 14 qualifying dates plus every other existing condition. Thus 12 qualifying plus 2 poor dates clears warm-up but remains Limited. Same-day segment breaks use only the current segment's eligible portion for its new-segment warm-up; a shared UTC date does not import old-segment qualification.
|
||||||
|
|
||||||
|
Habit-change comparisons require completed, consecutive UTC days with evaluable daily write totals. Do not compress missing calendar dates into adjacent-row windows or treat missing/disabled time as zero. Unknown daily totals cannot establish habit change and cannot pass the burst/concentration guard.
|
||||||
|
|
||||||
|
An affected scenario rate is withheld while other independently computable rates remain visible. If the headline regime lacks a complete monitored numerator, no lifespan number renders; show the specific evidence-unavailable reason. If a complete positive headline rate exists while daily shares remain unknown, the lifespan may render as Limited, but Supported is blocked wherever a required confidence check cannot be established.
|
||||||
|
|
||||||
|
### 5.4 Segment and legacy evidence
|
||||||
|
|
||||||
|
Never compute a delta across a reset or replacement boundary, even within one UTC hour/day. Same-identity reset preserves prior eligible habit evidence, subject to the existing current-segment re-warm gate before a lifespan number can render. Re-warming does not reconstruct missing counter support.
|
||||||
|
|
||||||
|
A controller-identity change, including to or from a blank key, quarantines previous identity from every projection horizon. Returning to a previously seen key does not undo the intervening quarantine. History browsing may still show older segments explicitly.
|
||||||
|
|
||||||
|
Trusted legacy day-only summaries remain usable at their actual represented precision: only in a window fully containing their represented span and only when monitoring-period/controller eligibility is known. They cannot supply subday detail, local-midnight splits, or partial rolling-window totals. Mixed legacy summaries that cannot separate eligible from ineligible writes remain browsable but cannot supply affected projection totals.
|
||||||
|
|
||||||
|
### 5.5 Projection acceptance examples
|
||||||
|
|
||||||
|
Future implementation must make these cases observable without using fabricated history:
|
||||||
|
|
||||||
|
1. 100 MB from 23:55 to 00:05 UTC in one period/segment: retain 100 MB once. Neither UTC-day share is known. A containing window can consume 100 MB; a window starting at midnight cannot claim an exact share or rate.
|
||||||
|
2. Sparse three-day recovery interval with compatible counters: a measured 900 MB total remains real and can supply a containing window. There is no 300 MB/day allocation.
|
||||||
|
3. A 7-day boundary cuts a positive interval and the 28-day boundary does not: withhold the 7-day rate, preserve the independently computable 28-day rate, and do not omit the unresolved old-enough horizon to manufacture Supported confidence.
|
||||||
|
4. The same boundary with zero measured delta: exact zero contribution is permitted for the represented subspan; missing time outside it remains unknown.
|
||||||
|
5. `T` at UTC noon: a 7-day window spans exactly 604,800 wall-clock seconds before subtracting deliberate-disable time. Refreshing the TUI without new evidence does not change the endpoint.
|
||||||
|
6. First sample arrives after monitoring starts: preceding monitored time lacks a write total. Keep its time, do not invent zero bytes, and withhold affected rates.
|
||||||
|
7. Pause/resume crossed by one counter interval: preserve total as original evidence, but do not count ambiguous paused writes as monitored.
|
||||||
|
8. 12 qualifying dates plus 2 poor dates: warm-up clears, Supported still fails the 14-qualifying-date requirement.
|
||||||
|
9. Good coverage with unknown daily shares: day progress can qualify; burst and habit checks remain unknown, not passed or zero-filled.
|
||||||
|
10. Only 14 days of eligible history: do not require 28/90-day horizons yet, but evaluate the burst guard over observed eligible history and keep unknown daily totals blocking.
|
||||||
|
11. Reset/replacement at 10:30 UTC: no cross-break delta or whole-date shortcut. Same-key reset preserves eligible prior habit evidence but requires new-segment re-warm; replacement excludes prior identity from scenarios too.
|
||||||
|
12. Trusted old UTC-day summary with raw samples gone: usable only in a window fully containing its represented span and known eligibility, once only; no partial local-day/hour split.
|
||||||
|
|
||||||
|
## 6. Usage history graph and interaction
|
||||||
|
|
||||||
|
### 6.1 Encoding
|
||||||
|
|
||||||
|
Adopt a writes-only daily bar graph with hourly drill-down. Reads are out of scope. Candles are rejected: they bury the daily total, import price-chart semantics that usage data does not have, and distort gap/partial evidence. A rolling hourly strip is rejected as the default because it lacks day totals without reintroducing the day level.
|
||||||
|
|
||||||
|
The graph's value is bytes written. The daily range view shows one bar per local display day. Activating a day drills into hourly bars for that selected local display day. Back returns to the range view.
|
||||||
|
|
||||||
|
### 6.2 Ranges, labels, and readout
|
||||||
|
|
||||||
|
Default range: 14 days. Selectable ranges: 7, 14, 28, and 90 days. Ranges limit the viewport only; they never delete retained history or hide older controller segments from browsing.
|
||||||
|
|
||||||
|
The header line is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
usage history · Local · UTC±HH:MM · <tz name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The day row uses day-of-month labels. The selected-day readout uses `Wed 09 Sep` style. Hourly detail labels every third hour `00…21` plus `midnight → 23:00 local`. DST and repeated local hours follow the Local display day contract.
|
||||||
|
|
||||||
|
The selected-item readout states totals, evidenced hours, unallocated usage separately, coverage, and partial-state text such as `partial · N h elapsed · so far`.
|
||||||
|
|
||||||
|
### 6.3 Controls
|
||||||
|
|
||||||
|
The graph pane is focusable. Keyboard controls:
|
||||||
|
|
||||||
|
- `←` / `→` select day or hour;
|
||||||
|
- `Enter` drills into the selected day;
|
||||||
|
- `Esc` / `Backspace` returns from hourly detail;
|
||||||
|
- `1` / `2` / `3` / `4` switch 7 / 14 / 28 / 90 days.
|
||||||
|
|
||||||
|
Mouse controls use widget-local coordinates to select bars. Where Textual mouse support is available, clickable equivalents must cover select, drill, and back. Global `p` / `r` / `c` / `d` / `q` remain unchanged, and the footer shows graph keys while the graph is focused.
|
||||||
|
|
||||||
|
### 6.4 State rendering
|
||||||
|
|
||||||
|
The legend is always visible when the graph is visible. Distinct glyphs:
|
||||||
|
|
||||||
|
| Glyph | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `█` | allocated measured writes |
|
||||||
|
| `▒` | unallocated measured writes, stacked separately |
|
||||||
|
| `░` | gap / no evidence, never zero |
|
||||||
|
| `·` | measured zero bytes |
|
||||||
|
| `┄` | partial-day cap |
|
||||||
|
| `▼` | selection marker |
|
||||||
|
|
||||||
|
Unallocated usage is measured, not fabricated; it is never spread into hours to make the graph look complete. Gaps remain distinct from measured zero. Deliberate-disable annotations remain visible.
|
||||||
|
|
||||||
|
### 6.5 Minimum terminal
|
||||||
|
|
||||||
|
At 80×24, the range view must fit the default 14-day graph using 4 columns per day, and hourly drill-down must fit 24 single-column hourly bars. Below 80×24, hide the graph region and show a one-line textual history summary plus exactly:
|
||||||
|
|
||||||
|
```text
|
||||||
|
graph needs ≥80×24
|
||||||
|
```
|
||||||
|
|
||||||
|
This is not an error. The titlebox, status label/reason, drive health, service facts, quit rail/action affordances, and selected-day readout or equivalent textual context must survive. Resizing must not leave stale graph state visible.
|
||||||
|
|
||||||
|
### 6.6 Framework obligation
|
||||||
|
|
||||||
|
Current Textual facts verified during charting: Textual 8.2.8 has no BarChart widget, Sparkline is non-interactive, and `textual-plotext` is not an installed dependency. Implement the graph as a custom block-glyph renderable in the usage-history pane, with click mapping from widget-local coordinates. Do not add a plotting dependency for the adopted graph.
|
||||||
|
|
||||||
|
## 7. Colour presets, persistence, and accessibility
|
||||||
|
|
||||||
|
### 7.1 Presets
|
||||||
|
|
||||||
|
Approved presets: Amber, Nord, and High Contrast. Amber is the default and keeps the graph amber by default. Other presets may use theme-appropriate graph/accent colours.
|
||||||
|
|
||||||
|
Themes style chrome, graph, borders, accents, and muted text. They do not override status semantics. The status lattice keeps semantic colours: green Monitoring/Collecting, amber Paused/Waiting, red Interrupted/Error/Stale, grey Unknown, always with glyph and text.
|
||||||
|
|
||||||
|
Preset palettes must maintain readable contrast in normal and focused states. High Contrast is a first-class preset, not merely a lightened Amber.
|
||||||
|
|
||||||
|
### 7.2 Persistence scope
|
||||||
|
|
||||||
|
Preset and reduced-motion choices are user-scoped TUI display preferences. Persist them at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not store these preferences in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration. Missing preferences default to Amber and normal motion. This preference file never affects collection, projection, history evidence, or CLI `status` facts.
|
||||||
|
|
||||||
|
### 7.3 Controls and reduced motion
|
||||||
|
|
||||||
|
Add accessible TUI controls for `t preset` and `m motion`, with clickable equivalents where Textual mouse support is available. Focusable graph/settings panes are acceptable as long as status text and global actions remain reachable.
|
||||||
|
|
||||||
|
Normal motion: only the Monitoring status dot blinks at the approved 750 ms on / 750 ms off cadence. Text never blinks, and no other state animates. Reduced motion: Monitoring renders steady as `● Monitoring`. No animation may imply successful collection.
|
||||||
|
|
||||||
|
Long reasons, paused states, degraded/error states, missing data, and warm-up/withheld-estimate lines must remain text-visible. Do not collapse them into colour, blank space, or a misleading zero.
|
||||||
|
|
||||||
|
### 7.4 Framework facts
|
||||||
|
|
||||||
|
The charting prototype verified current Textual documentation: `App.register_theme(theme)` and `App.theme` support theme registration/activation; Textual theme variables plus `$text` / `color: auto` support legibility; mouse events provide screen/widget-relative coordinates and focusable widgets can be resolved/clicked for keyboard+mouse proof paths. The existing lockfile still pins Textual 8.2.8; prototype branches remain throwaway assets and add no runtime dependency.
|
||||||
|
|
||||||
|
## 8. Future implementation proof paths
|
||||||
|
|
||||||
|
These are direct observable probes the execution effort can derive tests from; they are not a build-only checklist.
|
||||||
|
|
||||||
|
- Synthetic first-run store with zero samples: TUI and `fenris status` show Waiting/awaiting-first-sample; graph has no zero-filled bars; estimate is withheld with the correct reason.
|
||||||
|
- One sample followed by a compatible pair inside one hour: first state says `Awaiting another sample`; after the pair, graph shows a partial `so far` value. If the counter is unchanged, the evidenced interval is visible `0 B`.
|
||||||
|
- Cross-hour and cross-local-midnight intervals: totals are retained once; hour/day shares remain unallocated unless evidence supports them; gaps and zeros use distinct glyphs.
|
||||||
|
- Pause/resume-crossing interval: paused time is excluded, ambiguous bytes do not enter monitored totals, Paused status explains the consequence, and quitting the TUI does not pause monitoring.
|
||||||
|
- Failed collection with fresh data, retry in flight, external stop, and store fault: status precedence matches §3 and store fault suppresses store-dependent views.
|
||||||
|
- UTC projection horizon cut by a positive interval: affected rate is unavailable with a specific reason while independent horizons remain; refreshing without new evidence does not shift `T`.
|
||||||
|
- Warm-up fixtures for 12 qualifying + 2 poor dates and 14 qualifying dates: the first clears the progress gate but remains Limited; the second can be Supported only if all other prerequisites pass.
|
||||||
|
- Graph keyboard and mouse path: range switch → selected day → hourly detail → back, with focus/footer behavior and no conflict with global actions.
|
||||||
|
- 80×24 and narrower terminal captures: 80×24 keeps the graph; below 80×24 shows the exact fallback and preserves status/health/action context.
|
||||||
|
- Theme fixture: Amber default, switch to Nord/High Contrast, restart TUI under the same unprivileged user and see preference persist; `fenris status` and collection behavior are unchanged.
|
||||||
|
|
||||||
|
## 9. Out of scope
|
||||||
|
|
||||||
|
- Production implementation, release gates, package publishing, installation changes, and closing or reopening prior implementation umbrellas.
|
||||||
|
- Changing lifespan mathematics, evidence/confidence thresholds, collector cadence, or controller-segment semantics except for the explicit accounting/evaluability amendments in §5.
|
||||||
|
- Fabricating, interpolating, proportionally splitting, or endpoint-assigning missing history.
|
||||||
|
- Read-throughput graph selector, custom colour editor, web GUI, notifications, alerting, unrelated release-workflow changes, or a general application redesign beyond the named TUI requirements.
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
|
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
|
||||||
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
|
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
|
||||||
| Fedora 40+ | every release | rpm | `rpm/fenris` group |
|
| Fedora 40+ | every release | rpm | `rpm/fenris` group |
|
||||||
|
| openSUSE Tumbleweed | rolling | rpm | `rpm/fenris` group |
|
||||||
|
|
||||||
- Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog).
|
- Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog).
|
||||||
- Dependencies are vendored in a bundled venv for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata.
|
- Dependencies are vendored as locked, pure-Python runtime packages for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata.
|
||||||
- Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor.
|
- Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor.
|
||||||
- The bundled venv is staged with `python3 -m venv --copies` at `/opt/fenris`: shebangs point at the fixed absolute `/opt/fenris/bin/python`, and the stdlib still comes from the host interpreter, which is why `python3 (>= 3.10)` is a hard dependency.
|
- Runtime packages are staged with `python3 -m pip --target /opt/fenris/vendor`; entry points run the target system's `python3` with that directory on the import path. No package ships a copied Python interpreter, avoiding build-host ABI paths and rolling-distribution minor-version breakage.
|
||||||
|
|
||||||
## 2. Distribution channel
|
## 2. Distribution channel
|
||||||
|
|
||||||
@@ -33,17 +34,17 @@
|
|||||||
## 3. Build toolchain
|
## 3. Build toolchain
|
||||||
|
|
||||||
- **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020.
|
- **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020.
|
||||||
- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (venv `--copies` → `/opt/fenris` tree, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists.
|
- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (locked runtime packages → `/opt/fenris/vendor`, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists.
|
||||||
- **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`.
|
- **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`.
|
||||||
- **Version scheme:** `<pyproject-version>-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates).
|
- **Version scheme:** `<pyproject-version>-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates).
|
||||||
- **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline.
|
- **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline.
|
||||||
|
|
||||||
## 4. Signing and key policy
|
## 4. Signing and key policy
|
||||||
|
|
||||||
- **RPM payload: signed.** rpmsign with the dedicated packaging key, wired through the nfpm config. This is required, not optional: it is the only working dnf-native verification path.
|
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
|
||||||
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
||||||
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
||||||
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host.
|
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
||||||
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
||||||
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
||||||
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
||||||
- **Promotion flow:** tag → `make release` (manual: `make package` + rpmsign + registry PUTs + attach `.deb`, `.rpm`, `SHA256SUMS` to the release entry).
|
- **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
||||||
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
|
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
|
|||||||
|
|
||||||
| Artifact | Location | Ownership |
|
| Artifact | Location | Ownership |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Bundled venv | `/opt/fenris` | package (tree) |
|
| Bundled runtime packages | `/opt/fenris/vendor` | package (tree) |
|
||||||
| Wrapper | `/usr/bin/fenris` | package |
|
| Wrapper | `/usr/bin/fenris` | package |
|
||||||
| Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries |
|
| Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries |
|
||||||
| Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) |
|
| Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) |
|
||||||
@@ -76,7 +77,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
|
|||||||
|
|
||||||
- **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB.
|
- **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB.
|
||||||
- **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships.
|
- **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships.
|
||||||
- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter.
|
- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via target `python3` with `/opt/fenris/vendor` on its import path → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter.
|
||||||
- **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`.
|
- **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`.
|
||||||
- **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command.
|
- **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
# The device selector specifies which NVMe drive to monitor.
|
# The device selector specifies which NVMe drive to monitor.
|
||||||
# Uncomment and set exactly one device path:
|
# Uncomment and set exactly one device path:
|
||||||
#
|
#
|
||||||
# devices = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
|
# device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
|
||||||
|
#
|
||||||
|
# The observation store path is optional and defaults to
|
||||||
|
# /var/lib/fenris/observations.db when unset:
|
||||||
|
#
|
||||||
|
# store_path = /var/lib/fenris/observations.db
|
||||||
#
|
#
|
||||||
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
|
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
# Fenris Packaging Key — placeholder
|
# Fenris Packaging Key
|
||||||
#
|
#
|
||||||
# The public half of the dedicated RSA-3072 packaging key used to sign rpm
|
# Public half of dedicated RSA-3072 key used to sign RPM payloads and
|
||||||
# payloads and clearsign SHA256SUMS manifests.
|
# clearsign SHA256SUMS manifests.
|
||||||
#
|
#
|
||||||
# The private half lives only in the password manager. Each release performs:
|
# Fingerprint: CE4542E1E23EB50F09EDFFA5A5E8B22D1872FB07
|
||||||
# import → sign → delete. No machine permanently holds signing material.
|
# Algorithm: RSA 3072
|
||||||
|
# UID: Fenris Packaging <packaging@bongbetic.com>
|
||||||
|
# Expiry: 2 years from creation
|
||||||
#
|
#
|
||||||
# Key details (published with the first Release):
|
# Raw URL:
|
||||||
# Algorithm: RSA 3072
|
# https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
|
||||||
# UID: Fenris Packaging <packaging@bongbetic.com>
|
|
||||||
# Expiry: 2 years from creation
|
|
||||||
#
|
#
|
||||||
# This file will be replaced with the real public key at the time of the
|
# The private half lives in approved secret storage only. Each release uses
|
||||||
# first Release. Its raw URL doubles as the dnf gpgkey target.
|
# import -> sign -> delete. See docs/install/signing-key-ceremony.md.
|
||||||
|
#
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQGNBGqZYxgBDADEyYQhndEEzoETD17vk8/4x2DoXQm9hxW7hiX3TQNSmXORpgjR
|
||||||
|
NNt0vV/rTptwjmxgkrlevjrYqiBuoXfKJ0WRC16e9+NRnCGwJX5F4sR7jfgS9XbH
|
||||||
|
pAbLbySll5LfrD6JcPcB4JsSishKkY6X0zHQD0/zrCaOsuNdLj+fLhWDoxjpLFGy
|
||||||
|
92U7KHwtt87vSmUM4FgAjUY4keVKqIP5pSWIcPEy7z025RytL1JP6z3jBJR7KKD/
|
||||||
|
MLXd2KTGGaxTIvzgimcvjQYqFxrT2YIRsmhVYzddRyUnYYWgOh9dp5xn9CRM48Lz
|
||||||
|
klxHI/jI4lRPCQJy0atBpGZk1bRIc9XsBXRWiR9Zwvpjah/r3whkknBFv7C4srMr
|
||||||
|
Fl0Ml897zwbCsCP/Ejs43SYt+BJ6B2z4lg6KVsG6lypqV8B+gT+E6rJZ/ML6UpS3
|
||||||
|
2XQBlWDAw3hf0abjZIOQegi0f5igc7TVyDzYYihLOjKRwZuGSHY40w4mohxESLy8
|
||||||
|
ogdMveFJKoRZvBsAEQEAAbQqRmVucmlzIFBhY2thZ2luZyA8cGFja2FnaW5nQGJv
|
||||||
|
bmdiZXRpYy5jb20+iQH0BBMBCABeFiEEzkVC4eI+tQ8J7f+lpeiyLRhy+wcFAmqZ
|
||||||
|
YxgbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMiwyAxsvBAUJA8JnAAULCQgHAgIi
|
||||||
|
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCl6LItGHL7B7qZC/4yFm3JwuhXuaJ6JvsH
|
||||||
|
ZNVCAVOywktFbdcfKJYCXayaVsQ0Yc1w/gW6XhYCr4EECfWplnjtta9zPnN61ODD
|
||||||
|
B8ZIuM9VUOqxpwvBWJnHcnny1FjmbJ0r0NOwmqKMj54cFHEDbVzmVPoshQSukThj
|
||||||
|
Uz28XXw/JOkeQQaVl6OF3MoLvhLrLWvnqX310Z151dpl1lEA6gYWd1eKau2oIfU4
|
||||||
|
e6u1JnX6mKWb0WaaEqo1QARXloQTaKV+NiSUavckTn1LXXMxGCFkbtNWYZv7uf+U
|
||||||
|
WFB8KuaR3u8if7R8Bab7Y0lzmPCCeSkLHXDLq9FyfDdGOj5LyXxxzlVnTTUyRANh
|
||||||
|
+JwGiokDqUzh3yUdUYnx6pE6+3tcxP+Gp92K/GZXulmPQYhw0sSyqfnK8GAtUVaD
|
||||||
|
KAnrV7fKZ9jve87NWeb3G0xfQiH9mNSsEmnQVzd/DCuczOf5fMFfHlUNgkI4t4G7
|
||||||
|
+hTpKrOOubozZwfB23mdM+H9pxwWFN6To85Iy1ge9JKTFTY=
|
||||||
|
=V4/R
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
|||||||
+6
-6
@@ -15,7 +15,7 @@ depends:
|
|||||||
- systemd
|
- systemd
|
||||||
|
|
||||||
contents:
|
contents:
|
||||||
# Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles
|
# Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles
|
||||||
- src: build/stage/
|
- src: build/stage/
|
||||||
dst: /
|
dst: /
|
||||||
type: tree
|
type: tree
|
||||||
@@ -26,17 +26,17 @@ contents:
|
|||||||
file_info:
|
file_info:
|
||||||
mode: 0755
|
mode: 0755
|
||||||
|
|
||||||
# Default placeholder-commented config (conffile for deb)
|
# Default placeholder-commented config (deb conffile / rpm %config(noreplace))
|
||||||
- src: packaging/fenris.conf
|
- src: packaging/fenris.conf
|
||||||
dst: /etc/fenris/fenris.conf
|
dst: /etc/fenris/fenris.conf
|
||||||
type: config
|
type: config|noreplace
|
||||||
file_info:
|
file_info:
|
||||||
mode: 0644
|
mode: 0644
|
||||||
|
|
||||||
# Observation store directory — owned by package, never packed
|
# Observation store directory — owned by package, never packed.
|
||||||
# deb: created in postinst; rpm: %ghost
|
# Store files (observations.db, WAL sidecars, .bak) are never owned.
|
||||||
- dst: /var/lib/fenris
|
- dst: /var/lib/fenris
|
||||||
type: ghost
|
type: dir
|
||||||
file_info:
|
file_info:
|
||||||
mode: 2750
|
mode: 2750
|
||||||
group: fenris
|
group: fenris
|
||||||
|
|||||||
+18
-12
@@ -9,7 +9,8 @@ set -eu
|
|||||||
STORE_DIR="/var/lib/fenris"
|
STORE_DIR="/var/lib/fenris"
|
||||||
STORE_DB="${STORE_DIR}/observations.db"
|
STORE_DB="${STORE_DIR}/observations.db"
|
||||||
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||||
VENV_PYTHON="/opt/fenris/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
configure)
|
configure)
|
||||||
@@ -18,27 +19,32 @@ case "${1:-}" in
|
|||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then
|
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
|
||||||
"${VENV_PYTHON}" -c "
|
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
|
||||||
from fenris.store import migrate_to_latest
|
from fenris.store import migrate_to_latest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
n = migrate_to_latest(Path('${STORE_DB}'))
|
n = migrate_to_latest(Path('${STORE_DB}'))
|
||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
# Restart timer only if unit contents changed AND active (spec §7)
|
RUNNING_UNITS=""
|
||||||
for unit in fenris-collect.timer; do
|
for unit in fenris-collect.timer; do
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
TMPFILE="$(mktemp)"
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
|
|
||||||
UNIT_PATH="/usr/lib/systemd/system/${unit}"
|
|
||||||
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
|
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -f "${TMPFILE}"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
# Restart timer only if unit contents changed AND active
|
||||||
|
for unit in ${RUNNING_UNITS}; do
|
||||||
|
OLD_CONTENT="$(mktemp)"
|
||||||
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "${OLD_CONTENT}"
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
||||||
systemd-sysusers || true
|
systemd-sysusers || true
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## Install
|
||||||
|
|
||||||
|
Install Fenris from its package channel after following the [package setup instructions](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#install-from-package-recommended):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install fenris # Debian / Ubuntu
|
||||||
|
sudo dnf install fenris # Fedora
|
||||||
|
sudo zypper install fenris # openSUSE Tumbleweed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify downloads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg --output SHA256SUMS --decrypt SHA256SUMS.asc
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Installing an older package over a newer observation store is unsupported. Restore the observation-store snapshot, then install the earlier Release; see the [upgrade and rollback guidance](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#upgrade).
|
||||||
+20
-11
@@ -5,7 +5,8 @@ set -eu
|
|||||||
STORE_DIR="/var/lib/fenris"
|
STORE_DIR="/var/lib/fenris"
|
||||||
STORE_DB="${STORE_DIR}/observations.db"
|
STORE_DB="${STORE_DIR}/observations.db"
|
||||||
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||||
VENV_PYTHON="/opt/fenris/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
if [ "$1" -eq 1 ]; then
|
if [ "$1" -eq 1 ]; then
|
||||||
# Fresh install
|
# Fresh install
|
||||||
@@ -17,24 +18,32 @@ elif [ "$1" -ge 2 ]; then
|
|||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then
|
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
|
||||||
"${VENV_PYTHON}" -c "
|
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
|
||||||
from fenris.store import migrate_to_latest
|
from fenris.store import migrate_to_latest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
n = migrate_to_latest(Path('${STORE_DB}'))
|
n = migrate_to_latest(Path('${STORE_DB}'))
|
||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
|
RUNNING_UNITS=""
|
||||||
for unit in fenris-collect.timer; do
|
for unit in fenris-collect.timer; do
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
TMPFILE="$(mktemp)"
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
|
|
||||||
UNIT_PATH="/usr/lib/systemd/system/${unit}"
|
|
||||||
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
|
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -f "${TMPFILE}"
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
# Restart timer only if unit contents changed AND active
|
||||||
|
for unit in ${RUNNING_UNITS}; do
|
||||||
|
OLD_CONTENT="$(mktemp)"
|
||||||
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "${OLD_CONTENT}"
|
||||||
|
done
|
||||||
|
# Re-apply placement modes (store dir group access, issue #54)
|
||||||
|
systemd-tmpfiles --create || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
+12
-7
@@ -5,7 +5,7 @@
|
|||||||
#
|
#
|
||||||
# VERSION defaults to the version in pyproject.toml.
|
# VERSION defaults to the version in pyproject.toml.
|
||||||
# The staged tree contains:
|
# The staged tree contains:
|
||||||
# /opt/fenris/ — bundled venv with the built wheel
|
# /opt/fenris/vendor/ — bundled pure-Python application dependencies
|
||||||
# /usr/bin/fenris — unprivileged wrapper
|
# /usr/bin/fenris — unprivileged wrapper
|
||||||
# /usr/libexec/fenris/ — fenris-monitor, fenris-collect
|
# /usr/libexec/fenris/ — fenris-monitor, fenris-collect
|
||||||
# /usr/lib/systemd/system/ — fenris-collect.{timer,service}
|
# /usr/lib/systemd/system/ — fenris-collect.{timer,service}
|
||||||
@@ -35,18 +35,23 @@ rm -rf "${STAGE_DIR}"
|
|||||||
mkdir -p "${STAGE_DIR}"
|
mkdir -p "${STAGE_DIR}"
|
||||||
|
|
||||||
# --- Use pre-built wheel from dist/ ---
|
# --- Use pre-built wheel from dist/ ---
|
||||||
WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-*.whl 2>/dev/null | head -1)
|
WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-"${VERSION}"-*.whl 2>/dev/null | head -1)
|
||||||
if [ -z "${WHEEL}" ]; then
|
if [ -z "${WHEEL}" ]; then
|
||||||
echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2
|
echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo " Using wheel: $(basename "${WHEEL}")"
|
echo " Using wheel: $(basename "${WHEEL}")"
|
||||||
|
|
||||||
# --- Create venv with --copies and install wheel ---
|
# --- Vendor runtime packages without an interpreter ---
|
||||||
echo " Creating bundled venv ..."
|
# A copied Python binary contains an ABI and build-host dynamic-library path.
|
||||||
python3 -m venv --copies "${STAGE_DIR}/opt/fenris"
|
# It fails after rolling-distribution Python upgrades (for example Tumbleweed
|
||||||
"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1
|
# 3.12 -> 3.13). Fenris and its locked dependencies are pure Python, so place
|
||||||
"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1
|
# them in a version-neutral directory and execute with the target's python3.
|
||||||
|
echo " Installing version-neutral runtime packages ..."
|
||||||
|
VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
|
||||||
|
mkdir -p "${VENDOR_DIR}"
|
||||||
|
python3 -m pip install --disable-pip-version-check --no-compile \
|
||||||
|
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
||||||
|
|
||||||
# --- Inject version into wrapper from pyproject.toml ---
|
# --- Inject version into wrapper from pyproject.toml ---
|
||||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Type Path Mode User Group Age Argument
|
# Type Path Mode User Group Age Argument
|
||||||
d /var/lib/fenris 2750 root fenris - -
|
d /var/lib/fenris 2770 root fenris - -
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.0"
|
version = "0.3.4"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract one validated Keep a Changelog version section."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogError(ValueError):
|
||||||
|
"""A release cannot safely use the supplied changelog."""
|
||||||
|
|
||||||
|
|
||||||
|
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
||||||
|
_VERSION_HEADING = re.compile(
|
||||||
|
rf"^## \[(?P<version>{_SEMVER})\] - (?P<date>.+)$", re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_version_section(changelog: str, version: str) -> str:
|
||||||
|
"""Return *version*'s changelog section without altering its bytes.
|
||||||
|
|
||||||
|
The section ends immediately before the next level-two heading. A release
|
||||||
|
cannot use an absent, empty, or malformed version section.
|
||||||
|
"""
|
||||||
|
if not re.fullmatch(_SEMVER, version):
|
||||||
|
raise ChangelogError(f"requested version is not bare semver: {version!r}")
|
||||||
|
|
||||||
|
heading = next(
|
||||||
|
(match for match in _VERSION_HEADING.finditer(changelog)
|
||||||
|
if match.group("version") == version),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if heading is None:
|
||||||
|
if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE):
|
||||||
|
raise ChangelogError(f"version {version} has a malformed heading or date")
|
||||||
|
raise ChangelogError(f"version {version} is missing from the changelog")
|
||||||
|
|
||||||
|
heading_date = heading.group("date")
|
||||||
|
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date):
|
||||||
|
raise ChangelogError(f"version {version} has a malformed release date")
|
||||||
|
try:
|
||||||
|
date.fromisoformat(heading_date)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ChangelogError(f"version {version} has a malformed release date") from error
|
||||||
|
|
||||||
|
next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE)
|
||||||
|
section_end = heading.end() + next_heading.start() if next_heading else len(changelog)
|
||||||
|
section = changelog[heading.start():section_end]
|
||||||
|
if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE):
|
||||||
|
raise ChangelogError(f"version {version} has an empty changelog section")
|
||||||
|
return section
|
||||||
|
|
||||||
|
|
||||||
|
def extract_changelog(path: Path, version: str) -> str:
|
||||||
|
"""Read and extract a requested version from a changelog file."""
|
||||||
|
try:
|
||||||
|
return extract_version_section(path.read_text(encoding="utf-8"), version)
|
||||||
|
except OSError as error:
|
||||||
|
raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_release_body(section: str, footer: str) -> str:
|
||||||
|
"""Append standing guidance while preserving the extracted section verbatim."""
|
||||||
|
separator = "\n" if section.endswith("\n") else "\n\n"
|
||||||
|
return f"{section}{separator}{footer}"
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("changelog", type=Path)
|
||||||
|
parser.add_argument("version")
|
||||||
|
parser.add_argument(
|
||||||
|
"--footer",
|
||||||
|
type=Path,
|
||||||
|
help="append this standing release guidance after the extracted section",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
section = extract_changelog(args.changelog, args.version)
|
||||||
|
if args.footer:
|
||||||
|
try:
|
||||||
|
footer = args.footer.read_text(encoding="utf-8")
|
||||||
|
except OSError as error:
|
||||||
|
raise ChangelogError(
|
||||||
|
f"cannot read release footer {args.footer}: {error.strerror}"
|
||||||
|
) from error
|
||||||
|
section = assemble_release_body(section, footer)
|
||||||
|
sys.stdout.write(section)
|
||||||
|
except ChangelogError as error:
|
||||||
|
print(f"::error::{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+25
-2
@@ -10,6 +10,29 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def add_runtime_packages() -> None:
|
||||||
|
"""Make the package-owned, pure-Python dependencies importable.
|
||||||
|
|
||||||
|
RPM and deb installations deliberately use the target system's Python.
|
||||||
|
Their dependencies are vendored without a copied interpreter so a distro
|
||||||
|
Python minor-version update cannot leave Fenris linked to a removed ABI.
|
||||||
|
The legacy development install keeps its venv fallback.
|
||||||
|
"""
|
||||||
|
runtime_dir = Path("/opt/fenris")
|
||||||
|
vendor_dir = runtime_dir / "vendor"
|
||||||
|
if vendor_dir.is_dir():
|
||||||
|
sys.path.insert(0, str(vendor_dir))
|
||||||
|
return
|
||||||
|
|
||||||
|
site_packages = next((runtime_dir / "lib").glob("python*/site-packages"), None)
|
||||||
|
if site_packages:
|
||||||
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
|
|
||||||
|
add_runtime_packages()
|
||||||
|
|
||||||
|
|
||||||
def is_root() -> bool:
|
def is_root() -> bool:
|
||||||
@@ -51,8 +74,8 @@ def cmd_tui(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def cmd_status(args: argparse.Namespace) -> None:
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
"""Show status."""
|
"""Show status."""
|
||||||
from fenris.status import print_status
|
from fenris.status import render_status
|
||||||
print_status()
|
print(render_status())
|
||||||
|
|
||||||
|
|
||||||
def cmd_sample(args: argparse.Namespace) -> None:
|
def cmd_sample(args: argparse.Namespace) -> None:
|
||||||
|
|||||||
Executable
+206
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fenris one-command release flow (issue #52).
|
||||||
|
# Builds both packages, signs, uploads to registry, creates release entry,
|
||||||
|
# and attaches artifacts — or in dry-run mode, prints every command.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/release.sh --dry-run # Print commands without executing
|
||||||
|
# scripts/release.sh --publish # Execute the full release flow
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# GITEA_TOKEN - API token for Gitea registry and release API
|
||||||
|
# PACKAGING_KEY - GPG key UID (default: packaging@bongbetic.com)
|
||||||
|
#
|
||||||
|
# Spec: release-packaging.md §5
|
||||||
|
|
||||||
|
# ── Defaults ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
PUBLISH=false
|
||||||
|
GITEA_URL="https://git.bongbetic.com"
|
||||||
|
GITEA_OWNER="xavierk"
|
||||||
|
GITEA_REPO="Fenris"
|
||||||
|
PACKAGING_KEY="${PACKAGING_KEY:-packaging@bongbetic.com}"
|
||||||
|
|
||||||
|
CODENAMES=(bookworm jammy noble)
|
||||||
|
RPM_GROUP="fenris"
|
||||||
|
|
||||||
|
# ── Parse arguments ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run) DRY_RUN=true ;;
|
||||||
|
--publish) PUBLISH=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--dry-run | --publish]"
|
||||||
|
echo ""
|
||||||
|
echo "Modes:"
|
||||||
|
echo " --dry-run Print commands without executing (default)"
|
||||||
|
echo " --publish Execute the full release flow"
|
||||||
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
echo " GITEA_TOKEN API token for Gitea registry and release API"
|
||||||
|
echo " PACKAGING_KEY GPG key UID (default: packaging@bongbetic.com)"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $arg" >&2
|
||||||
|
echo "Usage: $0 [--dry-run | --publish]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! $DRY_RUN && ! $PUBLISH; then
|
||||||
|
DRY_RUN=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_version() {
|
||||||
|
sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml
|
||||||
|
}
|
||||||
|
|
||||||
|
_deb_name() {
|
||||||
|
local ver="$1"
|
||||||
|
echo "fenris_${ver}_amd64.deb"
|
||||||
|
}
|
||||||
|
|
||||||
|
_rpm_name() {
|
||||||
|
local ver="$1" rel="$2"
|
||||||
|
echo "fenris-${ver}-${rel}.x86_64.rpm"
|
||||||
|
}
|
||||||
|
|
||||||
|
_run() {
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo " $*"
|
||||||
|
else
|
||||||
|
eval "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VERSION=$(_version)
|
||||||
|
REVISION=1
|
||||||
|
DEB=$(_deb_name "$VERSION")
|
||||||
|
RPM=$(_rpm_name "$VERSION" "$REVISION")
|
||||||
|
|
||||||
|
echo "=== Fenris Release v${VERSION} ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo "[dry-run] Commands below will be executed in --publish mode."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 1: Build both formats ──────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Build packages ---"
|
||||||
|
_run "make package"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 2: Sign RPM payload ────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Sign RPM payload ---"
|
||||||
|
_run "rpmsign --addsign --define '_gpg_name ${PACKAGING_KEY}' dist/${RPM}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 3: Generate and clearsign SHA256SUMS ────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Generate SHA256SUMS ---"
|
||||||
|
_run "cd dist && sha256sum ${DEB} ${RPM} > SHA256SUMS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "--- Clearsign SHA256SUMS ---"
|
||||||
|
_run "gpg --batch --yes --clearsign --local-user ${PACKAGING_KEY} dist/SHA256SUMS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 4: Upload to Gitea package registry ─────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Upload packages to registry ---"
|
||||||
|
for codename in "${CODENAMES[@]}"; do
|
||||||
|
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${DEB} '${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${codename}/main/upload'"
|
||||||
|
done
|
||||||
|
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${RPM} '${GITEA_URL}/api/packages/${GITEA_OWNER}/rpm/${RPM_GROUP}/upload'"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 5: Create Gitea release with notes ─────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Create Gitea release ---"
|
||||||
|
_release_notes="Release v${VERSION}
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
Install via apt (Debian/Ubuntu):
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
curl --fail -fsSL https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/keys/fenris-packaging.asc | sudo gpg --dearmor -o /etc/apt/keyrings/fenris.asc
|
||||||
|
echo \"deb [signed-by=/etc/apt/keyrings/fenris.asc] https://git.bongbetic.com/api/packages/${GITEA_OWNER}/debian bookworm main\" | sudo tee /etc/apt/sources.list.d/fenris.list
|
||||||
|
sudo apt update && sudo apt install fenris
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Install via dnf (Fedora):
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sudo dnf config-manager --add-repo https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/fenris.repo
|
||||||
|
sudo dnf install fenris
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
rpm -Kv fenris-${VERSION}-1.x86_64.rpm
|
||||||
|
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- \`dist/${DEB}\` (Debian/Ubuntu)
|
||||||
|
- \`dist/${RPM}\` (Fedora)
|
||||||
|
- \`dist/SHA256SUMS.asc\` (clearsigned checksums)
|
||||||
|
|
||||||
|
See [docs/install/signing-key-ceremony.md](docs/install/signing-key-ceremony.md) for key ceremony details.
|
||||||
|
See [docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md) for migration from make install."
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -H 'Content-Type: application/json' -d '{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"...\"}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases'"
|
||||||
|
else
|
||||||
|
# Create release via Gitea API (creates the tag atomically — no bare tag)
|
||||||
|
RELEASE_RESPONSE=$(curl --fail -s -X POST \
|
||||||
|
-u "${GITEA_OWNER}:${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg tag "v${VERSION}" \
|
||||||
|
--arg name "v${VERSION}" \
|
||||||
|
--arg body "$_release_notes" \
|
||||||
|
'{tag_name: $tag, name: $name, body: $body}')" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||||
|
echo " Release created: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 6: Attach artifacts to release ──────────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Attach artifacts to release ---"
|
||||||
|
for artifact in "dist/${DEB}" "dist/${RPM}" "dist/SHA256SUMS.asc"; do
|
||||||
|
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -F 'attachment=@${artifact}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID:-0}/assets'"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Done ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "=== Release v${VERSION} complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " Packages: ${DEB}, ${RPM}"
|
||||||
|
echo " Checksums: dist/SHA256SUMS.asc"
|
||||||
|
echo " Registry: deb → bookworm, jammy, noble; rpm → ${RPM_GROUP}"
|
||||||
|
echo " Release: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
|
||||||
|
echo ""
|
||||||
|
echo "Key ceremony: delete the private key after release."
|
||||||
|
echo " See docs/install/signing-key-ceremony.md"
|
||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Describe the Gitea request that creates or resynchronizes a release."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseRequestError(ValueError):
|
||||||
|
"""A release request could not be prepared safely."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_request(
|
||||||
|
version: str, body: str, existing_release: dict[str, Any] | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the observable POST or PATCH request for a Gitea release."""
|
||||||
|
if not re.fullmatch(_SEMVER, version):
|
||||||
|
raise ReleaseRequestError(f"version is not bare semver: {version!r}")
|
||||||
|
if existing_release is None:
|
||||||
|
return {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/releases",
|
||||||
|
"payload": {"tag_name": f"v{version}", "name": f"v{version}", "body": body},
|
||||||
|
}
|
||||||
|
|
||||||
|
release_id = existing_release.get("id")
|
||||||
|
if not isinstance(release_id, int):
|
||||||
|
raise ReleaseRequestError("existing release does not contain an integer id")
|
||||||
|
return {
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": f"/releases/{release_id}",
|
||||||
|
"payload": {"body": body},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ReleaseRequestError(f"cannot read existing release {path}: {error}") from error
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ReleaseRequestError("existing release must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--version", required=True)
|
||||||
|
parser.add_argument("--body-file", type=Path, required=True)
|
||||||
|
parser.add_argument("--existing-release", type=Path)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
body = args.body_file.read_text(encoding="utf-8")
|
||||||
|
existing = _read_json(args.existing_release) if args.existing_release else None
|
||||||
|
print(json.dumps(build_release_request(args.version, body, existing)))
|
||||||
|
except (OSError, ReleaseRequestError) as error:
|
||||||
|
print(f"::error::{error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.3.1"
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ import sys
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add the venv to path if running from the installed location
|
# Package dependencies are vendored independently of the host Python minor
|
||||||
|
# version. Keep the venv fallback for the legacy development install.
|
||||||
VENV_DIR = Path("/opt/fenris")
|
VENV_DIR = Path("/opt/fenris")
|
||||||
if VENV_DIR.exists():
|
if VENV_DIR.exists():
|
||||||
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
|
vendor_dir = VENV_DIR / "vendor"
|
||||||
if site_packages:
|
if vendor_dir.is_dir():
|
||||||
sys.path.insert(0, str(site_packages))
|
sys.path.insert(0, str(vendor_dir))
|
||||||
|
else:
|
||||||
|
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
|
||||||
|
if site_packages:
|
||||||
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
from fenris.store import init_store, get_store_path
|
from fenris.store import init_store, get_store_path
|
||||||
from fenris.collector import run_collection
|
from fenris.collector import run_collection
|
||||||
|
|||||||
@@ -21,12 +21,17 @@ import sqlite3
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Add the venv to path if running from the installed location
|
# Package dependencies are vendored independently of the host Python minor
|
||||||
|
# version. Keep the venv fallback for the legacy development install.
|
||||||
VENV_DIR = Path("/opt/fenris")
|
VENV_DIR = Path("/opt/fenris")
|
||||||
if VENV_DIR.exists():
|
if VENV_DIR.exists():
|
||||||
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
|
vendor_dir = VENV_DIR / "vendor"
|
||||||
if site_packages:
|
if vendor_dir.is_dir():
|
||||||
sys.path.insert(0, str(site_packages))
|
sys.path.insert(0, str(vendor_dir))
|
||||||
|
else:
|
||||||
|
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
|
||||||
|
if site_packages:
|
||||||
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
from fenris.store import init_store, get_store_path
|
from fenris.store import init_store, get_store_path
|
||||||
from fenris.monitoring_periods import (
|
from fenris.monitoring_periods import (
|
||||||
@@ -53,10 +58,6 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
|||||||
- Resume with no open period: opens a new row
|
- Resume with no open period: opens a new row
|
||||||
"""
|
"""
|
||||||
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
|
||||||
if not store_path.exists():
|
|
||||||
print("Error: Observation store not found at", store_path, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
+61
-2
@@ -88,7 +88,13 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
|||||||
|
|
||||||
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
|
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
|
||||||
"""
|
"""
|
||||||
if not store_path.exists():
|
try:
|
||||||
|
exists = store_path.exists()
|
||||||
|
except OSError as e:
|
||||||
|
# A non-group user stat()ing a 2750 store directory gets
|
||||||
|
# PermissionError before any StoreFault can be raised (issue #54).
|
||||||
|
raise StoreFault("observation store not readable: %s" % e)
|
||||||
|
if not exists:
|
||||||
raise StoreFault("observation store not found at %s" % store_path)
|
raise StoreFault("observation store not found at %s" % store_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -443,7 +449,7 @@ def _format_headline(proj) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||||
"""Append the four separate service facts (§7.3, LC-9)."""
|
"""Append service facts and dashboard-clarity monitoring state."""
|
||||||
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
||||||
activity = "active" if service.get("timer_active") else "inactive"
|
activity = "active" if service.get("timer_active") else "inactive"
|
||||||
|
|
||||||
@@ -467,6 +473,55 @@ def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
||||||
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
||||||
|
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
|
||||||
|
if service.get("deliberately_paused"):
|
||||||
|
lines.extend(deliberate_pause_lines())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CONTINUITY_ACTIVE = "monitoring: active in background · persists across reboots"
|
||||||
|
_CONTINUITY_DISABLED = "monitoring: does not start on next boot"
|
||||||
|
_PAUSED_TITLE = "monitoring: paused — deliberate disable"
|
||||||
|
_PAUSED_CONSEQUENCE = (
|
||||||
|
"paused time is excluded from your usage habit · resume: fenris monitor resume"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||||
|
"""Return the boot-persistence wording, independent of timer runtime."""
|
||||||
|
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
|
||||||
|
|
||||||
|
|
||||||
|
def deliberate_pause_lines() -> List[str]:
|
||||||
|
"""Return the exact CLI/TUI presentation for a sanctioned pause."""
|
||||||
|
return [_PAUSED_TITLE, _PAUSED_CONSEQUENCE]
|
||||||
|
|
||||||
|
|
||||||
|
def is_deliberately_paused(conn: sqlite3.Connection, service: Dict[str, Any]) -> bool:
|
||||||
|
"""Whether the latest closed period was ended by Fenris's own pause path.
|
||||||
|
|
||||||
|
Raw systemd operations have no `user_disabled` row, so they must never be
|
||||||
|
presented as a Deliberate disable. A live enabled timer also wins over a
|
||||||
|
stale period marker, keeping the presentation consistent with service facts.
|
||||||
|
"""
|
||||||
|
if service.get("boot_enabled") or service.get("timer_active"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
open_period = conn.execute(
|
||||||
|
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if open_period is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT end_cause FROM monitoring_periods "
|
||||||
|
"WHERE ended_at IS NOT NULL "
|
||||||
|
"ORDER BY ended_at DESC, id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return row is not None and row[0] == "user_disabled"
|
||||||
|
|
||||||
|
|
||||||
def format_disclosures() -> str:
|
def format_disclosures() -> str:
|
||||||
@@ -556,6 +611,10 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
|
|
||||||
service["freshness"] = freshness
|
service["freshness"] = freshness
|
||||||
service["freshness_age_s"] = freshness_age_s
|
service["freshness_age_s"] = freshness_age_s
|
||||||
|
try:
|
||||||
|
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||||
|
except sqlite3.Error:
|
||||||
|
service["deliberately_paused"] = False
|
||||||
|
|
||||||
# --- Drive anomalies (§9.7, FL-7) ---
|
# --- Drive anomalies (§9.7, FL-7) ---
|
||||||
drive_facts = []
|
drive_facts = []
|
||||||
|
|||||||
+26
-2
@@ -15,9 +15,19 @@ from typing import Optional
|
|||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
|
# fresh install that sets only the device selector must collect cleanly.
|
||||||
|
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
||||||
|
|
||||||
|
|
||||||
def get_store_path(config: dict) -> Path:
|
def get_store_path(config: dict) -> Path:
|
||||||
"""Get the store path from config."""
|
"""Get the store path from config.
|
||||||
return Path(config["store_path"])
|
|
||||||
|
Falls back to the packaged default when the config does not pin one,
|
||||||
|
so a fresh install whose config holds only the device selector works
|
||||||
|
instead of crashing with KeyError 'store_path' (issue #53).
|
||||||
|
"""
|
||||||
|
return Path(config.get("store_path", DEFAULT_STORE_PATH))
|
||||||
|
|
||||||
|
|
||||||
def init_store(store_path: Path) -> sqlite3.Connection:
|
def init_store(store_path: Path) -> sqlite3.Connection:
|
||||||
@@ -31,6 +41,20 @@ def init_store(store_path: Path) -> sqlite3.Connection:
|
|||||||
# Enable WAL mode for concurrent reads during writes
|
# Enable WAL mode for concurrent reads during writes
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
|
||||||
|
# Group members (fenris group) read the live store read-only, but SQLite
|
||||||
|
# in WAL mode needs write access to the db and its -wal/-shm sidecars even
|
||||||
|
# for readers. Best effort: root-created stores stay group-accessible
|
||||||
|
# without relying on the creating process's umask (issue #54).
|
||||||
|
import os as _os
|
||||||
|
for sidecar in (store_path,
|
||||||
|
store_path.with_name(store_path.name + "-wal"),
|
||||||
|
store_path.with_name(store_path.name + "-shm")):
|
||||||
|
try:
|
||||||
|
mode = _os.stat(sidecar).st_mode & 0o777
|
||||||
|
_os.chmod(sidecar, mode | 0o060)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check if this is a new database
|
# Check if this is a new database
|
||||||
cursor = conn.execute("PRAGMA user_version")
|
cursor = conn.execute("PRAGMA user_version")
|
||||||
current_version = cursor.fetchone()[0]
|
current_version = cursor.fetchone()[0]
|
||||||
|
|||||||
+110
-29
@@ -23,7 +23,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Container, Horizontal, VerticalScroll
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
@@ -43,6 +43,9 @@ from .status import (
|
|||||||
format_disclosures,
|
format_disclosures,
|
||||||
freshness_age_human,
|
freshness_age_human,
|
||||||
grade_freshness,
|
grade_freshness,
|
||||||
|
deliberate_pause_lines,
|
||||||
|
is_deliberately_paused,
|
||||||
|
monitoring_continuity,
|
||||||
open_store_readonly,
|
open_store_readonly,
|
||||||
query_service_state,
|
query_service_state,
|
||||||
read_config,
|
read_config,
|
||||||
@@ -229,6 +232,7 @@ def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[
|
|||||||
"freshness": freshness,
|
"freshness": freshness,
|
||||||
"freshness_age_s": None,
|
"freshness_age_s": None,
|
||||||
"period": period_info,
|
"period": period_info,
|
||||||
|
"deliberately_paused": is_deliberately_paused(conn, svc),
|
||||||
**svc,
|
**svc,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,20 +290,38 @@ class DisclosuresScreen(ModalScreen[None]):
|
|||||||
class FenrisTuiApp(App):
|
class FenrisTuiApp(App):
|
||||||
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
||||||
|
|
||||||
TITLE = "Fenris"
|
TITLE = "Fenris — NVMe endurance monitor"
|
||||||
SUB_TITLE = "NVMe endurance monitor"
|
SUB_TITLE = ""
|
||||||
|
|
||||||
CSS = """
|
CSS = """
|
||||||
#main-grid {
|
#main-grid {
|
||||||
layout: grid;
|
layout: grid;
|
||||||
grid-size: 2 3;
|
grid-size: 2 4;
|
||||||
grid-columns: 3fr 2fr;
|
grid-columns: 3fr 2fr;
|
||||||
grid-rows: 8 1fr 7;
|
grid-rows: 8 10 7 3;
|
||||||
height: 1fr;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
#main-grid.paused {
|
||||||
|
grid-size: 2 5;
|
||||||
|
grid-rows: 8 5 10 7 3;
|
||||||
|
}
|
||||||
|
#dashboard-scroll { height: 1fr; }
|
||||||
#headline-band { column-span: 2; }
|
#headline-band { column-span: 2; }
|
||||||
#service-strip { column-span: 2; }
|
#paused-banner {
|
||||||
.pane { border: round #555555; padding: 0 1; }
|
column-span: 2;
|
||||||
|
display: none;
|
||||||
|
background: $error 20%;
|
||||||
|
color: $text;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#service-strip { column-span: 2; height: 100%; }
|
||||||
|
#quit-rail {
|
||||||
|
column-span: 2;
|
||||||
|
border: heavy $accent;
|
||||||
|
content-align: center middle;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.pane { border: round #555555; padding: 0 1; height: 100%; }
|
||||||
#confirm-text { padding: 1 2; }
|
#confirm-text { padding: 1 2; }
|
||||||
#disc-text { padding: 1 2; }
|
#disc-text { padding: 1 2; }
|
||||||
"""
|
"""
|
||||||
@@ -317,21 +339,27 @@ class FenrisTuiApp(App):
|
|||||||
store_path: Optional[Path] = None,
|
store_path: Optional[Path] = None,
|
||||||
config_path: Optional[Path] = None,
|
config_path: Optional[Path] = None,
|
||||||
helper_path: Optional[str] = None,
|
helper_path: Optional[str] = None,
|
||||||
|
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||||
self.config_path = config_path
|
self.config_path = config_path
|
||||||
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
|
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
|
||||||
|
self.refresh_interval_s = refresh_interval_s
|
||||||
|
self._show_auth_notice = True
|
||||||
self._conn: Optional[sqlite3.Connection] = None
|
self._conn: Optional[sqlite3.Connection] = None
|
||||||
self._clock_now = datetime.now(timezone.utc)
|
self._clock_now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Vertical(id="main-grid"):
|
with VerticalScroll(id="dashboard-scroll"):
|
||||||
yield Static("", id="headline-band", classes="pane")
|
with Container(id="main-grid"):
|
||||||
yield Static("", id="usage-history", classes="pane")
|
yield Static("", id="headline-band", classes="pane")
|
||||||
yield Static("", id="drive-health", classes="pane")
|
yield Static("", id="paused-banner")
|
||||||
yield Static("", id="service-strip", classes="pane")
|
yield Static("", id="usage-history", classes="pane")
|
||||||
|
yield Static("", id="drive-health", classes="pane")
|
||||||
|
yield Static("", id="service-strip", classes="pane")
|
||||||
|
yield Static("q QUIT TUI", id="quit-rail")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Set border titles and render initial state."""
|
"""Set border titles and render initial state."""
|
||||||
@@ -339,8 +367,30 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#usage-history").border_title = "usage history"
|
self.query_one("#usage-history").border_title = "usage history"
|
||||||
self.query_one("#drive-health").border_title = "drive"
|
self.query_one("#drive-health").border_title = "drive"
|
||||||
self.query_one("#service-strip").border_title = "service + actions"
|
self.query_one("#service-strip").border_title = "service + actions"
|
||||||
|
self._refresh_timer = self.set_interval(
|
||||||
|
self.refresh_interval_s, self.on_refresh_tick
|
||||||
|
)
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
|
def on_refresh_tick(self) -> None:
|
||||||
|
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||||
|
self._show_auth_notice = False
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _headline_prefix(self) -> str:
|
||||||
|
"""Render identity and any launch-only guidance above drive state."""
|
||||||
|
lines = ["[bold]Fenris — NVMe endurance monitor[/bold]"]
|
||||||
|
if self._show_auth_notice:
|
||||||
|
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _render_headline(self, body: str = "") -> None:
|
||||||
|
"""Render full-width identity, guidance, and current drive state."""
|
||||||
|
text = self._headline_prefix()
|
||||||
|
if body:
|
||||||
|
text += "\n\n" + body
|
||||||
|
self.query_one("#headline-band").update(text)
|
||||||
|
|
||||||
def _open_store(self) -> Optional[sqlite3.Connection]:
|
def _open_store(self) -> Optional[sqlite3.Connection]:
|
||||||
"""Open store read-only, handling faults."""
|
"""Open store read-only, handling faults."""
|
||||||
try:
|
try:
|
||||||
@@ -366,28 +416,33 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
def _render_empty_or_fault(self) -> None:
|
def _render_empty_or_fault(self) -> None:
|
||||||
"""Render empty store greeting or store fault."""
|
"""Render empty store greeting or store fault."""
|
||||||
|
self._hide_paused_banner()
|
||||||
if not self.store_path.exists():
|
if not self.store_path.exists():
|
||||||
# Empty store — greeting with enable hint (IN-3)
|
# Empty store — greeting with enable hint (IN-3)
|
||||||
self.query_one("#headline-band").update(
|
self._render_headline(
|
||||||
"[bold]No observations yet[/bold]\n\n"
|
"[bold]No observations yet[/bold]\n\n"
|
||||||
"Enable monitoring: fenris monitor resume"
|
"Enable monitoring: fenris monitor resume"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").update("")
|
self.query_one("#usage-history").update("")
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
|
||||||
"p pause · r resume · c collect · d disclosures · q quit"
|
"[dim]by Bongbetic[/dim]\n"
|
||||||
|
"[bold]CONTINUITY[/bold] %s\n"
|
||||||
|
"p pause · r resume · c collect · d disclosures"
|
||||||
|
% monitoring_continuity({"boot_enabled": False})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Store fault (FL-4)
|
# Store fault (FL-4)
|
||||||
self.query_one("#headline-band").update(
|
self._render_headline(
|
||||||
"[bold red]Observation store unreadable[/bold red]\n"
|
"[bold red]Observation store unreadable[/bold red]\n"
|
||||||
"Check journalctl -u fenris-collect.service"
|
"Check journalctl -u fenris-collect.service"
|
||||||
)
|
)
|
||||||
self.query_one("#usage-history").update("")
|
self.query_one("#usage-history").update("")
|
||||||
self.query_one("#drive-health").update("")
|
self.query_one("#drive-health").update("")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"p pause · r resume · c collect · d disclosures · q quit"
|
"[dim]by Bongbetic[/dim]\n"
|
||||||
|
"p pause · r resume · c collect · d disclosures"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||||
@@ -398,13 +453,9 @@ class FenrisTuiApp(App):
|
|||||||
headline = self._format_headline(proj)
|
headline = self._format_headline(proj)
|
||||||
confidence = self._format_confidence(proj)
|
confidence = self._format_confidence(proj)
|
||||||
scenario = self._format_scenario(proj)
|
scenario = self._format_scenario(proj)
|
||||||
self.query_one("#headline-band").update(
|
self._render_headline(headline + "\n" + confidence + "\n" + scenario)
|
||||||
headline + "\n" + confidence + "\n" + scenario
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.query_one("#headline-band").update(
|
self._render_headline("[bold]No projection available[/bold]")
|
||||||
"[bold]No projection available[/bold]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Usage-history pane (§7.2 left) ---
|
# --- Usage-history pane (§7.2 left) ---
|
||||||
history = _query_usage_history(conn)
|
history = _query_usage_history(conn)
|
||||||
@@ -448,17 +499,47 @@ class FenrisTuiApp(App):
|
|||||||
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
||||||
freshness = svc.get("freshness", "unknown")
|
freshness = svc.get("freshness", "unknown")
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: %s · timer: %s · last collect: %s · freshness: %s\n"
|
"boot: %s · timer: %s · last collect: %s · freshness: %s · "
|
||||||
|
"[dim]by Bongbetic[/dim]\n"
|
||||||
|
"[bold]CONTINUITY[/bold] %s\n"
|
||||||
"%s\n"
|
"%s\n"
|
||||||
"p pause · r resume · c collect · d disclosures · q quit"
|
"p pause · r resume · c collect · d disclosures"
|
||||||
% (boot, activity, collect, freshness, svc.get("period", ""))
|
% (
|
||||||
|
boot, activity, collect, freshness,
|
||||||
|
monitoring_continuity(svc), svc.get("period", ""),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
self._render_paused_banner(svc)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
self._hide_paused_banner()
|
||||||
self.query_one("#service-strip").update(
|
self.query_one("#service-strip").update(
|
||||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
|
||||||
"p pause · r resume · c collect · d disclosures · q quit"
|
"[dim]by Bongbetic[/dim]\n"
|
||||||
|
"p pause · r resume · c collect · d disclosures"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
|
||||||
|
"""Show the high-contrast Deliberate disable block only when sanctioned."""
|
||||||
|
banner = self.query_one("#paused-banner")
|
||||||
|
if service.get("deliberately_paused"):
|
||||||
|
banner.update(
|
||||||
|
"[bold black on red]%s[/bold black on red]\n%s"
|
||||||
|
% tuple(deliberate_pause_lines())
|
||||||
|
)
|
||||||
|
banner.styles.display = "block"
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
main_grid.add_class("paused")
|
||||||
|
main_grid.refresh(layout=True)
|
||||||
|
else:
|
||||||
|
self._hide_paused_banner()
|
||||||
|
|
||||||
|
def _hide_paused_banner(self) -> None:
|
||||||
|
"""Ensure an unavailable store cannot retain a stale paused presentation."""
|
||||||
|
self.query_one("#paused-banner").styles.display = "none"
|
||||||
|
main_grid = self.query_one("#main-grid")
|
||||||
|
main_grid.remove_class("paused")
|
||||||
|
main_grid.refresh(layout=True)
|
||||||
|
|
||||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||||
"""Format the lifespan headline (spec §6.11)."""
|
"""Format the lifespan headline (spec §6.11)."""
|
||||||
if proj.headline_remaining_seconds is None:
|
if proj.headline_remaining_seconds is None:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Shared test helpers for Fenris test suite."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""Extract version from pyproject.toml."""
|
||||||
|
for line in VERSION_FILE.read_text().splitlines():
|
||||||
|
if line.startswith("version"):
|
||||||
|
return line.split("=")[1].strip().strip('"')
|
||||||
|
raise RuntimeError("Could not determine version from pyproject.toml")
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: str | Path) -> str:
|
||||||
|
"""Read a file relative to the repository root."""
|
||||||
|
return (REPO_ROOT / path).read_text()
|
||||||
@@ -399,6 +399,89 @@ class TestCI2Parity:
|
|||||||
assert "last collect:" in status
|
assert "last collect:" in status
|
||||||
assert "freshness:" in status
|
assert "freshness:" in status
|
||||||
|
|
||||||
|
def test_dashboard_clarity_parity_strings_have_one_status_source(self):
|
||||||
|
"""DC-2/DC-3 wording originates in status and the TUI imports it."""
|
||||||
|
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||||
|
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||||
|
for wording in (
|
||||||
|
"monitoring: active in background · persists across reboots",
|
||||||
|
"monitoring: does not start on next boot",
|
||||||
|
"monitoring: paused — deliberate disable",
|
||||||
|
"paused time is excluded from your usage habit · resume: fenris monitor resume",
|
||||||
|
):
|
||||||
|
assert status_src.count(wording) == 1
|
||||||
|
assert wording not in tui_src
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("state", "service", "expected_lines"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"active_enabled",
|
||||||
|
{"boot_enabled": True, "timer_active": True},
|
||||||
|
["monitoring: active in background · persists across reboots"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"boot_disabled",
|
||||||
|
{"boot_enabled": False, "timer_active": False},
|
||||||
|
["monitoring: does not start on next boot"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"deliberately_paused",
|
||||||
|
{"boot_enabled": False, "timer_active": False},
|
||||||
|
[
|
||||||
|
"monitoring: does not start on next boot",
|
||||||
|
"monitoring: paused — deliberate disable",
|
||||||
|
"paused time is excluded from your usage habit · resume: fenris monitor resume",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_dashboard_clarity_monitoring_lines_match_both_views(
|
||||||
|
self, tmp_path, state, service, expected_lines
|
||||||
|
):
|
||||||
|
"""CI-2 synthetic-store sweep covers active, disabled, and paused states."""
|
||||||
|
db = tmp_path / (state + ".db")
|
||||||
|
conn = init_store(db)
|
||||||
|
if state == "active_enabled":
|
||||||
|
ensure_period_open(conn, _clock())
|
||||||
|
elif state == "deliberately_paused":
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-30T09:00:00+00:00", "2026-09-30T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
service_state = {
|
||||||
|
**service,
|
||||||
|
"last_collect_ok": None,
|
||||||
|
"last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value=service_state), patch(
|
||||||
|
"fenris.tui.query_service_state", return_value=service_state
|
||||||
|
):
|
||||||
|
status = get_status(
|
||||||
|
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||||
|
).lower()
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
async with app.run_test(size=(100, 40)):
|
||||||
|
tui_text = "\n".join(
|
||||||
|
(
|
||||||
|
str(app.query_one("#service-strip").render()),
|
||||||
|
str(app.query_one("#paused-banner").render()),
|
||||||
|
)
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
for expected in expected_lines:
|
||||||
|
assert expected in status
|
||||||
|
assert expected in tui_text
|
||||||
|
if state != "deliberately_paused":
|
||||||
|
assert "monitoring: paused — deliberate disable" not in status
|
||||||
|
assert "monitoring: paused — deliberate disable" not in tui_text
|
||||||
|
|
||||||
def test_pause_resume_action_names(self):
|
def test_pause_resume_action_names(self):
|
||||||
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
|
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
|
||||||
assert "p" in tui_keys
|
assert "p" in tui_keys
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Release-note changelog extraction tests (DC-6, DC-7)."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py"
|
||||||
|
RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py"
|
||||||
|
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _extractor_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_the_requested_version_section_verbatim():
|
||||||
|
extractor = _extractor_module()
|
||||||
|
changelog = """# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show a release summary to consumers.
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-09-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Preserve the observation history during upgrades.
|
||||||
|
"""
|
||||||
|
expected = """## [1.4.0] - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Show a release summary to consumers.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert extractor.extract_version_section(changelog, "1.4.0") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited():
|
||||||
|
lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines()
|
||||||
|
unreleased = lines.index("## [Unreleased]")
|
||||||
|
version_headings = [
|
||||||
|
index for index, line in enumerate(lines)
|
||||||
|
if line.startswith("## [") and line != "## [Unreleased]"
|
||||||
|
]
|
||||||
|
first_version = version_headings[0] if version_headings else len(lines)
|
||||||
|
categories = [
|
||||||
|
line.removeprefix("### ")
|
||||||
|
for line in lines[unreleased + 1:first_version]
|
||||||
|
if line.startswith("### ")
|
||||||
|
]
|
||||||
|
|
||||||
|
assert unreleased < first_version
|
||||||
|
assert set(categories) <= {"Added", "Changed", "Fixed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_footer_verifies_the_clearsigned_checksum_asset():
|
||||||
|
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "gpg --output SHA256SUMS --decrypt SHA256SUMS.asc" in footer
|
||||||
|
assert "sha256sum -c SHA256SUMS" in footer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("changelog", "expected_error"),
|
||||||
|
[
|
||||||
|
("# Changelog\n\n## [Unreleased]\n", "missing"),
|
||||||
|
(
|
||||||
|
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n",
|
||||||
|
"empty",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n",
|
||||||
|
"malformed release date",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fails_closed_for_missing_empty_or_malformed_sections(
|
||||||
|
changelog, expected_error
|
||||||
|
):
|
||||||
|
extractor = _extractor_module()
|
||||||
|
|
||||||
|
with pytest.raises(extractor.ChangelogError, match=expected_error):
|
||||||
|
extractor.extract_version_section(changelog, "1.4.0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path):
|
||||||
|
changelog = tmp_path / "CHANGELOG.md"
|
||||||
|
changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert result.stderr.startswith("::error::")
|
||||||
|
assert "missing" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_assembles_a_release_body_without_changing_the_section():
|
||||||
|
extractor = _extractor_module()
|
||||||
|
section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n"
|
||||||
|
footer = "## Install\n\nUse the package channel.\n"
|
||||||
|
|
||||||
|
assert extractor.assemble_release_body(section, footer) == (
|
||||||
|
section + "\n" + footer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_can_write_the_complete_release_body(tmp_path):
|
||||||
|
changelog = tmp_path / "CHANGELOG.md"
|
||||||
|
changelog.write_text(
|
||||||
|
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
|
||||||
|
"### Added\n\n- Show a release summary.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
footer = tmp_path / "footer.md"
|
||||||
|
footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(EXTRACTOR_PATH),
|
||||||
|
str(changelog),
|
||||||
|
"1.4.0",
|
||||||
|
"--footer",
|
||||||
|
str(footer),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert result.stdout == (
|
||||||
|
"## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n"
|
||||||
|
"## Install\n\nUse the package channel.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
|
||||||
|
body = tmp_path / "release-body.md"
|
||||||
|
body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8")
|
||||||
|
|
||||||
|
create = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(RELEASE_REQUEST_PATH),
|
||||||
|
"--version",
|
||||||
|
"1.4.0",
|
||||||
|
"--body-file",
|
||||||
|
str(body),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
existing = tmp_path / "existing-release.json"
|
||||||
|
existing.write_text('{"id": 17, "assets": []}', encoding="utf-8")
|
||||||
|
patch = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(RELEASE_REQUEST_PATH),
|
||||||
|
"--version",
|
||||||
|
"1.4.0",
|
||||||
|
"--body-file",
|
||||||
|
str(body),
|
||||||
|
"--existing-release",
|
||||||
|
str(existing),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert create.returncode == patch.returncode == 0
|
||||||
|
assert json.loads(create.stdout) == {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/releases",
|
||||||
|
"payload": {
|
||||||
|
"tag_name": "v1.4.0",
|
||||||
|
"name": "v1.4.0",
|
||||||
|
"body": "## [1.4.0] - 2026-09-10\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert json.loads(patch.stdout) == {
|
||||||
|
"method": "PATCH",
|
||||||
|
"path": "/releases/17",
|
||||||
|
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
|
||||||
|
}
|
||||||
@@ -52,6 +52,21 @@ class TestIsRoot:
|
|||||||
class TestEnableIdempotentMatrix:
|
class TestEnableIdempotentMatrix:
|
||||||
"""§8.6: Period-row idempotent matrix."""
|
"""§8.6: Period-row idempotent matrix."""
|
||||||
|
|
||||||
|
def test_first_enable_creates_missing_store(self, store_path):
|
||||||
|
"""A fresh package install has a store directory but no database yet."""
|
||||||
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
|
with patch("fenris.monitor.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
cmd_enable(args)
|
||||||
|
|
||||||
|
conn = init_store(store_path)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
def test_first_opens_period(self, store_path):
|
def test_first_opens_period(self, store_path):
|
||||||
"""First-ever enable opens a period at the enable moment."""
|
"""First-ever enable opens a period at the enable moment."""
|
||||||
# Initialize store
|
# Initialize store
|
||||||
|
|||||||
+883
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
|||||||
|
"""Release flow tests (issue #52).
|
||||||
|
|
||||||
|
Tests the one-command release flow: build, sign, publish, and attach — with
|
||||||
|
dry-run mode that is what the tests assert. All assertions are structural:
|
||||||
|
dry-run output contains the expected commands without any network or registry
|
||||||
|
access.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- scripts/release.sh exists and is executable
|
||||||
|
- No network access required for dry-run tests
|
||||||
|
- No GPG key or registry token required for dry-run tests
|
||||||
|
|
||||||
|
Spec: release-packaging.md §5, issue #52 acceptance criteria
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RELEASE_SCRIPT = REPO_ROOT / "scripts" / "release.sh"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read(path: str | Path) -> str:
|
||||||
|
from tests.conftest import read
|
||||||
|
return read(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_version() -> str:
|
||||||
|
"""Extract version from pyproject.toml."""
|
||||||
|
from tests.conftest import get_version
|
||||||
|
return get_version()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dry_run(*args: str) -> tuple[int, str]:
|
||||||
|
"""Run the release script in dry-run mode and return (exit_code, stdout)."""
|
||||||
|
cmd = ["bash", str(RELEASE_SCRIPT), "--dry-run"] + list(args)
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=30,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
return r.returncode, r.stdout + r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _deb_filename(version: str, release: int = 1) -> str:
|
||||||
|
"""Expected deb filename for a given version and release."""
|
||||||
|
return f"fenris_{version}_amd64.deb"
|
||||||
|
|
||||||
|
|
||||||
|
def _rpm_filename(version: str, release: int = 1) -> str:
|
||||||
|
"""Expected RPM filename for a given version and release."""
|
||||||
|
return f"fenris-{version}-{release}.x86_64.rpm"
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_upload_deb_url(version: str) -> str:
|
||||||
|
"""Expected registry upload URL for a deb package."""
|
||||||
|
return f"debian/pool/bookworm/main/upload"
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_upload_rpm_url() -> str:
|
||||||
|
"""Expected registry upload URL for an RPM package."""
|
||||||
|
return "rpm/fenris/upload"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — release script existence and permissions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestReleaseScriptExists:
|
||||||
|
"""Verify the release script is present and executable."""
|
||||||
|
|
||||||
|
def test_script_exists(self):
|
||||||
|
assert RELEASE_SCRIPT.exists(), \
|
||||||
|
"scripts/release.sh must exist"
|
||||||
|
|
||||||
|
def test_script_is_executable(self):
|
||||||
|
assert RELEASE_SCRIPT.stat().st_mode & 0o111, \
|
||||||
|
"scripts/release.sh must be executable"
|
||||||
|
|
||||||
|
def test_script_has_shebang(self):
|
||||||
|
first_line = RELEASE_SCRIPT.read_text().splitlines()[0]
|
||||||
|
assert first_line.startswith("#!/"), \
|
||||||
|
"scripts/release.sh must have a shebang"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — dry-run prints all expected commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDryRunCommandPrintout:
|
||||||
|
"""Verify dry-run prints every command that would execute."""
|
||||||
|
|
||||||
|
def test_dry_run_exits_zero(self):
|
||||||
|
rc, _ = _run_dry_run()
|
||||||
|
assert rc == 0, "Dry-run must exit zero"
|
||||||
|
|
||||||
|
def test_dry_run_prints_make_package(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "make" in output.lower() and "package" in output.lower(), \
|
||||||
|
"Dry-run must print the make package command"
|
||||||
|
|
||||||
|
def test_dry_run_prints_rpm_signing(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "rpmsign" in output or "sign" in output.lower(), \
|
||||||
|
"Dry-run must print RPM signing step"
|
||||||
|
|
||||||
|
def test_dry_run_prints_sha256sums(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "sha256sum" in output, \
|
||||||
|
"Dry-run must print SHA256SUMS generation"
|
||||||
|
|
||||||
|
def test_dry_run_prints_clearsign(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "clearsign" in output or "SHA256SUMS.asc" in output, \
|
||||||
|
"Dry-run must print clearsign step"
|
||||||
|
|
||||||
|
def test_dry_run_prints_deb_upload(self):
|
||||||
|
version = _get_version()
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert _registry_upload_deb_url(version) in output, \
|
||||||
|
f"Dry-run must print deb upload URL ({_registry_upload_deb_url(version)})"
|
||||||
|
|
||||||
|
def test_dry_run_prints_deb_upload_for_all_codenames(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
for codename in ("bookworm", "jammy", "noble"):
|
||||||
|
assert codename in output, \
|
||||||
|
f"Dry-run must include upload for {codename}"
|
||||||
|
|
||||||
|
def test_dry_run_prints_rpm_upload(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert _registry_upload_rpm_url() in output, \
|
||||||
|
f"Dry-run must print RPM upload URL ({_registry_upload_rpm_url()})"
|
||||||
|
|
||||||
|
def test_dry_run_prints_release_creation(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "release" in output.lower(), \
|
||||||
|
"Dry-run must print release creation step"
|
||||||
|
|
||||||
|
def test_dry_run_prints_attachment_upload(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "SHA256SUMS.asc" in output, \
|
||||||
|
"Dry-run must print SHA256SUMS.asc attachment upload"
|
||||||
|
|
||||||
|
def test_dry_run_prints_tag_push(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
# The tag is created atomically by the Gitea release API (step 5),
|
||||||
|
# not by a separate git push. Verify the release creation step is present.
|
||||||
|
assert "tag_name" in output or "release" in output.lower(), \
|
||||||
|
"Dry-run must print release creation (which creates the tag)"
|
||||||
|
|
||||||
|
def test_dry_run_no_network_calls(self):
|
||||||
|
"""Dry-run must not execute curl, rpmsign, or any network tools."""
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
# The dry-run mode prints a marker at the top; all commands are
|
||||||
|
# echoed (prefixed by spaces) but never executed. Verify the
|
||||||
|
# marker is present, confirming we're in dry-run mode.
|
||||||
|
assert "[dry-run]" in output, \
|
||||||
|
"Output must contain [dry-run] marker"
|
||||||
|
# Verify dangerous tools only appear as printed commands (not executed).
|
||||||
|
# Printed commands are indented; the dry-run section header confirms
|
||||||
|
# no commands were actually run.
|
||||||
|
assert "Commands below will be executed" in output, \
|
||||||
|
"Dry-run must indicate commands are for display only"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — dry-run prints correct package filenames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDryRunFilenames:
|
||||||
|
"""Verify dry-run uses the correct artifact filenames."""
|
||||||
|
|
||||||
|
def test_deb_filename_in_output(self):
|
||||||
|
version = _get_version()
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
expected = _deb_filename(version)
|
||||||
|
assert expected in output, \
|
||||||
|
f"Dry-run must reference deb filename {expected}"
|
||||||
|
|
||||||
|
def test_rpm_filename_in_output(self):
|
||||||
|
version = _get_version()
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
expected = _rpm_filename(version)
|
||||||
|
assert expected in output, \
|
||||||
|
f"Dry-run must reference RPM filename {expected}"
|
||||||
|
|
||||||
|
def test_checksums_filename_in_output(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
assert "SHA256SUMS" in output, \
|
||||||
|
"Dry-run must reference SHA256SUMS filename"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — dry-run does not create artifacts or tags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDryRunNoSideEffects:
|
||||||
|
"""Verify dry-run creates no filesystem or git side effects."""
|
||||||
|
|
||||||
|
def test_dry_run_no_git_tag_created(self):
|
||||||
|
version = _get_version()
|
||||||
|
tag = f"v{version}"
|
||||||
|
# Ensure tag doesn't exist before
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "tag", "-l", tag], capture_output=True, text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
pre_tags = r.stdout.strip()
|
||||||
|
|
||||||
|
_run_dry_run()
|
||||||
|
|
||||||
|
# Verify tag was not created
|
||||||
|
r = subprocess.run(
|
||||||
|
["git", "tag", "-l", tag], capture_output=True, text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
post_tags = r.stdout.strip()
|
||||||
|
assert pre_tags == post_tags, \
|
||||||
|
f"Dry-run must not create git tag {tag}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — revision bumping (structural: output contains incremented release)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRevisionBumping:
|
||||||
|
"""Verify the release script handles revision bumping.
|
||||||
|
|
||||||
|
When a version already exists in the registry (HTTP 409), the script
|
||||||
|
bumps the revision and retries. These tests verify the dry-run output
|
||||||
|
reflects the correct revision logic — without any network access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_dry_run_starts_at_revision_one(self):
|
||||||
|
version = _get_version()
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
rpm_expected = _rpm_filename(version, 1)
|
||||||
|
assert rpm_expected in output, \
|
||||||
|
f"Dry-run must start at release 1: expected {rpm_expected} in output"
|
||||||
|
|
||||||
|
def test_revision_bump_changes_rpm_filename(self):
|
||||||
|
"""When revision is bumped, the RPM filename changes accordingly."""
|
||||||
|
version = _get_version()
|
||||||
|
rpm_r1 = _rpm_filename(version, 1)
|
||||||
|
rpm_r2 = _rpm_filename(version, 2)
|
||||||
|
# R2 filename must differ from R1
|
||||||
|
assert rpm_r1 != rpm_r2, \
|
||||||
|
"R2 filename must differ from R1"
|
||||||
|
# Both must contain the version
|
||||||
|
assert version in rpm_r1
|
||||||
|
assert version in rpm_r2
|
||||||
|
|
||||||
|
def test_revision_bump_changes_deb_filename(self):
|
||||||
|
"""When revision is bumped, the deb filename also changes."""
|
||||||
|
version = _get_version()
|
||||||
|
# Deb filename includes release in nfpm naming
|
||||||
|
deb_r1 = f"fenris_{version}_amd64.deb"
|
||||||
|
deb_r2 = f"fenris_{version}_amd64.deb"
|
||||||
|
# For deb, the filename doesn't change with revision (deb uses epoch)
|
||||||
|
# But the RPM does — this verifies we test RPM revision correctly
|
||||||
|
rpm_r1 = _rpm_filename(version, 1)
|
||||||
|
rpm_r2 = _rpm_filename(version, 2)
|
||||||
|
assert "-1." in rpm_r1, "R1 RPM must contain -1."
|
||||||
|
assert "-2." in rpm_r2, "R2 RPM must contain -2."
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — bare tag prevention (structural)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBareTagPrevention:
|
||||||
|
"""Verify the flow prevents bare tags.
|
||||||
|
|
||||||
|
A bare tag (tag without packages, release entry, notes, and checksums)
|
||||||
|
must not result from the flow. The script checks for existing bare
|
||||||
|
tags before proceeding. These tests verify the dry-run doesn't create
|
||||||
|
any tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_dry_run_does_not_push_tag(self):
|
||||||
|
_, output = _run_dry_run()
|
||||||
|
# The dry-run marker confirms no commands are executed.
|
||||||
|
# git push appears only as a printed command, never executed.
|
||||||
|
assert "[dry-run]" in output, \
|
||||||
|
"Must be in dry-run mode"
|
||||||
|
# Tag push is printed but the [dry-run] marker confirms nothing ran
|
||||||
|
assert "Commands below will be executed" in output, \
|
||||||
|
"Dry-run must indicate commands are for display only"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — CI workflow file
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCIWorkflow:
|
||||||
|
"""Verify the dormant CI workflow is present and correctly structured."""
|
||||||
|
|
||||||
|
def test_workflow_file_exists(self):
|
||||||
|
path = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
||||||
|
assert path.exists(), \
|
||||||
|
".gitea/workflows/release.yml must exist"
|
||||||
|
|
||||||
|
def test_workflow_triggers_on_tags(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "v*" in content, \
|
||||||
|
"Workflow must trigger on version tags (v*)"
|
||||||
|
|
||||||
|
def test_workflow_has_release_step(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "release" in content.lower(), \
|
||||||
|
"Workflow must have a release step"
|
||||||
|
|
||||||
|
def test_workflow_mentions_signing(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "sign" in content.lower(), \
|
||||||
|
"Workflow must include signing step"
|
||||||
|
|
||||||
|
def test_workflow_mentions_upload(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "upload" in content.lower() or "publish" in content.lower(), \
|
||||||
|
"Workflow must include upload/publish step"
|
||||||
|
|
||||||
|
def test_workflow_validates_notes_before_publication(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "scripts/extract_changelog.py" in content, \
|
||||||
|
"Workflow must fail before publication if release notes cannot be extracted"
|
||||||
|
assert "--footer packaging/release-footer.md" in content, \
|
||||||
|
"Workflow must assemble the body from the standing release footer"
|
||||||
|
|
||||||
|
def test_workflow_resynchronizes_existing_release_bodies(self):
|
||||||
|
content = _read(".gitea/workflows/release.yml")
|
||||||
|
assert "scripts/release_request.py" in content, \
|
||||||
|
"Workflow must make the create-versus-update decision through the request seam"
|
||||||
|
assert '"${METHOD}"' in content, \
|
||||||
|
"Workflow must execute the helper-selected create-or-update request"
|
||||||
|
assert 'RELEASE_PATH="$(printf' in content and '\n PATH="$(printf' not in content, \
|
||||||
|
"Workflow must not overwrite the shell PATH while preparing the request URL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_points_consumers_to_release_notes():
|
||||||
|
readme = _read("README.md")
|
||||||
|
|
||||||
|
assert "Per-release notes live on the [releases page]" in readme
|
||||||
|
assert "standing install and verification instructions" in readme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — Makefile release targets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMakefileReleaseTargets:
|
||||||
|
"""Verify the Makefile exposes release-related targets."""
|
||||||
|
|
||||||
|
def _makefile_content(self) -> str:
|
||||||
|
return _read("Makefile")
|
||||||
|
|
||||||
|
def test_release_run_target_exists(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "release-run:" in content, \
|
||||||
|
"Makefile must have a release-run target"
|
||||||
|
|
||||||
|
def test_release_dry_run_target_exists(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "release-dry-run:" in content, \
|
||||||
|
"Makefile must have a release-dry-run target"
|
||||||
|
|
||||||
|
def test_release_run_calls_script(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "release.sh" in content, \
|
||||||
|
"release-run target must call scripts/release.sh"
|
||||||
|
|
||||||
|
def test_release_dry_run_uses_dry_run_flag(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
# Find the release-dry-run target and verify it passes --dry-run
|
||||||
|
in_target = False
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith("release-dry-run:"):
|
||||||
|
in_target = True
|
||||||
|
continue
|
||||||
|
if in_target and line.strip():
|
||||||
|
if "--dry-run" in line:
|
||||||
|
break
|
||||||
|
if not line.startswith("\t"):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pytest.fail("release-dry-run target must pass --dry-run to release.sh")
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
"""Signing and consumer-repo structural tests (issue #51).
|
||||||
|
|
||||||
|
Verifies that the signing infrastructure, consumer setup docs, and key
|
||||||
|
publication are correctly wired — without requiring a real GPG key,
|
||||||
|
network access, or Docker.
|
||||||
|
|
||||||
|
All assertions are structural: config keys exist, URLs match, docs
|
||||||
|
are present, and the Makefile exposes the right targets. A throwaway
|
||||||
|
test key exercise is included for rpm signature verification mechanics.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read(path: str | Path) -> str:
|
||||||
|
from tests.conftest import read
|
||||||
|
return read(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _gpg_available() -> bool:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["gpg", "--version"], capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rpmsign_available() -> bool:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["rpmsign", "--version"], capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
return r.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — nfpm.yaml signing configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestNfpmSigningConfig:
|
||||||
|
"""Verify that nfpm.yaml is correctly configured for RPM builds.
|
||||||
|
|
||||||
|
Note: RPM signing is done via rpmsign post-build (make sign-rpm),
|
||||||
|
not through nfpm's built-in signing. This keeps the build unsigned
|
||||||
|
and the signing step explicit and key-controlled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_rpm_overrides_exist(self):
|
||||||
|
"""nfpm.yaml must have overrides.rpm for per-format deltas."""
|
||||||
|
content = _read("packaging/nfpm.yaml")
|
||||||
|
assert "overrides:" in content, "overrides section missing from nfpm.yaml"
|
||||||
|
assert "rpm:" in content, "rpm overrides missing from nfpm.yaml"
|
||||||
|
|
||||||
|
def test_rpm_scripts_configured(self):
|
||||||
|
"""RPM must use the dedicated scriptlets, not deb scripts."""
|
||||||
|
content = _read("packaging/nfpm.yaml")
|
||||||
|
assert "packaging/rpm/post.sh" in content, \
|
||||||
|
"RPM postinstall must use rpm/post.sh"
|
||||||
|
assert "packaging/rpm/preun.sh" in content, \
|
||||||
|
"RPM preremove must use rpm/preun.sh"
|
||||||
|
assert "packaging/rpm/postun.sh" in content, \
|
||||||
|
"RPM postremove must use rpm/postun.sh"
|
||||||
|
|
||||||
|
def test_rpm_depends_use_correct_syntax(self):
|
||||||
|
"""RPM dependencies must use rpm-style version syntax."""
|
||||||
|
content = _read("packaging/nfpm.yaml")
|
||||||
|
assert "python3 >= 3.10" in content, \
|
||||||
|
"RPM depends must use rpm-style version constraint"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — Makefile signing targets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMakefileSigningTargets:
|
||||||
|
"""Verify that the Makefile exposes signing-related targets."""
|
||||||
|
|
||||||
|
def _makefile_content(self) -> str:
|
||||||
|
return _read("Makefile")
|
||||||
|
|
||||||
|
def test_generate_test_key_target(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "generate-test-key:" in content, \
|
||||||
|
"Makefile must have a generate-test-key target"
|
||||||
|
assert "packaging-test@bongbetic.com" in content or \
|
||||||
|
"packaging@bongbetic.com" in content, \
|
||||||
|
"generate-test-key must reference the packaging key UID"
|
||||||
|
|
||||||
|
def test_sign_rpm_target(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "sign-rpm:" in content, \
|
||||||
|
"Makefile must have a sign-rpm target"
|
||||||
|
assert "rpmsign" in content, \
|
||||||
|
"sign-rpm target must use rpmsign"
|
||||||
|
|
||||||
|
def test_checksums_target(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "checksums:" in content, \
|
||||||
|
"Makefile must have a checksums target"
|
||||||
|
assert "sha256sum" in content, \
|
||||||
|
"checksums target must use sha256sum"
|
||||||
|
|
||||||
|
def test_clearsign_target(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "clearsign:" in content, \
|
||||||
|
"Makefile must have a clearsign target"
|
||||||
|
assert "--clearsign" in content, \
|
||||||
|
"clearsign target must use gpg --clearsign"
|
||||||
|
|
||||||
|
def test_release_depends_on_signing(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
for line in content.splitlines():
|
||||||
|
if line.startswith("release:"):
|
||||||
|
deps = line.split(":", 1)[1].strip()
|
||||||
|
assert "sign-rpm" in deps, \
|
||||||
|
"release target must depend on sign-rpm"
|
||||||
|
assert "clearsign" in deps, \
|
||||||
|
"release target must depend on clearsign"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pytest.fail("release target not found in Makefile")
|
||||||
|
|
||||||
|
def test_packaging_key_uid_defined(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "PACKAGING_KEY" in content, \
|
||||||
|
"Makefile must define PACKAGING_KEY variable"
|
||||||
|
|
||||||
|
def test_release_mentions_key_ceremony(self):
|
||||||
|
content = self._makefile_content()
|
||||||
|
assert "signing-key-ceremony.md" in content, \
|
||||||
|
"release target must reference the key ceremony doc"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — fenris.repo configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFenrisRepo:
|
||||||
|
"""Verify the dnf repo file is correctly configured for Fenris."""
|
||||||
|
|
||||||
|
def _repo_content(self) -> str:
|
||||||
|
return _read("packaging/fenris.repo")
|
||||||
|
|
||||||
|
def test_gpgcheck_enabled(self):
|
||||||
|
content = self._repo_content()
|
||||||
|
assert "gpgcheck=1" in content, \
|
||||||
|
"fenris.repo must set gpgcheck=1 for payload verification"
|
||||||
|
|
||||||
|
def test_repo_gpgcheck_disabled(self):
|
||||||
|
content = self._repo_content()
|
||||||
|
assert "repo_gpgcheck=0" in content, \
|
||||||
|
"fenris.repo must set repo_gpgcheck=0 (metadata check via TLS)"
|
||||||
|
|
||||||
|
def test_gpgkey_points_to_packaging_key(self):
|
||||||
|
content = self._repo_content()
|
||||||
|
assert "gpgkey=" in content, \
|
||||||
|
"fenris.repo must have a gpgkey directive"
|
||||||
|
assert "fenris-packaging.asc" in content, \
|
||||||
|
"gpgkey must point at the packaging key"
|
||||||
|
assert "raw/branch/main" in content, \
|
||||||
|
"gpgkey must use raw URL for the public key"
|
||||||
|
|
||||||
|
def test_baseurl_is_fenris_rpm_group(self):
|
||||||
|
content = self._repo_content()
|
||||||
|
assert "rpm/fenris" in content, \
|
||||||
|
"baseurl must point at the fenris RPM group"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — public key publication
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestKeyPublication:
|
||||||
|
"""Verify the public key is published in-repo with correct metadata."""
|
||||||
|
|
||||||
|
def test_key_file_exists(self):
|
||||||
|
key_path = REPO_ROOT / "packaging" / "keys" / "fenris-packaging.asc"
|
||||||
|
assert key_path.exists(), \
|
||||||
|
"packaging/keys/fenris-packaging.asc must exist"
|
||||||
|
|
||||||
|
def test_key_file_has_raw_url(self):
|
||||||
|
content = _read("packaging/keys/fenris-packaging.asc")
|
||||||
|
raw_url = (
|
||||||
|
"https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/"
|
||||||
|
"packaging/keys/fenris-packaging.asc"
|
||||||
|
)
|
||||||
|
assert raw_url in content, \
|
||||||
|
"Key file must contain its own raw URL as documentation"
|
||||||
|
|
||||||
|
def test_key_file_documents_algorithm(self):
|
||||||
|
content = _read("packaging/keys/fenris-packaging.asc")
|
||||||
|
assert "RSA 3072" in content or "rsa3072" in content.lower(), \
|
||||||
|
"Key file must document the algorithm as RSA 3072"
|
||||||
|
|
||||||
|
def test_key_file_documents_uid(self):
|
||||||
|
content = _read("packaging/keys/fenris-packaging.asc")
|
||||||
|
assert "Fenris Packaging" in content, \
|
||||||
|
"Key file must document the UID"
|
||||||
|
|
||||||
|
def test_key_file_documents_expiry(self):
|
||||||
|
content = _read("packaging/keys/fenris-packaging.asc")
|
||||||
|
assert "2 year" in content or "2-year" in content or "expiry" in content.lower(), \
|
||||||
|
"Key file must document the expiry policy"
|
||||||
|
|
||||||
|
def test_key_file_references_ceremony_doc(self):
|
||||||
|
content = _read("packaging/keys/fenris-packaging.asc")
|
||||||
|
assert "signing-key-ceremony.md" in content, \
|
||||||
|
"Key file must reference the key ceremony document"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — key ceremony documentation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestKeyCeremonyDoc:
|
||||||
|
"""Verify the key ceremony document is complete and accurate."""
|
||||||
|
|
||||||
|
def _doc_content(self) -> str:
|
||||||
|
return _read("docs/install/signing-key-ceremony.md")
|
||||||
|
|
||||||
|
def test_ceremony_doc_exists(self):
|
||||||
|
assert (REPO_ROOT / "docs" / "install" / "signing-key-ceremony.md").exists(), \
|
||||||
|
"docs/install/signing-key-ceremony.md must exist"
|
||||||
|
|
||||||
|
def test_documents_key_specification(self):
|
||||||
|
content = self._doc_content()
|
||||||
|
assert "RSA 3072" in content, "Must document RSA 3072 algorithm"
|
||||||
|
assert "Fenris Packaging" in content, "Must document the UID"
|
||||||
|
assert "packaging@bongbetic.com" in content, "Must document the email"
|
||||||
|
|
||||||
|
def test_documents_import_sign_delete(self):
|
||||||
|
content = self._doc_content()
|
||||||
|
assert "import" in content.lower(), "Must document import step"
|
||||||
|
assert "sign" in content.lower(), "Must document sign step"
|
||||||
|
assert "delete" in content.lower(), "Must document delete step"
|
||||||
|
|
||||||
|
def test_documents_rotation_outline(self):
|
||||||
|
content = self._doc_content()
|
||||||
|
assert "rotation" in content.lower(), \
|
||||||
|
"Must document key rotation procedure"
|
||||||
|
|
||||||
|
def test_documents_dual_key_approach(self):
|
||||||
|
content = self._doc_content()
|
||||||
|
assert "previous" in content.lower() or "old" in content.lower(), \
|
||||||
|
"Must document old key retention during rotation"
|
||||||
|
|
||||||
|
def test_documents_private_key_storage(self):
|
||||||
|
content = self._doc_content()
|
||||||
|
assert "password manager" in content.lower(), \
|
||||||
|
"Must document that private key lives in password manager"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — consumer setup documentation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConsumerDocs:
|
||||||
|
"""Verify consumer setup docs are present and correctly wired."""
|
||||||
|
|
||||||
|
def _readme_content(self) -> str:
|
||||||
|
return _read("README.md")
|
||||||
|
|
||||||
|
def test_apt_signed_by_flow(self):
|
||||||
|
content = self._readme_content()
|
||||||
|
assert "signed-by" in content, \
|
||||||
|
"README must document apt signed-by keyring flow"
|
||||||
|
assert "keyrings" in content, \
|
||||||
|
"README must show the keyrings directory"
|
||||||
|
|
||||||
|
def test_apt_fingerprint_placeholder(self):
|
||||||
|
content = self._readme_content()
|
||||||
|
assert "Fingerprint" in content or "fingerprint" in content, \
|
||||||
|
"README must include fingerprint placeholder for TOFU hardening"
|
||||||
|
|
||||||
|
def test_dnf_repo_flow(self):
|
||||||
|
content = self._readme_content()
|
||||||
|
assert "dnf config-manager --add-repo" in content or \
|
||||||
|
"dnf install" in content, \
|
||||||
|
"README must document dnf install flow"
|
||||||
|
assert "fenris.repo" in content, \
|
||||||
|
"README must reference the Fenris-owned repo file"
|
||||||
|
|
||||||
|
def test_no_gitea_auto_repo(self):
|
||||||
|
"""Gitea's auto-generated .repo must never be referenced in docs."""
|
||||||
|
content = self._readme_content()
|
||||||
|
# The Gitea auto-generated repo would have gpgcheck=1 against the
|
||||||
|
# instance key, which is a trap. Our docs should only reference
|
||||||
|
# our own fenris.repo file.
|
||||||
|
assert "auto-generated" not in content.lower() or \
|
||||||
|
"never" in content.lower(), \
|
||||||
|
"README must not recommend Gitea's auto-generated .repo"
|
||||||
|
|
||||||
|
def test_package_signature_verification(self):
|
||||||
|
content = self._readme_content()
|
||||||
|
assert "rpm -K" in content or "rpm --checksig" in content, \
|
||||||
|
"README must document RPM signature verification"
|
||||||
|
assert "gpg --verify" in content, \
|
||||||
|
"README must document GPG verification for SHA256SUMS"
|
||||||
|
|
||||||
|
def test_migration_from_make_install(self):
|
||||||
|
content = self._readme_content()
|
||||||
|
assert "migrate-from-makeinstall" in content.lower() or \
|
||||||
|
"migration" in content.lower(), \
|
||||||
|
"README must reference the migration runbook"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — release spec references
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestReleaseSpecReferences:
|
||||||
|
"""Verify the release spec references the ceremony doc and nfpm config."""
|
||||||
|
|
||||||
|
def _spec_content(self) -> str:
|
||||||
|
return _read("docs/spec/release-packaging.md")
|
||||||
|
|
||||||
|
def test_spec_references_rpmsign(self):
|
||||||
|
content = self._spec_content()
|
||||||
|
assert "rpmsign" in content.lower() or "sign-rpm" in content, \
|
||||||
|
"Spec must reference rpmsign or make sign-rpm for RPM signing"
|
||||||
|
|
||||||
|
def test_spec_references_ceremony_doc(self):
|
||||||
|
content = self._spec_content()
|
||||||
|
assert "signing-key-ceremony.md" in content, \
|
||||||
|
"Spec must reference the key ceremony document"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — RPM signature mechanics (throwaway test key, no network)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRpmSignatureMechanics:
|
||||||
|
"""Verify RPM signing mechanics using a throwaway test key.
|
||||||
|
|
||||||
|
These tests generate a temporary GPG key, build an RPM (or use an
|
||||||
|
existing one), sign it, and verify the signature — all without
|
||||||
|
network access. They require gpg and rpmsign to be available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _gpg_available() or not _rpmsign_available(),
|
||||||
|
reason="gpg or rpmsign not available",
|
||||||
|
)
|
||||||
|
def test_throwaway_key_signs_and_verifies(self):
|
||||||
|
"""Generate a throwaway key, sign a test RPM, verify signature."""
|
||||||
|
# Find existing RPM
|
||||||
|
version = None
|
||||||
|
for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines():
|
||||||
|
if line.startswith("version"):
|
||||||
|
version = line.split("=")[1].strip().strip('"')
|
||||||
|
break
|
||||||
|
rpm_path = REPO_ROOT / "dist" / f"fenris-{version}-1.x86_64.rpm"
|
||||||
|
if not rpm_path.exists():
|
||||||
|
pytest.skip("RPM not built — run `make package-rpm` first")
|
||||||
|
|
||||||
|
key_uid = "fenris-test-signing@example.com"
|
||||||
|
try:
|
||||||
|
# Generate throwaway key
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--gen-key"],
|
||||||
|
input=f"""%no-protection
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 3072
|
||||||
|
Name-Real: {key_uid}
|
||||||
|
Name-Email: {key_uid}
|
||||||
|
Expire-Date: 0
|
||||||
|
%commit
|
||||||
|
""",
|
||||||
|
text=True, check=True, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy RPM to temp dir for signing
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
signed_rpm = Path(tmpdir) / rpm_path.name
|
||||||
|
signed_rpm.write_bytes(rpm_path.read_bytes())
|
||||||
|
|
||||||
|
# Sign the RPM
|
||||||
|
subprocess.run(
|
||||||
|
["rpmsign", "--addsign",
|
||||||
|
"--define", f"_gpg_name {key_uid}",
|
||||||
|
str(signed_rpm)],
|
||||||
|
check=True, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the signature exists and has correct format
|
||||||
|
# (rpm -Kv returns NOKEY if key isn't imported, but the
|
||||||
|
# signature header is still present and verifiable)
|
||||||
|
r = subprocess.run(
|
||||||
|
["rpm", "-Kv", str(signed_rpm)],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
output = r.stdout + r.stderr
|
||||||
|
assert "RSA" in output or "rsa" in output.lower(), \
|
||||||
|
f"RPM must have RSA signature: {output}"
|
||||||
|
assert "SHA256" in output or "sha256" in output.lower(), \
|
||||||
|
f"RPM must have SHA256 digest: {output}"
|
||||||
|
assert "Header V4" in output or "Header" in output, \
|
||||||
|
f"RPM must have V4 signature header: {output}"
|
||||||
|
assert "Signature" in output, \
|
||||||
|
f"RPM must show signature info: {output}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up the test key
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _gpg_available(),
|
||||||
|
reason="gpg not available",
|
||||||
|
)
|
||||||
|
def test_clearsign_and_verify(self):
|
||||||
|
"""Clearsign a test manifest and verify the signature."""
|
||||||
|
key_uid = "fenris-test-clearsign@example.com"
|
||||||
|
try:
|
||||||
|
# Generate throwaway key
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--gen-key"],
|
||||||
|
input=f"""%no-protection
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 3072
|
||||||
|
Name-Real: {key_uid}
|
||||||
|
Name-Email: {key_uid}
|
||||||
|
Expire-Date: 0
|
||||||
|
%commit
|
||||||
|
""",
|
||||||
|
text=True, check=True, timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sums = Path(tmpdir) / "SHA256SUMS"
|
||||||
|
sums.write_text(
|
||||||
|
"abc123 fenris_0.3.0_amd64.deb\n"
|
||||||
|
"def456 fenris-0.3.0-1.x86_64.rpm\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clearsign
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--yes", "--clearsign",
|
||||||
|
"--local-user", key_uid, str(sums)],
|
||||||
|
check=True, timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify (clearsigned file — just one argument to --verify)
|
||||||
|
r = subprocess.run(
|
||||||
|
["gpg", "--verify", str(sums.with_suffix(".asc"))],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
assert r.returncode == 0, \
|
||||||
|
f"Clearsign verification failed: {r.stderr}"
|
||||||
|
assert "Good signature" in r.stderr, \
|
||||||
|
f"Expected Good signature: {r.stderr}"
|
||||||
|
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
|
||||||
|
capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
@@ -415,6 +415,131 @@ class TestServiceFacts:
|
|||||||
assert "last collect:" in result
|
assert "last collect:" in result
|
||||||
assert "freshness:" in result
|
assert "freshness:" in result
|
||||||
|
|
||||||
|
def test_continuity_reports_boot_enabled_independently_of_runtime(self, tmp_path):
|
||||||
|
"""Status names reboot continuity while retaining the timer fact."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
init_store(db)
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
assert "monitoring: active in background · persists across reboots" in result
|
||||||
|
assert "timer: inactive" in result
|
||||||
|
|
||||||
|
def test_continuity_and_deliberate_pause_are_reported_separately(self, tmp_path):
|
||||||
|
"""Only a sanctioned user_disabled period renders the paused wording."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
assert "monitoring: does not start on next boot" in result
|
||||||
|
assert "monitoring: paused — deliberate disable" in result
|
||||||
|
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in result
|
||||||
|
|
||||||
|
def test_raw_system_state_without_user_disabled_is_not_a_deliberate_pause(self, tmp_path):
|
||||||
|
"""A non-sanctioned stop never acquires the deliberate-disable label."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "migrated"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
assert "monitoring: paused — deliberate disable" not in result
|
||||||
|
|
||||||
|
def test_resumed_open_period_clears_a_previous_deliberate_pause(self, tmp_path):
|
||||||
|
"""A later sanctioned resume takes precedence over an older pause."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
||||||
|
("2026-09-01T11:00:00+00:00",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
assert "monitoring: paused — deliberate disable" not in result
|
||||||
|
|
||||||
|
def test_live_enabled_service_suppresses_a_stale_pause_marker(self, tmp_path):
|
||||||
|
"""A raw re-enable cannot leave a contradictory paused presentation."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
assert "monitoring: paused — deliberate disable" not in result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Status output structure (§8.8, LC-9)
|
# Status output structure (§8.8, LC-9)
|
||||||
@@ -441,6 +566,29 @@ class TestStatusOutput:
|
|||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_status_excludes_tui_identity_and_auth_notice(self, tmp_path):
|
||||||
|
"""CLI status never renders TUI-only identity or launch guidance."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
init_store(db)
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=True, query_journal=False)
|
||||||
|
|
||||||
|
for tui_only in (
|
||||||
|
"Fenris — NVMe endurance monitor",
|
||||||
|
"by Bongbetic",
|
||||||
|
"privileged actions will prompt for authentication (polkit)",
|
||||||
|
):
|
||||||
|
assert tui_only not in result
|
||||||
|
|
||||||
def test_status_never_writes(self, tmp_path):
|
def test_status_never_writes(self, tmp_path):
|
||||||
"""Status never writes to the store."""
|
"""Status never writes to the store."""
|
||||||
from fenris.status import get_status
|
from fenris.status import get_status
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Store path resolution from config — regression coverage for issue #53.
|
||||||
|
|
||||||
|
A fresh install ships a placeholder-commented config whose only required
|
||||||
|
key is the device selector. The collector must not crash with
|
||||||
|
KeyError 'store_path' when the key is absent.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import DEFAULT_STORE_PATH, get_store_path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TEMPLATE = REPO_ROOT / "packaging" / "fenris.conf"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_like_load_config(text: str) -> dict:
|
||||||
|
"""Mirror collect.load_config()'s key=value parsing rules."""
|
||||||
|
config = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" in line:
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
config[key.strip()] = value.strip()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_store_path_falls_back_to_default():
|
||||||
|
"""Config with only the device selector resolves to the packaged default."""
|
||||||
|
assert get_store_path({"device": "/dev/nvme0n1"}) == DEFAULT_STORE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_store_path_wins():
|
||||||
|
"""An explicit store_path override is honored."""
|
||||||
|
assert get_store_path({"store_path": "/tmp/other.db"}) == Path("/tmp/other.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaged_template_yields_collectable_config():
|
||||||
|
"""The packaged template, once a device is set, must be collector-ready.
|
||||||
|
|
||||||
|
Reproduces the fresh-install path: parse packaging/fenris.conf the way
|
||||||
|
collect.load_config() does, add the device selector, then resolve the
|
||||||
|
store. Issue #53 made this raise KeyError.
|
||||||
|
"""
|
||||||
|
config = _parse_like_load_config(TEMPLATE.read_text())
|
||||||
|
config["device"] = "/dev/nvme0n1"
|
||||||
|
assert get_store_path(config) == DEFAULT_STORE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_documents_store_path():
|
||||||
|
"""The template must mention store_path so admins know it is overridable."""
|
||||||
|
assert "store_path" in TEMPLATE.read_text()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Group access to the observation store — regression coverage for issue #54.
|
||||||
|
|
||||||
|
Two defects: (1) a non-group user's stat() on the store directory raised
|
||||||
|
PermissionError straight through open_store_readonly(), crashing status/TUI
|
||||||
|
instead of degrading to the Store fault view; (2) even group members could
|
||||||
|
not open the WAL-mode store because root-created sidecars lacked group write
|
||||||
|
and the store directory lacked group execute-then-write.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import DEFAULT_STORE_PATH, init_store
|
||||||
|
from fenris.status import StoreFault, open_store_readonly
|
||||||
|
|
||||||
|
|
||||||
|
def test_stat_permission_error_becomes_store_fault(monkeypatch, tmp_path):
|
||||||
|
"""stat() denied (non-group user on a 2750 dir) → StoreFault, not crash."""
|
||||||
|
store = tmp_path / "observations.db"
|
||||||
|
store.write_bytes(b"")
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
def denied(self, follow_symlinks=True):
|
||||||
|
raise PermissionError(13, "Permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pathlib.Path, "exists", denied)
|
||||||
|
with pytest.raises(StoreFault):
|
||||||
|
open_store_readonly(store)
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_failure_becomes_store_fault(tmp_path):
|
||||||
|
"""sqlite failures stay wrapped as StoreFault (existing contract)."""
|
||||||
|
garbage = tmp_path / "observations.db"
|
||||||
|
garbage.write_bytes(b"not a database" * 100)
|
||||||
|
with pytest.raises(StoreFault):
|
||||||
|
open_store_readonly(garbage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_store_leaves_files_group_writable(tmp_path):
|
||||||
|
"""Root-created stores must stay readable by WAL readers: db and sidecars
|
||||||
|
need group write after init_store (issue #54)."""
|
||||||
|
store = tmp_path / "observations.db"
|
||||||
|
conn = init_store(store)
|
||||||
|
try:
|
||||||
|
assert (store.stat().st_mode & 0o060) == 0o060, "db not group rw"
|
||||||
|
wal = store.with_name(store.name + "-wal")
|
||||||
|
shm = store.with_name(store.name + "-shm")
|
||||||
|
if wal.exists():
|
||||||
|
assert (wal.stat().st_mode & 0o060) == 0o060, "wal not group rw"
|
||||||
|
if shm.exists():
|
||||||
|
assert (shm.stat().st_mode & 0o060) == 0o060, "shm not group rw"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_readonly_open_works_after_init_store(tmp_path):
|
||||||
|
"""The shipped read path opens a store created by init_store."""
|
||||||
|
store = tmp_path / "observations.db"
|
||||||
|
writer = init_store(store)
|
||||||
|
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-01-01T00:00:00+00:00')")
|
||||||
|
writer.commit()
|
||||||
|
conn = open_store_readonly(store)
|
||||||
|
assert conn is not None
|
||||||
|
conn.close()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_packaging_ships_group_access():
|
||||||
|
"""tmpfiles must create the store dir group-writable; collect unit must
|
||||||
|
keep the umask loose so root-created sidecars stay group-accessible."""
|
||||||
|
repo = Path(__file__).resolve().parent.parent
|
||||||
|
assert "2770" in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
|
||||||
|
assert "2750" not in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
|
||||||
|
assert "UMask=002" in (repo / "units" / "fenris-collect.service").read_text()
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Observation store migration unit tests (issue #48).
|
||||||
|
|
||||||
|
Tests the forward-only migration logic that underpins package upgrade
|
||||||
|
semantics: older stores are migrated, current stores pass through, and
|
||||||
|
newer stores are refused loudly.
|
||||||
|
|
||||||
|
Spec: §3.6, §9.5, §10.2
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import (
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
init_store,
|
||||||
|
migrate_to_latest,
|
||||||
|
)
|
||||||
|
from fenris.status import NewerSchema, open_store_readonly
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_store(path: Path, version: int = 0) -> sqlite3.Connection:
|
||||||
|
"""Create a store at *path* with the given user_version."""
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
if version == 0:
|
||||||
|
# Fresh DB with no schema — user_version defaults to 0
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Create a minimal schema so the DB is valid, then set version
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute(f"PRAGMA user_version={version}")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# migrate_to_latest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMigrateToLatest:
|
||||||
|
"""Forward-only migration via migrate_to_latest()."""
|
||||||
|
|
||||||
|
def test_migrates_from_zero(self, tmp_path):
|
||||||
|
"""Store at user_version=0 → SCHEMA_VERSION (fresh DB)."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=0)
|
||||||
|
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
|
||||||
|
# SCHEMA_VERSION - 0 = SCHEMA_VERSION migration steps
|
||||||
|
assert steps == SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Verify version was bumped
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
v = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert v == SCHEMA_VERSION
|
||||||
|
|
||||||
|
def test_already_current_returns_zero(self, tmp_path):
|
||||||
|
"""Store already at SCHEMA_VERSION → 0 steps applied."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
conn = _make_store(db, version=SCHEMA_VERSION)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
assert steps == 0
|
||||||
|
|
||||||
|
def test_refuses_newer_store(self, tmp_path):
|
||||||
|
"""Store with user_version > SCHEMA_VERSION → ValueError."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 1)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
migrate_to_latest(db)
|
||||||
|
|
||||||
|
def test_refuses_much_newer_store(self, tmp_path):
|
||||||
|
"""Store several versions ahead → ValueError."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 5)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
migrate_to_latest(db)
|
||||||
|
|
||||||
|
def test_store_not_corrupted_on_refusal(self, tmp_path):
|
||||||
|
"""After refusal, store is unchanged (no silent corruption)."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
migrate_to_latest(db)
|
||||||
|
|
||||||
|
# Version should be unchanged
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
v = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert v == SCHEMA_VERSION + 2
|
||||||
|
|
||||||
|
def test_idempotent_on_current(self, tmp_path):
|
||||||
|
"""Calling migrate_to_latest twice on a current store is safe."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION)
|
||||||
|
|
||||||
|
assert migrate_to_latest(db) == 0
|
||||||
|
assert migrate_to_latest(db) == 0
|
||||||
|
|
||||||
|
def test_migrates_intermediate_version(self, tmp_path):
|
||||||
|
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=1)
|
||||||
|
# SCHEMA_VERSION is 1, so version 1 is current
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
assert steps == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init_store — downgrade refusal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestInitStoreDowngradeRefusal:
|
||||||
|
"""init_store() refuses newer-schema stores."""
|
||||||
|
|
||||||
|
def test_refuses_newer_store(self, tmp_path):
|
||||||
|
"""init_store raises ValueError on newer-schema store."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 1)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
def test_store_not_corrupted_on_refusal(self, tmp_path):
|
||||||
|
"""After init_store refusal, store is unchanged."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 1)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
v = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert v == SCHEMA_VERSION + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# open_store_readonly — downgrade refusal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestOpenStoreReadonlyDowngradeRefusal:
|
||||||
|
"""open_store_readonly() raises NewerSchema on newer-schema stores."""
|
||||||
|
|
||||||
|
def test_raises_newer_schema(self, tmp_path):
|
||||||
|
"""Newer store → NewerSchema exception."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 1)
|
||||||
|
|
||||||
|
with pytest.raises(NewerSchema) as exc_info:
|
||||||
|
open_store_readonly(db)
|
||||||
|
|
||||||
|
assert exc_info.value.version == SCHEMA_VERSION + 1
|
||||||
|
|
||||||
|
def test_store_not_corrupted(self, tmp_path):
|
||||||
|
"""After NewerSchema refusal, store is unchanged."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION + 3)
|
||||||
|
|
||||||
|
with pytest.raises(NewerSchema):
|
||||||
|
open_store_readonly(db)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
v = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert v == SCHEMA_VERSION + 3
|
||||||
|
|
||||||
|
def test_current_store_opens(self, tmp_path):
|
||||||
|
"""Store at SCHEMA_VERSION opens without error."""
|
||||||
|
db = tmp_path / "observations.db"
|
||||||
|
_make_store(db, version=SCHEMA_VERSION)
|
||||||
|
|
||||||
|
conn = open_store_readonly(db)
|
||||||
|
assert conn is not None
|
||||||
|
conn.close()
|
||||||
+155
-1
@@ -10,6 +10,7 @@ Covers:
|
|||||||
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
|
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from xml.etree import ElementTree
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
@@ -384,6 +385,160 @@ class TestDenseScreen:
|
|||||||
assert "timer:" in strip
|
assert "timer:" in strip
|
||||||
assert "last collect:" in strip
|
assert "last collect:" in strip
|
||||||
assert "freshness:" in strip
|
assert "freshness:" in strip
|
||||||
|
assert "by Bongbetic" in strip
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||||||
|
"""The visible action footer excludes quit because the rail owns it."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
|
||||||
|
with patch("fenris.tui.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
async with app.run_test():
|
||||||
|
strip = str(app.query_one("#service-strip").render()).lower()
|
||||||
|
rail = str(app.query_one("#quit-rail").render())
|
||||||
|
usage = app.query_one("#usage-history")
|
||||||
|
service = app.query_one("#service-strip")
|
||||||
|
quit_rail = app.query_one("#quit-rail")
|
||||||
|
assert "continuity" in strip
|
||||||
|
assert "monitoring: active in background · persists across reboots" in strip
|
||||||
|
assert "p pause · r resume · c collect · d disclosures" in strip
|
||||||
|
assert "q quit" not in strip
|
||||||
|
assert rail == "q QUIT TUI"
|
||||||
|
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||||
|
assert usage.region.bottom <= service.region.y
|
||||||
|
assert service.region.bottom <= quit_rail.region.y
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deliberate_pause_banner_is_visible_and_quit_preserves_periods(self, tmp_path):
|
||||||
|
"""The Pilot sees the paused block; q leaves persisted monitoring state alone."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
|
||||||
|
with patch("fenris.tui.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||||
|
app, "_run_helper"
|
||||||
|
) as run_helper:
|
||||||
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
paused_banner = app.query_one("#paused-banner")
|
||||||
|
main_grid = app.query_one("#main-grid")
|
||||||
|
assert str(main_grid.styles.layout) == "<grid>"
|
||||||
|
assert main_grid.has_class("paused")
|
||||||
|
assert len(main_grid.styles.grid_rows) == 5
|
||||||
|
banner = str(paused_banner.render()).lower()
|
||||||
|
assert "monitoring: paused — deliberate disable" in banner
|
||||||
|
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||||
|
assert paused_banner.region.height >= 5
|
||||||
|
assert paused_banner.region.y < app.query_one("#usage-history").region.y
|
||||||
|
assert app.query_one("#usage-history").region.bottom <= app.query_one(
|
||||||
|
"#service-strip"
|
||||||
|
).region.y
|
||||||
|
screenshot = app.export_screenshot()
|
||||||
|
visible_text = " ".join(
|
||||||
|
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||||||
|
)
|
||||||
|
assert "paused time is excluded from your usage habit" in visible_text
|
||||||
|
assert "resume: fenris monitor resume" in visible_text
|
||||||
|
assert "monitoring: does not start on next boot" in str(
|
||||||
|
app.query_one("#service-strip").render()
|
||||||
|
).lower()
|
||||||
|
dashboard_scroll = app.query_one("#dashboard-scroll")
|
||||||
|
assert dashboard_scroll.max_scroll_y > 0
|
||||||
|
dashboard_scroll.focus()
|
||||||
|
await pilot.press("end")
|
||||||
|
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
|
||||||
|
footer_text = " ".join(
|
||||||
|
"".join(
|
||||||
|
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||||
|
).split()
|
||||||
|
)
|
||||||
|
assert "q QUIT TUI" in footer_text
|
||||||
|
await pilot.press("q")
|
||||||
|
assert not app.is_running
|
||||||
|
subprocess_run.assert_not_called()
|
||||||
|
run_helper.assert_not_called()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT ended_at, end_cause FROM monitoring_periods"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row == ("2026-09-01T10:00:00+00:00", "user_disabled")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quit_preserves_an_active_monitoring_period(self, tmp_path):
|
||||||
|
"""Quitting an active dashboard never closes or mutates its period."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
_open_period(conn, "2026-09-01T09:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
|
||||||
|
with patch("fenris.tui.query_service_state", return_value={
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||||
|
app, "_run_helper"
|
||||||
|
) as run_helper:
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("q")
|
||||||
|
assert not app.is_running
|
||||||
|
subprocess_run.assert_not_called()
|
||||||
|
run_helper.assert_not_called()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT started_at, ended_at, end_cause FROM monitoring_periods"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row == ("2026-09-01T09:00:00+00:00", None, None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
||||||
|
"""Identity is visible at launch; auth notice clears once per session."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||||
|
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "Fenris — NVMe endurance monitor" in headline
|
||||||
|
assert auth_notice in headline
|
||||||
|
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
|
||||||
|
|
||||||
|
await pilot.pause(0.25)
|
||||||
|
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||||
|
|
||||||
|
await pilot.pause(0.25)
|
||||||
|
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||||
|
|
||||||
|
fresh_app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with fresh_app.run_test():
|
||||||
|
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -524,4 +679,3 @@ class TestStateMatrixCombinations:
|
|||||||
# Incomplete provenance → UNVERIFIED tier
|
# Incomplete provenance → UNVERIFIED tier
|
||||||
assert proj.baseline_tier.value == "unverified_override"
|
assert proj.baseline_tier.value == "unverified_override"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ After=local-fs.target
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/libexec/fenris/fenris-collect
|
ExecStart=/usr/libexec/fenris/fenris-collect
|
||||||
TimeoutStartSec=90
|
TimeoutStartSec=90
|
||||||
|
UMask=002
|
||||||
|
|||||||
Reference in New Issue
Block a user