Compare commits
11
Commits
917c94fd65
..
v0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd053cf125 | ||
|
|
e3b6f89ebb | ||
|
|
412cbd51c6 | ||
|
|
3355c20202 | ||
|
|
2c93874799 | ||
|
|
3c07f37c78 | ||
|
|
b098132595 | ||
|
|
1dbe372714 | ||
|
|
1063fa4dae | ||
|
|
3f2dd6a5a1 | ||
|
|
a5b84f7566 |
@@ -0,0 +1,92 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: bongbetic-ci
|
||||||
|
timeout-minutes: 15
|
||||||
|
container:
|
||||||
|
image: docker.io/semgrep/semgrep:1.178.0
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git -c http.extraheader="Authorization: token ${GIT_TOKEN}" fetch -q --depth=1 origin "${{ gitea.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Semgrep
|
||||||
|
run: semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
|
||||||
|
|
||||||
|
lint:
|
||||||
|
if: gitea.event_name != 'schedule'
|
||||||
|
runs-on: bongbetic-ci
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
env:
|
||||||
|
GIT_TOKEN: ${{ gitea.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||||
|
git -c http.extraheader="Authorization: token ${GIT_TOKEN}" fetch -q --depth=1 origin "${{ gitea.sha }}"
|
||||||
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
|
# node:24-bookworm ships python3 without ensurepip, so python3-venv comes from apt.
|
||||||
|
- name: Install ruff
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends python3-venv
|
||||||
|
python3 -m venv /tmp/lint-venv
|
||||||
|
/tmp/lint-venv/bin/pip install -q ruff==0.16.10
|
||||||
|
|
||||||
|
- name: Ruff
|
||||||
|
run: /tmp/lint-venv/bin/ruff check src/ tests/
|
||||||
|
|
||||||
|
- name: Duplicate code (jscpd)
|
||||||
|
run: npx --yes jscpd@4.3.0 --config .jscpd.json .
|
||||||
|
|
||||||
|
ai-review:
|
||||||
|
if: gitea.event_name == 'pull_request'
|
||||||
|
runs-on: bongbetic-ci
|
||||||
|
timeout-minutes: 10
|
||||||
|
continue-on-error: true
|
||||||
|
container:
|
||||||
|
image: docker.io/pragent/pr-agent:0.47.0
|
||||||
|
env:
|
||||||
|
config__git_provider: gitea
|
||||||
|
gitea__url: https://git.bongbetic.com
|
||||||
|
gitea__personal_access_token: ${{ secrets.PR_AGENT_GITEA_TOKEN }}
|
||||||
|
openrouter__key: ${{ secrets.OPENROUTER_API_KEY }}
|
||||||
|
config__model: ${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}
|
||||||
|
config__fallback_models: "[\"${{ vars.PR_AGENT_MODEL || 'openrouter/anthropic/claude-sonnet-5' }}\"]"
|
||||||
|
config__custom_model_max_tokens: '200000'
|
||||||
|
steps:
|
||||||
|
# Advisory only: posts a review comment, never pushes code. Skips when secrets are absent
|
||||||
|
# (for example PRs from forks, where Gitea withholds secrets).
|
||||||
|
- name: PR-Agent review
|
||||||
|
env:
|
||||||
|
PR_URL: ${{ gitea.event.pull_request.html_url }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${gitea__personal_access_token}" ] || [ -z "${openrouter__key}" ]; then
|
||||||
|
echo "::notice::PR_AGENT_GITEA_TOKEN or OPENROUTER_API_KEY not set; skipping AI review"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd /app
|
||||||
|
pr-agent --pr_url="${PR_URL}" review
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: [self-hosted]
|
runs-on: [self-hosted]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4 # nosemgrep: github-actions-mutable-action-tag -- release path unchanged, tag pinned by major version
|
||||||
|
|
||||||
- name: Validate release tag and notes
|
- name: Validate release tag and notes
|
||||||
run: |
|
run: |
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
|
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5 # nosemgrep: github-actions-mutable-action-tag -- release path unchanged, tag pinned by major version
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
@@ -159,7 +159,6 @@ jobs:
|
|||||||
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
|
gpg --batch --yes --delete-secret-keys "${FINGERPRINT}"
|
||||||
gpg --batch --yes --delete-keys "${FINGERPRINT}"
|
gpg --batch --yes --delete-keys "${FINGERPRINT}"
|
||||||
fi
|
fi
|
||||||
rm -f ~/.ssh/id_xbps
|
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: version
|
id: version
|
||||||
@@ -204,9 +203,21 @@ jobs:
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Publish XBPS to distribution repository
|
- name: Publish XBPS to distribution repository
|
||||||
if: github.event.inputs.publish_xbps == 'true'
|
id: publish-xbps
|
||||||
|
if: ${{ github.event.inputs.publish_xbps == true || github.event.inputs.publish_xbps == 'true' }}
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
|
||||||
|
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git config user.name "xavierk"
|
||||||
|
git config user.email "xavierk@bongbetic.com"
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0='http.https://git.bongbetic.com/.extraheader'
|
||||||
|
export GIT_CONFIG_VALUE_0="Authorization: token ${GITEA_PUBLISH_TOKEN}"
|
||||||
VERSION=${{ steps.version.outputs.version }}
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
||||||
if [ ! -f "${XBPS_FILE}" ]; then
|
if [ ! -f "${XBPS_FILE}" ]; then
|
||||||
@@ -218,6 +229,7 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
bash scripts/xbps-publish.sh --publish
|
bash scripts/xbps-publish.sh --publish
|
||||||
|
echo "xbps_published=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Track format availability
|
- name: Track format availability
|
||||||
id: formats
|
id: formats
|
||||||
@@ -227,7 +239,7 @@ jobs:
|
|||||||
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
|
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
|
||||||
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
|
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
|
||||||
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
|
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
|
||||||
XBPS_PUBLISHED=$([ "${{ github.event.inputs.publish_xbps }}" = "true" ] && echo "true" || echo "false")
|
XBPS_PUBLISHED=$([ "${{ steps.publish-xbps.outputs.xbps_published }}" = "true" ] && echo "true" || echo "false")
|
||||||
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
|
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
|
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
|
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -318,7 +330,8 @@ jobs:
|
|||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
||||||
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
# Attach deb, rpm, clearsigned checksums, and XBPS artifacts once.
|
PUBLISH_XBPS="${{ steps.formats.outputs.xbps_published }}"
|
||||||
|
# Attach package artifacts; refresh XBPS assets after publication.
|
||||||
ARTIFACTS=(
|
ARTIFACTS=(
|
||||||
"dist/fenris_${VERSION}_amd64.deb"
|
"dist/fenris_${VERSION}_amd64.deb"
|
||||||
"dist/fenris-${VERSION}-1.x86_64.rpm"
|
"dist/fenris-${VERSION}-1.x86_64.rpm"
|
||||||
@@ -336,12 +349,24 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
for FILE in "${ARTIFACTS[@]}"; do
|
for FILE in "${ARTIFACTS[@]}"; do
|
||||||
ASSET_NAME="${FILE##*/}"
|
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
|
EXISTING_ASSET_ID=$(python3 -c 'import json,sys; name=sys.argv[1]; print(next((a["id"] for a in json.load(sys.stdin).get("assets", []) if a.get("name") == name), ""))' \
|
||||||
echo "${ASSET_NAME}: already attached"
|
"${ASSET_NAME}" <<<"${RELEASE_JSON}")
|
||||||
else
|
if [ -n "${EXISTING_ASSET_ID}" ]; then
|
||||||
curl --fail --silent --show-error -X POST \
|
if [ "${PUBLISH_XBPS}" = "true" ] && [[ "${ASSET_NAME}" = "${XBPS_FILE}" || "${ASSET_NAME}" = "${XBPS_FILE}.sig2" ]]; then
|
||||||
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
curl --fail --silent --show-error -X DELETE \
|
||||||
-F "attachment=@${FILE}" \
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}"
|
||||||
|
else
|
||||||
|
echo "${ASSET_NAME}: already attached"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
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"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Remove XBPS signing key
|
||||||
|
if: always()
|
||||||
|
run: rm -f ~/.ssh/id_xbps
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"threshold": 8,
|
||||||
|
"minLines": 5,
|
||||||
|
"minTokens": 50,
|
||||||
|
"reporters": ["console"],
|
||||||
|
"gitignore": true,
|
||||||
|
"ignore": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/.git/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/docs/**",
|
||||||
|
"**/packaging/**",
|
||||||
|
"**/*.lock",
|
||||||
|
"**/package-lock.json",
|
||||||
|
"**/requirements.txt",
|
||||||
|
"**/*.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# CI quality gates
|
||||||
|
|
||||||
|
Workflow: `.gitea/workflows/ci.yml` (runner label `bongbetic-ci`, no third-party `uses:` actions; code is checked out with shell git).
|
||||||
|
Status contexts: `CI / security (pull_request)`, `CI / lint (pull_request)`, `CI / ai-review (pull_request)`.
|
||||||
|
|
||||||
|
| Job | Runs on | Blocks merge? | What it does |
|
||||||
|
|-----|---------|---------------|--------------|
|
||||||
|
| `security` | PR, push to main, weekly schedule, manual | Yes | Semgrep 1.178.0, `p/default` + `p/owasp-top-ten`, `--error` |
|
||||||
|
| `lint` | PR, push to main, manual | Yes | `ruff check src/ tests/` (ruff 0.16.10, rules E4/E7/E9/F) and jscpd 4.3.0 duplicate-code threshold 8% |
|
||||||
|
| `ai-review` | PR only | No (advisory) | PR-Agent `review`, comment-only, `continue-on-error: true` |
|
||||||
|
|
||||||
|
There is no `e2e` (no web UI) and no `deploy` job (not a Coolify app). The weekly schedule (`0 3 * * 1`) runs only `security`.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# security (same command as CI)
|
||||||
|
podman run --rm -v "$PWD:/src:Z" docker.io/semgrep/semgrep:1.178.0 \
|
||||||
|
semgrep scan --config p/default --config p/owasp-top-ten --metrics off --error
|
||||||
|
|
||||||
|
# lint (needs `pip install -e '.[dev]'` or `pip install ruff==0.16.10`)
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# duplicate code
|
||||||
|
npx --yes jscpd@4.3.0 --config .jscpd.json .
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The semgrep container needs no extra flags. `:Z` is only for SELinux hosts; its working directory is `/src`.
|
||||||
|
- The `lint` job installs `python3-venv` from apt because `node:24-bookworm` has no `ensurepip`; ruff itself is pinned.
|
||||||
|
- `.jscpd.json` threshold is 8%, just above the 7.15% baseline, so duplication cannot grow. Lower it as duplication is removed.
|
||||||
|
- Semgrep prints some non-fatal `PartialParsing` errors; they do not fail the job.
|
||||||
|
|
||||||
|
## PR-Agent (advisory)
|
||||||
|
|
||||||
|
`ai-review` posts a review comment on the PR through the Gitea API. It never pushes code and never blocks. It exits 0 with a notice when its secrets are empty (for example on fork PRs).
|
||||||
|
|
||||||
|
Required repository secrets: `OPENROUTER_API_KEY`, `PR_AGENT_GITEA_TOKEN` (Gitea token of the bot account, scopes to comment on PRs).
|
||||||
|
Optional repository variable: `PR_AGENT_MODEL` (default `openrouter/anthropic/claude-sonnet-5`). The workflow sets `config__custom_model_max_tokens` to 200000, so adjust it if you pick a model with a different context window.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Semgrep registry rules (`p/default`, `p/owasp-top-ten`) are fetched at runtime and are not pinned, so a new rule can turn a green main red. The weekly scheduled `security` run catches this early.
|
||||||
|
- Intentional findings are suppressed with a narrow `# nosemgrep: <rule-id> -- <reason>` on the line. Do not use `.semgrepignore` for source files.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Revert the PR that added `ci.yml` (and its follow-up commits). If branch protection requires `CI / security`, `CI / lint` or `CI / ai-review`, relax it first, otherwise merges stay blocked on checks that no longer run.
|
||||||
@@ -12,7 +12,7 @@ and destruction.
|
|||||||
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
||||||
| Expiry | 2 years from creation |
|
| Expiry | 2 years from creation |
|
||||||
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
||||||
| Private key storage | Password manager only |
|
| Private key storage | Gitea repository Actions secret `GPG_PRIVATE_KEY` |
|
||||||
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
||||||
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
||||||
|
|
||||||
@@ -37,71 +37,72 @@ gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.a
|
|||||||
gpg --fingerprint packaging@bongbetic.com
|
gpg --fingerprint packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Save the **private key** to the password manager immediately:
|
Provision the **private key** as the repository Actions secret `GPG_PRIVATE_KEY`.
|
||||||
|
Run the export on the trusted key-generation machine, then enter its output in
|
||||||
|
the Gitea repository's Actions secret settings. Do not save it in the checkout,
|
||||||
|
logs, or a runner directory. The release workflow checks its fingerprint
|
||||||
|
against the committed public key before signing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --armor --export-secret-keys packaging@bongbetic.com
|
gpg --armor --export-secret-keys packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then **delete the private key from the local keyring** — it must never persist
|
After provisioning the secret, delete the private key from the key-generation
|
||||||
on any build host:
|
keyring:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --delete-secret-keys packaging@bongbetic.com
|
gpg --batch --yes --delete-secret-keys packaging@bongbetic.com
|
||||||
gpg --delete-keys packaging@bongbetic.com
|
gpg --batch --yes --delete-keys packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The committed `fenris-packaging.asc` must contain the real public key (replace
|
The committed `fenris-packaging.asc` must contain the real public key (replace
|
||||||
the placeholder comments).
|
the placeholder comments).
|
||||||
|
|
||||||
|
## XBPS signing key
|
||||||
|
|
||||||
|
XBPS uses a separate RSA 3072 key. Its private half is stored as the Gitea
|
||||||
|
repository Actions secret `XBPS_SIGNING_KEY`. The corresponding public key is
|
||||||
|
published at
|
||||||
|
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`,
|
||||||
|
with fingerprint `SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`.
|
||||||
|
The secret must match that public key.
|
||||||
|
|
||||||
|
The release workflow writes the key to `~/.ssh/id_xbps` to sign the XBPS
|
||||||
|
package. A requested XBPS publication also uses the key to sign repository
|
||||||
|
metadata. A final `always()` cleanup removes the runner copy after publication
|
||||||
|
and release asset upload, including when an earlier step fails.
|
||||||
|
|
||||||
## Per-release signing flow
|
## Per-release signing flow
|
||||||
|
|
||||||
Each release performs: **import → sign → delete**. The private key is never
|
Each tagged release performs: **import → verify → sign → delete** on the
|
||||||
stored on disk longer than the release takes.
|
repository-scoped Gitea Actions runner. The Gitea secret remains configured;
|
||||||
|
the runner's keyring copy is removed after the job.
|
||||||
|
|
||||||
### Step 1: Import the private key
|
### Step 1: Push the release tag
|
||||||
|
|
||||||
Retrieve the private key from the password manager and import it:
|
After updating the version and dated changelog section, push the matching tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --import /tmp/packaging-key-private.asc
|
git push origin v<version>
|
||||||
rm /f /tmp/packaging-key-private.asc # Shred if possible
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Build and sign packages
|
### Step 2: Build, verify, and sign packages
|
||||||
|
|
||||||
The Makefile target `make release` handles signing automatically when the
|
The release workflow imports `GPG_PRIVATE_KEY`, checks it against
|
||||||
key is in the keyring:
|
`packaging/keys/fenris-packaging.asc`, builds packages, signs the RPM and
|
||||||
|
clearsigned checksum manifest, validates both, and publishes the release. The
|
||||||
|
workflow imports `XBPS_SIGNING_KEY` separately and signs the XBPS package.
|
||||||
|
XBPS publication is optional and also signs repository metadata; it requires
|
||||||
|
host acceptance and explicit selection during workflow dispatch.
|
||||||
|
|
||||||
```bash
|
### Step 3: Verify runner cleanup
|
||||||
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
|
|
||||||
```
|
|
||||||
|
|
||||||
Under the hood:
|
The workflow's `always()` cleanup removes the GPG key from the runner's keyring
|
||||||
|
and deletes `~/.ssh/id_xbps`, including after a failed job. Confirm no signing
|
||||||
|
key remains on the runner after the release job.
|
||||||
|
|
||||||
1. `rpmsign --addsign` signs the RPM payload with the packaging key
|
The Gitea Actions secret remains the approved signing source. Do not copy it to
|
||||||
(invoked by `make sign-rpm`).
|
the runner or repository outside the workflow.
|
||||||
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)
|
## Key rotation (outline)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
- **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.
|
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
|
||||||
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
||||||
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
||||||
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy. The private key is stored as the repository Actions secret `GPG_PRIVATE_KEY`. The release workflow imports it on the self-hosted runner, verifies it against the in-repo public key, signs the RPM and SHA256SUMS, then deletes the runner's keyring copy in an `always()` cleanup step. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
|
- **XBPS key:** separate RSA 3072 key stored as the repository Actions secret `XBPS_SIGNING_KEY`; its public key and fingerprint are published in `Fenris-xbps`. The release workflow uses it for the XBPS package and, when publication is explicitly requested, the repository index. A final `always()` cleanup deletes its runner copy after publication and release asset upload.
|
||||||
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
||||||
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
||||||
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
||||||
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
||||||
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` remains the local package/sign/checksum fallback. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` is for local artifact preparation and does not replace the tag workflow as the supported publication path. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
||||||
- **CI:** a repository-scoped self-hosted runner is registered and online (checked 2026-09-29). `.gitea/workflows/release.yml` is the tag-triggered release path; maintainers must confirm runner availability and required Gitea secrets before tagging. The workflow publishes deb/rpm packages and release assets; Void publication is withheld unless the signed XBPS host-release step is explicitly requested.
|
- **CI:** a repository-scoped self-hosted runner is registered and online (checked 2026-09-29). `.gitea/workflows/release.yml` is the tag-triggered release path; maintainers must confirm runner availability and required Gitea secrets before tagging. The workflow publishes deb/rpm packages and release assets; Void publication is withheld unless the signed XBPS host-release step is explicitly requested.
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ dev = [
|
|||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
"pytest-cov>=4.0.0",
|
"pytest-cov>=4.0.0",
|
||||||
"pytest-asyncio>=0.20.0",
|
"pytest-asyncio>=0.20.0",
|
||||||
|
"ruff==0.16.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if VENV_DIR.exists():
|
|||||||
if site_packages:
|
if site_packages:
|
||||||
sys.path.insert(0, str(site_packages))
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
from fenris.collector import run_collection
|
from fenris.collector import run_collection # noqa: E402 -- must follow the sys.path bootstrap above
|
||||||
|
|
||||||
|
|
||||||
CONFIG_PATH = Path("/etc/fenris/fenris.conf")
|
CONFIG_PATH = Path("/etc/fenris/fenris.conf")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ usage-habit classification is known rather than unknown (§5.3).
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ No proportional allocation, endpoint assignment, or double counting.
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .hour_classify import classify_hour, HourSplit
|
from .hour_classify import classify_hour, HourSplit
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ Runit guarantees:
|
|||||||
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
|
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|||||||
@@ -18,11 +18,10 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .hour_classify import classify_hour
|
from .hour_classify import classify_hour
|
||||||
from .segment import open_segment, normalize_identity
|
from .segment import open_segment
|
||||||
from .monitoring_periods import close_period
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ def repair_legacy_local_day_evidence(conn: sqlite3.Connection) -> int:
|
|||||||
"bytes_written": row[2], "bytes_read": row[3],
|
"bytes_written": row[2], "bytes_read": row[3],
|
||||||
"segment_id": row[4], "local_tz": row[5],
|
"segment_id": row[4], "local_tz": row[5],
|
||||||
}
|
}
|
||||||
for row in conn.execute(sample_select)
|
for row in conn.execute(sample_select) # nosemgrep: sqlalchemy-execute-raw-query -- query text is built from constant fragments only; no external input
|
||||||
]
|
]
|
||||||
for previous, current in pairwise(samples):
|
for previous, current in pairwise(samples):
|
||||||
start = previous["ts"]
|
start = previous["ts"]
|
||||||
@@ -1018,7 +1018,7 @@ def query_local_day_summary(
|
|||||||
"""
|
"""
|
||||||
tz_filter = " AND tz_name = ?" if tz_name else ""
|
tz_filter = " AND tz_name = ?" if tz_name else ""
|
||||||
params = (local_date, tz_name) if tz_name else (local_date,)
|
params = (local_date, tz_name) if tz_name else (local_date,)
|
||||||
row = conn.execute(
|
row = conn.execute( # nosemgrep: sqlalchemy-execute-raw-query -- only constant fragments are concatenated; values are bound via ? placeholders
|
||||||
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||||
" bytes_written, bytes_read, coverage, sample_count, complete, "
|
" bytes_written, bytes_read, coverage, sample_count, complete, "
|
||||||
" activity_seconds, activity_intervals, activity_incomplete, "
|
" activity_seconds, activity_intervals, activity_incomplete, "
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ if VENV_DIR.exists():
|
|||||||
if site_packages:
|
if site_packages:
|
||||||
sys.path.insert(0, str(site_packages))
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
from fenris.store import init_store, get_store_path
|
from fenris.store import init_store # noqa: E402 -- must follow the sys.path bootstrap above
|
||||||
from fenris.monitoring_periods import (
|
from fenris.monitoring_periods import ( # noqa: E402 -- sys.path bootstrap above
|
||||||
ensure_period_open,
|
ensure_period_open,
|
||||||
close_period,
|
close_period,
|
||||||
get_open_period,
|
get_open_period,
|
||||||
)
|
)
|
||||||
from fenris.init_system import (
|
from fenris.init_system import ( # noqa: E402 -- sys.path bootstrap above
|
||||||
enable_timer,
|
enable_timer,
|
||||||
disable_timer,
|
disable_timer,
|
||||||
collect_now,
|
collect_now,
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ Criteria: PR-1–PR-17, CI-4.
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .monitoring_periods import interval_within_one_monitoring_period
|
from .monitoring_periods import interval_within_one_monitoring_period
|
||||||
|
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ def prune_old_samples(
|
|||||||
if owns_transaction:
|
if owns_transaction:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
else:
|
else:
|
||||||
conn.execute(f"SAVEPOINT {savepoint}")
|
conn.execute(f"SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rows = _sample_rows(conn)
|
rows = _sample_rows(conn)
|
||||||
@@ -344,7 +344,7 @@ def prune_old_samples(
|
|||||||
if owns_transaction:
|
if owns_transaction:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
else:
|
else:
|
||||||
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
newest_id = rows[-1][0]
|
newest_id = rows[-1][0]
|
||||||
@@ -361,12 +361,12 @@ def prune_old_samples(
|
|||||||
if owns_transaction:
|
if owns_transaction:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
else:
|
else:
|
||||||
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
|
||||||
return len(expired)
|
return len(expired)
|
||||||
except Exception:
|
except Exception:
|
||||||
if owns_transaction:
|
if owns_transaction:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
else:
|
else:
|
||||||
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
|
||||||
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- savepoint name is a function-local constant; SQLite cannot bind identifiers
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ Contracts:
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
|
from .derive import derive_hours_from_interval, _parse_ts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import sqlite3
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from .collector import normalize_identity
|
|
||||||
|
|
||||||
|
|
||||||
def find_current_segment(conn: sqlite3.Connection) -> Optional[Dict[str, Any]]:
|
def find_current_segment(conn: sqlite3.Connection) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -15,15 +15,14 @@ Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import sys
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .status_composition import StatusComposition
|
from .status_composition import StatusComposition
|
||||||
|
|
||||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
from .projection import compute_projection, DISCLOSURES
|
||||||
from .store import SCHEMA_VERSION
|
from .store import SCHEMA_VERSION
|
||||||
from .init_system import (
|
from .init_system import (
|
||||||
query_service_state as _init_query_service_state,
|
query_service_state as _init_query_service_state,
|
||||||
|
|||||||
@@ -11,25 +11,20 @@ Freshness grading uses shared constants from status.py.
|
|||||||
import enum
|
import enum
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
|
|
||||||
STATUS_POLL_INTERVAL_S = 5
|
|
||||||
|
|
||||||
from .status import (
|
from .status import (
|
||||||
FRESH_THRESHOLD_S,
|
|
||||||
STALENESS_THRESHOLD_S,
|
|
||||||
grade_freshness,
|
grade_freshness,
|
||||||
freshness_age_human,
|
freshness_age_human,
|
||||||
is_deliberately_paused,
|
is_deliberately_paused,
|
||||||
monitoring_continuity,
|
monitoring_continuity,
|
||||||
deliberate_pause_lines,
|
deliberate_pause_lines,
|
||||||
open_store_readonly,
|
|
||||||
StoreFault,
|
|
||||||
NewerSchema,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
|
||||||
|
STATUS_POLL_INTERVAL_S = 5
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Status state enum with glyph and label
|
# Status state enum with glyph and label
|
||||||
|
|||||||
+5
-6
@@ -7,7 +7,6 @@ This module handles:
|
|||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
@@ -61,7 +60,7 @@ def init_store(store_path: Path) -> sqlite3.Connection:
|
|||||||
if current_version == 0:
|
if current_version == 0:
|
||||||
# New database - create schema
|
# New database - create schema
|
||||||
_create_schema(conn)
|
_create_schema(conn)
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- SCHEMA_VERSION is an int constant; PRAGMA cannot bind parameters
|
||||||
conn.commit()
|
conn.commit()
|
||||||
elif current_version > SCHEMA_VERSION:
|
elif current_version > SCHEMA_VERSION:
|
||||||
# Unknown newer version - refuse
|
# Unknown newer version - refuse
|
||||||
@@ -312,7 +311,7 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
|||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
migration(conn)
|
migration(conn)
|
||||||
conn.execute(f"PRAGMA user_version={target_version}")
|
conn.execute(f"PRAGMA user_version={target_version}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- target_version is an int key of the static migrations map; PRAGMA cannot bind parameters
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
@@ -337,7 +336,7 @@ def _migrate_1_to_2(conn: sqlite3.Connection) -> None:
|
|||||||
).fetchall()}
|
).fetchall()}
|
||||||
for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
|
for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
|
||||||
if column not in cols:
|
if column not in cols:
|
||||||
conn.execute(
|
conn.execute( # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- column comes from a hardcoded tuple; DDL cannot bind identifiers
|
||||||
f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
|
f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -394,7 +393,7 @@ def _migrate_4_to_5(conn: sqlite3.Connection) -> None:
|
|||||||
("last_sample_id", "INTEGER"),
|
("last_sample_id", "INTEGER"),
|
||||||
):
|
):
|
||||||
if column not in local_cols:
|
if column not in local_cols:
|
||||||
conn.execute(
|
conn.execute( # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- column and declaration come from a hardcoded tuple; DDL cannot bind identifiers
|
||||||
f"ALTER TABLE local_days ADD COLUMN {column} {declaration}"
|
f"ALTER TABLE local_days ADD COLUMN {column} {declaration}"
|
||||||
)
|
)
|
||||||
_create_local_day_shared_evidence(conn)
|
_create_local_day_shared_evidence(conn)
|
||||||
@@ -436,7 +435,7 @@ def migrate_to_latest(store_path: Path) -> int:
|
|||||||
# Version 0 means no schema — create fresh (issue #73)
|
# Version 0 means no schema — create fresh (issue #73)
|
||||||
if current_version == 0:
|
if current_version == 0:
|
||||||
_create_schema(conn)
|
_create_schema(conn)
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") # nosemgrep: sqlalchemy-execute-raw-query, formatted-sql-query -- SCHEMA_VERSION is an int constant; PRAGMA cannot bind parameters
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return SCHEMA_VERSION
|
return SCHEMA_VERSION
|
||||||
|
|||||||
+9
-13
@@ -12,7 +12,6 @@ Criteria: TUI-1, TUI-2, TUI-4, CI-1, CI-2, CI-4, IN-3, LC-6, LC-8.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
@@ -21,7 +20,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Container, Horizontal, VerticalScroll
|
from textual.containers import Container, VerticalScroll
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
@@ -38,16 +37,14 @@ from .activity_plot import VolumePoint, volume_plot
|
|||||||
from .projection import (
|
from .projection import (
|
||||||
ConfidenceState,
|
ConfidenceState,
|
||||||
ProjectionResult,
|
ProjectionResult,
|
||||||
ScenarioRange,
|
|
||||||
compute_projection,
|
compute_projection,
|
||||||
)
|
)
|
||||||
from .status import (
|
from .status import (
|
||||||
CADENCE_DEFAULT_S,
|
CADENCE_DEFAULT_S,
|
||||||
FRESH_THRESHOLD_S,
|
FRESH_THRESHOLD_S, # noqa: F401 -- re-exported; tests/test_acceptance_sweep.py asserts parity with status
|
||||||
STALENESS_THRESHOLD_S,
|
STALENESS_THRESHOLD_S, # noqa: F401 -- re-exported (see above)
|
||||||
format_disclosures,
|
format_disclosures,
|
||||||
freshness_age_human,
|
grade_freshness, # noqa: F401 -- re-exported (see above)
|
||||||
grade_freshness,
|
|
||||||
deliberate_pause_lines,
|
deliberate_pause_lines,
|
||||||
read_status,
|
read_status,
|
||||||
_journalctl_hint,
|
_journalctl_hint,
|
||||||
@@ -55,7 +52,6 @@ from .status import (
|
|||||||
from .status_composition import (
|
from .status_composition import (
|
||||||
StatusComposition,
|
StatusComposition,
|
||||||
render_status_tui,
|
render_status_tui,
|
||||||
STATUS_POLL_INTERVAL_S,
|
|
||||||
)
|
)
|
||||||
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
||||||
from .derive import _parse_ts
|
from .derive import _parse_ts
|
||||||
@@ -1110,10 +1106,10 @@ def _query_local_day_graph_data(
|
|||||||
(start_text, end_text, selected_date or ""),
|
(start_text, end_text, selected_date or ""),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
recorded_zones: Dict[str, list[str]] = {}
|
recorded_zones: Dict[str, list[str]] = {}
|
||||||
for local_date, timezone in rows:
|
for local_date, zone_name in rows:
|
||||||
recorded_zones.setdefault(local_date, [])
|
recorded_zones.setdefault(local_date, [])
|
||||||
if timezone not in recorded_zones[local_date]:
|
if zone_name not in recorded_zones[local_date]:
|
||||||
recorded_zones[local_date].append(timezone)
|
recorded_zones[local_date].append(zone_name)
|
||||||
|
|
||||||
result: List[Dict[str, Any]] = []
|
result: List[Dict[str, Any]] = []
|
||||||
for local_date in sorted(local_dates):
|
for local_date in sorted(local_dates):
|
||||||
@@ -1138,9 +1134,9 @@ def _query_local_day_graph_data(
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for timezone in timezones:
|
for zone_name in timezones:
|
||||||
summary = query_local_day_summary(
|
summary = query_local_day_summary(
|
||||||
conn, local_date, timezone, now,
|
conn, local_date, zone_name, now,
|
||||||
)
|
)
|
||||||
if summary is None:
|
if summary is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ computation used by local-day derivation. All functions are
|
|||||||
stateless and safe to call from the collector and TUI reader.
|
stateless and safe to call from the collector and TUI reader.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Shared test helpers for Fenris test suite."""
|
"""Shared test helpers for Fenris test suite."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
VERSION_FILE = REPO_ROOT / "pyproject.toml"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ CI-3 Prohibition set: automated structural checks
|
|||||||
CI-4 Required wording and six disclosures in both views
|
CI-4 Required wording and six disclosures in both views
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -19,16 +18,13 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import init_store, SCHEMA_VERSION
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection,
|
compute_projection,
|
||||||
ConfidenceState,
|
ConfidenceState,
|
||||||
BaselineTier,
|
BaselineTier,
|
||||||
DISCLOSURES,
|
DISCLOSURES,
|
||||||
STALENESS_HOURS,
|
|
||||||
WARMING_MIN_DAYS,
|
|
||||||
YOUNG_REGIME_DAYS,
|
|
||||||
)
|
)
|
||||||
from fenris.status import (
|
from fenris.status import (
|
||||||
grade_freshness,
|
grade_freshness,
|
||||||
@@ -36,13 +32,9 @@ from fenris.status import (
|
|||||||
render_status,
|
render_status,
|
||||||
format_disclosures,
|
format_disclosures,
|
||||||
FRESH_THRESHOLD_S,
|
FRESH_THRESHOLD_S,
|
||||||
STALENESS_THRESHOLD_S,
|
|
||||||
CADENCE_DEFAULT_S,
|
|
||||||
ACCURACY_SEC,
|
|
||||||
)
|
)
|
||||||
from fenris.tui import (
|
from fenris.tui import (
|
||||||
FenrisTuiApp,
|
FenrisTuiApp,
|
||||||
_format_remaining,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Seams:
|
|||||||
- write side: run_collection() → observation store
|
- write side: run_collection() → observation store
|
||||||
- read side: get_status(), compute_projection() → observation store
|
- read side: get_status(), compute_projection() → observation store
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,10 +18,9 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.collector import run_collection, normalize_identity
|
from fenris.collector import run_collection
|
||||||
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
|
from fenris.store import init_store, SCHEMA_VERSION
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
|
from fenris.monitoring_periods import close_period, get_open_period
|
||||||
from fenris.day_aggregate import derive_day, derive_all_days
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -573,7 +571,6 @@ class TestDisplayStates:
|
|||||||
|
|
||||||
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||||
"""One sample → TUI shows awaiting state."""
|
"""One sample → TUI shows awaiting state."""
|
||||||
from fenris.tui import FenrisTuiApp
|
|
||||||
db = tmp_path / "test.db"
|
db = tmp_path / "test.db"
|
||||||
conn = init_store(db)
|
conn = init_store(db)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ Tests the thinnest complete write path through the system:
|
|||||||
|
|
||||||
Seam: write side of the observation store database file.
|
Seam: write side of the observation store database file.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Generator
|
from typing import Any, Dict
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -20,8 +18,8 @@ import pytest
|
|||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.collector import run_collection, AcquisitionError, InvariantViolationError
|
from fenris.collector import run_collection
|
||||||
from fenris.store import init_store, get_store_path
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
# Fixtures
|
# Fixtures
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Acceptance criteria:
|
|||||||
- Gate-3: Partial days don't satisfy the gate
|
- Gate-3: Partial days don't satisfy the gate
|
||||||
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -24,8 +23,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection, ConfidenceState, BaselineTier,
|
compute_projection, ConfidenceState,
|
||||||
WARMING_COVERAGE_FLOOR,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ shared boundary evidence, not duplicated into both days.
|
|||||||
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
|
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -14,8 +14,6 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.collector import run_collection
|
from fenris.collector import run_collection
|
||||||
from fenris.store import init_store
|
|
||||||
from fenris.monitoring_periods import ensure_period_open
|
|
||||||
|
|
||||||
|
|
||||||
def _make_smartctl(duw: int, dur: int):
|
def _make_smartctl(duw: int, dur: int):
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ From spec §5.4, §3.3, ST-4, ST-5:
|
|||||||
whose classification is known
|
whose classification is known
|
||||||
- No absent hour ever interpolated/estimated/fabricated (FL-3)
|
- No absent hour ever interpolated/estimated/fabricated (FL-3)
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -18,8 +17,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|||||||
|
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
from fenris.day_aggregate import derive_day, derive_all_days, DayAggregate
|
from fenris.day_aggregate import derive_day, derive_all_days
|
||||||
from fenris.hour_classify import HourSplit, ACTIVE_THRESHOLD_BYTES
|
from fenris.hour_classify import HourSplit
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ Four splits sum to exactly 3600s. Disabled time is never an hour state.
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.hour_classify import classify_hour, HourSplit, ACTIVE_THRESHOLD_BYTES
|
from fenris.hour_classify import classify_hour, ACTIVE_THRESHOLD_BYTES
|
||||||
|
|
||||||
HOUR_SECONDS = 3600
|
HOUR_SECONDS = 3600
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ From spec §2.3:
|
|||||||
|
|
||||||
Padded and unpadded renderings of the same field yield byte-identical stored values.
|
Padded and unpadded renderings of the same field yield byte-identical stored values.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ Covers:
|
|||||||
|
|
||||||
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
|
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock, PropertyMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -24,7 +21,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|||||||
from fenris.init_system import (
|
from fenris.init_system import (
|
||||||
InitSystem,
|
InitSystem,
|
||||||
detect_init_system,
|
detect_init_system,
|
||||||
get_init_system,
|
|
||||||
reset_init_system_cache,
|
reset_init_system_cache,
|
||||||
_systemd_enable,
|
_systemd_enable,
|
||||||
_systemd_disable,
|
_systemd_disable,
|
||||||
@@ -41,8 +37,6 @@ from fenris.init_system import (
|
|||||||
collect_now,
|
collect_now,
|
||||||
query_service_state,
|
query_service_state,
|
||||||
journal_hint,
|
journal_hint,
|
||||||
FENRIS_SV_DIR,
|
|
||||||
FENRIS_SERVICE_LINK,
|
|
||||||
COLLECT_TIMEOUT_S,
|
COLLECT_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ Covers:
|
|||||||
- TPH-#63: Horizon anchoring at evidence endpoint
|
- TPH-#63: Horizon anchoring at evidence endpoint
|
||||||
- TPH-#64: Burst/habit checks with unknown daily shares
|
- TPH-#64: Burst/habit checks with unknown daily shares
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
@@ -19,8 +17,8 @@ from fenris.store import init_store
|
|||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||||
from fenris.derive import derive_hours_from_interval
|
from fenris.derive import derive_hours_from_interval
|
||||||
from fenris.day_aggregate import derive_day
|
from fenris.day_aggregate import derive_day
|
||||||
from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS
|
from fenris.projection import compute_projection
|
||||||
from fenris.tui import _query_live_graph_data, _query_daily_graph_data
|
from fenris.tui import _query_live_graph_data
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ These tests verify that:
|
|||||||
3. Edge cases like zero-delta intervals and unknown daily shares are handled
|
3. Edge cases like zero-delta intervals and unknown daily shares are handled
|
||||||
4. qualifying_days_progress shows honest qualifying day count
|
4. qualifying_days_progress shows honest qualifying day count
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -15,10 +14,9 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection, ConfidenceState, BaselineTier,
|
compute_projection, ConfidenceState,
|
||||||
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-19
@@ -5,7 +5,6 @@ Covers:
|
|||||||
- TPH-11: Single maker-credit placement, vendor wear under Drive health
|
- TPH-11: Single maker-credit placement, vendor wear under Drive health
|
||||||
- Preserve: continuity, pause block, quit rail, auth banner
|
- Preserve: continuity, pause block, quit rail, auth banner
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -93,8 +92,8 @@ class TestFenrisIdentity:
|
|||||||
store_path=tmp_path / "nonexistent.db",
|
store_path=tmp_path / "nonexistent.db",
|
||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
)
|
)
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
headline = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -105,8 +104,8 @@ class TestFenrisIdentity:
|
|||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
)
|
)
|
||||||
# Simulate narrow terminal that can't render wolf
|
# Simulate narrow terminal that can't render wolf
|
||||||
async with app.run_test(size=(60, 24)) as pilot:
|
async with app.run_test(size=(60, 24)):
|
||||||
headline = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
# Either shows wolf or fallback - both are acceptable
|
# Either shows wolf or fallback - both are acceptable
|
||||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
@@ -117,7 +116,7 @@ class TestFenrisIdentity:
|
|||||||
store_path=tmp_path / "nonexistent.db",
|
store_path=tmp_path / "nonexistent.db",
|
||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
)
|
)
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
# No replacement character (U+FFFD) should appear
|
# No replacement character (U+FFFD) should appear
|
||||||
assert "\ufffd" not in headline.lower()
|
assert "\ufffd" not in headline.lower()
|
||||||
@@ -137,7 +136,7 @@ class TestFenrisIdentity:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
# Identity appears once, lifespan is separate data
|
# Identity appears once, lifespan is separate data
|
||||||
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
@@ -160,7 +159,7 @@ class TestSingleMakerCredit:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
# "by Bongbetic" should NOT appear in service strip
|
# "by Bongbetic" should NOT appear in service strip
|
||||||
assert "by Bongbetic" not in strip
|
assert "by Bongbetic" not in strip
|
||||||
@@ -172,15 +171,15 @@ class TestSingleMakerCredit:
|
|||||||
store_path=tmp_path / "nonexistent.db",
|
store_path=tmp_path / "nonexistent.db",
|
||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
)
|
)
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
headline = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
|
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
|
||||||
"""Empty store: no maker credit in service strip."""
|
"""Empty store: no maker credit in service strip."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "by Bongbetic" not in strip
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
@@ -191,7 +190,7 @@ class TestSingleMakerCredit:
|
|||||||
db = tmp_path / "test.db"
|
db = tmp_path / "test.db"
|
||||||
db.write_bytes(b"not a database")
|
db.write_bytes(b"not a database")
|
||||||
app = FenrisTuiApp(store_path=db)
|
app = FenrisTuiApp(store_path=db)
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "by Bongbetic" not in strip
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
@@ -212,7 +211,7 @@ class TestDriveHealthVendorWear:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
health = str(app.query_one("#drive-health").render())
|
health = str(app.query_one("#drive-health").render())
|
||||||
# Vendor wear should be present with health context
|
# Vendor wear should be present with health context
|
||||||
assert "vendor wear" in health.lower()
|
assert "vendor wear" in health.lower()
|
||||||
@@ -243,7 +242,7 @@ class TestDriveHealthVendorWear:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
health = str(app.query_one("#drive-health").render())
|
health = str(app.query_one("#drive-health").render())
|
||||||
# Should show 0% used or honest zero, not crash
|
# Should show 0% used or honest zero, not crash
|
||||||
assert "vendor wear" in health.lower()
|
assert "vendor wear" in health.lower()
|
||||||
@@ -265,7 +264,7 @@ class TestPreservedBehavior:
|
|||||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}):
|
}):
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "continuity" in strip.lower()
|
assert "continuity" in strip.lower()
|
||||||
assert "monitoring" in strip.lower()
|
assert "monitoring" in strip.lower()
|
||||||
@@ -274,7 +273,7 @@ class TestPreservedBehavior:
|
|||||||
async def test_quit_rail_preserved(self, tmp_path):
|
async def test_quit_rail_preserved(self, tmp_path):
|
||||||
"""Separate q Quit TUI rail preserved."""
|
"""Separate q Quit TUI rail preserved."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
rail = str(app.query_one("#action-rail").render())
|
rail = str(app.query_one("#action-rail").render())
|
||||||
assert "q Quit TUI" in rail
|
assert "q Quit TUI" in rail
|
||||||
|
|
||||||
@@ -286,11 +285,11 @@ class TestPreservedBehavior:
|
|||||||
refresh_interval_s=0.2,
|
refresh_interval_s=0.2,
|
||||||
)
|
)
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
headline = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
|
assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
|
||||||
# Should clear after first tick
|
# Should clear after first tick
|
||||||
await pilot.pause(0.25)
|
await pilot.pause(0.25)
|
||||||
headline_after = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
assert not app.query_one("#auth-notice").display
|
assert not app.query_one("#auth-notice").display
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -312,7 +311,7 @@ class TestPreservedBehavior:
|
|||||||
"last_collect_ok": None, "last_collect_age_s": None,
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}):
|
}):
|
||||||
async with app.run_test(size=(120, 24)) as pilot:
|
async with app.run_test(size=(120, 24)):
|
||||||
banner = str(app.query_one("#paused-banner").render())
|
banner = str(app.query_one("#paused-banner").render())
|
||||||
assert "paused" in banner.lower()
|
assert "paused" in banner.lower()
|
||||||
assert "deliberate" in banner.lower()
|
assert "deliberate" in banner.lower()
|
||||||
|
|||||||
+10
-15
@@ -8,7 +8,6 @@ Covers:
|
|||||||
- AC80-5: Theme roles for graph rendering
|
- AC80-5: Theme roles for graph rendering
|
||||||
- AC80-6: Headless interaction tests with temporary user config
|
- AC80-6: Headless interaction tests with temporary user config
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,15 +20,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|||||||
|
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.preferences import (
|
from fenris.preferences import (
|
||||||
load_preferences,
|
|
||||||
save_preferences,
|
save_preferences,
|
||||||
get_preference_path,
|
|
||||||
)
|
)
|
||||||
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
|
from fenris.themes import get_graph_colors, THEME_NAMES
|
||||||
from fenris.status_composition import (
|
from fenris.status_composition import (
|
||||||
StatusState,
|
StatusState,
|
||||||
compose_status,
|
compose_status,
|
||||||
render_status_tui,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +96,7 @@ class TestPresetLoading:
|
|||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
# Theme should be fenris-amber
|
# Theme should be fenris-amber
|
||||||
assert app.theme == "fenris-chalktone"
|
assert app.theme == "fenris-chalktone"
|
||||||
|
|
||||||
@@ -113,7 +109,7 @@ class TestPresetLoading:
|
|||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
save_preferences(theme="nord", reduced_motion=False)
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert app.theme == "fenris-nord"
|
assert app.theme == "fenris-nord"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -125,20 +121,19 @@ class TestPresetLoading:
|
|||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
save_preferences(theme="high_contrast", reduced_motion=False)
|
save_preferences(theme="high_contrast", reduced_motion=False)
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert app.theme == "fenris-high-contrast"
|
assert app.theme == "fenris-high-contrast"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
|
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
|
||||||
"""TUI respects reduced_motion preference."""
|
"""TUI respects reduced_motion preference."""
|
||||||
from fenris.tui import FenrisTuiApp
|
from fenris.tui import FenrisTuiApp
|
||||||
from fenris.status_composition import compose_status
|
|
||||||
|
|
||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
save_preferences(theme="amber", reduced_motion=True)
|
save_preferences(theme="amber", reduced_motion=True)
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
# The app should have reduced_motion set
|
# The app should have reduced_motion set
|
||||||
assert app._reduced_motion is True
|
assert app._reduced_motion is True
|
||||||
|
|
||||||
@@ -237,7 +232,7 @@ class TestPersistence:
|
|||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
# First run: change theme via preferences API
|
# First run: change theme via preferences API
|
||||||
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app1.run_test() as pilot:
|
async with app1.run_test():
|
||||||
save_preferences(theme="nord", reduced_motion=False)
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
app1._current_theme_name = "nord"
|
app1._current_theme_name = "nord"
|
||||||
app1.theme = "fenris-nord"
|
app1.theme = "fenris-nord"
|
||||||
@@ -246,7 +241,7 @@ class TestPersistence:
|
|||||||
|
|
||||||
# Second run: theme should persist
|
# Second run: theme should persist
|
||||||
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app2.run_test() as pilot:
|
async with app2.run_test():
|
||||||
assert app2.theme == theme_after
|
assert app2.theme == theme_after
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -334,7 +329,7 @@ class TestReducedMotion:
|
|||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
# The app should check for reduced motion on mount
|
# The app should check for reduced motion on mount
|
||||||
assert hasattr(app, '_reduced_motion')
|
assert hasattr(app, '_reduced_motion')
|
||||||
|
|
||||||
@@ -380,7 +375,7 @@ class TestSafePersistence:
|
|||||||
|
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
# TUI should start with default theme
|
# TUI should start with default theme
|
||||||
assert app.theme == "fenris-chalktone"
|
assert app.theme == "fenris-chalktone"
|
||||||
# Dashboard should be functional
|
# Dashboard should be functional
|
||||||
@@ -399,7 +394,7 @@ class TestSafePersistence:
|
|||||||
|
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
# TUI should start without crash
|
# TUI should start without crash
|
||||||
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
|
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ Covers:
|
|||||||
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
||||||
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -111,7 +110,6 @@ def _make_app(tmp_path, clock=None):
|
|||||||
"""
|
"""
|
||||||
if clock is None:
|
if clock is None:
|
||||||
clock = _clock()
|
clock = _clock()
|
||||||
from unittest.mock import patch
|
|
||||||
import fenris.tui as tui_mod
|
import fenris.tui as tui_mod
|
||||||
from datetime import datetime as _real_datetime
|
from datetime import datetime as _real_datetime
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ Seams:
|
|||||||
- Read side: query_local_day_history() returns entries with evidence flags
|
- Read side: query_local_day_history() returns entries with evidence flags
|
||||||
- Repair: repair_derivation() does not touch existing local_days
|
- Repair: repair_derivation() does not touch existing local_days
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
@@ -21,7 +20,7 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import init_store, SCHEMA_VERSION
|
from fenris.store import init_store
|
||||||
from fenris.local_day import (
|
from fenris.local_day import (
|
||||||
derive_local_day_summary,
|
derive_local_day_summary,
|
||||||
persist_local_day,
|
persist_local_day,
|
||||||
@@ -29,11 +28,9 @@ from fenris.local_day import (
|
|||||||
query_local_day_history,
|
query_local_day_history,
|
||||||
LocalDaySummary,
|
LocalDaySummary,
|
||||||
LocalDayHistoryEntry,
|
LocalDayHistoryEntry,
|
||||||
_is_detail_available,
|
|
||||||
)
|
)
|
||||||
from fenris.pruning import prune_old_samples, RAW_SAMPLE_RETENTION_DAYS
|
from fenris.pruning import prune_old_samples
|
||||||
from fenris.repair import repair_derivation
|
from fenris.repair import repair_derivation
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -226,7 +223,7 @@ class TestBoundaryPreservation:
|
|||||||
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
|
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
|
||||||
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
|
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
|
||||||
|
|
||||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
prune_old_samples(conn, now, retention_days=14)
|
||||||
|
|
||||||
# Boundary anchor should be retained
|
# Boundary anchor should be retained
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -305,7 +302,6 @@ class TestLegacyDataHandling:
|
|||||||
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
|
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
|
||||||
"""Legacy summary without full evidence is labelled incomplete."""
|
"""Legacy summary without full evidence is labelled incomplete."""
|
||||||
conn = init_store(tmp_path / "obs.db")
|
conn = init_store(tmp_path / "obs.db")
|
||||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
# Simulate a legacy summary that can't establish a full local-day total
|
# Simulate a legacy summary that can't establish a full local-day total
|
||||||
legacy = LocalDaySummary(
|
legacy = LocalDaySummary(
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ Tests the idempotent, interruption-safe import of history.jsonl into the
|
|||||||
observation store.
|
observation store.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
@@ -17,7 +14,7 @@ import pytest
|
|||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.legacy import import_legacy_history, is_legacy_imported, _parse_history_line
|
from fenris.legacy import import_legacy_history, _parse_history_line
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.collector import run_collection
|
from fenris.collector import run_collection
|
||||||
from fenris.store import init_store
|
|
||||||
from fenris.day_aggregate import derive_day
|
from fenris.day_aggregate import derive_day
|
||||||
from fenris.monitoring_periods import ensure_period_open
|
|
||||||
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
|
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
|
||||||
|
|
||||||
|
|
||||||
@@ -247,7 +245,6 @@ class TestCrossHourAttribution:
|
|||||||
for day in ("2026-09-01",):
|
for day in ("2026-09-01",):
|
||||||
agg = derive_day(conn, day)
|
agg = derive_day(conn, day)
|
||||||
assert agg is not None
|
assert agg is not None
|
||||||
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
|
|
||||||
unattributed_w = conn.execute(
|
unattributed_w = conn.execute(
|
||||||
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||||
(day,)
|
(day,)
|
||||||
@@ -483,7 +480,6 @@ class TestCrossDayBoundary:
|
|||||||
|
|
||||||
# The cross-day delta is unattributed at the day level
|
# The cross-day delta is unattributed at the day level
|
||||||
sep1 = day_map["2026-09-01"]
|
sep1 = day_map["2026-09-01"]
|
||||||
sep2 = day_map["2026-09-02"]
|
|
||||||
# Both days may show the unattributed bytes
|
# Both days may show the unattributed bytes
|
||||||
# (the delta is added to both days' unattributed totals as evidence)
|
# (the delta is added to both days' unattributed totals as evidence)
|
||||||
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
|
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Spec: §8.4, §8.5, §8.6, §8.7
|
Spec: §8.4, §8.5, §8.6, §8.7
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
@@ -56,7 +55,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
"""A fresh package install has a store directory but no database yet."""
|
"""A fresh package install has a store directory but no database yet."""
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
with patch("fenris.monitor.enable_timer"):
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
@@ -73,7 +72,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
with patch("fenris.monitor.enable_timer"):
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Period should be open
|
# Period should be open
|
||||||
@@ -98,7 +97,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
with patch("fenris.monitor.enable_timer"):
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should still have exactly one open period
|
# Should still have exactly one open period
|
||||||
@@ -122,7 +121,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.enable_timer") as mock_enable:
|
with patch("fenris.monitor.enable_timer"):
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should have a new open period
|
# Should have a new open period
|
||||||
@@ -151,7 +150,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.disable_timer") as mock_disable:
|
with patch("fenris.monitor.disable_timer"):
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# Period should be closed with user_disabled
|
# Period should be closed with user_disabled
|
||||||
@@ -169,7 +168,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.disable_timer") as mock_disable:
|
with patch("fenris.monitor.disable_timer"):
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# No periods should exist
|
# No periods should exist
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ From spec §5.2, §8.6, §9.8:
|
|||||||
- Powered-off time stays inside a period; disabled time does not
|
- Powered-off time stays inside a period; disabled time does not
|
||||||
- End causes: user_disabled, migrated, unknown_gap
|
- End causes: user_disabled, migrated, unknown_gap
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ Covers:
|
|||||||
- CLI status and collector behaviour unchanged by preferences
|
- CLI status and collector behaviour unchanged by preferences
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
@@ -295,7 +295,3 @@ class TestCLIIsolation:
|
|||||||
tables_after = sorted(r[0] for r in cursor.fetchall())
|
tables_after = sorted(r[0] for r in cursor.fetchall())
|
||||||
conn.close()
|
conn.close()
|
||||||
assert tables_before == tables_after
|
assert tables_before == tables_after
|
||||||
|
|
||||||
|
|
||||||
# Need datetime for CLI isolation test
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Covers acceptance criteria:
|
|||||||
- PR-13: Baseline provenance and validation per register
|
- PR-13: Baseline provenance and validation per register
|
||||||
- PR-17: Arithmetic exactly E_rated = TBW * 10^12, E_implied = 100*W/p, projected = max(E-W,0)/rate
|
- PR-17: Arithmetic exactly E_rated = TBW * 10^12, E_implied = 100*W/p, projected = max(E-W,0)/rate
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -18,11 +17,10 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
||||||
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
TBW_TO_BYTES, DISCLOSURES,
|
||||||
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1039,8 +1037,6 @@ class TestEvidenceAnchoredHorizons:
|
|||||||
# cumulative_bytes = 30 * 100 * 1024 * 1024
|
# cumulative_bytes = 30 * 100 * 1024 * 1024
|
||||||
# rate = cumulative_bytes / wall_clock
|
# rate = cumulative_bytes / wall_clock
|
||||||
# headline = (E_baseline - cumulative_bytes) / rate
|
# headline = (E_baseline - cumulative_bytes) / rate
|
||||||
E_baseline = 10.0 * TBW_TO_BYTES
|
|
||||||
cumulative_bytes = 30 * bw
|
|
||||||
assert result.headline_remaining_seconds >= 0
|
assert result.headline_remaining_seconds >= 0
|
||||||
|
|
||||||
def test_zero_boundary_delta_returns_zero(self, store):
|
def test_zero_boundary_delta_returns_zero(self, store):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ day aggregates are retained indefinitely.
|
|||||||
|
|
||||||
Issue #74: Boundary anchors required for successor evidence are retained.
|
Issue #74: Boundary anchors required for successor evidence are retained.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -124,7 +123,7 @@ class TestPruneOldSamples:
|
|||||||
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
# Run pruning
|
# Run pruning
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# The boundary anchor should be retained
|
# The boundary anchor should be retained
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
@@ -148,7 +147,7 @@ class TestPruneOldSamples:
|
|||||||
store_conn.commit()
|
store_conn.commit()
|
||||||
|
|
||||||
# Run pruning
|
# Run pruning
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# Keep the source samples until local-day replacement evidence exists.
|
# Keep the source samples until local-day replacement evidence exists.
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def _rpm_filename(version: str, release: int = 1) -> str:
|
|||||||
|
|
||||||
def _registry_upload_deb_url(version: str) -> str:
|
def _registry_upload_deb_url(version: str) -> str:
|
||||||
"""Expected registry upload URL for a deb package."""
|
"""Expected registry upload URL for a deb package."""
|
||||||
return f"debian/pool/bookworm/main/upload"
|
return "debian/pool/bookworm/main/upload"
|
||||||
|
|
||||||
|
|
||||||
def _registry_upload_rpm_url() -> str:
|
def _registry_upload_rpm_url() -> str:
|
||||||
@@ -258,8 +258,6 @@ class TestRevisionBumping:
|
|||||||
"""When revision is bumped, the deb filename also changes."""
|
"""When revision is bumped, the deb filename also changes."""
|
||||||
version = _get_version()
|
version = _get_version()
|
||||||
# Deb filename includes release in nfpm naming
|
# 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)
|
# For deb, the filename doesn't change with revision (deb uses epoch)
|
||||||
# But the RPM does — this verifies we test RPM revision correctly
|
# But the RPM does — this verifies we test RPM revision correctly
|
||||||
rpm_r1 = _rpm_filename(version, 1)
|
rpm_r1 = _rpm_filename(version, 1)
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ Tests the idempotent, safe repair of hour observations and day aggregates
|
|||||||
from surviving raw samples, boundary anchor retention, and legacy summary
|
from surviving raw samples, boundary anchor retention, and legacy summary
|
||||||
handling at actual precision.
|
handling at actual precision.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -102,7 +101,6 @@ class TestRepairUsesSameEvidenceRules:
|
|||||||
|
|
||||||
def test_repair_idempotent_on_fully_derived(self, store_conn):
|
def test_repair_idempotent_on_fully_derived(self, store_conn):
|
||||||
"""Repair on store with existing derived data does not duplicate."""
|
"""Repair on store with existing derived data does not duplicate."""
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
|
||||||
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
|
||||||
# Insert samples that span an hour
|
# Insert samples that span an hour
|
||||||
@@ -138,7 +136,7 @@ class TestRepairUsesSameEvidenceRules:
|
|||||||
store_conn.commit()
|
store_conn.commit()
|
||||||
|
|
||||||
# Run repair
|
# Run repair
|
||||||
result = repair_derivation(store_conn)
|
repair_derivation(store_conn)
|
||||||
|
|
||||||
# Verify marker preserved
|
# Verify marker preserved
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
@@ -265,7 +263,7 @@ class TestBoundaryAnchorRetention:
|
|||||||
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
|
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
|
||||||
|
|
||||||
# Run pruning
|
# Run pruning
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# The boundary anchor should be retained
|
# The boundary anchor should be retained
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
@@ -286,7 +284,7 @@ class TestBoundaryAnchorRetention:
|
|||||||
bytes_written_delta=1000000)
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
# Run pruning
|
# Run pruning
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# The source remains until local-day evidence is also durable.
|
# The source remains until local-day evidence is also durable.
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
@@ -309,7 +307,7 @@ class TestLegacySummaryPrecision:
|
|||||||
bytes_written_delta=5000000)
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
# Run repair
|
# Run repair
|
||||||
result = repair_derivation(store_conn)
|
repair_derivation(store_conn)
|
||||||
|
|
||||||
# Should not create hour observations for legacy day
|
# Should not create hour observations for legacy day
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
@@ -324,7 +322,7 @@ class TestLegacySummaryPrecision:
|
|||||||
bytes_written_delta=5000000)
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
# Run repair
|
# Run repair
|
||||||
result = repair_derivation(store_conn)
|
repair_derivation(store_conn)
|
||||||
|
|
||||||
# Day aggregate should not be modified
|
# Day aggregate should not be modified
|
||||||
cursor = store_conn.execute(
|
cursor = store_conn.execute(
|
||||||
|
|||||||
@@ -259,8 +259,8 @@ class TestKeyCeremonyDoc:
|
|||||||
|
|
||||||
def test_documents_private_key_storage(self):
|
def test_documents_private_key_storage(self):
|
||||||
content = self._doc_content()
|
content = self._doc_content()
|
||||||
assert "password manager" in content.lower(), \
|
assert "gitea repository actions secret `gpg_private_key`" in content.lower(), \
|
||||||
"Must document that private key lives in password manager"
|
"Must document that Gitea Actions stores the private signing key"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ Covers:
|
|||||||
- Retired command rejection with migration pointers
|
- Retired command rejection with migration pointers
|
||||||
- Configuration error surfaced from direct reads
|
- Configuration error surfaced from direct reads
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -35,7 +33,7 @@ from fenris.status import (
|
|||||||
ACCURACY_SEC,
|
ACCURACY_SEC,
|
||||||
)
|
)
|
||||||
from fenris.store import init_store, SCHEMA_VERSION
|
from fenris.store import init_store, SCHEMA_VERSION
|
||||||
from fenris.projection import DISCLOSURES, ConfidenceState
|
from fenris.projection import DISCLOSURES
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ Covers:
|
|||||||
- AC78-5: Separate freshness, last outcome, boot enablement, activity facts
|
- AC78-5: Separate freshness, last outcome, boot enablement, activity facts
|
||||||
- AC78-6: 5s poll reevaluates freshness from cached clock
|
- AC78-6: 5s poll reevaluates freshness from cached clock
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sys
|
import sys
|
||||||
@@ -23,12 +22,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|||||||
from fenris.store import init_store
|
from fenris.store import init_store
|
||||||
from fenris.status_composition import (
|
from fenris.status_composition import (
|
||||||
StatusState,
|
StatusState,
|
||||||
StatusComposition,
|
|
||||||
compose_status,
|
compose_status,
|
||||||
render_status_cli,
|
render_status_cli,
|
||||||
render_status_tui,
|
render_status_tui,
|
||||||
FRESH_THRESHOLD_S,
|
|
||||||
STALENESS_THRESHOLD_S,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -682,7 +678,6 @@ class TestStoreFaultSuppression:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_newer_schema_suppresses_projection(self, tmp_path):
|
def test_newer_schema_suppresses_projection(self, tmp_path):
|
||||||
import fenris.store as store_mod
|
|
||||||
conn = init_store(tmp_path / "test.db")
|
conn = init_store(tmp_path / "test.db")
|
||||||
now = _clock()
|
now = _clock()
|
||||||
|
|
||||||
@@ -971,7 +966,7 @@ class TestTUIIntegration:
|
|||||||
"last_collect_ok": True, "last_collect_age_s": 120,
|
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}):
|
}):
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "●" in strip
|
assert "●" in strip
|
||||||
assert "Monitoring" in strip or "monitoring" in strip.lower()
|
assert "Monitoring" in strip or "monitoring" in strip.lower()
|
||||||
@@ -997,7 +992,7 @@ class TestTUIIntegration:
|
|||||||
"last_collect_ok": None, "last_collect_age_s": None,
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}):
|
}):
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
# Should show Paused state
|
# Should show Paused state
|
||||||
assert "‖" in strip or "Paused" in strip or "paused" in strip.lower()
|
assert "‖" in strip or "Paused" in strip or "paused" in strip.lower()
|
||||||
@@ -1024,7 +1019,7 @@ class TestTUIIntegration:
|
|||||||
"last_collect_ok": False, "last_collect_age_s": 60,
|
"last_collect_ok": False, "last_collect_age_s": 60,
|
||||||
"last_collect_reason": "exit code 3",
|
"last_collect_reason": "exit code 3",
|
||||||
}):
|
}):
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
# Should show Error state
|
# Should show Error state
|
||||||
assert "✖" in strip or "Error" in strip or "error" in strip.lower()
|
assert "✖" in strip or "Error" in strip or "error" in strip.lower()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ instead of degrading to the Store fault view; (2) even group members could
|
|||||||
not open the WAL-mode store because root-created sidecars lacked group write
|
not open the WAL-mode store because root-created sidecars lacked group write
|
||||||
and the store directory lacked group execute-then-write.
|
and the store directory lacked group execute-then-write.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from fenris.store import DEFAULT_STORE_PATH, init_store
|
from fenris.store import init_store
|
||||||
from fenris.status import StoreFault, open_store_readonly
|
from fenris.status import StoreFault, open_store_readonly
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Covers:
|
|||||||
- Status semantic colours/glyphs/text always win over theme
|
- Status semantic colours/glyphs/text always win over theme
|
||||||
- Global action reachability and focus contrast in every preset
|
- Global action reachability and focus contrast in every preset
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
+13
-21
@@ -13,29 +13,22 @@ import sqlite3
|
|||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
from textual.app import App
|
|
||||||
from textual.pilot import Pilot
|
|
||||||
|
|
||||||
from fenris.store import init_store, SCHEMA_VERSION
|
from fenris.store import init_store
|
||||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
ConfidenceState,
|
ConfidenceState,
|
||||||
compute_projection,
|
compute_projection,
|
||||||
DISCLOSURES,
|
|
||||||
WARMING_MIN_DAYS,
|
|
||||||
STALENESS_HOURS,
|
|
||||||
YOUNG_REGIME_DAYS,
|
|
||||||
)
|
)
|
||||||
from fenris.status import (
|
from fenris.status import (
|
||||||
FRESH_THRESHOLD_S,
|
FRESH_THRESHOLD_S,
|
||||||
STALENESS_THRESHOLD_S,
|
|
||||||
grade_freshness,
|
grade_freshness,
|
||||||
)
|
)
|
||||||
from fenris.tui import (
|
from fenris.tui import (
|
||||||
@@ -45,7 +38,6 @@ from fenris.tui import (
|
|||||||
_query_drive_health,
|
_query_drive_health,
|
||||||
_query_daily_graph_data,
|
_query_daily_graph_data,
|
||||||
_query_hourly_graph_data,
|
_query_hourly_graph_data,
|
||||||
_RANGE_OPTIONS,
|
|
||||||
_RANGE_DEFAULT,
|
_RANGE_DEFAULT,
|
||||||
_MIN_WIDTH,
|
_MIN_WIDTH,
|
||||||
_MIN_HEIGHT,
|
_MIN_HEIGHT,
|
||||||
@@ -283,7 +275,7 @@ class TestDenseScreen:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
assert app.query_one("#headline-band") is not None
|
assert app.query_one("#headline-band") is not None
|
||||||
assert app.query_one("#usage-history") is not None
|
assert app.query_one("#usage-history") is not None
|
||||||
assert app.query_one("#drive-health") is not None
|
assert app.query_one("#drive-health") is not None
|
||||||
@@ -303,7 +295,7 @@ class TestDenseScreen:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||||
|
|
||||||
@@ -321,7 +313,7 @@ class TestDenseScreen:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "projection confidence" in headline.lower()
|
assert "projection confidence" in headline.lower()
|
||||||
# Contributing facts shown, never a percentage as confidence
|
# Contributing facts shown, never a percentage as confidence
|
||||||
@@ -343,7 +335,7 @@ class TestDenseScreen:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
strip = str(app.query_one("#service-strip").render())
|
strip = str(app.query_one("#service-strip").render())
|
||||||
assert "Boot:" in strip
|
assert "Boot:" in strip
|
||||||
assert "Timer:" in strip
|
assert "Timer:" in strip
|
||||||
@@ -484,7 +476,7 @@ class TestDenseScreen:
|
|||||||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
headline = str(app.query_one("#headline-band").render())
|
str(app.query_one("#headline-band").render())
|
||||||
# Identity now shows Fenris by Bongbetic (issue #79)
|
# Identity now shows Fenris by Bongbetic (issue #79)
|
||||||
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
assert auth_notice in str(app.query_one("#auth-notice").render())
|
assert auth_notice in str(app.query_one("#auth-notice").render())
|
||||||
@@ -526,7 +518,7 @@ class TestDisclosuresAndGreeting:
|
|||||||
async def test_empty_store_greeting(self, tmp_path):
|
async def test_empty_store_greeting(self, tmp_path):
|
||||||
"""Empty store shows 'no observations yet' with enable hint."""
|
"""Empty store shows 'no observations yet' with enable hint."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
assert "no observations yet" in headline.lower()
|
assert "no observations yet" in headline.lower()
|
||||||
assert "enable" in headline.lower() or "resume" in headline.lower()
|
assert "enable" in headline.lower() or "resume" in headline.lower()
|
||||||
@@ -591,9 +583,9 @@ class TestFirstRun:
|
|||||||
async def test_first_run_prompt(self, tmp_path):
|
async def test_first_run_prompt(self, tmp_path):
|
||||||
"""First-run prompt makes the keyboard action and boot effect explicit."""
|
"""First-run prompt makes the keyboard action and boot effect explicit."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test():
|
||||||
headline = str(app.query_one("#headline-band").render())
|
headline = str(app.query_one("#headline-band").render())
|
||||||
strip = str(app.query_one("#service-strip").render())
|
str(app.query_one("#service-strip").render())
|
||||||
assert "no observations yet" in headline.lower()
|
assert "no observations yet" in headline.lower()
|
||||||
assert "r resume — enable monitoring and future boots" in headline.lower()
|
assert "r resume — enable monitoring and future boots" in headline.lower()
|
||||||
assert "r resume" in str(app.query_one("#action-rail").render()).lower()
|
assert "r resume" in str(app.query_one("#action-rail").render()).lower()
|
||||||
@@ -866,7 +858,7 @@ class TestQueryHourlyGraphData:
|
|||||||
class TestHistoryGraph:
|
class TestHistoryGraph:
|
||||||
def test_empty_data(self, tmp_path):
|
def test_empty_data(self, tmp_path):
|
||||||
"""Empty data shows awaiting message."""
|
"""Empty data shows awaiting message."""
|
||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
# We test the widget directly via the app's compose
|
# We test the widget directly via the app's compose
|
||||||
graph = HistoryGraph()
|
graph = HistoryGraph()
|
||||||
# Simulate setting empty data
|
# Simulate setting empty data
|
||||||
@@ -1129,7 +1121,7 @@ class TestBarGraphTUI:
|
|||||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app.run_test(size=(80, 24)) as pilot:
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
await pilot.press("v", "v")
|
await pilot.press("v", "v")
|
||||||
graph = app.query_one("#usage-history")
|
app.query_one("#usage-history")
|
||||||
assert app.query_one("#activity-panel").border_title == "Drive activity"
|
assert app.query_one("#activity-panel").border_title == "Drive activity"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user