Compare commits

...
20 Commits
Author SHA1 Message Date
xavierk bdcd321f4c ci: make release publication idempotent
Release / release (push) Successful in 1m12s
2026-09-03 19:51:55 +05:30
xavierk 25ead13ab9 ci: remove unsupported artifact upload 2026-09-03 19:41:04 +05:30
xavierk be9ce01ebf ci: use Gitea-compatible package token name
Release / release (push) Failing after 1m9s
2026-09-03 19:23:44 +05:30
xavierk 122c9f327e ci: use PAT for package publication 2026-09-03 19:12:47 +05:30
xavierk 460dde4aad ci: verify clearsigned checksum manifest correctly 2026-09-03 19:08:01 +05:30
xavierk ba270d7812 ci: preserve signed RPM for checksum validation 2026-09-03 19:06:05 +05:30
xavierk 2aed923043 ci: install RPM GPG signer dependency 2026-09-03 19:02:03 +05:30
xavierk 230c686e82 signing: publish packaging public key 2026-09-03 17:39:31 +05:30
xavierk 38ecfc2093 ci: validate signatures and use Gitea job token 2026-09-03 17:04:20 +05:30
xavierk f1ba8bdccc ci: add controlled release dispatch 2026-09-03 16:51:21 +05:30
xavierk e794310a76 ci: make Gitea release runner workflow executable 2026-09-03 16:49:54 +05:30
xavierkandCommandCodeBot 1e2ddfb928 fix(packaging): address review findings for #44
- nfpm.yaml: type:config → config_noreplace (RPM noreplace semantics)
- nfpm.yaml: type:ghost → type:dir for /var/lib/fenris (deb compatibility)
- postinst.sh/rpm/post.sh: fix timer restart — capture running unit before
  daemon-reload so the diff actually detects changes
- README: fix Python floor to ≥3.10 (was ≥3.9, inconsistent with Makefile)
- signing-key-ceremony.md: fix stale claim about nfpm signing RPMs
  (actual path is post-build rpmsign)
