Our hand-written Alertmanager config replaced the chart's default and
dropped two pieces kube-prometheus relies on:
- a route sending InfoInhibitor to the null receiver
- the inhibit rules that let InfoInhibitor (and warning/critical)
suppress severity=info alerts
So both the helper alert and the info alerts it exists to silence were
emailed, daily. InfoInhibitor fired over the last 7 days in crowdsec,
kube-system, woodpecker-ci and without a namespace, driven by
CPUThrottlingHigh (crowdsec-agent, whereabouts-reconciler),
KubeletTooManyPods and KubeQuota* in woodpecker-ci.
Route and the three inhibit rules are copied from kube-prometheus
manifests/alertmanager-secret.yaml. Info alerts now reach email only
while a warning or critical alert fires in the same namespace, which is
the upstream design. amtool check-config passes; amtool config routes
test sends InfoInhibitor to null.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Final step of the CVE-2025-30156 remediation: allowedCiphers
[aes256k], which Rook applies as auth_allowed_ciphers. That is step 10
of Ceph's CephX aes256k upgrade procedure and clears
AUTH_INSECURE_KEYS_ALLOWED; per Ceph's health-check reference that also
resolves AUTH_INSECURE_KEYS_CREATABLE.
Precondition from the procedure, checked without printing any key:
AUTH_INSECURE_CLIENT_KEY_TYPE and AUTH_INSECURE_SERVICE_KEY_TYPE are both
clear (ceph health detail lists only KEYS_ALLOWED and KEYS_CREATABLE).
Rook status: daemon keys and CSI/rbdMirrorPeer at generation 2, prior CSI
keys deleted (#934).
Lockout recovery if auth breaks (Rook cephx-key-rotation doc): set
allowedCiphers [aes, aes256k] and daemon.keyType aes, wait for the mons
to carry --mon-auth-emergency-allowed-ciphers, then remove
daemon.keyType.
Closes#527
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
keepPriorKeyCountMax 1 -> 0. Rook deletes the generation-1 CSI keys
(client.csi-*.1, aes), which clears AUTH_INSECURE_CLIENT_KEY_TYPE.
Every PVC has been remounted with the generation-2 aes256k keys: all
three nodes were drained and uncordoned after the rotation, and no
kernel session on any mon uses a .1 entity any more. The remaining .1
sessions are idle librados connections in the CSI nodeplugins' pool;
ceph-csi keys pool entries by user and keyfile, and the CSI secrets now
name the .2 users, so nothing reuses them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Run 10608 aborted before its first edit: the agent calls
~/Notes/bin/wiki-snapshot.sh and git -C ~/Notes rev-parse/status, the
rules carried only /root/Notes. Both spellings are allowed for the vault
scripts and the read-only git subcommands on the vault checkout, config is
denied there too, and the consolidate step derives its --allowedTools
from the settings file instead of a second copy. Verified headless:
rev-parse, status and wiki-delete.sh allowed, config denied.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The last five CephX entities on aes are the four CSI clients and
client.rbd-mirror-peer. The CSI keys waited on node kernel support:
Rook's key-rotation doc asks for kernel 7.0+ for aes256k over kernel
mounts. Talos v1.13.10 (now on all nodes) backports those libceph
patches to 6.18 (siderolabs/pkgs 2da2cdc); CONFIG_CRYPTO_KRB5 and
CONFIG_CRYPTO_KRB5ENC are set on every node. ceph-csi is v3.17.1, the
minimum the doc names; Rook v1.20.7.
csi: KeyGeneration 2, keyType aes256k, keepPriorKeyCountMax 1. Rotated
keys only apply to new mounts; the prior key stays valid for PVCs
mounted before the rotation, per the Rook doc.
rbdMirrorPeer: KeyGeneration 2, aes256k. No CephRBDMirror and no peers
exist, so rotating the peer token affects nothing.
allowedCiphers / mon_auth_allow_insecure_key are NOT changed here: that
is the lockout-capable step and follows only once ceph health shows no
aes entity left.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review on #927: an empty or truncated /tmp/consolidate.json made jq the
failing command instead of the run. Commit subject of push-notes names
consolidate too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All three nodes run v1.13.10 (applied by hand, one node at a time:
drain, talosctl upgrade --image, wait for 217/217 active+clean). This
brings the repo in step so a rebuild or a new node installs the same
version.
Why this release: its pkgs include siderolabs/pkgs@2da2cdc, 'backport
aes256k support (Ceph)', the libceph patches from Linux 7.x on top of
6.18.48. Kernel config on every node now has CONFIG_CRYPTO_KRB5=y and
CONFIG_CRYPTO_KRB5ENC=y. That was the blocker for rotating the CSI
CephX keys to aes256k (#527).
Patch release within 1.13; Kubernetes stays v1.36.3. Schematic is
unchanged; factory installer and ISO verified to exist for the tag.
talosctl checksum matches the release's sha256sum.txt and a local
download.
Refs #527
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Draining k8s-3 for the Talos 1.13.10 upgrade stopped on four pods
(wp-pg-1, ha-pg-1, gf-pg-1, gitea-0) whose PDBs allow zero disruptions,
and each needed a manual --disable-eviction.
CNPG: ha-pg, gf-pg, wp-pg and tr-pg go to instances: 2. With a replica,
CNPG switches the primary over ahead of a drain, the same way ak-pg,
im-pg and pprl-pg already do. With one instance it creates a primary PDB
and 'will prevent draining the node where the instance is housed'
(CNPG docs, kubernetes_upgrade). smartShutdownTimeout 10 / stopDelay 60
as on ak-pg, which bound switchover time and the barman-plugin demote
hang (cloudnative-pg#9770). Network policies already allow intra-cluster
replication, operator :8000 and WAL-restore egress in all four
namespaces; nothing references a specific instance name.
Single-replica RWO workloads (gitea, woodpecker-server,
protonmail-bridge, syncthing, influxdb): drop the minAvailable:1 PDBs.
They cannot fail over, and eviction already stops the pod gracefully
with SIGTERM and its grace period; the PDB only forced a manual
--disable-eviction on every drain. influxdb's comes from the chart
(pdb.create, influxdb2 2.1.2 defaults).
The 'single-instance cluster, the archive is the only off-node copy'
comments no longer hold and are removed (im-pg's was already stale).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review on #927: the git -C /root/source/ prefix let config and push
through; rm /root/Notes/wiki/ matched path traversal. Allowed git forms
are now log/show/diff/status/rev-parse/ls-files per repo (both the ~ and
/root spelling), config is denied for those forms too, rm is denied and
notes are deleted through the vault's wiki-delete.sh, which validates the
slug. Verified headless: log allowed, config denied, push not allowed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pass that verifies notes against sources, repos and the live cluster
and edits them still ran on the workstation timer, with no TypeSafe key,
no cost record, and no run on days the machine was off. It joins the
nightly pipeline after the audit: full clones of homelab and homeassistant
under ~/source, read-only kubectl via the ci-diagnostics token, its own
permission scope, JSON output so the report and total_cost_usd land in
the step log. push-notes also commits the rotation state and pushes the
agent's own snapshot commits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every night with an empty queue started a full claude run that read the
directory and stopped, paying the agent's startup context and a model
pick for nothing (runs 10362, 10583).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit agent had no executable path in CI (Bash limited to the two
retrieval tools), wrote itself scripts it could not run and reported one
of seven checks (runs 10262, 10362). The checks are mechanical, so
notes/bin/wiki_audit.py does them without a model. push-notes adds only
wiki/, raw/ and output/, so scratch files in the checkout root are no
longer committed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The project moved to Codeberg; GitHub is a read-only mirror and the old
chart index at k8s-gateway.github.io returns 404, so the HelmRelease has
had no artifact. The chart is now published as an OCI artifact in the
project's own Codeberg registry (oci://codeberg.org/k8s-gateway/charts/
k8s-gateway), alongside the image.
OCIRepository + chartRef as for spegel and mariadb-operator; semver
range unchanged. Chart 3.7.2 -> 3.7.3, image moves to
codeberg.org/k8s-gateway/k8s_gateway:1.8.3. The rendered Corefile is
identical to the live one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NETWORK-traffic-classes.md described table 48 and rules 980/981/990/991,
which #857 replaced with one table (47) decided by longest prefix, and
listed management as not started. Rewritten to the live state: five
classes, table 47 contents owned by bird, node IP and etcd on node0, the
etcd leave-and-rejoin procedure, and the mixed-state hazard hit during
the node IP move. The bird DaemonSet comment still named table 48.
Closes#576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Records the live state after moving all three nodes:
- site-homelab.yaml: kubelet nodeIP.validSubnets fd02:46::/64 and
10.2.46.0/24 (was 10.2.40.0/24 and the legacy fd40::/64).
- cluster.yaml: etcd advertisedSubnets 10.2.46.0/24.
- mgmt0 comments: VLAN 40 is world-to-node only now.
Applied per node (k8s-3, k8s-2, k8s-1): drain, stage the config (delete
the old lists, set the new ones, one staged patch call), etcd
forfeit-leadership and leave, reboot. Each node rejoined etcd with its
VLAN 46 peer URL; EPHEMERAL was not wiped.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pod-sourced traffic is routed from table 47. Once the node IP moves to
VLAN 46 (step 3), pods reach node addresses there: the kubernetes
ClusterIP (socket LB rewrites it to the apiserver's advertise address),
hostNetwork pod IPs, and replies to kubelet probes. Without the attached
node0 route in table 47 those flows take the default to the UDM and every
in-cluster API client breaks. Taking it from the interface, like the
other attached networks, keeps the table in one owner.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- node0: VLANConfig on the uplink trunk, VLAN 46, static fd02:46::1x/64
and 10.2.46.1x/24, accept_ra 0. Closed like pod0. Unused until the node
IP moves there (step 3).
- cluster.etcd.advertisedSubnets pinned to 10.2.40.0/24 so a reboot
cannot pick a node0 address: Talos never updates an existing member's
peer URLs.
Applied live with talosctl patch --mode=no-reboot, k8s-3 first. Node IPs
unchanged, etcd 3/3 healthy, node0 reachable node to node over v6 and v4.
Refs #576
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing selects cni.renner.uno/cilium-uninstall or
cni.renner.uno/multus-sole-writer since stage B (#915, #916). Removed
live from all three nodes with a $patch: delete strategic merge patch
(--mode=no-reboot); Talos dropped them from the Kubernetes nodes and
left the other labels in place.
Closes#832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cni.confPath: /etc/cni/net.d/multus mounts that host directory as the
agent's /host/etc/cni/net.d, so write-cni-conf-when-ready (unchanged,
.../05-cilium.conflist) lands in /etc/cni/net.d/multus/05-cilium.conflist,
the file the per-node CiliumNodeConfigs already produced on all three
nodes. The agent finds it unchanged and does not rewrite it.
updateStrategy maxUnavailable: 1 (chart default 2) so the roll takes one
node's datapath at a time.
bootstrap.sh renders the chart from these values, so a cold rebuild
lands directly in this layout.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
All three nodes run the sole-writer layout. Converge the Multus side and
drop the per-node Cilium overrides:
- kube-multus-ds-subdir becomes the only Multus DaemonSet, no node
affinity; kube-multus-ds (0 pods) and its ConfigMap are pruned.
- /etc/cni/net.d/multus is mounted DirectoryOrCreate so Multus waits for
the Cilium conflist instead of exiting on a missing directory.
- CiliumNodeConfigs removed. They only act when a cilium pod is created;
stage B2 moves the same path into the HelmRelease (cni.confPath) and
rolls the agents.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-1 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.1) and no restarts. All three nodes are converted.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Deployment tolerated every NoSchedule taint (operator: Exists, no
key), including node.kubernetes.io/unschedulable. During the #832 node
reboots its ReplicaSet kept placing replacement pods on the cordoned,
shutting-down node; the kubelet rejected each with NodeShutdown, about
200 failed pods per reboot. Tolerate only the control-plane taint the
comment names.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-2 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, and after a reboot with immediate uncordon the OSD came up
with net1 (10.99.0.2) and no restarts.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
k8s-3 carries cni.renner.uno/cilium-uninstall and
cni.renner.uno/multus-sole-writer, applied live with talosctl patch.
Verified on the node: /etc/cni/net.d holds only 00-multus.conf at the
top level, Cilium writes multus/05-cilium.conflist, and after a reboot
with immediate uncordon the OSD came up with net1 (10.99.0.3) and no
restarts.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing changes until a node is labelled:
- post-crds/cilium-nodeconfig.yaml: CiliumNodeConfig cni-uninstall
(label cni.renner.uno/cilium-uninstall) arms the agent's preStop
conflist cleanup; multus-sole-writer (label
cni.renner.uno/multus-sole-writer) moves write-cni-conf-when-ready to
/host/etc/cni/net.d/multus/05-cilium.conflist, a subdirectory libcni
does not read.
- multus/multus-subdir.yaml: kube-multus-ds-subdir with
multusAutoconfigDir /host/etc/cni/net.d/multus, scheduled only on
labelled nodes; kube-multus-ds gets the inverse affinity.
The kube-multus-ds template change rolls Multus node by node once on
merge.
Refs #832
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- kubernetes/infrastructure/netbird-proxy-services -> netbird-service-sync:
SA, ClusterRole(Binding) and Deployment renamed, in namespace
netbird-service-sync, image gitea_admin/netbird-service-sync pinned to
the master build digest, BAO_ROLE=netbird-service-sync.
- Old policy doc netbird-proxy-services.hcl removed; Traefik comment
follows the name.
The infrastructure KS owns both old and new objects, so prune and apply
happen in one pass. OpenBao policy and role netbird-service-sync are
already written.
Closes#876
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Additive; the running controller is untouched:
- ci/images/netbird-proxy-services -> ci/images/netbird-service-sync,
build step and image name renamed, so the merge builds
gitea_admin/netbird-service-sync.
- Namespace netbird-service-sync with a baseline policy: probes in;
out only to kube-dns, kube-apiserver, OpenBao :8200 and world :443
(NetBird management API).
- openbao-ns admits netbird-service-sync on :8200, like wiki-index.
- policies/netbird-service-sync.hcl documents the new policy and role
(applied by hand, as before).
Refs #876
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Gitea runs in the gitea namespace since #906. Remove what only served it
in apps:
- openbao rotator egress (flux-git, review-bot): apps/gitea target.
- ai-ns wiki-index ingress: apps/gitea source.
- apps default-deny egress to wiki-index :8080 (gitea's webhook; now in
gitea-ns.yaml).
- Velero schedule comments.
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- kubernetes/apps/gitea: every object in namespace gitea; applied by a
new gitea Flux Kustomization (clusters/homelab/gitea.yaml) instead of
apps. gitea-allow-metrics-scrape dropped: the gitea-ns baseline
already admits monitoring.
- Rotators (flux-git, review-bot) and the Prometheus gitea job use
gitea.gitea.svc.
- bootstrap.sh waits for the gitea KS.
Data: data-gitea-0 is not copied. The PV (reclaimPolicy Retain) is
rebound to a PVC gitea/data-gitea-0 pre-created with volumeName, which
the new StatefulSet adopts. gitea-tls is pre-issued in the gitea
namespace so HTTPS has no certificate gap.
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Additive only, nothing moves yet:
- Namespace gitea, declared in infrastructure like woodpecker.
- network-policies/gitea-ns.yaml: baseline (same-ns, monitoring, host,
ICMP; egress to traefik, kube-dns, wiki-index, world) and
gitea-allow-traefik for :3000 and :2222.
- openbao rotator egress (flux-git, review-bot) and wiki-index ingress
also admit gitea in the gitea namespace; the apps entries stay until
stage 3.
- Velero schedule gitea-daily (07:45) so data-gitea-0 stays backed up
after the move; apps-daily comment updated (Woodpecker left apps).
Refs #878
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
decide required every consumer to pin the same ci-claude tag and aborted
otherwise (pipeline 10497: [2.1.250 2.1.278]), which needed a hand fix
before the latest version could roll out. Record all distinct tags, bump
whenever any is off latest, and rewrite every version-pinned tag to the
new one with a validated x.y.z version.
Merging this is itself a push touching the pipeline file, so the refresh
runs, builds the latest image, and opens the bump PR.
Refs #889
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pipeline 10186 failed in bump-pr with 'url contains a newline in its
username component': ci_claude_bump_bot_token carries a trailing newline,
so the push URL (and the PR API auth header) broke. Strip CR/LF once at
the top of the step and fail fast if the token ends up empty. This makes
the step independent of how the secret value was written.
Closes#889
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unset, paperless derives cpu_count / TASK_WORKERS, i.e. 20 parallel
ocrmypdf workers on k8s-1. Memory during OCR scales with that count, so
the limit alone is not enough headroom for large scans.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An 18 MB scan from the ADS-1700W has kept the paperless container in an
OOM crash loop since 2026-09-21 10:21: ocrmypdf runs with 20 workers
(paperless default, cpu_count / TASK_WORKERS on a 20-core node) and blows
the 2Gi limit ~40s into OCR. The consume task is re-queued on every
restart, so the same file kills every attempt. 117 restarts so far, web
UI down as a side effect since worker and webserver share the container.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Run 10254: POST .../merge with delete_branch_after_merge returned
HTTP 500 although PR #2 on k.renner/notes was merged, so the step
failed and left the branch behind. The merge call no longer asks Gitea
to delete the branch; the step reads the PR back and requires
merged=true, then deletes the branch as a separate best-effort call.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First live pick after #893 chose claude-sonnet-4-5 at 0.23 over
claude-sonnet-4-6: same-family models split the probability for a
mid-weight task, and "prefer newer" is a policy, not a judgment. New
scripts/ci/choose-model.sh applies it to the distribution: among
candidates within CHOOSE_MODEL_MARGIN (0.7) of the best probability the
most recently released wins; a clear best stays; NONE yields nothing.
The picker logs the top probabilities. Four policy tests added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The picker ran a full claude -p in plan mode per selection and parsed
JSON out of prose. With TYPESAFE_API_KEY set it now sends the task and
the catalog rows as state and asks one Choice over the model ids, each
with its catalog row as criterion plus NONE; the id is validated against
the pristine catalog as before. No key or a failed request falls through
to the claude picker unchanged.
Measured on the two-model fixture (Opus 5 / Haiku 4.5): issue-fix and
security-fix -> Opus 0.99, alert triage -> Opus 0.62, wiki audit ->
Haiku 0.94, update audit -> Haiku 0.46, distill -> Haiku 0.63; about a
second per pick. Dry-run test added to test-model-selection.sh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Instead of rebasing onto a moving main: the run's changes go to
wiki-maintenance/<date>-<run>, a PR is opened and merged via the API
(Do: merge, branch deleted afterwards). A merge conflict leaves the PR
open for a human and fails the run. Fields checked against the
instance's swagger.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Run 10241: distill and audit succeeded (5 sources, 8 new + 6 updated
notes), the push was rejected non-fast-forward because the workstation
pushed the vault while the agents ran. The push step now fetches,
rebases and retries; a real conflict still fails the run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A deployment-event run (10239) errored before its first step: the
typesafe_api_key secret is scoped to cron and manual only. Manual
pipelines (woodpecker pipeline create) are the way to start this by
hand; the deployment trigger is dropped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First cron run (10220) failed in checkout-notes with "could not read
Username": the wiki_bot_token secret had been set from a file with a
trailing newline, which git carried into the extraheader. The secret
is rewritten without it; the pipeline now strips CR/LF from the token
and the index credential regardless of how they were entered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The refresh triggered by #888 built 2.1.278 (the first ci-claude with
python3) rather than rebuilding 2.1.250, and its bump-pr step failed on
a newline in the push token secret (#889), so the consumer bump never
came. This pipeline needs python for wiki_ask.py, so it moves ahead of
the general bump.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Replaces the workstation's wiki-maintenance.timer. The pipeline clones
k.renner/notes, runs wiki-distill over raw/ and wiki-audit over wiki/
as two separate `claude --agent` runs (no background tasks in print
mode, which killed the workstation job at the 600 s ceiling), then
commits and pushes as Claude Bot; the push webhook refreshes wiki-index.
The agent definitions now live in the vault (.claude/agents/), so CI
and workstation share them. The agents and their retrieval scripts
address the vault as ~/Notes; each agent step links $HOME/Notes to the
checkout. ci-claude gains python3 and py3-yaml for wiki_ask.py.
Secrets on the repo: claude_code_oauth_token (existing), typesafe_api_key,
wiki_bot_token (claude-bot, write:repository), wiki_index_basic_auth.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
wiki-index:2026-09-20-4b529cd1 is the build of the previous commit,
i.e. master's code with the paging fix and /ready. Manifest-only
commit, so it does not trigger another build.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three builds on 2026-09-20 (two branch pushes, the merge) rewrote
wiki-index:2026-09-20 in an order the digest pin could not follow: the
pinned digest ran the paging-only build without /ready and crash-looped
on its startup probe. A <date>-<sha8> tag is immutable per commit and
lets the deployment pin by tag without a registry lookup.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same dated tag again; the pod holds the #879 build. Pinning the new
digest (paging fix, /ready readiness) rolls it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/health answered 503 until an index existed and fed startup and
liveness, so a live process without an index counted as dead and was
restarted every 15 min, and a later failed sync would have pulled a pod
with a good index out of the Service. /health is now always 200
(process serves); /ready is 200 once an index exists and stays so
across failed syncs, which keep the last good index. Probes follow:
startup 60 s on /health, readiness on /ready, liveness on /health.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The notes repo tree has 3192 entries. Gitea reports truncated=true on
every page including the last one (page 4, 192 entries) and answers
"tree": null one page further, which the sync hit:
"sync failed: 'NoneType' object is not iterable". Paging now stops on a
short page or when total_count is reached, and a null tree is empty.
Unit test with the observed page shapes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#879 rebuilt wiki-index under the same dated tag 2026-09-20; the node
still holds the first build (sha256:085438d8) and IfNotPresent would
not re-pull. Pinning tag@digest rolls the exact new image.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`bao kv put key=@file` stores the file's trailing newline; the first
sync after #875 failed with "Invalid header value b'token ...\n'" and a
webhook signed with the newline-free secret would never have verified.
Credentials are stripped on read. A failed sync now retries after 60 s
instead of waiting the full RESYNC_SECONDS.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The apps namespace is egress default-deny; gitea's webhook to
wiki-index.ai.svc.cluster.local:8080 was dropped (a test delivery
produced no request at the service). Namespace-wide egress rule scoped
to the wiki-index pod on 8080, the mosquitto pattern; wiki-index's own
ingress policy already admits only the gitea pod from apps.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The openbao namespace policy admits only its own pods, Traefik and
External Secrets. wiki-index (ns ai) logs in through the Kubernetes auth
role on every sync and timed out on the first attempt:
"POST .../v1/auth/kubernetes/login: timed out". Pod-scoped ingress rule
for it, mirroring the external-secrets entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deployment + Service + PVC + ServiceAccount in namespace ai, LAN-only
Ingress wiki-index.renner.uno behind Traefik BasicAuth (ExternalSecret
from OpenBao, same shape as wiki-embed), Flux Kustomization depending on
llama-server (wiki-embed is the embedding backend), pod-scoped
CiliumNetworkPolicy admitting Traefik and Gitea (push webhook) on 8080
and allowing egress to OpenBao 8200, and the OpenBao policy for the
service's Kubernetes auth role.
Image: gitea.renner.uno/gitea_admin/wiki-index:2026-09-20, built by the
step added in the previous PR; the tag is the build date.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Runtime image for a wiki-index service in namespace ai (manifests follow
once the image exists): standard-library Python that mirrors the notes
repository's wiki/ from Gitea into a bge-m3 index and serves POST /rank
to ~/Notes/bin/wiki_rank.py.
Sync fetches the branch head and tree via the Gitea API, embeds only notes
whose blob sha changed (through the in-cluster wiki-embed), drops notes
that left the tree, and persists the index on a PVC. It runs at start, on
every push webhook (HMAC-SHA256 verified) and every RESYNC_SECONDS. Gitea
token and webhook secret come from OpenBao through the Kubernetes auth
role on every use. Five unit tests cover parsing, incremental sync,
persistence, ranking and error handling.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wiki-embed-basic-auth ExternalSecret from #871 never synced:
"using cluster store openbao is not allowed from namespace ai: denied by
spec.condition". The store's namespace allowlist predates the ai
namespace. Adding it, sorted, as the manifest's own maintenance note
prescribes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Second bge-m3 llama.cpp Deployment in ns ai so wiki queries never queue
behind Paperless on llama-embed. Shares the llama-server-models PVC, so
the weights are not downloaded again.
Exposed LAN-only as wiki-embed.renner.uno (no dns.renner.uno/public
label) for bin/wiki_rank.py on the workstation and the nightly wiki jobs.
The client is a script, and the Authentik gate is browser-only, so the
Ingress uses Traefik BasicAuth backed by an ExternalSecret, the same
shape as loki.renner.uno. A pod-scoped CiliumNetworkPolicy admits Traefik
to wiki-embed only; llama-server stays reachable solely via its gate.
Measured on 40 past wiki-synth queries: embeddings picking 150 candidates
plus one TypeSafe Choice puts a cited note in the top 5 for 95% of
queries at a sixth of the cost of ranking all 1700 notes with TypeSafe.
Needs before first use: OpenBao key apps/ai/wiki-embed-basic-auth with
field `users` (htpasswd line), and the same credential as user:password
in ~/.config/wiki-embed/basic-auth on the workstation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Einzelraumsteuerung (8 radiator valves, 9 climate sensors, 9 underfloor
actuators, 3 floor thermostats) runs on Viessmann's One Base ZigBee system.
Its room and device registries and its live per-device values sit on ECU 0x6c3
(BACKENDGATEWAY) and are readable in plaintext over the E3 bus -- room
temperature, the active target temperature, humidity, valve opening, demand,
battery and RSSI. That replaces the ViCare cloud room sensors with a local
source.
0x6c3 had never been enumerated: only 0x680, 0x6a1 and 0x68c ever had a full
scan. A full read of 0x6c3 returns 155 decodable datapoints plus 35 that exist
but fail the stock codec.
Those 35 are the reason for the overlay. open3e declares
ZigBee<N>DeviceCurrentValues as 57 bytes; a PAIRED slot answers with 68 -- the
known 57 plus the parent device serial and three trailing bytes. udsoncan reads
only the declared length and then treats the next two bytes, the first half of
that serial, as a foreign data identifier, so every paired slot fails with
"Server returned values for data identifier 0x.... that was not requested". The
bogus identifier is stable per device and differs between devices because the
serial does, which is why this previously read as an encrypted channel. It is
not encrypted.
The overlay re-declares the 29 paired slots at 68 bytes and inherits upstream's
sub-type list, so the per-device-type switch -- including
DeviceTemperatureSetpoint -- comes from upstream rather than being copied.
Empty slots answer with 57, decode against the stock codec and are not polled.
Only CurrentValues are polled. The registries are static configuration and are
read on demand.
Verified against payloads captured from 0x6c3 on 2026-09-17, decoded with
open3e 0.7.5 (the commit pinned in the heating-can image): all 29 slots are 68
bytes, and a radiator valve yields ActualTemperature 19.3 °C, ValveOpening 0 %,
DeviceTemperatureSetpoint 8.0 °C, battery 38 %, RSSI -69 dBm. The setpoint
field was confirmed by changing one room in the ViCare app three times and
re-reading: 8.0 -> 15.0 -> 13.0.
Read-only. No write path to the per-room setpoint is known; writes to the
CurrentValues DID were attempted and had no effect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ServiceMonitor added in #865 discovers both external-dns targets and
then times out on them: the apps namespace is default-deny-ingress, so the
scrape never arrives.
lastError: Get "http://10.244.2.218:7979/metrics": context deadline exceeded
health: down
Same shape as gitea-allow-metrics-scrape, and one policy covers both
releases — the chart gives them the same app.kubernetes.io/name and they
share the namespace, even though external-dns-unifi is declared under
infrastructure/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
external-dns metrics say what the publisher intends. This says what a client
actually gets, and catches causes the publisher cannot see — a record removed
on the UDM directly, for one.
Two findings shaped it.
The probe asks the UDM directly rather than the cluster resolver. k8s-gateway
caches these names with `serve_stale 1h immediate`, so a deleted record would
keep answering from cache for an hour before the probe noticed.
And rcode alone is not a usable signal here. Measured against 10.2.40.1:
does-not-exist-xyz.renner.uno -> NOERROR, CNAME proxy.renner.uno
does-not-exist-xyz.paperless.renner.uno -> no answer
The public *.renner.uno wildcard matches one label deep, which is exactly
where ha, mqtt, ldap and syslog live: delete their records and the UDM still
answers NOERROR, pointing at the WAN proxy. A probe checking only the rcode
would have stayed green through the outage for four of the six names. So the
modules require an A record in the answer and reject a CNAME, which separates
a real record from the wildcard without encoding any address.
One module per name because the dns prober reads query_name from the module
and takes the resolver as its target; both families are probed per name and
the alert wants both to fail, so the UDM's bursty connection refusals do not
read as a missing record. That is also why the window is 30m rather than the
10m the HTTP probes use.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither external-dns release was scraped: both expose /metrics on :7979 and
no ServiceMonitor existed, so external_dns_* series were absent from
Prometheus entirely. The process that publishes every LAN name in the
cluster had no instrumentation at all.
That is how 2026-09-12 went unnoticed for five days. external-dns v0.22.0
changed the default annotation prefix, the service source went empty, and
policy=sync deleted six records. source_endpoints_total fell 48 -> 36 and
controller_verified_records 24 -> 18 per family; nothing was watching either.
ExternalDNSSourceEndpointsDropped is the rule for that shape: a sustained
drop below 90% of the 24h peak. The threshold is chosen against the observed
inventory — one retired Ingress is 2 endpoints of ~48 and stays quiet, a
whole class of names does not. A deliberate mass removal fires it once and
clears itself after 24h, which is the right trade for a signal that
otherwise only speaks when records are already gone.
The rest of the group covers the ways the drop rule itself can go blind:
source and registry errors, the instance being down, and an absent() dead
man mirroring BlackboxProbesAbsent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
external-dns v0.22.0 changed the default --annotation-prefix from
external-dns.alpha.kubernetes.io/ to external-dns.kubernetes.io/ with no
fallback, and its release notes lead with the consequence: "This change can
delete all your DNS records."
The chart floated onto it at 2026-09-11 23:47:56 (appVersion 0.21.0 ->
0.22.0). Two hours later, at 2026-09-12 01:47:59, the unifi provider logged
`client deleted record` for every name that depended on the annotation:
ha.renner.uno mqtt.renner.uno ldap.renner.uno
syslog.renner.uno ftp.paperless.renner.uno
syncthing.paperless.renner.uno
A, AAAA and the TXT ownership records, and no `client created record` since.
Every one of those is a LoadBalancer Service, whose hostname external-dns
can only learn from the annotation — so the source went silent and
--policy=sync removed the records. Ingress-sourced names take their host
from spec.rules[].host and were untouched, which is exactly the split
observed: source_endpoints_total is 36 with source_errors_total 0.
These six annotations were the only alpha-prefixed external-dns annotations
in the repo, so moving them to the current prefix is the whole fix. Pinning
the prefix back with --annotation-prefix would keep us on a deprecated
default that upstream intends to drop.
Scanning has been broken since: the Brother resolves ftp.paperless.renner.uno
rather than using the address.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The LB address was declared twice: on the Service as io.cilium/lb-ipam-ips,
and again as a literal PASV_ADDRESS in the ConfigMap generator.
The copy was not doing anything. vsftpd listens on a single IPv6 socket, so
an IPv4 client arrives as ::ffff:a.b.c.d and vsftpd answers PASV with
0.0.0.0 regardless of the configured address — the only such session in 30
days of logs got `227 Entering Passive Mode (0,0,0,0,82,109)` while
PASV_ADDRESS=10.2.99.82 was set. IPv6 clients negotiate EPSV, which carries
a port and no address at all; that is what the scanner uses.
Removing the literal falls back to the image default of 0.0.0.0, which is
the reply clients already receive, so behaviour is unchanged and the address
now exists in exactly one place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vsftpd 3.0.5 segfaults its standalone listener on roughly 0.3% of the
connections it accepts: the parent closes the accepted socket before it
registers the forked child's pid, so a child that exits inside that window
makes the SIGCHLD path look up an unregistered pid, get NULL, and
dereference it in hash_ip(). PID 1 is vsftpd, so the segfault takes the
container with it.
The tcpSocket probes added in 469cdbc2 opened ~8600 connections a day
against that, which turned a latent crash into a restart every ~25 minutes
and left the scanner hitting CrashLoopBackOff windows. Measured on the live
pod: 52 restarts in 21h, one node-side `vsftpd[...]: segfault at 0` per
restart, starting the hour the probes rolled out.
Assert the listening socket from inside the container instead. The probe
still catches the failure it was added for — vsftpd not serving port 21 —
without being a client of the process it watches.
vsftpd 3.0.5 is upstream's last release (2021) and Alpine ships it
unpatched in 3.21 (3.0.5-r2) and edge (3.0.5-r3), so there is no version
bump that fixes this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TokenStorageMethod is Encrypted and the container has no OS secret store,
so LibationCli falls back to minting a portable master key at
/config-internal/libation-master.key -- a path that is not a mounted
volume. The key is therefore new on every run and discarded at exit,
which is why the entrypoint logs LAST-RESORT on each pass.
The entrypoint looks for the key at ${LIBATION_CONFIG_DIR}/
libation-master.key, i.e. on the PVC, and copies it into place when
found. The key is now stored in OpenBao alongside the other bootstrap
files, so seed it like them: without it a rebuilt PVC would hold tokens
encrypted under a key that no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image entrypoint stages config into /config-internal, which is not a
mounted volume, and it does so by copy:
init_config_file() {
...
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
Only LibationContext.db is symlinked to the PVC. AccountsSettings.json is
therefore read from the PVC at startup and written back nowhere, so every
token refresh Libation performs is discarded when the container exits. On
the live PVC the file still carried its 2026-05-14 seed timestamp, four
months after deployment, while the DB beside it was current.
Pass args instead of letting the entrypoint run its own loop. The
entrypoint completes its setup and then execs what it is given, so the
staged environment is intact; an EXIT trap copies the refreshed file back
to the PVC whether the run succeeds or fails.
This was found while recovering from Audible refusing content licenses
for this account. Re-registering the account fixed the refusals; without
this change the fresh registration would have been discarded on the next
run exactly like every refresh before it.
Also corrects the init container comment, which claimed the sentinel
preserved token refreshes. It preserves the DB; it never preserved the
tokens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CronJob ran `rmcrackan/libation:latest` with imagePullPolicy Always
but was stuck on 13.7.11 (2026-08-24). Spegel answers containerd's tag
resolution from its peer cache -- a 200 from the mirror is a hit, so
upstream is never consulted and the tag never advances. Verified: the
pod's imageID was sha256:946177d3 (= tag 13.7.11) while Docker Hub's
:latest pointed at sha256:6f9d8ba1 (= 14.2.1), and querying a Spegel
pod's registry port directly returned the stale digest for that tag.
Audible changed device registration in early September; upstream shipped
an experimental fix in 14.1 and the real fix in 14.2, so the stale image
predates both. Since 2026-09-15 three newly purchased titles fail with
"Content license denied" and the library has not grown.
A digest reference is immune to the mirror's tag cache. Note that the
upstream maintainer's remedy also requires removing and re-adding the
Audible account with an interactive login, which this change does not
and cannot perform.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measuring VLAN 40 for the traffic-class split turned up a share of the
management segment that is not management: the node's Ceph client
talking to OSDs on the other two nodes, sourced from the node address
on mgmt0.
Ceph sends every client read to the acting primary of the PG, which
with three OSDs is remote two times out of three. Read affinity has the
client read from the replica in its own CRUSH location instead.
The preconditions hold here. replicapool and cephfs/data0 are size 3
over failureDomain host across three nodes, so every object has a copy
on every node. The OSDs are created under zone buckets
(root=default host=k8s-N zone=<name>) and the nodes carry
topology.kubernetes.io/zone, which is in Rook's default
crushLocationLabels. Ceph is v20.2.4 and the kernel 6.18, against a
documented minimum of 5.8.
Writes are unaffected: they always go to the primary, and the
replication behind them already rides the VLAN 49 cluster network.
cephfs-ci is size 2, so a local replica is not guaranteed there and the
client falls back to the primary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rolling out #857 took cross-node pod traffic down, and cluster DNS with it, until it was reverted. The table was incomplete: the node's own pod CIDR was originated as a blackhole so it could be advertised without being installed, and then filtered out of the kernel export.
The rule matches the SOURCE, and a reply from a pod on another node is forwarded traffic with a pod source — so it matches too. Its destination is a pod on this node, the table had no route for that, and it took the default and left for the UDM. The comment claiming such traffic would fall through to main was wrong: nothing falls through once a default matches.
Originating it via cilium_host instead makes it both advertisable and correct in the table, and the export filter that existed only to hide the blackhole goes with it.
Diagnosed by narrowing the rule to a single pod address, where the same path worked because the return packet no longer matched: 404 with src <pod>/32, 000 with src 10.244.0.0/16, 404 again once 10.244.0.0/24 dev cilium_host was added to table 47 by hand. bird -p parses the corrected config inside the image, exit 0.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule stack grew a carve-out layer because table 47 held nothing but a default route: it matched everything, so every cluster-internal destination had to be excluded by a rule naming its prefix. Those exclusions were wrong twice over — too broad, since fd00::/8 and 10.0.0.0/8 also caught every LAN client and sent its reply back over mgmt0, and unnecessary, since the networks in question are directly attached and a connected route already beats a default.
So the table gets the connected routes and decides on its own. bird's direct protocol generates them from the interfaces rather than from a list, alongside the peers' pod CIDRs it already learns and the egress default it now originates. One rule per family remains, on the pod source prefix; 980/981, 990/991 and table 48 are gone.
bird owns the table outright, including the default. That is what keeps the failure benign: if the daemon stops the table empties, the rule finds nothing and falls through to main, so pod traffic returns to VLAN 40 unseparated rather than misrouted. A default declared in the Talos config would have outlived the specific routes and sent pod traffic to the UDM.
The ingress gateway moves from a route in each node patch to one node annotation in the site patch, which is now the only place it is stated.
Verified: bird -p parses the rendered config inside the image, exit 0; the templates render GW6 fd02:47::1 and GW4 10.2.47.1 from the live k8s-1 object; kubectl kustomize builds.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the proposal, which was wrong before it was written down. It presented A, B and C as the option space and concluded pod-to-pod was blocked; policy routing was available the whole time and is what moved pod egress and then pod-to-pod. The file recorded a blind spot and gave it the authority of a checked-in document.
Three of four classes are separated now, so this describes the built design rather than proposing one, and the PROPOSAL- name goes with it: which link each class uses, the rule order and why 980/981 match source and destination, where each table comes from, how the bird mesh renders itself from the API without repeating an address, and why the node's own CIDR is originated rather than read back from the kernel.
Management is the remaining class. Its levers are listed, along with the two approaches the owner rejected — with the caveat that the same reasoning which produced that list also called pod-to-pod impossible, so it is a record of decisions and not an analysis to trust.
Failure behaviour is stated because it is what makes the design safe to roll one node at a time: an empty table 48 falls through to main and pod traffic returns to VLAN 40, unseparated rather than broken.
bootstrap.sh's reference follows the rename; its point was always that the QEMU/Hetzner sites use two segments, not the homelab's split.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last step of the traffic-class split. The bird mesh already fills table 48 with one route per peer pod CIDR, next hop the peer's pod0 address; these two rules are what make the kernel look there.
Source AND destination are matched. Source alone would also catch the node's own traffic to a pod, which would then leave with a pod0 source — an address CiliumNode does not list, so Cilium would classify it as world rather than host and policy would drop it. Destination alone would catch traffic from anywhere.
Priority 980/981 sits ahead of the 990/991 pair that sends the cluster and local networks to main, so pod-to-pod wins for that traffic and everything else is unchanged. Traffic to a pod on the same node falls through: table 48 holds only the peers' CIDRs, the lookup misses, and 990/991 send it to main and cilium_host.
Verified on k8s-1 with --mode=try, 200 requests per family from a pod on k8s-1 to a pod on k8s-2: pod0 gained 6957 transmit packets, matching 400 requests, while pod0 receive moved by 45 — the peer is unpatched and still answers over mgmt0, the same asymmetry the earlier measurement showed. Both rules were installed as expected, one per family.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IPv6 populated table 48 on all three nodes; IPv4 was empty. The daemon log gives the reason: KRT: Received route 10.244.0.0/24 with strange next-hop 10.244.0.195. Cilium's IPv4 pod route points at a next hop inside the prefix it describes, which BIRD's kernel protocol refuses. The IPv6 route is a device route with no next hop, so it passed — the asymmetry, not a filter, is why one family worked.
Reading the route back made BIRD's view depend on the shape Cilium happens to install. The prefix is already in the Node object, so a static protocol originates it directly: blackhole, because the route exists only to be advertised, and the sole kernel protocol exporting from these tables takes BGP routes.
That removes the kernel-learning tables, the pipes and the own-CIDR filters entirely — with them the dependence on learn all, on ifname, and on the maximum-length exclusion.
Verified: the templates render OWN_POD4 10.244.0.0/24 and OWN_POD6 fd10:244::/64 from the live k8s-1 object, and bird -p parses the result inside the image with exit 0.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DaemonSet came up with Init:Error, kubectl: not found. The build had pushed sha256:3bc85006 for bird:2026-09-16, but every pod resolved that tag to sha256:75e19214 — the first build of the same day, from before kubectl was added.
imagePullPolicy: Always does not help: it re-pulls, but the tag itself resolves to the stale digest. Deleting the pods changed nothing; three fresh pods on three nodes all resolved to the old digest. A one-off pod pulling the digest directly ran kubectl 1.33.1 and BIRD 2.17.2, so the registry holds the right image and only tag resolution is stale.
The dated tag cannot carry a same-day rebuild, and this image was rebuilt three times today. Referencing the digest makes the deployment deterministic. The cost is that the reference has to be updated whenever the image is rebuilt; the alternative, tagging by commit, does not work for a manifest living in the same commit.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing reads table 48 yet. The mesh fills it so the routing rules land on a table that is already correct, and so a mistake here shows as an empty table rather than as broken pod traffic.
No address is written down twice. Each node publishes its pod0 addresses as Talos nodeAnnotations, beside the VLANConfig that assigns them; the init container reads the node list and renders from it. bird.conf holds no address at all, and the pod CIDRs are not restated either — the own-CIDR filter keys on the outgoing interface plus a maximum-length exclusion, which separates the node's pod route from cilium_host's own host address without naming a prefix.
Rendering is go-template-file, not string building: kubectl renders the two templates, a field selector drops the node itself server-side, and in-cluster config removes the token and CA plumbing. kubectl comes from the Alpine package, so there is no version literal to drift out of sync and no download to trust; it only ever reads core/v1 Nodes. jq and curl are gone.
The init container ends with bird -p, so BIRD's own parser gates startup: a node whose annotation is missing renders as <no value> and fails there rather than crashlooping the daemon or forming a mesh with a hole.
learn all rather than learn, since plain learn skips proto kernel. direct is explicit because iBGP defaults to multihop, which would make the gateway recursive. strict bind keeps the listener off mgmt0 and ingress0.
Verified against the live cluster with all three nodes annotated via --mode=try: the templates rendered the expected defines and four peer protocols, and bird -p parsed the result inside the image with exit 0 — which also confirms net.len and the include ordering. kubectl kustomize builds.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-node peer list is rendered from the Kubernetes API rather than written into the repo a second time, so the init container needs an HTTP client that can verify against the API server's CA bundle. busybox wget cannot, and the query is not going to run unverified. Using the same image for the init container and the daemon keeps one lifecycle instead of borrowing a CI image at runtime.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other Alpine image under ci/images/ pins alpine:3.21 by index digest, and alpine-jq states the reason in the file: the resolved version drifts between restarts of an otherwise pinned image. The first revision used a bare alpine:3.22 tag. 3.22 is still right — bird2 first appears in 3.22/community — so the tag stays and the index digest is added.
heating-can runs --help checks before publishing so a wrong path fails the build rather than the first pod; bird documents --version, so it gets the same guarantee.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pod east-west follows the peer's Kubernetes InternalIP on mgmt0, and Cilium offers no way to point it elsewhere — established by test in #838, and directRoutingDevice (#837) does not do it. Policy routing does: a rule on the pod source prefixes into a table whose routes carry pod0 next-hops, proven on k8s-1 by counting 4647 packets onto pod0.
Those routes have to be generated, not written down: PodCIDRs are Kubernetes-assigned and do not survive a rebuild in place. BGP between the nodes over pod0 generates them, with the session source as the next-hop.
BIRD rather than FRR. FRR's docs describe 'set table' only as setting a BGP table identifier, never a kernel table; binding bgpd to an address implies --no_kernel and would suppress the route installation the design exists for; and a gatewayless IPv6 device route has no next-hop for its route-maps to match, so the own-CIDR filter could not be uniform. BIRD documents 'kernel table', 'strict bind' without touching the kernel protocol, and an 'ifname' filter attribute that discriminates the local route from the learned ones in both families.
No maintained standalone BIRD image exists: pierky/bird self-declares as lab-only and amd64-only, calico/bird is a Calico-internal artifact expecting confd, slipmesh/bird is five weeks old, srl-labs and ibhde are lab-scoped or on BIRD 1.6. A custom controller would have needed an image of its own anyway, so the build is common to both routes.
Image only. The DaemonSet is 2/3 and the routing rules 3/3, so the table fills before anything reads it.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default route cannot simply move to ingress0: the node IP, the API VIP and apid answer on mgmt0, so a reply leaving over ingress0 would carry an mgmt0 source onto VLAN 47. Policy routing splits the two instead — pod source prefixes look up table 47, everything the node sends itself keeps main and mgmt0.
Table 47 holds only the two default routes, so destination rules at 990/991 send the cluster and local networks back to main first; without them a pod's packet to a service matches the source rule, finds only the default, and leaves for the UDM. That was measured on k8s-1, where in-cluster DNS stopped resolving until the rules were added.
The v6 gateway is the UDM's configured ULA on the segment, the address BGP already peers with, rather than the RA-learned link-local. accept_ra_defrtr stays 0 on ingress0 so the RA default does not land in main.
Verified on k8s-1 with --mode=try: a pod egressed as 2003:d6:973f:5605:5a47:caff:fe74:7027, the ingress0 GUA from the VLAN 47 prefix, while in-cluster DNS and the Talos and Kubernetes APIs kept working.
Refs #576
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pod-to-pod traffic currently rides the management VLAN. In tunnel mode
Cilium encapsulates to the peer's Kubernetes InternalIP, so east-west
follows VLAN 40 wherever it goes -- which blocks moving management onto
the 1G NIC without dragging pod traffic with it.
The node IP stays on VLAN 40. Instead this uses the documented device
selection: native routing hands forwarding to the kernel, and
autoDirectNodeRoutes installs the remote PodCIDR routes, which is
permitted because all three nodes share pod0's L2 segment.
"By default, if a single device was detected or specified via devices
then Cilium will use that device for direct routing. Otherwise,
Cilium will use a device with Kubernetes InternalIP or ExternalIP
set."
directRoutingDevice is therefore set explicitly: mgmt0, ingress0 and
pod0 all carry addresses, so the fallback would pick mgmt0.
Verified beforehand that pod0 is a working path between nodes -- from a
pod on k8s-2, k8s-1's kubelet answers 401 on both 10.2.48.11:10250 and
[fd02:48::11]:10250, so the L2 and both address families work.
ipv4/ipv6NativeRoutingCIDR are already set and become mandatory in this
mode; 10.244.0.0/16 and fd10:244::/48 sit inside them, so pod-to-pod
stays unmasqueraded as before.
Not rolling: dropping encapsulation is a cluster-wide datapath change
and a node that has switched cannot exchange pod traffic with one that
has not. Needs a scheduled window, not a background reconcile.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase B step 2 of the traffic-class split. The eBGP session source
address is what the UDM installs as the next-hop for every PodCIDR and
LoadBalancer prefix the cluster advertises. Peering over the management
LAN therefore pinned all world->pod return traffic to the 1G mgmt link,
which is the thing #576 exists to fix. Moving the peers onto VLAN 47
puts that traffic on the 10G ingress link instead.
No localAddress or sourceInterface override is needed: the kernel
already picks the ingress0 address for both families.
ip -6 route get fd02:47::1
fd02:47::1 dev ingress0 proto kernel src fd02:47::5a47:caff:fe74:7027
Both peer addresses were verified reachable from the cluster before
this change -- 10.2.47.1 and fd02:47::1 each answer HTTP with a 301,
and the UDM's neighbour entry on ingress0 is REACHABLE.
The UDM already accepts sessions from this segment: PR #835 added the
10.2.47.0/24 and fd02:47::/64 listen ranges and is uploaded. Its VLAN 40
ranges stay in place for now and get dropped in step 3, once every
session is confirmed established on 47.
gracefulRestart (120s) covers the peer replacement, so the UDM holds the
learned routes across the flap rather than withdrawing the LB VIPs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First step of moving the world-to-pod class onto ingress0 (#576). This
changes nothing about where the cluster peers from — it only widens
what the UDM will accept.
The order matters. Cilium's sessions are outbound to the UDM, and the
UDM accepts them through `bgp listen range`. Moving the cluster to
ingress0 before the UDM listens on 10.2.47.0/24 would drop every
session, and with them every advertised LoadBalancer VIP, so external
ingress would go down until the UDM caught up. Listing both segments
first removes that window entirely: a listen range only accepts a
session, it never opens one, so the 47 ranges sit idle until the
cluster actually moves.
The prefix-lists and route-maps are untouched. They filter which
prefixes the UDM accepts — pod CIDRs and the LB pool — and the cluster
advertises the same prefixes either way. Only the next hop changes.
Uploaded by hand at UniFi UI -> Settings -> Routing -> BGP, as before.
Verify with `vtysh -c "show ip bgp summary"`: the six existing sessions
on 10.2.40.x and fd40:: must still be established afterwards, and no
new ones appear until the cluster side moves.
The 40 ranges come out in a later change, once every session is
established on 47.
Refs #576
Adding a second DHCP client per node in #831 broke node DNS. Both
clients announce the same hostname, so the UDM registered each node
against VLAN 47 instead of VLAN 40: `k8s-1.srv.renner.uno` and
`k8s-3.srv.renner.uno` stopped resolving, and the reverse for
10.2.47.11 came back as bare `k8s-1`. The lease's resolver also
displaced the management one, leaving 10.2.47.1 ahead of 10.2.40.1 in
the node's DNS servers.
That matters beyond `talosctl -n <fqdn>`: bootstrap.sh resolves node
FQDNs in the talos phase, so a rebuild depends on those records.
The UDM cannot register a network's clients selectively, so the client
goes rather than the record. DHCP is now off on VLAN 47 entirely and
ingress0 declares .11/.12/.13 by node ordinal, matching pod0's
numbering. Its default route is declared with the same losing metric
2048 the lease supplied, so nothing about routing changes: mgmt0 keeps
both default routes until the flip.
IPv4 only. The problem was DHCPv4 announcing a hostname; IPv6 never had
one. The prefix is announced correctly, so ingress0 keeps taking its
IPv6 address by SLAAC exactly as mgmt0 takes its fd40:: address —
derived from the MAC, stable across reboots, nothing to allocate.
accept_ra therefore stays at its default of 2. An IPv6 default router
is a link-local address that can only be learned from the RA, and with
net.ipv6.conf.all.forwarding=1 — which Cilium sets — Linux ignores RAs
below 2 entirely. Only accept_ra_defrtr is set to 0, because mgmt0 owns
the default until the flip; dropping that one line is what later hands
the IPv6 default to ingress0.
pod0 keeps accept_ra 0: it is a closed segment with no router on it, so
there is genuinely nothing to learn.
No reboot needed. Address, route and sysctl changes apply live, and the
whole change is provable per node with `--mode=try`, which reverts
itself.
Refs #576
First pass of the traffic-class split (#576). Purely additive: nothing
moves onto the new segments yet.
Links by role, not by PCI topology. A udev rule per node matches the
burned-in MAC and renames at boot, the way ceph-san already did:
enp2s0f0np0 -> uplink, enp87s0 -> mgmt. Shared manifests now name roles
and only each node's own patch knows the hardware. The two comments
elsewhere that named the old links go with them.
Two new VLANs on the uplink, both created on the UDM already:
- pod0, VLAN 48. Node-to-node only; the overlay rides it once the
kubelet node IP moves. Statically addressed .11/.12/.13 out of
10.2.48.0/24 and fd02:48::/64, no gateway, no DHCP, accept_ra 0. A
lease would make the cluster's data path depend on the UDM being up in
order to form, on a segment whose traffic never reaches the UDM.
- ingress0, VLAN 47. World-to-pod: BGP session, LoadBalancer next hop,
masquerade source. Publicly routed, so DHCP — but at routeMetric 2048
and with accept_ra_defrtr 0, because DHCPv4Config can only
deprioritise a default route, not suppress one, and two defaults at
1024 tie.
mgmt0 keeps the node IP and both default routes. A mistrunked VLAN 47 or
48 therefore cannot strand a node, and the flip is a later change that
needs no reboot and is provable with `--mode=try`.
Applying needs a rolling reboot: a udev rule cannot rename a link on a
running node (Resource busy). Drain first, one node at a time, and wait
for CephCluster HEALTH_OK rather than node Ready between them.
Refs #576
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tag build-ci-images published from #827 (pipeline 8573): talosctl
v1.13.9, kubectl v1.36.3, flux 2.9.5. Six references moved from
2026-08-10.
Refs #426
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Regenerated with `flux install --export
--components-extra=image-reflector-controller,image-automation-controller`
from the v2.9.5 CLI; the same component set as before.
Controllers: source v1.8.3 -> v1.9.5, kustomize v1.8.4 -> v1.9.5, helm
v1.5.4 -> v1.6.4, notification v1.8.4 -> v1.9.4, image-reflector v1.1.1
-> v1.2.5, image-automation v1.1.2 -> v1.2.5. CRDs updated, the
allow-webhooks NetworkPolicy now names port 9292.
Prerequisites from the 2.9 release notes, verified before this commit:
the removed image v1beta2 and notification v1beta2 API versions are
neither used in the repo (image.* all v1, Alert/Provider v1beta3) nor
stored in the cluster's CRDs (storedVersions all current), so no
`flux migrate` step is needed. helm-controller 1.6 changes the default
post-render strategy; no HelmRelease here uses postRenderers.
`flux check --pre` passes on Kubernetes 1.36.3 and the manifests pass a
server-side dry run against the live cluster.
Refs #426
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
From the update audit (#426): the tools CI drives the cluster with
trailed what the cluster runs.
ci/images/k8s/Containerfile
- talosctl v1.12.6 -> v1.13.9, the Talos version on the nodes. An older
talosctl can misjudge or reject config generated for a newer machine.
- kubectl v1.36.2 -> v1.36.3, the cluster's Kubernetes version.
- flux CLI 2.9.2 -> 2.9.5, the current 2.9.x. Checksums verified against
the upstream release assets (talos sha256sum.txt, flux checksums.txt)
and against a local download.
.woodpecker/ci-manifests-test.yml
- woodpecker-cli v3.17.0 -> v3.18.0, the version the server and agent
already run; the lint step was one minor behind its own server.
Merging triggers build-ci-images (path ci/images/**), which publishes
ci-k8s:<date>. The six pipelines pinned to ci-k8s:2026-08-10 move to
that tag in a follow-up once it exists.
Refs #426
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The description told on-call to try the recovery key as a last resort,
while RUNBOOK Step 4 in the same change (and upstream: recovery keys are
"purely an authorization mechanism") says it cannot unseal an
auto-sealed server. Say what actually helps: fix the Secret, restart
the pod. Also drop a duplicate root-token computation in
restore_openbao.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #825 (static-seal auto-unseal). The rebuild path and the
operator docs still assumed shamir with a manual unseal per replica.
bootstrap.sh
- Required secrets: `seal-key` (pass show homelab/openbao/seal-key)
replaces `unseal-key`. The snapshots are encrypted under the seal key,
so it is the DR-critical secret now; the former unseal key is the
recovery key and is not needed for a restore (upstream: recovery keys
"are purely an authorization mechanism" and cannot unseal a server
whose auto-seal is broken).
- flux phase creates the `openbao-seal-key` Secret (namespace + Secret,
before `flux bootstrap`) from the collected key. --from-literal via a
substitution, because `pass show` appends a newline and OpenBao
rejects a key that is not exactly 32 bytes of base64 ("unknown
encoding for AES-256 key" — verified). An existing Secret with the
same value is left alone; a different value is refused, since the
data is written under whichever key OpenBao started with.
- restore_openbao: `init -recovery-shares=1 -recovery-threshold=1`,
root token from the output, wait for the self-unseal (init returns
with the node sealed; the server's own retry loop unseals it within
~5 s), then `snapshot restore` WITHOUT -force: OpenBao verifies the
snapshot was written under this server's seal key and refuses a
mismatch by name, which is the one guard against restoring under a
wrong Secret. -force skipped that check and would leave a server that
can never unseal. The throwaway `unseal` step and its argv/tty
discussion are gone — under an auto-seal there is no unseal key.
- unseal_openbao -> bring_up_openbao: waits for spec.replicas pods to
report sealed=false, walking the OrderedReady chain as before, and on
timeout prints each still-sealed pod's `bao status` instead of a bare
"timed out".
Rehearsed locally with two OpenBao servers sharing a static key (file://
form, bao 2.5.5): init on an auto-seal returns an empty unseal_keys_b64,
one recovery key and a root token and the server is unsealed within
seconds; a restore of A's snapshot into fresh B (both with and without
force) re-seals and reloads the root key via the auto seal ~5 s later
("done reloading root key using auto seal"), after which B serves A's
data with A's root token. Restarting B under a different key leaves it
sealed with "failed to decrypt keys from storage: message
authentication failed".
Docs and alerts: RUNBOOK-cold-rebuild Step 4 and MIGRATION-sops-openbao
describe the auto-seal flow and the recovery-key semantics; the
OpenBao alert descriptions and the snapshot CronJob comment no longer
tell the reader to unseal by hand. vmtest-job comment updated.
Not covered: a full VMTEST_FULL=1 rehearsal through the flux phase. The
wiki records none ever reaching past the Flux bootstrap checkpoint, so
that gap predates this change.
Refs #805
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every restart sealed the replica until a human unsealed it. On
2026-09-07 a rolling talosctl upgrade rebooted all three nodes; the PDB
refused the second eviction while the first replica was sealed, Talos
waited out its 5-minute drain timeout and rebooted anyway, and raft
quorum was gone for 24 minutes — every ExternalSecret and the
Kustomizations behind them with it. A power loss would do the same
with no timeout to blame.
OpenBao 2.6.1 supports a static seal: a 32-byte key the server uses to
unseal itself. The key is generated into pass (homelab/openbao/seal-key)
and created as the openbao-seal-key Secret before this release reconciles;
the chart hands it to the server as BAO_SEAL_KEY from that Secret.
This overturns the principle in bootstrap.sh that the key protecting
OpenBao must not live in the cluster OpenBao secures. Decided by the
owner on 2026-09-08: the alternatives were a transit seal from a second
OpenBao on edge-1, which raft traffic would wear out, or a workstation
unseal script that may not be at hand when needed. The exposure is
bounded by what ESO already does — every synced secret is plaintext in
etcd (encrypted at rest) — and grows only by what lives solely in
OpenBao.
Migration is a live, per-replica procedure with the shamir key becoming
the recovery key; the StatefulSet is OnDelete, so merging this changes
nothing until each pod is restarted in turn. The bootstrap and rebuild
paths (bootstrap.sh, vmtest) follow in a separate change once the
migration is verified.
Refs #805
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
OpenBaoQuorumLost waited for: 5m. Below quorum the secret store is
down — the ClusterSecretStore goes InvalidProviderConfig and every
ExternalSecret and the Kustomizations behind them stall — and a sealed
replica never recovers on its own, so the window only delayed the page.
It fires on the first evaluation now.
OpenBaoUnavailable, the external probe, drops from 5m to 1m: two
consecutive failed probes, enough to skip a single lost probe through
Traefik, not enough to sit on a sealed store.
Both StatefulSet expressions are wrapped in max by (namespace,
statefulset). kube-state-metrics stamps instance/pod on the series, so
a rescheduled exporter starts a new series and Prometheus resets the
for: timer with it — on 2026-09-07 that made OpenBaoReplicaNotReady fire
14 minutes late. Alertmanager routes match alertname/source only, so the
dropped labels change nothing downstream.
Refs #805
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Grafana dashboard for the gpumon_* series from intel-gpu-exporter:
current Render/3D busy, GPU power and GT frequency per node as stat
tiles, then one time series per engine (Render/3D, Video, VideoEnhance,
Blitter) with node as the series, GT frequency, GPU and package power,
and memory-controller read/write bandwidth.
Node colours are fixed per node across every panel — blue, orange, aqua
for k8s-1/2/3 — rather than assigned by series order, so a node keeps
its colour whatever the $node filter shows. The trio validates for
colour-vision deficiency and normal vision on both Grafana themes
(worst all-pairs ΔE 9.4 / 20.9). Reads and writes share one axis and
differ by dash, not hue. One axis per panel throughout.
gpumon_clients_count is deliberately absent: per-client attribution
needs hostPID, which the exporter does not have, so it would read 0
forever and imply an idle GPU.
Refs #752
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The -device flag named /dev/dri/card0. That is the right node today on
every host, but nothing guarantees the number: a second GPU or a
different probe order and the exporter would silently watch the wrong
device or none. The plugin's monitoring resource injects every GPU on
the node and intel_gpu_top selects it on its own — verified with a probe
pod on k8s-2, which produced the same gpumon_* series without the flag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A DaemonSet running clambin/intel-gpu-exporter (intel_gpu_top behind a
Prometheus endpoint) with a ServiceMonitor, giving gpumon_engine_usage,
gpumon_frequency, gpumon_power, gpumon_imc_* and gpumon_clients_count
per node.
Privilege model, validated with a one-shot pod before this commit:
/dev/dri comes from the device plugin's gpu.intel.com/monitoring
resource (every GPU on the node, no hostPath); the i915 PMU that
intel_gpu_top reads is a system-wide event source and the nodes run
perf_event_paranoid=3, so CAP_PERFMON is the one capability added.
No privileged, no hostPID, no hostNetwork, read-only root filesystem.
Root rather than a fixed uid because the capability has to be effective
without file capabilities on the binary.
End-to-end check on k8s-2 during a 40 s chat completion on llama-server:
Render/3D busy 98 %, actual frequency 1449 MHz, GPU power 18.8 W, while
the pod's CPU stayed near 100m — the "is it really on the GPU" question
this exists to answer.
XPU Manager was ruled out (Arc Pro / data-centre parts only); the
archived onedr0p exporter needs privileged + hostPID.
Fixes#752
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Enables -enable-monitoring on the device plugin. With 0.36.0 that
advertises gpu.intel.com/monitoring, a single resource per node that
hands a pod every GPU rather than one of the shared slots; it is what a
metrics exporter requests to see the whole iGPU, and it is the only way
to get /dev/dri into a pod without a hostPath.
Nothing consumes it yet. The nodes currently advertise gpu.intel.com/i915
only — the issue assumed the chart default had this on, but the
DaemonSet here is hand-written and did not.
Refs #752
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both consume their ConfigMap with envFrom, which Kubernetes does not
watch: a changed value applies in git and in the cluster while the
running container keeps the environment it started with. Paperless hit
exactly this in #739 and got the annotation in #741; these two were the
remaining envFrom consumers without one.
ftp-scanner, the third Deployment named in the issue, needs nothing: its
ConfigMap comes from a configMapGenerator that deliberately keeps the
content-hash suffix, so any change renames the ConfigMap, mutates the
pod template and rolls the pod on its own.
Reloader already watches all namespaces (kubernetes/infrastructure/reloader).
Fixes#742
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#817 pinned the digest from the branch build (pipeline 8452). Merging
#816 rebuilt the same date tag on master (pipeline 8457), Gitea kept
only the new manifest, and the pod has been in ImagePullBackOff since:
"failed to resolve reference ...@sha256:0d266b02...". With the Recreate
strategy no controller is running, so the services are unchanged.
The kaniko step of the master build pushed sha256:caffa308...; that is
the digest to pin. Comment states the rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pins the controller image by digest. The registry tag is the build
date and pipeline 8452 rebuilt today's tag in place, so the tag alone
cannot carry a new build to the cluster; the digest can.
Merge only after the Traefik change from 1/2 has reconciled: from this
build on every service target sends a PROXY protocol v2 header, and
Traefik drops such connections unless websecure trusts the pod CIDRs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TLS-passthrough services hand Traefik a raw TCP stream, so every WAN
request now logs the routing peer's pod IP (10.244.4.64/.112 in the
access log) instead of the client. CrowdSec's detection parses exactly
that log; with the HTTP-mode services gone it would attribute every
external request to two pod IPs, and a re-armed bouncer would repeat the
2026-06-27 mass ban.
The controller now sets options.proxy_protocol on every target (PROXY
protocol v2, TCP/TLS modes only per NetBird) and treats a service
without it as out of date, so the eleven existing services are updated
on the next pass. Traefik's websecure entrypoint accepts the header from
the pod CIDRs, mirroring forwardedHeaders.trustedIPs. Traefik takes
connections with or without a header from a trusted IP, so mesh and LAN
traffic that carries none is unaffected and the Traefik change can land
before the controller image.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First rollout of #814 sat in CreateContainerConfigError: "container has
runAsNonRoot and image has non-numeric user (controller), cannot verify
user is non-root". The kubelet cannot resolve a named user; nobody/nogroup
in the pod spec is enough, the script needs no home and no writable path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deployment for the controller of the previous commit: openbao namespace
like the rotators (its default-deny already allows OpenBao, DNS, the
apiserver and world), own ServiceAccount with a read-only ClusterRole
(Ingresses list/watch, Services get), one replica, Recreate. The image tag
is the branch build that pushed this exact code.
The OpenBao role is applied once by hand (policies/netbird-proxy-services
.hcl); until then the pod logs an auth error per minute and changes nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#797 added build-vmtest with gcr.io/kaniko-project/executor:v1.23.2-debug,
the archived Google image the other nine steps left in August (60f231f8).
It passed on master only because that image was still cached on the node;
the branch push of the controller PR scheduled it elsewhere and the step
died before its first command. Same fork pin as the rest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
netbird-proxy-services reconciles the NetBird reverse-proxy services that
nobody managed so far: one TLS-passthrough service on :443 per host of every
Ingress carrying dns.renner.uno/public, targeting Traefik ClusterIP through
a subnet resource over the service CIDR. SNI and Host reach Traefik
untouched, the proxy terminates nothing, no domain resource is involved --
so the dns-interceptor pinning bug that wedges the proxies on every
routing-peer loss has nothing to pin on.
Ownership is derived from NetBird, not from local state: managed iff mode is
tls and the target is the controller subnet resource. The ten hand-made
HTTP/domain services are reported as held and never touched; migration is
deleting one of them, the controller creates the passthrough on its next
pass. The PAT is read from OpenBao per pass through a read-only role
(policies/netbird-proxy-services.hcl, applied once by hand like the rotator
roles). Standard library only; unprivileged image built alongside heating-can.
Validated against the live management (ONCE=1 through the rotator role):
network, resource, router and policy created, 10 hosts held, one host that
had no service at all (llm.renner.uno) created and answering behind its
Authentik gate; second pass 0 created / 0 updated / 0 deleted.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every loss of the routing peer a proxy has pinned wedges its route
manager; the flood watchdog restarts the container within 2 s and the
public edge is back after one failed probe round. Inside the 600 s
cooldown the restart is suppressed and the URL watchdog three one-minute
rounds are the only recovery: 3.5 min of 000 on every public domain,
measured 2026-09-07 10:28 on proxy-2 ("restart suppressed, last restart
567s ago"). The morning node roll hit the same suppression on both
proxies at 06:58 and survived only because the routes recovered by luck.
Rolling reboots lose one peer every few minutes by design, so with 600 s
the second roll is always the unlucky one. A minute keeps the loop
protection (one restart per probe interval at most).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 1 (#802) put the ServersTransport under post-crds and
infrastructure-crds has applied it. The copy in crowdsec/lapi-ingress.yaml
goes; the Ingress stays (core kind). With it, the infrastructure apply set
holds no traefik.io consumer any more, which is what a cold rebuild needs.
Prune: infrastructure reconciles first and skips the object if the last
applier label names infrastructure-crds; otherwise it deletes and
infrastructure-crds recreates it seconds later. Only the public LAPI
ingress used by the external bouncers is behind that transport.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Since the host-device rollout (#801) Rook cannot finish a CephCluster
reconcile: it runs a canary pod with OSD placement to read the cluster
CIDR off the attachment, the OSD on that node already holds the NIC, and
the canary fails "error adding container to network ceph-san" until the
15-minute discovery timeout, then the next reconcile repeats it. Under
macvlan the canary could sit beside the OSD; a whole-device attachment has
exactly one consumer per node.
Discovery is skipped when addressRanges.cluster is set (Rook: "if user
specifies CIDRs in the network config, that is the definitive source of
truth"). The value is what discovery produced and what ceph config already
holds, so no daemon changes. The old comment claiming addressRanges is
honoured only under provider: host was wrong — the CRD documents it for
host and multus.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every node has a second, address-less NIC (enp87s0) up on the untagged
fallback network, which shares the mgmt VLAN's broadcast domain. With
the Linux default arp_ignore=0 the host answers ARP for the node IP from
that NIC too, so the UDM can learn 10.2.40.x against enp87s0's MAC.
Inbound IPv4 then arrives on a device Cilium is not attached to, never
reaches cil_from_netdev, and is routed straight back to the UDM by the
default route — a loop that made every IPv4 LoadBalancer VIP on the
affected node unreachable for 13.5 hours. IPv6 was untouched because
NDP only answers on the interface that owns the address.
The flux was measurable on all three nodes: an ARP request for a node
IP from a neighbour returned replies from two MACs. Which node's entry
had actually flipped at the UDM was a race, and it moved during the
diagnosis.
arp_ignore=1 answers only when the target address is configured on the
receiving interface; arp_announce=2 stops the host from sourcing ARP
from an interface that does not own the address. Applied live with
talosctl on all three nodes before this commit and verified with
enp87s0 up: each node now answers from its mgmt0 MAC only.
A node property, so it lives next to the existing enp87s0 sysctls in
each machine patch rather than in the site patch.
Refs #793, #576
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
All three OSD prepare jobs on the Hetzner rehearsal finished with zero
OSDs:
cephosd: skipping device "sdb1": ["Used by ceph-disk"]
The partition is exactly what the machine config asked for (PARTLABEL
r-ceph-osd, plain Linux partition type). ceph-volume rejects it anyway —
its ceph-disk-member test is `"ceph" in PARTLABEL or PARTTYPE in
ceph_disk_guids` (ceph_volume/util/device.py) — so any label containing
"ceph" reads as a legacy ceph-disk partition and is never provisioned.
Talos derives the label from the RawVolumeConfig name (r-<name>), so the
name changes: ceph-osd -> osd-data, label r-osd-data. This is why the
rebuild branch called it osd-data; the #798 review renamed it back for
symmetry with the node label without this failure being known.
Production is unaffected today: the MS-01 OSDs match on the by-id half of
devicePathFilter and no r-ceph-osd partition exists there. The partlabel
half is the migration target and would have failed on the first
repartitioned drive.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A cold rebuild stops permanently at the infrastructure Kustomization:
ServersTransport/crowdsec/crowdsec-lapi dry-run failed: no matches for
kind "ServersTransport" in version "traefik.io/v1alpha1"
Flux dry-runs the whole apply set, so this one unknown kind rejects all of
it — including the Traefik HelmRelease that installs the CRD. Measured on
the Hetzner rehearsal from master: zero HelmReleases, zero CRDs. The
public LAPI endpoint (2026-09-02) landed after the post-crds sweep and put
a traefik.io consumer back into the layer that creates traefik.io.
Phase 1 only adds the object under post-crds; crowdsec/lapi-ingress.yaml
keeps its copy until infrastructure-crds has applied once, because both
Kustomizations prune and a one-commit move deletes before the new owner
applies. Phase 2 removes the original.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The NetworkAttachmentDefinition switches from a macvlan on a kernel
interface name to host-device on the role name `ceph-san`. The NIC is
dedicated to Ceph and has exactly one consumer per node, so it is moved
into the OSD pod whole — real MAC, real MTU — and returns to the host when
the pod goes away. No hardware name appears in this file any more; every
machine renames its own link at boot (previous commit).
ROLLOUT — not atomic with the machine config. Between this flip and a
node's reboot, an OSD restart on that node fails: the NAD asks for
ceph-san and the running node only has the old name. Merge only inside a
maintenance window, then drain + noout + reboot k8s-1/2/3 back to back.
Running OSD pods keep their existing attachment until restarted.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only the Talos machine config knows hardware. Each machine renames its own
Ceph cluster-network link to `ceph-san` at boot, so shared manifests can
address the role and no kernel interface name leaves this directory:
- k8s-1/2/3 (MS-01): a udev rule matches the X710 port 1 by burned-in MAC
and renames it before the network stack opens links. The LinkConfig then
names ceph-san (up, mtu 9000). A rule applied to a RUNNING node cannot
rename (Resource busy) and takes effect at the next reboot, so this
change is inert on production until each node is rebooted — that is the
planned rollout, node by node.
- vm-1..4 (qemu site): one NIC only, so the segment is VLAN 30 named
ceph-san directly. The DHCPv4Config on the parent is load-bearing:
declaring any network document stops the implicit DHCP that addresses
the node.
- hcloud-1/2/3 already carry the same shape (udev by PCI bus path) since
the rebuild-tooling merge.
Validated on Hetzner with the host-device attachment: OSD pods held the
real fabric MACs, Ceph reached HEALTH_OK.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 19.x pin existed to keep this cutover deliberate and tied to the
Rook operator version. That coupling has already come apart in the
dangerous direction: Rook floating constraint pulled v1.20 in June, and
with it cephcsi v3.17.1, whose client libraries are Ceph 20.2.1 — so
every CSI pod has been talking to 19.2.6 mons with a client a whole
major ahead of them.
That is not academic. The 19.2.6 mons mint auth keys as aes256k, and
the 20.2.1 client cannot decode them ("auth: failed to decode key").
Nothing has broken only because the four CSI users predate the cipher
change and still carry legacy aes keys — which Ceph itself flags daily
as HEALTH_WARN (AUTH_INSECURE_CLIENT_KEY_TYPE, 5 entities). The moment
one of those is recreated — ceph auth rotate, a deleted Secret, Rook
resyncing — the provisioner stops and no PVC binds. A cold rebuild hits
it immediately: a fresh cluster has no legacy keys at all.
So the cutover happens now, with the version the client already is.
Rook v1.20 supports Tentacle from v20.2.1; v20.2.0 is excluded
deliberately — upstream reports data corruption there with read
affinity enabled. The two imagepolicy-marked lines are bumped in the
same commit so image automation finds nothing left to do.
Rollout is Rook-managed and rolling: osdMaxUpdatesInParallel: 1 and
upgradeOSDRequiresHealthyPGs: true bound it to one OSD at a time with
clean PGs between steps. Expect mons, then mgrs, then three sequential
OSD restarts with recovery between them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
None of this is reconciled by Flux — bootstrap.sh, the vmtest Job and
the Talos hcloud patches are operator tooling, applied only when a
rebuild test runs. Zero effect on the live cluster from merging.
New: an hcloudtest phase that provisions three cpx42 servers with
private cluster/storage networks, a this-host-only firewall and one raw
OSD volume per node, then hands over to the ordinary talos phase through
the same four variables vmtest uses. Servers carrying the test label are
adopted and rebuilt from the Talos snapshot instead of recreated —
capacity, not cleanliness, is the scarce resource. Network ranges and
volume sizes are derived from what the cluster itself declares (the site
patch and the machine patches), never stated a second time.
hcloudclean deletes everything by label.
The vmtest Job clones from the GitHub mirror instead of in-cluster
Gitea: a rebuild rehearsal cannot depend on the thing whose loss it
rehearses, and a run died on resolving gitea.renner.uno before the
first VM booted.
bootstrap.sh hardening from a week of rehearsals: the OpenBao unseal
loops until the StatefulSet converges instead of unsealing whichever
pods existed at one instant, require_secrets takes a subset, secrets can
be streamed into a waiting run, retries around talosctl bootstrap and
server placement, and node waits print progress and refuse an expected
count of zero.
import-cluster-access.sh keeps its default AWS profile but stops
overriding one the caller already chose.
Known boundary, documented in site-hcloud.yaml: from master the hcloud
site validates provisioning through cluster-Ready; the SAN path needs
the follow-up rollout PR that renames the storage NIC by role.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One unknown kind fails a server-side dry-run for the WHOLE apply set,
including the very HelmRelease that would have installed the missing
CRDs. On a running cluster every CRD exists, so this never showed; a
cold rebuild deadlocks on it immediately.
Three groups move, content unchanged:
ExternalSecrets and Traefik Middlewares out of infrastructure into
post-crds, which reconciles after the operators. The flux-git
ExternalSecret also changes creationPolicy Merge -> Orphan with
deletionPolicy Retain: Merge never CREATES the target secret, so on a
fresh cluster the Gitea credential handover silently did not happen.
ServiceMonitors/PodMonitors out of their charts and app sets into
apps/monitoring-rules, which applies after kube-prometheus-stack owns
the CRDs. Rook additionally stops creating its own two monitors
(spec.monitoring.enabled: false) — creating them is fatal to its
node-daemon reconcile when the CRD is missing, which held the
CephCluster in Progressing and with it the whole apps layer. Both
objects are reproduced field for field off the live cluster; the
ceph-exporter DaemonSet and the metrics endpoints are untouched.
Cilium is the worst case: its chart aborts rendering outright on the
missing CRD, and cilium is one of the seven infrastructure
healthChecks — the lowest layer in the cluster waiting on the highest.
Steady-state effect: object ownership moves between Kustomizations;
specs are identical. Verified on local vmtest runs and the Hetzner
throwaway cluster.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gitea runs inside the cluster, so during a cold rebuild every image that
only exists at gitea.renner.uno is unpullable — a rehearsal died with the
cni-plugins installer in ImagePullBackOff, no macvlan binary, no ceph-san
attachment, CephCluster stuck at "Configuring Ceph Mons", OpenBao waiting
forever on a PVC. The failure surfaced four layers from its cause.
The CI images move to konairius/, published by the build pipeline to
Gitea AND ghcr.io with one digest. The manifests keep pointing at Gitea;
the node-level fallback (machine.registries.mirrors, written by
bootstrap.sh at install time) tries Gitea first and ghcr second, and
containerd keeps the repository path when it falls through — so the same
path resolves on both. Steady state never leaves the network.
The Multus, whereabouts and cni-plugins-installer DaemonSets also gain
priorityClassName system-node-critical. Upstream ships them at priority
0: on a rebuild two nodes sat at 99% memory requests when the DaemonSet
rolled, both multus pods stayed Pending, and every pod needing a
secondary attachment waited on an annotation that could never come — with
nothing reporting why.
Rollout effect on the live cluster: the three CNI DaemonSets restart once
(existing pods keep their networking; only new attachments wait), and the
three init containers pull the new tag on their next pod restart. The
ghcr side is proven by the Hetzner throwaway runs, which pull through the
fallback; eyeball gitea.renner.uno/konairius packages before merging.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by rebuild rehearsals; each stopped a cold start outright and none
changes behaviour on a running cluster.
infrastructure-ceph-cluster no longer dependsOn infrastructure. That
edge closed a cycle no new cluster could escape: infrastructure is Ready
only once its openbao healthCheck passes, openbao-0 runs only once its
PVC binds, and the PVC wants the ceph-block StorageClass defined in this
very Kustomization. infrastructure runs wait: false, so everything this
path needs is applied long before readiness; on an empty API server it
self-heals on retryInterval like post-crds already does.
home-assistant gains dependsOn infrastructure-cnpg-barman: its
ObjectStore kind arrives with the Barman plugin, and one unknown kind
fails the server-side dry-run for the whole apply set — taking
speech-to-phrase, zone-configurator and matter-server down with it.
Every other ObjectStore user already declares the edge.
woodpecker-ci namespace moves to infrastructure/namespaces: its
CiliumNetworkPolicies ship with infrastructure-crds, which runs before
every app KS, so declaring the namespace in the workload KS inverted the
order and held infrastructure-crds — and the twenty app Kustomizations
behind it — on "namespaces woodpecker-ci not found". Same reasoning as
its sibling namespace, previously not carried across.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vmtest Job passes VM_STATE=/work/vm to aim the qcow2 images at the
150Gi emptyDir it sizes for them. bootstrap.sh overwrote it
unconditionally, so the images landed on the container's writable layer
instead and the sizeLimit guard never applied to anything.
Default to the same path as before when the caller sets nothing, so a
plain `bootstrap.sh vmtest` is unchanged.
Two guards come with it, because the value is now caller-controlled:
* a length cap, since the provisioner puts a qemu monitor socket under
this directory and sun_path stops at 108 bytes. Too long used to be a
silent failure to boot; it is now an error. Both the old default (21
bytes) and the Job's /work/vm (8) are well inside it.
* a shape check, because cleanup_vmtest runs `sudo rm -rf "$VM_STATE"`.
That was safe by construction while the path was hardcoded and is not
once a caller picks it.
This does not by itself reduce drive wear — an emptyDir also lives on
/var, so both paths are on the same device. It restores the size guard
and makes it possible to point VM state at a different device later.
VMTEST_FULL and VMTEST_KEEP, which the Job also sets, are still not read
anywhere in this script. Left alone rather than guessed at: the Job is
applied out of band and is not in this repo, so the intended semantics
are not visible from here.
Refs #754
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The evaluation is over. Removing clusters/homelab/paperless-eval.yaml is
the designed teardown: that Kustomization is prune: true with no
prune-disabled annotation anywhere underneath, and its own comment says
being able to delete the namespace by removing the file is the point.
Removed alongside it, so nothing recreates the namespace or points at it:
kubernetes/apps/paperless-eval/ the manifests
clusters/homelab/paperless-eval.yaml the Flux Kustomization
network-policies/paperless-eval-ns.yaml and its kustomization entry
namespaces/namespaces.yaml the Namespace object
network-policies/ai-ns.yaml the llama-embed ingress grant
post-crds/openbao-clustersecretstore.yaml the ESO namespace grant
Production backups are unaffected. The eval Cluster read
s3://renner-uno-k8s-cnpg/pprl-pg as a recovery source with
isWALArchiver: false and no retentionPolicy, so it never wrote to the
production prefix and deleting it removes nothing from the store.
The model weights on the namespace's PVCs were already deleted by hand
before this change; the volumes go with the namespace.
scripts/paperless-eval/ is deliberately left in place — it is the tooling
that drove the evaluation, not part of the namespace, and it is only
useful if the namespace is ever recreated from these manifests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cilium-health reports 0/3 reachable on every node, with Node 1/2 and
Endpoints 2/2 — exactly one of the two node probes fails, including
against localhost.
The Node objects carry only InternalIP=10.2.40.x, because kubelet's
nodeIP.validSubnets lists only the IPv4 mgmt subnet. With no IPv6
InternalIP to take from the Node object, Cilium falls back to detecting
the node's IPv6 from mgmt0 and picks the SLAAC address derived from the
dynamic provider prefix. It records that in CiliumNode and probes it
until the agent restarts, so a prefix change leaves every agent probing
an address that no longer exists:
cilium-health: 2003:d6:9718:3b02:5a47:caff:fe74:7027
mgmt0 now: 2003:d6:9713:b202:5a47:caff:fe74:7027
The interface identifier is the same EUI-64; only the prefix moved. This
is not a one-off — the prefix was 2003:d6:9744:4802 in May and
2003:d6:9718:3b02 in August.
The GUA was never meant to be a control-plane address; that is what the
ULA is for, and BGP already peers on fd40::/64. Listing it in
validSubnets gives the Node a stable IPv6 InternalIP, which Cilium then
uses in preference to its own detection — the same path that already
makes CiliumNode's IPv4 InternalIP agree with the Node object.
Verified fd40::/64 SLAAC addresses are present on mgmt0 on all three
nodes, so no node is left without an address in a listed subnet.
The data path is unaffected either way: Endpoints probes are 2/2 and
conntrack is clean. What this fixes is the health signal, which is
permanently red today and therefore useless for spotting a real outage.
Refs #789
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both socat containers exit 1 on the first datagram they cannot deliver.
While no ready pod backs home-assistant-mdns, its ClusterIP has no
backend and the sendto fails with EHOSTUNREACH, so every Home Assistant
restart costs both containers a kubelet restart — 52 in 10 days.
Run each socat under a shell loop instead. A target that is briefly gone
now costs a two-second gap in what is already a best-effort cache-refresh
path, rather than a restart. Re-running from scratch also re-resolves the
ClusterIP name and rejoins the multicast group.
socat has no option that avoids the wrapper: -s ("sloppy") downgrades the
exit status to 0 but still ends the transfer loop, so the kubelet restarts
the container anyway and the failure stops being visible in the exit code.
The address-level retry/forever options govern opening an address, not
writing to one. Both verified against the pinned image.
The loop traps TERM and forwards it to the backgrounded socat, so the pod
still terminates in ~0.1s; with strategy: Recreate a stalled terminate
would delay every rollout by the full grace period.
Trade-off: a permanently broken relay no longer surfaces as a restart
count. The restart line on stderr is the replacement signal.
Refs #787
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both carried a hand-written exemption in image-automation/README.md. The
exclusion list is for images whose tag format makes a policy impossible;
these two publish clean semver streams and were enrollable all along.
Excluding them on blast-radius judgement left them unowned, and they
drifted.
terminus 0.68.0 -> 0.72.0 (both containers)
trmnl-renderer 0.9.5 -> 0.10.3
That drift is not cosmetic. 0.69-0.72 include "Fixed device provisioner
answer blank API key when device exists", "Fixed Display API image name
to use timestamp instead of checksum" and "Fixed API log create action to
authenticate with API key" — the provisioning, redraw and log paths that
the entrance panel's onboarding was debugged and worked around by hand on
2026-09-05, on a server a month behind the fixes.
The renderer's second stated reason, no upstream multi-arch manifest, is
not an obstruction: the reference already names the architecture, so the
policy pins nothing the tag did not.
Adds apps/trmnl/README.md: which firmware artefact to use for a manual
flash (the mirror carries the C3 build, this panel is S3), what to check
after an image bump, and the device endpoint's scheme constraint.
Watch the panel after this lands: a bad renderer or terminus bump shows
up as a blank or corrupted screen, not as a failed rollout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cleartext LoadBalancer was justified with "the firmware cannot be
handed the internal CA". There is no internal CA in that path:
letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
so a LAN-only name gets a publicly-trusted certificate without ever being
publicly reachable — which is why trmnl.renner.uno has always served an
issuer CN=YR2 cert.
trmnl-device.renner.uno therefore moves onto Traefik with its own
Ingress, and API_URI follows it to https so the image_url handed back on
/api/display is fetched over the same scheme the device polls with. The
name moves rather than being duplicated: external-dns runs --policy=sync
and would flap the record between the Traefik VIP and the LB address if
both an Ingress and the Service claimed it.
The LoadBalancer stays, without a hostname. http://10.2.99.89 remains a
path to /api/setup that depends on neither Traefik nor cert renewal, and
the captive portal takes a bare address, so it is the recovery route if
TLS is ever what breaks the panel.
Note Traefik's web entrypoint carries a global http -> websecure
redirect, so http://trmnl-device.renner.uno now answers 308 rather than
serving. The device is provisioned with the https URL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Hermes probe reported "never became healthy" after an hour. It had not
failed to load — it had never been scheduled, because both iGPUs were busy with
other jobs and there are only two.
Same blind spot as the eval pods sitting in Init: from outside, "waiting" and
"will never start" look identical, and the message blamed the model. It now
prints the pod phase every two minutes and exits immediately with the
scheduler's own reason when it cannot place the pod.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The measurements were coupled. One request that corrupts the slot leaves it
returning a single repeated token for everything after it, so a run counted
that fault once per remaining document. "Qwen3-8B failed 4 of 5" was really
"document 1970 succeeded, document 363 broke the slot, and the remaining three
measured the wreckage".
Proved mid-run rather than inferred: with two documents failed, the model
answered "Nenne die Hauptstadt von Frankreich" with
" \" \" \" \" \" \" \" \" \" \" ...
llama-server clears a slot on POST /slots/0?action=erase, but answers 501
without --slot-save-path, so the flag goes on the sidecar and eval.py calls it
after every AI document. It costs the prefix cache and some prefill speed,
which is the right trade for numbers that mean what they say.
Also worth recording: a synthetic reproduction of the poisoning failed. The
real paperless request corrupts the slot; my own 5000-token probe with the same
14-field schema does not, and the canary stayed healthy through it. Whatever
triggers this lives in the parts of a real request I was not reproducing — the
system prompt, the RAG context, the full taxonomy lists. Third time today that
a synthesised test passed where the real one fails, so the harness should stop
trying to stand in for the real request and instead detect when it has gone
wrong, which is what the canary now does.
Re-screening qwen3-8b to find out whether it scores more than one of five once
the documents are actually independent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The canary ran before the documents, every tenth document, and on abort. A
five-document screen whose first document succeeds trips none of those after
the first, so it could finish having never re-verified the instrument it just
used — which is the exact hole the canary exists to close.
It now runs at the end of every AI engine pass regardless of outcome. Healthy
before and broken after means the failures in between are one fault, and the
run says so rather than leaving it to be worked out later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third false verdict from this script in a row, all the same shape: it asked
whether "Paris" came back, and a working model failed twice for reasons that
had nothing to do with the defect.
read only `content` -> a thinking model writes to reasoning_content
64-token budget -> a thinking model never reaches the answer
Both were my bounds, not the model's, which is the error that has run through
this whole exercise.
So it now tests the defect itself. The signature is a single token repeated —
" de de de", " phosphorus phosphorus", whitespace — and a run is BROKEN when
one token is more than half the output, EMPTY when nothing comes back. That
survives truncation, does not care which field the model writes to, and does
not require the model to finish or to be right.
The general lesson, since it cost three attempts: an implausible reading is the
only kind that gets checked. BROKEN at 154 prompt tokens was obviously wrong,
so it got investigated. A boundary reported at 4000 would have been believed —
it would have fit the expectation. That is exactly how the 768-token cap, the
Q8 comparison and the 35B threshold all survived as long as they did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run of probe-length.sh called Qwen3-8B BROKEN at 154 prompt tokens. No
model degenerates at 154 tokens — the verdict was wrong, not the model.
It read only `content`, and a thinking model puts its answer in
`reasoning_content` and leaves content empty. Empty content is not a broken
model, and this is the same mistake that made Qwen3.6 look slow and broken this
morning before --reasoning off went in.
Now reads both fields.
Noting it because the script exists specifically to stop me judging models on
bad readings, and its first reading was bad. A tool that checks the instrument
still has to be checked itself — the 154 is what gave it away, since it was
implausible rather than merely unwelcome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not for scoring. Their classification numbers were taken against a slot that
may already have been corrupted, so the only honest use for them now is finding
where each one's prompt-length boundary actually sits.
Also repairs qwen3-30b-a3b's row, which kept the old repo:QUANT shape because
it was commented out when the file moved to explicit filenames.
Probing hermes4-14b and qwen3-8b first: both were written up as capability
failures on evidence that cannot support it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tests have to get better, and this is the change that would have caught
today's whole error class on document one instead of after six candidates.
Every AI run now asks "Nenne die Hauptstadt von Frankreich" before the
documents, every ten documents, and again when it aborts. No Paris, no run.
Why it goes mid-run and not only at the start: failures are not always
clustered. Ministral-3 3B failed 29 of 50 scattered through the run, which a
start-of-run check would have passed straight through. A slot that dies at
document 12 otherwise produces 38 more "failures" that are one event.
What the harness did wrong, so it is written down rather than relearned:
- it measured classification accuracy while a confound (slot corruption)
dominated, and never established that it could tell the two apart
- it counted sequential failures on a shared slot as independent evidence
- it recorded status codes and token counts and never once looked at what the
model actually emitted — twice the answer was one curl away
- it treated my own bounds, --n-predict 768 and a 300 s timeout, as
properties of the models hitting them
A run whose canary breaks is void, not a result. Failures adjacent to the break
are one fault, and probe-length.sh says where the model's boundary really is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything this harness measured about failing candidates was the wrong thing.
On this Vulkan/Iris Xe stack every model has a prompt length past which
llama.cpp stops producing language and repeats a single token — " de de de",
" the the the", " phosphorus phosphorus", or whitespace. Silent: HTTP 200,
finish_reason "stop", nothing in the log. And stateful: one request past the
boundary breaks the slot for everything after it until the pod restarts.
Which means a 50-document run measures where each model's boundary sits
relative to paperless's ~5300-token request, and nothing else. "Failed 3 of 3"
is one measurement, not three — the first document took the other two with it.
Demonstrated on gemma-4-26B-A4B, which I had written up as failing on
capability:
fresh pod, "Nenne die Hauptstadt von Frankreich" -> "Die Hauptstadt von
Frankreich ist Paris."
same pod after one 5000-token request -> " phosphorus phosphorus..."
fresh pod, 2334-token prompt, no tools at all -> whitespace
The model is fine. Its boundary is under 2334 tokens, so it can never serve a
paperless request, and every classification number I recorded for it described
a corrupted slot.
So the size threshold, the generation theory and the capability rankings are
all withdrawn. What correlated with success was having a boundary above 5300 —
Qwen3.6 measured coherent at 13588 — not being larger or newer.
probe-length.sh answers that in minutes instead of ninety. A fresh pod per
size, because probing sizes in sequence against one server would measure the
first bad request and nothing after it.
The galling part is that this defect is already documented in my own notes from
this morning, boundary and slot-poisoning both, and I did not apply it when
reading the results.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final numbers were 30 scored, 20 failed — and all 20 failures sat exactly on
the 768-token cap. That does not measure precision, it measures how often a
wordier model hits a bound I chose. Discarded rather than recorded.
Q8's uncapped answers run 460-520 tokens against Q4's 280-443, roughly 60%
longer for the same task, which is enough to turn one cap hit in fifty into
twenty. Re-running at --n-predict 2048 with a 600 s client bound.
Q4's baseline stays comparable despite running at 768: it hit that cap once in
fifty documents, so raising it can move at most one document. Stated rather
than assumed.
README now documents both bounds as harness properties rather than model
properties, with the rule that follows: before comparing two configurations,
check that neither is sitting on the cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Retrying gemma-4 at --n-predict 4096 did not answer the question — it moved the
wall. At 6.6 tok/s a 4096-token budget needs 620 s of generation, and the
client bound was still 300, so the run failed with TimeoutError instead of
hitting the cap. Two bounds of mine, and lifting one just exposed the other.
EVAL_TIMEOUT is now an environment variable and run-candidate.sh takes it as a
fourth argument, so a diagnostic can raise both together:
./run-candidate.sh gemma4-26b-a4b 3 4096 900
What the aborted attempt did show: gemma-4 reached 1634 generated tokens and
was still going when the clock ran out. A model needing 900 tokens for a valid
call would have finished long before that, so this looks like genuine
non-termination rather than a cap set too tight — but "looks like" is exactly
the reasoning that put 768 in here unexamined, so it is running properly now.
The same caveat applies to the Q8 comparison and I have not resolved it. Q8's
uncapped responses run 460-520 tokens against Q4's 280-443, roughly 60% longer,
and 20 of its requests hit 768 against Q4's one. So "Q8 performs worse" may be
"Q8 is more verbose and my cap catches it", which is a much weaker claim. That
comparison needs redoing at a raised cap before its numbers mean anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fair challenge I had not put to myself: --n-predict 768 is a bound I chose this
morning because Qwen3.6 once ran to 10269 tokens and cost 900 s a document. It
is not a property of any model.
Which means every candidate recorded as failing at exactly 768 generated
tokens — Hermes-4-14B, gemma-4-26B-A4B, both Ministrals, Granite, Qwen3-8B — is
failing against MY bound, and I wrote them up as incapable. A model that needs
900 tokens to emit a valid tool call is indistinguishable, in that record, from
one that never terminates at all.
run-candidate.sh takes the cap as a third argument:
./run-candidate.sh gemma4-26b-a4b 5 4096
gemma-4 is re-running at 4096 against the same three documents it failed, since
it is the newest model in the field and the cleanest failure of the set. If it
produces valid calls given room, six rejections in this file are wrong and the
size threshold I committed an hour ago is built on an artefact. If it still
runs to the new cap, the cap only changed how long it took to find out.
Worth the node time either way. A working answer takes 241-291 tokens, so 768
looked generous — but "looked generous" is not evidence, and I should have
tested the bound before drawing conclusions from models hitting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gemma-4-26B-A4B aborted 3 of 3, all HTTP 400 rather than timeouts, and the
server log shows it ran to the --n-predict cap on every one — the same
capability failure as the small dense models, not a template mismatch.
It was chosen precisely because it could have refuted the threshold: newest
generation in the whole field, 26B total. It did not.
Qwen3.6-35B-A3B 35B newest works
Qwen3.5-35B-A3B 35B older works
Qwen3-30B-A3B 30B oldest fails
gemma-4-26B-A4B 26B NEWEST fails
Hermes-4-14B 14B — fails
dense 3-8B — — fail
Two generations work at 35B total; everything at 30B and below fails. After
three hypotheses that died on contact, this is the first one that predicted a
result in advance and was right, so it becomes the operating rule: do not spend
a screening slot on anything under ~35B total.
Deliberately not pinning it further. Whether the line is at 31B or 34B is
interesting and changes no decision — there are two working candidates and the
useful next number is the second one measured over all 50 documents, which is
now running.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aborted 3 of 3 — but every failure was a TimeoutError rather than an invalid
answer, so the experiment does not answer the question it was started for.
Document 1 did reach the --n-predict cap without producing a tool call; 2 and 3
were cut off by the 300 s client bound before anything could be concluded.
Disqualified on speed regardless, and that part is unambiguous: 31 tok/s
prefill against Qwen3.6's 100 and a dense 8B's 240, plus 4.9 tok/s decode. That
is over seven minutes per document, about six hours for a full run.
Prefill is now recorded in candidates.tsv as a selection criterion. It varies
more than sixfold across architectures and is roughly half of a run's wall
clock, and I had not been tracking it at all.
gemma4-26b-a4b replaces it as the size-versus-generation discriminator: 26B
total, well under 35, newest generation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GLM-4.7-Flash was killed mid-download at 17.8 GB and restarted from the
beginning. The startupProbe allowed 60 * 20s = 20 minutes, and the comment
explaining that number gives the reason it was wrong: "~20 GB of weights off
CephFS". It was sized for reading a warm PVC, not for a first download from
HuggingFace, which is what actually happens the first time a candidate runs.
Too small a budget here is not a slow start — the container is killed, restarts,
and downloads again, so it never finishes. Raised to 180 * 20s = 60 minutes.
Worth writing down because the signature is misleading: a probe kill shows as
exitCode 137 with reason "Error", not OOMKilled, and 137 reads like a memory
limit. The pod meanwhile sits in Init, which by now has hidden three separate
faults in this harness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured, same model, same documents, only the quantisation differs:
Qwen3.6-35B-A3B Q4_K_M ~21 GB 11.0 tok/s
Qwen3.6-35B-A3B Q8_0 ~34 GB 10.3 tok/s
I predicted ~6 tok/s for Q8 — a halving, on the reasoning that doubling bytes
per active parameter doubles what crosses the memory bus. It cost ten percent.
So decode is not bandwidth-bound for this MoE at all. It is compute-bound on
many small per-expert matmuls, which retroactively explains a measurement I
recorded but never followed through: the MoE reached 21.4 GB/s against a
53.9 GB/s ceiling. It was already leaving 60% of the bus idle, so more bytes
cost almost nothing.
That formula has been steering decisions in this file — it is what ranked
candidates by size and what I used to predict runtimes. It predicts dense
models and not this one.
Practical consequence: higher precision is nearly free on an MoE here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 of 5, no failures, 584 s. Qwen3.5 is a generation older than the working
Qwen3.6 and handles the 14-field schema cleanly, so "only the newest models can
do this" does not survive either. That is the third hypothesis to die here.
What the two working models share is 35B total parameters against the failing
MoE's 30B:
Qwen3.6-35B-A3B 35B total newest works
Qwen3.5-35B-A3B 35B total older works
Qwen3-30B-A3B 30B total oldest 4 of 5 failed
Confounded, and I am not going to call it. Qwen3-30B-A3B is both smaller and
older; three points cannot separate size from generation.
glm47-flash is the experiment that can: 30B total, newest generation. Works
means generation matters and 35B is not a threshold. Fails means total size is,
and there is no point screening anything below ~35B.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting the previous commit, which named the wrong cause with some
confidence. The llama-models volume was 100% full — 120 GiB, nothing free.
llama-server could not write its metadata cache, so it could not resolve the
repository listing, and reported
no GGUF files found in repository <repo>
Available GGUF files:
- <whatever it had cached before the disk filled>
That reads as a bad model reference, and I read it that way. It is a full disk.
Nine screened candidates filled the volume: 2.5 GB for a small dense model,
17-25 GB for an MoE, 34 GB for a Q8. Freed 56.5 GB by removing the weights of
candidates already rejected — they are re-downloadable, so that costs only
bandwidth.
--hf-file stays. Naming the exact file removes a genuine ambiguity (ggml-org
ships mmproj-, mtp- and dflash- variants at Q8_0 beside the model) and costs
nothing. It just was not the fault here, and the comment claiming it was is
rewritten.
README now says to check `df -h /models` before believing anything llama-server
says about a repository.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two jobs crashlooped for three hours without producing a single document, and
I did not notice because the pods sat in Init the whole time — which looks
exactly like a slow model download.
llama_model_load_from_file_impl: exactly one out metadata, path_model,
and file must be defined
That reads as a loading error. It is an ambiguous reference. `--hf-repo
repo:QUANT` matches by substring across the entire repository, and repos
routinely carry several files bearing one tag: ggml-org ships mmproj-, mtp- and
dflash- variants at Q8_0 beside the model, bartowski ships
kld_results/kld_Q4_K_M.txt beside the weights. Q4_K_M happened to be
unambiguous for the baseline, which is why this never surfaced before.
candidates.tsv now carries the exact filename as its own column, resolved from
the HF API for all fifteen entries, and run-candidate.sh refuses to start a
candidate that has none rather than guessing. --hf-file overrides the quant.
Also fixing the monitoring gap that hid it. The watch treated PodInitializing
as "keep waiting" indefinitely, so a container failing to start was
indistinguishable from one still pulling 34 GB. It now checks init container
states for CrashLoopBackOff/Error/ImagePullBackOff and reports with the last
lines of the failing container's log.
Silence was not success, and the tooling should have said so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qwen3-30b-a3b — MoE, 30B total, 3B active, the shape that was supposed to
explain the whole pattern — scored 1 of 5 and failed 4. Indistinguishable from
the dense 8B it was meant to outclass.
That is the second architectural theory to die here. First "a smaller dense
model gains at both ends", which the timings contradicted. Now "capability
comes from total parameters, so MoE gets it cheaply".
Qwen3.6-35B-A3B MoE 35B/3B Apr 2026 2 of 50 failed
Qwen3-30B-A3B MoE 30B/3B older 4 of 5 failed
Qwen3-8B dense 8B older 4 of 5 failed
Hermes-4-14B dense 14B — 3 of 3 failed
Neither architecture nor parameter count predicts success. The only thing that
correlates so far is how recent and capable the model is — which is an
unsatisfying hypothesis, because unlike the other two it predicts nothing
cheaply and cannot be used to narrow a search.
qwen35-35b-a3b is the experiment that isolates it: identical architecture,
total and active parameters to the working baseline, one generation older. If
it fails, generation is the variable and every shape argument in this file was
noise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five dense models have failed and the one MoE works, so the list was searched
again for that shape specifically — 20-60B total, under ~6B active:
glm47-flash GLM-4.7-Flash, 30B/3B, 17.8 GB
gemma4-26b-a4b gemma-4-26B-A4B-it, 26B/4B, 16.9 GB
qwen35-35b-a3b Qwen3.5-35B-A3B, 35B/3B, 21.5 GB
nemotron35-30b-a3b Nemotron-3.5-Lightning-30B-A3B, 30B/3B, 25.5 GB
Correcting an exclusion of mine: Gemma is not out. Gemma 3 has no tool support
at any size — verified, "tool" appears zero times in its template — but Gemma 4
26B-A4B does, and claims 140+ languages pretrained. Writing off a family by its
previous generation was sloppy.
Two carry a Vulkan risk that has to be checked in the server log rather than
assumed: Nemotron-3.5 interleaves real Mamba2 layers and Qwen3.5 uses DeltaNet
linear attention, and llama.cpp's Vulkan SSM shaders are less exercised on Iris
Xe than on Arc. Issue #19957 reported exactly this for Qwen3.5-35B-A3B, and it
turned out to be a stale build fixed well before b10731 — but a silent CPU
fallback would read as a slow model, not as an error, so confirm the layers are
offloaded.
Rejections with reasons are in the file: ERNIE-4.5-21B-A3B has tool calling
only in its Thinking variant, Ling-3.0-flash is 124B total, Granite-4.0-H-Small
is 9B active, gpt-oss-20b is a reasoning-effort model in Harmony format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Screening results so far, and they point somewhere I did not expect:
qwen35 MoE 35B total / 3B active works, 2 of 50 failed
qwen3-8b dense 8B 4 of 5 failed
hermes4-14b dense 14B 3 of 3 failed, aborted
ministral8b dense 8B 3 of 3 failed, aborted
ministral3b dense 3B 29 of 50 failed
granite8b dense 8B every document failed
Hermes is the cleanest failure of the set and the reason this is worth writing
down. Its <tool_call> JSON is an explicitly tested fixture in llama.cpp's own
test suite, so the parser demonstrably handles the format — the failure has to
be the model, not the plumbing. That also retroactively weakens the Granite
verdict, which I attributed to llama.cpp alone.
Working hypothesis: holding a long grammar-constrained structure needs model
capacity, and what supplies it is total parameters rather than active ones.
qwen3-30b-a3b tests it directly at 30B total / 3B active.
If it holds, the search for a smaller faster model is over before it started —
and the consolation is that the fastest shape on this hardware is the only one
that works, since decode cost scales with active parameters while capability
appears to scale with total.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything in this comparison is Q4_K_M, including the baseline, and the
quantisation damage has never been measured. That is an assumption sitting
underneath every number in the README.
ggml-org ships Q4_K_M, Q8_0 and BF16 of Qwen3.6-35B-A3B in one repo, so the
control is exact: same model, same 50 documents, same flags, precision the only
variable.
There is a specific suspicion behind this, not just diligence. Ministral-3
leaking [THINK] and [TOOL_CALLS] into a tool call as literal text is the kind
of fault 4-bit quantisation can produce, and Q8 would separate "the model
cannot do this" from "Q4 broke it". Three candidates have been rejected on
behaviour that no one has ruled out as a quantisation artefact.
Q8 before BF16 deliberately: ~34 GB and ~6 tok/s against Q4's ~21 GB and
11 tok/s, so roughly double the wall clock, where BF16 would be ~65 GB at
~3.5 tok/s and near five hours. If Q8 matches Q4 within the noise floor, BF16
almost certainly does too and the expensive run is unnecessary. If it does not,
BF16 becomes worth its cost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delegated research across nine model families, every repo:quant checked against
the HF API by filename and byte size, and every chat template inspected for
tool support rather than assumed.
Ordered by the corrected size rule — MoE with few active parameters first,
then dense 3-9B with German named in the model's own card, then the rest:
qwen3-30b-a3b Qwen3-30B-A3B-Instruct-2507, 18.6 GB, ~3B active
nemotron3-30b-a3b Nemotron-3-Nano-30B-A3B, 24.7 GB, ~3B active
glm4-9b GLM-4-9B-Chat, 5.8 GB, BFCL 81.00, German explicit
commandr7b Command-R7B, 4.7 GB, native tool grammar, German explicit
qwen25-7b Qwen2.5-7B, 4.7 GB, German named, no thinking mode at all
llama31-8b Llama-3.1-8B, 4.9 GB, German in Meta's official set
nemotron-nano-12b Nemotron-Nano-12B-v2, 7.0 GB, German verified
phi4-mini Phi-4-mini, 2.5 GB, German documented
qwen3-4b-2507 Qwen3-4B-Instruct-2507, 2.5 GB
qwen35-9b Qwen3.5-9B, 6.2 GB
The exclusions are in the file too, because they cost real search time and
should not be re-derived. Gemma 3 at every size, phi-4 14B, Aya and InternLM
have no tool support in their templates at all — inspected, not inferred.
Falcon 3 states EN/FR/ES/PT and German is simply not on it. Every German
fine-tune checked (SauerkrautLM, DiscoLM, LeoLM, Occiglot, Teuken) overwrites
its base template with a minimal ChatML loop that drops tool calling, even
where the base had it.
The most useful thing found explains the Granite failure. Current llama.cpp has
no per-vendor tool-call parser table; common/chat.cpp derives the grammar from
the model's own template via a PEG auto-parser. Hermes-style <tool_call> JSON
is an explicit tested fixture; Granite's shape is not. So the earlier note
saying llama.cpp "cannot parse Granite" is better read as its template being
outside what the auto-parser was validated against.
Two candidates dropped after verification: Qwen3.8-27B is dense 27.8B at
19 GB, on the wrong side of the size rule, and carries the only negative
German signal found anywhere — users reporting fabricated German words on its
own HF discussion page. aya-expanse-8b has no tool support.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight candidates now, every repo:quant verified present via the HF API and
every one a ggml-org / bartowski / lmstudio-community / unsloth conversion
rather than the vendor's own.
The selection rule in this file was wrong and is corrected. It said a smaller
dense model gains at both ends, and the Ministral pair was chosen on that
basis. Measured:
Ministral-3 8B dense, ~4.7 GB weights 7.1-8.2 tok/s
Qwen3.6 35B-A3B MoE, ~20 GB weights 11 tok/s
The largest model is the fastest one here. Decode is bandwidth-bound on the
ACTIVE parameter set, which for an A3B mixture is ~3B however much is resident;
total size only has to fit, and 96 GiB per node means it always does. So the
order is MoE with few active parameters, then dense 3-9B, then dense 10-14B
only for an exceptional model — that is ~4 tok/s, near three hours for 50
documents — and nothing dense above 14B.
hermes4-14b is in the list despite being on the wrong side of that line: five
screening documents are cheap, and it answers whether llama.cpp's named
Hermes tool-call parser handles what Granite's format could not. That answer
transfers to smaller Hermes sizes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The setup is now good enough to test everything with a chance, so the gate
matters more than any single run. run-candidate.sh takes a sample size:
./run-candidate.sh <label> 5 screen
./run-candidate.sh <label> measure
With ABORT_AFTER a hopeless candidate is rejected in about five minutes. Three
of the first four were rejections, and each was left running for an hour or
more first.
Screening deliberately uses genuine paperless requests rather than
probe-toolcall.sh. The standalone probe is cheaper and still worth a first
pass, but it passed two candidates that failed on document one — a synthesised
request cannot reproduce the RAG neighbours and the taxonomy candidate lists
that make a real prompt what it is. Document 1970 is 1976 characters and still
produces a 5333-token request.
A screen is a gate, not a measurement. Numbers are never compared across
sample sizes; the comparison happens at 50.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Qwen3.6-35B-A3B re-measured with --reasoning off:
72.9 / 77.1 / 85.4 / 94.9 / 44.4, two failures, 5494 s. Complement 30 both
right, 5 rescued, 4 broken, 9 both wrong.
Ministral-3 3B: 21 scored, 29 failed. Its correspondent numbers over those 21
sit within a document of Qwen3.6's, which is not a near-tie — the 21 are the
documents it could answer at all, a set selected for being easy, and the
classifier does better on them too (11 of 21 both right against 30 of 48). A
model that declines three documents in five has no usable accuracy number.
Commented out.
The re-run was worth more than the row it produced. Both Qwen3.6 runs used the
same 50 documents and differ by exactly one document on every metric — one of
nine on the catchall. That is the noise floor, and it is now written down: a
difference between two candidates smaller than about two documents means
nothing. Decoding is sampled; the same model on the same input does not answer
the same way twice.
I had called the re-run "better" at the 10-document mark on the strength of a
0-vs-1 failure count. It finished 2-vs-2. That was the same mistake as reading
a probe pass as a verdict, and it is the reason the noise floor is in the
README rather than in my head.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The earlier attempt ended on three failures of which one was my own token
collision, so the verdict was not defensible. Re-run alone, with bartowski's
conversion, --reasoning off and its own API user: three documents, three
HTTP 400s, aborted after 531 s. Generation runs to the --n-predict cap and no
tool call ever comes back. Commented out.
The 3B of the same family gets through documents the 8B cannot, which is not
what I expected from a size ablation and is left enabled because it produces
real numbers with failures mixed in.
qwen35 is re-running on the freed node with --reasoning off. Its measured
result used the deprecated --chat-template-kwargs, and while that flag does
what it says on Qwen's template, a comparison table whose only filled row was
produced under different flags than everything after it is not one I would
trust.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I claimed bartowski's GGUF was "the whole difference between a working
candidate and a failing one" on the strength of a probe pass. The real run
disproved it: both conversions still run to the --n-predict cap against a
~5300-token paperless request.
What bartowski genuinely fixes is narrower — at probe scale mistralai's file
leaks [THINK] and [TOOL_CALLS] as text where bartowski's does not, and
llama-server defaults to -sp off, so those tokens are simply not marked special
in mistralai's conversion. That is worth knowing and is not sufficient.
Also correcting a verdict: Ministral-3 3B does not fail everything. Its run
never tripped ABORT_AFTER, which only fires when the first three documents all
fail — so at least one succeeded, and the absence of the abort was itself the
evidence. I read three error lines and called the model broken without noticing
what sat next to them.
probe-toolcall.sh now states plainly that a FAIL is conclusive and a PASS is
not, and why: it cannot reproduce the RAG neighbours or the taxonomy candidate
lists that inflate a real prompt. Document 1970 is 1976 characters and still
produces a 5333-token request. The real gate is ABORT_AFTER against genuine
requests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every parallel run so far was quietly broken, and it took a 401 sitting in the
middle of three genuine model failures to notice.
api_token() took the first superuser and deleted its existing token before
inserting a new one, because Django's authtoken holds one token per user. Both
candidates share this database, so the second job to start silently revoked the
first one's credentials. In the log it appears as
50173: HTTP 401: b'{"detail":"Invalid token."}'
among HTTP 400s from the model, where it reads like one more broken document.
It was counted toward ABORT_AFTER, so a harness fault helped end a run and
would have been recorded as a model verdict.
Two changes. Each run now creates its own eval-<label> superuser, so the token
belongs to the run and concurrency is safe — only the user can differ, the
database cannot. And a 401 is now fatal with an explicit message rather than
another row in the error column: it is never a fact about the model, and every
number after it is worthless.
The qwen35 result predates this and ran alone, so it is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe used the real 14-field schema but a 1191-token prompt, while a real
paperless request carries ~5300 tokens once the document and its RAG
neighbours are in. Ministral-3 8B passed the probe and then ran to the
--n-predict cap on the first document of the real run.
Whether that document is pathological or the probe is systematically too easy
is not yet settled — it has failed under every engine tried so far, including
the one that works. But a probe is only worth having if it fails where
production fails, and it costs nothing to make it realistic in both dimensions
rather than one. ~5000 tokens of German filler now precedes the document text.
This is the same mistake twice: the first version was too easy on schema
width, this one was too easy on prompt length, and both times a pass sent a
ninety-minute run after a model that could not do the job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"These are all invocation errors" — correct, and I had written two of them off
as properties of the models.
probe-toolcall.sh starts a candidate, sends paperless's real 14-field
DocumentClassifierSchema, and passes only on finish_reason "tool_calls". The
payload is the actual schema rather than a stand-in, which is the entire point:
a three-field probe got a clean call out of Ministral-3 while the real schema
made it degenerate, and that false pass cost an hour of chasing prompt length.
What it turned up immediately:
Ministral-3 8B and 3B pass with bartowski's conversion and fail with
mistralai's own, at identical quant and flags. llama-server defaults to
-sp off, so [THINK] and [TOOL_CALLS] should never have appeared as text at
all — they are not marked special in mistralai's file. A conversion defect,
not the model. Both candidates now point at bartowski and are running.
Granite fails with bartowski too, finish_reason "stop" and no tool_calls, so
its cause is different and unchanged: llama.cpp b10731 cannot read Granite's
tool-call format back whoever converted it. The remaining lever there is
--chat-template-file with a template the server can parse, or a newer build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three candidates in a row failed every document, and each time I let it run.
The harness now stops after ABORT_AFTER = 3 consecutive failures from the
first document, because document one already said everything the remaining
forty-nine would.
Not a ratio — a model failing a third of the time is a result worth measuring
in full. This only catches "nothing has ever worked".
What the three were, since none of them is a verdict on the model:
granite8b classifies correctly, but llama.cpp b10731 hands its tool call
back as content with finish_reason "stop" and no tool_calls
array, so llama_index raises "Expected at least one tool call,
but got 0".
ministral8b given paperless's real 14-field DocumentClassifierSchema, opens
ministral3b a valid tool call and then fills its first string with its own
control tokens until the cap:
{"title": "[TOOL_CALLS][ARGS][THINK][/THINK][THINK]...
The grammar allows any character inside a JSON string, so those
are "valid" and it never closes.
I chased the Ministral failure as a prompt-length problem for an hour on the
strength of a three-field probe that worked. It reproduces at a 1191-token
prompt with the real schema, and a 5799-token prompt with the simple one gets a
clean tool call — the schema is the variable, not the length.
So the first thing to establish about a candidate is not German, or speed, or
classification quality. It is whether it can emit one valid call against a
14-field schema, and that is a two-minute probe rather than a ninety-minute
run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I wrote in the last commit that passing '{"enable_thinking": false}' to every
candidate was a no-op on templates that never read the variable. That is
wrong, and it cost a run.
Ministral-3-8B produced token salad on a 681-token prompt — far below the ~14k
corruption boundary, so not that defect:
1UnComplete1Self1UnCompleteSDirect11S1S111Document1CompleteFromS11Un...
Every document failed, and each one generated to the --n-predict cap. The
identical request against a server started without the flag returned a clean
tool call and the correct answer. Plain German was fine throughout — "Die
Hauptstadt von Frankreich ist Paris" — so it only ever broke with tools, which
is what made it look like a model that cannot do function calling.
llama-server prints "Setting 'enable_thinking' via --chat-template-kwargs is
deprecated. Use --reasoning on / --reasoning off instead." at startup. It means
it. --reasoning off is verified to give Ministral a proper tool_calls response
before this went near a real run.
Qwen3.6's template does consume enable_thinking, so its measured result stands
— but its run used the old flag, and the configuration should be uniform
across candidates before the numbers are compared. Worth a re-run.
granite8b is commented out of candidates.tsv, and this one is not my flag: it
classifies correctly and llama.cpp b10731 simply cannot read its tool calls
back, returning them as content with finish_reason "stop". Reproduced on an
idle server both with and without the flag. Details are in the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The point of this harness is comparing chat models, and it was shaped for
comparing engines. Four candidates meant four 300-line copies of the same Job,
which is exactly how --chat-template-kwargs ended up on the isolation pod but
not on the first eval job — two hours of empty completions before anyone
looked.
So: job-template.yaml holds the sidecar configuration once, candidates.tsv
holds the five values that differ, and run-candidate.sh substitutes them.
Adding a model is a line, not a file.
Candidates, all first-party GGUFs verified to exist with a Q4_K_M:
ministral8b mistralai/Ministral-3-8B-Instruct-2512
ministral3b mistralai/Ministral-3-3B-Instruct-2512
granite8b ibm-granite/granite-3.3-8b-instruct
Instruct, never Reasoning — a model tuned to think first does by design what
cost Qwen3.6 two of fifty documents. Sizes are chosen because decode is
bandwidth-bound at ~11 tok/s and prefill was 51% of the first run's wall clock,
so a smaller dense model gains at both ends; 3B against 8B is the size
ablation, granite against ministral the family one.
The classifier stays in every run as a fixed reference line, not as a quality
bar: train() fits on every non-inbox document with no holdout, so the
evaluation documents are in its training set and its 100% on document type is
memorisation. Irrelevant to ranking models — same constant under all of them —
and the README now says so rather than inviting the comparison.
First result, Qwen3.6-35B-A3B Q4_K_M over 50 documents: correspondent 70.8
offered / 75.0 recognised against the classifier's 72.0, document type 83.3
against 100, and 33.3 against 100 on the "Sonstige" catch-all. 5 rescued
against 5 broken — but two of those five are difflib rejecting a correct
answer, "GOTOOLS GmbH" against the stored "GOTOOLS", an exact prefix scoring
~0.74 under MATCH_THRESHOLD = 0.8.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three of the first eleven documents in a run never terminated. One reached
10269 tokens on a classification that needs ~300, at a perfectly healthy
11 tok/s — so it read as slowness and was neither slow nor a context problem.
llama-server's --n-predict defaults to -1, infinity, and paperless sets no
token budget of its own and waits. Nothing anywhere bounded the request. Four
documents cost most of an hour.
--n-predict 768 on the sidecar caps it, and a truncated response is better
evidence than a timeout: it shows what the model was doing instead of
stopping. The client timeout drops to 300 s and becomes a backstop.
Also:
- the degeneracy check fired on 'Rechnung 2025-5425541' — 8 letters in 21
characters, below the 40% Latin threshold. Digits and punctuation are normal
in a title; look for characters from other scripts directly instead of
inferring them from a shortage of Latin ones.
- the ctx-size comment still carried the claim bisection disproved in #777,
that 32768 corrupts inference. The boundary is prompt length.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the harness against the eval instance instead of waiting for review,
which is the point of it not being Flux-managed. Two faults surfaced in
the first two attempts, and the third run scored 10/10 cleanly.
seed-data used `cp -a`. Preserving ownership needs CHOWN, which the
container does not have, so cp copied everything and then exited
non-zero, and `set -e` killed the pod before any sidecar started. It
copies with `-r` now and runs as uid 1000, which also gets the ownership
right — paperless writes to the LLM index while serving, and a copy made
by root would have been read-only to it.
The degeneracy check fired on every classic row. The classifier answers
with resolved ids and no `title` at all, so "empty title" is its normal
shape rather than broken inference; the check now runs for the AI engine
only. Left as it was, every classic run would have reported zero scored
documents and a 100% degenerate rate.
Validated: 10 documents, 0 failed, 0 degenerate, correspondent 80.0%,
document type 100.0% — matching what the bash version measured against
production, so the port to Python changed nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Job from #776 ran and failed, which was more useful than it sounds.
It died before scoring anything: psycopg returns Postgres numerics as
Decimal, and `100.0 * top / total` raises TypeError rather than coercing.
Both operands are cast now.
It also seeded nothing. The golden volume was empty, `cp -a` copied
nothing, and `du ... || true` swallowed it — so the run would have
measured the no-RAG path and reported it as a RAG result, which is
precisely the failure the initContainer was written to prevent. It now
refuses to start when llm_index is under 1 MiB, and says how to build it.
And the script had no way to tell broken inference from a wrong guess.
Past roughly 14k prompt tokens this stack returns empty completions and
then runs of digits and CJK characters, with status 200 and
finish_reason "stop", and one such request poisons the llama.cpp slot for
everything after it. Scored as wrong answers those would quietly depress
whichever candidate happened to trip it, and take the rest of that run
down with it. They are flagged, excluded from the accuracy figures, and
counted separately — a non-zero `degenerate` means the run is suspect
from that point on rather than that the model is bad.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Job is immutable and a measurement is finished when it finishes —
neither is something Flux should converge on. Re-running a configuration
means deleting and recreating the Job, and a managed one comes straight
back. Worse, eval.py was a kustomize-generated ConfigMap, so editing the
script between runs and applying it by hand would have been reverted on
the next reconcile: the iteration loop the harness exists for was closed
off by where it was put.
The namespace stays managed. It holds a database clone, PVCs and a
network policy — state that should converge and should vanish when the
Kustomization is removed. Only the harness moves to scripts/, alongside
the other tools nobody deploys, with a README covering how to run one,
how to add a candidate, and how to read the output.
Two failure modes are written down there rather than left to be
rediscovered: prompts past ~14k tokens make this stack emit garbage
silently and poison the slot until restart, and ground truth is a filing
convention that has drifted over ten years, so the comparison between
engines is the durable result rather than the absolute numbers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment blamed --ctx-size 32768 for corrupting inference. Bisection
on a scratch pod disproves that: at --ctx-size 65536 with a single slot,
so four times the headroom, the same server answers a 650-token prompt
correctly seven times in a row. What breaks it is how long the prompt is.
13588 prompt tokens coherent answer
15086 one token, immediate EOS, empty content
16088 CJK characters and digit runs
And one request past the boundary poisons the slot: the 13588-token
prompt that had just answered cleanly returns one empty token afterwards
and stays broken until the pod restarts. That explains what looked
intermittent, and why a 650-token classification once came back as 1025
tokens of "!" — it followed Home Assistant's ~19k prompts through the
same slot.
The practical consequence changes with it. The rule is not "keep this
flag low", it is "keep callers under roughly 14k tokens". Paperless sits
near 5k and was never at risk; Home Assistant's Assist prompt measured
18910 and never had a chance, which is a better explanation of its empty
completions and invented device list than the model being weak at long
contexts.
Nothing about the failure is visible from outside: finish_reason "stop",
a well-formed OpenAI response, HTTP 200, /health green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A paperless instance points at exactly one chat endpoint, so comparing
two candidates means two paperless instances. The pod is therefore the
unit, not the namespace: llama-server, redis and paperless as native
sidecars, the eval script as the one regular container. When it exits the
sidecars are torn down and the Job completes, which a plain multi-
container pod behind a llama-server would not.
Sidecar ordering also removes a failure seen on the smoke test, where
paperless started while the database was still restoring and exited after
its 60s wait.
The script is Python rather than the bash-and-jq version, because the
image has no jq and because the thing that was missing is easier this
way: it writes one row per document with both engines' verdicts. Two
aggregates cannot answer the question worth asking — 74% against 72%
could overlap almost entirely or barely, and that difference is the whole
value of running an LLM beside a classifier that costs 0.6s and keeps
running regardless. The summary now reports how often the LLM rescues a
document the classifier got wrong, and how often it breaks one the
classifier had right.
/data is copied into the pod rather than mounted. LLM_INDEX_DIR is
hardcoded to DATA_DIR/"llm_index", the store behind it is SQLite, and
SQLite locking across pods over CephFS is not dependable — but leaving it
out is worse than sharing it: an empty index makes retrieve_similar_nodes
return nothing, so a job would measure the no-RAG path while reporting a
RAG result. ~160 MiB, seconds, and each job gets a private writable copy.
That volume had to be renamed. paperless-eval-data was RWO and a bound
claim's spec is immutable, so paperless-eval-golden replaces it — named
for its role, since the smoke-test deployment builds it and the jobs read
it.
Anti-affinity puts one measurement per node. Two on a node would share
its iGPU and every timing would then describe contention rather than the
model, which is how 4.7 tokens/s got blamed on context length once
already.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Companion to ai-eval.sh, for the half of the stack that was never measured. The
chat model was chosen from a four-way benchmark; the embedder was taken from a
first recommendation and deployed unexamined. It now backs Paperless's RAG over
a German archive, where retrieval quality decides which past documents the
classifier gets to see.
It cannot work like ai-eval.sh. A chat answer can be read and judged; an
embedding is 1024 opaque numbers. What is measurable is whether the right
document comes back first, so this embeds a labelled German corpus and scores
real questions against it: recall@1, recall@3, MRR, and the cosine margin
between the correct document and the best wrong one — a model that is right by
0.002 is guessing, one that is right by 0.15 has actually separated them.
The corpus is built to be failable. Two Allianz policies, four documents that
are all Rechnungen, a car insurance and a vehicle registration sharing a number
plate. Queries are paraphrases rather than keyword matches — "Wann muss mein
Auto zum TUEV" against a document that only ever says Hauptuntersuchung. An easy
corpus would score every model 100% and rank nothing.
Baseline for the deployed model:
RESULT model=qwen3-embedding-0.6b dim=1024 recall@1=92% recall@3=100% mrr=0.958
The single miss is the intended hard case — the car-insurance question lands on
the household policy, rank 2, margin -0.0132. The margin column also exposes
what pass/fail would hide: arbeitsvertrag is won by +0.0059, a coin flip
recorded as a hit.
Reaches llama-embed from inside the cluster by default, since that Service has
no ingress by design.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:28:31 +02:00
217 changed files with 9815 additions and 3394 deletions
MODEL=$$(sh scripts/ci/pick-model.sh "distill raw source notes into atomic wiki notes: read sources, check the wiki for existing coverage, write or merge notes; careful writing, moderate reasoning")
if [ -n "$$MODEL" ]; then echo "distill: selected model $$MODEL"; else echo "distill: using the CLI default model"; fi
cd notes
cat ../ci/agents/wiki-maintenance/distill.md | claude $${MODEL:+--model $$MODEL} \
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes); the homelab and homeassistant repositories are checked out under ~/source/. `kubectl` reads the live cluster with a read-only identity.
Run the wiki-consolidate agent now for today's scheduled consolidation pass. Follow its instructions exactly: snapshot the vault to git first, abort if that fails, select today's rotating cohort of 20 from the state file, verify claims against both their sources and the live systems, act on the verdicts, write the dated report to ~/Notes/output/, update the state file, and snapshot again at the end. Do not push; the pipeline pushes the run's commits.
Report the counts and the coverage progress when you are done.
You are running as the scheduled wiki-maintenance job in CI. The Notes vault is checked out at ~/Notes (the repository k.renner/notes). Do exactly this, nothing else:
Glob ~/Notes/raw/*.md (top level only, never ~/Notes/raw/_processed/). Distill every file you find into ~/Notes/wiki/ following your process, including the ranker check for every concept. Move each fully processed source to ~/Notes/raw/_processed/. If raw/ has no top-level files besides _index.md, say so and stop.
Do not commit or push; the pipeline does that after you finish. Do not touch anything outside ~/Notes/wiki/ and ~/Notes/raw/.
End with one line per file: which wiki notes it produced or merged into, or "nothing distillable".
(later: Database, Ollama/Onnx, local Jev, pgvector/Qdrant, OpenAI-compatible answer models)
```
Rules that keep it modular, enforced by project references and a test:
-`Wiki.Core` references nothing. It holds the domain types and the port
interfaces. Adapters reference Core only; they never reference each other
or the host. `Wiki.Host` is the only project that knows which adapters
exist.
- Every port has a **contract test suite** in `test/Wiki.Contracts` that
runs against every adapter of that port. A new adapter is done when the
suite is green. That is what makes "swap the storage" a safe operation.
- Adapters are selected by configuration and registered by name (keyed
services), so several can be active at once. The UI's lab page and the
evaluator use that to compare, the API uses the one marked active.
- Nothing in Core or Application knows about HTTP, Gitea, TypeSafe, files or
Kubernetes.
### Ports
| port | what it abstracts | first adapter | examples of the next ones |
|---|---|---|---|
| `INoteStore` | read notes, raw sources and reports; enumerate by kind; observe changes (content hash per document) | Gitea repository API (`k.renner/notes`, blob SHA) | git checkout on a volume; a database (CNPG Postgres) with the vault imported; an Obsidian sync folder |
| `IFactSink` | append a new raw source with provenance | Gitea contents API, new file on `main` | database insert; git commit on a checkout |
| `IEmbeddingModel` | text → vector, with a model id and dimension | llama-server `/v1/embeddings` (bge-m3 via wiki-embed, the model that won the German-document comparison on accuracy per cost) | the qwen3 embedding variants from that comparison; ONNX in-process; any OpenAI-compatible endpoint |
| `IVectorIndex` | store vectors per (model id, document), query top-k | in-memory cosine with a file snapshot per model, as today | pgvector in the database; Qdrant |
| `IChunker` | split a document into addressable units for typed questions | paragraph splitter (headings, fences), as in `wiki_ask.py` | sentence windows; semantic chunks |
| `IJudge` | typed questions over a state: Choice, Noul, Score, with probabilities | TypeSafe System One (`jev-latest`, cloud; the wiki records no locally hosted Jev today) | a locally hosted Jev with the same request shape, should TypeSafe offer one; a constrained-output local LLM adapter, flagged as uncalibrated |
| `IAnswerModel` | prose from a question plus evidence: the selected paragraphs with their notes and the judgements; must cite by slug; streaming | Anthropic Messages API (Claude, model from config, Sonnet by default) | any OpenAI-compatible endpoint, including the local llama-server; a different Claude model per question |
| `IEvaluator` | measure a retrieval or judge configuration against the labelled set (questions and cited sources in `output/`) | recall@k, hit rate, MRR, as `wiki_rank_eval.py` | judge accuracy against hand-labelled paragraphs |
| `IIdentity` | who is calling, for attribution of facts and saved answers | Authentik proxy headers (humans), API key (machines), as in shelly-manager | OIDC bearer tokens |
Two ports deserve a sentence of scope each. The judge returns judgements
with probabilities, never prose, and the UI never presents its output as an
answer, only as ranked, quoted evidence. The answer model turns that
evidence into words and is allowed nothing else: it sees the selected
paragraphs and the judgements, not the whole context, and its output is
shown next to the evidence it was given. Both are single model calls inside
the request. The service runs no agent: no tool loop, no vault edits. The
wiki-synth agent in a Claude session remains the path for artifacts and for
multi-question synthesis, and it can call `/questions` itself.
### Solution layout
```
Wiki.slnx
src/
Wiki.Core/ domain types, ports, frontmatter and link parsing
| `POST /api/v1/rank` | `{question, k, model?}` → ranked slugs; also served at `/rank` for the existing scripts |
| `POST /api/v1/ask` | `{queries, questions, k, judge?, model?}` → context, typed judgements with the answering paragraphs verbatim, `covered`. For agents and code. |
| `POST /api/v1/questions` | `{question, k?, judge?, model?, answerModel?, save?}` → the answer text with `[[slug]]` citations, the evidence (paragraphs, probabilities), `covered`, model and token usage; streamed as server-sent events when requested; with `save`, the `output/` path and commit |
| `POST /api/v1/facts` | deposit a raw source: title, source, `kind: fact`, observed_at, content, tags → path and commit |
| `GET /api/v1/raw` | the distill queue with ages |
| `GET /api/v1/reports` | `output/` documents by type, newest first |
| `POST /api/v1/lab/eval` | run the evaluator for a (model, judge) pair |
| `POST /webhook` | Gitea push, HMAC as today |
`POST /api/v1/facts` is rejected without `source`, with a `kind` other than
`fact`, or with content under a minimum length. The raw rule is enforced by
shape: there is no field for a hypothesis. The file gets the vault's
provenance frontmatter with `captured_by` set from the API key's identity.
`POST /api/v1/ask` is `wiki_ask.py` moved server-side, so the scripts become
thin clients and agents no longer need a vault checkout. Its result is
judgements and quotations, never prose.
`POST /api/v1/questions` is `/ask` plus one answer-model call, in process:
rank the question, build the context, run the default typed questions
(answered, where, covered), hand the selected paragraphs and judgements to
the answer model, stream the prose back. Below the `covered` threshold it
returns the gap instead of an answer. Time is the sum of the parts, about
0.2 s rank, 1 to 4 s judge, a few seconds of generation. Cost is one model
call over a few thousand tokens, a few cents on Sonnet, against a quarter
dollar for a full agent run.
## Auth and network
- Humans: `wiki.renner.uno`, LAN-only (no `dns.renner.uno/public` label),
Ingress backend `ak-outpost-wiki-gate:9000`, a `wiki-gate-egress` CNP in
`authentik`, the proxy-header handler trusts `X-Authentik-*` only from that
outpost.
- Machines: `wiki-index.renner.uno` with the API-key handler; the workstation
pod as environment variables from Kubernetes Secrets filled by
ExternalSecret from the `openbao` ClusterSecretStore. The pod itself has
no OpenBao access. The Python service fetched its secrets from OpenBao
directly through its own Kubernetes-auth role; that role, its policy and
the `ai` → `openbao` CNP entry are retired with it.
- Egress from the pod: Gitea, wiki-embed, `api.typesafe.ai:443`,
`api.anthropic.com:443`. Every
further adapter that talks to a new endpoint gets its own CNP line, which
is the one place adding an adapter touches the cluster.
## Health endpoints
Three endpoints with three different meanings, wired to the .NET health
checks with tags so that the probes and the humans see the right thing:
| endpoint | meaning | probe |
|---|---|---|
| `GET /health` | the process serves HTTP. Always 200 once Kestrel listens; no dependency checks. | liveness |
| `GET /ready` | the active note store has synced at least once and the active index is loaded; 503 while the first sync or index build runs. A failed later sync keeps the last good state and stays ready. | readiness, plus a startup probe with enough budget for the first sync (the Python service was killed mid-build when its `/health` returned 503 during the initial sync) |
| `GET /api/v1/status` | the full picture for humans and the Settings page: index commit and age, counts, queue lengths, and one entry per registered adapter with its last check result and latency. | none |
# Eviction protection for the single-replica stateful paperless sidecars.
#
# protonmail-bridge and syncthing each hold state on a single RWO PVC and run
# replicas:1. minAvailable:1 makes the eviction API refuse voluntary evictions,
# so a node drain blocks and surfaces these pods instead of evicting them
# abruptly. Involuntary disruptions (node crash) are not affected.
#
# NOTE: the other single-replica paperless pods (paperless, redis, gotenberg,
# tika, ftp-scanner) are intentionally omitted — they are caches / stateless
# converters where a blocking PDB would only wedge node drains with no
# recoverability benefit.
apiVersion:policy/v1
kind:PodDisruptionBudget
metadata:
name:protonmail-bridge
namespace:paperless
labels:
app.kubernetes.io/name:protonmail-bridge
spec:
minAvailable:1
selector:
matchLabels:
app.kubernetes.io/name:protonmail-bridge
---
apiVersion:policy/v1
kind:PodDisruptionBudget
metadata:
name:syncthing
namespace:paperless
labels:
app.kubernetes.io/name:syncthing
spec:
minAvailable:1
selector:
matchLabels:
app.kubernetes.io/name:syncthing
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.