feat(packaging): nfpm-based deb + rpm build infrastructure (spec §3-7, ADR 0007)
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 <noreply@commandcode.ai>
This commit is contained in:
co-authored by
CommandCodeBot
parent
b005049733
commit
babc8eeeb1
@@ -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/
|
||||||
@@ -7,3 +7,9 @@ data/fenris.log
|
|||||||
data/history.jsonl
|
data/history.jsonl
|
||||||
data/hourly.jsonl
|
data/hourly.jsonl
|
||||||
plan-dash-changes.md
|
plan-dash-changes.md
|
||||||
|
|
||||||
|
# Packaging build artifacts
|
||||||
|
build/
|
||||||
|
dist/*.deb
|
||||||
|
dist/*.rpm
|
||||||
|
dist/SHA256SUMS*
|
||||||
|
|||||||
@@ -17,7 +17,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
|
.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:
|
help:
|
||||||
@echo "Fenris NVMe endurance monitor"
|
@echo "Fenris NVMe endurance monitor"
|
||||||
@@ -30,6 +30,12 @@ help:
|
|||||||
@echo " test - Run tests"
|
@echo " test - Run tests"
|
||||||
@echo " lint - Run linter"
|
@echo " lint - Run linter"
|
||||||
@echo " update-deps - Update dependency pins"
|
@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 ──────────────────────────────────────────────────────
|
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -216,3 +222,50 @@ 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) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 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 <keyfile>"
|
||||||
|
@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*
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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 <packaging@bongbetic.com>
|
||||||
|
# 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.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: fenris
|
||||||
|
arch: amd64
|
||||||
|
platform: linux
|
||||||
|
version: "${VERSION}"
|
||||||
|
maintainer: Fenris Maintainers <ops@bongbetic.com>
|
||||||
|
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
|
||||||
Executable
+59
@@ -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
|
||||||
Executable
+23
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+48
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+86
@@ -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}"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# System user/group for Fenris observation store access
|
||||||
|
# Created by systemd-sysusers during package install
|
||||||
|
g fenris -
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Type Path Mode User Group Age Argument
|
||||||
|
d /var/lib/fenris 2750 root fenris - -
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user