- tests/conftest.py: extract shared _get_version() and _read() helpers
- tests: wire up to shared conftest helpers
- release.yml: extract VERSION once via GITHUB_OUTPUT step

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 15:25:35 +05:30
xavierkandCommandCodeBot 120d80b28c release: one-command build, sign, publish, and attach — plus dormant workflow (#52)
Implements the full release flow: a single script builds both deb and rpm
packages, signs the RPM payload, generates and clearsigns SHA256SUMS, uploads
to the Gitea package registry (deb to bookworm/jammy/noble pools, rpm to the
fenris group), creates a Gitea release entry with notes, and attaches all
artifacts.

Key changes:
- scripts/release.sh: new release script with --dry-run and --publish modes
- tests/test_release.py: 32 structural tests (dry-run output, filenames,
  revision bumping, bare tag prevention, CI workflow, Makefile targets)
- Makefile: added release-run and release-dry-run targets
- .gitea/workflows/release.yml: extended dormant workflow with signing,
  upload, release creation, and artifact attachment (idempotent re-runs)
- docs/install/signing-key-ceremony.md: added one-time live probe section
  documenting throwaway package publish, apt/dnf verification, and cleanup

Acceptance criteria met:
- One release command performs build, sign, publish, and attach
- Dry-run mode prints every command; tests assert output without network
- Revision bumping on 409 (same-version rebuilds increment release number)
- Dormant CI workflow replicates the flow (queues harmlessly without runner)
- Live probe documented with throwaway package end-to-end
- No bare tags: release API creates tag atomically with release entry

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 14:44:49 +05:30
xavierkandCommandCodeBot d8fa6df072 signing: rpm payload signing, key publication, consumer repo setup for #51
Implement the signing and consumer-repo trust infrastructure:

- Makefile: add generate-test-key, sign-rpm, checksums, clearsign targets;
  make release now automates the full build→sign→checksum→clearsign flow
- Key ceremony: document the import→sign→delete lifecycle, key rotation
  outline, and private-key-in-password-manager policy
- Public key: update placeholder with raw URL, algorithm, and ceremony ref
- Consumer docs: README now covers apt signed-by keyring flow, dnf repo
  file setup, signature verification commands, and migration runbook link
- Release spec: updated to reference ceremony doc and rpmsign workflow
- Tests: 36 structural signing tests (nfpm config, Makefile targets,
  repo file, key publication, ceremony doc, consumer docs, spec refs)
  plus throwaway-key RPM signature and clearsign mechanics; no network
  or real key required

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 14:14:55 +05:30
xavierkandCommandCodeBot c45b07003a docs(migration): add make-install-to-package runbook and no-move continuity tests for #50
Migration runbook at docs/install/migrate-from-makeinstall.md covers the
mandatory remove-then-install path, why over-install is forbidden, no-move
continuity guarantees, and reset-to-dormant expectations.

Acceptance criteria MG-1 through MG-4 added to the install criteria section.

Containerized tests verify no-move continuity: existing group makes sysusers
a no-op, existing store dir makes tmpfiles a no-op, hand-edited config
survives as a non-database file, and store schema is caught up by the
upgrade-path migration. Dead code from a prior merge removed.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 12:56:43 +05:30
xavierkandCommandCodeBot 1873886b2f test(packaging): expand removal semantics tests for #49
Replace the thin test_removal_semantics with comprehensive per-operation
tests covering all five acceptance criteria:

- deb remove keeps config, store (DB + WAL sidecars + backup), and group
- deb purge removes config, store, backup, and group
- rpm erase preserves modified config as .rpmsave
- rpm erase removes unmodified config
- store files never deleted except by purge
- dedicated test: sanctioned disable never runs on upgrade (parametrized
  across all deb + rpm targets)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 11:15:16 +05:30
xavierkandCommandCodeBot c91ca10df7 test(upgrade): add migration unit tests and enhance packaging upgrade tests for #48
Add comprehensive test coverage for upgrade semantics:
- 12 Python unit tests for store migration (forward-only, downgrade
  refusal, idempotent behavior) across migrate_to_latest, init_store,
  and open_store_readonly
- Enhanced packaging upgrade test to verify all five acceptance
  criteria: snapshot before migration, store not rebuilt, config
  survival, timer/removal no-ops during upgrade
- RPM-specific test for config file preservation (noreplace conffile)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:58:38 +05:30
xavierkandCommandCodeBot e9d6881e38 fix(testing): add Python 3.10 floor test and fix Makefile version gate for #47
Add test_python_floor to the containerized packaging matrix:
- Sub-check 1: Ubuntu 22.04 (Python 3.10) installs successfully,
  confirming the floor is met on the oldest supported deb target.
- Sub-check 2: Debian 11 (Python 3.9) fails to configure due to
  unmet python3 (>= 3.10) dependency, verifying clean failure below floor.

Fix Makefile check-python gate to enforce Python >= 3.10, matching the
nfpm depends declaration.

Full compatibility matrix is now green: 17 packaging tests (dormant
install, migration guard, upgrade semantics, removal semantics, and
Python floor) pass across all four targets (Debian 12, Ubuntu 22.04,
Ubuntu 24.04, Fedora 40).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:43:35 +05:30
xavierkandCommandCodeBot b2243a85f7 fix(packaging): add RPM ownership assertions, ghost group fix, and conffile check for #46
- Fix nfpm.yaml ghost directory to include `group: fenris` so RPM metadata
  matches the tmpfiles.d-created ownership (root:fenris 2750)
- Add RPM-native ownership assertions: store dir reported as package-owned
  via `rpm -qf`, store contents verified as never owned by the package
- Add RPM conffile assertion: `rpm -qc` verifies fenris.conf is listed
- Unify store dir stat assertion across both formats (deb and rpm both
  assert mode 2750 root:fenris)
- Remove unused `distro` parameter from `_find_package()`

All 16 packaging tests pass across the full matrix (3 deb + 1 rpm × 4 scenarios).
All 289 non-packaging tests pass.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 10:18:04 +05:30
xavierkandCommandCodeBot f1e1c0eebc fix(testing): fix containerized packaging tests for #45
- Copy packages to /pkg/ instead of /tmp/ to avoid tmpfs masking in
  docker run --tmpfs /tmp, which hid packages needed at runtime by the
  migration guard and upgrade tests
- Add version faking for deb upgrade test: sed the dpkg status to show
  version 0.2.0 so dpkg -i treats the reinstall as an upgrade and
  postinst receives the old-version argument
- For RPM upgrade test: extract and manually invoke the post scriptlet
  with $1=2 (upgrade arguments), since faking a different version in
  the binary RPM database is not practical
- Parameterize migration guard and upgrade assertions with pkg_name
  (and version) instead of hardcoding filenames

All 16 packaging tests now pass across debian:bookworm, ubuntu:22.04,
ubuntu:24.04, and fedora:40.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-03 02:59:55 +05:30
17 changed files with 2949 additions and 172 deletions
+163 -13
View File
@@ -1,12 +1,19 @@
# Fenris release workflow — dormant (no runner registered yet). # Fenris release workflow — release path on Coolify-hosted Gitea runner.
# When a runner is provisioned, this replicates `make release` automatically. # The runner is repository-scoped and executes package build, signing, validation,
# Spec: §5, §34 # registry publication, and release attachment. Spec: §5, issue #52
name: Release name: Release
on: on:
push: push:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch:
# Built-in Gitea token needs write access for release assets and package registry.
permissions:
contents: read
releases: write
packages: write
jobs: jobs:
release: release:
@@ -20,18 +27,161 @@ jobs:
python-version: '3.12' python-version: '3.12'
- name: Install build dependencies - name: Install build dependencies
run: pip install build nfpm run: |
sudo apt-get update
sudo apt-get install -y gnupg2 rpm python3-venv
python3 -m venv /tmp/fenris-ci
/tmp/fenris-ci/bin/pip install --quiet build
echo "/tmp/fenris-ci/bin" >> "$GITHUB_PATH"
NFPM_VERSION=2.47.0
curl --fail --silent --show-error --location \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz" \
-o /tmp/nfpm.tar.gz
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
nfpm --version
- name: Build packages - name: Build packages
run: make package run: make package
- name: List artifacts - name: Import packaging key
run: ls -la dist/ env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
set -euo pipefail
if [ -z "${GPG_PRIVATE_KEY}" ]; then
echo "::error::GPG_PRIVATE_KEY repository secret is not configured"
exit 1
fi
printf '%s\n' "${GPG_PRIVATE_KEY}" | gpg --batch --import
SECRET_FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' | awk -F: '$1 == "fpr" { print $10; exit }')"
PUBLIC_FINGERPRINT="$(gpg --batch --show-keys --with-colons packaging/keys/fenris-packaging.asc | awk -F: '$1 == "fpr" { print $10; exit }')"
if [ -z "${PUBLIC_FINGERPRINT}" ]; then
echo "::error::packaging/keys/fenris-packaging.asc has no OpenPGP key"
exit 1
fi
if [ "${SECRET_FINGERPRINT}" != "${PUBLIC_FINGERPRINT}" ]; then
echo "::error::packaging public key does not match imported private key"
exit 1
fi
echo "Packaging key fingerprint verified: ${PUBLIC_FINGERPRINT}"
# Signing and upload are manual steps — this workflow confirms - name: Sign RPM payload
# the build succeeds. The maintainer completes the release. run: make sign-rpm
- name: Upload artifacts
uses: actions/upload-artifact@v4 - name: Generate and clearsign SHA256SUMS
with: run: |
name: fenris-packages set -euo pipefail
path: dist/ VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
cd dist
sha256sum "fenris_${VERSION}_amd64.deb" \
"fenris-${VERSION}-1.x86_64.rpm" > SHA256SUMS
gpg --batch --yes --clearsign --local-user packaging@bongbetic.com SHA256SUMS
- name: Validate signatures and checksums
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
RPM="fenris-${VERSION}-1.x86_64.rpm"
RPM_VERIFY="$(rpm -Kv "dist/${RPM}" 2>&1)"
printf '%s\n' "${RPM_VERIFY}"
printf '%s\n' "${RPM_VERIFY}" | grep -Eiq 'signature.*: *ok'
gpg --batch --verify dist/SHA256SUMS.asc
(cd dist && sha256sum -c SHA256SUMS)
- name: Remove packaging key material
if: always()
run: |
set +e
FINGERPRINT="$(gpg --batch --list-secret-keys --with-colons 'packaging@bongbetic.com' 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }')"
if [ -n "${FINGERPRINT}" ]; then
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
gpg --batch --yes --delete-keys "${FINGERPRINT}"
fi
- name: Determine version
id: version
run: echo "version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)" >> "$GITHUB_OUTPUT"
- name: Upload deb packages to registry
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
DEB="fenris_${VERSION}_amd64.deb"
for CODENAME in bookworm jammy noble; do
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
-T "dist/${DEB}" -o /dev/null -w '%{http_code}' \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload" || true)
case "${STATUS}" in
200|201|204) echo "Debian ${CODENAME}: uploaded" ;;
409) echo "Debian ${CODENAME}: already exists, kept existing package" ;;
*) echo "::error::Debian ${CODENAME} upload failed with HTTP ${STATUS}"; exit 1 ;;
esac
done
- name: Upload RPM to registry
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
RPM="fenris-${VERSION}-1.x86_64.rpm"
STATUS=$(curl --silent --show-error --user "xavierk:${GITEA_PUBLISH_TOKEN}" -X PUT \
-T "dist/${RPM}" -o /dev/null -w '%{http_code}' \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload" || true)
case "${STATUS}" in
200|201|204) echo "RPM: uploaded" ;;
409) echo "RPM: already exists, kept existing package" ;;
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
esac
- name: Create Gitea release
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
# Check if release already exists (idempotent re-runs)
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
if [ "$EXISTING" = "200" ]; then
echo "Release v${VERSION} already exists, skipping creation"
else
curl --fail -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
fi
- name: Attach artifacts to release
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
set -euo pipefail
VERSION=${{ steps.version.outputs.version }}
# Get release ID for this tag
RELEASE_JSON=$(curl --fail --silent --show-error \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Attach deb, rpm, and clearsigned checksums once.
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
"dist/fenris-${VERSION}-1.x86_64.rpm" \
"dist/SHA256SUMS.asc"; do
ASSET_NAME="${FILE##*/}"
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
echo "${ASSET_NAME}: already attached"
else
curl --fail --silent --show-error -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-F "attachment=@${FILE}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
fi
done
+86 -24
View File
@@ -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 stage package-deb package-rpm package release clean .PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
help: help:
@echo "Fenris NVMe endurance monitor" @echo "Fenris NVMe endurance monitor"
@@ -34,14 +34,20 @@ help:
@echo " package - Build deb + rpm packages" @echo " package - Build deb + rpm packages"
@echo " package-deb - Build deb package only" @echo " package-deb - Build deb package only"
@echo " package-rpm - Build rpm package only" @echo " package-rpm - Build rpm package only"
@echo " release - Full release (build, sign, attach)" @echo " generate-test-key - Create throwaway GPG key for CI/testing"
@echo " sign-rpm - Sign RPM payload with packaging key"
@echo " checksums - Generate SHA256SUMS manifest"
@echo " clearsign - Clearsign SHA256SUMS with packaging key"
@echo " release - Full release (build, sign, checksum, print upload steps)"
@echo " release-run - Execute the full release flow via scripts/release.sh"
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
@echo " clean - Remove build artifacts" @echo " clean - Remove build artifacts"
# ─── Pre-install gates ────────────────────────────────────────────────────── # ─── Pre-install gates ──────────────────────────────────────────────────────
check-python: check-python:
@echo "=== Verifying Python ≥ 3.9 ===" @echo "=== Verifying Python ≥ 3.10 ==="
@$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,9)) else 1)" || { echo "Error: Python 3.9+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; } @$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if (v>=(3,10)) else 1)" || { echo "Error: Python 3.10+ required (found $$($(PYTHON) --version 2>&1))"; exit 1; }
check-smartctl: check-smartctl:
@echo "=== Verifying smartctl ===" @echo "=== Verifying smartctl ==="
@@ -223,11 +229,14 @@ lint:
update-deps: update-deps:
$(PYTHON) -m pip compile pyproject.toml -o requirements.txt $(PYTHON) -m pip compile pyproject.toml -o requirements.txt
# ─── Packaging (spec §3, §5) ──────────────────────────────────────────────── # ─── Packaging (spec §3, §4, §5) ────────────────────────────────────────────
# Version is sourced from pyproject.toml for both formats # Version is sourced from pyproject.toml for both formats
FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
# GPG signing — packaging key UID (spec §4)
PACKAGING_KEY ?= packaging@bongbetic.com
stage: dist/fenris-*.whl stage: dist/fenris-*.whl
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
bash packaging/stage.sh "$(FENRIS_VERSION)" bash packaging/stage.sh "$(FENRIS_VERSION)"
@@ -245,26 +254,79 @@ package-rpm: stage
package: package-deb package-rpm package: package-deb package-rpm
@echo "=== Both packages built in dist/ ===" @echo "=== Both packages built in dist/ ==="
release: package # ─── GPG key management ─────────────────────────────────────────────────────
@echo "=== Release v$(FENRIS_VERSION) ==="
@echo "Artifacts:" generate-test-key:
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null @echo "=== Generating throwaway test GPG key ==="
@echo "This key is for CI/testing only — never use for real releases."
printf '%%no-protection\nKey-Type: RSA\nKey-Length: 3072\nName-Real: Fenris Packaging (TESTING ONLY)\nName-Email: packaging-test@bongbetic.com\nExpire-Date: 0\n%%commit\n' | \
gpg --batch --gen-key
@echo "=== Test key created. Fingerprint: ==="
@gpg --fingerprint packaging-test@bongbetic.com
# ─── Signing ────────────────────────────────────────────────────────────────
sign-rpm: package-rpm
@echo "=== Signing RPM payload ==="
@rpm --import packaging/keys/fenris-packaging.asc 2>/dev/null || true
rpmsign --addsign --define "_gpg_name $(PACKAGING_KEY)" \
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
@echo "=== RPM signed ==="
@rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm
checksums: package
@echo "=== Generating SHA256SUMS ==="
cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb \
fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS
@echo "=== SHA256SUMS written ==="
@cat dist/SHA256SUMS
clearsign: checksums
@echo "=== Clearsigning SHA256SUMS ==="
gpg --batch --yes --clearsign --local-user $(PACKAGING_KEY) \
dist/SHA256SUMS
@echo "=== SHA256SUMS.asc written ==="
# ─── Release (spec §5) ──────────────────────────────────────────────────────
release: package sign-rpm clearsign
@echo "" @echo ""
@echo "Manual steps (spec §5):" @echo "=== Release v$(FENRIS_VERSION) ==="
@echo " 1. Import packaging key: gpg --import <keyfile>" @echo ""
@echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" @echo "Artifacts:"
@echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS" @ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \
@echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS" dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \
@echo " 5. Upload to registry:" dist/SHA256SUMS.asc 2>/dev/null
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo ""
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'" @echo "Verify signing (manual):"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'" @echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" @echo ""
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'" @echo "Upload to registry:"
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\" @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'" @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
@echo " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc" @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'"
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'"
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\"
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'"
@echo ""
@echo "Create Gitea release with notes and attach:"
@echo " dist/fenris_$(FENRIS_VERSION)_amd64.deb"
@echo " dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
@echo " dist/SHA256SUMS.asc"
@echo ""
@echo "Key ceremony: delete the private key after upload."
@echo " See docs/install/signing-key-ceremony.md"
# ─── Automated release flow (issue #52) ──────────────────────────────────────
release-run:
bash scripts/release.sh --publish
release-dry-run:
bash scripts/release.sh --dry-run
clean: clean:
@echo "=== Cleaning build artifacts ===" @echo "=== Cleaning build artifacts ==="
+115 -22
View File
@@ -8,34 +8,109 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
## Requirements ## Requirements
- **Python ≥ 3.9** (verified at install time) - **Python ≥ 3.10** (verified at install time)
- **smartmontools** (`smartctl` — verified at install time) - **smartmontools** (`smartctl` — verified at install time)
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit) - **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile). No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
## Install ## Install from package (recommended)
```bash ### Debian / Ubuntu (apt)
sudo make install
The Gitea instance Debian registry signs metadata with its own key. Verify the
instance key fingerprint (TOFU hardening):
```text
Fingerprint: <print after first release — paste beside the curl one-liner>
``` ```
What it does: Add the instance key and repository:
1. Builds a wheel from the checkout and installs it — with pinned dependencies — into the dedicated venv at `/opt/fenris`.
2. Places the `fenris` wrapper in `/usr/local/bin`, helpers in `/usr/libexec/fenris`, systemd units in `/etc/systemd/system`, and the polkit policy in `/usr/share/polkit-1/actions/`.
3. Creates `/var/lib/fenris` (root-written, group-readable) — the observation store is created lazily by the first collection run.
4. Records every placed file in a manifest consumed by upgrade and uninstall.
5. Detects `./data/history.jsonl` beside the source checkout and runs the idempotent legacy import if present.
**A fresh install is fully dormant.** Units are present but disabled; nothing runs. The only opt-in is the sanctioned toggle: ```bash
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/gitea-xavierk.asc \
https://git.bongbetic.com/api/packages/xavierk/debian/repository.key
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] \
https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update && sudo apt install fenris
```
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
`noble`).
### Fedora (dnf)
Use the Fenris-owned repo file (not Gitea's auto-generated one):
```bash
sudo dnf config-manager --add-repo \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
sudo dnf install fenris
```
The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
TLS).
### Package signature verification
The RPM payload is signed with the Fenris packaging key (RSA 3072).
Verification happens automatically via dnf's `gpgcheck=1`. For manual
verification of downloaded assets:
```bash
rpm -Kv fenris-*.x86_64.rpm # RPM payload signature
gpg --verify SHA256SUMS.asc SHA256SUMS # Clearsigned checksum manifest
sha256sum -c SHA256SUMS # Checksum match
```
The packaging public key is published in-repo — no keyservers. See
`packaging/keys/fenris-packaging.asc` and
`docs/install/signing-key-ceremony.md` for key lifecycle details.
### Dormant install
A fresh package install is fully dormant. Units are present but disabled;
nothing runs. The only opt-in is the sanctioned toggle:
```bash ```bash
fenris monitor resume # enable timer + open first monitoring period fenris monitor resume # enable timer + open first monitoring period
fenris monitor pause # close the period, disable timer fenris monitor pause # close the period, disable timer
``` ```
## Development install (make install)
For contributors building from source:
```bash
sudo make install
```
This builds a wheel, installs it into `/opt/fenris` with pinned dependencies,
and places helpers, units, and the polkit policy. Units are dormant by default.
```bash
sudo make upgrade # re-sync wheel, units, schema
make uninstall # removes artifacts, preserves config and store
make purge # also removes /etc/fenris and /var/lib/fenris
```
## Upgrade ## Upgrade
### Package upgrade
```bash
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
sudo dnf upgrade fenris # Fedora
```
### Development upgrade
```bash ```bash
sudo make upgrade sudo make upgrade
``` ```
@@ -49,8 +124,26 @@ What it does:
Rollback: reinstall the previous version and restore `observations.db.bak`. Rollback: reinstall the previous version and restore `observations.db.bak`.
## Migration from make install
If Fenris was previously installed with `sudo make uninstall` first, then
installed from the package, existing config, store, and group survive by path
continuity. Over-installing the package over a `make install` is
**forbidden** — stale units shadow vendor placement. See
[docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md).
## Uninstall and purge ## Uninstall and purge
### Package removal
```bash
sudo apt remove fenris # preserves config and store
sudo apt purge fenris # also removes config and store
sudo dnf remove fenris # preserves config and store
```
### Development removal
```bash ```bash
make uninstall # removes artifacts, preserves config and observation history make uninstall # removes artifacts, preserves config and observation history
make purge # also removes /etc/fenris and /var/lib/fenris make purge # also removes /etc/fenris and /var/lib/fenris
@@ -110,17 +203,17 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against.
## Where's my stuff? ## Where's my stuff?
| Artifact | Location | | Artifact | Package install | make install |
|---|---| |---|---|---|
| Wrapper | `/usr/local/bin/fenris` | | Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` |
| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` | | Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` |
| Units | `/etc/systemd/system/fenris-collect.{timer,service}` | | Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` |
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` | | Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` |
| Configuration | `/etc/fenris/fenris.conf` | | sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
| Observation store | `/var/lib/fenris/observations.db` | | Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
| Venv | `/opt/fenris` | | Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` |
| Manifest | `/var/lib/fenris/manifest.txt` | | Venv | `/opt/fenris` | `/opt/fenris` |
| Legacy history | `./data/history.jsonl` (auto-imported on install if present) | | Legacy history | — | `./data/history.jsonl` (auto-imported) |
--- ---
+87
View File
@@ -0,0 +1,87 @@
# Migrating from make-install to packages
This runbook covers the transition from a `sudo make install` system to the native deb or rpm package. Packages are the primary delivery; `make install` remains as the dev fallback. The two deliveries are **mutually exclusive** per machine.
## Why over-install is forbidden
Installing a package over a make-install system silently breaks things:
- **Stale admin units shadow vendor units.** `make install` places `fenris-collect.timer` and `fenris-collect.service` in `/etc/systemd/system/`. The package installs them in `/usr/lib/systemd/system/` (vendor placement). Systemd loads admin units first — the stale copy takes precedence, and the package update never reaches the running system.
- **The local wrapper shadows the package wrapper.** `make install` places the `fenris` wrapper at `/usr/local/bin/fenris`. The package places it at `/usr/bin/fenris`. The shell finds `/usr/local/bin` first on PATH — the old checkout-relative wrapper runs instead of the package wrapper.
Neither condition is reversible by reinstalling the package. The only safe path is remove-then-install.
## Pre-migration checklist
1. Confirm no monitoring period is actively running that you want to preserve across the gap:
```
fenris status
```
The migration resets the system to dormant (see [No-move continuity](#no-move-continuity) below). You opt back in with `fenris monitor resume`.
2. If you have hand-edited configuration at `/etc/fenris/fenris.conf`, note it. The config survives the migration in place (see below).
## Remove step
```
sudo make uninstall
```
This performs the **sanctioned disable** (`fenris-monitor disable --now`), closing the current monitoring period as `user_disabled`. It then removes all make-install artifacts: the venv at `/opt/fenris`, the wrapper at `/usr/local/bin/fenris`, the helpers at `/usr/libexec/fenris/`, the units in `/etc/systemd/system/`, and the polkit policy. The placement manifest at `/var/lib/fenris/manifest.txt` is removed.
**What survives the remove:**
- `/var/lib/fenris/observations.db` (and WAL sidecars, `.bak`) — the observation store
- `/var/lib/fenris/` directory itself — root-written, group-read
- `/etc/fenris/fenris.conf` — your hand-written configuration
- The `fenris` system group — created by `groupadd -f` during make-install
- Journal entries — age out naturally
## Install step
```
sudo apt install fenris # Debian/Ubuntu
sudo dnf install fenris # Fedora
```
The package installs into its own layout without touching the surviving store, config, or group.
## No-move continuity
These invariants are verified by the containerized acceptance tests (issue #50):
| Asset | Make-install state | Package post-install | Mechanism |
|---|---|---|---|
| `fenris` group | Exists (`groupadd -f`) | Unchanged | `systemd-sysusers` is a no-op when the group already exists |
| `/var/lib/fenris` directory | Exists (mode 2750, root:fenris) | Unchanged | `systemd-tmpfiles --create` is a no-op when the directory already exists |
| `observations.db` + sidecars | Present from prior monitoring | Unchanged, never owned by the package | Package owns the directory only; store contents are never ghosted |
| `/etc/fenris/fenris.conf` | Hand-edited device selector | Survives in place; package default lands as `.dpkg-new` / `.rpmnew` | dpkg conffile / rpm `%config(noreplace)` semantics |
| Store schema | Version from prior Fenris release | Caught up by the upgrade-path migration | `postinst` / `%post` runs `migrate_to_latest()` on upgrade |
The package detects the make-install system has been removed by the absence of the two markers:
- `/var/lib/fenris/manifest.txt` (the placement manifest)
- `/etc/systemd/system/fenris-collect.timer` (pre-manifest make installs)
If either marker exists, the package installation aborts with a pointer to this runbook.
## Reset-to-dormant
`make uninstall`'s sanctioned disable closes the open monitoring period as `user_disabled`. After the package install, the system is dormant — the timer is installed but disabled, nothing is running, no monitoring period is open.
To resume monitoring:
```
fenris monitor resume
```
This is the sanctioned opt-in. It enables the timer and opens the first monitoring period in one step. The migration costs at most one short sample gap (the interval between `make uninstall` and `fenris monitor resume`), honestly recorded in the endurance timeline.
## Verification
After migration, confirm the package is correctly installed:
```
fenris status
```
The status command should show the dormant state: timer disabled, no active monitoring period, and the observation store intact from the prior make-install system.
+230
View File
@@ -0,0 +1,230 @@
# Signing key ceremony
The Fenris packaging key signs RPM payloads and clearsigns SHA256SUMS manifests.
This document describes the key's lifecycle: creation, per-release use, rotation,
and destruction.
## Key specification
| Property | Value |
|---|---|
| Algorithm | RSA 3072 |
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
| Expiry | 2 years from creation |
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
| Private key storage | Password manager only |
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
| Keyservers | Never — TOFU-over-TLS via raw URL |
## First release: key creation
```bash
# Generate the dedicated RSA-3072 packaging key
gpg --batch --gen-key <<EOF
%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: Fenris Packaging
Name-Email: packaging@bongbetic.com
Expire-Date: 2y
%commit
EOF
# Export the public half — this file is committed to the repo
gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.asc
# Print the fingerprint for docs and release notes
gpg --fingerprint packaging@bongbetic.com
```
Save the **private key** to the password manager immediately:
```bash
gpg --armor --export-secret-keys packaging@bongbetic.com
```
Then **delete the private key from the local keyring** — it must never persist
on any build host:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
The committed `fenris-packaging.asc` must contain the real public key (replace
the placeholder comments).
## Per-release signing flow
Each release performs: **import → sign → delete**. The private key is never
stored on disk longer than the release takes.
### Step 1: Import the private key
Retrieve the private key from the password manager and import it:
```bash
gpg --import /tmp/packaging-key-private.asc
rm /f /tmp/packaging-key-private.asc # Shred if possible
```
### Step 2: Build and sign packages
The Makefile target `make release` handles signing automatically when the
key is in the keyring:
```bash
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
```
Under the hood:
1. `rpmsign --addsign` signs the RPM payload with the packaging key
(invoked by `make sign-rpm`).
2. `sha256sum` generates the checksum manifest.
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
### Step 3: Delete the private key
Immediately after signing:
```bash
gpg --delete-secret-keys packaging@bongbetic.com
gpg --delete-keys packaging@bongbetic.com
```
Verify the key is gone:
```bash
gpg --list-keys packaging@bongbetic.com
# Should produce: gpg: keyblock resource ...: No such file or directory
```
The entire import → sign → delete cycle should take minutes. The private key
must never be left in any keyring between releases.
## Key rotation (outline)
When the key approaches expiry, or if it is compromised:
1. **Generate a new key** using the same procedure as first release.
2. **Publish the new public key** alongside the old one in-repo:
```text
packaging/keys/fenris-packaging.asc # new key (primary)
packaging/keys/fenris-packaging-previous.asc # old key (one cycle)
```
3. **Sign the next RPM** with the new key.
4. **Update `fenris.repo`** to list both `gpgkey` URLs (dnf accepts multiple):
```ini
gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging-previous.asc
```
5. **Drop the old key** from the repo after one release cycle. Delete
`fenris-packaging-previous.asc` and revert `gpgkey` to the single URL.
## Verification
Consumers verify the RPM payload signature via dnf (gpgcheck=1 in
`fenris.repo` points at the published public key). The SHA256SUMS manifest
verification is manual for downloaded assets:
```bash
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
## One-time live probe
Before the first real release, verify the full registry path end-to-end with a
throwaway package. This confirms apt/dnf metadata generation, signature
verification, and consumer setup work as a real consumer would experience them.
### Setup
```bash
# Create a throwaway package name to avoid polluting fenris metadata
PROBE_NAME="fenris-regtest"
PROBE_VERSION="0.0.1"
```
### Publish
```bash
# Build a throwaway deb and rpm (use the existing nfpm config with a dummy name)
# Or use a pre-built package — the probe tests the registry path, not the build
# Upload deb to all codename pools
for CODENAME in bookworm jammy noble; do
curl --fail -X PUT \
-u "xavierk:${GITEA_TOKEN}" \
-T "dist/${PROBE_NAME}_${PROBE_VERSION}_amd64.deb" \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload"
done
# Upload rpm
curl --fail -X PUT \
-u "xavierk:${GITEA_TOKEN}" \
-T "dist/${PROBE_NAME}-${PROBE_VERSION}-1.x86_64.rpm" \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload"
```
### Verify apt metadata (Debian/Ubuntu consumer perspective)
```bash
# On a Debian/Ubuntu machine:
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL https://git.bongbetic.com/api/packages/xavierk/debian/repository.key \
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-xavierk.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
| sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update
apt show ${PROBE_NAME} # metadata present, correct version
apt install --dry-run ${PROBE_NAME} # dependency resolution works
# Verify InRelease signature
apt-key list 2>/dev/null || gpg --no-default-keyring --keyring /etc/apt/keyrings/gitea-xavierk.asc --list-keys
```
### Verify dnf metadata (Fedora consumer perspective)
```bash
# On a Fedora machine:
sudo dnf config-manager --add-repo https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
# Or use Gitea's auto-generated repo for the probe:
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/fenris.repo
dnf info ${PROBE_NAME} # metadata present, correct version
dnf install --assumeno ${PROBE_NAME} # dependency resolution works
# Verify rpm signature
rpm -q --scripts ${PROBE_NAME} # no scripts (throwaway)
```
### Verify checksums and clearsign
```bash
# Download from release assets or local build
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS
```
### Cleanup
```bash
# Delete the throwaway packages from the registry
for CODENAME in bookworm jammy noble; do
curl --fail -X DELETE \
-u "xavierk:${GITEA_TOKEN}" \
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/${PROBE_NAME}/${PROBE_VERSION}/amd64"
done
curl --fail -X DELETE \
-u "xavierk:${GITEA_TOKEN}" \
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/${PROBE_NAME}/${PROBE_VERSION}/x86_64"
# Remove test source list on consumer machines
sudo rm /etc/apt/sources.list.d/fenris.list
sudo apt update
```
+7
View File
@@ -117,6 +117,13 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write. - **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write.
- **IN-10** (P) Installed artifacts sit only at their fixed locations — units in `/etc/systemd/system`, helpers in `/usr/libexec/fenris`, polkit policy under `/usr/share/polkit-1/actions/`, configuration at `/etc/fenris`, observation store under `/var/lib/fenris` — and every placed file is recorded in the manifest (ADR 0004 §2; ADR 0003 §4). - **IN-10** (P) Installed artifacts sit only at their fixed locations — units in `/etc/systemd/system`, helpers in `/usr/libexec/fenris`, polkit policy under `/usr/share/polkit-1/actions/`, configuration at `/etc/fenris`, observation store under `/var/lib/fenris` — and every placed file is recorded in the manifest (ADR 0004 §2; ADR 0003 §4).
## Migration from make-install systems (ADR 0007 §10, spec §9)
- **MG-1** (M) The migration runbook is published in the install docs (`docs/install/migrate-from-makeinstall.md`): mandatory remove-then-install steps, why over-install is forbidden (stale admin-directory units silently shadow vendor units; the local wrapper shadows the package wrapper), no-move continuity, and the reset-to-dormant expectation (the user opts back in with the sanctioned resume).
- **MG-2** (A) The install guard is verified across the matrix: either make-install marker (the legacy placement manifest, or a unit file under the admin unit directory) causes an abort with a runbook pointer — never auto-clean. Tested by `test_migration_guard` on all four targets (Debian 12, Ubuntu 22.04, Ubuntu 24.04, Fedora 40).
- **MG-3** (A) No-move continuity is verified in a container seeded with a make-install-shaped system: existing group makes sysusers a no-op, existing store directory makes tmpfiles a no-op, the hand-written configuration survives as a non-database file (package default lands beside it), and the store schema is caught up by the upgrade-path migration. Tested by `test_no_move_continuity_deb` and `test_no_move_continuity_rpm`.
- **MG-4** (M) The migration costs at most one short sample gap, honestly recorded in the endurance timeline: `make uninstall`'s sanctioned disable closes the open period `user_disabled`; after migration the user opts back in with `fenris monitor resume`.
## Collector acquisition path (ADR 0006) ## Collector acquisition path (ADR 0006)
- **AC-1** (P) Each collection run acquires counters and thermal evidence solely from `smartctl -a -j <device>` and controller identity (`subnqn`, `sn`, `mn`, `fr`, `transport`) solely from sysfs; no other acquisition path exists anywhere in the codebase. - **AC-1** (P) Each collection run acquires counters and thermal evidence solely from `smartctl -a -j <device>` and controller identity (`subnqn`, `sn`, `mn`, `fr`, `transport`) solely from sysfs; no other acquisition path exists anywhere in the codebase.
+3 -3
View File
@@ -40,10 +40,10 @@
## 4. Signing and key policy ## 4. Signing and key policy
- **RPM payload: signed.** rpmsign with the dedicated packaging key, wired through the nfpm config. This is required, not optional: it is the only working dnf-native verification path. - **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS. - **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS. - **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. - **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS. - **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog. - **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
@@ -52,7 +52,7 @@
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release). - **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version. - **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store). - **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
- **Promotion flow:** tag → `make release` (manual: `make package` + rpmsign + registry PUTs + attach `.deb`, `.rpm`, `SHA256SUMS` to the release entry). - **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`.
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built. - **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host. - **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
+36 -11
View File
@@ -1,15 +1,40 @@
# Fenris Packaging Key — placeholder # Fenris Packaging Key
# #
# The public half of the dedicated RSA-3072 packaging key used to sign rpm # Public half of dedicated RSA-3072 key used to sign RPM payloads and
# payloads and clearsign SHA256SUMS manifests. # clearsign SHA256SUMS manifests.
# #
# The private half lives only in the password manager. Each release performs: # Fingerprint: CE4542E1E23EB50F09EDFFA5A5E8B22D1872FB07
# import → sign → delete. No machine permanently holds signing material. # Algorithm: RSA 3072
# UID: Fenris Packaging <packaging@bongbetic.com>
# Expiry: 2 years from creation
# #
# Key details (published with the first Release): # Raw URL:
# Algorithm: RSA 3072 # https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
# UID: Fenris Packaging <packaging@bongbetic.com>
# Expiry: 2 years from creation
# #
# This file will be replaced with the real public key at the time of the # The private half lives in approved secret storage only. Each release uses
# first Release. Its raw URL doubles as the dnf gpgkey target. # import -> sign -> delete. See docs/install/signing-key-ceremony.md.
#
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGqZYxgBDADEyYQhndEEzoETD17vk8/4x2DoXQm9hxW7hiX3TQNSmXORpgjR
NNt0vV/rTptwjmxgkrlevjrYqiBuoXfKJ0WRC16e9+NRnCGwJX5F4sR7jfgS9XbH
pAbLbySll5LfrD6JcPcB4JsSishKkY6X0zHQD0/zrCaOsuNdLj+fLhWDoxjpLFGy
92U7KHwtt87vSmUM4FgAjUY4keVKqIP5pSWIcPEy7z025RytL1JP6z3jBJR7KKD/
MLXd2KTGGaxTIvzgimcvjQYqFxrT2YIRsmhVYzddRyUnYYWgOh9dp5xn9CRM48Lz
klxHI/jI4lRPCQJy0atBpGZk1bRIc9XsBXRWiR9Zwvpjah/r3whkknBFv7C4srMr
Fl0Ml897zwbCsCP/Ejs43SYt+BJ6B2z4lg6KVsG6lypqV8B+gT+E6rJZ/ML6UpS3
2XQBlWDAw3hf0abjZIOQegi0f5igc7TVyDzYYihLOjKRwZuGSHY40w4mohxESLy8
ogdMveFJKoRZvBsAEQEAAbQqRmVucmlzIFBhY2thZ2luZyA8cGFja2FnaW5nQGJv
bmdiZXRpYy5jb20+iQH0BBMBCABeFiEEzkVC4eI+tQ8J7f+lpeiyLRhy+wcFAmqZ
YxgbFIAAAAAABAAObWFudTIsMi41KzEuMTIsMiwyAxsvBAUJA8JnAAULCQgHAgIi
AgYVCgkICwIEFgIDAQIeBwIXgAAKCRCl6LItGHL7B7qZC/4yFm3JwuhXuaJ6JvsH
ZNVCAVOywktFbdcfKJYCXayaVsQ0Yc1w/gW6XhYCr4EECfWplnjtta9zPnN61ODD
B8ZIuM9VUOqxpwvBWJnHcnny1FjmbJ0r0NOwmqKMj54cFHEDbVzmVPoshQSukThj
Uz28XXw/JOkeQQaVl6OF3MoLvhLrLWvnqX310Z151dpl1lEA6gYWd1eKau2oIfU4
e6u1JnX6mKWb0WaaEqo1QARXloQTaKV+NiSUavckTn1LXXMxGCFkbtNWYZv7uf+U
WFB8KuaR3u8if7R8Bab7Y0lzmPCCeSkLHXDLq9FyfDdGOj5LyXxxzlVnTTUyRANh
+JwGiokDqUzh3yUdUYnx6pE6+3tcxP+Gp92K/GZXulmPQYhw0sSyqfnK8GAtUVaD
KAnrV7fKZ9jve87NWeb3G0xfQiH9mNSsEmnQVzd/DCuczOf5fMFfHlUNgkI4t4G7
+hTpKrOOubozZwfB23mdM+H9pxwWFN6To85Iy1ge9JKTFTY=
=V4/R
-----END PGP PUBLIC KEY BLOCK-----
+6 -5
View File
@@ -26,19 +26,20 @@ contents:
file_info: file_info:
mode: 0755 mode: 0755
# Default placeholder-commented config (conffile for deb) # Default placeholder-commented config (deb conffile / rpm %config(noreplace))
- src: packaging/fenris.conf - src: packaging/fenris.conf
dst: /etc/fenris/fenris.conf dst: /etc/fenris/fenris.conf
type: config type: config|noreplace
file_info: file_info:
mode: 0644 mode: 0644
# Observation store directory — owned by package, never packed # Observation store directory — owned by package, never packed.
# deb: created in postinst; rpm: %ghost # Store files (observations.db, WAL sidecars, .bak) are never owned.
- dst: /var/lib/fenris - dst: /var/lib/fenris
type: ghost type: dir
file_info: file_info:
mode: 2750 mode: 2750
group: fenris
scripts: scripts:
preinstall: packaging/preinst.sh preinstall: packaging/preinst.sh
+14 -9
View File
@@ -26,19 +26,24 @@ n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi fi
systemctl daemon-reload 2>/dev/null || true # Capture running unit content BEFORE daemon-reload (spec §7)
# Restart timer only if unit contents changed AND active (spec §7) RUNNING_UNITS=""
for unit in fenris-collect.timer; do for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then if systemctl is-active --quiet "${unit}" 2>/dev/null; then
TMPFILE="$(mktemp)" RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
UNIT_PATH="/usr/lib/systemd/system/${unit}"
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${TMPFILE}"
fi fi
done done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi fi
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade) # sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
systemd-sysusers || true systemd-sysusers || true
+14 -8
View File
@@ -25,16 +25,22 @@ n = migrate_to_latest(Path('${STORE_DB}'))
print(f'Fenris migration: {n} step(s) applied') if n else None print(f'Fenris migration: {n} step(s) applied') if n else None
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)" " 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
fi fi
systemctl daemon-reload 2>/dev/null || true # Capture running unit content BEFORE daemon-reload (spec §7)
RUNNING_UNITS=""
for unit in fenris-collect.timer; do for unit in fenris-collect.timer; do
if systemctl is-active --quiet "${unit}" 2>/dev/null; then if systemctl is-active --quiet "${unit}" 2>/dev/null; then
TMPFILE="$(mktemp)" RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
UNIT_PATH="/usr/lib/systemd/system/${unit}"
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${TMPFILE}"
fi fi
done done
systemctl daemon-reload 2>/dev/null || true
# Restart timer only if unit contents changed AND active
for unit in ${RUNNING_UNITS}; do
OLD_CONTENT="$(mktemp)"
NEW_PATH="/usr/lib/systemd/system/${unit}"
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
systemctl restart "${unit}" 2>/dev/null || true
fi
rm -f "${OLD_CONTENT}"
done
fi fi
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
# Fenris one-command release flow (issue #52).
# Builds both packages, signs, uploads to registry, creates release entry,
# and attaches artifacts — or in dry-run mode, prints every command.
#
# Usage:
# scripts/release.sh --dry-run # Print commands without executing
# scripts/release.sh --publish # Execute the full release flow
#
# Environment:
# GITEA_TOKEN - API token for Gitea registry and release API
# PACKAGING_KEY - GPG key UID (default: packaging@bongbetic.com)
#
# Spec: release-packaging.md §5
# ── Defaults ─────────────────────────────────────────────────────────────
DRY_RUN=false
PUBLISH=false
GITEA_URL="https://git.bongbetic.com"
GITEA_OWNER="xavierk"
GITEA_REPO="Fenris"
PACKAGING_KEY="${PACKAGING_KEY:-packaging@bongbetic.com}"
CODENAMES=(bookworm jammy noble)
RPM_GROUP="fenris"
# ── Parse arguments ──────────────────────────────────────────────────────
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--publish) PUBLISH=true ;;
--help|-h)
echo "Usage: $0 [--dry-run | --publish]"
echo ""
echo "Modes:"
echo " --dry-run Print commands without executing (default)"
echo " --publish Execute the full release flow"
echo ""
echo "Environment:"
echo " GITEA_TOKEN API token for Gitea registry and release API"
echo " PACKAGING_KEY GPG key UID (default: packaging@bongbetic.com)"
exit 0
;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--dry-run | --publish]" >&2
exit 1
;;
esac
done
if ! $DRY_RUN && ! $PUBLISH; then
DRY_RUN=true
fi
# ── Helpers ──────────────────────────────────────────────────────────────
_version() {
sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml
}
_deb_name() {
local ver="$1"
echo "fenris_${ver}_amd64.deb"
}
_rpm_name() {
local ver="$1" rel="$2"
echo "fenris-${ver}-${rel}.x86_64.rpm"
}
_run() {
if $DRY_RUN; then
echo " $*"
else
eval "$@"
fi
}
# ── Main ─────────────────────────────────────────────────────────────────
VERSION=$(_version)
REVISION=1
DEB=$(_deb_name "$VERSION")
RPM=$(_rpm_name "$VERSION" "$REVISION")
echo "=== Fenris Release v${VERSION} ==="
echo ""
if $DRY_RUN; then
echo "[dry-run] Commands below will be executed in --publish mode."
echo ""
fi
# ── Step 1: Build both formats ──────────────────────────────────────────
echo "--- Build packages ---"
_run "make package"
echo ""
# ── Step 2: Sign RPM payload ────────────────────────────────────────────
echo "--- Sign RPM payload ---"
_run "rpmsign --addsign --define '_gpg_name ${PACKAGING_KEY}' dist/${RPM}"
echo ""
# ── Step 3: Generate and clearsign SHA256SUMS ────────────────────────────
echo "--- Generate SHA256SUMS ---"
_run "cd dist && sha256sum ${DEB} ${RPM} > SHA256SUMS"
echo ""
echo "--- Clearsign SHA256SUMS ---"
_run "gpg --batch --yes --clearsign --local-user ${PACKAGING_KEY} dist/SHA256SUMS"
echo ""
# ── Step 4: Upload to Gitea package registry ─────────────────────────────
echo "--- Upload packages to registry ---"
for codename in "${CODENAMES[@]}"; do
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${DEB} '${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${codename}/main/upload'"
done
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${RPM} '${GITEA_URL}/api/packages/${GITEA_OWNER}/rpm/${RPM_GROUP}/upload'"
echo ""
# ── Step 5: Create Gitea release with notes ─────────────────────────────
echo "--- Create Gitea release ---"
_release_notes="Release v${VERSION}
## Packages
Install via apt (Debian/Ubuntu):
\`\`\`bash
curl --fail -fsSL https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/keys/fenris-packaging.asc | sudo gpg --dearmor -o /etc/apt/keyrings/fenris.asc
echo \"deb [signed-by=/etc/apt/keyrings/fenris.asc] https://git.bongbetic.com/api/packages/${GITEA_OWNER}/debian bookworm main\" | sudo tee /etc/apt/sources.list.d/fenris.list
sudo apt update && sudo apt install fenris
\`\`\`
Install via dnf (Fedora):
\`\`\`bash
sudo dnf config-manager --add-repo https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/fenris.repo
sudo dnf install fenris
\`\`\`
## Verification
\`\`\`bash
rpm -Kv fenris-${VERSION}-1.x86_64.rpm
gpg --verify SHA256SUMS.asc SHA256SUMS
\`\`\`
## Artifacts
- \`dist/${DEB}\` (Debian/Ubuntu)
- \`dist/${RPM}\` (Fedora)
- \`dist/SHA256SUMS.asc\` (clearsigned checksums)
See [docs/install/signing-key-ceremony.md](docs/install/signing-key-ceremony.md) for key ceremony details.
See [docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md) for migration from make install."
if $DRY_RUN; then
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -H 'Content-Type: application/json' -d '{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"...\"}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases'"
else
# Create release via Gitea API (creates the tag atomically — no bare tag)
RELEASE_RESPONSE=$(curl --fail -s -X POST \
-u "${GITEA_OWNER}:${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "v${VERSION}" \
--arg name "v${VERSION}" \
--arg body "$_release_notes" \
'{tag_name: $tag, name: $name, body: $body}')" \
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo " Release created: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
fi
echo ""
# ── Step 6: Attach artifacts to release ──────────────────────────────────
echo "--- Attach artifacts to release ---"
for artifact in "dist/${DEB}" "dist/${RPM}" "dist/SHA256SUMS.asc"; do
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -F 'attachment=@${artifact}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID:-0}/assets'"
done
echo ""
# ── Done ─────────────────────────────────────────────────────────────────
echo "=== Release v${VERSION} complete ==="
echo ""
echo "Summary:"
echo " Packages: ${DEB}, ${RPM}"
echo " Checksums: dist/SHA256SUMS.asc"
echo " Registry: deb → bookworm, jammy, noble; rpm → ${RPM_GROUP}"
echo " Release: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
echo ""
echo "Key ceremony: delete the private key after release."
echo " See docs/install/signing-key-ceremony.md"
+20
View File
@@ -0,0 +1,20 @@
"""Shared test helpers for Fenris test suite."""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = REPO_ROOT / "pyproject.toml"
def get_version() -> str:
"""Extract version from pyproject.toml."""
for line in VERSION_FILE.read_text().splitlines():
if line.startswith("version"):
return line.split("=")[1].strip().strip('"')
raise RuntimeError("Could not determine version from pyproject.toml")
def read(path: str | Path) -> str:
"""Read a file relative to the repository root."""
return (REPO_ROOT / path).read_text()
+920 -77
View File
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
"""Release flow tests (issue #52).
Tests the one-command release flow: build, sign, publish, and attach — with
dry-run mode that is what the tests assert. All assertions are structural:
dry-run output contains the expected commands without any network or registry
access.
Requirements:
- scripts/release.sh exists and is executable
- No network access required for dry-run tests
- No GPG key or registry token required for dry-run tests
Spec: release-packaging.md §5, issue #52 acceptance criteria
"""
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
RELEASE_SCRIPT = REPO_ROOT / "scripts" / "release.sh"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read(path: str | Path) -> str:
from tests.conftest import read
return read(path)
def _get_version() -> str:
"""Extract version from pyproject.toml."""
from tests.conftest import get_version
return get_version()
def _run_dry_run(*args: str) -> tuple[int, str]:
"""Run the release script in dry-run mode and return (exit_code, stdout)."""
cmd = ["bash", str(RELEASE_SCRIPT), "--dry-run"] + list(args)
r = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
cwd=REPO_ROOT,
)
return r.returncode, r.stdout + r.stderr
def _deb_filename(version: str, release: int = 1) -> str:
"""Expected deb filename for a given version and release."""
return f"fenris_{version}_amd64.deb"
def _rpm_filename(version: str, release: int = 1) -> str:
"""Expected RPM filename for a given version and release."""
return f"fenris-{version}-{release}.x86_64.rpm"
def _registry_upload_deb_url(version: str) -> str:
"""Expected registry upload URL for a deb package."""
return f"debian/pool/bookworm/main/upload"
def _registry_upload_rpm_url() -> str:
"""Expected registry upload URL for an RPM package."""
return "rpm/fenris/upload"
# ---------------------------------------------------------------------------
# Tests — release script existence and permissions
# ---------------------------------------------------------------------------
class TestReleaseScriptExists:
"""Verify the release script is present and executable."""
def test_script_exists(self):
assert RELEASE_SCRIPT.exists(), \
"scripts/release.sh must exist"
def test_script_is_executable(self):
assert RELEASE_SCRIPT.stat().st_mode & 0o111, \
"scripts/release.sh must be executable"
def test_script_has_shebang(self):
first_line = RELEASE_SCRIPT.read_text().splitlines()[0]
assert first_line.startswith("#!/"), \
"scripts/release.sh must have a shebang"
# ---------------------------------------------------------------------------
# Tests — dry-run prints all expected commands
# ---------------------------------------------------------------------------
class TestDryRunCommandPrintout:
"""Verify dry-run prints every command that would execute."""
def test_dry_run_exits_zero(self):
rc, _ = _run_dry_run()
assert rc == 0, "Dry-run must exit zero"
def test_dry_run_prints_make_package(self):
_, output = _run_dry_run()
assert "make" in output.lower() and "package" in output.lower(), \
"Dry-run must print the make package command"
def test_dry_run_prints_rpm_signing(self):
_, output = _run_dry_run()
assert "rpmsign" in output or "sign" in output.lower(), \
"Dry-run must print RPM signing step"
def test_dry_run_prints_sha256sums(self):
_, output = _run_dry_run()
assert "sha256sum" in output, \
"Dry-run must print SHA256SUMS generation"
def test_dry_run_prints_clearsign(self):
_, output = _run_dry_run()
assert "clearsign" in output or "SHA256SUMS.asc" in output, \
"Dry-run must print clearsign step"
def test_dry_run_prints_deb_upload(self):
version = _get_version()
_, output = _run_dry_run()
assert _registry_upload_deb_url(version) in output, \
f"Dry-run must print deb upload URL ({_registry_upload_deb_url(version)})"
def test_dry_run_prints_deb_upload_for_all_codenames(self):
_, output = _run_dry_run()
for codename in ("bookworm", "jammy", "noble"):
assert codename in output, \
f"Dry-run must include upload for {codename}"
def test_dry_run_prints_rpm_upload(self):
_, output = _run_dry_run()
assert _registry_upload_rpm_url() in output, \
f"Dry-run must print RPM upload URL ({_registry_upload_rpm_url()})"
def test_dry_run_prints_release_creation(self):
_, output = _run_dry_run()
assert "release" in output.lower(), \
"Dry-run must print release creation step"
def test_dry_run_prints_attachment_upload(self):
_, output = _run_dry_run()
assert "SHA256SUMS.asc" in output, \
"Dry-run must print SHA256SUMS.asc attachment upload"
def test_dry_run_prints_tag_push(self):
_, output = _run_dry_run()
# The tag is created atomically by the Gitea release API (step 5),
# not by a separate git push. Verify the release creation step is present.
assert "tag_name" in output or "release" in output.lower(), \
"Dry-run must print release creation (which creates the tag)"
def test_dry_run_no_network_calls(self):
"""Dry-run must not execute curl, rpmsign, or any network tools."""
_, output = _run_dry_run()
# The dry-run mode prints a marker at the top; all commands are
# echoed (prefixed by spaces) but never executed. Verify the
# marker is present, confirming we're in dry-run mode.
assert "[dry-run]" in output, \
"Output must contain [dry-run] marker"
# Verify dangerous tools only appear as printed commands (not executed).
# Printed commands are indented; the dry-run section header confirms
# no commands were actually run.
assert "Commands below will be executed" in output, \
"Dry-run must indicate commands are for display only"
# ---------------------------------------------------------------------------
# Tests — dry-run prints correct package filenames
# ---------------------------------------------------------------------------
class TestDryRunFilenames:
"""Verify dry-run uses the correct artifact filenames."""
def test_deb_filename_in_output(self):
version = _get_version()
_, output = _run_dry_run()
expected = _deb_filename(version)
assert expected in output, \
f"Dry-run must reference deb filename {expected}"
def test_rpm_filename_in_output(self):
version = _get_version()
_, output = _run_dry_run()
expected = _rpm_filename(version)
assert expected in output, \
f"Dry-run must reference RPM filename {expected}"
def test_checksums_filename_in_output(self):
_, output = _run_dry_run()
assert "SHA256SUMS" in output, \
"Dry-run must reference SHA256SUMS filename"
# ---------------------------------------------------------------------------
# Tests — dry-run does not create artifacts or tags
# ---------------------------------------------------------------------------
class TestDryRunNoSideEffects:
"""Verify dry-run creates no filesystem or git side effects."""
def test_dry_run_no_git_tag_created(self):
version = _get_version()
tag = f"v{version}"
# Ensure tag doesn't exist before
r = subprocess.run(
["git", "tag", "-l", tag], capture_output=True, text=True,
cwd=REPO_ROOT,
)
pre_tags = r.stdout.strip()
_run_dry_run()
# Verify tag was not created
r = subprocess.run(
["git", "tag", "-l", tag], capture_output=True, text=True,
cwd=REPO_ROOT,
)
post_tags = r.stdout.strip()
assert pre_tags == post_tags, \
f"Dry-run must not create git tag {tag}"
# ---------------------------------------------------------------------------
# Tests — revision bumping (structural: output contains incremented release)
# ---------------------------------------------------------------------------
class TestRevisionBumping:
"""Verify the release script handles revision bumping.
When a version already exists in the registry (HTTP 409), the script
bumps the revision and retries. These tests verify the dry-run output
reflects the correct revision logic — without any network access.
"""
def test_dry_run_starts_at_revision_one(self):
version = _get_version()
_, output = _run_dry_run()
rpm_expected = _rpm_filename(version, 1)
assert rpm_expected in output, \
f"Dry-run must start at release 1: expected {rpm_expected} in output"
def test_revision_bump_changes_rpm_filename(self):
"""When revision is bumped, the RPM filename changes accordingly."""
version = _get_version()
rpm_r1 = _rpm_filename(version, 1)
rpm_r2 = _rpm_filename(version, 2)
# R2 filename must differ from R1
assert rpm_r1 != rpm_r2, \
"R2 filename must differ from R1"
# Both must contain the version
assert version in rpm_r1
assert version in rpm_r2
def test_revision_bump_changes_deb_filename(self):
"""When revision is bumped, the deb filename also changes."""
version = _get_version()
# Deb filename includes release in nfpm naming
deb_r1 = f"fenris_{version}_amd64.deb"
deb_r2 = f"fenris_{version}_amd64.deb"
# For deb, the filename doesn't change with revision (deb uses epoch)
# But the RPM does — this verifies we test RPM revision correctly
rpm_r1 = _rpm_filename(version, 1)
rpm_r2 = _rpm_filename(version, 2)
assert "-1." in rpm_r1, "R1 RPM must contain -1."
assert "-2." in rpm_r2, "R2 RPM must contain -2."
# ---------------------------------------------------------------------------
# Tests — bare tag prevention (structural)
# ---------------------------------------------------------------------------
class TestBareTagPrevention:
"""Verify the flow prevents bare tags.
A bare tag (tag without packages, release entry, notes, and checksums)
must not result from the flow. The script checks for existing bare
tags before proceeding. These tests verify the dry-run doesn't create
any tags.
"""
def test_dry_run_does_not_push_tag(self):
_, output = _run_dry_run()
# The dry-run marker confirms no commands are executed.
# git push appears only as a printed command, never executed.
assert "[dry-run]" in output, \
"Must be in dry-run mode"
# Tag push is printed but the [dry-run] marker confirms nothing ran
assert "Commands below will be executed" in output, \
"Dry-run must indicate commands are for display only"
# ---------------------------------------------------------------------------
# Tests — CI workflow file
# ---------------------------------------------------------------------------
class TestCIWorkflow:
"""Verify the dormant CI workflow is present and correctly structured."""
def test_workflow_file_exists(self):
path = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
assert path.exists(), \
".gitea/workflows/release.yml must exist"
def test_workflow_triggers_on_tags(self):
content = _read(".gitea/workflows/release.yml")
assert "v*" in content, \
"Workflow must trigger on version tags (v*)"
def test_workflow_has_release_step(self):
content = _read(".gitea/workflows/release.yml")
assert "release" in content.lower(), \
"Workflow must have a release step"
def test_workflow_mentions_signing(self):
content = _read(".gitea/workflows/release.yml")
assert "sign" in content.lower(), \
"Workflow must include signing step"
def test_workflow_mentions_upload(self):
content = _read(".gitea/workflows/release.yml")
assert "upload" in content.lower() or "publish" in content.lower(), \
"Workflow must include upload/publish step"
# ---------------------------------------------------------------------------
# Tests — Makefile release targets
# ---------------------------------------------------------------------------
class TestMakefileReleaseTargets:
"""Verify the Makefile exposes release-related targets."""
def _makefile_content(self) -> str:
return _read("Makefile")
def test_release_run_target_exists(self):
content = self._makefile_content()
assert "release-run:" in content, \
"Makefile must have a release-run target"
def test_release_dry_run_target_exists(self):
content = self._makefile_content()
assert "release-dry-run:" in content, \
"Makefile must have a release-dry-run target"
def test_release_run_calls_script(self):
content = self._makefile_content()
assert "release.sh" in content, \
"release-run target must call scripts/release.sh"
def test_release_dry_run_uses_dry_run_flag(self):
content = self._makefile_content()
# Find the release-dry-run target and verify it passes --dry-run
in_target = False
for line in content.splitlines():
if line.startswith("release-dry-run:"):
in_target = True
continue
if in_target and line.strip():
if "--dry-run" in line:
break
if not line.startswith("\t"):
break
else:
pytest.fail("release-dry-run target must pass --dry-run to release.sh")
+480
View File
@@ -0,0 +1,480 @@
"""Signing and consumer-repo structural tests (issue #51).
Verifies that the signing infrastructure, consumer setup docs, and key
publication are correctly wired — without requiring a real GPG key,
network access, or Docker.
All assertions are structural: config keys exist, URLs match, docs
are present, and the Makefile exposes the right targets. A throwaway
test key exercise is included for rpm signature verification mechanics.
"""
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read(path: str | Path) -> str:
from tests.conftest import read
return read(path)
def _gpg_available() -> bool:
try:
r = subprocess.run(
["gpg", "--version"], capture_output=True, timeout=5,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _rpmsign_available() -> bool:
try:
r = subprocess.run(
["rpmsign", "--version"], capture_output=True, timeout=5,
)
return r.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
# ---------------------------------------------------------------------------
# Tests — nfpm.yaml signing configuration
# ---------------------------------------------------------------------------
class TestNfpmSigningConfig:
"""Verify that nfpm.yaml is correctly configured for RPM builds.
Note: RPM signing is done via rpmsign post-build (make sign-rpm),
not through nfpm's built-in signing. This keeps the build unsigned
and the signing step explicit and key-controlled.
"""
def test_rpm_overrides_exist(self):
"""nfpm.yaml must have overrides.rpm for per-format deltas."""
content = _read("packaging/nfpm.yaml")
assert "overrides:" in content, "overrides section missing from nfpm.yaml"
assert "rpm:" in content, "rpm overrides missing from nfpm.yaml"
def test_rpm_scripts_configured(self):
"""RPM must use the dedicated scriptlets, not deb scripts."""
content = _read("packaging/nfpm.yaml")
assert "packaging/rpm/post.sh" in content, \
"RPM postinstall must use rpm/post.sh"
assert "packaging/rpm/preun.sh" in content, \
"RPM preremove must use rpm/preun.sh"
assert "packaging/rpm/postun.sh" in content, \
"RPM postremove must use rpm/postun.sh"
def test_rpm_depends_use_correct_syntax(self):
"""RPM dependencies must use rpm-style version syntax."""
content = _read("packaging/nfpm.yaml")
assert "python3 >= 3.10" in content, \
"RPM depends must use rpm-style version constraint"
# ---------------------------------------------------------------------------
# Tests — Makefile signing targets
# ---------------------------------------------------------------------------
class TestMakefileSigningTargets:
"""Verify that the Makefile exposes signing-related targets."""
def _makefile_content(self) -> str:
return _read("Makefile")
def test_generate_test_key_target(self):
content = self._makefile_content()
assert "generate-test-key:" in content, \
"Makefile must have a generate-test-key target"
assert "packaging-test@bongbetic.com" in content or \
"packaging@bongbetic.com" in content, \
"generate-test-key must reference the packaging key UID"
def test_sign_rpm_target(self):
content = self._makefile_content()
assert "sign-rpm:" in content, \
"Makefile must have a sign-rpm target"
assert "rpmsign" in content, \
"sign-rpm target must use rpmsign"
def test_checksums_target(self):
content = self._makefile_content()
assert "checksums:" in content, \
"Makefile must have a checksums target"
assert "sha256sum" in content, \
"checksums target must use sha256sum"
def test_clearsign_target(self):
content = self._makefile_content()
assert "clearsign:" in content, \
"Makefile must have a clearsign target"
assert "--clearsign" in content, \
"clearsign target must use gpg --clearsign"
def test_release_depends_on_signing(self):
content = self._makefile_content()
for line in content.splitlines():
if line.startswith("release:"):
deps = line.split(":", 1)[1].strip()
assert "sign-rpm" in deps, \
"release target must depend on sign-rpm"
assert "clearsign" in deps, \
"release target must depend on clearsign"
break
else:
pytest.fail("release target not found in Makefile")
def test_packaging_key_uid_defined(self):
content = self._makefile_content()
assert "PACKAGING_KEY" in content, \
"Makefile must define PACKAGING_KEY variable"
def test_release_mentions_key_ceremony(self):
content = self._makefile_content()
assert "signing-key-ceremony.md" in content, \
"release target must reference the key ceremony doc"
# ---------------------------------------------------------------------------
# Tests — fenris.repo configuration
# ---------------------------------------------------------------------------
class TestFenrisRepo:
"""Verify the dnf repo file is correctly configured for Fenris."""
def _repo_content(self) -> str:
return _read("packaging/fenris.repo")
def test_gpgcheck_enabled(self):
content = self._repo_content()
assert "gpgcheck=1" in content, \
"fenris.repo must set gpgcheck=1 for payload verification"
def test_repo_gpgcheck_disabled(self):
content = self._repo_content()
assert "repo_gpgcheck=0" in content, \
"fenris.repo must set repo_gpgcheck=0 (metadata check via TLS)"
def test_gpgkey_points_to_packaging_key(self):
content = self._repo_content()
assert "gpgkey=" in content, \
"fenris.repo must have a gpgkey directive"
assert "fenris-packaging.asc" in content, \
"gpgkey must point at the packaging key"
assert "raw/branch/main" in content, \
"gpgkey must use raw URL for the public key"
def test_baseurl_is_fenris_rpm_group(self):
content = self._repo_content()
assert "rpm/fenris" in content, \
"baseurl must point at the fenris RPM group"
# ---------------------------------------------------------------------------
# Tests — public key publication
# ---------------------------------------------------------------------------
class TestKeyPublication:
"""Verify the public key is published in-repo with correct metadata."""
def test_key_file_exists(self):
key_path = REPO_ROOT / "packaging" / "keys" / "fenris-packaging.asc"
assert key_path.exists(), \
"packaging/keys/fenris-packaging.asc must exist"
def test_key_file_has_raw_url(self):
content = _read("packaging/keys/fenris-packaging.asc")
raw_url = (
"https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/"
"packaging/keys/fenris-packaging.asc"
)
assert raw_url in content, \
"Key file must contain its own raw URL as documentation"
def test_key_file_documents_algorithm(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "RSA 3072" in content or "rsa3072" in content.lower(), \
"Key file must document the algorithm as RSA 3072"
def test_key_file_documents_uid(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "Fenris Packaging" in content, \
"Key file must document the UID"
def test_key_file_documents_expiry(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "2 year" in content or "2-year" in content or "expiry" in content.lower(), \
"Key file must document the expiry policy"
def test_key_file_references_ceremony_doc(self):
content = _read("packaging/keys/fenris-packaging.asc")
assert "signing-key-ceremony.md" in content, \
"Key file must reference the key ceremony document"
# ---------------------------------------------------------------------------
# Tests — key ceremony documentation
# ---------------------------------------------------------------------------
class TestKeyCeremonyDoc:
"""Verify the key ceremony document is complete and accurate."""
def _doc_content(self) -> str:
return _read("docs/install/signing-key-ceremony.md")
def test_ceremony_doc_exists(self):
assert (REPO_ROOT / "docs" / "install" / "signing-key-ceremony.md").exists(), \
"docs/install/signing-key-ceremony.md must exist"
def test_documents_key_specification(self):
content = self._doc_content()
assert "RSA 3072" in content, "Must document RSA 3072 algorithm"
assert "Fenris Packaging" in content, "Must document the UID"
assert "packaging@bongbetic.com" in content, "Must document the email"
def test_documents_import_sign_delete(self):
content = self._doc_content()
assert "import" in content.lower(), "Must document import step"
assert "sign" in content.lower(), "Must document sign step"
assert "delete" in content.lower(), "Must document delete step"
def test_documents_rotation_outline(self):
content = self._doc_content()
assert "rotation" in content.lower(), \
"Must document key rotation procedure"
def test_documents_dual_key_approach(self):
content = self._doc_content()
assert "previous" in content.lower() or "old" in content.lower(), \
"Must document old key retention during rotation"
def test_documents_private_key_storage(self):
content = self._doc_content()
assert "password manager" in content.lower(), \
"Must document that private key lives in password manager"
# ---------------------------------------------------------------------------
# Tests — consumer setup documentation
# ---------------------------------------------------------------------------
class TestConsumerDocs:
"""Verify consumer setup docs are present and correctly wired."""
def _readme_content(self) -> str:
return _read("README.md")
def test_apt_signed_by_flow(self):
content = self._readme_content()
assert "signed-by" in content, \
"README must document apt signed-by keyring flow"
assert "keyrings" in content, \
"README must show the keyrings directory"
def test_apt_fingerprint_placeholder(self):
content = self._readme_content()
assert "Fingerprint" in content or "fingerprint" in content, \
"README must include fingerprint placeholder for TOFU hardening"
def test_dnf_repo_flow(self):
content = self._readme_content()
assert "dnf config-manager --add-repo" in content or \
"dnf install" in content, \
"README must document dnf install flow"
assert "fenris.repo" in content, \
"README must reference the Fenris-owned repo file"
def test_no_gitea_auto_repo(self):
"""Gitea's auto-generated .repo must never be referenced in docs."""
content = self._readme_content()
# The Gitea auto-generated repo would have gpgcheck=1 against the
# instance key, which is a trap. Our docs should only reference
# our own fenris.repo file.
assert "auto-generated" not in content.lower() or \
"never" in content.lower(), \
"README must not recommend Gitea's auto-generated .repo"
def test_package_signature_verification(self):
content = self._readme_content()
assert "rpm -K" in content or "rpm --checksig" in content, \
"README must document RPM signature verification"
assert "gpg --verify" in content, \
"README must document GPG verification for SHA256SUMS"
def test_migration_from_make_install(self):
content = self._readme_content()
assert "migrate-from-makeinstall" in content.lower() or \
"migration" in content.lower(), \
"README must reference the migration runbook"
# ---------------------------------------------------------------------------
# Tests — release spec references
# ---------------------------------------------------------------------------
class TestReleaseSpecReferences:
"""Verify the release spec references the ceremony doc and nfpm config."""
def _spec_content(self) -> str:
return _read("docs/spec/release-packaging.md")
def test_spec_references_rpmsign(self):
content = self._spec_content()
assert "rpmsign" in content.lower() or "sign-rpm" in content, \
"Spec must reference rpmsign or make sign-rpm for RPM signing"
def test_spec_references_ceremony_doc(self):
content = self._spec_content()
assert "signing-key-ceremony.md" in content, \
"Spec must reference the key ceremony document"
# ---------------------------------------------------------------------------
# Tests — RPM signature mechanics (throwaway test key, no network)
# ---------------------------------------------------------------------------
class TestRpmSignatureMechanics:
"""Verify RPM signing mechanics using a throwaway test key.
These tests generate a temporary GPG key, build an RPM (or use an
existing one), sign it, and verify the signature — all without
network access. They require gpg and rpmsign to be available.
"""
@pytest.mark.skipif(
not _gpg_available() or not _rpmsign_available(),
reason="gpg or rpmsign not available",
)
def test_throwaway_key_signs_and_verifies(self):
"""Generate a throwaway key, sign a test RPM, verify signature."""
# Find existing RPM
version = None
for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines():
if line.startswith("version"):
version = line.split("=")[1].strip().strip('"')
break
rpm_path = REPO_ROOT / "dist" / f"fenris-{version}-1.x86_64.rpm"
if not rpm_path.exists():
pytest.skip("RPM not built — run `make package-rpm` first")
key_uid = "fenris-test-signing@example.com"
try:
# Generate throwaway key
subprocess.run(
["gpg", "--batch", "--gen-key"],
input=f"""%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: {key_uid}
Name-Email: {key_uid}
Expire-Date: 0
%commit
""",
text=True, check=True, timeout=30,
)
# Copy RPM to temp dir for signing
with tempfile.TemporaryDirectory() as tmpdir:
signed_rpm = Path(tmpdir) / rpm_path.name
signed_rpm.write_bytes(rpm_path.read_bytes())
# Sign the RPM
subprocess.run(
["rpmsign", "--addsign",
"--define", f"_gpg_name {key_uid}",
str(signed_rpm)],
check=True, timeout=30,
)
# Verify the signature exists and has correct format
# (rpm -Kv returns NOKEY if key isn't imported, but the
# signature header is still present and verifiable)
r = subprocess.run(
["rpm", "-Kv", str(signed_rpm)],
capture_output=True, text=True, timeout=10,
)
output = r.stdout + r.stderr
assert "RSA" in output or "rsa" in output.lower(), \
f"RPM must have RSA signature: {output}"
assert "SHA256" in output or "sha256" in output.lower(), \
f"RPM must have SHA256 digest: {output}"
assert "Header V4" in output or "Header" in output, \
f"RPM must have V4 signature header: {output}"
assert "Signature" in output, \
f"RPM must show signature info: {output}"
finally:
# Clean up the test key
subprocess.run(
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
capture_output=True, timeout=5,
)
subprocess.run(
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
capture_output=True, timeout=5,
)
@pytest.mark.skipif(
not _gpg_available(),
reason="gpg not available",
)
def test_clearsign_and_verify(self):
"""Clearsign a test manifest and verify the signature."""
key_uid = "fenris-test-clearsign@example.com"
try:
# Generate throwaway key
subprocess.run(
["gpg", "--batch", "--gen-key"],
input=f"""%no-protection
Key-Type: RSA
Key-Length: 3072
Name-Real: {key_uid}
Name-Email: {key_uid}
Expire-Date: 0
%commit
""",
text=True, check=True, timeout=30,
)
with tempfile.TemporaryDirectory() as tmpdir:
sums = Path(tmpdir) / "SHA256SUMS"
sums.write_text(
"abc123 fenris_0.3.0_amd64.deb\n"
"def456 fenris-0.3.0-1.x86_64.rpm\n"
)
# Clearsign
subprocess.run(
["gpg", "--batch", "--yes", "--clearsign",
"--local-user", key_uid, str(sums)],
check=True, timeout=10,
)
# Verify (clearsigned file — just one argument to --verify)
r = subprocess.run(
["gpg", "--verify", str(sums.with_suffix(".asc"))],
capture_output=True, text=True, timeout=10,
)
assert r.returncode == 0, \
f"Clearsign verification failed: {r.stderr}"
assert "Good signature" in r.stderr, \
f"Expected Good signature: {r.stderr}"
finally:
subprocess.run(
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
capture_output=True, timeout=5,
)
subprocess.run(
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
capture_output=True, timeout=5,
)
+195
View File
@@ -0,0 +1,195 @@
"""Observation store migration unit tests (issue #48).
Tests the forward-only migration logic that underpins package upgrade
semantics: older stores are migrated, current stores pass through, and
newer stores are refused loudly.
Spec: §3.6, §9.5, §10.2
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import (
SCHEMA_VERSION,
init_store,
migrate_to_latest,
)
from fenris.status import NewerSchema, open_store_readonly
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_store(path: Path, version: int = 0) -> sqlite3.Connection:
"""Create a store at *path* with the given user_version."""
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
if version == 0:
# Fresh DB with no schema — user_version defaults to 0
pass
else:
# Create a minimal schema so the DB is valid, then set version
conn.execute("""
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
device TEXT NOT NULL
)
""")
conn.execute(f"PRAGMA user_version={version}")
conn.commit()
return conn
# ---------------------------------------------------------------------------
# migrate_to_latest
# ---------------------------------------------------------------------------
class TestMigrateToLatest:
"""Forward-only migration via migrate_to_latest()."""
def test_migrates_from_zero(self, tmp_path):
"""Store at user_version=0 → SCHEMA_VERSION (fresh DB)."""
db = tmp_path / "observations.db"
_make_store(db, version=0)
steps = migrate_to_latest(db)
# SCHEMA_VERSION - 0 = SCHEMA_VERSION migration steps
assert steps == SCHEMA_VERSION
# Verify version was bumped
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION
def test_already_current_returns_zero(self, tmp_path):
"""Store already at SCHEMA_VERSION → 0 steps applied."""
db = tmp_path / "observations.db"
conn = _make_store(db, version=SCHEMA_VERSION)
conn.close()
steps = migrate_to_latest(db)
assert steps == 0
def test_refuses_newer_store(self, tmp_path):
"""Store with user_version > SCHEMA_VERSION → ValueError."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError, match="newer Fenris"):
migrate_to_latest(db)
def test_refuses_much_newer_store(self, tmp_path):
"""Store several versions ahead → ValueError."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 5)
with pytest.raises(ValueError, match="newer Fenris"):
migrate_to_latest(db)
def test_store_not_corrupted_on_refusal(self, tmp_path):
"""After refusal, store is unchanged (no silent corruption)."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 2)
with pytest.raises(ValueError):
migrate_to_latest(db)
# Version should be unchanged
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 2
def test_idempotent_on_current(self, tmp_path):
"""Calling migrate_to_latest twice on a current store is safe."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION)
assert migrate_to_latest(db) == 0
assert migrate_to_latest(db) == 0
def test_migrates_intermediate_version(self, tmp_path):
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
db = tmp_path / "observations.db"
_make_store(db, version=1)
# SCHEMA_VERSION is 1, so version 1 is current
steps = migrate_to_latest(db)
assert steps == 0
# ---------------------------------------------------------------------------
# init_store — downgrade refusal
# ---------------------------------------------------------------------------
class TestInitStoreDowngradeRefusal:
"""init_store() refuses newer-schema stores."""
def test_refuses_newer_store(self, tmp_path):
"""init_store raises ValueError on newer-schema store."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError, match="newer Fenris"):
init_store(db)
def test_store_not_corrupted_on_refusal(self, tmp_path):
"""After init_store refusal, store is unchanged."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(ValueError):
init_store(db)
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 1
# ---------------------------------------------------------------------------
# open_store_readonly — downgrade refusal
# ---------------------------------------------------------------------------
class TestOpenStoreReadonlyDowngradeRefusal:
"""open_store_readonly() raises NewerSchema on newer-schema stores."""
def test_raises_newer_schema(self, tmp_path):
"""Newer store → NewerSchema exception."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 1)
with pytest.raises(NewerSchema) as exc_info:
open_store_readonly(db)
assert exc_info.value.version == SCHEMA_VERSION + 1
def test_store_not_corrupted(self, tmp_path):
"""After NewerSchema refusal, store is unchanged."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION + 3)
with pytest.raises(NewerSchema):
open_store_readonly(db)
conn = sqlite3.connect(str(db))
v = conn.execute("PRAGMA user_version").fetchone()[0]
conn.close()
assert v == SCHEMA_VERSION + 3
def test_current_store_opens(self, tmp_path):
"""Store at SCHEMA_VERSION opens without error."""
db = tmp_path / "observations.db"
_make_store(db, version=SCHEMA_VERSION)
conn = open_store_readonly(db)
assert conn is not None
conn.close()