Compare commits
5
Commits
2b05267690
...
b2243a85f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2243a85f7 | ||
|
|
f1e1c0eebc | ||
|
|
8fa86c3bf8 | ||
|
|
babc8eeeb1 | ||
|
|
b005049733 |
@@ -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*
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ _Avoid_: Uptime, sample count
|
|||||||
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
||||||
_Avoid_: Poll, daemon tick
|
_Avoid_: Poll, daemon tick
|
||||||
|
|
||||||
|
**Release**:
|
||||||
|
A published version of Fenris: a version tag, its packages in the channel, and its human-readable change notes, all together; a bare tag is not one.
|
||||||
|
_Avoid_: Tag, upload, build
|
||||||
|
|
||||||
|
**Rollback**:
|
||||||
|
Returning to an earlier release by restoring an observation-store snapshot and then installing that release; installing an older package over a newer store is unsupported.
|
||||||
|
_Avoid_: Downgrade, version pinning (as a promise)
|
||||||
|
|
||||||
**Deliberate disable**:
|
**Deliberate disable**:
|
||||||
A monitoring pause made through Fenris's own control path, closing the monitoring period so the paused time is excluded from the usage habit.
|
A monitoring pause made through Fenris's own control path, closing the monitoring period so the paused time is excluded from the usage habit.
|
||||||
_Avoid_: Manual stop, service stop
|
_Avoid_: Manual stop, service stop
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
|
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1). Amended by [ADR 0007](0007-package-delivery-amends-0004.md): package delivery replaces `make install` as primary; layout/ownership and maintainer-script mechanics per 0007. Runtime semantics (dormant install, polkit-only elevation, snapshot + forward-only migration, one-generation rollback) unchanged.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 7. Package delivery: native deb + rpm packages, amending the installation lifecycle
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://git.bongbetic.com/xavierk/Fenris/issues/42) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/33). This ADR **amends [ADR 0004](0004-install-upgrade-removal-lifecycle.md)** on delivery and file ownership only; every runtime semantic of 0004 — dormant install, polkit-only elevation, observation-store snapshot + forward-only migration, one-generation rollback — is inherited verbatim, restated below where the package delivery changes *who* performs it.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
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`).
|
||||||
|
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`.
|
||||||
|
10. **Migration from make-install systems (new).** Remove-then-install via runbook only — no migration script, no auto-clean. preinst/%pre aborts with a pointer to the runbook if make-install remnants are detected (`/var/lib/fenris/manifest.txt` or `/etc/systemd/system/fenris-collect.timer`). Store and config survive by path continuity; the migration resets the system to dormant and the user opts back in with `fenris monitor resume`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Package installs, upgrades, and removals carry dpkg/rpm-native semantics; nothing in Fenris's own tooling duplicates them.
|
||||||
|
- The manifest was 0004's answer to "what did the installer place"; the package database answers it better, and uninstall-keeps-store now holds by package ownership rather than by manifest discipline.
|
||||||
|
- `make install` and packages are mutually exclusive per machine; over-install is blocked, not repaired (stale `/etc` units would silently shadow vendor units).
|
||||||
|
- Hand-edited configuration remains the model: the device selector is a root-edited file in every delivery, keeping the polkit surface at exactly one binary.
|
||||||
|
- Release mechanics — channel, signing, cadence, rollback documentation — are fixed in the [release and packaging specification](../spec/release-packaging.md) and the tickets it cites; this ADR deliberately stops at lifecycle semantics.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Research: deb + rpm packaging toolchain for bundled-venv builds
|
||||||
|
|
||||||
|
Issue: #34 (parent plan: #33) — branch `research/toolchain`
|
||||||
|
Date: 2026-09-03 · target: Fenris 0.3.0, x86_64, Debian 12 / Ubuntu 22.04+24.04 / Fedora 40+
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
**Recommended: nfpm** with a build script that stages a `--copies` venv at
|
||||||
|
`/opt/fenris`. One `nfpm.yaml` is the single source of truth for both formats;
|
||||||
|
`nfpm pkg -p deb && nfpm pkg -p rpm` (one invocation per format — `-p` takes a
|
||||||
|
single string, verified in `internal/cmd/package.go`). Actively maintained
|
||||||
|
(releases v2.47.0, 2026-06-20; repo pushed 2026-08-31). Runner-up: fpm (active,
|
||||||
|
v1.18.0 gem 2026-08-26), but its "config" is a long CLI invocation per format —
|
||||||
|
the single source of truth degrades into a shell script. dh-virtualenv is
|
||||||
|
deb-only and its last upstream release is 2020-10 (effectively dormant);
|
||||||
|
rpmbuild spec is rpm-only and cannot share file lists with a deb build without
|
||||||
|
external generation.
|
||||||
|
|
||||||
|
## Constraint evidence: distro textual is unusable (mostly)
|
||||||
|
|
||||||
|
| Distro | python3-textual | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Debian 12 (bookworm) | **0.1.13-1** | https://packages.debian.org/bookworm/python3-textual |
|
||||||
|
| Ubuntu 22.04 (jammy) | **0.1.13-1** | https://packages.ubuntu.com/jammy/python3-textual |
|
||||||
|
| Ubuntu 24.04 (noble) | **0.1.13-1** | https://packages.ubuntu.com/noble/python3-textual |
|
||||||
|
| Fedora 40 | 0.48.1 | https://src.fedoraproject.org/rpms/python-textual (f40 spec) |
|
||||||
|
| Fedora 41 / 42 / 43 | 0.69.0 / 1.0.0 / 4.0.0 | same spec, f41–f43 branches |
|
||||||
|
|
||||||
|
Fenris declares `textual>=0.40.0` (pyproject) but pins `textual==8.2.8`
|
||||||
|
(requirements.txt). Debian 12 + Ubuntu 22.04/24.04 are ~1 major era behind even
|
||||||
|
the *floor*; Fedora 40 technically meets `>=0.40` but not the pin. Verdict
|
||||||
|
unchanged: **vendor deps inside the package for all targets**; per-format
|
||||||
|
`depends:` only on `python3 (>= 3.9)`, `smartmontools`, `systemd`.
|
||||||
|
|
||||||
|
## Tool-by-tool
|
||||||
|
|
||||||
|
### 1. nfpm (goreleaser) — RECOMMENDED
|
||||||
|
|
||||||
|
- **Route:** Makefile target builds staging tree → one `nfpm.yaml` →
|
||||||
|
`nfpm package -p deb` + `nfpm package -p rpm`. (Goreleaser release pipeline
|
||||||
|
can wrap both later.)
|
||||||
|
- **Shared assets:** version, description, maintainer, `depends`,
|
||||||
|
`contents:` file list, `scripts:` all live once in `nfpm.yaml`; per-format
|
||||||
|
deltas via `overrides: { deb: ..., rpm: ... }` and `packager:`-scoped
|
||||||
|
content entries (https://nfpm.goreleaser.com/configuration/, source
|
||||||
|
`www/content/docs/configuration.md`).
|
||||||
|
- **Prerequisites:** single static Go binary (`go install
|
||||||
|
github.com/goreleaser/nfpm/v2/cmd/nfpm@latest`, Homebrew, or release
|
||||||
|
tarball — https://nfpm.goreleaser.com/install/). No toolchain per distro,
|
||||||
|
no root, no containers required (build same tree for both formats).
|
||||||
|
- **Venv → file list:** stage with `python3 -m venv --copies staging/opt/fenris
|
||||||
|
&& staging/opt/fenris/bin/pip install dist/fenris-*.whl`; map in one entry:
|
||||||
|
`contents: [{ src: staging/opt/fenris/, dst: /opt/fenris, type: tree }]`.
|
||||||
|
Shebangs point at fixed absolute `/opt/fenris/bin/python` → no relocation
|
||||||
|
issues. `--copies` avoids symlink-to-/usr breakage. Config file →
|
||||||
|
`type: config|noreplace` (=%config(noreplace) on rpm, conffile semantics on
|
||||||
|
deb). `/var/lib/fenris` store → `type: ghost` (rpm: owned-but-not-packed;
|
||||||
|
deb: ignored → create in `postinstall` script instead).
|
||||||
|
- **Systemd/polkit/libexec:** plain `contents:` entries —
|
||||||
|
`/usr/lib/systemd/system/fenris-collect.{service,timer}` (or
|
||||||
|
`/etc/systemd/system` to match current Makefile), polkit action at
|
||||||
|
`/usr/share/polkit-1/actions/`, helpers under `/usr/libexec/fenris/`.
|
||||||
|
`scripts:` supports `postinstall` (deb maintainer script / rpm scriptlet) —
|
||||||
|
run `systemctl daemon-reload`, create `/var/lib/fenris` root:fenris 2750,
|
||||||
|
group creation.
|
||||||
|
- **Upgrade/removal:** deb — dpkg replaces all non-conffile files, conffile
|
||||||
|
prompts/preserves (`.dpkg-new`) per Debian Policy ch-files
|
||||||
|
(https://www.debian.org/doc/debian-policy/ch-files.html); removal keeps
|
||||||
|
conffiles + unowned store; purge cleans. rpm — `rpm -U` replaces,
|
||||||
|
`%config(noreplace)` keeps local edits as `.rpmnew`; only owned dirs are
|
||||||
|
removed on erase (nfpm `type: dir` exists precisely to claim ownership —
|
||||||
|
docs warn not to claim distro-owned dirs).
|
||||||
|
- **Maintenance:** very active. goreleaser/nfpm, 2.6k stars, last push
|
||||||
|
2026-08-31, v2.47.0 released 2026-06-20 (GitHub API).
|
||||||
|
|
||||||
|
### 2. fpm — viable, weaker single-source-of-truth
|
||||||
|
|
||||||
|
- **Route:** staging tree (same as above) then
|
||||||
|
`fpm -s dir -t deb ... staging/=/ ; fpm -s dir -t rpm ...`.
|
||||||
|
- **Shared assets:** none declarative — everything is CLI flags
|
||||||
|
(`-n`, `-v`, `--config-files`, `--deb-systemd`, `--directories`,
|
||||||
|
`--after-install`, `--rpm-posttrans`, …). Flag list:
|
||||||
|
https://fpm.readthedocs.io/en/latest/cli-reference.html. The two
|
||||||
|
invocations *will* drift unless wrapped in a Makefile that shares variables;
|
||||||
|
the "single source" is then a shell script, not a checked declarative file.
|
||||||
|
(`--deb-systemd` exists; no rpm-native unit macro — you hand it the unit
|
||||||
|
file plus `--rpm-posttrans` for daemon-reload.)
|
||||||
|
- **Prerequisites:** Ruby + gem (`gem install fpm`) or distro package;
|
||||||
|
building rpm side needs `rpmbuild` present for some features.
|
||||||
|
- **Venv → file list:** `-s dir` maps a directory into the package verbatim —
|
||||||
|
same staging-tree trick as nfpm. `--config-files /etc/fenris` marks
|
||||||
|
conffiles (deb) / %config (rpm).
|
||||||
|
- **Upgrade/removal:** identical downstream semantics to nfpm (native dpkg/rpm
|
||||||
|
behavior); differences are only in how metadata/scripts land in the
|
||||||
|
package.
|
||||||
|
- **Maintenance:** active — releases v1.16.0 (2024-12), v1.17.0 (2025-10),
|
||||||
|
v1.18.0 (2026-08-26); gem 1.18.0 on rubygems; ~11.5k stars. But docs are
|
||||||
|
openly "work in progress" (https://fpm.readthedocs.io/en/latest/).
|
||||||
|
|
||||||
|
### 3. dh-virtualenv (Spotify) — deb-only, dorms
|
||||||
|
|
||||||
|
- **Route:** debhelper add-on: `debian/rules` with
|
||||||
|
`dh $@ --with python-virtualenv --buildsystem=python_distutils`;
|
||||||
|
produces a .deb containing venv at `/opt/venvs/<package>`
|
||||||
|
(`DH_VIRTUALENV_INSTALL_ROOT` overridable, `--builtin-venv` for `python -m
|
||||||
|
venv`). Docs: repo `doc/usage.rst`, `doc/tutorial.rst`
|
||||||
|
(https://github.com/spotify/dh-virtualenv).
|
||||||
|
- **Shared assets:** none with rpm — it cannot emit .rpm at all. Would still
|
||||||
|
need a second toolchain for Fedora → fails the criterion outright.
|
||||||
|
- **Prerequisites:** `build-essential debhelper devscripts equivs` +
|
||||||
|
`dh-virtualenv` (tutorial.rst); Debian 12 still ships it as
|
||||||
|
`dh-virtualenv 1.2.2-1.3` (https://packages.debian.org/bookworm/dh-virtualenv).
|
||||||
|
- **Venv → file list:** automatic — it builds the venv during the debhelper
|
||||||
|
sequence and rewrites shebangs; the .deb owns the whole venv tree. Least
|
||||||
|
manual work of all four, for deb alone.
|
||||||
|
- **Upgrade/removal:** standard dpkg; whole venv tree is package-owned, so
|
||||||
|
`apt remove` deletes it cleanly; `--pypi-url`/requirements handled by tool.
|
||||||
|
- **Maintenance:** last upstream release **1.2.2, 2020-10-22** (GitHub tag);
|
||||||
|
repo last pushed 2024-04-27, RTD docs 404. Effectively dormant upstream —
|
||||||
|
fine via Debian's own packaging, but risky as strategic dependency.
|
||||||
|
- **Bonus fact:** PyPI `dh-virtualenv` project now returns 404 — install only
|
||||||
|
from Debian repo / git.
|
||||||
|
|
||||||
|
### 4. rpmbuild spec + vendored venv — rpm-native, no deb
|
||||||
|
|
||||||
|
- **Route:** hand-written `fenris.spec`: `%install` stage builds venv into
|
||||||
|
`%{buildroot}/opt/fenris`, `%files` lists it plus units/polkit/libexec,
|
||||||
|
`%ghost %attr(2750,root,fenris) /var/lib/fenris`, `%config(noreplace)` for
|
||||||
|
`/etc/fenris`, `systemd_post/preun` macros for the timer. Reference style:
|
||||||
|
https://docs.fedoraproject.org/en-US/packaging-guidelines/.
|
||||||
|
- **Shared assets:** the spec is a second, parallel description of the same
|
||||||
|
file list — nothing is shared with any deb build without generating one
|
||||||
|
side from the other (e.g. generate spec + debian/control from a manifest).
|
||||||
|
Worst single-source-of-truth score.
|
||||||
|
- **Prerequisites:** `rpm-build`, mock/koji for cleanroots; Fedora toolchain
|
||||||
|
knowledge; per-distro `Release:`/dist tag handling.
|
||||||
|
- **Venv → file list:** `%files` line `%{buildroot}/opt/fenris/...` — venv
|
||||||
|
becomes ordinary payload; shebangs already absolute.
|
||||||
|
- **Upgrade/removal:** canonical rpm semantics (same as above) plus real
|
||||||
|
systemd scriptlet macros — the *best-behaved* rpm integration of the four,
|
||||||
|
at the cost of hand-maintained spec.
|
||||||
|
- **Maintenance:** rpmbuild itself is maintained forever (part of RPM), but
|
||||||
|
*your* spec is 100% hand-maintained duplication.
|
||||||
|
|
||||||
|
## Comparison matrix
|
||||||
|
|
||||||
|
| Criterion | nfpm | fpm | dh-virtualenv | rpmbuild spec |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| deb + rpm from one config | ✅ one YAML (2 invocations) | ⚠️ flags per invocation | ❌ deb only | ❌ rpm only |
|
||||||
|
| File list shared across formats | ✅ `contents:` | ⚠️ per-invocation args | n/a | ❌ |
|
||||||
|
| Vendored venv supported | ✅ staging `type: tree` | ✅ `-s dir` | ✅✅ automatic (deb) | ✅ `%files` |
|
||||||
|
| conffile / %config(noreplace) | ✅ `type: config\|noreplace` | ✅ `--config-files` | ✅ (debhelper) | ✅ `%config(noreplace)` |
|
||||||
|
| ghost store dir | ✅ `type: ghost` | ⚠️ `--rpm-ghost`? (no deb equiv) | ❌ | ✅ `%ghost` |
|
||||||
|
| systemd scriptlets | ✅ `scripts:` + macros? (plain scripts) | ✅ `--deb-systemd`, `--rpm-posttrans` | ✅ (deb) | ✅✅ native macros |
|
||||||
|
| Prereqs on build host | Go binary (or brew/apt tarball) | Ruby gem | debhelper stack | rpm-build + mock |
|
||||||
|
| Maintenance (2026) | 🟢 active (v2.47.0) | 🟢 active (v1.18.0) | 🔴 dormant since 2020 (Debian carries it) | 🟢 tool yes / 🔴 your spec |
|
||||||
|
| Risk | young-ish config schema churn | docs thin | dead upstream | duplication forever |
|
||||||
|
|
||||||
|
## Proposed pipeline (sketch)
|
||||||
|
|
||||||
|
```make
|
||||||
|
# Makefile additions (build only — install target stays for source installs)
|
||||||
|
stage: dist/fenris-*.whl
|
||||||
|
rm -rf build/stage
|
||||||
|
python3 -m venv --copies build/stage/opt/fenris
|
||||||
|
build/stage/opt/fenris/bin/pip install --no-compile dist/fenris-*.whl
|
||||||
|
install -D -m 0755 scripts/fenris build/stage/usr/bin/fenris
|
||||||
|
install -D -m 0755 src/fenris/monitor.py build/stage/usr/libexec/fenris/fenris-monitor
|
||||||
|
install -D -m 0755 src/fenris/collect.py build/stage/usr/libexec/fenris/fenris-collect
|
||||||
|
install -D -m 0644 units/fenris-collect.timer build/stage/usr/lib/systemd/system/fenris-collect.timer
|
||||||
|
install -D -m 0644 units/fenris-collect.service build/stage/usr/lib/systemd/system/fenris-collect.service
|
||||||
|
install -D -m 0644 polkit/com.bongbetic.fenris.monitor.policy \
|
||||||
|
build/stage/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy
|
||||||
|
|
||||||
|
package-deb package-rpm: stage
|
||||||
|
nfpm pkg -f packaging/nfpm.yaml -p deb -t dist/
|
||||||
|
nfpm pkg -f packaging/nfpm.yaml -p rpm -t dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# packaging/nfpm.yaml (excerpt)
|
||||||
|
name: fenris
|
||||||
|
arch: amd64
|
||||||
|
platform: linux
|
||||||
|
version: ${VERSION} # env expansion, documented feature
|
||||||
|
maintainer: Fenris Maintainers <ops@bongbetic.com>
|
||||||
|
description: SMART drive observation daemon with persistent TUI
|
||||||
|
homepage: https://git.bongbetic.com/xavierk/Fenris
|
||||||
|
depends: [smartmontools]
|
||||||
|
contents:
|
||||||
|
- src: build/stage/ # everything above
|
||||||
|
dst: /
|
||||||
|
type: tree
|
||||||
|
- dst: /etc/fenris # config dir; ship fenris.conf as config|noreplace
|
||||||
|
type: dir
|
||||||
|
- src: packaging/fenris.conf
|
||||||
|
dst: /etc/fenris/fenris.conf
|
||||||
|
type: config|noreplace
|
||||||
|
- dst: /var/lib/fenris # rpm: %ghost ownership; deb: create in postinst
|
||||||
|
type: ghost
|
||||||
|
scripts:
|
||||||
|
postinstall: packaging/postinst.sh # groupadd fenris; install -d -o root -g fenris -m 2750 /var/lib/fenris; systemctl daemon-reload (units shipped dormant)
|
||||||
|
preremove: packaging/prerm.sh # stop timer if running
|
||||||
|
overrides:
|
||||||
|
deb:
|
||||||
|
depends: [python3 (>= 3.9), smartmontools]
|
||||||
|
rpm:
|
||||||
|
depends: [python3 >= 3.9, smartmontools]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Adopt **nfpm + staged `--copies` venv**: closest to single source of truth
|
||||||
|
(one YAML for both formats), smallest prerequisite surface (one static binary),
|
||||||
|
actively maintained, and every Fenris constraint (units, polkit, libexec,
|
||||||
|
`/etc/fenris` conffile, `/var/lib/fenris` ghost/store) has a first-class
|
||||||
|
mapping. Keep fpm as documented fallback (identical staging tree, works
|
||||||
|
anywhere Ruby exists). Do not build the release pipeline on dh-virtualenv
|
||||||
|
(dormant, deb-only) or on a hand-maintained spec file (duplication, deb side
|
||||||
|
unaddressed).
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- nfpm config reference: https://nfpm.goreleaser.com/configuration/ (source:
|
||||||
|
goreleaser/nfpm `www/content/docs/configuration.md`, accessed 2026-09-03)
|
||||||
|
- nfpm CLI (single `-p`): goreleaser/nfpm `internal/cmd/package.go`
|
||||||
|
- nfpm releases/status: GitHub API, repo pushed 2026-08-31, v2.47.0 2026-06-20
|
||||||
|
- fpm README + CLI reference: https://github.com/jordansissel/fpm,
|
||||||
|
https://fpm.readthedocs.io/en/latest/cli-reference.html; releases v1.18.0
|
||||||
|
(2026-08-26), gem 1.18.0
|
||||||
|
- dh-virtualenv docs: `doc/usage.rst`, `doc/tutorial.rst` @ master; tag 1.2.2
|
||||||
|
dated 2020-10-22 (GitHub commits API); PyPI project 404;
|
||||||
|
Debian 12 package 1.2.2-1.3 (packages.debian.org)
|
||||||
|
- Distro textual versions: packages.debian.org, packages.ubuntu.com,
|
||||||
|
src.fedoraproject.org `python-textual.spec` f40–f43
|
||||||
|
- Upgrade semantics: Debian Policy ch-files
|
||||||
|
(https://www.debian.org/doc/debian-policy/ch-files.html); Fedora packaging
|
||||||
|
guidelines (https://docs.fedoraproject.org/en-US/packaging-guidelines/);
|
||||||
|
RPM directive behavior quoted in nfpm config docs (%ghost, %config(noreplace))
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Research: Gitea 1.27 Debian + RPM package registry feasibility
|
||||||
|
|
||||||
|
Issue: [Fenris deb + rpm release plan](https://git.bongbetic.com/xavierk/Fenris/issues/33) →
|
||||||
|
[Research: Gitea 1.27 Debian + RPM package registry feasibility](https://git.bongbetic.com/xavierk/Fenris/issues/35)
|
||||||
|
Verified 2026-09-03 against live instance `https://git.bongbetic.com` (reports `1.27.1` via `/api/v1/version`)
|
||||||
|
and primary sources: docs.gitea.com 1.27 Debian/RPM registry pages and Gitea `v1.27.1` source (go-gitea/gitea tag).
|
||||||
|
|
||||||
|
**Verdict: feasible.** Every publish/consume path tested live with throwaway packages `fenris-regtest` (all deleted afterward; package list verified empty).
|
||||||
|
|
||||||
|
## 1. Publish paths (verified live, HTTP 201)
|
||||||
|
|
||||||
|
### Debian (`.deb`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --user xavierk:$TOKEN --upload-file fenris_0.3.0_amd64.deb \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/{distribution}/{component}/upload"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `distribution` and `component` are free-form path segments chosen at upload time (e.g. `bookworm/main`, `noble/main`). Gitea derives apt suites from what was uploaded — verified: same .deb published to `pool/bookworm/main` and `pool/noble/main` (both 201), both then served in `dists/bookworm/` and `dists/noble/` with correct `Suite:`/`Codename:` headers.
|
||||||
|
- Republish of identical name+version+distribution+component+architecture → **409 Conflict** (verified). Must delete first.
|
||||||
|
|
||||||
|
### RPM (`.rpm`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# no group (flat repo)
|
||||||
|
curl --user xavierk:$TOKEN --upload-file fenris-0.3.0-1.el9.x86_64.rpm \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/rpm/upload"
|
||||||
|
# with group (distro tag, nestable)
|
||||||
|
curl --user xavierk:$TOKEN --upload-file fenris-0.3.0-1.fc40.x86_64.rpm \
|
||||||
|
"https://git.bongbetic.com/api/packages/xavierk/rpm/el9/upload" # e.g. el9, rocky/el9, fc40
|
||||||
|
```
|
||||||
|
|
||||||
|
- Group = free-form nesting used to partition repos per distro/track. Verified: publish to root group and `el9` group (both 201), duplicate → 409.
|
||||||
|
- Owner can be the user (`xavierk`) or an org; packages under a public owner are readable anonymously (verified: metadata fetches without auth succeeded).
|
||||||
|
|
||||||
|
## 2. Consumer setup (exact commands)
|
||||||
|
|
||||||
|
### apt clients
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
sudo curl -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/gitea.list # one line per distribution
|
||||||
|
sudo apt update
|
||||||
|
apt install fenris # or fenris=0.3.0
|
||||||
|
# private owner variant: https://{user}:{token}@git.bongbetic.com/api/packages/... in the URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### dnf clients
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/el9.repo
|
||||||
|
# private owner: add user:token into the baseurl inside /etc/yum.repos.d/gitea-xavierk-el9.repo afterwards
|
||||||
|
sudo dnf install fenris # or fenris-0.3.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The served `.repo` (verified live) sets `gpgcheck=1` and points `gpgkey` at `…/rpm/repository.key`, so `dnf` auto-imports on first use.
|
||||||
|
|
||||||
|
## 3. Metadata signing: native, not passthrough
|
||||||
|
|
||||||
|
Gitea **signs generated metadata itself** with per-instance auto-generated PGP keys. Client-side signing config is limited to trusting the served keys.
|
||||||
|
|
||||||
|
- Debian: `dists/{suite}/InRelease` is clearsigned; `Release.gpg` detached sig also served. Key (RSA) fetched from `…/debian/repository.key`, uid literally `(Automatically generated Debian Registry Key; created …)`.
|
||||||
|
- RPM: `repodata/repomd.xml.asc` detached ASCII-armored signature, uid `(RPM Registry)`. Key from `…/rpm/repository.key`.
|
||||||
|
- Both verified with `gpg --verify` → **Good signature** (keys are self-generated; the "not certified" warning is expected and handled by the signed-by/keyring flow above).
|
||||||
|
- The apt `Release` also advertises `Acquire-By-Hash: yes` with MD5/SHA1/SHA256/SHA512 indexes of `Packages`/`.gz`/`.xz` (verified live). RPM repomd carries sha256 checksums for `primary/filelists/other.xml.gz`.
|
||||||
|
|
||||||
|
There is **no bring-your-own-signing-key config** for these registries in 1.27 — trust anchor is the instance's auto keys. For Fenris this is acceptable; TOFU over TLS via the key URLs above.
|
||||||
|
|
||||||
|
## 4. Multi-distro metadata
|
||||||
|
|
||||||
|
- Debian: distributions/suites are implicit — whatever `{distribution}` path segments appear on upload become `dists/{distribution}/` trees with `Suite:`/`Codename:` set to the segment. No server-side list to maintain; adding a new distro = upload with new segment + one more `deb …` sources line. Components likewise (`main`, etc.). Architectures come from each `.deb`'s control stanza (index served as `dists/{dist}/{component}/binary-{arch}/Packages`).
|
||||||
|
- RPM: same via `{group}` path segments (`el9`, `rocky/el9`, …); each group gets its own `repodata/`. No `basearch` filtering — clients pick the group; Gitea publishes whatever RPM arch was uploaded.
|
||||||
|
|
||||||
|
## 5. Version retention
|
||||||
|
|
||||||
|
- Default: **all versions retained indefinitely**; nothing auto-deletes. Old versions stay installable (`apt install fenris=0.2.9`, `dnf install fenris-0.2.9`).
|
||||||
|
- Republishing an existing name+version (deb: same dist/component/arch; rpm: same file name in group) → 409; overwrite requires delete-then-upload.
|
||||||
|
- Optional cleanup rules exist (per owner + package type): `KeepCount`, `KeepPattern`, `RemoveDays`, `RemovePattern`, `MatchFullName` (source: `models/packages/package_cleanup_rule.go`, executed by scheduled `CleanupTask` in `services/packages/cleanup/cleanup.go`). In 1.27.1 they are configurable **only in the web UI** (owner → Packages → Cleanup Rules); no v1 REST route (verified by route table grep of `routers/api/v1/api.go` — probes of `/api/v1/packages/{owner}/cleanuprules…` return 404/409-style errors).
|
||||||
|
- Deletes: format-specific `DELETE …/debian/pool/{dist}/{component}/{name}/{version}/{arch}` and `DELETE …/rpm/{group}/package/{name}/{version}/{arch}` (both verified, 204). Deleting last file removes the version. Generic fallback: `DELETE /api/v1/packages/{owner}/{type}/{name}/{version}`.
|
||||||
|
|
||||||
|
## 6. Release attachment: not supported
|
||||||
|
|
||||||
|
Gitea 1.27.1 has **no package↔release linkage**. Release assets (`…/releases/{id}/assets`) are standalone file uploads; the package model has no release field and no route links them (verified against `v1.27.1` source: `routers/api/v1/repo/release_attachment.go`, `models/packages/`). Options for Fenris releases:
|
||||||
|
|
||||||
|
1. Publish `.deb`/`.rpm` to the registry (real apt/dnf install UX) and reference the registry URLs in release notes.
|
||||||
|
2. Additionally upload tarballs/SHA256SUMS as plain release attachments.
|
||||||
|
3. Generic registry (`PUT /api/packages/{owner}/generic/{name}/{version}/{filename}`) if an untyped artifact store is needed.
|
||||||
|
|
||||||
|
## 7. Caveats for the release plan
|
||||||
|
|
||||||
|
- Owner choice matters: publish under an **org** (e.g. `fenris`) if multiple maintainers need write; `xavierk` user owner works today (token owner is admin).
|
||||||
|
- Metadata access follows owner visibility — public owner → anonymous consumers, no token in URLs (current state, verified). Keep owner public for frictionless installs, or embed `user:token` in sources/baseurl.
|
||||||
|
- apt distro naming should match OS release names (`bookworm`, `trixie`, `noble`) purely for client convention; server accepts anything.
|
||||||
|
- RPM groups should mirror `$distver` (e.g. `el9`, `fc40`) so `.repo` selection is obvious per target.
|
||||||
|
|
||||||
|
## 8. Test log (live, 2026-09-03)
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
|---|---|
|
||||||
|
| `PUT debian/pool/bookworm/main/upload` | 201 |
|
||||||
|
| `PUT debian/pool/noble/main/upload` (multi-dist) | 201 |
|
||||||
|
| `PUT debian` duplicate | 409 (expected) |
|
||||||
|
| `PUT rpm/upload` (no group) | 201 |
|
||||||
|
| `PUT rpm/el9/upload` (group) | 201 |
|
||||||
|
| `PUT rpm` duplicate | 409 (expected) |
|
||||||
|
| `GET debian/repository.key` / `rpm/repository.key` | PGP public keys (200) |
|
||||||
|
| `GET dists/bookworm/{Release,InRelease,Packages}` | correct; `gpg --verify` Good signature |
|
||||||
|
| `GET rpm{,/el9}/repodata/repomd.xml{,.asc}` | 200; Good signature |
|
||||||
|
| `GET rpm{,/el9}.repo` | generated repo files with `gpgcheck=1` |
|
||||||
|
| Cleanup-rules REST probes | 404 (not in v1 API — UI only) |
|
||||||
|
| `DELETE` all four test entries | 204 ×4; package list then empty |
|
||||||
|
|
||||||
|
Sources: [docs.gitea.com 1.27 Debian registry](https://docs.gitea.com/1.27/usage/packages/debian), [docs.gitea.com 1.27 RPM registry](https://docs.gitea.com/1.27/usage/packages/rpm), Gitea source tag `v1.27.1` (`routers/api/v1/api.go`, `models/packages/package_cleanup_rule.go`, `services/packages/cleanup/cleanup.go`), live instance `git.bongbetic.com`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Research: OBS as an alternative build + distribution route
|
||||||
|
|
||||||
|
Resolves [Research: OBS as alternative build + distribution route](https://git.bongbetic.com/xavierk/Fenris/issues/36) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/33).
|
||||||
|
|
||||||
|
- Date: 2026-09-03
|
||||||
|
- Verdict: **Reject OBS now; ship via the self-hosted Gitea 1.27.1 registry** (deb + rpm), and revisit OBS only if publishing reach becomes a goal.
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Evaluate openSUSE Open Build Service (OBS) as the build + distribution route — deb build quality, vendoring `textual>=0.40` via source services (offline sandbox), signing, publishing reach, account/maintenance cost, build latency — against the Gitea registry on our matrix: Debian 12, Ubuntu 22.04/24.04, Fedora 40+, x86_64.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 1. deb build support quality — real, with quirks
|
||||||
|
|
||||||
|
- OBS builds deb via the classic recipe trio: `debian.control`, `debian.rules`, `PACKAGE.dsc` (OBS User Guide §2.3 "Debian: Dsc"). The build phase runs `dpkg-buildpackage` on Debian-based distributions (§25.1.3 "Package Build"); Debian build environments can alternatively use the `debootstrap` build engine (§"Configuration File Syntax", `BuildEngine`).
|
||||||
|
- Quirk: release numbers are **not** auto-incremented across rebuilds unless the dsc carries `DEBTRANSFORM-RELEASE` (§2.3) — a packaging decision we'd own either way.
|
||||||
|
- Upstream build deps are available: `dh-virtualenv` and `dh-python` exist in Debian 12 (packages.debian.org, checked 2026-09-03), so the ADR-0004 venv/lockfile design maps onto an OBS dsc without patching the build root.
|
||||||
|
- All five matrix targets exist as public OBS build roots: `Debian:12`, `Ubuntu:22.04`, `Ubuntu:24.04`, `Fedora:40`, `Fedora:41` — each project `_meta` answered HTTP 200 on build.opensuse.org (checked 2026-09-03).
|
||||||
|
- Live proof of deb publishing quality: `isv:ownCloud:desktop/Debian_10` on download.opensuse.org serves a proper Debian archive (`Release`, `Release.gpg`, `InRelease` all HTTP 200, checked 2026-09-03).
|
||||||
|
|
||||||
|
### 2. Vendoring textual≥0.40 — the offline sandbox forces the same work we already planned
|
||||||
|
|
||||||
|
- The build environment has **no network**: "services requiring external network access are likely to fail in [buildtime] mode, because such access is not available if the build workers are running in secure mode (as is always the case at https://build.opensuse.org)" (User Guide §7.2, "Modes of Source Services"); Dockerfile builds likewise run "in a safe build environment without network access" (§29.3).
|
||||||
|
- Vendoring must therefore happen **before** the build, via source services that run server-side on commit (`default`/`trylocal` modes, §7.2) or via files committed to the package. The standard services are per-file fetchers — `download_url` (§22.1.3), `download_files`, `obs_scm`/`tar`/`set_version` (§8 SCM integration) — there is no "pip resolve" service, so a pinned dependency tree like Fenris's means either N `download_url` entries mirroring the committed lockfile, or simply committing the vendored wheel/sdist tree.
|
||||||
|
- Conclusion: OBS does not remove the vendoring step; it reproduces ADR-0004's committed-lockfile design with extra XML. Since distro `python3-textual` is 0.1.13 on Debian 12, Ubuntu 22.04 and 24.04 (packages.debian.org / packages.ubuntu.com, checked 2026-09-03) — far below the `>=0.40` floor — vendoring is unavoidable on any route.
|
||||||
|
|
||||||
|
### 3. Signing — OBS key, not ours; Gitea deb repo is our key
|
||||||
|
|
||||||
|
- OBS signs published repositories with the **instance's** key: one signer per partition "calls an external tool to execute the signing" (User Guide §23 "OBS Architecture", Signer); consumers accept the OBS repo key ("When prompted, accept the GPG key of the download repository", §1.10). A build.opensuse.org user cannot upload a personal signing key. Trust therefore flows to openSUSE infra, and the signature says nothing about Fenris's maintainers.
|
||||||
|
- Gitea 1.27.1's Debian registry serves apt metadata signed with the Gitea instance's PGP key (`repository.key` endpoint, `signed-by` in sources.list — docs.gitea.com, "Debian Package Registry"), i.e. **our** host and **our** key. The RPM registry serves a `.repo` endpoint but documents no GPG signing of repodata; rpm-file signing stays our choice at build time.
|
||||||
|
|
||||||
|
### 4. Publishing reach — OBS wins reach; reach is not our bottleneck
|
||||||
|
|
||||||
|
- OBS publishes home-project results to `https://download.opensuse.org/repositories/home:USER/<dist>` (§1.10) and offers generated download pages on software.opensuse.org (§17.4). That is genuine CDN-class reach.
|
||||||
|
- Caveats from the same docs: branched projects are **not** published by default (§1.10), and the repo is a live view of the project state — no release artefact pinning; deleting the project or flag disables distribution.
|
||||||
|
- The Gitea route's reach is exactly `git.bongbetic.com` plus whatever the README says — adequate for a named four-distro matrix whose users follow our instructions, and it keeps the release artefact under versioned control on the same host as the source.
|
||||||
|
|
||||||
|
### 5. Account and maintenance cost — strictly additive
|
||||||
|
|
||||||
|
- Using build.opensuse.org requires an openSUSE account (single sign-on; the web UI's "Sign up!") and work happens in `home:USERNAME` plus permitted subprojects (§"Setting Up Your Home Project for the First Time"; §23 "OBS Concepts" on home projects).
|
||||||
|
- Day-to-day: `osc` + `_service` XML + dsc/spec recipes maintained in OBS's own package VCS, kept in sync with Fenris's git. The SCM bridge (`scmsync`) does support self-hosted Gitea ("We also support Self-Hosted instances from GitHub, GitLab and Gitea", §8.1.3; setup in §28.1.2 — build descriptions must live in the repo's top level), but it also disables OBS-side workflows (no `_link` merging, limited workflows, §28.1.1).
|
||||||
|
- No published quota/SLA for the public instance; capacity and availability are a shared commons. The Gitea route needs zero new accounts, zero new artefact formats beyond the two package recipes we must write anyway, and reuses the existing release host.
|
||||||
|
|
||||||
|
### 6. Build latency — shared queue vs. deterministic local
|
||||||
|
|
||||||
|
- OBS routes every commit through scheduler → dispatcher → shared workers; the dispatcher "tries to assign jobs fairly between the project repositories" using a per-repository load model (§23, Scheduler/Dispatcher). For our five tiny x86_64 jobs this is typically minutes, but there is no documented SLA and the queue is global — worst case is unbounded (estimate; the docs guarantee only fairness, not latency).
|
||||||
|
- The Gitea route builds wherever `make` runs and publishes with one authenticated `PUT` per artefact (docs.gitea.com: Debian `PUT .../pool/{distribution}/{component}/upload`; RPM `PUT .../rpm/{group}/upload`). Latency = build time, fully under our control.
|
||||||
|
|
||||||
|
## Comparison on the 4-distro matrix
|
||||||
|
|
||||||
|
| Axis | OBS (build.opensuse.org) | Gitea 1.27.1 registry |
|
||||||
|
|---|---|---|
|
||||||
|
| Debian 12 / Ubuntu 22.04/24.04 deb | dsc + dpkg-buildpackage; DEBTRANSFORM-RELEASE quirk | we build the same deb locally, upload via PUT |
|
||||||
|
| Fedora 40+ rpm | spec + rpmbuild in Fedora roots | same spec built locally, `.repo` grouping (`fedora/40`) |
|
||||||
|
| Vendoring textual≥0.40 | offline sandbox forces committed vendored tree (no pip service) | same committed vendored tree (ADR-0004 lockfile) |
|
||||||
|
| Signing | OBS instance key (not ours) | deb repo signed with our key; rpm repodata unsigned |
|
||||||
|
| Reach | download.opensuse.org CDN + software.o.o pages | our domain only |
|
||||||
|
| Accounts/infra | new openSUSE account, osc workflow, commons SLA-free | zero new infra |
|
||||||
|
| Latency | global shared queue, minutes typical, no SLA | deterministic (local build) |
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Reject OBS as the build + distribution route for Fenris.** The offline sandbox forces the exact vendoring work the Gitea route already requires, so OBS adds cost (account, osc/source-service maintenance, external commons in the release path, queue latency) without removing any; its one real advantage — CDN and software.o.o reach — does not matter for a hobby project whose four target distros are served by one signed apt repo and one rpm repo on the existing Gitea host, under our own key.
|
||||||
|
|
||||||
|
Revisit trigger: if Fenris later wants one-click installs via software.opensuse.org, architectures beyond x86_64, or many more distro targets — the deb publishing quality (verified live) and self-hosted-Gitea SCM bridge make OBS a viable amplifier then.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- OBS User Guide (openbuildservice.org/help/manuals/obs-user-guide/, PDF): §2.3 Debian: Dsc; §7 Using Source Services (offline buildtime services, modes); §8.1.3 Supported SCMs; §17.4 download pages; §22.1.3 download_url; §23 OBS Architecture (Scheduler/Dispatcher/Signer); §25.1.3 Package Build; §28.1 SCM bridge; §29.3 Dockerfile builds (no network); §1.10 Installing Packages from OBS; "Configuration File Syntax" (BuildEngine, Repotype: debian).
|
||||||
|
- Live checks (2026-09-03): `Debian:12`/`Ubuntu:22.04`/`Ubuntu:24.04`/`Fedora:40`/`Fedora:41` project `_meta` on build.opensuse.org (all 200); `isv:ownCloud:desktop/Debian_10` `Release`/`Release.gpg`/`InRelease` on download.opensuse.org (all 200).
|
||||||
|
- packages.debian.org / packages.ubuntu.com (2026-09-03): `python3-textual` 0.1.13 on bookworm, jammy, noble; `dh-virtualenv`, `dh-python` present in bookworm.
|
||||||
|
- docs.gitea.com, "Debian Package Registry" and "RPM Package Registry" (1.27 line): apt sources with `signed-by` + `repository.key`, `PUT` upload endpoints, `.repo` groups.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Fenris release and packaging specification
|
||||||
|
|
||||||
|
**Status: decision-complete.** Assembled by [Task: Compose release spec + ADR amending 0004](https://git.bongbetic.com/xavierk/Fenris/issues/42) from the closed tickets of the Wayfinder map [Fenris deb + rpm release plan](https://git.bongbetic.com/xavierk/Fenris/issues/33). This document is normative for the follow-up **execution effort** that builds and publishes packages; no packages are built here.
|
||||||
|
|
||||||
|
**Canonical roles.** [ADR 0007](../adr/0007-package-delivery-amends-0004.md) records the lifecycle rationale (amending [ADR 0004](../adr/0004-install-upgrade-removal-lifecycle.md)); this document restates the **operative contracts** — compat matrix, channel, toolchain, signing, release mechanics, package layout, maintainer-script behavior, migration — so the executing effort never needs Wayfinder-ticket access. Runtime semantics come from [ADRs 0001–0006](../adr/) and the [redesign specification](fenris-redesign.md) verbatim; nothing here overrides them. Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Release* and *Rollback*.
|
||||||
|
|
||||||
|
**Binding language.** *Must*, *exactly*, and *never* are normative.
|
||||||
|
|
||||||
|
## 1. Compatibility matrix
|
||||||
|
|
||||||
|
| Target | Version | Format | Registry placement |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Debian 12 (bookworm) | — | deb | `debian/pool/bookworm/main` |
|
||||||
|
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
|
||||||
|
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
|
||||||
|
| Fedora 40+ | every release | rpm | `rpm/fenris` group |
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 2. Distribution channel
|
||||||
|
|
||||||
|
- **Channel:** the self-hosted Gitea 1.27.1 package registry at `git.bongbetic.com`, owner public for anonymous consumers ([registry research](../research/gitea-package-registry.md); [OBS rejected](../research/obs-route.md)).
|
||||||
|
- **Single channel.** No stable/testing split — deferred until external users ask to track pre-release builds (map fog). Every published version is retained indefinitely (registry has no REST cleanup; republishing a filename is a 409).
|
||||||
|
- **deb publication:** one deb artifact PUT to each codename pool — `PUT /api/packages/{owner}/debian/pool/{bookworm|jammy|noble}/main/upload`.
|
||||||
|
- **rpm publication:** one rpm artifact PUT to the single `fenris` group — `PUT /api/packages/{owner}/rpm/fenris/upload` — serving Fedora 40+ collectively.
|
||||||
|
- **Consumer setup (install docs, normative):**
|
||||||
|
- apt: keyring file from `…/debian/repository.key` via `signed-by`, one sources line per distribution, instance Debian Registry Key **fingerprint printed beside the curl one-liner** (TOFU hardening).
|
||||||
|
- dnf: `dnf config-manager --add-repo <raw-url of packaging/fenris.repo>` — the in-repo, Fenris-owned `.repo` with `gpgkey` pointing at the published packaging key and `repo_gpgcheck=0`. **Gitea's auto-generated `.repo` is never mentioned in docs**: it sets `gpgcheck=1` against the instance auto-key, which never signed our rpm payload — a trap that breaks installs.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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.
|
||||||
|
- **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).
|
||||||
|
- **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
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 5. Release mechanics
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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).
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 6. Package layout and ownership
|
||||||
|
|
||||||
|
Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm database is the manifest; no `manifest.txt` ships:
|
||||||
|
|
||||||
|
| Artifact | Location | Ownership |
|
||||||
|
|---|---|---|
|
||||||
|
| Bundled venv | `/opt/fenris` | package (tree) |
|
||||||
|
| Wrapper | `/usr/bin/fenris` | package |
|
||||||
|
| 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) |
|
||||||
|
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` | package |
|
||||||
|
| sysusers fragment | `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) | package |
|
||||||
|
| tmpfiles fragment | `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`) | package |
|
||||||
|
| Configuration | `/etc/fenris/fenris.conf` | package as conffile / `%config(noreplace)` — placeholder-commented default, no active selector |
|
||||||
|
| Observation store | `/var/lib/fenris/observations.db` (+ WAL, `.bak`) | **never owned, never ghosted** — the package owns the directory only |
|
||||||
|
|
||||||
|
## 7. Maintainer-script contracts
|
||||||
|
|
||||||
|
- **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 (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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 8. Initial configuration
|
||||||
|
|
||||||
|
- The device selector is **entered by hand**: root edits `/etc/fenris/fenris.conf` (world-readable, exactly one key per [ADR 0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §3). The shipped default is placeholder-commented and carries no active selector — a fresh install reads as a `configuration error`-free dormant system until the first `fenris monitor resume` + edit, exactly the dormant-install contract.
|
||||||
|
- No configuration verb is added to `fenris-monitor`; the polkit surface stays at one binary. (This resolves the open item from [Package ownership + ADR 0004 amendment](https://git.bongbetic.com/xavierk/Fenris/issues/40): nothing in any delivery writes the selector — `make install` never wrote `fenris.conf` either; hand-editing has been the model since ADR 0003.)
|
||||||
|
- On upgrade, local edits survive; a changed package default lands as `.dpkg-new` / `.rpmnew`.
|
||||||
|
|
||||||
|
## 9. Migration from make-install systems
|
||||||
|
|
||||||
|
- **Runbook only** — no migration script, no auto-clean. Population is author machines plus a few testers; store and config survive by path continuity.
|
||||||
|
- **Remove-then-install, mandatory:** `sudo make uninstall` (preserves store + `/etc/fenris`) → `apt install fenris` / `dnf install fenris`. **Over-install is forbidden:** stale `/etc/systemd/system/fenris-collect.*` silently shadows vendor units (systemd precedence), `/usr/local/bin/fenris` shadows `/usr/bin/fenris` on PATH.
|
||||||
|
- **No-move continuity:** `/var/lib/fenris` untouched; existing `fenris` group → sysusers no-op; existing dir → tmpfiles no-op; hand-written `fenris.conf` survives (dpkg ships the default as `.dpkg-new`; rpm as `.rpmnew`); store schema caught up by the upgrade-path migration.
|
||||||
|
- **Reset-to-dormant:** `make uninstall`'s sanctioned disable closes the open period `user_disabled`; after migration the user opts back in with `fenris monitor resume` — one ≤15-min sample gap, honest against the endurance timeline.
|
||||||
|
- **Mutual exclusion:** package and `make install` never on the same machine. The dev loop is checkout + `make test`; a dev-local install mode stays in map fog.
|
||||||
|
|
||||||
|
## 10. Out of scope
|
||||||
|
|
||||||
|
- Building and publishing packages (the follow-up execution effort).
|
||||||
|
- Snap/Flatpak/AppImage/Homebrew, PyPI as an install path.
|
||||||
|
- Stable/testing channel split, COPR/PPA fallback, arm64 — map fog until demand appears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Assembled from [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership + ADR 0004 amendment](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path from make-install systems to packages](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence + stable/testing channel split](https://git.bongbetic.com/xavierk/Fenris/issues/43), [Actions runner availability](https://git.bongbetic.com/xavierk/Fenris/issues/37), and the research tickets [deb + rpm packaging toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/34), [Gitea 1.27 package registry feasibility](https://git.bongbetic.com/xavierk/Fenris/issues/35), [OBS route](https://git.bongbetic.com/xavierk/Fenris/issues/36).*
|
||||||
@@ -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,60 @@
|
|||||||
|
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
|
||||||
|
group: fenris
|
||||||
|
|
||||||
|
scripts:
|
||||||
|
preinstall: packaging/preinst.sh
|
||||||
|
postinstall: packaging/postinst.sh
|
||||||
|
preremove: packaging/prerm.sh
|
||||||
|
postremove: packaging/postrm.sh
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
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
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# postinst — deb install and upgrade paths (spec §7).
|
||||||
|
#
|
||||||
|
# dpkg calls: postinst configure [most-recently-configured-version]
|
||||||
|
# fresh install: $1 = "configure", $2 = ""
|
||||||
|
# upgrade: $1 = "configure", $2 = old version
|
||||||
|
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 — snapshot, migration, daemon-reload, conditional timer restart
|
||||||
|
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
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
# Restart timer only if unit contents changed AND active (spec §7)
|
||||||
|
for unit in fenris-collect.timer; 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
|
||||||
|
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
||||||
|
systemd-sysusers || true
|
||||||
|
systemd-tmpfiles --create || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
;;
|
||||||
|
abort-upgrade|abort-install|disappear)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# postrm — deb post-removal (spec §7, §9).
|
||||||
|
#
|
||||||
|
# dpkg calls: postrm remove (after package files removed)
|
||||||
|
# postrm purge (after conffiles and config removed)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
purge)
|
||||||
|
rm -rf /etc/fenris
|
||||||
|
rm -rf /var/lib/fenris
|
||||||
|
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
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# preinst — abort if make-install remnants detected (spec §7, §9).
|
||||||
|
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 monitoring period (spec §7)
|
||||||
|
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
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RPM %post — post-install/upgrade scriptlet (spec §7).
|
||||||
|
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 — snapshot, migration, daemon-reload, conditional timer restart
|
||||||
|
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
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
for unit in fenris-collect.timer; 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
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RPM %postun — post-uninstall scriptlet (spec §7).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
# Package fully erased — remove config, store, group
|
||||||
|
rm -rf /etc/fenris
|
||||||
|
rm -rf /var/lib/fenris
|
||||||
|
if getent group fenris > /dev/null 2>&1; then
|
||||||
|
groupdel fenris 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
# Package is being erased — sanctioned disable (spec §7)
|
||||||
|
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||||
|
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
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
|
||||||
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,592 @@
|
|||||||
|
"""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) -> 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} /pkg/{pkg_name}
|
||||||
|
RUN dpkg -i /pkg/{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} /pkg/{pkg_name}
|
||||||
|
RUN rpm -ivh /pkg/{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"
|
||||||
|
|
||||||
|
if fmt == "rpm":
|
||||||
|
# rpm-native: config marked noreplace (not replaced on upgrade)
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container,
|
||||||
|
"rpm -qc fenris 2>/dev/null | grep fenris.conf || true",
|
||||||
|
)
|
||||||
|
assert "fenris.conf" in out, "fenris.conf not listed as conffile by RPM"
|
||||||
|
|
||||||
|
# fenris group exists
|
||||||
|
rc, out = _container_exec(container, "getent group fenris")
|
||||||
|
assert rc == 0, "fenris group not created"
|
||||||
|
|
||||||
|
# Observation store directory
|
||||||
|
rc, out = _container_exec(container, "stat -c '%a %U %G' /var/lib/fenris")
|
||||||
|
assert rc == 0, "Observation store directory not created"
|
||||||
|
parts = out.strip().split()
|
||||||
|
assert parts[0] == "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]}"
|
||||||
|
|
||||||
|
if fmt == "rpm":
|
||||||
|
# rpm-native: store dir reported as package-owned (ghost),
|
||||||
|
# but no contents are owned by the package
|
||||||
|
rc, out = _container_exec(container, "rpm -qf /var/lib/fenris 2>/dev/null")
|
||||||
|
assert rc == 0, "Store dir not reported as package-owned by RPM"
|
||||||
|
assert "fenris" in out.lower(), f"Store dir not owned by fenris package: {out}"
|
||||||
|
|
||||||
|
# No files inside the store should be package-owned
|
||||||
|
rc, out = _container_exec(
|
||||||
|
container,
|
||||||
|
"find /var/lib/fenris -mindepth 1 -type f -exec rpm -qf {} \\; 2>&1",
|
||||||
|
)
|
||||||
|
# rpm -qf exits 1 for unowned files; we expect all to be unowned
|
||||||
|
owned = [
|
||||||
|
line for line in out.strip().splitlines()
|
||||||
|
if not line.startswith("not owned by any package")
|
||||||
|
and "is not owned by any package" not in line
|
||||||
|
and rc == 0
|
||||||
|
]
|
||||||
|
assert not owned, f"Store contents owned by RPM (should not be): {owned}"
|
||||||
|
|
||||||
|
# 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, pkg_name: 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,
|
||||||
|
f"dpkg -i /pkg/{pkg_name} 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,
|
||||||
|
f"rpm -ivh /pkg/{pkg_name} 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, pkg_name: str, version: 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":
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
|
# Re-install triggers upgrade path
|
||||||
|
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: manually invoke the post scriptlet with upgrade arguments ($1=2)
|
||||||
|
# because faking a different version in the binary RPM database is not
|
||||||
|
# practical — we only need to verify the scriptlet's upgrade behaviour.
|
||||||
|
_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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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} /pkg/{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} /pkg/{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, pkg_name)
|
||||||
|
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, pkg_name, version)
|
||||||
|
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