Compare commits
20
Commits
8fa86c3bf8
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcd321f4c | ||
|
|
25ead13ab9 | ||
|
|
be9ce01ebf | ||
|
|
122c9f327e | ||
|
|
460dde4aad | ||
|
|
ba270d7812 | ||
|
|
2aed923043 | ||
|
|
230c686e82 | ||
|
|
38ecfc2093 | ||
|
|
f1ba8bdccc | ||
|
|
e794310a76 | ||
|
|
1e2ddfb928 | ||
|
|
120d80b28c | ||
|
|
d8fa6df072 | ||
|
|
c45b07003a | ||
|
|
1873886b2f | ||
|
|
c91ca10df7 | ||
|
|
e9d6881e38 | ||
|
|
b2243a85f7 | ||
|
|
f1e1c0eebc |
+163
-13
@@ -1,12 +1,19 @@
|
||||
# Fenris release workflow — dormant (no runner registered yet).
|
||||
# When a runner is provisioned, this replicates `make release` automatically.
|
||||
# Spec: §5, §34
|
||||
# Fenris release workflow — release path on Coolify-hosted Gitea runner.
|
||||
# The runner is repository-scoped and executes package build, signing, validation,
|
||||
# registry publication, and release attachment. Spec: §5, issue #52
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
# Built-in Gitea token needs write access for release assets and package registry.
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -20,18 +27,161 @@ jobs:
|
||||
python-version: '3.12'
|
||||
|
||||
- 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
|
||||
run: make package
|
||||
|
||||
- name: List artifacts
|
||||
run: ls -la dist/
|
||||
- name: Import packaging key
|
||||
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
|
||||
# the build succeeds. The maintainer completes the release.
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fenris-packages
|
||||
path: dist/
|
||||
- name: Sign RPM payload
|
||||
run: make sign-rpm
|
||||
|
||||
- name: Generate and clearsign SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
|
||||
# Legacy history path (IN-4)
|
||||
LEGACY_HISTORY := ./data/history.jsonl
|
||||
|
||||
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy 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:
|
||||
@echo "Fenris NVMe endurance monitor"
|
||||
@@ -34,14 +34,20 @@ help:
|
||||
@echo " package - Build deb + rpm packages"
|
||||
@echo " package-deb - Build deb package only"
|
||||
@echo " package-rpm - Build rpm package only"
|
||||
@echo " release - Full release (build, sign, attach)"
|
||||
@echo " 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"
|
||||
|
||||
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
||||
|
||||
check-python:
|
||||
@echo "=== Verifying Python ≥ 3.9 ==="
|
||||
@$(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; }
|
||||
@echo "=== Verifying Python ≥ 3.10 ==="
|
||||
@$(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:
|
||||
@echo "=== Verifying smartctl ==="
|
||||
@@ -223,11 +229,14 @@ lint:
|
||||
update-deps:
|
||||
$(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
|
||||
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
|
||||
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
|
||||
bash packaging/stage.sh "$(FENRIS_VERSION)"
|
||||
@@ -245,17 +254,55 @@ package-rpm: stage
|
||||
package: package-deb package-rpm
|
||||
@echo "=== Both packages built in dist/ ==="
|
||||
|
||||
release: package
|
||||
@echo "=== Release v$(FENRIS_VERSION) ==="
|
||||
@echo "Artifacts:"
|
||||
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null
|
||||
# ─── GPG key management ─────────────────────────────────────────────────────
|
||||
|
||||
generate-test-key:
|
||||
@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 "Manual steps (spec §5):"
|
||||
@echo " 1. Import packaging key: gpg --import <keyfile>"
|
||||
@echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
|
||||
@echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS"
|
||||
@echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS"
|
||||
@echo " 5. Upload to registry:"
|
||||
@echo "=== Release v$(FENRIS_VERSION) ==="
|
||||
@echo ""
|
||||
@echo "Artifacts:"
|
||||
@ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \
|
||||
dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \
|
||||
dist/SHA256SUMS.asc 2>/dev/null
|
||||
@echo ""
|
||||
@echo "Verify signing (manual):"
|
||||
@echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm"
|
||||
@echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS"
|
||||
@echo ""
|
||||
@echo "Upload to registry:"
|
||||
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'"
|
||||
@echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\"
|
||||
@@ -264,7 +311,22 @@ release: package
|
||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'"
|
||||
@echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\"
|
||||
@echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'"
|
||||
@echo " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc"
|
||||
@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:
|
||||
@echo "=== Cleaning build artifacts ==="
|
||||
|
||||
@@ -8,34 +8,109 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python ≥ 3.9** (verified at install time)
|
||||
- **Python ≥ 3.10** (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)
|
||||
|
||||
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
||||
|
||||
## Install
|
||||
## Install from package (recommended)
|
||||
|
||||
```bash
|
||||
sudo make install
|
||||
### Debian / Ubuntu (apt)
|
||||
|
||||
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:
|
||||
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.
|
||||
Add the instance key and repository:
|
||||
|
||||
**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
|
||||
fenris monitor resume # enable timer + open first monitoring period
|
||||
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
|
||||
|
||||
### Package upgrade
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
||||
sudo dnf upgrade fenris # Fedora
|
||||
```
|
||||
|
||||
### Development upgrade
|
||||
|
||||
```bash
|
||||
sudo make upgrade
|
||||
```
|
||||
@@ -49,8 +124,26 @@ What it does:
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
make uninstall # removes artifacts, preserves config and observation history
|
||||
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?
|
||||
|
||||
| Artifact | Location |
|
||||
|---|---|
|
||||
| Wrapper | `/usr/local/bin/fenris` |
|
||||
| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` |
|
||||
| Units | `/etc/systemd/system/fenris-collect.{timer,service}` |
|
||||
| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` |
|
||||
| Configuration | `/etc/fenris/fenris.conf` |
|
||||
| Observation store | `/var/lib/fenris/observations.db` |
|
||||
| Venv | `/opt/fenris` |
|
||||
| Manifest | `/var/lib/fenris/manifest.txt` |
|
||||
| Legacy history | `./data/history.jsonl` (auto-imported on install if present) |
|
||||
| Artifact | Package install | make install |
|
||||
|---|---|---|
|
||||
| Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` |
|
||||
| Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` |
|
||||
| Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` |
|
||||
| Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` |
|
||||
| sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
|
||||
| Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
|
||||
| Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` |
|
||||
| Venv | `/opt/fenris` | `/opt/fenris` |
|
||||
| Legacy history | — | `./data/history.jsonl` (auto-imported) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Migrating from make-install to packages
|
||||
|
||||
This runbook covers the transition from a `sudo make install` system to the native deb or rpm package. Packages are the primary delivery; `make install` remains as the dev fallback. The two deliveries are **mutually exclusive** per machine.
|
||||
|
||||
## Why over-install is forbidden
|
||||
|
||||
Installing a package over a make-install system silently breaks things:
|
||||
|
||||
- **Stale admin units shadow vendor units.** `make install` places `fenris-collect.timer` and `fenris-collect.service` in `/etc/systemd/system/`. The package installs them in `/usr/lib/systemd/system/` (vendor placement). Systemd loads admin units first — the stale copy takes precedence, and the package update never reaches the running system.
|
||||
- **The local wrapper shadows the package wrapper.** `make install` places the `fenris` wrapper at `/usr/local/bin/fenris`. The package places it at `/usr/bin/fenris`. The shell finds `/usr/local/bin` first on PATH — the old checkout-relative wrapper runs instead of the package wrapper.
|
||||
|
||||
Neither condition is reversible by reinstalling the package. The only safe path is remove-then-install.
|
||||
|
||||
## Pre-migration checklist
|
||||
|
||||
1. Confirm no monitoring period is actively running that you want to preserve across the gap:
|
||||
```
|
||||
fenris status
|
||||
```
|
||||
The migration resets the system to dormant (see [No-move continuity](#no-move-continuity) below). You opt back in with `fenris monitor resume`.
|
||||
|
||||
2. If you have hand-edited configuration at `/etc/fenris/fenris.conf`, note it. The config survives the migration in place (see below).
|
||||
|
||||
## Remove step
|
||||
|
||||
```
|
||||
sudo make uninstall
|
||||
```
|
||||
|
||||
This performs the **sanctioned disable** (`fenris-monitor disable --now`), closing the current monitoring period as `user_disabled`. It then removes all make-install artifacts: the venv at `/opt/fenris`, the wrapper at `/usr/local/bin/fenris`, the helpers at `/usr/libexec/fenris/`, the units in `/etc/systemd/system/`, and the polkit policy. The placement manifest at `/var/lib/fenris/manifest.txt` is removed.
|
||||
|
||||
**What survives the remove:**
|
||||
|
||||
- `/var/lib/fenris/observations.db` (and WAL sidecars, `.bak`) — the observation store
|
||||
- `/var/lib/fenris/` directory itself — root-written, group-read
|
||||
- `/etc/fenris/fenris.conf` — your hand-written configuration
|
||||
- The `fenris` system group — created by `groupadd -f` during make-install
|
||||
- Journal entries — age out naturally
|
||||
|
||||
## Install step
|
||||
|
||||
```
|
||||
sudo apt install fenris # Debian/Ubuntu
|
||||
sudo dnf install fenris # Fedora
|
||||
```
|
||||
|
||||
The package installs into its own layout without touching the surviving store, config, or group.
|
||||
|
||||
## No-move continuity
|
||||
|
||||
These invariants are verified by the containerized acceptance tests (issue #50):
|
||||
|
||||
| Asset | Make-install state | Package post-install | Mechanism |
|
||||
|---|---|---|---|
|
||||
| `fenris` group | Exists (`groupadd -f`) | Unchanged | `systemd-sysusers` is a no-op when the group already exists |
|
||||
| `/var/lib/fenris` directory | Exists (mode 2750, root:fenris) | Unchanged | `systemd-tmpfiles --create` is a no-op when the directory already exists |
|
||||
| `observations.db` + sidecars | Present from prior monitoring | Unchanged, never owned by the package | Package owns the directory only; store contents are never ghosted |
|
||||
| `/etc/fenris/fenris.conf` | Hand-edited device selector | Survives in place; package default lands as `.dpkg-new` / `.rpmnew` | dpkg conffile / rpm `%config(noreplace)` semantics |
|
||||
| Store schema | Version from prior Fenris release | Caught up by the upgrade-path migration | `postinst` / `%post` runs `migrate_to_latest()` on upgrade |
|
||||
|
||||
The package detects the make-install system has been removed by the absence of the two markers:
|
||||
- `/var/lib/fenris/manifest.txt` (the placement manifest)
|
||||
- `/etc/systemd/system/fenris-collect.timer` (pre-manifest make installs)
|
||||
|
||||
If either marker exists, the package installation aborts with a pointer to this runbook.
|
||||
|
||||
## Reset-to-dormant
|
||||
|
||||
`make uninstall`'s sanctioned disable closes the open monitoring period as `user_disabled`. After the package install, the system is dormant — the timer is installed but disabled, nothing is running, no monitoring period is open.
|
||||
|
||||
To resume monitoring:
|
||||
|
||||
```
|
||||
fenris monitor resume
|
||||
```
|
||||
|
||||
This is the sanctioned opt-in. It enables the timer and opens the first monitoring period in one step. The migration costs at most one short sample gap (the interval between `make uninstall` and `fenris monitor resume`), honestly recorded in the endurance timeline.
|
||||
|
||||
## Verification
|
||||
|
||||
After migration, confirm the package is correctly installed:
|
||||
|
||||
```
|
||||
fenris status
|
||||
```
|
||||
|
||||
The status command should show the dormant state: timer disabled, no active monitoring period, and the observation store intact from the prior make-install system.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Signing key ceremony
|
||||
|
||||
The Fenris packaging key signs RPM payloads and clearsigns SHA256SUMS manifests.
|
||||
This document describes the key's lifecycle: creation, per-release use, rotation,
|
||||
and destruction.
|
||||
|
||||
## Key specification
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Algorithm | RSA 3072 |
|
||||
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
||||
| Expiry | 2 years from creation |
|
||||
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
||||
| Private key storage | Password manager only |
|
||||
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
||||
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
||||
|
||||
## First release: key creation
|
||||
|
||||
```bash
|
||||
# Generate the dedicated RSA-3072 packaging key
|
||||
gpg --batch --gen-key <<EOF
|
||||
%no-protection
|
||||
Key-Type: RSA
|
||||
Key-Length: 3072
|
||||
Name-Real: Fenris Packaging
|
||||
Name-Email: packaging@bongbetic.com
|
||||
Expire-Date: 2y
|
||||
%commit
|
||||
EOF
|
||||
|
||||
# Export the public half — this file is committed to the repo
|
||||
gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.asc
|
||||
|
||||
# Print the fingerprint for docs and release notes
|
||||
gpg --fingerprint packaging@bongbetic.com
|
||||
```
|
||||
|
||||
Save the **private key** to the password manager immediately:
|
||||
|
||||
```bash
|
||||
gpg --armor --export-secret-keys packaging@bongbetic.com
|
||||
```
|
||||
|
||||
Then **delete the private key from the local keyring** — it must never persist
|
||||
on any build host:
|
||||
|
||||
```bash
|
||||
gpg --delete-secret-keys packaging@bongbetic.com
|
||||
gpg --delete-keys packaging@bongbetic.com
|
||||
```
|
||||
|
||||
The committed `fenris-packaging.asc` must contain the real public key (replace
|
||||
the placeholder comments).
|
||||
|
||||
## Per-release signing flow
|
||||
|
||||
Each release performs: **import → sign → delete**. The private key is never
|
||||
stored on disk longer than the release takes.
|
||||
|
||||
### Step 1: Import the private key
|
||||
|
||||
Retrieve the private key from the password manager and import it:
|
||||
|
||||
```bash
|
||||
gpg --import /tmp/packaging-key-private.asc
|
||||
rm /f /tmp/packaging-key-private.asc # Shred if possible
|
||||
```
|
||||
|
||||
### Step 2: Build and sign packages
|
||||
|
||||
The Makefile target `make release` handles signing automatically when the
|
||||
key is in the keyring:
|
||||
|
||||
```bash
|
||||
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
|
||||
```
|
||||
|
||||
Under the hood:
|
||||
|
||||
1. `rpmsign --addsign` signs the RPM payload with the packaging key
|
||||
(invoked by `make sign-rpm`).
|
||||
2. `sha256sum` generates the checksum manifest.
|
||||
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
|
||||
|
||||
### Step 3: Delete the private key
|
||||
|
||||
Immediately after signing:
|
||||
|
||||
```bash
|
||||
gpg --delete-secret-keys packaging@bongbetic.com
|
||||
gpg --delete-keys packaging@bongbetic.com
|
||||
```
|
||||
|
||||
Verify the key is gone:
|
||||
|
||||
```bash
|
||||
gpg --list-keys packaging@bongbetic.com
|
||||
# Should produce: gpg: keyblock resource ...: No such file or directory
|
||||
```
|
||||
|
||||
The entire import → sign → delete cycle should take minutes. The private key
|
||||
must never be left in any keyring between releases.
|
||||
|
||||
## Key rotation (outline)
|
||||
|
||||
When the key approaches expiry, or if it is compromised:
|
||||
|
||||
1. **Generate a new key** using the same procedure as first release.
|
||||
2. **Publish the new public key** alongside the old one in-repo:
|
||||
```text
|
||||
packaging/keys/fenris-packaging.asc # new key (primary)
|
||||
packaging/keys/fenris-packaging-previous.asc # old key (one cycle)
|
||||
```
|
||||
3. **Sign the next RPM** with the new key.
|
||||
4. **Update `fenris.repo`** to list both `gpgkey` URLs (dnf accepts multiple):
|
||||
```ini
|
||||
gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
|
||||
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging-previous.asc
|
||||
```
|
||||
5. **Drop the old key** from the repo after one release cycle. Delete
|
||||
`fenris-packaging-previous.asc` and revert `gpgkey` to the single URL.
|
||||
|
||||
## Verification
|
||||
|
||||
Consumers verify the RPM payload signature via dnf (gpgcheck=1 in
|
||||
`fenris.repo` points at the published public key). The SHA256SUMS manifest
|
||||
verification is manual for downloaded assets:
|
||||
|
||||
```bash
|
||||
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
## One-time live probe
|
||||
|
||||
Before the first real release, verify the full registry path end-to-end with a
|
||||
throwaway package. This confirms apt/dnf metadata generation, signature
|
||||
verification, and consumer setup work as a real consumer would experience them.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Create a throwaway package name to avoid polluting fenris metadata
|
||||
PROBE_NAME="fenris-regtest"
|
||||
PROBE_VERSION="0.0.1"
|
||||
```
|
||||
|
||||
### Publish
|
||||
|
||||
```bash
|
||||
# Build a throwaway deb and rpm (use the existing nfpm config with a dummy name)
|
||||
# Or use a pre-built package — the probe tests the registry path, not the build
|
||||
|
||||
# Upload deb to all codename pools
|
||||
for CODENAME in bookworm jammy noble; do
|
||||
curl --fail -X PUT \
|
||||
-u "xavierk:${GITEA_TOKEN}" \
|
||||
-T "dist/${PROBE_NAME}_${PROBE_VERSION}_amd64.deb" \
|
||||
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/upload"
|
||||
done
|
||||
|
||||
# Upload rpm
|
||||
curl --fail -X PUT \
|
||||
-u "xavierk:${GITEA_TOKEN}" \
|
||||
-T "dist/${PROBE_NAME}-${PROBE_VERSION}-1.x86_64.rpm" \
|
||||
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload"
|
||||
```
|
||||
|
||||
### Verify apt metadata (Debian/Ubuntu consumer perspective)
|
||||
|
||||
```bash
|
||||
# On a Debian/Ubuntu machine:
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
sudo curl -fsSL https://git.bongbetic.com/api/packages/xavierk/debian/repository.key \
|
||||
| sudo gpg --dearmor -o /etc/apt/keyrings/gitea-xavierk.asc
|
||||
|
||||
echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \
|
||||
| sudo tee /etc/apt/sources.list.d/fenris.list
|
||||
|
||||
sudo apt update
|
||||
apt show ${PROBE_NAME} # metadata present, correct version
|
||||
apt install --dry-run ${PROBE_NAME} # dependency resolution works
|
||||
|
||||
# Verify InRelease signature
|
||||
apt-key list 2>/dev/null || gpg --no-default-keyring --keyring /etc/apt/keyrings/gitea-xavierk.asc --list-keys
|
||||
```
|
||||
|
||||
### Verify dnf metadata (Fedora consumer perspective)
|
||||
|
||||
```bash
|
||||
# On a Fedora machine:
|
||||
sudo dnf config-manager --add-repo https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo
|
||||
# Or use Gitea's auto-generated repo for the probe:
|
||||
sudo dnf config-manager --add-repo https://git.bongbetic.com/api/packages/xavierk/rpm/fenris.repo
|
||||
|
||||
dnf info ${PROBE_NAME} # metadata present, correct version
|
||||
dnf install --assumeno ${PROBE_NAME} # dependency resolution works
|
||||
|
||||
# Verify rpm signature
|
||||
rpm -q --scripts ${PROBE_NAME} # no scripts (throwaway)
|
||||
```
|
||||
|
||||
### Verify checksums and clearsign
|
||||
|
||||
```bash
|
||||
# Download from release assets or local build
|
||||
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||
sha256sum -c SHA256SUMS
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
|
||||
```bash
|
||||
# Delete the throwaway packages from the registry
|
||||
for CODENAME in bookworm jammy noble; do
|
||||
curl --fail -X DELETE \
|
||||
-u "xavierk:${GITEA_TOKEN}" \
|
||||
"https://git.bongbetic.com/api/packages/xavierk/debian/pool/${CODENAME}/main/${PROBE_NAME}/${PROBE_VERSION}/amd64"
|
||||
done
|
||||
|
||||
curl --fail -X DELETE \
|
||||
-u "xavierk:${GITEA_TOKEN}" \
|
||||
"https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/${PROBE_NAME}/${PROBE_VERSION}/x86_64"
|
||||
|
||||
# Remove test source list on consumer machines
|
||||
sudo rm /etc/apt/sources.list.d/fenris.list
|
||||
sudo apt update
|
||||
```
|
||||
@@ -117,6 +117,13 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
||||
- **IN-9** (P) The installer verifies `python3 ≥ 3.9` and fails cleanly otherwise; `/var/lib/fenris` is created with root-written group-read permissions; the database file is created lazily by the first write.
|
||||
- **IN-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)
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -40,10 +40,10 @@
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
||||
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
||||
- **Promotion flow:** tag → `make release` (manual: `make package` + rpmsign + registry PUTs + attach `.deb`, `.rpm`, `SHA256SUMS` to the release entry).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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
|
||||
# payloads and clearsign SHA256SUMS manifests.
|
||||
# Public half of dedicated RSA-3072 key used to sign RPM payloads and
|
||||
# clearsign SHA256SUMS manifests.
|
||||
#
|
||||
# The private half lives only in the password manager. Each release performs:
|
||||
# import → sign → delete. No machine permanently holds signing material.
|
||||
#
|
||||
# Key details (published with the first Release):
|
||||
# Fingerprint: CE4542E1E23EB50F09EDFFA5A5E8B22D1872FB07
|
||||
# Algorithm: RSA 3072
|
||||
# UID: Fenris Packaging <packaging@bongbetic.com>
|
||||
# Expiry: 2 years from creation
|
||||
#
|
||||
# This file will be replaced with the real public key at the time of the
|
||||
# first Release. Its raw URL doubles as the dnf gpgkey target.
|
||||
# Raw URL:
|
||||
# https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc
|
||||
#
|
||||
# The private half lives in approved secret storage only. Each release uses
|
||||
# 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
@@ -26,19 +26,20 @@ contents:
|
||||
file_info:
|
||||
mode: 0755
|
||||
|
||||
# Default placeholder-commented config (conffile for deb)
|
||||
# Default placeholder-commented config (deb conffile / rpm %config(noreplace))
|
||||
- src: packaging/fenris.conf
|
||||
dst: /etc/fenris/fenris.conf
|
||||
type: config
|
||||
type: config|noreplace
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
# Observation store directory — owned by package, never packed
|
||||
# deb: created in postinst; rpm: %ghost
|
||||
# Observation store directory — owned by package, never packed.
|
||||
# Store files (observations.db, WAL sidecars, .bak) are never owned.
|
||||
- dst: /var/lib/fenris
|
||||
type: ghost
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 2750
|
||||
group: fenris
|
||||
|
||||
scripts:
|
||||
preinstall: packaging/preinst.sh
|
||||
|
||||
+13
-8
@@ -26,18 +26,23 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
# Restart timer only if unit contents changed AND active (spec §7)
|
||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||
RUNNING_UNITS=""
|
||||
for unit in fenris-collect.timer; do
|
||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||
TMPFILE="$(mktemp)"
|
||||
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
|
||||
UNIT_PATH="/usr/lib/systemd/system/${unit}"
|
||||
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
|
||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||
fi
|
||||
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 "${TMPFILE}"
|
||||
fi
|
||||
rm -f "${OLD_CONTENT}"
|
||||
done
|
||||
fi
|
||||
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
||||
|
||||
+14
-8
@@ -25,16 +25,22 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||
RUNNING_UNITS=""
|
||||
for unit in fenris-collect.timer; do
|
||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||
TMPFILE="$(mktemp)"
|
||||
systemctl cat "${unit}" > "${TMPFILE}" 2>/dev/null || true
|
||||
UNIT_PATH="/usr/lib/systemd/system/${unit}"
|
||||
if ! diff -q "${TMPFILE}" "${UNIT_PATH}" > /dev/null 2>&1; then
|
||||
systemctl restart "${unit}" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "${TMPFILE}"
|
||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||
fi
|
||||
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
|
||||
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fenris one-command release flow (issue #52).
|
||||
# Builds both packages, signs, uploads to registry, creates release entry,
|
||||
# and attaches artifacts — or in dry-run mode, prints every command.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/release.sh --dry-run # Print commands without executing
|
||||
# scripts/release.sh --publish # Execute the full release flow
|
||||
#
|
||||
# Environment:
|
||||
# GITEA_TOKEN - API token for Gitea registry and release API
|
||||
# PACKAGING_KEY - GPG key UID (default: packaging@bongbetic.com)
|
||||
#
|
||||
# Spec: release-packaging.md §5
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────────
|
||||
|
||||
DRY_RUN=false
|
||||
PUBLISH=false
|
||||
GITEA_URL="https://git.bongbetic.com"
|
||||
GITEA_OWNER="xavierk"
|
||||
GITEA_REPO="Fenris"
|
||||
PACKAGING_KEY="${PACKAGING_KEY:-packaging@bongbetic.com}"
|
||||
|
||||
CODENAMES=(bookworm jammy noble)
|
||||
RPM_GROUP="fenris"
|
||||
|
||||
# ── Parse arguments ──────────────────────────────────────────────────────
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--publish) PUBLISH=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--dry-run | --publish]"
|
||||
echo ""
|
||||
echo "Modes:"
|
||||
echo " --dry-run Print commands without executing (default)"
|
||||
echo " --publish Execute the full release flow"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " GITEA_TOKEN API token for Gitea registry and release API"
|
||||
echo " PACKAGING_KEY GPG key UID (default: packaging@bongbetic.com)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: $0 [--dry-run | --publish]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! $DRY_RUN && ! $PUBLISH; then
|
||||
DRY_RUN=true
|
||||
fi
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
_version() {
|
||||
sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml
|
||||
}
|
||||
|
||||
_deb_name() {
|
||||
local ver="$1"
|
||||
echo "fenris_${ver}_amd64.deb"
|
||||
}
|
||||
|
||||
_rpm_name() {
|
||||
local ver="$1" rel="$2"
|
||||
echo "fenris-${ver}-${rel}.x86_64.rpm"
|
||||
}
|
||||
|
||||
_run() {
|
||||
if $DRY_RUN; then
|
||||
echo " $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
VERSION=$(_version)
|
||||
REVISION=1
|
||||
DEB=$(_deb_name "$VERSION")
|
||||
RPM=$(_rpm_name "$VERSION" "$REVISION")
|
||||
|
||||
echo "=== Fenris Release v${VERSION} ==="
|
||||
echo ""
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo "[dry-run] Commands below will be executed in --publish mode."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── Step 1: Build both formats ──────────────────────────────────────────
|
||||
|
||||
echo "--- Build packages ---"
|
||||
_run "make package"
|
||||
echo ""
|
||||
|
||||
# ── Step 2: Sign RPM payload ────────────────────────────────────────────
|
||||
|
||||
echo "--- Sign RPM payload ---"
|
||||
_run "rpmsign --addsign --define '_gpg_name ${PACKAGING_KEY}' dist/${RPM}"
|
||||
echo ""
|
||||
|
||||
# ── Step 3: Generate and clearsign SHA256SUMS ────────────────────────────
|
||||
|
||||
echo "--- Generate SHA256SUMS ---"
|
||||
_run "cd dist && sha256sum ${DEB} ${RPM} > SHA256SUMS"
|
||||
echo ""
|
||||
|
||||
echo "--- Clearsign SHA256SUMS ---"
|
||||
_run "gpg --batch --yes --clearsign --local-user ${PACKAGING_KEY} dist/SHA256SUMS"
|
||||
echo ""
|
||||
|
||||
# ── Step 4: Upload to Gitea package registry ─────────────────────────────
|
||||
|
||||
echo "--- Upload packages to registry ---"
|
||||
for codename in "${CODENAMES[@]}"; do
|
||||
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${DEB} '${GITEA_URL}/api/packages/${GITEA_OWNER}/debian/pool/${codename}/main/upload'"
|
||||
done
|
||||
_run "curl --fail -X PUT -u ${GITEA_OWNER}:\$GITEA_TOKEN -T dist/${RPM} '${GITEA_URL}/api/packages/${GITEA_OWNER}/rpm/${RPM_GROUP}/upload'"
|
||||
echo ""
|
||||
|
||||
# ── Step 5: Create Gitea release with notes ─────────────────────────────
|
||||
|
||||
echo "--- Create Gitea release ---"
|
||||
_release_notes="Release v${VERSION}
|
||||
|
||||
## Packages
|
||||
|
||||
Install via apt (Debian/Ubuntu):
|
||||
|
||||
\`\`\`bash
|
||||
curl --fail -fsSL https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/keys/fenris-packaging.asc | sudo gpg --dearmor -o /etc/apt/keyrings/fenris.asc
|
||||
echo \"deb [signed-by=/etc/apt/keyrings/fenris.asc] https://git.bongbetic.com/api/packages/${GITEA_OWNER}/debian bookworm main\" | sudo tee /etc/apt/sources.list.d/fenris.list
|
||||
sudo apt update && sudo apt install fenris
|
||||
\`\`\`
|
||||
|
||||
Install via dnf (Fedora):
|
||||
|
||||
\`\`\`bash
|
||||
sudo dnf config-manager --add-repo https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/main/packaging/fenris.repo
|
||||
sudo dnf install fenris
|
||||
\`\`\`
|
||||
|
||||
## Verification
|
||||
|
||||
\`\`\`bash
|
||||
rpm -Kv fenris-${VERSION}-1.x86_64.rpm
|
||||
gpg --verify SHA256SUMS.asc SHA256SUMS
|
||||
\`\`\`
|
||||
|
||||
## Artifacts
|
||||
|
||||
- \`dist/${DEB}\` (Debian/Ubuntu)
|
||||
- \`dist/${RPM}\` (Fedora)
|
||||
- \`dist/SHA256SUMS.asc\` (clearsigned checksums)
|
||||
|
||||
See [docs/install/signing-key-ceremony.md](docs/install/signing-key-ceremony.md) for key ceremony details.
|
||||
See [docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md) for migration from make install."
|
||||
|
||||
if $DRY_RUN; then
|
||||
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -H 'Content-Type: application/json' -d '{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\",\"body\":\"...\"}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases'"
|
||||
else
|
||||
# Create release via Gitea API (creates the tag atomically — no bare tag)
|
||||
RELEASE_RESPONSE=$(curl --fail -s -X POST \
|
||||
-u "${GITEA_OWNER}:${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg tag "v${VERSION}" \
|
||||
--arg name "v${VERSION}" \
|
||||
--arg body "$_release_notes" \
|
||||
'{tag_name: $tag, name: $name, body: $body}')" \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
|
||||
echo " Release created: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Step 6: Attach artifacts to release ──────────────────────────────────
|
||||
|
||||
echo "--- Attach artifacts to release ---"
|
||||
for artifact in "dist/${DEB}" "dist/${RPM}" "dist/SHA256SUMS.asc"; do
|
||||
_run "curl --fail -X POST -u ${GITEA_OWNER}:\$GITEA_TOKEN -F 'attachment=@${artifact}' '${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${RELEASE_ID:-0}/assets'"
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== Release v${VERSION} complete ==="
|
||||
echo ""
|
||||
echo "Summary:"
|
||||
echo " Packages: ${DEB}, ${RPM}"
|
||||
echo " Checksums: dist/SHA256SUMS.asc"
|
||||
echo " Registry: deb → bookworm, jammy, noble; rpm → ${RPM_GROUP}"
|
||||
echo " Release: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/v${VERSION}"
|
||||
echo ""
|
||||
echo "Key ceremony: delete the private key after release."
|
||||
echo " See docs/install/signing-key-ceremony.md"
|
||||
@@ -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()
|
||||
+912
-69
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
"""Release flow tests (issue #52).
|
||||
|
||||
Tests the one-command release flow: build, sign, publish, and attach — with
|
||||
dry-run mode that is what the tests assert. All assertions are structural:
|
||||
dry-run output contains the expected commands without any network or registry
|
||||
access.
|
||||
|
||||
Requirements:
|
||||
- scripts/release.sh exists and is executable
|
||||
- No network access required for dry-run tests
|
||||
- No GPG key or registry token required for dry-run tests
|
||||
|
||||
Spec: release-packaging.md §5, issue #52 acceptance criteria
|
||||
"""
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
RELEASE_SCRIPT = REPO_ROOT / "scripts" / "release.sh"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read(path: str | Path) -> str:
|
||||
from tests.conftest import read
|
||||
return read(path)
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""Extract version from pyproject.toml."""
|
||||
from tests.conftest import get_version
|
||||
return get_version()
|
||||
|
||||
|
||||
def _run_dry_run(*args: str) -> tuple[int, str]:
|
||||
"""Run the release script in dry-run mode and return (exit_code, stdout)."""
|
||||
cmd = ["bash", str(RELEASE_SCRIPT), "--dry-run"] + list(args)
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=30,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
return r.returncode, r.stdout + r.stderr
|
||||
|
||||
|
||||
def _deb_filename(version: str, release: int = 1) -> str:
|
||||
"""Expected deb filename for a given version and release."""
|
||||
return f"fenris_{version}_amd64.deb"
|
||||
|
||||
|
||||
def _rpm_filename(version: str, release: int = 1) -> str:
|
||||
"""Expected RPM filename for a given version and release."""
|
||||
return f"fenris-{version}-{release}.x86_64.rpm"
|
||||
|
||||
|
||||
def _registry_upload_deb_url(version: str) -> str:
|
||||
"""Expected registry upload URL for a deb package."""
|
||||
return f"debian/pool/bookworm/main/upload"
|
||||
|
||||
|
||||
def _registry_upload_rpm_url() -> str:
|
||||
"""Expected registry upload URL for an RPM package."""
|
||||
return "rpm/fenris/upload"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — release script existence and permissions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReleaseScriptExists:
|
||||
"""Verify the release script is present and executable."""
|
||||
|
||||
def test_script_exists(self):
|
||||
assert RELEASE_SCRIPT.exists(), \
|
||||
"scripts/release.sh must exist"
|
||||
|
||||
def test_script_is_executable(self):
|
||||
assert RELEASE_SCRIPT.stat().st_mode & 0o111, \
|
||||
"scripts/release.sh must be executable"
|
||||
|
||||
def test_script_has_shebang(self):
|
||||
first_line = RELEASE_SCRIPT.read_text().splitlines()[0]
|
||||
assert first_line.startswith("#!/"), \
|
||||
"scripts/release.sh must have a shebang"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — dry-run prints all expected commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRunCommandPrintout:
|
||||
"""Verify dry-run prints every command that would execute."""
|
||||
|
||||
def test_dry_run_exits_zero(self):
|
||||
rc, _ = _run_dry_run()
|
||||
assert rc == 0, "Dry-run must exit zero"
|
||||
|
||||
def test_dry_run_prints_make_package(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "make" in output.lower() and "package" in output.lower(), \
|
||||
"Dry-run must print the make package command"
|
||||
|
||||
def test_dry_run_prints_rpm_signing(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "rpmsign" in output or "sign" in output.lower(), \
|
||||
"Dry-run must print RPM signing step"
|
||||
|
||||
def test_dry_run_prints_sha256sums(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "sha256sum" in output, \
|
||||
"Dry-run must print SHA256SUMS generation"
|
||||
|
||||
def test_dry_run_prints_clearsign(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "clearsign" in output or "SHA256SUMS.asc" in output, \
|
||||
"Dry-run must print clearsign step"
|
||||
|
||||
def test_dry_run_prints_deb_upload(self):
|
||||
version = _get_version()
|
||||
_, output = _run_dry_run()
|
||||
assert _registry_upload_deb_url(version) in output, \
|
||||
f"Dry-run must print deb upload URL ({_registry_upload_deb_url(version)})"
|
||||
|
||||
def test_dry_run_prints_deb_upload_for_all_codenames(self):
|
||||
_, output = _run_dry_run()
|
||||
for codename in ("bookworm", "jammy", "noble"):
|
||||
assert codename in output, \
|
||||
f"Dry-run must include upload for {codename}"
|
||||
|
||||
def test_dry_run_prints_rpm_upload(self):
|
||||
_, output = _run_dry_run()
|
||||
assert _registry_upload_rpm_url() in output, \
|
||||
f"Dry-run must print RPM upload URL ({_registry_upload_rpm_url()})"
|
||||
|
||||
def test_dry_run_prints_release_creation(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "release" in output.lower(), \
|
||||
"Dry-run must print release creation step"
|
||||
|
||||
def test_dry_run_prints_attachment_upload(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "SHA256SUMS.asc" in output, \
|
||||
"Dry-run must print SHA256SUMS.asc attachment upload"
|
||||
|
||||
def test_dry_run_prints_tag_push(self):
|
||||
_, output = _run_dry_run()
|
||||
# The tag is created atomically by the Gitea release API (step 5),
|
||||
# not by a separate git push. Verify the release creation step is present.
|
||||
assert "tag_name" in output or "release" in output.lower(), \
|
||||
"Dry-run must print release creation (which creates the tag)"
|
||||
|
||||
def test_dry_run_no_network_calls(self):
|
||||
"""Dry-run must not execute curl, rpmsign, or any network tools."""
|
||||
_, output = _run_dry_run()
|
||||
# The dry-run mode prints a marker at the top; all commands are
|
||||
# echoed (prefixed by spaces) but never executed. Verify the
|
||||
# marker is present, confirming we're in dry-run mode.
|
||||
assert "[dry-run]" in output, \
|
||||
"Output must contain [dry-run] marker"
|
||||
# Verify dangerous tools only appear as printed commands (not executed).
|
||||
# Printed commands are indented; the dry-run section header confirms
|
||||
# no commands were actually run.
|
||||
assert "Commands below will be executed" in output, \
|
||||
"Dry-run must indicate commands are for display only"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — dry-run prints correct package filenames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRunFilenames:
|
||||
"""Verify dry-run uses the correct artifact filenames."""
|
||||
|
||||
def test_deb_filename_in_output(self):
|
||||
version = _get_version()
|
||||
_, output = _run_dry_run()
|
||||
expected = _deb_filename(version)
|
||||
assert expected in output, \
|
||||
f"Dry-run must reference deb filename {expected}"
|
||||
|
||||
def test_rpm_filename_in_output(self):
|
||||
version = _get_version()
|
||||
_, output = _run_dry_run()
|
||||
expected = _rpm_filename(version)
|
||||
assert expected in output, \
|
||||
f"Dry-run must reference RPM filename {expected}"
|
||||
|
||||
def test_checksums_filename_in_output(self):
|
||||
_, output = _run_dry_run()
|
||||
assert "SHA256SUMS" in output, \
|
||||
"Dry-run must reference SHA256SUMS filename"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — dry-run does not create artifacts or tags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDryRunNoSideEffects:
|
||||
"""Verify dry-run creates no filesystem or git side effects."""
|
||||
|
||||
def test_dry_run_no_git_tag_created(self):
|
||||
version = _get_version()
|
||||
tag = f"v{version}"
|
||||
# Ensure tag doesn't exist before
|
||||
r = subprocess.run(
|
||||
["git", "tag", "-l", tag], capture_output=True, text=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
pre_tags = r.stdout.strip()
|
||||
|
||||
_run_dry_run()
|
||||
|
||||
# Verify tag was not created
|
||||
r = subprocess.run(
|
||||
["git", "tag", "-l", tag], capture_output=True, text=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
post_tags = r.stdout.strip()
|
||||
assert pre_tags == post_tags, \
|
||||
f"Dry-run must not create git tag {tag}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — revision bumping (structural: output contains incremented release)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRevisionBumping:
|
||||
"""Verify the release script handles revision bumping.
|
||||
|
||||
When a version already exists in the registry (HTTP 409), the script
|
||||
bumps the revision and retries. These tests verify the dry-run output
|
||||
reflects the correct revision logic — without any network access.
|
||||
"""
|
||||
|
||||
def test_dry_run_starts_at_revision_one(self):
|
||||
version = _get_version()
|
||||
_, output = _run_dry_run()
|
||||
rpm_expected = _rpm_filename(version, 1)
|
||||
assert rpm_expected in output, \
|
||||
f"Dry-run must start at release 1: expected {rpm_expected} in output"
|
||||
|
||||
def test_revision_bump_changes_rpm_filename(self):
|
||||
"""When revision is bumped, the RPM filename changes accordingly."""
|
||||
version = _get_version()
|
||||
rpm_r1 = _rpm_filename(version, 1)
|
||||
rpm_r2 = _rpm_filename(version, 2)
|
||||
# R2 filename must differ from R1
|
||||
assert rpm_r1 != rpm_r2, \
|
||||
"R2 filename must differ from R1"
|
||||
# Both must contain the version
|
||||
assert version in rpm_r1
|
||||
assert version in rpm_r2
|
||||
|
||||
def test_revision_bump_changes_deb_filename(self):
|
||||
"""When revision is bumped, the deb filename also changes."""
|
||||
version = _get_version()
|
||||
# Deb filename includes release in nfpm naming
|
||||
deb_r1 = f"fenris_{version}_amd64.deb"
|
||||
deb_r2 = f"fenris_{version}_amd64.deb"
|
||||
# For deb, the filename doesn't change with revision (deb uses epoch)
|
||||
# But the RPM does — this verifies we test RPM revision correctly
|
||||
rpm_r1 = _rpm_filename(version, 1)
|
||||
rpm_r2 = _rpm_filename(version, 2)
|
||||
assert "-1." in rpm_r1, "R1 RPM must contain -1."
|
||||
assert "-2." in rpm_r2, "R2 RPM must contain -2."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — bare tag prevention (structural)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBareTagPrevention:
|
||||
"""Verify the flow prevents bare tags.
|
||||
|
||||
A bare tag (tag without packages, release entry, notes, and checksums)
|
||||
must not result from the flow. The script checks for existing bare
|
||||
tags before proceeding. These tests verify the dry-run doesn't create
|
||||
any tags.
|
||||
"""
|
||||
|
||||
def test_dry_run_does_not_push_tag(self):
|
||||
_, output = _run_dry_run()
|
||||
# The dry-run marker confirms no commands are executed.
|
||||
# git push appears only as a printed command, never executed.
|
||||
assert "[dry-run]" in output, \
|
||||
"Must be in dry-run mode"
|
||||
# Tag push is printed but the [dry-run] marker confirms nothing ran
|
||||
assert "Commands below will be executed" in output, \
|
||||
"Dry-run must indicate commands are for display only"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — CI workflow file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCIWorkflow:
|
||||
"""Verify the dormant CI workflow is present and correctly structured."""
|
||||
|
||||
def test_workflow_file_exists(self):
|
||||
path = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
||||
assert path.exists(), \
|
||||
".gitea/workflows/release.yml must exist"
|
||||
|
||||
def test_workflow_triggers_on_tags(self):
|
||||
content = _read(".gitea/workflows/release.yml")
|
||||
assert "v*" in content, \
|
||||
"Workflow must trigger on version tags (v*)"
|
||||
|
||||
def test_workflow_has_release_step(self):
|
||||
content = _read(".gitea/workflows/release.yml")
|
||||
assert "release" in content.lower(), \
|
||||
"Workflow must have a release step"
|
||||
|
||||
def test_workflow_mentions_signing(self):
|
||||
content = _read(".gitea/workflows/release.yml")
|
||||
assert "sign" in content.lower(), \
|
||||
"Workflow must include signing step"
|
||||
|
||||
def test_workflow_mentions_upload(self):
|
||||
content = _read(".gitea/workflows/release.yml")
|
||||
assert "upload" in content.lower() or "publish" in content.lower(), \
|
||||
"Workflow must include upload/publish step"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Makefile release targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMakefileReleaseTargets:
|
||||
"""Verify the Makefile exposes release-related targets."""
|
||||
|
||||
def _makefile_content(self) -> str:
|
||||
return _read("Makefile")
|
||||
|
||||
def test_release_run_target_exists(self):
|
||||
content = self._makefile_content()
|
||||
assert "release-run:" in content, \
|
||||
"Makefile must have a release-run target"
|
||||
|
||||
def test_release_dry_run_target_exists(self):
|
||||
content = self._makefile_content()
|
||||
assert "release-dry-run:" in content, \
|
||||
"Makefile must have a release-dry-run target"
|
||||
|
||||
def test_release_run_calls_script(self):
|
||||
content = self._makefile_content()
|
||||
assert "release.sh" in content, \
|
||||
"release-run target must call scripts/release.sh"
|
||||
|
||||
def test_release_dry_run_uses_dry_run_flag(self):
|
||||
content = self._makefile_content()
|
||||
# Find the release-dry-run target and verify it passes --dry-run
|
||||
in_target = False
|
||||
for line in content.splitlines():
|
||||
if line.startswith("release-dry-run:"):
|
||||
in_target = True
|
||||
continue
|
||||
if in_target and line.strip():
|
||||
if "--dry-run" in line:
|
||||
break
|
||||
if not line.startswith("\t"):
|
||||
break
|
||||
else:
|
||||
pytest.fail("release-dry-run target must pass --dry-run to release.sh")
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Signing and consumer-repo structural tests (issue #51).
|
||||
|
||||
Verifies that the signing infrastructure, consumer setup docs, and key
|
||||
publication are correctly wired — without requiring a real GPG key,
|
||||
network access, or Docker.
|
||||
|
||||
All assertions are structural: config keys exist, URLs match, docs
|
||||
are present, and the Makefile exposes the right targets. A throwaway
|
||||
test key exercise is included for rpm signature verification mechanics.
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _read(path: str | Path) -> str:
|
||||
from tests.conftest import read
|
||||
return read(path)
|
||||
|
||||
|
||||
def _gpg_available() -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["gpg", "--version"], capture_output=True, timeout=5,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _rpmsign_available() -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["rpmsign", "--version"], capture_output=True, timeout=5,
|
||||
)
|
||||
return r.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — nfpm.yaml signing configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNfpmSigningConfig:
|
||||
"""Verify that nfpm.yaml is correctly configured for RPM builds.
|
||||
|
||||
Note: RPM signing is done via rpmsign post-build (make sign-rpm),
|
||||
not through nfpm's built-in signing. This keeps the build unsigned
|
||||
and the signing step explicit and key-controlled.
|
||||
"""
|
||||
|
||||
def test_rpm_overrides_exist(self):
|
||||
"""nfpm.yaml must have overrides.rpm for per-format deltas."""
|
||||
content = _read("packaging/nfpm.yaml")
|
||||
assert "overrides:" in content, "overrides section missing from nfpm.yaml"
|
||||
assert "rpm:" in content, "rpm overrides missing from nfpm.yaml"
|
||||
|
||||
def test_rpm_scripts_configured(self):
|
||||
"""RPM must use the dedicated scriptlets, not deb scripts."""
|
||||
content = _read("packaging/nfpm.yaml")
|
||||
assert "packaging/rpm/post.sh" in content, \
|
||||
"RPM postinstall must use rpm/post.sh"
|
||||
assert "packaging/rpm/preun.sh" in content, \
|
||||
"RPM preremove must use rpm/preun.sh"
|
||||
assert "packaging/rpm/postun.sh" in content, \
|
||||
"RPM postremove must use rpm/postun.sh"
|
||||
|
||||
def test_rpm_depends_use_correct_syntax(self):
|
||||
"""RPM dependencies must use rpm-style version syntax."""
|
||||
content = _read("packaging/nfpm.yaml")
|
||||
assert "python3 >= 3.10" in content, \
|
||||
"RPM depends must use rpm-style version constraint"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — Makefile signing targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMakefileSigningTargets:
|
||||
"""Verify that the Makefile exposes signing-related targets."""
|
||||
|
||||
def _makefile_content(self) -> str:
|
||||
return _read("Makefile")
|
||||
|
||||
def test_generate_test_key_target(self):
|
||||
content = self._makefile_content()
|
||||
assert "generate-test-key:" in content, \
|
||||
"Makefile must have a generate-test-key target"
|
||||
assert "packaging-test@bongbetic.com" in content or \
|
||||
"packaging@bongbetic.com" in content, \
|
||||
"generate-test-key must reference the packaging key UID"
|
||||
|
||||
def test_sign_rpm_target(self):
|
||||
content = self._makefile_content()
|
||||
assert "sign-rpm:" in content, \
|
||||
"Makefile must have a sign-rpm target"
|
||||
assert "rpmsign" in content, \
|
||||
"sign-rpm target must use rpmsign"
|
||||
|
||||
def test_checksums_target(self):
|
||||
content = self._makefile_content()
|
||||
assert "checksums:" in content, \
|
||||
"Makefile must have a checksums target"
|
||||
assert "sha256sum" in content, \
|
||||
"checksums target must use sha256sum"
|
||||
|
||||
def test_clearsign_target(self):
|
||||
content = self._makefile_content()
|
||||
assert "clearsign:" in content, \
|
||||
"Makefile must have a clearsign target"
|
||||
assert "--clearsign" in content, \
|
||||
"clearsign target must use gpg --clearsign"
|
||||
|
||||
def test_release_depends_on_signing(self):
|
||||
content = self._makefile_content()
|
||||
for line in content.splitlines():
|
||||
if line.startswith("release:"):
|
||||
deps = line.split(":", 1)[1].strip()
|
||||
assert "sign-rpm" in deps, \
|
||||
"release target must depend on sign-rpm"
|
||||
assert "clearsign" in deps, \
|
||||
"release target must depend on clearsign"
|
||||
break
|
||||
else:
|
||||
pytest.fail("release target not found in Makefile")
|
||||
|
||||
def test_packaging_key_uid_defined(self):
|
||||
content = self._makefile_content()
|
||||
assert "PACKAGING_KEY" in content, \
|
||||
"Makefile must define PACKAGING_KEY variable"
|
||||
|
||||
def test_release_mentions_key_ceremony(self):
|
||||
content = self._makefile_content()
|
||||
assert "signing-key-ceremony.md" in content, \
|
||||
"release target must reference the key ceremony doc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — fenris.repo configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFenrisRepo:
|
||||
"""Verify the dnf repo file is correctly configured for Fenris."""
|
||||
|
||||
def _repo_content(self) -> str:
|
||||
return _read("packaging/fenris.repo")
|
||||
|
||||
def test_gpgcheck_enabled(self):
|
||||
content = self._repo_content()
|
||||
assert "gpgcheck=1" in content, \
|
||||
"fenris.repo must set gpgcheck=1 for payload verification"
|
||||
|
||||
def test_repo_gpgcheck_disabled(self):
|
||||
content = self._repo_content()
|
||||
assert "repo_gpgcheck=0" in content, \
|
||||
"fenris.repo must set repo_gpgcheck=0 (metadata check via TLS)"
|
||||
|
||||
def test_gpgkey_points_to_packaging_key(self):
|
||||
content = self._repo_content()
|
||||
assert "gpgkey=" in content, \
|
||||
"fenris.repo must have a gpgkey directive"
|
||||
assert "fenris-packaging.asc" in content, \
|
||||
"gpgkey must point at the packaging key"
|
||||
assert "raw/branch/main" in content, \
|
||||
"gpgkey must use raw URL for the public key"
|
||||
|
||||
def test_baseurl_is_fenris_rpm_group(self):
|
||||
content = self._repo_content()
|
||||
assert "rpm/fenris" in content, \
|
||||
"baseurl must point at the fenris RPM group"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — public key publication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeyPublication:
|
||||
"""Verify the public key is published in-repo with correct metadata."""
|
||||
|
||||
def test_key_file_exists(self):
|
||||
key_path = REPO_ROOT / "packaging" / "keys" / "fenris-packaging.asc"
|
||||
assert key_path.exists(), \
|
||||
"packaging/keys/fenris-packaging.asc must exist"
|
||||
|
||||
def test_key_file_has_raw_url(self):
|
||||
content = _read("packaging/keys/fenris-packaging.asc")
|
||||
raw_url = (
|
||||
"https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/"
|
||||
"packaging/keys/fenris-packaging.asc"
|
||||
)
|
||||
assert raw_url in content, \
|
||||
"Key file must contain its own raw URL as documentation"
|
||||
|
||||
def test_key_file_documents_algorithm(self):
|
||||
content = _read("packaging/keys/fenris-packaging.asc")
|
||||
assert "RSA 3072" in content or "rsa3072" in content.lower(), \
|
||||
"Key file must document the algorithm as RSA 3072"
|
||||
|
||||
def test_key_file_documents_uid(self):
|
||||
content = _read("packaging/keys/fenris-packaging.asc")
|
||||
assert "Fenris Packaging" in content, \
|
||||
"Key file must document the UID"
|
||||
|
||||
def test_key_file_documents_expiry(self):
|
||||
content = _read("packaging/keys/fenris-packaging.asc")
|
||||
assert "2 year" in content or "2-year" in content or "expiry" in content.lower(), \
|
||||
"Key file must document the expiry policy"
|
||||
|
||||
def test_key_file_references_ceremony_doc(self):
|
||||
content = _read("packaging/keys/fenris-packaging.asc")
|
||||
assert "signing-key-ceremony.md" in content, \
|
||||
"Key file must reference the key ceremony document"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — key ceremony documentation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeyCeremonyDoc:
|
||||
"""Verify the key ceremony document is complete and accurate."""
|
||||
|
||||
def _doc_content(self) -> str:
|
||||
return _read("docs/install/signing-key-ceremony.md")
|
||||
|
||||
def test_ceremony_doc_exists(self):
|
||||
assert (REPO_ROOT / "docs" / "install" / "signing-key-ceremony.md").exists(), \
|
||||
"docs/install/signing-key-ceremony.md must exist"
|
||||
|
||||
def test_documents_key_specification(self):
|
||||
content = self._doc_content()
|
||||
assert "RSA 3072" in content, "Must document RSA 3072 algorithm"
|
||||
assert "Fenris Packaging" in content, "Must document the UID"
|
||||
assert "packaging@bongbetic.com" in content, "Must document the email"
|
||||
|
||||
def test_documents_import_sign_delete(self):
|
||||
content = self._doc_content()
|
||||
assert "import" in content.lower(), "Must document import step"
|
||||
assert "sign" in content.lower(), "Must document sign step"
|
||||
assert "delete" in content.lower(), "Must document delete step"
|
||||
|
||||
def test_documents_rotation_outline(self):
|
||||
content = self._doc_content()
|
||||
assert "rotation" in content.lower(), \
|
||||
"Must document key rotation procedure"
|
||||
|
||||
def test_documents_dual_key_approach(self):
|
||||
content = self._doc_content()
|
||||
assert "previous" in content.lower() or "old" in content.lower(), \
|
||||
"Must document old key retention during rotation"
|
||||
|
||||
def test_documents_private_key_storage(self):
|
||||
content = self._doc_content()
|
||||
assert "password manager" in content.lower(), \
|
||||
"Must document that private key lives in password manager"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — consumer setup documentation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsumerDocs:
|
||||
"""Verify consumer setup docs are present and correctly wired."""
|
||||
|
||||
def _readme_content(self) -> str:
|
||||
return _read("README.md")
|
||||
|
||||
def test_apt_signed_by_flow(self):
|
||||
content = self._readme_content()
|
||||
assert "signed-by" in content, \
|
||||
"README must document apt signed-by keyring flow"
|
||||
assert "keyrings" in content, \
|
||||
"README must show the keyrings directory"
|
||||
|
||||
def test_apt_fingerprint_placeholder(self):
|
||||
content = self._readme_content()
|
||||
assert "Fingerprint" in content or "fingerprint" in content, \
|
||||
"README must include fingerprint placeholder for TOFU hardening"
|
||||
|
||||
def test_dnf_repo_flow(self):
|
||||
content = self._readme_content()
|
||||
assert "dnf config-manager --add-repo" in content or \
|
||||
"dnf install" in content, \
|
||||
"README must document dnf install flow"
|
||||
assert "fenris.repo" in content, \
|
||||
"README must reference the Fenris-owned repo file"
|
||||
|
||||
def test_no_gitea_auto_repo(self):
|
||||
"""Gitea's auto-generated .repo must never be referenced in docs."""
|
||||
content = self._readme_content()
|
||||
# The Gitea auto-generated repo would have gpgcheck=1 against the
|
||||
# instance key, which is a trap. Our docs should only reference
|
||||
# our own fenris.repo file.
|
||||
assert "auto-generated" not in content.lower() or \
|
||||
"never" in content.lower(), \
|
||||
"README must not recommend Gitea's auto-generated .repo"
|
||||
|
||||
def test_package_signature_verification(self):
|
||||
content = self._readme_content()
|
||||
assert "rpm -K" in content or "rpm --checksig" in content, \
|
||||
"README must document RPM signature verification"
|
||||
assert "gpg --verify" in content, \
|
||||
"README must document GPG verification for SHA256SUMS"
|
||||
|
||||
def test_migration_from_make_install(self):
|
||||
content = self._readme_content()
|
||||
assert "migrate-from-makeinstall" in content.lower() or \
|
||||
"migration" in content.lower(), \
|
||||
"README must reference the migration runbook"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — release spec references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReleaseSpecReferences:
|
||||
"""Verify the release spec references the ceremony doc and nfpm config."""
|
||||
|
||||
def _spec_content(self) -> str:
|
||||
return _read("docs/spec/release-packaging.md")
|
||||
|
||||
def test_spec_references_rpmsign(self):
|
||||
content = self._spec_content()
|
||||
assert "rpmsign" in content.lower() or "sign-rpm" in content, \
|
||||
"Spec must reference rpmsign or make sign-rpm for RPM signing"
|
||||
|
||||
def test_spec_references_ceremony_doc(self):
|
||||
content = self._spec_content()
|
||||
assert "signing-key-ceremony.md" in content, \
|
||||
"Spec must reference the key ceremony document"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — RPM signature mechanics (throwaway test key, no network)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRpmSignatureMechanics:
|
||||
"""Verify RPM signing mechanics using a throwaway test key.
|
||||
|
||||
These tests generate a temporary GPG key, build an RPM (or use an
|
||||
existing one), sign it, and verify the signature — all without
|
||||
network access. They require gpg and rpmsign to be available.
|
||||
"""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _gpg_available() or not _rpmsign_available(),
|
||||
reason="gpg or rpmsign not available",
|
||||
)
|
||||
def test_throwaway_key_signs_and_verifies(self):
|
||||
"""Generate a throwaway key, sign a test RPM, verify signature."""
|
||||
# Find existing RPM
|
||||
version = None
|
||||
for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines():
|
||||
if line.startswith("version"):
|
||||
version = line.split("=")[1].strip().strip('"')
|
||||
break
|
||||
rpm_path = REPO_ROOT / "dist" / f"fenris-{version}-1.x86_64.rpm"
|
||||
if not rpm_path.exists():
|
||||
pytest.skip("RPM not built — run `make package-rpm` first")
|
||||
|
||||
key_uid = "fenris-test-signing@example.com"
|
||||
try:
|
||||
# Generate throwaway key
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--gen-key"],
|
||||
input=f"""%no-protection
|
||||
Key-Type: RSA
|
||||
Key-Length: 3072
|
||||
Name-Real: {key_uid}
|
||||
Name-Email: {key_uid}
|
||||
Expire-Date: 0
|
||||
%commit
|
||||
""",
|
||||
text=True, check=True, timeout=30,
|
||||
)
|
||||
|
||||
# Copy RPM to temp dir for signing
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
signed_rpm = Path(tmpdir) / rpm_path.name
|
||||
signed_rpm.write_bytes(rpm_path.read_bytes())
|
||||
|
||||
# Sign the RPM
|
||||
subprocess.run(
|
||||
["rpmsign", "--addsign",
|
||||
"--define", f"_gpg_name {key_uid}",
|
||||
str(signed_rpm)],
|
||||
check=True, timeout=30,
|
||||
)
|
||||
|
||||
# Verify the signature exists and has correct format
|
||||
# (rpm -Kv returns NOKEY if key isn't imported, but the
|
||||
# signature header is still present and verifiable)
|
||||
r = subprocess.run(
|
||||
["rpm", "-Kv", str(signed_rpm)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
output = r.stdout + r.stderr
|
||||
assert "RSA" in output or "rsa" in output.lower(), \
|
||||
f"RPM must have RSA signature: {output}"
|
||||
assert "SHA256" in output or "sha256" in output.lower(), \
|
||||
f"RPM must have SHA256 digest: {output}"
|
||||
assert "Header V4" in output or "Header" in output, \
|
||||
f"RPM must have V4 signature header: {output}"
|
||||
assert "Signature" in output, \
|
||||
f"RPM must show signature info: {output}"
|
||||
|
||||
finally:
|
||||
# Clean up the test key
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _gpg_available(),
|
||||
reason="gpg not available",
|
||||
)
|
||||
def test_clearsign_and_verify(self):
|
||||
"""Clearsign a test manifest and verify the signature."""
|
||||
key_uid = "fenris-test-clearsign@example.com"
|
||||
try:
|
||||
# Generate throwaway key
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--gen-key"],
|
||||
input=f"""%no-protection
|
||||
Key-Type: RSA
|
||||
Key-Length: 3072
|
||||
Name-Real: {key_uid}
|
||||
Name-Email: {key_uid}
|
||||
Expire-Date: 0
|
||||
%commit
|
||||
""",
|
||||
text=True, check=True, timeout=30,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sums = Path(tmpdir) / "SHA256SUMS"
|
||||
sums.write_text(
|
||||
"abc123 fenris_0.3.0_amd64.deb\n"
|
||||
"def456 fenris-0.3.0-1.x86_64.rpm\n"
|
||||
)
|
||||
|
||||
# Clearsign
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--yes", "--clearsign",
|
||||
"--local-user", key_uid, str(sums)],
|
||||
check=True, timeout=10,
|
||||
)
|
||||
|
||||
# Verify (clearsigned file — just one argument to --verify)
|
||||
r = subprocess.run(
|
||||
["gpg", "--verify", str(sums.with_suffix(".asc"))],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert r.returncode == 0, \
|
||||
f"Clearsign verification failed: {r.stderr}"
|
||||
assert "Good signature" in r.stderr, \
|
||||
f"Expected Good signature: {r.stderr}"
|
||||
|
||||
finally:
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["gpg", "--batch", "--yes", "--delete-keys", key_uid],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user