Compare commits
19
Commits
c91ca10df7
..
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512df2ae83 | ||
|
|
bcbc97a947 | ||
|
|
b593a2742e | ||
|
|
bdcd321f4c | ||
|
|
25ead13ab9 | ||
|
|
be9ce01ebf | ||
|
|
122c9f327e | ||
|
|
460dde4aad | ||
|
|
ba270d7812 | ||
|
|
2aed923043 | ||
|
|
230c686e82 | ||
|
|
38ecfc2093 | ||
|
|
f1ba8bdccc | ||
|
|
e794310a76 | ||
|
|
1e2ddfb928 | ||
|
|
120d80b28c | ||
|
|
d8fa6df072 | ||
|
|
c45b07003a | ||
|
|
1873886b2f |
+163
-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:
|
||||||
@@ -20,18 +27,161 @@ jobs:
|
|||||||
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: |
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
# Check if release already exists (idempotent re-runs)
|
||||||
|
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
||||||
|
if [ "$EXISTING" = "200" ]; then
|
||||||
|
echo "Release v${VERSION} already exists, skipping creation"
|
||||||
|
else
|
||||||
|
curl --fail -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- 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/
|
||||||
|
|||||||
@@ -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,7 +35,13 @@ 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 ──────────────────────────────────────────────────────
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -110,17 +213,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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+18
-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,30 @@ 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
|
||||||
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
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.0"
|
version = "0.3.2"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+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"
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+12
-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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
+641
-62
@@ -21,7 +21,6 @@ import pytest
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DIST_DIR = REPO_ROOT / "dist"
|
DIST_DIR = REPO_ROOT / "dist"
|
||||||
VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -29,10 +28,8 @@ VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
|||||||
|
|
||||||
def _get_version() -> str:
|
def _get_version() -> str:
|
||||||
"""Extract version from pyproject.toml."""
|
"""Extract version from pyproject.toml."""
|
||||||
for line in VERSION_FILE.read_text().splitlines():
|
from tests.conftest import get_version
|
||||||
if line.startswith("version"):
|
return get_version()
|
||||||
return line.split("=")[1].strip().strip('"')
|
|
||||||
raise RuntimeError("Could not determine version from pyproject.toml")
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_available() -> bool:
|
def _docker_available() -> bool:
|
||||||
@@ -81,6 +78,7 @@ DEB_TARGETS = [
|
|||||||
|
|
||||||
RPM_TARGETS = [
|
RPM_TARGETS = [
|
||||||
("fedora:40", "rpm"),
|
("fedora:40", "rpm"),
|
||||||
|
("opensuse/tumbleweed", "rpm"),
|
||||||
]
|
]
|
||||||
|
|
||||||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
|
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
|
||||||
@@ -104,11 +102,14 @@ def _build_deb_dockerfile(image: str, pkg_name: str) -> str:
|
|||||||
|
|
||||||
def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
|
def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
|
||||||
"""Dockerfile for testing rpm installation."""
|
"""Dockerfile for testing rpm installation."""
|
||||||
|
install_command = (
|
||||||
|
"zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
|
||||||
|
if image.startswith("opensuse/")
|
||||||
|
else "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
|
||||||
|
)
|
||||||
return textwrap.dedent(f"""\
|
return textwrap.dedent(f"""\
|
||||||
FROM {image}
|
FROM {image}
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN {install_command}
|
||||||
python3 smartmontools systemd dbus && \
|
|
||||||
dnf clean all
|
|
||||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||||
RUN rpm -ivh /pkg/{pkg_name}
|
RUN rpm -ivh /pkg/{pkg_name}
|
||||||
""")
|
""")
|
||||||
@@ -135,13 +136,16 @@ def skip_no_docker():
|
|||||||
|
|
||||||
def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
||||||
"""Assert the dormant-install contract (spec §6, §7)."""
|
"""Assert the dormant-install contract (spec §6, §7)."""
|
||||||
# Venv directory exists with expected structure
|
# Version-neutral vendored runtime exists. It must not contain a copied
|
||||||
|
# Python binary tied to the package build host.
|
||||||
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
|
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
|
||||||
assert "OK" in out, "Bundled venv not found at /opt/fenris"
|
assert "OK" in out, "Bundled runtime not found at /opt/fenris"
|
||||||
|
|
||||||
# Venv Python binary exists (may not execute if shared libs differ)
|
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
|
||||||
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
|
assert rc == 0, "Fenris runtime package not found"
|
||||||
assert rc == 0, "Venv Python binary not found"
|
|
||||||
|
rc, _ = _container_exec(container, "test ! -e /opt/fenris/bin/python3")
|
||||||
|
assert rc == 0, "Package must not ship a copied Python interpreter"
|
||||||
|
|
||||||
# Wrapper on PATH
|
# Wrapper on PATH
|
||||||
rc, out = _container_exec(container, "command -v fenris")
|
rc, out = _container_exec(container, "command -v fenris")
|
||||||
@@ -155,6 +159,11 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
|
|||||||
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
|
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
|
||||||
assert "OK" in out, f"Version {version} not found in wrapper script"
|
assert "OK" in out, f"Version {version} not found in wrapper script"
|
||||||
|
|
||||||
|
# This catches launcher import-path errors and copied-interpreter ABI
|
||||||
|
# breakage. Status is read-only and succeeds before monitoring setup.
|
||||||
|
rc, out = _container_exec(container, "fenris status")
|
||||||
|
assert rc == 0, f"Installed CLI cannot run: {out}"
|
||||||
|
|
||||||
# Helpers in /usr/libexec/fenris
|
# Helpers in /usr/libexec/fenris
|
||||||
for helper in ("fenris-monitor", "fenris-collect"):
|
for helper in ("fenris-monitor", "fenris-collect"):
|
||||||
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
|
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
|
||||||
@@ -287,8 +296,7 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
|
|||||||
4. Removal scripts are no-ops during upgrade
|
4. Removal scripts are no-ops during upgrade
|
||||||
5. Downgrade refusal via forward-only version check (tested at Python level)
|
5. Downgrade refusal via forward-only version check (tested at Python level)
|
||||||
"""
|
"""
|
||||||
# Create a fake observation store using system Python
|
# Create a fake observation store using the target system Python.
|
||||||
# (venv Python may not execute if host shared libs differ)
|
|
||||||
_container_exec(
|
_container_exec(
|
||||||
container,
|
container,
|
||||||
"mkdir -p /var/lib/fenris && "
|
"mkdir -p /var/lib/fenris && "
|
||||||
@@ -394,7 +402,7 @@ def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None:
|
|||||||
# Modify the config file (simulate hand-edited device selector)
|
# Modify the config file (simulate hand-edited device selector)
|
||||||
_container_exec(
|
_container_exec(
|
||||||
container,
|
container,
|
||||||
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
"echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
)
|
)
|
||||||
# Record original config hash
|
# Record original config hash
|
||||||
rc, original_hash = _container_exec(
|
rc, original_hash = _container_exec(
|
||||||
@@ -418,36 +426,115 @@ def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assert_removal_semantics(container: str, fmt: str) -> None:
|
def _setup_store_and_config(container: str) -> None:
|
||||||
"""Assert removal mapping (spec §7)."""
|
"""Plant observation store and config for removal semantics testing."""
|
||||||
if fmt == "deb":
|
_container_exec(
|
||||||
# remove (not purge) — config and store survive
|
container,
|
||||||
rc, out = _container_exec(
|
"mkdir -p /var/lib/fenris && "
|
||||||
container, "dpkg --purge fenris 2>&1 || true"
|
"python3 -c \""
|
||||||
)
|
"import sqlite3; "
|
||||||
# After purge: config gone, store gone (our postrm removes on purge)
|
"c = sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||||
# But the store dir may survive since it's not package-owned
|
"c.execute('PRAGMA user_version=1'); "
|
||||||
else:
|
"c.commit(); c.close()\" && "
|
||||||
# rpm erase
|
"touch /var/lib/fenris/observations.db-wal && "
|
||||||
rc, out = _container_exec(
|
"touch /var/lib/fenris/observations.db-shm && "
|
||||||
container, "rpm -e fenris 2>&1 || true"
|
"cp /var/lib/fenris/observations.db /var/lib/fenris/observations.db.bak",
|
||||||
)
|
)
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
|
)
|
||||||
|
|
||||||
# Units removed
|
|
||||||
|
def _assert_package_files_removed(container: str) -> None:
|
||||||
|
"""Assert package-owned files are removed after removal."""
|
||||||
rc, _ = _container_exec(
|
rc, _ = _container_exec(
|
||||||
container, "test -f /usr/lib/systemd/system/fenris-collect.timer"
|
container, "test -f /usr/lib/systemd/system/fenris-collect.timer"
|
||||||
)
|
)
|
||||||
assert rc != 0, "Timer unit should be removed after uninstall"
|
assert rc != 0, "Timer should be removed"
|
||||||
|
|
||||||
# Helpers removed
|
|
||||||
rc, _ = _container_exec(
|
rc, _ = _container_exec(
|
||||||
container, "test -f /usr/libexec/fenris/fenris-monitor"
|
container, "test -x /usr/libexec/fenris/fenris-monitor"
|
||||||
)
|
)
|
||||||
assert rc != 0, "Helper should be removed after uninstall"
|
assert rc != 0, "Helper should be removed"
|
||||||
|
|
||||||
# Venv key files removed (directories may remain if non-empty)
|
rc, _ = _container_exec(container, "test -d /opt/fenris/vendor")
|
||||||
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
|
assert rc != 0, "Vendored runtime packages should be removed"
|
||||||
assert rc != 0, "Venv Python should be removed after uninstall"
|
|
||||||
|
|
||||||
|
def _assert_deb_remove_preserves(container: str) -> None:
|
||||||
|
"""Assert deb remove keeps config, store, and group (spec §7)."""
|
||||||
|
# Config survives
|
||||||
|
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
|
||||||
|
assert rc == 0, "Config should survive deb remove"
|
||||||
|
|
||||||
|
# Store directory survives
|
||||||
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
|
assert rc == 0, "Store directory should survive deb remove"
|
||||||
|
|
||||||
|
# Store DB, WAL sidecars, and backup survive
|
||||||
|
for name in ("observations.db", "observations.db-wal",
|
||||||
|
"observations.db-shm", "observations.db.bak"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Store file {name} should survive deb remove"
|
||||||
|
|
||||||
|
# Service group survives
|
||||||
|
rc, _ = _container_exec(container, "getent group fenris")
|
||||||
|
assert rc == 0, "Group should survive deb remove"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_deb_purge_removes(container: str) -> None:
|
||||||
|
"""Assert deb purge removes config, store, backup, and group (spec §7)."""
|
||||||
|
rc, _ = _container_exec(container, "test -f /etc/fenris/fenris.conf")
|
||||||
|
assert rc != 0, "Config should be removed by purge"
|
||||||
|
|
||||||
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
|
assert rc != 0, "Store directory should be removed by purge"
|
||||||
|
|
||||||
|
# Service group removed
|
||||||
|
rc, _ = _container_exec(container, "getent group fenris 2>/dev/null || true")
|
||||||
|
assert rc != 0, "Group should be removed by purge"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_rpm_erase_removes_unmodified(container: str) -> None:
|
||||||
|
"""Assert rpm erase removes unmodified config, keeps store (spec §7)."""
|
||||||
|
# Default config removed (unmodified by user)
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, "test -f /etc/fenris/fenris.conf"
|
||||||
|
)
|
||||||
|
assert rc != 0, "Unmodified config should be removed by rpm erase"
|
||||||
|
|
||||||
|
# Store directory and files survive
|
||||||
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
|
assert rc == 0, "Store directory should survive rpm erase"
|
||||||
|
|
||||||
|
for name in ("observations.db", "observations.db-wal",
|
||||||
|
"observations.db-shm", "observations.db.bak"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Store file {name} should survive rpm erase"
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_rpm_erase_preserves_modified(container: str) -> None:
|
||||||
|
"""Assert rpm erase preserves modified config as .rpmsave (spec §7)."""
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, "test -f /etc/fenris/fenris.conf.rpmsave"
|
||||||
|
)
|
||||||
|
assert rc == 0, "Modified config should survive as .rpmsave"
|
||||||
|
|
||||||
|
# Store directory and files survive
|
||||||
|
rc, _ = _container_exec(container, "test -d /var/lib/fenris")
|
||||||
|
assert rc == 0, "Store directory should survive rpm erase"
|
||||||
|
|
||||||
|
for name in ("observations.db", "observations.db-wal",
|
||||||
|
"observations.db-shm", "observations.db.bak"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Store file {name} should survive rpm erase"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -511,10 +598,9 @@ def test_python_floor(skip_no_docker, version):
|
|||||||
assert "python3" in out, f"python3 dependency not declared: {out}"
|
assert "python3" in out, f"python3 dependency not declared: {out}"
|
||||||
assert "3.10" in out, f"Python 3.10 floor not in dependency: {out}"
|
assert "3.10" in out, f"Python 3.10 floor not in dependency: {out}"
|
||||||
|
|
||||||
# Verify the bundled venv exists (binary may not execute on the host
|
# Verify the version-neutral bundled runtime exists.
|
||||||
# interpreter — that's tested separately by the dormant-install test)
|
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
|
||||||
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
|
assert rc == 0, "Bundled runtime package not found"
|
||||||
assert rc == 0, "Bundled venv Python binary not found"
|
|
||||||
|
|
||||||
# fenris on PATH
|
# fenris on PATH
|
||||||
rc, _ = _container_exec(container, "command -v fenris")
|
rc, _ = _container_exec(container, "command -v fenris")
|
||||||
@@ -780,17 +866,108 @@ def test_upgrade_semantics(skip_no_docker, image, fmt, version):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests — removal semantics
|
# Tests — removal semantics (issue #49)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS])
|
@pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS])
|
||||||
def test_removal_semantics(skip_no_docker, image, fmt, version):
|
def test_deb_remove_preserves_config_and_store(skip_no_docker, image, version):
|
||||||
"""Assert removal cleans package-owned files."""
|
"""deb remove keeps config, store, and group (spec §7, #49)."""
|
||||||
|
pkg = _find_package("deb")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-deb-remove"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(
|
||||||
|
_build_deb_dockerfile(image, pkg_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = f"fenris-deb-remove-{image.replace(':', '-')}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-deb-remove-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_setup_store_and_config(container)
|
||||||
|
rc, out = _container_exec(container, "dpkg --remove fenris 2>&1")
|
||||||
|
assert rc == 0, f"dpkg --remove failed: {out}"
|
||||||
|
_assert_deb_remove_preserves(container)
|
||||||
|
_assert_package_files_removed(container)
|
||||||
|
# Sanctioned disable runs in prerm on remove — verified indirectly by
|
||||||
|
# the timer no longer being active. "Never on upgrade" is covered by
|
||||||
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
@pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS])
|
||||||
|
def test_deb_purge_removes_everything(skip_no_docker, image, version):
|
||||||
|
"""deb purge removes config, store, backup, and group (spec §7, #49)."""
|
||||||
|
pkg = _find_package("deb")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-deb-purge"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(
|
||||||
|
_build_deb_dockerfile(image, pkg_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = f"fenris-deb-purge-{image.replace(':', '-')}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-deb-purge-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_setup_store_and_config(container)
|
||||||
|
rc, out = _container_exec(container, "dpkg --purge fenris 2>&1")
|
||||||
|
assert rc == 0, f"dpkg --purge failed: {out}"
|
||||||
|
_assert_deb_purge_removes(container)
|
||||||
|
_assert_package_files_removed(container)
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
@pytest.mark.parametrize("image", [t[0] for t in ALL_TARGETS],
|
||||||
|
ids=[t[0] for t in ALL_TARGETS])
|
||||||
|
def test_sanctioned_disable_skipped_on_upgrade(skip_no_docker, image, fmt,
|
||||||
|
version):
|
||||||
|
"""Sanctioned disable never runs during upgrade (spec §7, #49)."""
|
||||||
pkg = _find_package(fmt)
|
pkg = _find_package(fmt)
|
||||||
pkg_name = pkg.name
|
pkg_name = pkg.name
|
||||||
|
|
||||||
build_dir = REPO_ROOT / "build" / "test-container-removal"
|
build_dir = REPO_ROOT / "build" / "test-container-upgrade-no-disable"
|
||||||
build_dir.mkdir(parents=True, exist_ok=True)
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(build_dir / "dist").mkdir(exist_ok=True)
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -804,29 +981,431 @@ def test_removal_semantics(skip_no_docker, image, fmt, version):
|
|||||||
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
dockerfile = _build_rpm_dockerfile(image, pkg_name)
|
||||||
(build_dir / "Dockerfile").write_text(dockerfile)
|
(build_dir / "Dockerfile").write_text(dockerfile)
|
||||||
|
|
||||||
tag = f"fenris-removal-{image.replace(':', '-').replace('/', '-')}"
|
tag = f"fenris-upgrade-no-disable-{image.replace(':', '-').replace('/', '-')}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "build", "-t", tag, str(build_dir)],
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
check=True,
|
check=True, capture_output=True, timeout=300,
|
||||||
capture_output=True,
|
|
||||||
timeout=300,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
container = f"fenris-removal-{os.getpid()}"
|
container = f"fenris-upgrade-no-disable-{os.getpid()}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[
|
["docker", "run", "-d", "--name", container,
|
||||||
"docker", "run", "-d", "--name", container,
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
"--tmpfs", "/tmp:exec,size=64m",
|
check=True, capture_output=True,
|
||||||
tag, "sleep", "infinity",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_assert_removal_semantics(container, fmt)
|
_setup_store_and_config(container)
|
||||||
|
|
||||||
|
if fmt == "deb":
|
||||||
|
# Fake older version so dpkg treats reinstall as upgrade
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
f"sed -i 's/^Version: {version}$/Version: 0.2.0/' "
|
||||||
|
"/var/lib/dpkg/status",
|
||||||
|
)
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container,
|
||||||
|
f"dpkg --force-confnew -i /pkg/{pkg_name} 2>&1 || "
|
||||||
|
"apt-get install -f -y 2>&1 || true",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# RPM: invoke upgrade-path scriptlets ($1=1 for preun/postun)
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
f"rpm -q --scripts -p /pkg/{pkg_name} "
|
||||||
|
"| sed -n '/^preuninstall scriptlet/,/^postinstall scriptlet/"
|
||||||
|
"{/^postinstall scriptlet/d;/^preuninstall scriptlet/d;p}' "
|
||||||
|
"| sh -s 1 2",
|
||||||
|
)
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
f"rpm -q --scripts -p /pkg/{pkg_name} "
|
||||||
|
"| sed -n '/^postinstall scriptlet/,/^preuninstall/"
|
||||||
|
"{/^preuninstall/d;/^postinstall scriptlet/d;p}' "
|
||||||
|
"| sh -s 2 2",
|
||||||
|
)
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
f"rpm -q --scripts -p /pkg/{pkg_name} "
|
||||||
|
"| sed -n '/^postuninstall scriptlet/,/^preuninstall/"
|
||||||
|
"{/^preuninstall/d;/^postuninstall scriptlet/d;p}' "
|
||||||
|
"| sh -s 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timer still exists — removal scripts were no-ops, no disable
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container,
|
||||||
|
"test -f /usr/lib/systemd/system/fenris-collect.timer",
|
||||||
|
)
|
||||||
|
assert rc == 0, (
|
||||||
|
"Timer removed during upgrade — sanctioned disable may have run"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper still exists
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container,
|
||||||
|
"test -x /usr/libexec/fenris/fenris-monitor",
|
||||||
|
)
|
||||||
|
assert rc == 0, "Helper removed during upgrade"
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["docker", "rm", "-f", container],
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
capture_output=True,
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_rpm_erase_modified_config_preserved(skip_no_docker, version):
|
||||||
|
"""rpm erase preserves modified config as .rpmsave (spec §7, #49)."""
|
||||||
|
pkg = _find_package("rpm")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-rpm-erase-mod"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(
|
||||||
|
_build_rpm_dockerfile("fedora:40", pkg_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = "fenris-rpm-erase-mod"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-rpm-erase-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_setup_store_and_config(container)
|
||||||
|
# Install RPM (installs default fenris.conf, but we modified it above)
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container, f"rpm -ivh /pkg/{pkg_name} 2>&1"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"rpm -ivh failed: {out}"
|
||||||
|
# Modify config after install (simulate hand-edited device selector)
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
|
)
|
||||||
|
# Erase — RPM should save modified config as .rpmsave
|
||||||
|
rc, out = _container_exec(container, "rpm -e fenris 2>&1")
|
||||||
|
assert rc == 0, f"rpm -e failed: {out}"
|
||||||
|
_assert_rpm_erase_preserves_modified(container)
|
||||||
|
_assert_package_files_removed(container)
|
||||||
|
# Sanctioned disable runs in %preun on erase — verified indirectly by
|
||||||
|
# the timer no longer being active. "Never on upgrade" is covered by
|
||||||
|
# test_upgrade_semantics (criterion 4: removal scripts are no-ops).
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_rpm_erase_unmodified_config_removed(skip_no_docker, version):
|
||||||
|
"""rpm erase removes unmodified config (spec §7, #49)."""
|
||||||
|
pkg = _find_package("rpm")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-rpm-erase-unmod"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(
|
||||||
|
_build_rpm_dockerfile("fedora:40", pkg_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
tag = "fenris-rpm-erase-unmod"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-rpm-erase-unmod-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_setup_store_and_config(container)
|
||||||
|
# Install RPM (default config)
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container, f"rpm -ivh /pkg/{pkg_name} 2>&1"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"rpm -ivh failed: {out}"
|
||||||
|
# Erase without modifying config — default should be removed
|
||||||
|
rc, out = _container_exec(container, "rpm -e fenris 2>&1")
|
||||||
|
assert rc == 0, f"rpm -e failed: {out}"
|
||||||
|
_assert_rpm_erase_removes_unmodified(container)
|
||||||
|
_assert_package_files_removed(container)
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests — no-move continuity (issue #50)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _seed_make_install_state(container: str, *, include_manifest: bool = True) -> None:
|
||||||
|
"""Seed a container with make-install-shaped state."""
|
||||||
|
cmds = [
|
||||||
|
"groupadd -f fenris",
|
||||||
|
"install -d -o root -g fenris -m 2750 /var/lib/fenris",
|
||||||
|
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
|
||||||
|
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||||
|
"c.execute('PRAGMA user_version=1'); c.commit(); c.close()\"",
|
||||||
|
"mkdir -p /etc/systemd/system",
|
||||||
|
"echo '[Unit]' > /etc/systemd/system/fenris-collect.timer",
|
||||||
|
]
|
||||||
|
if include_manifest:
|
||||||
|
cmds.append("echo '# manifest' > /var/lib/fenris/manifest.txt")
|
||||||
|
_container_exec(container, " && ".join(cmds))
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_make_uninstall(container: str) -> None:
|
||||||
|
"""Simulate `make uninstall` by removing make-install artifacts.
|
||||||
|
|
||||||
|
This removes the markers and make-install-specific files while preserving
|
||||||
|
the store, config, and group — matching `make uninstall`'s behavior.
|
||||||
|
"""
|
||||||
|
_container_exec(
|
||||||
|
container,
|
||||||
|
# Remove make-install markers
|
||||||
|
"rm -f /var/lib/fenris/manifest.txt && "
|
||||||
|
"rm -f /etc/systemd/system/fenris-collect.timer && "
|
||||||
|
# Remove make-install wrapper (if present)
|
||||||
|
"rm -f /usr/local/bin/fenris",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_schema_version(container: str) -> str:
|
||||||
|
"""Read PRAGMA user_version from the observation store."""
|
||||||
|
_, out = _container_exec(
|
||||||
|
container,
|
||||||
|
"python3 -c \"import sqlite3; c=sqlite3.connect('/var/lib/fenris/observations.db'); "
|
||||||
|
"print(c.execute('PRAGMA user_version').fetchone()[0]); c.close()\"",
|
||||||
|
)
|
||||||
|
return out.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_no_move_continuity(
|
||||||
|
container: str, fmt: str, version: str,
|
||||||
|
pre_group_id: str, pre_dir_stat: str, pre_config_content: str,
|
||||||
|
pre_schema_version: str, pkg_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Assert all no-move continuity invariants after package install."""
|
||||||
|
# Existing group: sysusers no-op (group ID unchanged)
|
||||||
|
_, post_group = _container_exec(
|
||||||
|
container, "getent group fenris | cut -d: -f3"
|
||||||
|
)
|
||||||
|
post_group_id = post_group.strip()
|
||||||
|
assert post_group_id == pre_group_id, (
|
||||||
|
f"Group changed during migration: {pre_group_id} → {post_group_id} "
|
||||||
|
"(sysusers should be a no-op)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing store dir: tmpfiles no-op (same permissions)
|
||||||
|
_, post_dir_stat = _container_exec(
|
||||||
|
container, "stat -c '%a %U %G' /var/lib/fenris"
|
||||||
|
)
|
||||||
|
assert post_dir_stat.strip() == pre_dir_stat.strip(), (
|
||||||
|
f"Store dir permissions changed: {pre_dir_stat.strip()} → {post_dir_stat.strip()} "
|
||||||
|
"(tmpfiles should be a no-op)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hand-written config survives as a non-database file
|
||||||
|
_, post_config = _container_exec(
|
||||||
|
container, "cat /etc/fenris/fenris.conf"
|
||||||
|
)
|
||||||
|
post_config_content = post_config.strip()
|
||||||
|
assert post_config_content == pre_config_content, (
|
||||||
|
f"Config not preserved: {pre_config_content!r} → {post_config_content!r}"
|
||||||
|
)
|
||||||
|
_, out = _container_exec(
|
||||||
|
container,
|
||||||
|
"file /etc/fenris/fenris.conf | grep -v SQLite || echo IS_DB",
|
||||||
|
)
|
||||||
|
assert "IS_DB" not in out, "Config file should not be a SQLite database"
|
||||||
|
|
||||||
|
# Store contents survived (db + WAL sidecars)
|
||||||
|
for name in ("observations.db", "observations.db-wal",
|
||||||
|
"observations.db-shm"):
|
||||||
|
rc, _ = _container_exec(
|
||||||
|
container, f"test -f /var/lib/fenris/{name}"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Store file {name} not preserved"
|
||||||
|
|
||||||
|
# Store schema caught up by upgrade-path migration
|
||||||
|
post_schema_version = _get_schema_version(container)
|
||||||
|
assert post_schema_version == pre_schema_version, (
|
||||||
|
f"Schema version changed unexpectedly: {pre_schema_version} → {post_schema_version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Package is correctly installed
|
||||||
|
_assert_dormant_layout(container, fmt, version)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
@pytest.mark.parametrize("image", [t[0] for t in DEB_TARGETS])
|
||||||
|
def test_no_move_continuity_deb(skip_no_docker, image, version):
|
||||||
|
"""Verify no-move continuity: make-install state survives package migration."""
|
||||||
|
pkg = _find_package("deb")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-no-move-deb"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(textwrap.dedent(f"""\
|
||||||
|
FROM {image}
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||||
|
python3 smartmontools systemd systemd-sysv dbus && \\
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||||
|
"""))
|
||||||
|
|
||||||
|
tag = f"fenris-no-move-deb-{image.replace(':', '-')}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-no-move-deb-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_seed_make_install_state(container, include_manifest=True)
|
||||||
|
|
||||||
|
pre_group_id = _container_exec(
|
||||||
|
container, "getent group fenris | cut -d: -f3"
|
||||||
|
)[1].strip()
|
||||||
|
pre_dir_stat = _container_exec(
|
||||||
|
container, "stat -c '%a %U %G' /var/lib/fenris"
|
||||||
|
)[1].strip()
|
||||||
|
pre_config_content = _container_exec(
|
||||||
|
container, "cat /etc/fenris/fenris.conf"
|
||||||
|
)[1].strip()
|
||||||
|
pre_schema_version = _get_schema_version(container)
|
||||||
|
|
||||||
|
# Verify guard blocks install with remnants
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container, f"dpkg -i /pkg/{pkg_name} 2>&1 || true"
|
||||||
|
)
|
||||||
|
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||||||
|
f"Guard should block install with remnants: {out}"
|
||||||
|
|
||||||
|
_simulate_make_uninstall(container)
|
||||||
|
|
||||||
|
# Install the package — guard should pass
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container,
|
||||||
|
f"dpkg -i /pkg/{pkg_name} 2>&1 || apt-get install -f -y 2>&1",
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Package install failed after simulated uninstall: {out}"
|
||||||
|
|
||||||
|
_assert_no_move_continuity(
|
||||||
|
container, "deb", version,
|
||||||
|
pre_group_id, pre_dir_stat, pre_config_content, pre_schema_version,
|
||||||
|
pkg_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_no_move_continuity_rpm(skip_no_docker, version):
|
||||||
|
"""Verify no-move continuity for rpm: make-install state survives migration."""
|
||||||
|
pkg = _find_package("rpm")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
|
||||||
|
build_dir = REPO_ROOT / "build" / "test-container-no-move-rpm"
|
||||||
|
build_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(build_dir / "dist").mkdir(exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
["cp", str(pkg), str(build_dir / "dist" / pkg_name)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
(build_dir / "Dockerfile").write_text(textwrap.dedent(f"""\
|
||||||
|
FROM fedora:40
|
||||||
|
RUN dnf install -y --setopt=install_weak_deps=False \
|
||||||
|
python3 smartmontools systemd dbus && \
|
||||||
|
dnf clean all
|
||||||
|
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||||
|
"""))
|
||||||
|
|
||||||
|
tag = "fenris-no-move-rpm"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "build", "-t", tag, str(build_dir)],
|
||||||
|
check=True, capture_output=True, timeout=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
container = f"fenris-no-move-rpm-{os.getpid()}"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "run", "-d", "--name", container,
|
||||||
|
"--tmpfs", "/tmp:exec,size=64m", tag, "sleep", "infinity"],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_seed_make_install_state(container, include_manifest=False)
|
||||||
|
|
||||||
|
pre_group_id = _container_exec(
|
||||||
|
container, "getent group fenris | cut -d: -f3"
|
||||||
|
)[1].strip()
|
||||||
|
pre_dir_stat = _container_exec(
|
||||||
|
container, "stat -c '%a %U %G' /var/lib/fenris"
|
||||||
|
)[1].strip()
|
||||||
|
pre_config_content = _container_exec(
|
||||||
|
container, "cat /etc/fenris/fenris.conf"
|
||||||
|
)[1].strip()
|
||||||
|
pre_schema_version = _get_schema_version(container)
|
||||||
|
|
||||||
|
# Verify guard blocks install with remnants
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container, f"rpm -ivh /pkg/{pkg_name} 2>&1 || true"
|
||||||
|
)
|
||||||
|
assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \
|
||||||
|
f"Guard should block install with remnants: {out}"
|
||||||
|
|
||||||
|
_simulate_make_uninstall(container)
|
||||||
|
|
||||||
|
# Install the package — guard should pass
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container, f"rpm -ivh /pkg/{pkg_name} 2>&1"
|
||||||
|
)
|
||||||
|
assert rc == 0, f"Package install failed after simulated uninstall: {out}"
|
||||||
|
|
||||||
|
_assert_no_move_continuity(
|
||||||
|
container, "rpm", version,
|
||||||
|
pre_group_id, pre_dir_stat, pre_config_content, pre_schema_version,
|
||||||
|
pkg_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", container], capture_output=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user