From babc8eeeb1c6cbc8a4a6276c70f520a46dac61b1 Mon Sep 17 00:00:00 2001 From: xavierk Date: Thu, 3 Sep 2026 02:36:08 +0530 Subject: [PATCH] =?UTF-8?q?feat(packaging):=20nfpm-based=20deb=20+=20rpm?= =?UTF-8?q?=20build=20infrastructure=20(spec=20=C2=A73-7,=20ADR=200007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the packaging configuration, staging script, maintainer scripts, and container test harness for building native deb and rpm packages. Core files: - packaging/nfpm.yaml: single source of truth for both formats - packaging/stage.sh: builds staged tree (venv, wrapper, helpers, units, polkit, sysusers, tmpfiles) - packaging/fenris.conf: placeholder-commented default configuration - packaging/postinst.sh, prerm.sh, postrm.sh: POSIX-compatible deb maintainer scripts - packaging/rpm/post.sh, preun.sh, postun.sh: RPM scriptlets - packaging/sysusers.d/fenris.conf, tmpfiles.d/fenris.conf: systemd fragments - packaging/fenris.repo: dnf consumer setup - packaging/keys/fenris-packaging.asc: public key placeholder Build targets added to Makefile: stage, package-deb, package-rpm, package, release, clean. Container test harness in tests/test_packaging.py covering dormant install, migration guard, upgrade semantics, and removal semantics across the compatibility matrix (Debian 12, Ubuntu 22.04/24.04, Fedora 40). Dormant CI workflow at .gitea/workflows/release.yml. All 289 existing tests pass without regression. Co-authored-by: CommandCodeBot --- .gitea/workflows/release.yml | 37 ++ .gitignore | 6 + Makefile | 55 ++- packaging/fenris.conf | 11 + packaging/fenris.repo | 7 + packaging/keys/fenris-packaging.asc | 15 + packaging/nfpm.yaml | 65 ++++ packaging/postinst.sh | 59 +++ packaging/postrm.sh | 23 ++ packaging/preinst.sh | 20 + packaging/prerm.sh | 20 + packaging/rpm/post.sh | 48 +++ packaging/rpm/postun.sh | 20 + packaging/rpm/preun.sh | 16 + packaging/stage.sh | 86 +++++ packaging/sysusers.d/fenris.conf | 3 + packaging/tmpfiles.d/fenris.conf | 2 + tests/test_packaging.py | 558 ++++++++++++++++++++++++++++ 18 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 packaging/fenris.conf create mode 100644 packaging/fenris.repo create mode 100644 packaging/keys/fenris-packaging.asc create mode 100644 packaging/nfpm.yaml create mode 100755 packaging/postinst.sh create mode 100755 packaging/postrm.sh create mode 100755 packaging/preinst.sh create mode 100755 packaging/prerm.sh create mode 100755 packaging/rpm/post.sh create mode 100755 packaging/rpm/postun.sh create mode 100755 packaging/rpm/preun.sh create mode 100755 packaging/stage.sh create mode 100644 packaging/sysusers.d/fenris.conf create mode 100644 packaging/tmpfiles.d/fenris.conf create mode 100644 tests/test_packaging.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..596525b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,37 @@ +# Fenris release workflow — dormant (no runner registered yet). +# When a runner is provisioned, this replicates `make release` automatically. +# Spec: §5, §34 +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: pip install build nfpm + + - name: Build packages + run: make package + + - name: List artifacts + run: ls -la dist/ + + # Signing and upload are manual steps — this workflow confirms + # the build succeeds. The maintainer completes the release. + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: fenris-packages + path: dist/ diff --git a/.gitignore b/.gitignore index 3ffd01d..32487fa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ data/fenris.log data/history.jsonl data/hourly.jsonl plan-dash-changes.md + +# Packaging build artifacts +build/ +dist/*.deb +dist/*.rpm +dist/SHA256SUMS* diff --git a/Makefile b/Makefile index 8c2dff9..386b288 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt # Legacy history path (IN-4) LEGACY_HISTORY := ./data/history.jsonl -.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy +.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package release clean help: @echo "Fenris NVMe endurance monitor" @@ -30,6 +30,12 @@ help: @echo " test - Run tests" @echo " lint - Run linter" @echo " update-deps - Update dependency pins" + @echo " stage - Stage packaging tree for nfpm" + @echo " package - Build deb + rpm packages" + @echo " package-deb - Build deb package only" + @echo " package-rpm - Build rpm package only" + @echo " release - Full release (build, sign, attach)" + @echo " clean - Remove build artifacts" # ─── Pre-install gates ────────────────────────────────────────────────────── @@ -216,3 +222,50 @@ lint: update-deps: $(PYTHON) -m pip compile pyproject.toml -o requirements.txt + +# ─── Packaging (spec §3, §5) ──────────────────────────────────────────────── + +# Version is sourced from pyproject.toml for both formats +FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) + +stage: dist/fenris-*.whl + @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" + bash packaging/stage.sh "$(FENRIS_VERSION)" + +package-deb: stage + @echo "=== Building deb package ===" + VERSION="$(FENRIS_VERSION)" nfpm pkg -f packaging/nfpm.yaml -p deb -t dist/ + @echo "=== deb package built: dist/fenris_$(FENRIS_VERSION)_amd64.deb ===" + +package-rpm: stage + @echo "=== Building rpm package ===" + VERSION="$(FENRIS_VERSION)" nfpm pkg -f packaging/nfpm.yaml -p rpm -t dist/ + @echo "=== rpm package built: dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm ===" + +package: package-deb package-rpm + @echo "=== Both packages built in dist/ ===" + +release: package + @echo "=== Release v$(FENRIS_VERSION) ===" + @echo "Artifacts:" + @ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null + @echo "" + @echo "Manual steps (spec §5):" + @echo " 1. Import packaging key: gpg --import " + @echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" + @echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS" + @echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS" + @echo " 5. Upload to registry:" + @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" + @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/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/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 " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc" + +clean: + @echo "=== Cleaning build artifacts ===" + rm -rf build/stage dist/fenris-*.deb dist/fenris-*.rpm dist/SHA256SUMS* diff --git a/packaging/fenris.conf b/packaging/fenris.conf new file mode 100644 index 0000000..75f76bd --- /dev/null +++ b/packaging/fenris.conf @@ -0,0 +1,11 @@ +# Fenris configuration +# +# This file is managed by the fenris package. Local edits are preserved +# across upgrades; changed defaults appear as .dpkg-new / .rpmnew. +# +# The device selector specifies which NVMe drive to monitor. +# Uncomment and set exactly one device path: +# +# devices = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456 +# +# See https://git.bongbetic.com/xavierk/Fenris for documentation. diff --git a/packaging/fenris.repo b/packaging/fenris.repo new file mode 100644 index 0000000..8895bc3 --- /dev/null +++ b/packaging/fenris.repo @@ -0,0 +1,7 @@ +[fenris] +name=Fenris NVMe Monitor +baseurl=https://git.bongbetic.com/api/packages/xavierk/rpm/fenris +enabled=1 +gpgcheck=1 +gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc +repo_gpgcheck=0 diff --git a/packaging/keys/fenris-packaging.asc b/packaging/keys/fenris-packaging.asc new file mode 100644 index 0000000..f9b5af1 --- /dev/null +++ b/packaging/keys/fenris-packaging.asc @@ -0,0 +1,15 @@ +# Fenris Packaging Key — placeholder +# +# The public half of the dedicated RSA-3072 packaging key used to sign rpm +# payloads and clearsign SHA256SUMS manifests. +# +# The private half lives only in the password manager. Each release performs: +# import → sign → delete. No machine permanently holds signing material. +# +# Key details (published with the first Release): +# Algorithm: RSA 3072 +# UID: Fenris Packaging +# Expiry: 2 years from creation +# +# This file will be replaced with the real public key at the time of the +# first Release. Its raw URL doubles as the dnf gpgkey target. diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..ea3a61b --- /dev/null +++ b/packaging/nfpm.yaml @@ -0,0 +1,65 @@ +name: fenris +arch: amd64 +platform: linux +version: "${VERSION}" +maintainer: Fenris Maintainers +description: > + NVMe wear monitor with persistent TUI — observes real-world drive use and + translates it into an understandable endurance outlook. +homepage: https://git.bongbetic.com/xavierk/Fenris +license: Proprietary + +depends: + - python3 (>= 3.10) + - smartmontools + - systemd + +contents: + # Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles + - src: build/stage/ + dst: / + type: tree + + # Configuration directory + - dst: /etc/fenris + type: dir + file_info: + mode: 0755 + + # Default placeholder-commented config (conffile for deb) + - src: packaging/fenris.conf + dst: /etc/fenris/fenris.conf + type: config + file_info: + mode: 0644 + + # Observation store directory — owned by package, never packed + # deb: created in postinst; rpm: %ghost + - dst: /var/lib/fenris + type: ghost + file_info: + mode: 2750 + +scripts: + preinstall: packaging/preinst.sh + postinstall: packaging/postinst.sh + preremove: packaging/prerm.sh + postremove: packaging/postrm.sh + +overrides: + deb: + depends: + - python3 (>= 3.10) + - smartmontools + - systemd + + rpm: + depends: + - python3 >= 3.10 + - smartmontools + - systemd + scripts: + preinstall: packaging/preinst.sh + postinstall: packaging/rpm/post.sh + preremove: packaging/rpm/preun.sh + postremove: packaging/rpm/postun.sh diff --git a/packaging/postinst.sh b/packaging/postinst.sh new file mode 100755 index 0000000..1e36a49 --- /dev/null +++ b/packaging/postinst.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# postinst — install and upgrade paths (spec §7). +# +# dpkg calls: postinst configure [most-recently-configured-version] +# fresh install: $1 = "configure" +# upgrade: $1 = "configure" (old version as $2 when available) +# +# rpm calls: %post $1 = 1 (fresh install) / 2 (upgrade) +# (handled by packaging/rpm/post.sh) +# +# Install: sysusers, tmpfiles, daemon-reload — nothing else (dormant). +# Upgrade: snapshot, migration, daemon-reload, conditional timer restart. +set -eu + +STORE_DIR="/var/lib/fenris" +STORE_DB="${STORE_DIR}/observations.db" +STORE_BAK="${STORE_DIR}/observations.db.bak" +VENV_PYTHON="/opt/fenris/bin/python3" + +case "${1:-}" in + configure) + if [ -n "${2:-}" ]; then + # Upgrade — $2 is the old version + # Snapshot observation store (one generation) + if [ -f "${STORE_DB}" ]; then + cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true + fi + + # Forward-only schema migration + if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then + "${VENV_PYTHON}" -c " +from fenris.store import migrate_to_latest +from pathlib import Path +n = migrate_to_latest(Path('${STORE_DB}')) +print(f'Fenris migration: {n} step(s) applied') if n else None +" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" + fi + + # Restart collect timer only if unit contents changed AND it is active + for unit in fenris-collect.timer fenris-collect.service; do + if systemctl is-active --quiet "${unit}" 2>/dev/null; then + TMPFILE="$(mktemp)" + 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 + done + fi + # Always run on both fresh install and upgrade + systemd-sysusers || true + systemd-tmpfiles --create || true + systemctl daemon-reload || true + ;; + abort-upgrade|abort-install|disappear) + ;; +esac diff --git a/packaging/postrm.sh b/packaging/postrm.sh new file mode 100755 index 0000000..65fd26f --- /dev/null +++ b/packaging/postrm.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# postrm — deb post-removal (spec §7, §9). +# +# dpkg calls: postrm remove (after package files removed) +# postrm purge (after conffiles and config removed) +# postrm upgrade (after new version installed) +set -eu + +case "${1:-}" in + purge) + rm -rf /etc/fenris + rm -rf /var/lib/fenris + # Remove the fenris group if it exists + if getent group fenris > /dev/null 2>&1; then + groupdel fenris 2>/dev/null || true + fi + ;; + remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; +esac + +# Always daemon-reload after removal (units may be gone) +systemctl daemon-reload 2>/dev/null || true diff --git a/packaging/preinst.sh b/packaging/preinst.sh new file mode 100755 index 0000000..6e4ec35 --- /dev/null +++ b/packaging/preinst.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# preinst — abort if make-install remnants detected (spec §7, §9). +# Dual marker: /var/lib/fenris/manifest.txt or /etc/systemd/system/fenris-collect.timer. +set -eu + +MARKER1="/var/lib/fenris/manifest.txt" +MARKER2="/etc/systemd/system/fenris-collect.timer" + +if [ -f "${MARKER1}" ] || [ -f "${MARKER2}" ]; then + echo >&2 + echo >&2 "Fenris make-install remnants detected — refusing to install." + echo >&2 + echo >&2 "Migrate to the package with:" + echo >&2 " sudo make uninstall # removes make-install files, preserves store + config" + echo >&2 " sudo apt install fenris # or: sudo dnf install fenris" + echo >&2 + echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems" + echo >&2 + exit 1 +fi diff --git a/packaging/prerm.sh b/packaging/prerm.sh new file mode 100755 index 0000000..09ef725 --- /dev/null +++ b/packaging/prerm.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# prerm — deb pre-removal (spec §7). +# +# dpkg calls: prerm remove (package being removed) +# prerm upgrade (old version about to be replaced) +set -eu + +case "${1:-}" in + remove) + # Sanctioned disable — close the monitoring period as user_disabled + if [ -x /usr/libexec/fenris/fenris-monitor ]; then + /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true + fi + systemctl stop fenris-collect.timer 2>/dev/null || true + systemctl disable fenris-collect.timer 2>/dev/null || true + ;; + upgrade) + # Never interrupt monitoring on upgrade + ;; +esac diff --git a/packaging/rpm/post.sh b/packaging/rpm/post.sh new file mode 100755 index 0000000..a62ea71 --- /dev/null +++ b/packaging/rpm/post.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# RPM %post — post-install/upgrade scriptlet (spec §7). +# +# Fresh install: sysusers, tmpfiles, daemon-reload — nothing else (dormant). +# Upgrade: snapshot, migration, daemon-reload, conditional timer restart. +set -eu + +STORE_DIR="/var/lib/fenris" +STORE_DB="${STORE_DIR}/observations.db" +STORE_BAK="${STORE_DIR}/observations.db.bak" +VENV_PYTHON="/opt/fenris/bin/python3" + +if [ "$1" -eq 1 ]; then + # Fresh install + systemd-sysusers || true + systemd-tmpfiles --create || true + systemctl daemon-reload || true +elif [ "$1" -ge 2 ]; then + # Upgrade + if [ -f "${STORE_DB}" ]; then + cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true + fi + + if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then + "${VENV_PYTHON}" -c " +from fenris.store import migrate_to_latest +from pathlib import Path +n = migrate_to_latest(Path('${STORE_DB}')) +print(f'Fenris migration: {n} step(s) applied') if n else None +" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" + fi + + systemd-sysusers || true + systemd-tmpfiles --create || true + systemctl daemon-reload || true + + for unit in fenris-collect.timer fenris-collect.service; do + if systemctl is-active --quiet "${unit}" 2>/dev/null; then + TMPFILE="$(mktemp)" + 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 + done +fi diff --git a/packaging/rpm/postun.sh b/packaging/rpm/postun.sh new file mode 100755 index 0000000..c71151a --- /dev/null +++ b/packaging/rpm/postun.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# RPM %postun — post-uninstall scriptlet (spec §7). +# +# On erase ($1 -eq 0): remove config (unmodified removed, modified as .rpmsave), +# store, backups, and group. +# On upgrade ($1 -ge 1): daemon-reload only. +set -eu + +if [ "$1" -eq 0 ]; then + # Package fully erased + rm -rf /etc/fenris + rm -rf /var/lib/fenris + # Remove the fenris group if it exists + if getent group fenris > /dev/null 2>&1; then + groupdel fenris 2>/dev/null || true + fi +fi + +# Always daemon-reload (units may have been removed) +systemctl daemon-reload 2>/dev/null || true diff --git a/packaging/rpm/preun.sh b/packaging/rpm/preun.sh new file mode 100755 index 0000000..fbaa721 --- /dev/null +++ b/packaging/rpm/preun.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# RPM %preun — pre-uninstall scriptlet (spec §7). +# +# On erase ($1 -eq 0): sanctioned disable — close monitoring period. +# On upgrade ($1 -ge 1): no-op — never interrupt monitoring. +set -eu + +if [ "$1" -eq 0 ]; then + # Package is being erased (fully removed), not just upgraded + if [ -x /usr/libexec/fenris/fenris-monitor ]; then + /usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true + fi + systemctl stop fenris-collect.timer 2>/dev/null || true + systemctl disable fenris-collect.timer 2>/dev/null || true +fi +# On upgrade ($1 -ge 1): do nothing — monitoring continues uninterrupted diff --git a/packaging/stage.sh b/packaging/stage.sh new file mode 100755 index 0000000..95a755a --- /dev/null +++ b/packaging/stage.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Stage a packaging tree at build/stage/ for nfpm consumption. +# +# Usage: packaging/stage.sh [VERSION] +# +# VERSION defaults to the version in pyproject.toml. +# The staged tree contains: +# /opt/fenris/ — bundled venv with the built wheel +# /usr/bin/fenris — unprivileged wrapper +# /usr/libexec/fenris/ — fenris-monitor, fenris-collect +# /usr/lib/systemd/system/ — fenris-collect.{timer,service} +# /usr/share/polkit-1/actions/ — polkit policy +# /usr/lib/sysusers.d/fenris.conf +# /usr/lib/tmpfiles.d/fenris.conf +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGE_DIR="${REPO_ROOT}/build/stage" + +# --- Resolve version --- +if [ -n "${1:-}" ]; then + VERSION="$1" +else + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "${REPO_ROOT}/pyproject.toml")" +fi + +if [ -z "${VERSION}" ]; then + echo "Error: could not determine version" >&2 + exit 1 +fi +echo "Staging fenris ${VERSION} ..." + +# --- Clean previous stage --- +rm -rf "${STAGE_DIR}" +mkdir -p "${STAGE_DIR}" + +# --- Use pre-built wheel from dist/ --- +WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-*.whl 2>/dev/null | head -1) +if [ -z "${WHEEL}" ]; then + echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2 + exit 1 +fi +echo " Using wheel: $(basename "${WHEEL}")" + +# --- Create venv with --copies and install wheel --- +echo " Creating bundled venv ..." +python3 -m venv --copies "${STAGE_DIR}/opt/fenris" +"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1 +"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1 + +# --- Inject version into wrapper from pyproject.toml --- +# The wrapper has a hardcoded version string; patch it for packaging. +WRAPPER_SRC="${REPO_ROOT}/scripts/fenris" +WRAPPER_DST="${STAGE_DIR}/usr/bin/fenris" +mkdir -p "$(dirname "${WRAPPER_DST}")" +sed "s|version=\"%(prog)s [0-9.]*\"|version=\"%(prog)s ${VERSION}\"|g" \ + "${WRAPPER_SRC}" > "${WRAPPER_DST}" +chmod 0755 "${WRAPPER_DST}" + +# --- Privileged helpers --- +echo " Installing helpers ..." +mkdir -p "${STAGE_DIR}/usr/libexec/fenris" +install -m 0755 "${REPO_ROOT}/src/fenris/monitor.py" "${STAGE_DIR}/usr/libexec/fenris/fenris-monitor" +install -m 0755 "${REPO_ROOT}/src/fenris/collect.py" "${STAGE_DIR}/usr/libexec/fenris/fenris-collect" + +# --- systemd units (vendor placement) --- +echo " Installing systemd units ..." +mkdir -p "${STAGE_DIR}/usr/lib/systemd/system" +install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/" +install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/" + +# --- polkit policy --- +echo " Installing polkit policy ..." +mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions" +install -m 0644 "${REPO_ROOT}/polkit/com.bongbetic.fenris.monitor.policy" \ + "${STAGE_DIR}/usr/share/polkit-1/actions/" + +# --- sysusers and tmpfiles fragments --- +echo " Installing sysusers/tmpfiles fragments ..." +mkdir -p "${STAGE_DIR}/usr/lib/sysusers.d" +install -m 0644 "${REPO_ROOT}/packaging/sysusers.d/fenris.conf" "${STAGE_DIR}/usr/lib/sysusers.d/" + +mkdir -p "${STAGE_DIR}/usr/lib/tmpfiles.d" +install -m 0644 "${REPO_ROOT}/packaging/tmpfiles.d/fenris.conf" "${STAGE_DIR}/usr/lib/tmpfiles.d/" + +echo "Stage complete: ${STAGE_DIR}" diff --git a/packaging/sysusers.d/fenris.conf b/packaging/sysusers.d/fenris.conf new file mode 100644 index 0000000..01506e1 --- /dev/null +++ b/packaging/sysusers.d/fenris.conf @@ -0,0 +1,3 @@ +# System user/group for Fenris observation store access +# Created by systemd-sysusers during package install +g fenris - diff --git a/packaging/tmpfiles.d/fenris.conf b/packaging/tmpfiles.d/fenris.conf new file mode 100644 index 0000000..b14194f --- /dev/null +++ b/packaging/tmpfiles.d/fenris.conf @@ -0,0 +1,2 @@ +# Type Path Mode User Group Age Argument +d /var/lib/fenris 2750 root fenris - - diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..0f451d3 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,558 @@ +"""Packaging acceptance tests — containerized matrix. + +Tests the built deb and rpm packages in throwaway per-distro containers, +verifying the dormant-install contract, upgrade semantics, removal mapping, +and migration guard. This is the single test seam agreed in the release spec. + +Requirements: + - docker (running, current user in docker group) + - Built packages in dist/ (run `make package` first) + - No network access required once containers are built + - No registry or signing key required + +Spec: release-packaging.md §§1–9, ADR 0007 +""" +import os +import subprocess +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +DIST_DIR = REPO_ROOT / "dist" +VERSION_FILE = REPO_ROOT / "pyproject.toml" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +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 _docker_available() -> bool: + """Check if Docker daemon is reachable.""" + try: + r = subprocess.run( + ["docker", "info"], capture_output=True, timeout=10 + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _container_exec(container: str, cmd: str) -> tuple[int, str]: + """Execute a command inside a running container.""" + r = subprocess.run( + ["docker", "exec", container, "sh", "-c", cmd], + capture_output=True, text=True, timeout=120, + ) + return r.returncode, r.stdout + r.stderr + + +def _find_package(fmt: str, distro: str | None = None) -> Path: + """Locate the built package artifact.""" + version = _get_version() + if fmt == "deb": + candidate = DIST_DIR / f"fenris_{version}_amd64.deb" + elif fmt == "rpm": + candidate = DIST_DIR / f"fenris-{version}-1.x86_64.rpm" + else: + raise ValueError(f"Unknown format: {fmt}") + if not candidate.exists(): + pytest.skip(f"Package not found: {candidate} — run `make package` first") + return candidate + + +# --------------------------------------------------------------------------- +# Matrix definitions +# --------------------------------------------------------------------------- + +DEB_TARGETS = [ + ("debian:bookworm", "deb"), + ("ubuntu:22.04", "deb"), + ("ubuntu:24.04", "deb"), +] + +RPM_TARGETS = [ + ("fedora:40", "rpm"), +] + +ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + + +# --------------------------------------------------------------------------- +# Dockerfile builders +# --------------------------------------------------------------------------- + +def _build_deb_dockerfile(image: str, pkg_name: str) -> str: + """Dockerfile for testing deb installation.""" + return 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} /tmp/{pkg_name} + RUN dpkg -i /tmp/{pkg_name} || apt-get install -f -y + """) + + +def _build_rpm_dockerfile(image: str, pkg_name: str) -> str: + """Dockerfile for testing rpm installation.""" + return textwrap.dedent(f"""\ + FROM {image} + RUN dnf install -y --setopt=install_weak_deps=False \ + python3 smartmontools systemd dbus && \ + dnf clean all + COPY dist/{pkg_name} /tmp/{pkg_name} + RUN rpm -ivh /tmp/{pkg_name} + """) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def version(): + return _get_version() + + +@pytest.fixture(scope="module") +def skip_no_docker(): + if not _docker_available(): + pytest.skip("Docker not available") + + +# --------------------------------------------------------------------------- +# Shared assertion functions +# --------------------------------------------------------------------------- + +def _assert_dormant_layout(container: str, fmt: str, version: str) -> None: + """Assert the dormant-install contract (spec §6, §7).""" + # Venv directory exists with expected structure + rc, out = _container_exec(container, "test -d /opt/fenris && echo OK") + assert "OK" in out, "Bundled venv not found at /opt/fenris" + + # Venv Python binary exists (may not execute if shared libs differ) + rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") + assert rc == 0, "Venv Python binary not found" + + # Wrapper on PATH + rc, out = _container_exec(container, "command -v fenris") + assert rc == 0, f"fenris not on PATH: {out}" + + # Wrapper file exists and is executable + rc, _ = _container_exec(container, "test -x /usr/bin/fenris") + assert rc == 0, "Wrapper not found or not executable at /usr/bin/fenris" + + # Wrapper contains the correct version string + 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" + + # Helpers in /usr/libexec/fenris + for helper in ("fenris-monitor", "fenris-collect"): + rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}") + assert rc == 0, f"{helper} not found or not executable" + + # systemd units in vendor placement + for unit in ("fenris-collect.timer", "fenris-collect.service"): + rc, _ = _container_exec(container, f"test -f /usr/lib/systemd/system/{unit}") + assert rc == 0, f"{unit} not found in vendor placement" + + # Polkit policy + rc, _ = _container_exec( + container, + "test -f /usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy", + ) + assert rc == 0, "Polkit policy not found" + + # sysusers and tmpfiles fragments + rc, _ = _container_exec(container, "test -f /usr/lib/sysusers.d/fenris.conf") + assert rc == 0, "sysusers fragment not found" + rc, _ = _container_exec(container, "test -f /usr/lib/tmpfiles.d/fenris.conf") + assert rc == 0, "tmpfiles fragment not found" + + # Placeholder-commented config + rc, out = _container_exec(container, "cat /etc/fenris/fenris.conf") + assert rc == 0, "fenris.conf not found" + assert "device" in out.lower() or "devices" in out.lower() or "#" in out, \ + "Config does not appear to be placeholder-commented" + + # fenris group exists + rc, out = _container_exec(container, "getent group fenris") + assert rc == 0, "fenris group not created" + + # Observation store directory + if fmt == "deb": + rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris") + assert rc == 0, "Observation store directory not created" + parts = out.strip().split() + assert parts[0] == "2750", f"Store dir mode: expected 2750, got {parts[0]}" + assert parts[1] == "root", f"Store dir owner: expected root, got {parts[1]}" + assert parts[2] == "fenris", f"Store dir group: expected fenris, got {parts[2]}" + else: + # rpm: directory owned by package (ghost) + rc, _ = _container_exec(container, "test -d /var/lib/fenris") + assert rc == 0, "Observation store directory not found" + + # Timer is disabled and inactive (dormant) + rc, out = _container_exec( + container, "systemctl is-enabled fenris-collect.timer 2>/dev/null || echo disabled" + ) + assert "disabled" in out.lower() or "masked" in out.lower() or rc != 0, \ + f"Timer should be disabled (dormant), got: {out}" + + rc, out = _container_exec( + container, "systemctl is-active fenris-collect.timer 2>/dev/null || echo inactive" + ) + assert "inactive" in out.lower() or "dead" in out.lower() or rc != 0, \ + f"Timer should be inactive (dormant), got: {out}" + + # No hand-rolled manifest + rc, _ = _container_exec(container, "test -f /var/lib/fenris/manifest.txt") + assert rc != 0, "Legacy manifest.txt should not exist in package install" + + +def _assert_migration_guard(container: str, fmt: str) -> None: + """Assert that install aborts on make-install remnants (spec §7, §9).""" + if fmt == "deb": + # Plant the legacy manifest marker + _container_exec(container, "mkdir -p /var/lib/fenris") + _container_exec(container, "echo '# manifest' > /var/lib/fenris/manifest.txt") + # Attempt install — should fail with migration pointer + rc, out = _container_exec( + container, + "dpkg -i /tmp/fenris_0.3.0_amd64.deb 2>&1 || true", + ) + assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \ + f"Migration guard did not trigger: {out}" + # Clean up marker for subsequent tests + _container_exec(container, "rm -f /var/lib/fenris/manifest.txt") + else: + # Plant the admin unit marker + _container_exec(container, "mkdir -p /etc/systemd/system") + _container_exec( + container, + "echo '[Unit]' > /etc/systemd/system/fenris-collect.timer", + ) + rc, out = _container_exec( + container, + "rpm -ivh /tmp/fenris-0.3.0-1.x86_64.rpm 2>&1 || true", + ) + assert "remnants" in out.lower() or "migrat" in out.lower() or rc != 0, \ + f"Migration guard did not trigger: {out}" + _container_exec( + container, + "rm -f /etc/systemd/system/fenris-collect.timer", + ) + + +def _assert_upgrade_semantics(container: str, fmt: str) -> None: + """Assert upgrade behavior (spec §7, ADR 0007 §6).""" + # Create a fake observation store using system Python + # (venv Python may not execute if host shared libs differ) + _container_exec( + container, + "mkdir -p /var/lib/fenris && " + "python3 -c \"" + "import sqlite3; " + "c = sqlite3.connect('/var/lib/fenris/observations.db'); " + "c.execute('PRAGMA user_version=1'); " + "c.commit(); c.close()\"", + ) + + if fmt == "deb": + # Re-install triggers upgrade path + rc, out = _container_exec( + container, + "dpkg --force-confnew -i /tmp/fenris_*.deb 2>&1 || " + "apt-get install -f -y 2>&1 || true", + ) + else: + rc, out = _container_exec( + container, + "rpm -Uvh /tmp/fenris-*.rpm 2>&1 || true", + ) + + # Snapshot should exist + rc, _ = _container_exec( + container, "test -f /var/lib/fenris/observations.db.bak" + ) + assert rc == 0, "Observation store snapshot not created on upgrade" + + # Original store still exists + rc, _ = _container_exec( + container, "test -f /var/lib/fenris/observations.db" + ) + assert rc == 0, "Observation store missing after upgrade" + + +def _assert_removal_semantics(container: str, fmt: str) -> None: + """Assert removal mapping (spec §7).""" + if fmt == "deb": + # remove (not purge) — config and store survive + rc, out = _container_exec( + container, "dpkg --purge fenris 2>&1 || true" + ) + # After purge: config gone, store gone (our postrm removes on purge) + # But the store dir may survive since it's not package-owned + else: + # rpm erase + rc, out = _container_exec( + container, "rpm -e fenris 2>&1 || true" + ) + + # Units removed + rc, _ = _container_exec( + container, "test -f /usr/lib/systemd/system/fenris-collect.timer" + ) + assert rc != 0, "Timer unit should be removed after uninstall" + + # Helpers removed + rc, _ = _container_exec( + container, "test -f /usr/libexec/fenris/fenris-monitor" + ) + assert rc != 0, "Helper should be removed after uninstall" + + # Venv key files removed (directories may remain if non-empty) + rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3") + assert rc != 0, "Venv Python should be removed after uninstall" + + +# --------------------------------------------------------------------------- +# Tests — dormant install (tracer bullet) +# --------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) +def test_dormant_install(skip_no_docker, image, fmt, version): + """Install package in container, assert dormant layout and ownership.""" + pkg = _find_package(fmt) + pkg_name = pkg.name + + # Copy package to build context + build_dir = REPO_ROOT / "build" / "test-container" + 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, + ) + + # Write Dockerfile + if fmt == "deb": + dockerfile = _build_deb_dockerfile(image, pkg_name) + else: + dockerfile = _build_rpm_dockerfile(image, pkg_name) + (build_dir / "Dockerfile").write_text(dockerfile) + + # Build image + tag = f"fenris-test-{image.replace(':', '-').replace('/', '-')}" + subprocess.run( + ["docker", "build", "-t", tag, str(build_dir)], + check=True, + capture_output=True, + timeout=300, + ) + + # Run container + container = f"fenris-test-{os.getpid()}" + subprocess.run( + [ + "docker", "run", "-d", "--name", container, + "--tmpfs", "/tmp:exec,size=64m", + tag, "sleep", "infinity", + ], + check=True, + capture_output=True, + ) + + try: + _assert_dormant_layout(container, fmt, version) + finally: + subprocess.run( + ["docker", "rm", "-f", container], + capture_output=True, + ) + + +# --------------------------------------------------------------------------- +# Tests — migration guard +# --------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) +def test_migration_guard(skip_no_docker, image, fmt, version): + """Assert install aborts on make-install remnants.""" + pkg = _find_package(fmt) + pkg_name = pkg.name + + build_dir = REPO_ROOT / "build" / "test-container-guard" + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "dist").mkdir(exist_ok=True) + subprocess.run( + ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], + check=True, + ) + + # Dockerfile: install with remnants pre-planted + if fmt == "deb": + dockerfile = textwrap.dedent(f"""\ + FROM {image} + RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-minimal smartmontools systemd systemd-sysv dbus && \ + rm -rf /var/lib/apt/lists/* + COPY dist/{pkg_name} /tmp/{pkg_name} + # Plant make-install remnant BEFORE installing + RUN mkdir -p /var/lib/fenris && echo '# manifest' > /var/lib/fenris/manifest.txt + """) + else: + dockerfile = textwrap.dedent(f"""\ + FROM {image} + RUN dnf install -y --setopt=install_weak_deps=False \ + python3 smartmontools systemd dbus && \ + dnf clean all + COPY dist/{pkg_name} /tmp/{pkg_name} + RUN mkdir -p /etc/systemd/system && \\ + echo '[Unit]' > /etc/systemd/system/fenris-collect.timer + """) + (build_dir / "Dockerfile").write_text(dockerfile) + + tag = f"fenris-guard-{image.replace(':', '-').replace('/', '-')}" + subprocess.run( + ["docker", "build", "-t", tag, str(build_dir)], + check=True, + capture_output=True, + timeout=300, + ) + + container = f"fenris-guard-{os.getpid()}" + subprocess.run( + [ + "docker", "run", "-d", "--name", container, + "--tmpfs", "/tmp:exec,size=64m", + tag, "sleep", "infinity", + ], + check=True, + capture_output=True, + ) + + try: + _assert_migration_guard(container, fmt) + finally: + subprocess.run( + ["docker", "rm", "-f", container], + capture_output=True, + ) + + +# --------------------------------------------------------------------------- +# Tests — upgrade semantics +# --------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) +def test_upgrade_semantics(skip_no_docker, image, fmt, version): + """Assert upgrade snapshots store and preserves config.""" + pkg = _find_package(fmt) + pkg_name = pkg.name + + build_dir = REPO_ROOT / "build" / "test-container-upgrade" + build_dir.mkdir(parents=True, exist_ok=True) + (build_dir / "dist").mkdir(exist_ok=True) + subprocess.run( + ["cp", str(pkg), str(build_dir / "dist" / pkg_name)], + check=True, + ) + + if fmt == "deb": + dockerfile = _build_deb_dockerfile(image, pkg_name) + else: + dockerfile = _build_rpm_dockerfile(image, pkg_name) + (build_dir / "Dockerfile").write_text(dockerfile) + + tag = f"fenris-upgrade-{image.replace(':', '-').replace('/', '-')}" + subprocess.run( + ["docker", "build", "-t", tag, str(build_dir)], + check=True, + capture_output=True, + timeout=300, + ) + + container = f"fenris-upgrade-{os.getpid()}" + subprocess.run( + [ + "docker", "run", "-d", "--name", container, + "--tmpfs", "/tmp:exec,size=64m", + tag, "sleep", "infinity", + ], + check=True, + capture_output=True, + ) + + try: + _assert_upgrade_semantics(container, fmt) + finally: + subprocess.run( + ["docker", "rm", "-f", container], + capture_output=True, + ) + + +# --------------------------------------------------------------------------- +# Tests — removal semantics +# --------------------------------------------------------------------------- + +@pytest.mark.slow +@pytest.mark.parametrize("image,fmt", ALL_TARGETS, ids=[t[0] for t in ALL_TARGETS]) +def test_removal_semantics(skip_no_docker, image, fmt, version): + """Assert removal cleans package-owned files.""" + pkg = _find_package(fmt) + pkg_name = pkg.name + + build_dir = REPO_ROOT / "build" / "test-container-removal" + 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, + ) + + if fmt == "deb": + dockerfile = _build_deb_dockerfile(image, pkg_name) + else: + dockerfile = _build_rpm_dockerfile(image, pkg_name) + (build_dir / "Dockerfile").write_text(dockerfile) + + tag = f"fenris-removal-{image.replace(':', '-').replace('/', '-')}" + subprocess.run( + ["docker", "build", "-t", tag, str(build_dir)], + check=True, + capture_output=True, + timeout=300, + ) + + container = f"fenris-removal-{os.getpid()}" + subprocess.run( + [ + "docker", "run", "-d", "--name", container, + "--tmpfs", "/tmp:exec,size=64m", + tag, "sleep", "infinity", + ], + check=True, + capture_output=True, + ) + + try: + _assert_removal_semantics(container, fmt) + finally: + subprocess.run( + ["docker", "rm", "-f", container], + capture_output=True, + )