Compare commits

...
Author SHA1 Message Date
claude-botandClaude Fable 5.1 ebb214f290 ci(kaniko): build-vmtest on the maintained fork like every other step
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#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>
2026-09-07 12:08:57 +02:00
claude-botandClaude Fable 5.1 c9ec97f6b6 feat(netbird): controller for reverse-proxy services from public Ingresses
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/push/build-ci-images Pipeline failed
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-07 12:02:23 +02:00
k.renner 57b2485487 Merge pull request 'fix(ceph): state the cluster CIDR — host-device leaves Rook's canary no NIC' (#807) from ceph-address-ranges into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#807
2026-09-07 05:22:30 +00:00
claude-botandClaude Fable 5.1 49c33b9b0f fix(ceph): state the cluster CIDR — host-device leaves the canary no NIC
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-07 07:21:36 +02:00
k.renner cbd47596bf Merge pull request 'fix(talos): answer ARP only on the link that owns the address' (#804) from fix/arp-flux-second-nic into master
Reviewed-on: k.renner/homelab#804
2026-09-07 05:17:09 +00:00
k.renner 99827fb776 Merge pull request 'fix(ceph): OSD raw volume label must not contain "ceph" (ceph-volume rejects it)' (#803) from osd-partlabel-no-ceph into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#803
2026-09-07 05:15:17 +00:00
k.renner 19f8d5bc51 Merge pull request 'fix(infrastructure): LAPI ServersTransport behind the Traefik CRDs (phase 1/2, add)' (#802) from lapi-transport-post-crds into master
Reviewed-on: k.renner/homelab#802
2026-09-07 05:00:59 +00:00
k.renner a88d3cd224 Merge pull request 'feat(ceph): hand the SAN NIC to the OSD pod (rollout 2/2, maintenance window)' (#801) from san-host-device-nad into master
ci/woodpecker/cron/e2e Pipeline is running
ci/woodpecker/cron/update-audit Pipeline is running
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#801
2026-09-07 04:51:00 +00:00
claude-botandClaude Fable 5.1 4d629bfd66 fix(talos): answer ARP only on the link that owns the address
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 23:46:51 +02:00
claude-botandClaude Fable 5.1 93c0e33dac fix(ceph): OSD raw volume label must not contain "ceph"
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 23:43:25 +02:00
claude-botandClaude Fable 5.1 c595451548 fix(infrastructure): LAPI ServersTransport behind the Traefik CRDs (phase 1: add)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 23:24:46 +02:00
k.renner 078b250fbd Merge pull request 'feat(talos): name the SAN NIC by its role on every site (rollout 1/2, inert)' (#800) from san-role-rename into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#800
2026-09-06 21:11:47 +00:00
claude-botandClaude Fable 5.1 d31d07ef87 feat(ceph): hand the SAN NIC to the OSD pod (host-device)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 22:54:39 +02:00
claude-botandClaude Fable 5.1 05b20ec74c feat(talos): name the SAN NIC by its role on every site
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 22:54:19 +02:00
security-bot 1e4bb9ef8a chore(image-automation): bump quay.io/ceph/ceph:v20.2.4
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-06 20:20:39 +00:00
security-bot da67da70ee chore(image-automation): bump quay.io/ceph/ceph:v19.2.6
Updates managed by image-automation-controller.
2026-09-06 20:20:34 +00:00
k.renner f4795ff3f1 Merge pull request 'feat(ceph): deliberate cutover to Ceph 20.2.1 (Tentacle)' (#799) from ceph-20-cutover into master
Reviewed-on: k.renner/homelab#799
2026-09-06 20:19:42 +00:00
claude-botandClaude Opus 5 35e3aead0f feat(ceph): deliberate cutover to Ceph 20.2.1 (Tentacle)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 22:15:18 +02:00
k.renner afad580452 Merge pull request 'fix(rebuild): images and CNI pods must survive a cluster without Gitea' (#797) from rebuild-image-resilience into master
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#797
2026-09-06 19:01:36 +00:00
k.renner 41dbcb67c9 Merge pull request 'refactor(flux): CRD consumers move behind the CRDs they need' (#796) from apply-set-hygiene into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#796
2026-09-06 18:56:09 +00:00
k.renner 3dcb4ec320 Merge pull request 'feat(tooling): Hetzner throwaway site and rebuild-harness hardening' (#798) from rebuild-tooling into master
Reviewed-on: k.renner/homelab#798
2026-09-06 18:49:20 +00:00
claude-botandClaude Opus 5 a4b659c965 feat(tooling): Hetzner throwaway site and rebuild-harness hardening
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 20:47:45 +02:00
claude-botandClaude Opus 5 939028fadd refactor(flux): CRD consumers move behind the CRDs they need
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 20:47:01 +02:00
k.renner b1345a4662 Merge pull request 'fix(flux): four cold-rebuild ordering deadlocks' (#795) from cold-rebuild-ordering into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#795
2026-09-06 18:44:58 +00:00
claude-botandClaude Opus 5 26ae750219 fix(rebuild): images and CNI pods must survive a cluster without Gitea
ci/woodpecker/push/build-ci-images Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 20:42:19 +02:00
claude-botandClaude Opus 5 ac16e53098 fix(flux): four cold-rebuild ordering deadlocks
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 20:23:05 +02:00
k.renner 19824a176a Merge pull request 'fix(bootstrap): honour a caller-supplied VM_STATE in vmtest' (#792) from fix/bootstrap-honour-vm-state into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#792
2026-09-06 18:00:02 +00:00
claude-botandClaude Opus 5 eec1f36c44 fix(bootstrap): honour a caller-supplied VM_STATE in vmtest
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 11:28:06 +02:00
k.renner 7ef86e3948 Merge pull request 'chore(paperless-eval): remove the namespace and its whole footprint' (#791) from chore/remove-paperless-eval into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#791
2026-09-06 08:46:27 +00:00
claude-botandClaude Opus 5 4c723015ed chore(paperless-eval): remove the namespace and its whole footprint
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 10:41:51 +02:00
k.renner a467e2925c Merge pull request 'fix(talos): give nodes an IPv6 InternalIP from the mgmt ULA' (#790) from issue-fix/789-cilium-node-ula into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#790
2026-09-06 08:08:43 +00:00
claude-botandClaude Opus 5 6b6258aa5e fix(talos): give nodes an IPv6 InternalIP from the mgmt ULA
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 10:03:11 +02:00
k.renner d071be1c8a Merge pull request 'fix(mdns-relay): supervise socat so a briefly absent target is not fatal' (#788) from issue-fix/787-mdns-relay-supervisor into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#788
2026-09-06 07:56:39 +00:00
claude-botandClaude Opus 5 47e3d30870 fix(mdns-relay): supervise socat so a briefly absent target is not fatal
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-06 09:50:35 +02:00
k.renner b3278290c2 Merge pull request 'fix(trmnl): enroll terminus and the renderer in image-automation' (#786) from trmnl-firmware-sync-off into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#786
2026-09-05 18:49:43 +00:00
claude-botandClaude Opus 5 66a9e5ff52 fix(trmnl): enroll terminus and the renderer in image-automation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-05 20:44:58 +02:00
k.renner b0b1527247 Merge pull request 'feat(trmnl): serve the device endpoint over TLS' (#785) from trmnl-device-tls into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#785
2026-09-05 15:24:50 +00:00
claude-botandClaude Opus 5 aa1af65807 feat(trmnl): serve the device endpoint over TLS
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-05 17:20:46 +02:00
security-bot da11c1cb5e chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.1
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-05 13:24:29 +00:00
k.renner 784c0d460a Merge pull request 'feat(scripts): retrieval eval for embedding models on German documents' (#743) from embed-eval into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#743
2026-09-05 07:33:11 +00:00
k.renner 19dbd05783 Merge pull request 'fix(paperless-eval): three faults the first job run exposed' (#778) from eval-script-fixes into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#778
2026-09-05 07:29:40 +00:00
claude-botandClaude Opus 5 8123021ea4 fix(paperless-eval): say whether the probe is loading or just not scheduled
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 16:03:23 +02:00
claude-botandClaude Opus 5 1ec6825b66 feat(paperless-eval): erase the slot between documents so failures are independent
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 15:44:45 +02:00
claude-botandClaude Opus 5 3f50fa4e60 fix(paperless-eval): check the instrument at the end too, not only on failure
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 15:31:07 +02:00
claude-botandClaude Opus 5 f54b1dc7fa fix(paperless-eval): test for the defect, not for the right answer
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 15:13:02 +02:00
claude-botandClaude Opus 5 63ae57ce6e fix(paperless-eval): the probe read one output field and failed a working model
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-04 15:11:13 +02:00
claude-botandClaude Opus 5 73db641d47 chore(paperless-eval): re-enable the rejected candidates as probe targets
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 15:02:33 +02:00
claude-botandClaude Opus 5 220bf6bfc3 feat(paperless-eval): check the instrument, don't assume it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-04 15:01:45 +02:00
claude-botandClaude Opus 5 85768cd7e4 feat(paperless-eval): probe the length boundary — the test that should have been first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 14:52:22 +02:00
claude-botandClaude Opus 5 5f2955cdde docs(paperless-eval): the Q8 comparison was invalid, and why
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 13:56:33 +02:00
claude-botandClaude Opus 5 7136a39f3a fix(paperless-eval): the client timeout is a second bound, and it hid the first
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-04 13:53:50 +02:00
claude-botandClaude Opus 5 4ba68b91aa fix(paperless-eval): 768 is my number, not the model's — make it settable
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 13:46:02 +02:00
claude-botandClaude Opus 5 47ed4383f1 docs(paperless-eval): the size threshold survives a deliberate attempt to kill it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 13:36:43 +02:00
claude-botandClaude Opus 5 ec849f2e29 docs(paperless-eval): glm47-flash is out on speed, and settles nothing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 12:50:58 +02:00
claude-botandClaude Opus 5 43634e46c9 fix(paperless-eval): the startup budget was sized for the wrong thing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 12:31:03 +02:00
claude-botandClaude Opus 5 7658cf8bb6 docs(paperless-eval): precision is nearly free on this MoE, and my formula was wrong
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 12:19:58 +02:00
claude-botandClaude Opus 5 1ae0e59ea6 docs(paperless-eval): qwen3.5-35b-a3b passes, killing the generation theory
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 12:04:33 +02:00
claude-botandClaude Opus 5 faebeb1594 fix(paperless-eval): the crashloop was a full disk, not an ambiguous reference
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 11:48:14 +02:00
claude-botandClaude Opus 5 baf3b3e1b7 fix(paperless-eval): name the exact .gguf, the :QUANT shorthand is ambiguous
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-04 11:45:00 +02:00
security-bot a358e8cfd5 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.2
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-04 09:22:42 +00:00
claude-botandClaude Opus 5 319968c0f1 docs(paperless-eval): the MoE hypothesis is dead too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 08:41:16 +02:00
claude-botandClaude Opus 5 043d1af4d4 feat(paperless-eval): four more MoE candidates, and Gemma was wrongly excluded
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 08:30:45 +02:00
claude-botandClaude Opus 5 11b0def055 docs(paperless-eval): five dense models fail, the one MoE works
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 08:17:14 +02:00
claude-botandClaude Opus 5 71a4c1b3a7 feat(paperless-eval): measure what Q4 costs, instead of assuming it costs nothing
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 07:57:42 +02:00
claude-botandClaude Opus 5 0c46a219e3 feat(paperless-eval): eleven verified candidates, and what the search ruled out
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 07:51:35 +02:00
claude-botandClaude Opus 5 17e23202d1 feat(paperless-eval): second wave of candidates, and the size rule was backwards
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-04 07:50:05 +02:00
k.renner b8116a1cf0 Merge pull request 'fix: [alert] KubeDeploymentRolloutStuck (ai) (#750)' (#753) from alert-fix/issue-750 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#753
2026-09-04 05:44:34 +00:00
k.renner 3563a9e67e Merge pull request 'fix: [alert] KubeHpaMaxedOut (authentik) (#761)' (#763) from alert-fix/issue-761 into master
Reviewed-on: k.renner/homelab#763
2026-09-04 05:43:40 +00:00
k.renner 088fce90a4 Merge pull request 'fix: [alert] VeleroBackupStale (velero) (#780)' (#781) from alert-fix/issue-780 into master
Reviewed-on: k.renner/homelab#781
2026-09-04 05:43:05 +00:00
claude-botandClaude Opus 5 851e1b9ef6 feat(paperless-eval): screen candidates at five documents before measuring
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 07:24:24 +02:00
claude-botandClaude Opus 5 f6b0789c01 feat(paperless-eval): both remaining candidates measured, and a noise floor
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-04 06:29:50 +02:00
security-bot 9ee8cfb4bc chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.1.3
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Updates managed by image-automation-controller.
2026-09-04 03:21:52 +00:00
issue-fix-bot 325baf7047 fix: [alert] VeleroBackupStale (velero) (#780)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-03 20:47:37 +00:00
claude-botandClaude Opus 5 44636a8268 docs(paperless-eval): ministral8b fails, cleanly this time
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 22:04:11 +02:00
claude-botandClaude Opus 5 440cbaec90 docs(paperless-eval): the conversion was not the whole story
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 21:54:06 +02:00
claude-botandClaude Opus 5 b64f971373 fix(paperless-eval): two concurrent runs were revoking each other's token
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 21:42:20 +02:00
claude-botandClaude Opus 5 9ff85f7d1f fix(paperless-eval): probe at production prompt length too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
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>
2026-09-03 21:39:24 +02:00
claude-botandClaude Opus 5 22d92d158d feat(paperless-eval): probe tool calling in two minutes, not ninety
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was canceled
"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>
2026-09-03 21:36:59 +02:00
security-bot a667920bb0 chore(image-automation): bump ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.15.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-03 19:22:34 +00:00
security-bot 53fe8efa02 chore(image-automation): bump ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.15.0
Updates managed by image-automation-controller.
2026-09-03 19:21:48 +00:00
claude-botandClaude Opus 5 b00ca34783 fix(paperless-eval): abort a run once it is clear nothing works
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 21:15:05 +02:00
claude-botandClaude Opus 5 c2f47e58bc fix(paperless-eval): --reasoning off, not --chat-template-kwargs
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 20:58:30 +02:00
claude-botandClaude Opus 5 a7bba7acbb feat(paperless-eval): one manifest, a candidate list, and the first result
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 20:14:15 +02:00
claude-botandClaude Opus 5 cb751cbedc fix(paperless-eval): bound generation server-side; the model does not always stop
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 14:51:56 +02:00
claude-botandClaude Opus 5 34c250ad2a fix(paperless-eval): two more, found by actually running it
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 11:08:46 +02:00
claude-botandClaude Opus 5 e261089666 fix(paperless-eval): three faults the first job run exposed
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 11:01:19 +02:00
k.renner c3a5662f72 Merge pull request 'fix(llama-server): the limit is prompt length; take the eval harness out of GitOps' (#777) from eval-out-of-iac into master
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#777
2026-09-03 08:57:34 +00:00
claude-botandClaude Opus 5 c47c2e6bc1 refactor(paperless-eval): take the measurement harness out of GitOps
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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>
2026-09-03 10:52:58 +02:00
claude-botandClaude Opus 5 d4e1f49d78 docs(llama-server): the limit is prompt length, not --ctx-size
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>
2026-09-03 10:52:04 +02:00
k.renner f7f041e71f Merge pull request 'feat(paperless-eval): run a measurement as one self-contained job' (#776) from paperless-eval-jobs into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#776
2026-09-03 07:51:59 +00:00
claude-botandClaude Opus 5 5ccf9672af chore(paperless-eval): drop a __pycache__ left by a syntax check
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Committed by accident from running py_compile against eval.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 09:45:03 +02:00
claude-botandClaude Opus 5 55dcd60fa8 feat(paperless-eval): run a measurement as one self-contained job
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>
2026-09-03 09:44:51 +02:00
k.renner 92dbb4873b Merge pull request 'fix(paperless-eval): admit the namespace to OpenBao, restore the capabilities' (#775) from paperless-eval-fixes into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#775
2026-09-03 06:55:51 +00:00
claude-botandClaude Opus 5 61bf321b52 fix(paperless-eval): admit the namespace to OpenBao, restore the capabilities
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The instance deployed and got nowhere. Both ExternalSecrets reported
"could not get secret data from provider", so hetzner-s3-creds never
existed, and the CNPG restore job died on it:

    can't get credentials for cluster pprleval-pg: while getting secret
    hetzner-s3-creds: secrets "hetzner-s3-creds" not found

The openbao ClusterSecretStore carries an explicit namespace allowlist in
spec.conditions, and paperless-eval was not on it. Nothing in the
manifests shows that — it is cluster state, which is why neither the
review nor `kubectl kustomize` caught it, and why the failure only
appeared on deploy.

The paperless container also gets production's securityContext back. The
review was right about this and it was dismissed as being about the
scanner and mail bridge sidecars, which those manifests were only cited
as evidence for the pattern. The paperless-ngx entrypoint starts as root,
chowns the mounted volumes and drops to uid 1000 via setgid/setuid, so
drop: ["ALL"] with nothing added fails that sequence with EPERM. It would
have bitten as soon as the restore succeeded and the pod actually tried
to start — and harder here than in production, whose volumes were chowned
once and kept it, while these are fresh on every rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:53:04 +02:00
k.renner a8e981b27a Merge pull request 'feat(paperless-eval): a disposable copy to measure the LLM against' (#774) from paperless-eval-instance into master
Reviewed-on: k.renner/homelab#774
2026-09-03 06:48:54 +00:00
claude-botandClaude Opus 5 b18ed71361 fix(paperless-eval): open the other half of the embedding path
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The egress grant to llama-embed was there; the matching ingress in
ai-ns.yaml was not, so Cilium would have dropped every embedding call —
the one dependency an eval has outside its own pod. The same pairing was
done correctly for home-assistant in #759 and simply missed here.

Scoped to the namespace rather than an app label, unlike the grants
around it. That namespace exists only to run evaluations, and its
workload is jobs created per measurement whose labels vary with what is
being measured; a label match would present later as an embedding call
silently dropped.

Also from the review:

secret.reloader annotation alongside the configmap one, matching
production. The secret refreshes on the same 10m cycle, and without the
annotation a rotated key would sit unused until something else restarted
the pod.

The retention argument in cluster.yaml was too thin, and checking it made
it worse before it made it better: the plugin runs retention from the
instance sidecar on a timer
(instanceSidecarConfiguration.retentionPolicyIntervalSeconds, default
1800), not as part of a backup — so "this cluster never backs up" would
not have covered it.

What does cover it is a second property: retention runs
barman-cloud-backup-delete, which takes a server name and operates inside
<destinationPath>/<serverName>. The production store's status keys its
recovery window by "pprl-pg", confirming that layout; this cluster is
pprleval-pg, so even an accidental retention policy would address an
empty prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:34:49 +02:00
claude-botandClaude Opus 5 940d3f15ad fix(paperless-eval): give the first boot room to build its index
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The startupProbe was production's 10 minutes, and production sized that
budget for exactly this case — a from-scratch boot on the same ~3900
documents, per its own comment — so it would probably have held. The
difference is frequency, not size: production's volume is Retain and
keeps the index, making the expensive path a one-off, while this one is
ephemeral and rebuilds on every re-test. A killed pod restarts the work
rather than resuming, so a slow boot becomes a crash-loop that stalls
Flux behind wait: true. 60 minutes now, matching the Kustomization.

The three-hour LLM index is not in that budget and was never at risk: it
is a celery task, and boot only runs init-llmindex-migrate.

Also from the review:

The shared secret refreshes at 10m like production rather than 1h. The
point of sharing the key is that both hold the same value, which a slower
refresh would undo for an hour after a rotation.

The data PVC comment carries measured numbers instead of an unexplained
10Gi: 36 MiB search index and 125 MiB LLM index on production for the
same corpus. The margin is deliberate — phase two varies the embedding
model, and one with wider vectors than bge-m3's 1024 dimensions would
grow that index.

The ApplicationConfiguration warning now carries the query to check it
with. That row arrives in the restore and silently overrides the
ConfigMap, so an eval could measure production's settings while appearing
to measure the job's. Currently all four columns are null on production,
so the environment does apply — but that is a fact about today, not a
property of the design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:15:35 +02:00
claude-botandClaude Opus 5 3ea3791f05 fix(paperless-eval): load the barman plugin, admit the CNPG operator
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both blocking findings from the review, and both were already written
down in this repo.

kubernetes/RECOVERY.md:198-201 says the barman plugin MUST be declared
for bootstrap.recovery, with isWALArchiver: false to keep it read-only —
and notes that an earlier version of that runbook said to omit it, which
fails at instance start with "Unknown plugin:
barman-cloud.cloudnative-pg.io". externalClusters only says where the
backup is; it does not get the plugin injected into the pod. So the
read-only guarantee comes from the flag, not from leaving the plugin
out, and the previous commit would not have restored at all.

The namespace also never admitted the database namespace. The CNPG
operator dials each instance at https://<pod-ip>:8000/pg/status, and
without it the Cluster reports "Instance Status Extraction Error" while
Postgres serves normally (issue #433). Every other CNPG-backed namespace
policy here carries the rule; this one is scoped the same way, to the
pprleval-pg endpoints and that one port.

Storage moves to ceph-block-ephemeral for all five volumes while fixing
the first point, because RECOVERY.md makes the same choice for the same
reason: ceph-block is Retain, so a rebuilt-on-demand copy would leave
Released PVs and orphaned RBD images behind every time (issue #262).

The images keep their pinned tags without $imagepolicy markers, now with
a comment saying so: a measurement is comparable only against a fixed
version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 08:07:11 +02:00
claude-botandClaude Opus 5 e7a94be54b feat(paperless-eval): a disposable copy to measure the LLM against
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Choosing a chat model, and later an embedding model, means running the
same 50 documents through each candidate. That has been happening against
the live archive: the embedding model was swapped under it, the LLM index
rebuilt twice, the chat server broken for an afternoon, and the UI left
spinning while an eval held the only GPU slot. None of that belongs there.

The database is 90 MB and the AI path never opens a file — it reads
title, content and filename out of Postgres — so a copy costs a restore
and an empty media volume rather than a second copy of the documents.

Restores from paperless's backup store and archives nothing back. Three
omissions carry that, rather than any setting pointing somewhere safe:

  no spec.plugins       the cluster has no barman plugin at all, so no
                        WAL is archived
  no retentionPolicy    the source ObjectStore points at the production
                        prefix; with a retention policy this namespace
                        could expire paperless's backups
  no ScheduledBackup    nothing here is worth keeping

pg_basebackup would have avoided touching the store at all, but it needs
the production cluster's credentials, and those live in a Secret in the
paperless namespace that this one cannot read.

Everything that exists to serve people is left out: no ingress, no gate
outpost, no hostname, no remote-user auth, no Tika or Gotenberg, no mail
or FTP consumption. Nothing signs in and nothing is consumed, so the
instance is unreachable from outside the cluster and the documents are
not exposed a second time.

The deployment here is a smoke test — it proves the restore is usable and
that the search index builds. The evaluation runs as jobs that bring
their own paperless and llama-server, since a paperless instance can only
point at one chat endpoint and two candidates need to run at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:58:29 +02:00
k.renner 75f2269cb3 Merge pull request 'fix(crowdsec): poll the Traefik logs instead of watching them with inotify' (#773) from crowdsec-tailer-polling into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#773
2026-09-03 05:34:27 +00:00
claude-botandClaude Opus 5 d1aba106ce fix(crowdsec): poll the Traefik logs instead of watching them with inotify
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both agents stopped reading on 2026-08-27 and had read nothing since:
k8s-1 at 12:15:41Z, k8s-2 at 19:14:27Z, each frozen at its line count
(35449 and 29917) while Traefik kept logging normally.

Each had hit its first kubelet log rotation after starting. The paths
under /var/log/containers are symlinks into /var/log/pods, and kubelet
rotates the target at containerLogMaxSize; the inotify tailer reopens
the path, waits for a file that is already present, and never returns.
Nothing about the result looks broken: pods Running and Ready, LAPI
answering, bouncers pulling, cumulative counters still showing large
numbers. The only symptom is that no scenario fires again, which reads
as quiet rather than blind.

So this was not a one-off. Rotation happens roughly every eight hours
here, and the agents die on the first one after every start, which
bounds local detection to a few hours at a time.

CrowdSec diagnoses this itself and names the fix, at agent startup:

  File /var/log/containers/traefik-...log is a symlink, but inotify
  polling is enabled. Crowdsec will not be able to detect rotation.
  Consider setting poll_without_inotify to true in your configuration

Upstream carries the identical case as crowdsec#2449 (k3s + Traefik),
and the chart gained this field in helm-charts#109 to fix it. It does
not conflict with the force_inotify: true the chart hardcodes: that
governs the fsnotify watch on the directory, used to discover the log
of a replacement Traefik pod, while this governs how an already
discovered file is read. Verified by rendering the chart.

The acquisition ConfigMap is checksummed into the agent pod template,
so this rolls the DaemonSet and the stalled agents restart with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:17:59 +02:00
k.renner 87cc238d33 Merge pull request 'fix(llama-server): one slot, so 16k belongs to one request' (#771) from llama-single-slot into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#771
2026-09-02 19:34:27 +00:00
claude-botandClaude Opus 5 353b7882d7 fix(llama-server): one slot, so 16k belongs to one request
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Four Paperless requests overlapped and all four died:

    decode: failed to find free space in the KV cache
    srv decode: Context size has been exceeded.

They wanted 6209, 6472, 4303 and 1271 tokens from a shared pool of
16384. Auto slot count opens four of them and is also what enables
--kv-unified, so they draw on one buffer rather than a quarter each --
generous for a single caller, and impossible for four.

They overlapped because a client timeout does not stop the generation
behind it. The eval abandons a request after 300 s and moves to the next
document; the abandoned one keeps consuming the budget the next one
needs, and one slow document is enough to start the pile-up. With one
slot they queue instead, which is what should happen: nothing calls this
server in parallel on purpose, and Paperless's celery worker runs at
concurrency 1.

Deliberately no liveness probe alongside this. /health answered 200 in
0.4 ms throughout, which looks like the probe failing at its job -- but
the server drained the batch and served again by itself, so a probe that
restarted the pod would have turned a self-healing incident into an
outage. With a single slot, a probe asking for a real completion would
also queue behind live work and kill healthy pods mid-document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:31:05 +02:00
k.renner 403a8e0e90 Merge pull request 'fix(llama-server): back to 16k — 32k per slot corrupts inference' (#770) from llama-ctx-revert into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#770
2026-09-02 19:06:25 +00:00
claude-botandClaude Opus 5 5023a5971b fix(llama-server): back to 16k — 32k per slot corrupts inference
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#767 broke the model. Asked to classify a document and answer with JSON
only, the server returned 1025 tokens of "!" against a 650-token prompt.
That is the KV cache emitting numbers rather than language, and it takes
everything downstream with it: Paperless ran past its 300 s timeout on
every document, Home Assistant got an empty completion once and a
hallucinated list of devices that do not exist another time.

None of that looked like a backend fault from the outside. The server
starts, /props reports the requested size, /health passes, and the
OpenAI response is well-formed with finish_reason "stop". Only the
generated text gives it away, which is why three separate symptoms got
blamed on prompt size, on tokenizer behaviour and on the RAG context
before anyone read the output.

The boundary is between the two changes rather than at either: #765 left
16384 per slot and produced coherent answers; #767 raised it to 32768 and
produced this. The same Vulkan/Mesa stack already fails at --ubatch-size
2048 with ErrorDeviceLost, so a ceiling here is not a surprise -- only
that it degrades quietly instead of erroring.

--parallel goes with it. Unset, slots return to auto, which is what keeps
--kv-unified enabled ("if number of slots is auto"), so the auto slots
share one buffer and each may draw the full 16384.

Home Assistant needed 18910 tokens for one Assist request and cannot have
them at this ceiling. That path is closed, and Paperless -- which fits in
16k and never needed more -- is what this restores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 21:05:03 +02:00
k.renner 1951497175 Merge pull request 'feat(crowdsec): arm the bouncer everywhere, drop the per-host override' (#769) from arm-crowdsec-proxy-1 into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#769
2026-09-02 18:57:09 +00:00
security-bot ad32ab566b chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.0
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Updates managed by image-automation-controller.
2026-09-02 16:23:17 +00:00
claude-botandClaude Opus 5 b96260cef2 feat(crowdsec): arm the bouncer everywhere, drop the per-host override
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
proxy-2 has been enforcing since 14:21:54 without incident, so the role
default moves to nftables and the host_vars override goes away rather than
being duplicated for proxy-1. proxy-2's rendered config is unchanged by this,
so it will not restart; only proxy-1 does.

The staged rollout did its job and can be read off the deploy log:

  proxy-1: deployed in mode "dry-run"
  proxy-2: deployed in mode "nftables"

and only proxy-2 restarted, which showed up as a 1,887,882-byte startup pull
from its address alone.

The delivery path was tested in enforcing mode with a decision on 192.0.2.1
(TEST-NET-1): both hosts had the ban 3s later as a 205-byte delta, and the
revocation as a 203-byte delta 1-2s after deletion.

What is still not proven is that a packet is actually dropped, and the
measurement for that argues for finishing the rollout rather than waiting.
Requests from banned addresses reaching Traefik stopped after proxy-2 armed —
1 at 14:02, 3 at 14:07, 22 at 14:08, 1 at 14:18, none after — but 25 of those
27 arrived in a single burst beforehand, so at this volume that is consistent
with enforcement rather than evidence of it. With both hosts armed the claim
becomes falsifiable instead: no request from an address in the decision set
should reach Traefik at all, and a single one would prove otherwise.

dry-run remains available per host through inventory/host_vars, which is what
a newly added proxy should get before it enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:28:25 +02:00
k.renner 67b0fece93 Merge pull request 'fix(ci): deploy the proxies when their inventory changes, not just their roles' (#768) from fix-proxy-deploy-triggers into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#768
2026-09-02 14:19:21 +00:00
claude-botandClaude Opus 5 364cc01291 fix(ci): deploy the proxies when their inventory changes, not just their roles
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Arming the bouncer on proxy-2 (#766) merged and deployed nothing. The switch
lives in ansible/inventory/host_vars/proxy-2.renner.uno.yml, and the trigger
list covered ansible/roles/** and the play but no inventory path at all. The
change sat in git looking applied while the host kept running dry-run, and
nothing reported a problem — there was simply no pipeline run to fail.

The bouncer's LAPI key has the same shape: it lives in
group_vars/netbird_proxy/vault.yml, so a rotation would never have reached the
hosts either.

Both paths are now listed. pve_hosts.yml deliberately is not: it is the single
inventory file for the whole fleet, so listing it would redeploy the proxies
for any unrelated host edit. The cost is that a change to [netbird_proxy] group
membership needs a manual deployment run.

Merging this triggers the run itself, since .woodpecker/deploy-netbird-proxy.yml
is in its own trigger list — so the arming from #766 applies with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:18:03 +02:00
k.renner 91849e31ff Merge pull request 'fix(llama-server): --ctx-size is the total, so ask for 65536' (#767) from llama-ctx-fix into master
Reviewed-on: k.renner/homelab#767
2026-09-02 14:17:24 +00:00
claude-botandClaude Opus 5 021fd6a4ed docs(llama-server): cite the flag semantics instead of inferring them
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
`llama-server --help` documents both halves of what #765 got wrong, and
it ships in the image:

    -np, --parallel N   number of server slots (default: -1, -1 = auto)
    -kvu, --kv-unified  use single unified KV buffer shared across all
                        sequences (default: enabled if number of slots
                        is auto)

So leaving --parallel unset did not merely pick a slot count, it enabled
a shared KV buffer that every slot could draw the full --ctx-size from.
That is why this server reported n_ctx_slot 16384 across four slots, and
the startup log said so outright: kv_unified = 'true'. Naming a slot
count switches that off and splits the context.

The value stays 65536. Only the reasoning in the comment changes, from an
inference drawn off two /props readings to the documented behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:16:16 +02:00
k.renner c99e94cc0b Merge pull request 'feat(crowdsec): arm the bouncer on proxy-2' (#766) from arm-crowdsec-proxy-2 into master
Reviewed-on: k.renner/homelab#766
2026-09-02 14:15:42 +00:00
claude-botandClaude Opus 5 7e74b93fb2 fix(llama-server): --ctx-size is the total, so ask for 65536
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
#765 claimed --ctx-size was per slot and halved the slot count to pay for
doubling it. Both halves of that were wrong. With --parallel set,
llama.cpp divides --ctx-size across the slots, so 32768 over two slots
gave 16384 each -- exactly what was there before -- and the Assist
request fails with the same message it did then:

    request (18910 tokens) exceeds the available context size (16384)

The flag does behave differently without --parallel, which is where the
wrong reading came from: the four-slot server reported n_ctx_slot equal
to --ctx-size rather than a quarter of it. Adding --parallel changed
that, and the memory reading confirms it -- the pod fell from 24979 MiB
to 21601 MiB, so total KV halved rather than holding steady.

65536 across two slots is 32768 each, and puts total KV back where it was
before #765 along with the memory that came with it.

Verify rather than assume this time:

    kubectl -n ai exec deploy/llama-server -- curl -sS \
      http://127.0.0.1:8080/props | jq .default_generation_settings.n_ctx

must report 32768.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:13:35 +02:00
claude-botandClaude Opus 5 05cd7f3674 feat(crowdsec): arm the bouncer on proxy-2
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First host goes from dry-run to enforcing. proxy-2 deliberately: it is the
dual-stack member, while proxy-1 is IPv6-only and the one where every
network-shaped problem has surfaced first — the dhcpcd/resolv.conf breakage,
and a GitHub download that could not work at all. host_vars keeps the change
to one host; proxy-1 stays on the role default.

Verified before committing: ansible-inventory resolves nftables for proxy-2
and nothing for proxy-1.

Adds netbird.renner.uno to capi_whitelists first, and that is not decoration.
The bouncer's chain sits on the INPUT hook, the proxy hosts run no other
firewall, and there is therefore no conntrack rule accepting established flows
ahead of it. A ban on the management address would drop the return traffic of
the proxies' own outbound control connection and take them off the mesh, with
no inbound attack involved at all. The same reasoning already covers the proxy
addresses, which were listed from the start.

Checked at the time of writing: none of these addresses appear in the current
decision set — searched all ~15k entries. That is the state today, not a
guarantee; they are rented addresses on a hoster whose ranges do end up on
community blocklists, which is the whole reason for listing them.

The delivery path was tested end to end beforehand with a decision on
192.0.2.1 (TEST-NET-1, so no real traffic could be affected). Both bouncers
received the ban within one poll cycle — a 206-byte delta against the usual
26 — and the revocation the same way, 203 bytes, ~5s after deletion. What that
did not test is whether a packet is actually dropped, which dry-run cannot
show. This commit is what makes that testable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:13:11 +02:00
k.renner d655d65ad9 Merge pull request 'feat(llama-server): 32k context per slot for the Assist agent' (#765) from llama-ctx-assist into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#765
2026-09-02 14:09:07 +00:00
claude-botandClaude Opus 5 60a3da9447 feat(llama-server): 32k context per slot for the Assist agent
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The Home Assistant conversation agent is configured and refuses every
request:

    API error: request (18910 tokens) exceeds the available
    context size (16384 tokens), try increasing it

18910 breaks down as ~12k for the exposed-entity overview and ~7k for
the base instructions and tool schemas, whose parameters enumerate every
exposed name, area and floor. Trimming the exposed set from 845 entities
to 666 already took the overview from 16045 tokens to 12048; the
remainder is not the sort of thing curation reaches.

--ctx-size is per slot rather than the total, which is what makes this
affordable: /props reports n_ctx_slot equal to the flag while running
four slots, where a total would have divided down to 4096. Doubling the
flag alone would double the KV cache, so the slot count halves in step
and the allocation lands where it already is -- 24979 MiB against the
32Gi limit, most of it the 19.5 GiB of weights.

Two slots matches the actual callers: one holds Home Assistant's prompt,
one holds Paperless's, and prompt caching keeps each warm. A cold prefill
of this prompt costs about two minutes, so a third caller queueing is a
better outcome than it evicting one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:07:06 +02:00
k.renner 47086a5fe9 Merge pull request 'feat(monitoring): scrape CrowdSec and give it a dashboard' (#764) from crowdsec-metrics-dashboard into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#764
2026-09-02 13:59:21 +00:00
k.renner 412acf7cb4 Merge pull request 'feat(network-policies): let Home Assistant reach the chat model' (#759) from ha-llm-agent into master
Reviewed-on: k.renner/homelab#759
2026-09-02 13:59:00 +00:00
k.renner 75ecf020cc Merge pull request 'fix(llama-embed): size the batch for bge-m3 tokenizer, not Paperless' (#762) from embed-batch-fix into master
Reviewed-on: k.renner/homelab#762
2026-09-02 13:58:42 +00:00
claude-botandClaude Opus 5 6a65a5982a feat(monitoring): scrape CrowdSec and give it a dashboard
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
A dashboard first needed something to draw. Nothing scraped CrowdSec:
`up{namespace="crowdsec"}` returned zero series and Prometheus held no cs_*
metric at all, so the detection half of the ingress path was invisible —
including whether the agents were reading logs and whether any bouncer was
pulling decisions.

Both chart services already expose a `metrics` port on 6060 and the
crowdsec-ns CiliumNetworkPolicy already admits the monitoring namespace
without a port restriction. Only the ServiceMonitor was missing. One object
covers both services via matchExpressions, since they differ only in `app`.

Every panel query was written against label sets read off the live endpoints
rather than from the documentation:

  cs_active_decisions{action,origin,reason}
  cs_lapi_bouncer_requests_total{bouncer,method,route}
  cs_bucket_overflowed_total{name}
  cs_filesource_hits_total{acquis_type,datasource_type,source}
  cs_node_wl_hits_ok_total{...,reason,...}
  cs_parser_hits_ok_total / _ko_total

The bouncer panel is the one worth having: the LAPI counts requests per
bouncer, so a bouncer that stops pulling shows up as a line going to zero
rather than as nothing at all. It also makes the `<name>@<ip>` behaviour
visible — the LAPI registers a new entry per observed client address, and what
it observes is the netbird-ingress pod IP, so one logical bouncer appears
several times and gains new names as those pods roll.

The last panel is a text panel naming what the dashboard cannot show. The
bouncers' own metrics — fw_bouncer_active_decisions, dropped packets, their
view of the LAPI — sit on 127.0.0.1:60601 on public hosts with no metrics path
of any kind. So "Bouncer-Abholrate" proves a bouncer is asking, not that it
applied the answer. Better to say that on the dashboard than to let someone
read enforcement into a panel that does not measure it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:58:17 +02:00
k.renner 154d0328ce Merge pull request 'fix(crowdsec): move the LAPI backend annotations onto the Service' (#760) from crowdsec-lapi-service-annotations into master
Reviewed-on: k.renner/homelab#760
2026-09-02 13:48:51 +00:00
issue-fix-bot e0a00f6b05 fix: [alert] KubeHpaMaxedOut (authentik) (#761)
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-02 13:45:55 +00:00
claude-botandClaude Opus 5 0a101eb8ee fix(llama-embed): size the batch for bge-m3's tokenizer, not Paperless's
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The LLM index rebuild ran for 79 minutes and then died:

    input (1069 tokens) is too large to process.
    increase the physical batch size (current batch size: 1024)

Setting the batch to Paperless's chunk size looked like an exact match
and was not. Paperless counts its 1024 tokens with llama_index's
tokenizer; this server measures the batch in bge-m3's, which is
XLM-RoBERTa and splits German into more pieces. A chunk Paperless
considered full arrived here 4.4% over the limit, and the request failed
rather than truncating.

1536 absorbs a tokenizer ratio rather than a rounding error. 2048 stays
out of reach: that is where the chat model hit ErrorDeviceLost on this
Vulkan stack, under a 20 GB model's memory pressure that a 600 MB one may
well not reproduce — but there is no reason to go looking.

The index is left partial by the failed run and has to be rebuilt with
`document_llmindex rebuild` once this is live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:13:54 +02:00
claude-botandClaude Opus 5 f86f19045e fix(crowdsec): move the LAPI backend annotations onto the Service
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both bouncers reach the LAPI and pull every 10s, and every pull gets a 400:

  2a03:4000:28:f6e:...:9a29 "GET /v1/decisions/stream?..." 400 48
  2a03:4000:4b:c1e:...:8f85 "GET /v1/decisions/stream?..." 400 48

The body says what it is: "Client sent an HTTP request to an HTTPS server."
Traefik was talking plaintext to a backend that serves TLS only.

serversscheme and serverstransport were on the Ingress. Under the Kubernetes
Ingress provider `traefik.ingress.kubernetes.io/service.*` are SERVICE
annotations; on an Ingress they are silently ignored. Nothing warns, and the
failure surfaces as a 400 that reads like a malformed request rather than a
scheme mismatch. They move to the chart's Service via lapi.service.annotations.

With tls.enabled the LAPI serves HTTPS on 8080 and offers no plaintext port,
so https is mandatory here rather than hardening.

Also switches the ServersTransport from rootCAsSecrets to rootCAs, which is
what Traefik has been warning about on every reconcile:

  RootCAsSecrets option is deprecated, please use the RootCA option instead.

Two of my own diagnostics were wrong along the way and both were measurement
artifacts. "Traefik sees no requests" came from a Loki query whose limit was
consumed by those deprecation warnings. And the source address that justified
dropping the IPAllowList was measured on the watchdog, which curls 127.0.0.1;
the bouncer resolves the public name and arrives on the proxy's public
address, as the log lines above show.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:53:53 +02:00
claude-botandClaude Opus 5 7903c81c9a feat(network-policies): let Home Assistant reach the chat model
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
HA 2026.8.3 ships a native `llama_cpp` integration, so an Assist
conversation agent backed by the cluster's own model needs no custom
component — only a path from the HA pod to llama-server, which two
default-deny policies currently block in both directions. Verified from
inside the pod: the call to llama-server.ai.svc.cluster.local:8080 times
out today.

Both grants are scoped to the single pod and port, mirroring the
paperless grant next to them, so neither widens if something else is
placed in the other namespace. llama-embed is deliberately excluded: the
conversation agent uses chat completions only.

Tool calling was verified against the running model before adding this —
the Assist API requires it, and llama.cpp only exposes it for some
builds. b10731 returns finish_reason=tool_calls and parses German
commands into the right arguments without --jinja.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:18:30 +02:00
k.renner 116d124e2d Merge pull request 'fix(crowdsec): unpack the bouncer on the host, not on the controller' (#758) from crowdsec-bouncer-unpack-on-host into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline was successful
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#758
2026-09-02 11:52:46 +00:00
claude-botandClaude Opus 5 cf1237bf33 fix(crowdsec): unpack the bouncer on the host, not on the controller
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The download moved to the controller in the previous fix and now works. The
extraction that followed it does not:

  Failed to find handler for "/tmp/crowdsec-firewall-bouncer-v0.0.36.tgz".
  Command "/bin/tar" detected as tar type None. GNU tar required.

The ci-ansible image ships a tar that ansible's unarchive rejects. Both host
distributions have GNU tar, and only the download ever needed to move — the
v6-only problem was reaching github.com, not opening a tarball.

So the controller now ships the archive rather than the unpacked binary, and
the host extracts it. That also keeps the artefact the checksum was verified
against intact all the way to its destination, instead of verifying an archive
and then copying something extracted from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:46:23 +02:00
k.renner 4742b26047 Merge pull request 'feat(llama-embed): switch to bge-m3, activating the Paperless AI config' (#757) from embed-bgem3 into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#757
2026-09-02 11:41:19 +00:00
claude-botandClaude Opus 5 3bde2a4316 feat(llama-embed): switch to bge-m3, activating the Paperless AI config
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
First candidate to be carried through to an end-to-end measurement. The
embedding comparison on 400 real archive documents puts bge-m3 level with the
deployed Qwen3-Embedding-0.6B where it matters and ahead everywhere else:

  correspondent  74.0% vs 75.0%   (ceiling 82.5%, so 90% vs 91% of achievable)
  document type  70.0% vs 68.2%
  time           147s vs 380s for 400 documents
  peak memory    1.6 GiB vs 10.6 GiB

Same 1024 dimensions, so nothing downstream changes shape.

Note the pooling type differs — bge-m3 wants CLS where the Qwen family wants
last-token. Read from the model's own 1_Pooling/config.json rather than assumed;
across the candidates measured, three different pooling types were required, and
a wrong one produces plausible but untrained vectors with no error anywhere.

The memory limit is now measured rather than estimated. It also supersedes #745,
which raised the limit for the model being replaced: the footprint is dominated
by four slots at 4096 context plus the Vulkan working set, not by the weights —
which is why several candidates were OOMKilled at limits that looked generous
against their model size.

This is not a final answer. Every candidate has to go through the end-to-end
test (scripts/paperless-eval.sh) before the embedding choice means anything,
because nothing has yet verified that the retrieval metric predicts what
Paperless actually suggests. bge-m3 is simply the one to start with.

Changing paperless-config is also what lets that test run at all: the AI
settings from #739 have never reached the running pod, which predates the
Reloader annotation from #741. This edit gives Reloader something real to react
to, rather than forcing a restart by hand or inventing a change to trigger one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:39:36 +02:00
k.renner f38033e30a Merge pull request 'fix(crowdsec): fetch the bouncer on the controller, not on the host' (#755) from crowdsec-bouncer-fetch-controller into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline failed
Reviewed-on: k.renner/homelab#755
2026-09-02 11:33:52 +00:00
claude-botandClaude Opus 5 a5d8bf35a9 fix(crowdsec): fetch the bouncer on the controller, not on the host
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The deploy failed on proxy-1:

  Request failed: <urlopen error [Errno 101] Network is unreachable>
  url: https://github.com/.../crowdsec-firewall-bouncer-linux-amd64.tgz

proxy-1 is IPv6-only, and neither github.com nor
objects.githubusercontent.com publishes an AAAA record. The host cannot reach
GitHub at all, so no retry or timeout would have helped.

Two alternatives do not work either. Mirroring into gitea.renner.uno is out:
its public records are the internal LB VIP (10.2.99.86 / fd99::86), so it is
unreachable from outside the LAN. And there is no upstream container image for
this bouncer, which is expected of something that manipulates host netfilter —
crowdsecurity publishes cloudflare, fastly, custom and others, but no firewall
bouncer.

So the controller fetches and pushes instead of the host pulling. The CI runner
has v4. The checksum is still verified, now before anything reaches a host,
which is the better place for it anyway.

The repo had told me twice that proxy-1 is the v6-only member — in
unifi_nat_hairpin.tf and in the netbird-proxy role's dhcpcd comment — and I
wrote it into the PR description myself. I never asked whether a v6-only host
can reach GitHub.

Nothing was deployed before this: the play runs serial: 1, proxy-1 went first
and failed, so proxy-2 never started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:25:39 +02:00
k.renner 60cfcf9e7e Merge pull request 'feat(crowdsec): LAPI endpoint and key for the edge firewall bouncers' (#751) from crowdsec-edge-lapi into master
ci/woodpecker/push/deploy-netbird-proxy Pipeline failed
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#751
2026-09-02 11:15:56 +00:00
claude-botandClaude Opus 5 a88bebc76d fix(crowdsec): make Reloader watch the edge bouncer key too
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Found by the pr-review bot on #751, and it is the exact failure this design
was written to avoid.

BOUNCER_KEY_edge is consumed as an env var from a secretKeyRef, and env is
fixed at pod start. The Reloader annotation listed only crowdsec-bouncer-key,
so a rotation of the edge key would update OpenBao, sync into the Secret, and
reach the hosts through the Ansible play — while the LAPI kept the old value
in its environment and went on trusting the old key. The bouncers would then
present the new one and get 401s, at exactly the moment the rotation was
supposed to be seamless.

The comment directly above that annotation already stated the rule — "it's an
env-var secret, so a restart is required to pick up a rotated key". I added a
second env-var secret and did not extend the list.

Comma-separated, matching shelly-manager-deployment.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:08:16 +02:00
issue-fix-bot 1ab901d746 fix: [alert] KubeDeploymentRolloutStuck (ai) (#750)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
2026-09-02 10:44:39 +00:00
claude-botandClaude Opus 5 7897b50318 feat(crowdsec): vault the edge bouncer key for the proxy hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Completes the pair: the ExternalSecret feeds BOUNCER_KEY_edge to the LAPI, this
feeds the same value to the bouncers that authenticate against it.

A single encrypted value rather than a whole-file vault like its siblings in
group_vars/all and group_vars/edge_host. Appending to one of those means
decrypting it first, which exposes every other secret in that file to add one
line. encrypt_string keeps the blast radius at this value, and the plaintext
never reaches a file: it goes from `bao kv get -field=key` through a pipe
straight into the encryption.

Scoped to netbird_proxy rather than all, because only those hosts run a
bouncer.

Not enrolled in the quarterly rotator, unlike crowdsec-bouncer-key. Nothing on
these hosts re-reads OpenBao on a schedule, so a rotation there would leave
them 401ing against the LAPI until someone ran the playbook. Rotating this one
means updating OpenBao and re-running plays/install-netbird-proxy.yml, in that
order.

Verified with `ansible-inventory --list`: the inventory parses and the value
decrypts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 12:29:13 +02:00
claude-botandClaude Opus 5 22143c6a7c feat(crowdsec): firewall bouncer on the NetBird proxy hosts
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Enforces the decisions the in-cluster CrowdSec already detects, at the point
where public traffic actually enters. These hosts terminate TLS for every
*.renner.uno name, so a drop here never reaches the mesh, the relay or the
cluster. Detection stays where it is: the reverse-proxy produces no parseable
access log and has no collection, while Traefik has a working one, and every
scenario in use needs HTTP detail that does not exist at this layer.

Deployed in dry-run. It pulls decisions and logs what it would block without
creating a single nftables rule. The first bouncer to arm anywhere enforces
~24k CAPI decisions at once on the only external entry path, so the
false-positive window has to come before the enforcement rather than after it.
Arming is flipping crowdsec_bouncer_mode per host.

The static release tarball, not a package. The AUR package
crowdsec-firewall-bouncer-nftables depends on `crowdsec` and would pull the
whole Security Engine onto hosts that need only the bouncer; the group is
mixed (proxy-1 Arch, VPS-1 Debian); and the AUR would be a pattern this repo
does not otherwise have. The tarball is the same artefact the AUR package
downloads, pinned by version and sha256, and only the binary is unpacked —
the install/upgrade/uninstall scripts beside it manage systemd and config
themselves, which would leave two owners for the same files.

retry_initial_connect is set, and it is not cosmetic. Without it the process
exits when the LAPI is unreachable at startup, and since a clean shutdown
deletes the nftables tables, the systemd restart loop would leave the host
with no blocklist at all until the first successful connect. The flag exists
in the code but not in the shipped example config.

nftables_hooks covers input and forward. input is the traffic this exists for,
because the reverse-proxy is host-networked and owns :443. forward covers the
bridge-networked http-redirect sidecar, whose packets never traverse INPUT —
the same Docker bridge that showed up as 172.18.0.3 in the Traefik access log.

Prometheus is enabled but bound to loopback. These hosts are public and have
no metrics collection path today; opening :60601 to the internet to gain a
scrape target is the wrong trade.

No CA file: Traefik serves Let's Encrypt for crowdsec.renner.uno, and
ca_cert_path is added to the system pool rather than replacing it.

Rollback is `systemctl stop` — a clean stop deletes both tables by itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 12:19:09 +02:00
claude-botandClaude Opus 5 bc896e550c fix(crowdsec): drop the LAPI IPAllowList, it would have rejected the bouncer
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The allowlist carried the published proxy addresses on the assumption that a
request from a proxy host arrives carrying that host's public address. It does
not.

The client connects to the local proxy on 127.0.0.1, the reverse-proxy writes
what it saw into X-Forwarded-For, and Traefik resolves that into ClientHost.
Measured against netbird-proxy-watchdog, which makes exactly this kind of
request every round:

  172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-immich-...
  172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-home-...

Two probes one second apart, one per watchdog target. 172.18.0.3 is a Docker
bridge address on the proxy side.

So the allowlist would have 403ed the bouncer. Allowlisting 172.18.0.0/16
instead is not a control: it is a private range on a host whose addressing we
do not own, and it would admit anything else that happens to originate there.

The bouncer key stays as the access control, which is what it always was. The
endpoint serves read-only decision streams to a valid key; the admin surface
is cscli, in-cluster. If the source address ever becomes deterministic, an
ipAllowList can go back on top.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 12:13:27 +02:00
claude-botandClaude Opus 5 ea6e8e5083 feat(crowdsec): LAPI endpoint and key for the edge firewall bouncers
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Groundwork for enforcing CrowdSec decisions on proxy-1/proxy-2. Inert on its
own: an Ingress nobody calls yet, a bouncer key nobody uses yet, and a
whitelist that only ever narrows what may be banned.

The proxies cannot reach the LAPI over the mesh. The netbird-proxy container
runs non-root — that is what the ip_unprivileged_port_start task in the role
is for — so it has no NET_ADMIN and no /dev/net/tun, and its embedded NetBird
client terminates the tunnel in userspace. The tunnel belongs to that process,
not to the host netns, so a second process on the same host cannot route
through it. The alternative was a host-level NetBird client plus the LAPI on a
LoadBalancer VIP: two more peers, an ACL policy and a CNP rule. This reuses
the path every other service already takes.

Exposure is narrowed twice. The IPAllowList carries the three proxy addresses,
resolving them past the netbird-ingress pod IP through X-Forwarded-For the
same way the rate-limit middleware does — without that it would compare
against the shared forwarder IP, which is the gap behind the 2026-06-27 mass
ban. Behind it, the bouncer key. The LAPI serves read-only decision streams to
a valid key; the admin surface is cscli, in-cluster.

No CNP change is needed: crowdsec-ns.yaml already admits the traefik pods, and
ingress-default-deny carries no egress section.

BOUNCER_KEY_edge is separate from BOUNCER_KEY_ingress on purpose. The latter
rotates quarterly and both its consumers are in-cluster, so ESO propagates
within 10m. The edge hosts are Ansible-managed and read OpenBao on no
schedule, so a rotation would leave them 401ing until someone runs the
playbook. This key rotates with the playbook that carries it.

capi_whitelists.yaml is the only declarative allowlist the chart offers.
`cscli allowlists` objects live in the LAPI database, which runs without
persistence here deliberately, so anything created that way is gone at the
next restart. This file is a ConfigMap mount and survives by construction.
Upstream deprecates it; it is still the only thing that works against a LAPI
with no PVC.

It lists the proxy addresses and not the home WAN address, which is not
needed: unifi_nat_hairpin.tf DNATs the public proxy addresses to the Traefik
VIP on the UDM, so LAN clients reach the cluster directly with a LAN source
and never traverse the proxies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:50:05 +02:00
k.renner f0dcda4917 Merge pull request 'chore(llama-server): grow the model cache to 120Gi for candidate evaluation' (#748) from embed-pvc into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#748
2026-09-02 09:10:58 +00:00
claude-botandClaude Opus 5 e99e502da7 chore(llama-server): grow the model cache to 120Gi for candidate evaluation
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
The embedding model was picked from a recommendation and never compared. MTEB's
German retrieval results put the deployed Qwen3-Embedding-0.6B well down the
field, so a dozen candidates are being measured against the real archive with
scripts/embed-eval-real.sh — and they will not fit in 40Gi alongside the two
deployed models.

Growing the shared cache rather than using a scratch volume is deliberate: on a
slow connection the download is the long pole, and a shared cache lets the large
candidates pull in the background while the small ones are already being
measured. It also means nothing is fetched twice across runs, and a candidate
that wins is already local when it is promoted.

120Gi against 309 GiB currently free in cephfs-data0. CephFS has
allowVolumeExpansion, so this grows online without disturbing the running pods.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:10:04 +02:00
k.renner bf2b520fc4 Merge pull request 'refactor(immich): finish the VectorChord move, restore the replica' (#747) from immich-drop-pgvectors into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#747
2026-09-02 07:44:49 +00:00
claude-botandClaude Opus 5 780401fe91 refactor(immich): finish the VectorChord move, restore the replica
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Immich's in-app migration to VectorChord is complete: both vector indexes,
face_index and clip_index, run on the vchordrq access method, and nothing uses
pgvecto.rs. The manifest still described a migration window.

`DROP EXTENSION vectors` was run against the immich database before this. It
was safe to do without CASCADE — nothing outside the extension depended on it,
which a rolled-back `BEGIN; DROP EXTENSION vectors; ROLLBACK;` confirmed
first. The embedding columns are `public.vector(512)`, which belongs to
pgvector, not to pgvecto.rs's type in the `vectors` schema. Afterwards both
vchordrq indexes are intact at 96 MB and 79 MB and the cluster stays healthy.

With the extension gone, vectors.so has nothing to serve and preloading it
only recreates pg_vectors in PGDATA. pgvector needs no preloading, so vchord.so
is the whole list.

instances goes back to 2. The blocker was never the extension as such — it was
that pg_basebackup's tar format cannot represent the long
pg_vectors/indexes/<uuid> paths pgvecto.rs creates, so a replica could not
join. That directory is now empty, 20 KiB in total, so the constraint is gone.

The image keeps its `:16-migration` tag. That is the upstream build's own name
for a multi-extension image, not a statement about this cluster, and it still
provides the two extensions in use. Moving to a different image is a separate
change with its own risk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 09:41:48 +02:00
k.renner f9a9dc7ff8 Merge pull request 'docs: correct migration records that outlived their migrations' (#746) from fix-stale-migration-docs into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#746
2026-09-02 06:48:52 +00:00
claude-botandClaude Opus 5 05abc3291f docs: correct migration records that outlived their migrations
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Both documents described work as outstanding that the cluster shows finished.
A record that says "REMAINING" long after the fact is worse than no record:
it is read as current state, and the next person plans around it.

## Home Assistant recorder

Phase 2c is done, and done correctly. Against the live database:

  statistics             11,092,586   2024-03-13 20:00 -> current
  statistics_short_term   1,883,709   rolling ~7 days
  statistics_meta             1,480

The 2024-03-13 origin is the part that matters. It is unchanged from the
10,793,712-row count recorded on 2026-08-19, so the history was copied across
engines rather than started from zero — the exact failure the document was
written to prevent. `states` carries current rows, so db_url points at
Postgres.

The three "INERT"/"PHASE 1" comments went with it. All three described a
cluster that nothing consumed; it is now the recorder's live database, and the
render-db-url init container is load-bearing rather than inert — HA will not
start without the secret it renders.

## Bare-metal conversion

`talosctl get systeminformation` reports Minisforum MS-01 hardware with
distinct chassis serials for all three nodes and no hypervisor, so saturn and
neptune are not outstanding. Node creationTimestamp is not evidence either way:
k8s-3 still carries its 2026-05-09 object and is on metal regardless.

The runbook stays as the procedure for a rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:37:19 +02:00
claude-botandClaude Opus 5 18d3f3c850 feat(scripts): retrieval eval for embedding models on German documents
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
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
109 changed files with 5952 additions and 332 deletions
+131 -12
View File
@@ -19,12 +19,13 @@
# the admin account could (#330).
#
# The token is still OWNED by gitea_admin, because Gitea scopes packages to
# a user or org namespace and these images live under `gitea_admin/*` — a
# different account cannot publish there. Moving them to a dedicated org is
# the remaining cleanup; it is a 38-reference migration across pipelines and
# Flux-deployed CronJobs, so it is tracked separately in #330 rather than
# bundled here. Note this pipeline runs no LLM agent, so the exfil path that
# motivated the other cutovers does not apply to it.
# a user or org namespace and most of these images live under `gitea_admin/*`.
# The rebuild-critical ones additionally publish to `konairius/` — an org
# created for the ghcr mirror path, with gitea_admin as a member so this same
# token can push there. The full gitea_admin/* -> org migration is a
# 38-reference change tracked separately in #330 rather than bundled here.
# Note this pipeline runs no LLM agent, so the exfil path that motivated the
# other cutovers does not apply to it.
when:
- event: push
path:
@@ -87,6 +88,30 @@ steps:
--destination gitea.renner.uno/gitea_admin/ci-k8s:$TAG \
--destination gitea.renner.uno/gitea_admin/ci-k8s:latest
- name: build-vmtest
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/vmtest
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/vmtest/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/ci-vmtest:$TAG \
--destination gitea.renner.uno/gitea_admin/ci-vmtest:latest
- name: build-tofu
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/tofu
@@ -147,6 +172,28 @@ steps:
# not for who uses it — its first consumer is the zigbee2mqtt seed-data init
# container, but nothing about it is zigbee-specific. Exists so tools come from
# the image rather than an `apk add` on every container start.
#
# Published THREE times, and the third one is not a typo. Its consumers — the
# zigbee2mqtt seed-data init container and the trmnl renderer — start while
# the cluster is still being rebuilt, and at that moment gitea.renner.uno does
# not exist, because Gitea runs inside the cluster being built. A cold-rebuild
# rehearsal caught both in ImagePullBackOff.
#
# So there is a copy on ghcr.io. But the manifests must NOT point at it:
# normal operation pulls from the local registry, and an external reference
# would make every steady-state pull leave the network. The recovery source
# and the everyday source are different things, exactly as they are for Flux's
# git source.
#
# Talos resolves that with machine.registries.mirrors, which takes an ordered
# endpoint list — gitea first, ghcr second (bootstrap.sh). Containerd keeps
# the repository path when it falls through, so the two registries have to
# agree on it: `gitea_admin/alpine-jq` cannot be served by ghcr, where the
# namespace is fixed to the account name. Hence the second Gitea destination
# under konairius/, which the manifests reference and which matches the ghcr
# path exactly.
#
# The gitea_admin/ copy stays until nothing references it any more.
- name: build-alpine-jq
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/alpine-jq
@@ -159,23 +206,47 @@ steps:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
GHCR_USER: konairius
GHCR_TOKEN:
from_secret: ghcr_mirror_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-jq:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest
--destination gitea.renner.uno/gitea_admin/alpine-jq:latest \
--destination gitea.renner.uno/konairius/alpine-jq:$TAG \
--destination gitea.renner.uno/konairius/alpine-jq:latest \
--destination ghcr.io/konairius/alpine-jq:$TAG \
--destination ghcr.io/konairius/alpine-jq:latest
# Runtime image, not CI tooling: upstream containernetworking/plugins binaries
# baked in, consumed by the multus cni-plugins-installer DaemonSet. Replaces a
# node-side fetch from GitHub that had NO checksum verification and sat on the
# critical path of every node rebuild. The download now happens here, once,
# where the sha256 is checked — see the Containerfile for the full rationale.
#
# This one ALSO publishes to ghcr.io. A cold-rebuild rehearsal proved why: the
# DaemonSet could not pull from gitea.renner.uno, because Gitea runs inside the
# very cluster being rebuilt. Without the plugins there is no macvlan, so
# Multus cannot attach ceph-san, so Rook's network canary never starts, so the
# CephCluster sits at "Configuring Ceph Mons" — and OpenBao waits forever for a
# PVC that will never bind. Moving the download here fixed the integrity gap
# but quietly swapped github.com on the critical path for something strictly
# worse: a registry that by definition does not exist during a recovery. The
# GHCR copy is public, so a bare node can pull it with no credential and no
# Gitea.
#
# The test that proved this also proved the rule is not specific to this
# image. alpine-jq and alpine-openssl mirror for the same reason — see the
# note on build-alpine-jq. Anything an app needs while the cluster is still
# being built cannot live only in that cluster's own registry.
- name: build-cni-plugins
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/cni-plugins
@@ -188,23 +259,36 @@ steps:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
GHCR_USER: konairius
GHCR_TOKEN:
from_secret: ghcr_mirror_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/cni-plugins:$TAG \
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest
--destination gitea.renner.uno/gitea_admin/cni-plugins:latest \
--destination gitea.renner.uno/konairius/cni-plugins:$TAG \
--destination gitea.renner.uno/konairius/cni-plugins:latest \
--destination ghcr.io/konairius/cni-plugins:$TAG \
--destination ghcr.io/konairius/cni-plugins:latest
# Runtime image, not CI tooling: alpine + the openssl CLI, for shell scripts
# needing s_client/x509. Named for what it is; first consumer is the paperless
# fetch-bridge-cert init container. Replaces an `apk add openssl` that ran on
# every paperless pod start. The openssl CLI is genuinely absent from the alpine
# base, so this one needs an image rather than a busybox substitute.
#
# Mirrored to ghcr.io for the reason given on build-alpine-jq: it is an init
# container, so paperless does not start without it, and during a rebuild
# gitea.renner.uno is not there to serve it. A cold-rebuild rehearsal left
# paperless in Init:ErrImagePull on exactly this image.
- name: build-alpine-openssl
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/alpine-openssl
@@ -217,17 +301,25 @@ steps:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
GHCR_USER: konairius
GHCR_TOKEN:
from_secret: ghcr_mirror_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- GH_AUTH=$(printf '%s:%s' "$GHCR_USER" "$GHCR_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"},"ghcr.io":{"auth":"%s"}}}' "$AUTH" "$GH_AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:$TAG \
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest
--destination gitea.renner.uno/gitea_admin/alpine-openssl:latest \
--destination gitea.renner.uno/konairius/alpine-openssl:$TAG \
--destination gitea.renner.uno/konairius/alpine-openssl:latest \
--destination ghcr.io/konairius/alpine-openssl:$TAG \
--destination ghcr.io/konairius/alpine-openssl:latest
# Heating CAN readers: open3e (Vitocal/VX3) + E3onCAN (E380 meter), one image
# run as two services on edge-1. Replaces pip-into-venv on the host, where an
@@ -257,3 +349,30 @@ steps:
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/heating-can:$TAG \
--destination gitea.renner.uno/gitea_admin/heating-can:latest
# netbird-proxy-services: the controller that reconciles NetBird reverse-proxy
# services (public Ingress hosts -> TLS passthrough -> Traefik ClusterIP).
# Runtime artifact like heating-can; same registry so the audit tracks it.
- name: build-netbird-proxy-services
image: registry.gitlab.com/gitlab-ci-utils/container-images/kaniko:v1.25.18-debug
directory: ci/images/netbird-proxy-services
depends_on: []
when:
- event: deployment
- event: push
path: ["ci/images/netbird-proxy-services/**", ".woodpecker/build-ci-images.yml"]
environment:
GITEA_USER: gitea_admin
GITEA_TOKEN:
from_secret: ci_images_token
commands:
- mkdir -p /kaniko/.docker
- AUTH=$(printf '%s:%s' "$GITEA_USER" "$GITEA_TOKEN" | base64 | tr -d '\n')
- printf '{"auths":{"gitea.renner.uno":{"auth":"%s"}}}' "$AUTH" > /kaniko/.docker/config.json
- TAG=$(date +%Y-%m-%d)
- |
/kaniko/executor \
--context . \
--dockerfile Containerfile \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:$TAG \
--destination gitea.renner.uno/gitea_admin/netbird-proxy-services:latest
+14
View File
@@ -7,7 +7,21 @@ when:
# install-netbird-proxy.yml also applies the netbird-proxy-watchdog role,
# so changes to it must redeploy this host too.
- "ansible/roles/netbird-proxy-watchdog/**"
# install-netbird-proxy.yml also applies the edge bouncer.
- "ansible/roles/crowdsec-edge-bouncer/**"
- "ansible/plays/install-netbird-proxy.yml"
# The variables that steer those roles, not just the roles themselves.
# Without these two, a change that only touches inventory never deploys
# and nothing says so: arming the bouncer lives in host_vars, and its
# LAPI key lives in group_vars, so both would sit in git looking applied
# while the hosts kept running the previous configuration.
#
# pve_hosts.yml is deliberately NOT listed. It is the single inventory
# file for the whole fleet, so listing it would redeploy the proxies for
# any unrelated host edit. The cost is that a change to [netbird_proxy]
# group membership needs a manual deployment run.
- "ansible/inventory/host_vars/proxy-*.yml"
- "ansible/inventory/group_vars/netbird_proxy/**"
- ".woodpecker/deploy-netbird-proxy.yml"
- event: deployment
evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "deploy-netbird-proxy"'
@@ -0,0 +1,23 @@
---
# Bouncer key for crowdsec-edge-bouncer, scoped to the proxy hosts.
#
# A single encrypted VALUE rather than a whole-file vault like its siblings in
# group_vars/all and group_vars/edge_host. Deliberate: adding this variable to
# an existing whole-file vault means decrypting that file first, which exposes
# every other secret in it for the sake of appending one line. encrypt_string
# keeps the blast radius at this one value.
#
# Same value as OpenBao homelab/infrastructure/crowdsec-edge-bouncer-key, which
# the crowdsec-edge-bouncer-key ExternalSecret feeds to BOUNCER_KEY_edge on the
# LAPI. Both sides must move together — and unlike crowdsec-bouncer-key, this
# one is NOT in the quarterly rotator, because nothing on these hosts re-reads
# OpenBao on a schedule. Rotating it means updating OpenBao and re-running
# plays/install-netbird-proxy.yml.
vault_crowdsec_edge_bouncer_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
32653764333536316163303838333839616562663862666637626165303837633866643033393239
3363376538656236643161386432326437633232643564630a633635306365323537346139626364
36613033343938363366306239363638383466396337623333623136313234656366333366623433
6437656632373364340a346566343866366433306533353431383064356362346536383136323663
39333864643534326433653233303137373162663066633132623931316135323733333863306330
6366623463666435636362376238313639666263323163303733
+4
View File
@@ -26,3 +26,7 @@
- docker-install
- netbird-proxy
- netbird-proxy-watchdog
# Enforces the cluster's CrowdSec decisions here, where public traffic
# enters. Deployed in dry-run: it logs what it would block and creates no
# nftables rules until crowdsec_bouncer_mode is flipped per host.
- crowdsec-edge-bouncer
@@ -0,0 +1,56 @@
---
# CrowdSec firewall bouncer on the NetBird proxy hosts.
#
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
# where public traffic actually enters: these hosts terminate TLS for every
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
# cluster.
#
# Detection stays in the cluster. The reverse-proxy produces no parseable
# access log and there is no CrowdSec collection for it, while Traefik has a
# working one — and every scenario in use needs HTTP detail (paths, status
# codes, user agents) that does not exist at this layer.
# Pinned. A release tarball URL is immutable, and this checksum is of the
# amd64 asset for exactly this tag.
crowdsec_bouncer_version: v0.0.36
crowdsec_bouncer_sha256: f86e4b72693549d99f40a9402abefb894108f047a3fbe6e72fada25ee17ce88b
# The LAPI over the public path these hosts already serve, see
# kubernetes/infrastructure/crowdsec/lapi-ingress.yaml. The proxies cannot
# reach it over the mesh: the reverse-proxy container runs non-root, so its
# embedded NetBird client terminates the tunnel in userspace and the tunnel
# belongs to that process rather than to the host netns.
#
# No ca_cert_path: Traefik serves a Let's Encrypt certificate for this name,
# and the bouncer adds ca_cert_path to the system pool rather than replacing
# it — the system pool already trusts LE.
crowdsec_bouncer_api_url: https://crowdsec.renner.uno/
crowdsec_bouncer_api_key: "{{ vault_crowdsec_edge_bouncer_key }}"
# Enforcing. The bouncer creates two nftables tables (ip crowdsec, ip6
# crowdsec6) holding the decisions it pulled, touches no existing chain and
# sets no chain policy — SSH and :443 stay open unless the source is banned.
#
# `dry-run` is the other value: it pulls and logs what it would block without
# creating a single rule. That is what a new host should be given before it
# enforces, and what an existing one can be dropped back to, per host through
# inventory/host_vars, without disarming the rest.
#
# Worth knowing before arming anything new: the first bouncer on a path
# enforces the whole CAPI set at once — around 15k addresses today — so the
# false-positive window belongs before the switch, not after it. And because
# this chain sits on the INPUT hook while these hosts run no other firewall,
# there is no conntrack rule accepting established flows ahead of it: a banned
# address is dropped even when it is answering our own outbound connection.
# That is why capi_whitelists.yaml carries the proxies and netbird.renner.uno.
crowdsec_bouncer_mode: nftables
crowdsec_bouncer_update_frequency: 10s
crowdsec_bouncer_log_level: info
# Bound to loopback deliberately. These hosts are public and have no metrics
# collection path at all today — opening :60601 to the internet to gain a
# scrape target is the wrong trade. Revisit when host metrics exist.
crowdsec_bouncer_metrics_listen: 127.0.0.1
crowdsec_bouncer_metrics_port: 60601
@@ -0,0 +1,10 @@
---
# A restart drops both nftables tables and rebuilds them from the next
# successful LAPI pull, so there is a short window with no blocklist. That is
# the documented behaviour of a clean shutdown, not a defect — and it is why
# retry_initial_connect matters.
- name: Restart crowdsec firewall bouncer
ansible.builtin.systemd:
name: crowdsec-firewall-bouncer.service
state: restarted
daemon_reload: true
@@ -0,0 +1,111 @@
---
# See defaults/main.yml for why this exists and why it starts inert.
#
# The static release tarball, not a distribution package: the AUR package
# `crowdsec-firewall-bouncer-nftables` depends on `crowdsec` and would pull the
# whole Security Engine onto hosts that only need the bouncer, the group is
# mixed (proxy-1 Arch, VPS-1 Debian), and the AUR would be a new pattern this
# repo does not otherwise have. The tarball is the same artefact the AUR
# package downloads.
- name: Ensure nftables is present (the enforcing backend)
ansible.builtin.package:
name: nftables
state: present
- name: Ensure the bouncer config directory exists
ansible.builtin.file:
path: /etc/crowdsec/bouncers
state: directory
mode: "0755"
# Fetched on the CONTROLLER and pushed, not pulled by the host.
#
# proxy-1 is IPv6-only, and neither github.com nor objects.githubusercontent.com
# publishes an AAAA record — a get_url on the host fails with "Network is
# unreachable" and no amount of retrying fixes it. Mirroring into
# gitea.renner.uno is no help either: its public records are the internal LB
# VIP (10.2.99.86 / fd99::86), so it is unreachable from outside the LAN. And
# upstream ships no container image for this bouncer, which is expected —
# it manipulates host netfilter.
#
# The CI runner has v4, so it does the fetching. The checksum is verified
# there, before anything is copied to a host.
- name: Fetch the pinned bouncer release on the controller
ansible.builtin.get_url:
url: >-
https://github.com/crowdsecurity/cs-firewall-bouncer/releases/download/{{
crowdsec_bouncer_version }}/crowdsec-firewall-bouncer-linux-amd64.tgz
dest: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
checksum: sha256:{{ crowdsec_bouncer_sha256 }}
mode: "0644"
delegate_to: localhost
become: false
run_once: true
# Only the download had to move to the controller — the extraction stays on
# the host. The ci-ansible image ships a tar that ansible rejects ("detected as
# tar type None. GNU tar required"), while both host distributions have GNU
# tar. Shipping the archive rather than the unpacked binary also keeps the
# artefact that the checksum was verified against intact all the way to the
# host.
- name: Copy the bouncer archive to the host
ansible.builtin.copy:
src: /tmp/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
mode: "0644"
# Only the binary. The tarball also carries install.sh / upgrade.sh /
# uninstall.sh, which manage systemd and config themselves — that is this
# role's job, and letting them run would leave two owners for the same files.
- name: Unpack the bouncer binary
ansible.builtin.unarchive:
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}.tgz
dest: /opt
remote_src: true
include:
- crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
creates: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
- name: Install the bouncer binary
ansible.builtin.copy:
src: /opt/crowdsec-firewall-bouncer-{{ crowdsec_bouncer_version }}/crowdsec-firewall-bouncer
dest: /usr/local/bin/crowdsec-firewall-bouncer
remote_src: true
mode: "0755"
notify: Restart crowdsec firewall bouncer
# 0600: this file carries the LAPI key.
- name: Deploy the bouncer configuration
ansible.builtin.template:
src: crowdsec-firewall-bouncer.yaml.j2
dest: /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
mode: "0600"
notify: Restart crowdsec firewall bouncer
- name: Deploy the bouncer systemd unit
ansible.builtin.template:
src: crowdsec-firewall-bouncer.service.j2
dest: /etc/systemd/system/crowdsec-firewall-bouncer.service
mode: "0644"
notify: Restart crowdsec firewall bouncer
- name: Enable and start the bouncer
ansible.builtin.systemd:
name: crowdsec-firewall-bouncer.service
enabled: true
state: started
daemon_reload: true
- name: Report how to read it
ansible.builtin.debug:
msg: |
crowdsec-firewall-bouncer deployed in mode "{{ crowdsec_bouncer_mode }}".
What it would block: journalctl -u crowdsec-firewall-bouncer -f
What it does block: nft list table ip crowdsec ; nft list table ip6 crowdsec6
Metrics: curl -s {{ crowdsec_bouncer_metrics_listen }}:{{ crowdsec_bouncer_metrics_port }}/metrics
Rollback: systemctl stop crowdsec-firewall-bouncer. A clean stop deletes
both nftables tables by itself. Only after a kill -9 do they need
removing by hand: nft delete table ip crowdsec ; nft delete table ip6 crowdsec6
@@ -0,0 +1,24 @@
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
#
# Upstream's shipped unit orders itself After=crowdsec.service. There is no
# local Security Engine on these hosts — the LAPI is in the cluster — so that
# dependency is dropped; nss-lookup instead, because api_url is a public name
# that has to resolve before the first connect.
[Unit]
Description=CrowdSec firewall bouncer (edge enforcement for the NetBird proxy)
After=network-online.target nss-lookup.target
Wants=network-online.target
[Service]
Type=notify
# -t validates the config and exits non-zero on a bad one, so a broken config
# fails the start instead of taking the running rules down with it.
ExecStartPre=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml -t
ExecStart=/usr/local/bin/crowdsec-firewall-bouncer -c /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
Restart=always
RestartSec=10
LimitNOFILE=65536
KillMode=mixed
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,55 @@
# Managed by Ansible (crowdsec-edge-bouncer role) — do not edit.
mode: {{ crowdsec_bouncer_mode }}
update_frequency: {{ crowdsec_bouncer_update_frequency }}
# stdout, so journald owns the log and there is no second rotation policy to
# keep in sync. The shipped example writes to /var/log; that only matters for
# hosts without journald.
log_mode: stdout
log_level: {{ crowdsec_bouncer_log_level }}
api_url: {{ crowdsec_bouncer_api_url }}
api_key: {{ crowdsec_bouncer_api_key }}
# Without this the process EXITS when the LAPI is unreachable at startup —
# there is no retry on the first connect. And because a clean shutdown deletes
# the nftables tables, the systemd restart loop would leave the host with no
# blocklist at all until the first successful connect. Present in the code,
# absent from the shipped example config.
retry_initial_connect: true
disable_ipv4: false
disable_ipv6: false
deny_action: DROP
supported_decisions_types:
- ban
# Separate tables per family; the bouncer never touches an existing table or
# chain, and never sets a chain policy. Priority -10 puts it ahead of anything
# at the default filter priority.
nftables:
ipv4:
enabled: true
set-only: false
table: crowdsec
chain: crowdsec-chain
priority: -10
ipv6:
enabled: true
set-only: false
table: crowdsec6
chain: crowdsec6-chain
priority: -10
# input is the traffic this exists for: the reverse-proxy is host-networked and
# owns :443, so its packets traverse INPUT. forward covers the bridge-networked
# http-redirect sidecar, whose packets never do.
nftables_hooks:
- input
- forward
prometheus:
enabled: true
listen_addr: {{ crowdsec_bouncer_metrics_listen }}
listen_port: {{ crowdsec_bouncer_metrics_port }}
@@ -0,0 +1,12 @@
# netbird-proxy-services: reconciles NetBird reverse-proxy services (public
# domains -> TLS passthrough -> Traefik ClusterIP) from Ingresses carrying the
# public label. Standard library only, so the image is the interpreter plus one
# file; runs unprivileged as its own user.
FROM python:3.13-slim
RUN useradd --system --no-create-home --shell /usr/sbin/nologin controller
COPY controller.py /opt/controller.py
ENV PYTHONUNBUFFERED=1
USER controller
ENTRYPOINT ["python3", "/opt/controller.py"]
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""netbird-proxy-services: reconcile NetBird reverse-proxy services from Ingresses.
Source of truth: every Ingress carrying the public label (dns.renner.uno/public).
For each host on such an Ingress the NetBird management gets one reverse-proxy
service in TLS-passthrough mode on :443, targeting Traefik's ClusterIP through a
subnet resource that covers the cluster's service CIDR. SNI and Host reach
Traefik untouched, the proxy terminates nothing, and no domain resource is
involved -- the routing peers stay ordinary IP routers.
Ownership is derived from NetBird itself, not from local state: a service is
managed iff its mode is "tls" and its target is this controller's subnet
resource. Services that do not match (the hand-made HTTP/domain ones) are never
touched; a desired host held by such a service is reported and skipped until
that service is removed by hand.
Credentials: the NetBird PAT is read from OpenBao on every pass through the
Kubernetes auth role, exactly like the setup-key rotator; it never lands in a
Kubernetes Secret or in a log line.
"""
import json
import logging
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
LOG = logging.getLogger("netbird-proxy-services")
NB = os.environ.get("NB_MANAGEMENT_URL", "https://netbird.renner.uno").rstrip("/")
BAO_ADDR = os.environ.get("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200").rstrip("/")
BAO_ROLE = os.environ.get("BAO_ROLE", "netbird-proxy-services")
BAO_PAT_PATH = os.environ.get("BAO_PAT_PATH", "homelab/data/infrastructure/secret-rotators-bootstrap/netbird")
BAO_PAT_KEY = os.environ.get("BAO_PAT_KEY", "pat")
SERVICE_CIDR = os.environ.get("SERVICE_CIDR", "10.96.0.0/12")
NETWORK_NAME = os.environ.get("NETWORK_NAME", "k8s public ingress")
ROUTER_GROUP = os.environ.get("ROUTER_GROUP_NAME", "k8s-ingress-router")
SOURCE_GROUP = os.environ.get("SOURCE_GROUP_NAME", "All")
TRAEFIK_NS, TRAEFIK_SVC = os.environ.get("TRAEFIK_SERVICE", "ingress/traefik").split("/", 1)
PUBLIC_LABEL = os.environ.get("PUBLIC_LABEL", "dns.renner.uno/public")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "443"))
INTERVAL = int(os.environ.get("INTERVAL_SECONDS", "60"))
HTTP_TIMEOUT = 20
K8S_API = "https://kubernetes.default.svc"
K8S_TOKEN_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/token"
K8S_CA_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
class ApiError(Exception):
pass
def http(method, url, body=None, headers=None, cafile=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
ctx = None
if cafile:
import ssl
ctx = ssl.create_default_context(cafile=cafile)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT, context=ctx) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
raise ApiError(f"{method} {url} -> {e.code}: {detail}") from None
# --- Kubernetes -------------------------------------------------------------
def k8s(path):
with open(K8S_TOKEN_FILE) as f:
token = f.read().strip()
return http("GET", K8S_API + path, headers={"Authorization": f"Bearer {token}"}, cafile=K8S_CA_FILE)
def desired_hosts():
sel = urllib.parse.quote(f"{PUBLIC_LABEL}=true")
ingresses = k8s(f"/apis/networking.k8s.io/v1/ingresses?labelSelector={sel}")
hosts = {}
for ing in ingresses.get("items", []):
ref = f'{ing["metadata"]["namespace"]}/{ing["metadata"]["name"]}'
for rule in ing.get("spec", {}).get("rules", []):
host = rule.get("host")
if not host or host.startswith("*"):
continue
hosts.setdefault(host, ref)
return hosts
def traefik_cluster_ip():
svc = k8s(f"/api/v1/namespaces/{TRAEFIK_NS}/services/{TRAEFIK_SVC}")
for ip in svc["spec"].get("clusterIPs", []):
if ":" not in ip:
return ip
raise ApiError(f"{TRAEFIK_NS}/{TRAEFIK_SVC} has no IPv4 ClusterIP")
# --- OpenBao ----------------------------------------------------------------
def netbird_pat():
with open(K8S_TOKEN_FILE) as f:
jwt = f.read().strip()
login = http("POST", f"{BAO_ADDR}/v1/auth/kubernetes/login", {"role": BAO_ROLE, "jwt": jwt})
bao_token = login["auth"]["client_token"]
kv = http("GET", f"{BAO_ADDR}/v1/{BAO_PAT_PATH}", headers={"X-Vault-Token": bao_token})
pat = kv["data"]["data"].get(BAO_PAT_KEY)
if not pat:
raise ApiError(f"OpenBao {BAO_PAT_PATH} has no key {BAO_PAT_KEY}")
return pat
# --- NetBird management -----------------------------------------------------
class NetBird:
def __init__(self, pat):
self.h = {"Authorization": f"Token {pat}"}
def get(self, path):
return http("GET", f"{NB}/api/{path}", headers=self.h)
def list(self, path):
# An empty collection comes back as an empty body / null, not [].
return self.get(path) or []
def post(self, path, body):
return http("POST", f"{NB}/api/{path}", body, headers=self.h)
def put(self, path, body):
return http("PUT", f"{NB}/api/{path}", body, headers=self.h)
def delete(self, path):
return http("DELETE", f"{NB}/api/{path}", headers=self.h)
def group_id(nb, name):
for g in nb.list("groups"):
if g["name"] == name:
return g["id"]
raise ApiError(f"NetBird group {name!r} not found")
def ensure_network(nb):
for n in nb.list("networks"):
if n["name"] == NETWORK_NAME:
return n["id"]
n = nb.post("networks", {"name": NETWORK_NAME,
"description": "managed by netbird-proxy-services; public ingress via TLS passthrough"})
LOG.info("created network %s (%s)", NETWORK_NAME, n["id"])
return n["id"]
def ensure_resource(nb, net):
for r in nb.list(f"networks/{net}/resources"):
if r.get("address") == SERVICE_CIDR:
return r["id"]
r = nb.post(f"networks/{net}/resources", {"name": "cluster service CIDR", "address": SERVICE_CIDR,
"description": "managed by netbird-proxy-services",
"enabled": True, "groups": []})
LOG.info("created subnet resource %s (%s)", SERVICE_CIDR, r["id"])
return r["id"]
def ensure_router(nb, net, router_group):
for r in nb.list(f"networks/{net}/routers"):
if router_group in (r.get("peer_groups") or []):
return r["id"]
r = nb.post(f"networks/{net}/routers", {"peer_groups": [router_group], "metric": 9999,
"masquerade": True, "enabled": True})
LOG.info("created router on group %s (%s)", ROUTER_GROUP, r["id"])
return r["id"]
def ensure_policy(nb, resource, source_group):
name = f"{NETWORK_NAME} access"
for p in nb.list("policies"):
if p["name"] == name:
return p["id"]
p = nb.post("policies", {"name": name, "enabled": True, "rules": [{
"name": name, "enabled": True, "action": "accept", "protocol": "tcp",
"ports": [str(LISTEN_PORT)], "bidirectional": False, "sources": [source_group],
"destinationResource": {"id": resource, "type": "subnet"}}]})
LOG.info("created policy %s (%s)", name, p["id"])
return p["id"]
def service_body(host, resource, target_ip):
return {"name": host, "domain": host, "mode": "tls", "listen_port": LISTEN_PORT, "enabled": True,
"targets": [{"target_type": "subnet", "target_id": resource, "protocol": "tcp",
"host": target_ip, "port": 443, "enabled": True}]}
def is_managed(svc, resource):
return svc.get("mode") == "tls" and any(t.get("target_id") == resource for t in svc.get("targets") or [])
def target_matches(svc, target_ip):
if svc.get("listen_port") != LISTEN_PORT or not svc.get("enabled"):
return False
ts = svc.get("targets") or []
return len(ts) == 1 and ts[0].get("host") == target_ip and ts[0].get("port") == 443 \
and ts[0].get("protocol") == "tcp" and ts[0].get("enabled")
def reconcile():
hosts = desired_hosts()
target_ip = traefik_cluster_ip()
nb = NetBird(netbird_pat())
router_group = group_id(nb, ROUTER_GROUP)
source_group = group_id(nb, SOURCE_GROUP)
net = ensure_network(nb)
resource = ensure_resource(nb, net)
ensure_router(nb, net, router_group)
ensure_policy(nb, resource, source_group)
by_domain = {s["domain"]: s for s in nb.list("reverse-proxies/services")}
created = updated = deleted = held = 0
for host, ref in sorted(hosts.items()):
svc = by_domain.get(host)
if svc is None:
nb.post("reverse-proxies/services", service_body(host, resource, target_ip))
LOG.info("created %s (from %s) -> %s:443", host, ref, target_ip)
created += 1
elif not is_managed(svc, resource):
LOG.warning("%s is held by unmanaged service %s (mode %s); remove it to migrate",
host, svc["id"], svc.get("mode"))
held += 1
elif not target_matches(svc, target_ip):
nb.put(f"reverse-proxies/services/{svc['id']}", service_body(host, resource, target_ip))
LOG.info("updated %s -> %s:443", host, target_ip)
updated += 1
for domain, svc in by_domain.items():
if is_managed(svc, resource) and domain not in hosts:
nb.delete(f"reverse-proxies/services/{svc['id']}")
LOG.info("deleted %s (no public Ingress carries it any more)", domain)
deleted += 1
LOG.info("reconciled: %d hosts desired, %d created, %d updated, %d deleted, %d held by unmanaged services",
len(hosts), created, updated, deleted, held)
def main():
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), stream=sys.stdout,
format="%(asctime)s %(levelname)s %(message)s")
if os.environ.get("ONCE") == "1":
reconcile()
return
while True:
try:
reconcile()
except Exception as e: # keep running; the next pass retries
LOG.error("reconcile failed: %s", e)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# Everything `bootstrap.sh vmtest` needs, so the rebuild test can run as a Job in
# the cluster instead of on a workstation. The workstation has 31G and the test
# wants ~14.5G for four VMs — a single run left under 1G headroom, and two
# overlapping runs OOM-killed the session. The nodes have ~94G with 70G free.
#
# Separate from ci-k8s rather than built on it: that image pins talosctl v1.12.6,
# and this test creates a cluster at the version talos/patches/cluster.yaml
# installs. A talosctl older than the Talos it provisions is exactly the kind of
# skew the test exists to catch, so it must not be baked in here.
#
# KEEP TALOS_VERSION IN STEP with the installer image tag in
# kubernetes/talos/patches/cluster.yaml. The phase reads the version from that
# file at run time and passes it to the provisioner; this pin only decides which
# client talks to it.
ARG TALOS_VERSION=v1.13.9
ARG KUBECTL_VERSION=v1.36.2
ARG FLUX_VERSION=2.9.2
ARG KOPIA_VERSION=0.23.1
# qemu-system-x86_64 and qemu-img are the provisioner's actual backend, and ovmf
# supplies the UEFI firmware those VMs boot from — without it the provisioner
# stops with "the required flash image was not found in any of the expected
# paths", having already downloaded the ISO and generated the PKI. Note the
# firmware comes from ovmf specifically: qemu-system-x86_64 ships an edk2 CODE
# image but no VARS, which is the half the provisioner needs.
#
# iptables is shelled out to by the CNI plugins that build the VM network; the
# provisioner fails with "error accessing iptables" without it. The rest is
# what the phase shells out to: helm for the Cilium install, yq to read the
# HelmRelease and the installer image, jq for OpenBao's seal status.
#
# Versions come from the digest-pinned base rather than being pinned one by one,
# the same way ci-ansible does it.
RUN apk add --no-cache \
bash \
ca-certificates \
curl \
git \
helm \
iptables \
jq \
openssl \
ovmf \
qemu-img \
qemu-system-x86_64 \
yq
RUN curl -sfL -o /usr/local/bin/talosctl \
"https://github.com/siderolabs/talos/releases/download/${TALOS_VERSION}/talosctl-linux-amd64" \
&& echo "7e1d4b7d5846964bdcf63a794e3c8161bb6ef2983d5ace58ea5322f3bf32a27e /usr/local/bin/talosctl" | sha256sum -c - \
&& chmod +x /usr/local/bin/talosctl
RUN curl -sfL -o /usr/local/bin/kubectl \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \
&& curl -sfL -o /tmp/kubectl.sha256 \
"https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" \
&& echo "$(cat /tmp/kubectl.sha256) /usr/local/bin/kubectl" | sha256sum -c - \
&& rm -f /tmp/kubectl.sha256 \
&& chmod +x /usr/local/bin/kubectl
RUN curl -sfL -o /tmp/flux.tar.gz \
"https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_linux_amd64.tar.gz" \
&& echo "4092b367fd060097976fb7261601f79420ecf7266328417dfd8dfee27de0e6a3 /tmp/flux.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \
&& chmod +x /usr/local/bin/flux \
&& rm -f /tmp/flux.tar.gz
# kopia reads the Velero backup repository directly, which is what the OpenBao
# restore does when there is no Velero yet to do it: the credentials Velero
# needs live in OpenBao, so OpenBao cannot be restored by Velero. Its absence
# was found by a rebuild rehearsal reaching the restore for the first time and
# stopping at "kopia not found".
#
# Same checksum-verified download as the tools above; the tarball unpacks into
# a versioned directory, so the binary is pulled out by name.
RUN curl -sfL -o /tmp/kopia.tar.gz \
"https://github.com/kopia/kopia/releases/download/v${KOPIA_VERSION}/kopia-${KOPIA_VERSION}-linux-x64.tar.gz" \
&& echo "416d0f84a3dbb321a8b2d8f0997b1a0a6e915babe79ee76fa6e4d2bd1e1c5178 /tmp/kopia.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/kopia.tar.gz -C /tmp \
&& mv "/tmp/kopia-${KOPIA_VERSION}-linux-x64/kopia" /usr/local/bin/kopia \
&& chmod +x /usr/local/bin/kopia \
&& rm -rf /tmp/kopia.tar.gz "/tmp/kopia-${KOPIA_VERSION}-linux-x64"
+6 -1
View File
@@ -2,7 +2,12 @@
Converts one MS-01 from "Proxmox host running a Talos VM" to "Talos on bare
metal", and swaps its Ceph OSD onto a Samsung PM983 at the same time. Written
after doing jupiter on 2026-08-25; saturn and neptune remain.
after doing jupiter on 2026-08-25.
All three nodes are now on bare metal — `talosctl get systeminformation`
reports Minisforum MS-01 hardware with distinct chassis serials for k8s-1,
k8s-2 and k8s-3, with no hypervisor in between. Kept as the procedure for a
rebuild, not as outstanding work.
Everything here that reads like an over-specific warning is something that
actually happened. The conversion itself is straightforward — the failures were
+13 -2
View File
@@ -120,7 +120,14 @@ spec:
server:
resources:
requests:
cpu: 200m
# 500m, not 200m: the HPA below targets 70% of *this* request, so at
# 200m the scale-up threshold sat at 140m — below the pod's own
# ordinary peak. Measured over 7d, a warm server pod p99s at ~108m
# and peaks at ~190m, and a freshly-started one burns ~300m booting
# Django. That made the HPA oscillate 2<->3<->4 all day and pin at
# maxReplicas (KubeHpaMaxedOut, issue #761). At 500m the threshold
# is 350m/pod, above the boot burst, so only real load scales out.
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
@@ -154,7 +161,11 @@ spec:
worker:
resources:
requests:
cpu: 200m
# Same reasoning as server above, and the worker is burstier still:
# over 7d its per-pod p99 reaches ~336m and peaks hit ~630m while
# running blueprint applies and scheduled tasks. 200m understated
# that badly enough that routine task batches read as saturation.
cpu: 500m
memory: 1Gi
limits:
memory: 2Gi
@@ -16,7 +16,6 @@ resources:
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- llama-gate-egress.yaml
- outpost-servicemonitors.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
- ldap-outpost-cnp.yaml
@@ -111,9 +111,23 @@ statistics max_id=13323904 max_start_ts=1787162400.0
statistics_short_term max_id=159205762 max_start_ts=1787166000.0
```
## Phase 2c — cutover (REMAINING)
## Phase 2c — cutover: DONE
Do not start until phase 1 is verified.
Verified 2026-09-02 against the live database:
| table | rows | span |
|---|---|---|
| `statistics` | 11,092,586 | 2024-03-13 20:00 → current |
| `statistics_short_term` | 1,883,709 | rolling ~7 days |
| `statistics_meta` | 1,480 | |
The 2024-03-13 origin is the proof that matters. It is unchanged from the
pre-migration count in "What is at risk" above, so the history was copied
across engines rather than started from zero — which is the failure this
document exists to prevent. `states` carries current rows, so `db_url` points
at Postgres.
The procedure below is kept as the record of what was done.
**Migrate statistics only.** Copying all 18M `states` rows is slow, needs a long
HA outage, and buys a table that is discarded within 7 days anyway.
@@ -56,10 +56,9 @@ spec:
# Re-rendered on every pod start, so a CNPG password rotation takes
# effect rather than silently leaving a stale URI behind.
#
# INERT until the recorder is repointed: nothing reads this key until
# `db_url: !secret recorder_db_url` lands in configuration.yaml in the
# homeassistant repo. That ordering is required — HA refuses to start
# when a referenced secret is missing.
# Load-bearing: `db_url: !secret recorder_db_url` in the homeassistant
# repo's configuration.yaml reads this key, so HA does not start
# without it. Removing this init container takes the recorder down.
- name: render-db-url
image: busybox:1.36
command:
@@ -96,7 +95,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.8.3 # {"$imagepolicy": "flux-system:home-assistant"}
image: ghcr.io/home-assistant/home-assistant:2026.9.1 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
@@ -18,12 +18,12 @@
# That is the point of the move — it attacks the multiplier rather than trimming
# what gets recorded, which would only hide the problem.
#
# STAGING
# Phase 1 (this manifest): the cluster comes up EMPTY and HA keeps using SQLite.
# Nothing depends on it, so a failure here is inert. Verify Ready, WAL archiving
# to Hetzner, and a successful base backup before going further.
# Phase 2: migrate the statistics tables and repoint recorder.db_url.
# See MIGRATION-recorder-postgres.md for the sequence and the rollback.
# This is the live recorder database. HA writes here; the SQLite file is no
# longer the system of record. The 2.4 years of long-term statistics were
# copied across engines rather than regenerated — HA does not migrate data
# between database engines, so an empty Postgres would have silently started
# from zero and taken the energy dashboard's history with it.
# MIGRATION-recorder-postgres.md holds the sequence that was followed.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
@@ -60,9 +60,8 @@ resources:
- zigbee2mqtt-gate-service.yaml
- zigbee2mqtt-ingress.yaml
# Postgres backend for the recorder (MIGRATION-recorder-postgres.md).
# PHASE 1: this cluster comes up EMPTY and nothing consumes it — HA is still
# on SQLite. Deploying it is therefore inert; phase 2 migrates the statistics
# tables and repoints recorder.db_url in the homeassistant repo.
# Live: recorder.db_url in the homeassistant repo points here, and the
# long-term statistics carry their full history from 2024.
- ha-pg-hetzner-creds-externalsecret.yaml
- ha-pg-objectstore.yaml
- ha-pg-cluster.yaml
@@ -60,7 +60,7 @@ spec:
# Two processes rather than one: socat carries a single address pair per
# invocation, and the families need different receive and send addresses.
# They are independent — one crashing does not blind the other — and each
# is restarted by the kubelet on its own.
# carries its own supervisor loop.
containers:
# -u is load-bearing on both: unidirectional, left to right. Without it
# socat would also carry HA's replies back out, and this must stay a
@@ -77,17 +77,29 @@ spec:
# UDP4-SENDTO takes its A record and UDP6-SENDTO its AAAA. See
# mdns-relay-service.yaml for why that indirection is required and why
# the Service is dual-stack.
#
# Supervisor loop: socat exits 1 on the first datagram it cannot
# deliver, and the ClusterIP has no backend while HA restarts. The
# trap forwards SIGTERM so the pod still terminates promptly.
- name: socat-ipv4
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
imagePullPolicy: IfNotPresent
args:
- -u
- "UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr"
- "UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
# No probes. socat is a single process with no health surface: if it
# exits, the container exits and the kubelet restarts it, which is the
# entire liveness story. A probe here could only re-assert that PID 1
# is running.
command:
- /bin/sh
- -c
- |
trap 'kill "$pid"; exit 0' TERM INT
while :; do
socat -u \
"UDP4-RECV:5353,ip-add-membership=224.0.0.251:net1,so-bindtodevice=net1,reuseaddr" \
"UDP4-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
pid=$!
wait "$pid"
echo "mdns-relay: socat-ipv4 exited ($?); restarting in 2s" >&2
sleep 2
done
# No probes. The supervisor loop is the liveness story; a probe here
# could only re-assert that PID 1 is running.
resources:
requests:
cpu: 10m
@@ -114,10 +126,20 @@ spec:
- name: socat-ipv6
image: alpine/socat:1.8.0.3@sha256:beb4a68d9e4fe6b0f21ea774a0fde6c31f580dde6368939ed70100c5385b015e
imagePullPolicy: IfNotPresent
args:
- -u
- "UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr"
- "UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353"
command:
- /bin/sh
- -c
- |
trap 'kill "$pid"; exit 0' TERM INT
while :; do
socat -u \
"UDP6-RECV:5353,ipv6-join-group=[ff02::fb]:net1,so-bindtodevice=net1,ipv6-v6only=1,reuseaddr" \
"UDP6-SENDTO:home-assistant-mdns.home-assistant.svc.cluster.local:5353" &
pid=$!
wait "$pid"
echo "mdns-relay: socat-ipv6 exited ($?); restarting in 2s" >&2
sleep 2
done
resources:
requests:
cpu: 10m
@@ -57,7 +57,14 @@ spec:
# and leaves this one untouched (tags are not deleted), so this keeps
# resolving — but the image then came from the branch build rather than
# master. Re-point it at a master-built tag when convenient.
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
#
# konairius/, not gitea_admin/: this init container runs during a cold
# rebuild, when Gitea does not exist because it runs inside the
# cluster being rebuilt. The node then falls back to a ghcr.io copy
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path when it falls through — so the path has to
# exist on both registries. Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
command:
- sh
- -c
+12 -10
View File
@@ -10,15 +10,15 @@ metadata:
# Kept permanently as a standing guard.
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
# Single instance during pgvecto.rs → VectorChord migration window;
# pg_basebackup tar format chokes on the long pg_vectors/indexes/<uuid>
# paths, blocking replica join. Bump back to 2 once Immich's in-app
# vchord migration drops the vectors extension.
instances: 1
# See ak-pg cluster.yaml for rationale. Single-instance, so no failover, but
# the barman-plugin shutdown wedge (cloudnative-pg#9770) takes the whole DB
# down until SIGKILL — bounding terminationGracePeriod (stopDelay) matters
# even without a standby. smartShutdownTimeout keeps pod restarts quick.
# A replica is viable because nothing here writes pg_vectors any more. Both
# vector indexes run on vchordrq and the vectors extension is gone, so the
# long pg_vectors/indexes/<uuid> paths are absent — pg_basebackup's tar
# format cannot represent them, and that is what blocks a replica join.
instances: 2
# See ak-pg cluster.yaml for rationale. The barman-plugin shutdown wedge
# (cloudnative-pg#9770) holds an instance up until SIGKILL, so bounding
# terminationGracePeriod (stopDelay) is what keeps a failover from waiting
# on it. smartShutdownTimeout keeps pod restarts quick.
smartShutdownTimeout: 10
stopDelay: 60
imageName: ghcr.io/corentingiraud/cnpg-pgvector-vectorchord:16-migration
@@ -36,8 +36,10 @@ spec:
database: immich
owner: immich
postgresql:
# vchord only. The image also ships pgvecto.rs (vectors.so), but its
# extension is not installed and preloading it would recreate pg_vectors
# in PGDATA for nothing. pgvector needs no preloading.
shared_preload_libraries:
- vectors.so
- vchord.so
parameters:
shared_buffers: "512MB"
+61 -3
View File
@@ -25,6 +25,13 @@ metadata:
app.kubernetes.io/name: llama-server
spec:
replicas: 1
# The startupProbe below sanctions a 40 min cold download, but the default
# progressDeadlineSeconds is 600. Left implicit the Deployment declares
# ProgressDeadlineExceeded at 10 minutes while the probe is still legitimately
# waiting, and KubeDeploymentRolloutStuck fires 15 min after that on a rollout
# that is fine. Kept above the startup budget, with room for the image pull
# and scheduling that happen before the first probe.
progressDeadlineSeconds: 3000
# A rolling update would load a second copy of the weights onto the same
# iGPU and into the same node's RAM before releasing the first.
strategy:
@@ -75,11 +82,62 @@ spec:
# many small per-expert matmuls that sets the pace, not bandwidth.
- --threads
- "4"
# 16k rather than 8k because the measured prefill falloff is mild
# and this model's KV cache is unusually cheap — only 10 of its 40
# layers use full attention.
# 16k is room to spare, not a safety limit. The limit that matters
# is not set here at all.
#
# Isolated by bisection on a scratch pod: this stack breaks on
# PROMPT length, and the value of --ctx-size makes no difference to
# it. Measured at --ctx-size 65536 with a single slot, so with four
# times the headroom:
#
# 13588 prompt tokens coherent answer
# 15086 one token, immediate EOS, empty content
# 16088 CJK characters and digit runs
#
# A single request past that boundary then poisons the slot: the
# same 13588-token prompt that answered cleanly a minute earlier
# returns one empty token afterwards, and stays broken until the
# pod restarts. That is why it 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.
#
# Nothing reports any of it: finish_reason "stop", a well-formed
# OpenAI response, HTTP 200, /health green.
#
# So the operating rule is about callers, not this flag. Paperless
# sits near 5k and is safe; Home Assistant's Assist prompt measured
# 18910 and never had a chance. An earlier version of this comment
# blamed --ctx-size 32768, which the bisection disproves — 32768
# and 65536 both answer correctly as long as the prompt stays under
# the boundary.
#
# The same stack also fails hard at --ubatch-size 2048, which is
# the loud version of a limit this one keeps quiet about.
#
- --ctx-size
- "16384"
# One slot, so the 16384 belongs to one request at a time.
#
# Left on auto, llama.cpp opens four slots and — because auto is
# also what enables --kv-unified — has them share a single buffer
# of this size. Four Paperless requests at once wanted 6209, 6472,
# 4303 and 1271 tokens against those 16384, and all four died with
# "Context size has been exceeded".
#
# They were concurrent because a client timeout does not stop the
# generation behind it: each abandoned request keeps consuming the
# shared budget the next one needs, so one slow document is enough
# to start the pile-up. Queueing is the behaviour worth having
# here — nothing calls this server in parallel on purpose, and
# Paperless's celery worker runs at concurrency 1.
#
# No liveness probe change goes with this. The server recovered on
# its own once the batch drained, so a probe that restarted the pod
# would have made the incident worse; with a single slot, one that
# asked for a real completion would also queue behind live work and
# kill healthy pods during a long document.
- --parallel
- "1"
# A micro-batch of 2048 or more makes this Vulkan/Mesa stack fail
# with ErrorDeviceLost.
- --ubatch-size
+43 -17
View File
@@ -19,6 +19,11 @@ metadata:
app.kubernetes.io/name: llama-embed
spec:
replicas: 1
# Same reasoning as llama-server: the startupProbe budget (600s) must stay
# below progressDeadlineSeconds, whose default is also 600 — exactly equal, so
# any cold download that uses most of its budget trips
# KubeDeploymentRolloutStuck on a rollout that is still progressing normally.
progressDeadlineSeconds: 900
strategy:
type: Recreate
selector:
@@ -41,7 +46,7 @@ spec:
image: ghcr.io/ggml-org/llama.cpp:server-vulkan-b10731
args:
- --hf-repo
- Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
- gpustack/bge-m3-GGUF:Q8_0
# Serves POST /v1/embeddings.
- --embeddings
# A short, stable id for consumers. Without it the model is
@@ -50,28 +55,41 @@ spec:
# One name only: llama-server keeps aliases in a sorted set and
# reports the first as the id, so a second name can steal it.
- --alias
- qwen3-embedding-0.6b
# Qwen3-Embedding is trained for last-token pooling. Left
# unspecified llama-server takes the GGUF's default, which is not
# visible from outside and would silently produce vectors the model
# was not trained to emit. Set before anything is indexed: changing
# pooling later invalidates every stored embedding.
- bge-m3
# bge-m3 is trained for CLS pooling — a different type from the
# Qwen family this replaces, which uses last-token. Left unspecified
# llama-server takes the GGUF default, which is not visible from
# outside and would silently produce vectors the model was not
# trained to emit. Read from the model's own 1_Pooling/config.json
# rather than assumed; of the candidates measured, three different
# pooling types were needed.
- --pooling
- last
- cls
- --n-gpu-layers
- "99"
- --threads
- "2"
# Paperless chunks documents at 1024 tokens. The defaults are
# batch 2048 / ubatch 512, and with --embeddings llama-server
# clamps both down to 512 rather than run them mismatched — below
# the chunk size. Matching both to 1024 fits a whole chunk in one
# pass; 2048 is deliberately avoided, since that is where this
# Vulkan stack fails with ErrorDeviceLost.
# Paperless chunks at 1024 tokens, but counts them with its own
# tokenizer while this server measures the batch in bge-m3's.
# Those disagree: XLM-RoBERTa splits German into more pieces than
# llama_index's counter does, so a chunk Paperless considers full
# at 1024 arrived here as 1069 and the request failed outright —
# "input (1069 tokens) is too large to process".
#
# The headroom therefore has to absorb a tokenizer ratio, not just
# a rounding error, and 1.5x covers far more than the 1.044 that
# broke it. The defaults are batch 2048 / ubatch 512, and with
# --embeddings llama-server clamps both to the smaller rather than
# run them mismatched, so both are set.
#
# 2048 stays avoided: that is where the chat model met
# ErrorDeviceLost on this Vulkan stack. That failure came under a
# 20 GB model's memory pressure and may not bind a 600 MB one, but
# this does not need to find out.
- --batch-size
- "1024"
- "1536"
- --ubatch-size
- "1024"
- "1536"
# Room for a full chunk plus overhead rather than exactly one.
- --ctx-size
- "4096"
@@ -91,6 +109,14 @@ spec:
capabilities:
drop:
- ALL
# Measured, not estimated: embedding 400 real documents peaked at
# 1.6 GiB (Prometheus max_over_time on the working set). The limit is
# well above that because the footprint is dominated by the four slots
# at 4096 context and the Vulkan working set rather than by the
# weights — the Qwen model this replaces peaked at 10.6 GiB on the
# same settings for the same 600 MiB of weights, and several
# candidates were OOMKilled at limits that looked generous against
# their model size.
resources:
requests:
cpu: 250m
@@ -98,7 +124,7 @@ spec:
gpu.intel.com/i915: "1"
limits:
cpu: "2"
memory: 4Gi
memory: 8Gi
gpu.intel.com/i915: "1"
# ~600 MiB rather than ~5 GiB, so a much shorter budget than the chat
# model needs, but still sized for a cold download.
+16 -6
View File
@@ -1,10 +1,20 @@
---
# Holds the GGUF weights, downloaded once by the Deployment's initContainer.
# Prune-disabled so retiring this Kustomization does not throw away several
# GiB of model files that would have to be pulled from Hugging Face again.
# Holds the GGUF weights, downloaded once by llama-server itself via --hf-repo.
# Prune-disabled so retiring this Kustomization does not throw away tens of GiB
# of model files that would have to be pulled from Hugging Face again.
#
# ReadWriteMany so a second replica or a one-off benchmark Job can mount the
# same weights; the models are read-only at inference time.
# ReadWriteMany so a second replica, a one-off benchmark Job, or a candidate
# model being evaluated can mount the same weights; the models are read-only at
# inference time.
#
# Sized well beyond the two deployed models. The cache is shared by every
# candidate under evaluation (scripts/embed-eval-real.sh), and on a slow
# connection the download is the long pole — a shared cache lets the large
# candidates pull in the background while the small ones are already being
# measured, and means a model is never fetched twice across runs.
#
# CephFS supports online expansion (allowVolumeExpansion on the storage class),
# so growing this does not disturb the running pods.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@@ -18,4 +28,4 @@ spec:
storageClassName: ceph-filesystem
resources:
requests:
storage: 40Gi
storage: 120Gi
@@ -0,0 +1,46 @@
# Cilium's agent ServiceMonitor, declared here rather than by the chart.
#
# Reproduces what cilium 1.20.1 renders with prometheus.serviceMonitor.enabled,
# taken field for field off the live object so scraping is unchanged: same name,
# same namespace, same selector, same 10s interval and node relabeling.
#
# It lives here because the chart's template is gated on monitoring.coreos.com/v1
# existing at install time, and on an empty cluster it does not — those CRDs
# arrive with kube-prometheus-stack, which reconciles after infrastructure.
# Cilium is one of infrastructure's healthChecks, so the chart aborting on the
# missing CRD held the whole cluster: CNI waiting on monitoring waiting on CNI.
#
# velero/helmrelease.yaml already does exactly this for its own SM/PM and says
# why; this is the same move for the component where it mattered most.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: cilium-agent
namespace: kube-system
labels:
app.kubernetes.io/name: cilium-agent
app.kubernetes.io/part-of: cilium
spec:
selector:
matchLabels:
app.kubernetes.io/name: cilium-agent
namespaceSelector:
matchNames:
- kube-system
# The chart's Service keeps rendering (prometheus.enabled), so the target and
# its `metrics` port are there regardless of who declares the ServiceMonitor.
targetLabels:
- k8s-app
endpoints:
- port: metrics
path: /metrics
interval: 10s
honorLabels: true
relabelings:
# Agent metrics are per-node; without this every series lands without a
# node label and BPF map pressure cannot be attributed to a machine.
- action: replace
sourceLabels:
- __meta_kubernetes_pod_node_name
targetLabel: node
replacement: ${1}
@@ -0,0 +1,36 @@
# Scrapes CrowdSec. Until this landed, nothing did: `up{namespace="crowdsec"}`
# returned zero series and Prometheus held no cs_* metric at all, so the
# detection side of the ingress path was invisible — including whether the
# agents were reading logs and whether any bouncer was pulling decisions.
#
# One monitor for both services. They carry the same shape (a `metrics` port on
# 6060) and differ only in `app`, so a matchExpressions selector keeps this to
# a single object rather than two near-identical ones:
#
# crowdsec-service the LAPI — decisions, bouncer + machine requests
# crowdsec-agent-service the agents — acquisition, parsers, scenarios
#
# The crowdsec-ns CiliumNetworkPolicy already admits the monitoring namespace
# without a port restriction, so no policy change is needed.
#
# `release: kube-prometheus-stack` is what the chart's Prometheus selects on.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: crowdsec
namespace: crowdsec
labels:
app.kubernetes.io/name: crowdsec
release: kube-prometheus-stack
spec:
selector:
matchExpressions:
- key: app
operator: In
values:
- crowdsec-service
- crowdsec-agent-service
endpoints:
- port: metrics
path: /metrics
interval: 30s
@@ -22,7 +22,19 @@ resources:
# endpoint, which proves reachability, not that anything was written.
- prometheusrule-cnpg-backup.yaml
- alloy-servicemonitor.yaml
- cilium-servicemonitor.yaml
- cnpg-podmonitors.yaml
- crowdsec-servicemonitor.yaml
# Velero's own monitors: monitoring.coreos.com CRDs arrive with
# kube-prometheus-stack, which reconciles after infrastructure, so these
# cannot live beside the Velero HelmRelease.
- velero-podmonitor.yaml
- velero-servicemonitor.yaml
- authentik-outpost-servicemonitors.yaml
# Rook's exporter/mgr monitors: the operator aborts its node-daemon reconcile
# when it cannot create these, which held the CephCluster in Progressing and
# deadlocked the whole apps layer. See the file for the cycle.
- rook-servicemonitors.yaml
- home-assistant-servicemonitor.yaml
- immich-servicemonitor.yaml
- loki-servicemonitor.yaml
@@ -58,10 +58,30 @@ spec:
# VeleroBackupFailed alert fires on the first failure regardless — so
# nothing is hidden, only the escalation to critical is deferred
# until a second cycle confirms it.
#
# The `and on(schedule) velero_backup_last_status` gate exists because
# velero_backup_last_successful_timestamp is an in-process gauge that
# velero does NOT retract when a Schedule is deleted: the series just
# stops being updated, ages past 60h and pages critical forever, for a
# schedule that no longer exists. That is what decommissioning
# netbird-backup-daily did on 2026-09-01.
#
# velero_backup_last_status is the same shape but IS retracted on
# Schedule deletion, so it works as a liveness set for "this schedule
# still exists". It hides nothing: velero repopulates it for every
# existing Schedule on its first scrape after a restart (verified
# across the three pods of the 2026-08-27 rollout), and it is written
# on failed runs too (0) as well as successful ones (1) — so a
# schedule that exists and is failing, or that exists and has stopped
# firing, still carries the series and still alerts.
expr: |
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
and
velero_backup_last_successful_timestamp{schedule!=""} > 0
(
time() - velero_backup_last_successful_timestamp{schedule!=""} > 60 * 3600
and
velero_backup_last_successful_timestamp{schedule!=""} > 0
)
and on(schedule)
velero_backup_last_status
for: 10m
labels:
severity: critical
@@ -0,0 +1,93 @@
# Rook's two ServiceMonitors, declared here rather than by the operator.
#
# Reproduced field for field off the live objects in the production cluster, so
# scraping is unchanged: same names, same namespace, same selectors, same 10s
# interval and `cluster` relabeling. The only thing dropped is the CephCluster
# ownerReference, which Rook set and Flux cannot.
#
# They live here because CephCluster.spec.monitoring.enabled is now false, and
# that is not a monitoring decision — it breaks a deadlock. Rook's node-daemon
# reconcile creates this ServiceMonitor and, unlike the exporter DaemonSet next
# to it, treats a failure as fatal (pkg/operator/ceph/cluster/nodedaemon/
# reconcile.go, v1.20.7):
#
# if !cephCluster.Spec.Monitoring.MetricsDisabled {
# // exporter reconciled; its errors are logged and reconcile continues
# if cephCluster.Spec.Monitoring.Enabled {
# if err := EnableCephExporterServiceMonitor(...); err != nil {
# return errors.Wrap(err, "failed to enable service monitor")
# }
# }
# }
#
# On an empty cluster monitoring.coreos.com/v1 does not exist yet, so that call
# fails with "the server could not find the requested resource (post
# servicemonitors.monitoring.coreos.com)", the whole node-daemon reconcile
# aborts, and the CephCluster never leaves status.phase Progressing. That is
# what infrastructure-ceph-cluster's healthCheckExpr waits on — and the chain
# closes on itself:
#
# infrastructure-ceph-cluster → CephCluster phase Ready
# → Rook reconcile settles
# → monitoring.coreos.com CRDs exist
# → kube-prometheus-stack (monitoring)
# → grafana-db
# → infrastructure-ceph-cluster
#
# Nothing outside that loop can break it, so a cold rebuild stopped here with
# every one of the twenty app Kustomizations behind it.
#
# Same treatment velero/helmrelease.yaml and cilium/helmrelease.yaml already
# get, and for the same reason: the metrics stay where they are, only the
# scrape wiring moves to the layer where its CRDs exist.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: rook-ceph-exporter
namespace: rook-ceph
labels:
team: rook
spec:
selector:
matchLabels:
app: rook-ceph-exporter
rook_cluster: rook-ceph
namespaceSelector:
matchNames:
- rook-ceph
endpoints:
- port: ceph-exporter-http-metrics
path: /metrics
interval: 10s
honorLabels: true
# Rook stamps the cluster name so series from several Ceph clusters stay
# separable. One cluster here, but dropping it would rename every series.
relabelings:
- action: replace
replacement: rook-ceph
targetLabel: cluster
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: rook-ceph-mgr
namespace: rook-ceph
labels:
team: rook
spec:
selector:
matchLabels:
app: rook-ceph-mgr
rook_cluster: rook-ceph
namespaceSelector:
matchNames:
- rook-ceph
endpoints:
- port: http-metrics
path: /metrics
interval: 10s
honorLabels: true
relabelings:
- action: replace
replacement: rook-ceph
targetLabel: cluster
@@ -0,0 +1,163 @@
{
"annotations": {"list": []},
"description": "CrowdSec: Erkennung im Cluster, Durchsetzung am NetBird-Edge",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Aktive Ban-Entscheidungen in der LAPI, ganz überwiegend aus den CAPI-Community-Blocklisten. Das ist die Menge, die ein scharfgeschalteter Bouncer durchsetzt.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}, "unit": "short"},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
"id": 1,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(cs_active_decisions)", "refId": "A"}],
"title": "Aktive Decisions",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Bouncer, die in den letzten 10 Minuten Decisions abgeholt haben. Fällt das unter die erwartete Zahl, holt ein Bouncer nichts mehr — und setzt damit einen veralteten Stand durch, ohne dass irgendetwas ausfällt.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, "unit": "short"},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 6, "y": 0},
"id": 2,
"options": {"colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "count(sum by (bouncer) (rate(cs_lapi_bouncer_requests_total[10m])) > 0)", "refId": "A"}],
"title": "Bouncer, die abholen",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Vom Agent gelesene Logzeilen pro Sekunde. Steht das auf 0, detektiert CrowdSec nichts mehr — der häufigste stille Ausfall, weil alles andere weiterläuft.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 0.01}]}, "unit": "reqps"},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"id": 3,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_filesource_hits_total[5m]))", "refId": "A"}],
"title": "Gelesene Logzeilen/s",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Szenario-Overflows der letzten 24 h — also lokal erkannte Angriffe, im Gegensatz zu den von CAPI übernommenen.",
"fieldConfig": {
"defaults": {"color": {"mode": "thresholds"}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "text", "value": null}]}, "unit": "short"},
"overrides": []
},
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"id": 4,
"options": {"colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto"},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(increase(cs_bucket_overflowed_total[24h]))", "refId": "A"}],
"title": "Lokale Overflows (24 h)",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Abholrate je Bouncer. Der Firewall-Bouncer auf den Proxies fragt alle 10 s. Die LAPI legt bei jeder neuen beobachteten Client-Adresse einen Eintrag `<name>@<ip>` an, und beobachtet wird die Pod-IP des netbird-ingress-Peers — deshalb erscheint ein logischer Bouncer hier mehrfach und nach Pod-Rollen unter neuen Namen.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
"id": 5,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (bouncer) (rate(cs_lapi_bouncer_requests_total[5m]))", "legendFormat": "{{bouncer}}", "refId": "A"}],
"title": "Bouncer-Abholrate",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Aktive Decisions nach Grund. `http:scan` dominiert und stammt aus den CAPI-Blocklisten.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "short"},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
"id": 6,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (reason) (cs_active_decisions)", "legendFormat": "{{reason}}", "refId": "A"}],
"title": "Aktive Decisions nach Grund",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Welche Szenarien lokal auslösen. Das ist das, was CrowdSec selbst aus den Traefik-Logs erkennt.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "bars", "fillOpacity": 70, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "normal"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "short"},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 12},
"id": 7,
"options": {"legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (name) (increase(cs_bucket_overflowed_total[1h]))", "legendFormat": "{{name}}", "refId": "A"}],
"title": "Szenario-Overflows (je Stunde)",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Geparste gegen nicht geparste Zeilen. Ein Anstieg bei `ko` heißt meist, dass sich ein Logformat geändert hat und der Parser nicht mehr greift — CrowdSec liest dann weiter, erkennt aber nichts mehr.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 12},
"id": 8,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_parser_hits_ok_total[5m]))", "legendFormat": "geparst", "refId": "A"},
{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum(rate(cs_parser_hits_ko_total[5m]))", "legendFormat": "nicht geparst", "refId": "B"}
],
"title": "Parser",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Whitelist-Treffer nach Grund. Nützlich beim Scharfschalten: was hier zählt, wäre sonst ein Kandidat für einen Ban gewesen.",
"fieldConfig": {
"defaults": {"color": {"mode": "palette-classic"}, "custom": {"axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": {"legend": false, "tooltip": false, "viz": false}, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": {"type": "linear"}, "showPoints": "never", "spanNulls": false, "stacking": {"group": "A", "mode": "none"}, "thresholdsStyle": {"mode": "off"}}, "mappings": [], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, "unit": "reqps"},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 20},
"id": 9,
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "right", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"datasource": {"type": "prometheus", "uid": "prometheus"}, "expr": "sum by (reason) (rate(cs_node_wl_hits_ok_total[5m]))", "legendFormat": "{{reason}}", "refId": "A"}],
"title": "Whitelist-Treffer",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 20},
"id": 10,
"options": {
"code": {"language": "plaintext", "showLineNumbers": false, "showMiniMap": false},
"content": "## Was dieses Dashboard NICHT zeigt\n\nAlle Panels stammen aus der LAPI und den Agents im Cluster. Die **Bouncer auf proxy-1 und proxy-2 exportieren eigene Metriken**, die hier fehlen:\n\n- `fw_bouncer_active_decisions` — was tatsächlich in den nftables-Sets steht\n- `fw_bouncer_dropped_packets` / `_bytes` — was wirklich verworfen wurde\n- `lapi_requests_total` / `_failures_total` — aus Sicht des Bouncers\n\nSie liegen auf `127.0.0.1:60601` der Proxy-Hosts. Die Hosts sind öffentlich und haben bisher keinerlei Metrik-Pfad; den Port ins Internet zu öffnen, nur um ein Scrape-Ziel zu bekommen, wäre der falsche Tausch.\n\n**Konsequenz:** „Bouncer-Abholrate\" oben zeigt, dass ein Bouncer *fragt* — nicht, dass er die Antwort auch angewandt hat. Solange der Modus `dry-run` ist, wendet er ohnehin nichts an.\n\nEin Scrape-Pfad für die Hosts ist eigene Arbeit und Voraussetzung dafür, dass „was wird wirklich geblockt\" beantwortbar wird."
},
"title": "Blinder Fleck",
"type": "text"
}
],
"refresh": "30s",
"schemaVersion": 38,
"style": "dark",
"tags": ["homelab", "crowdsec", "security"],
"templating": {"list": []},
"time": {"from": "now-6h", "to": "now"},
"timepicker": {},
"timezone": "",
"title": "CrowdSec Overview",
"uid": "crowdsec-overview",
"version": 1,
"weekStart": ""
}
@@ -38,6 +38,9 @@ configMapGenerator:
- name: dashboard-blackbox-overview
files:
- blackbox-overview.json
- name: dashboard-crowdsec-overview
files:
- crowdsec-overview.json
- name: dashboard-disk-health
files:
- disk-health.json
@@ -55,7 +55,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -65,7 +65,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.1 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.2 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+19 -4
View File
@@ -82,7 +82,10 @@ data:
# primarily English archives — the better fit for a German corpus.
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "qwen3-embedding-0.6b"
# Must match --alias in kubernetes/apps/llama-server/embed.yaml. Changing the
# embedding model invalidates every stored vector, so a swap means the LLM
# index has to be rebuilt.
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "bge-m3"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
apiVersion: apps/v1
@@ -140,7 +143,20 @@ spec:
# on every paperless pod start: tools belong in the image, not
# installed at runtime. Dated tag so a change is a reviewable diff
# here, matching the ci-ansible / alpine-jq convention.
image: gitea.renner.uno/gitea_admin/alpine-openssl:2026-07-30
#
# konairius/, not gitea_admin/, and that is not cosmetic. This is an
# INIT container, so paperless does not start without it — and during
# a cold rebuild gitea.renner.uno does not exist, because Gitea runs
# inside the cluster being rebuilt. A rehearsal left this pod in
# Init:ErrImagePull on exactly this image.
#
# The node falls back to a ghcr.io copy in that window
# (machine.registries.mirrors, applied by bootstrap.sh). Containerd
# keeps the repository path when it falls through, so the path has to
# exist on both registries — hence konairius/, which matches the ghcr
# namespace. Steady state still pulls from Gitea and never leaves the
# network.
image: gitea.renner.uno/konairius/alpine-openssl:2026-09-04
command:
- sh
- -c
@@ -167,7 +183,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -322,7 +338,6 @@ spec:
- port: 80
targetPort: http
---
---
# ExternalName mirror so the paperless-namespace Ingress can backend to
# the gate outpost in the authentik namespace (cross-namespace Service
# refs aren't supported in standard Ingress).
+36
View File
@@ -0,0 +1,36 @@
# trmnl
Terminus (self-hosted TRMNL BYOS server) plus the headless renderer that
screenshots the Home Assistant entrance dashboard and pushes it in. One
device: a TRMNL X at the entrance, 1872x1404, 16 greys.
## Device firmware
Terminus mirrors one model-agnostic binary and it is the **ESP32-C3** (OG)
build. This panel is a TRMNL X, i.e. ESP32-S3, so that file is not the
artefact for a manual flash here. Use
`https://trmnl-fw.s3.us-east-2.amazonaws.com/trmnl_x/flash/FW<version>.bin`
— a merged image at offset `0x0` — and run `esptool image-info` on
anything before writing it.
The panel runs 1.8.16, flashed over USB on 2026-09-05. It shipped on
1.8.6, where turning on `firmware_update` froze the screen for as long as
an update was offered (fixed upstream in 1.8.7, commit `9b7a0f7`).
## Image automation
Both images are enrolled (`infrastructure/image-automation/terminus.yaml`,
`trmnl-renderer.yaml`) and track the latest `0.x`. A bad bump on either
surfaces as a **blank or corrupted panel**, not as a failed rollout — no
readiness probe can tell that a rendered screen became unreadable. Look at
the panel after a bump lands; revert the tag if it is wrong.
## Device endpoint
`trmnl-device.renner.uno` is the device-facing name, TLS via Traefik
(`device-ingress.yaml`). `API_URI` must carry the same scheme the device
polls with, because Terminus bakes it into the absolute `image_url` it
returns. The LoadBalancer in `device-service.yaml` keeps `10.2.99.89`
without a hostname as a cleartext fallback that depends on neither
Traefik nor certificate renewal — the captive portal accepts a bare
address.
+52
View File
@@ -0,0 +1,52 @@
# Device-facing endpoint, over TLS.
#
# This replaces the reason the cleartext LoadBalancer in device-service.yaml
# was given a hostname at all. That file claimed the firmware "would reject
# the internal CA" — but nothing here is signed by an internal CA:
# letsencrypt-cluster solves DNS-01 against the renner.uno Cloudflare zone,
# so trmnl.renner.uno already serves a public Let's Encrypt certificate
# (issuer CN=YR2) that any client with a normal CA bundle trusts. A
# LAN-only name gets the same treatment — DNS-01 never needs the host to be
# publicly reachable — so the device can speak HTTPS like everything else.
#
# The hostname moves here from the Service's external-dns annotation rather
# than being duplicated: two sources claiming trmnl-device.renner.uno would
# have external-dns flapping the record between the Traefik VIP and the
# device LB. The LoadBalancer keeps its address and stays reachable at
# http://10.2.99.89 as a nameless fallback if TLS ever turns out to be the
# thing that breaks.
#
# CONSEQUENCE, and the reason API_URI in terminus.yaml changes with this:
# Traefik's `web` entrypoint carries a global http -> websecure redirect
# (helmrelease.yaml: `to: websecure, scheme: https, permanent: true`), so
# once this name points at Traefik, http://trmnl-device.renner.uno answers
# 308 rather than serving. Whether the firmware follows that is not
# documented, so the device is provisioned with the https URL and Terminus
# hands back https image_urls to match. No mixed-scheme fetch.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: terminus-device
namespace: trmnl
# No external-dns hostname annotation: external-dns-unifi runs with
# --source=ingress, so it registers the hosts in spec.rules on its own.
# Same as the terminus Ingress next door.
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
ingressClassName: traefik
tls:
- hosts:
- trmnl-device.renner.uno
secretName: trmnl-device-tls
rules:
- host: trmnl-device.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: terminus
port:
number: 80
+21 -10
View File
@@ -1,15 +1,24 @@
# Device-facing endpoint. Cleartext HTTP, deliberately.
# Cleartext fallback for the device, reachable by address only.
#
# The TRMNL firmware initiates every request and cannot be handed the
# internal CA, so terminating TLS here would simply break polling. Scope of
# the exposure: this Service is LAN-only (no public DNS label, no Cloudflare
# record), it carries no admin session — the device authenticates with its
# MAC in the `ID` header plus a per-device api_key — and the Terminus admin
# UI stays on the TLS route at trmnl.renner.uno.
# This used to be THE device endpoint, on the premise that the firmware
# "cannot be handed the internal CA". The premise was wrong: nothing here
# hands out an internal CA for these names — letsencrypt-cluster solves
# DNS-01 against the renner.uno Cloudflare zone and issues publicly-trusted
# certificates for LAN-only hosts, which is why trmnl.renner.uno has always
# served an issuer CN=YR2 certificate. The device speaks HTTPS via
# device-ingress.yaml, which now owns the trmnl-device.renner.uno name.
#
# Kept, without a hostname, because it is the one path to Terminus that
# depends on neither Traefik nor certificate renewal: http://10.2.99.89
# still serves /api/setup and /api/display. If TLS is ever what breaks the
# panel, the recovery is to type that address into the captive portal.
#
# Scope of the exposure is unchanged: LAN-only (no public DNS label, no
# Cloudflare record), no admin session — the device authenticates with its
# MAC in the `ID` header plus a per-device api_key.
#
# Same shape as home-assistant-bgp: a dedicated address on the Cilium BGP
# pool rather than a TLS-less Ingress, which would be a new pattern for this
# repo. The device is provisioned with the HOSTNAME, not the address.
# pool.
#
# The address is requested explicitly, matching every other LB service here.
# Left to allocate freely, Cilium IPAM handed out the first free address in
@@ -30,8 +39,10 @@ metadata:
labels:
app.kubernetes.io/name: terminus
lb-pool: bgp
# No external-dns hostname: device-ingress.yaml claims that name. Two
# sources claiming trmnl-device.renner.uno would flap the record between
# the Traefik VIP and this address (external-dns runs --policy=sync).
annotations:
external-dns.alpha.kubernetes.io/hostname: trmnl-device.renner.uno
lbipam.cilium.io/ips: "10.2.99.89,fd99::89"
spec:
type: LoadBalancer
+3
View File
@@ -6,6 +6,9 @@ resources:
- valkey.yaml
- terminus.yaml
- ingress.yaml
# TLS device endpoint (owns trmnl-device.renner.uno) plus the nameless
# cleartext LoadBalancer it falls back to.
- device-ingress.yaml
- device-service.yaml
# Headless HA screenshotter. Lives in this namespace because Terminus
# FETCHES rendered screens back from it (uri delivery mode), so the
+13 -10
View File
@@ -177,7 +177,14 @@ spec:
# webhook start failing with "expired JWT access token" about
# thirty minutes after seeding.
- name: render-schedule
image: gitea.renner.uno/gitea_admin/alpine-jq:2026-07-30
# konairius/, not gitea_admin/: this container starts during a cold
# rebuild, when Gitea does not exist because it runs inside the
# cluster being rebuilt — a rehearsal left it in Init:ImagePullBackOff.
# The node falls back to a ghcr.io copy then
# (machine.registries.mirrors, applied by bootstrap.sh), and containerd
# keeps the repository path, so it has to exist on both registries.
# Steady state still pulls from Gitea.
image: gitea.renner.uno/konairius/alpine-jq:2026-09-04
command:
- sh
- -c
@@ -227,16 +234,12 @@ spec:
capabilities:
drop: ["ALL"]
containers:
# Pinned, not enrolled in image automation — same reasoning as
# Terminus itself. This drives a wall-mounted panel and upstream
# is pre-1.0; an unattended bump that changes the dithering
# pipeline or the webhook payload shows up as a corrupted
# screen, not as a failed rollout.
#
# Note the image is ARCH-SUFFIXED: upstream publishes no
# multi-arch manifest, only trmnl-ha-amd64 and trmnl-ha-aarch64.
# Enrolled in image automation (flux-system:trmnl-renderer). A bad
# bump shows up as a corrupted screen, not a failed rollout — check
# the panel after one lands. ARCH-SUFFIXED image: upstream publishes
# no multi-arch manifest.
- name: renderer
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.9.5
image: ghcr.io/usetrmnl/trmnl-ha-amd64:0.10.3 # {"$imagepolicy": "flux-system:trmnl-renderer"}
ports:
- name: http
containerPort: 10000
+13 -7
View File
@@ -7,11 +7,17 @@ data:
TZ: "Europe/Berlin"
HANAMI_PORT: "2345"
# What the device is told to fetch from. Terminus bakes this into the
# absolute `image_url` it hands back on /api/display, so it MUST be the
# cleartext LAN name, not trmnl.renner.uno — the firmware speaks plain
# HTTP and would reject the internal CA. No trailing slash: the same
# string is typed into the captive portal at provisioning time.
API_URI: "http://trmnl-device.renner.uno"
# absolute `image_url` it hands back on /api/display, so it has to be a
# name the DEVICE can resolve and fetch — and it has to carry the same
# scheme the device polls with, or every screen fetch is a second,
# cleartext connection. No trailing slash: the same string is typed into
# the captive portal at provisioning time.
#
# https since device-ingress.yaml took over trmnl-device.renner.uno. The
# earlier value was http on the theory that the firmware would reject an
# internal CA; there is no internal CA here — letsencrypt-cluster issues
# publicly-trusted certs for LAN-only names via DNS-01.
API_URI: "https://trmnl-device.renner.uno"
KEYVALUE_URL: "redis://terminus-valkey:6379"
# Rack::Attack rate-limit bypass. Beyond upstream's RFC1918 defaults this
# adds fc00::/7, which covers the pod CIDR (fd10:244::/48), the Cilium LB
@@ -128,7 +134,7 @@ spec:
# is a device-bricking class of risk. Bumps are a reviewed, manual
# act — see kubernetes/infrastructure/image-automation/README.md.
- name: terminus
image: ghcr.io/usetrmnl/terminus:0.68.0
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
ports:
- name: http
containerPort: 2345
@@ -191,7 +197,7 @@ spec:
capabilities:
drop: ["ALL"]
- name: sidekiq
image: ghcr.io/usetrmnl/terminus:0.68.0
image: ghcr.io/usetrmnl/terminus:0.72.0 # {"$imagepolicy": "flux-system:terminus"}
command: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"]
envFrom:
- configMapRef:
-15
View File
@@ -1,18 +1,3 @@
# Dedicated namespace where the kubernetes backend spawns pipeline step pods,
# isolated from the apps namespace.
apiVersion: v1
kind: Namespace
metadata:
name: woodpecker-ci
labels:
app.kubernetes.io/name: woodpecker
# Image-build steps run a privileged docker/buildx plugin pod (see the
# server's WOODPECKER_PLUGINS_PRIVILEGED). Talos enforces Pod Security;
# without this the privileged step pods are rejected at admission. The
# namespace is dedicated to ephemeral CI step pods, so scoping the
# exemption here keeps it off apps/.
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: v1
kind: ServiceAccount
metadata:
+1384 -60
View File
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,19 @@ spec:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
# ha-pg-objectstore.yaml declares an ObjectStore, whose CRD comes from the
# CNPG Barman plugin in infrastructure-cnpg-barman. Without this edge the
# kind is unknown when this path is first applied, and a server-side
# dry-run rejects the ENTIRE set over the one object it cannot recognise:
#
# ObjectStore/home-assistant/ha-pg-store dry-run failed:
# no matches for kind "ObjectStore" in version ...
#
# Every other ObjectStore user — authentik, immich, paperless, trmnl,
# woodpecker — already declares it. This was the only one that did not, and
# it took speech-to-phrase, zone-configurator and matter-server down with
# it, since all three wait on home-assistant.
- name: infrastructure-cnpg-barman
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
@@ -122,8 +122,20 @@ spec:
path: ./kubernetes/infrastructure/rook-ceph-cluster
prune: true
wait: true
dependsOn:
- name: infrastructure
# Deliberately NOT dependsOn: infrastructure. That edge closed a cycle no
# existing cluster could ever hit and no new one could ever escape:
# infrastructure is Ready only once its openbao healthCheck passes, openbao-0
# only runs once its PVC binds, and that PVC wants the ceph-block StorageClass
# defined in this very Kustomization. A cold rebuild stopped here for good,
# with openbao-0 Pending on "unbound immediate PersistentVolumeClaims".
#
# The edge was never needed. infrastructure runs wait: false, so it applies
# the rook-ceph namespace and the operator immediately and only its readiness
# waits on the healthChecks — everything this path needs exists long before
# infrastructure reports Ready. On an empty API server this Kustomization
# retries on retryInterval until the operator installs its CRDs, the same
# self-healing shape post-crds already relies on; on a live cluster the CRDs
# are always present and nothing changes.
# kstatus doesn't know how to read Rook CR readiness, so without these
# this Kustomization reports Ready as soon as the manifests apply — long
# before OSDs/mons/MDS are actually serving I/O. Apps depending on this
@@ -70,9 +70,23 @@ spec:
# map only shows up as "no space left on device" in the log while pod
# sandbox creation fails (#452). cilium_bpf_map_pressure makes it
# alertable beforehand.
#
# That reasoning is about the metrics, which this line provides. The
# ServiceMonitor is only the scrape wiring and is declared standalone in
# apps/monitoring-rules instead — the same treatment velero/helmrelease
# already gives its own, and for the same reason: the chart's template is
# gated on monitoring.coreos.com/v1 existing at install time.
#
# Here it is worse than for velero. Cilium's chart does not skip the
# template, it aborts rendering — "Service Monitor requires
# monitoring.coreos.com/v1 CRDs" — and cilium is one of the seven
# healthChecks the infrastructure Kustomization waits on. So on an empty
# cluster the CNI blocked infrastructure, which blocked grafana-db, which
# blocked monitoring, which is where those CRDs come from. The lowest
# layer in the cluster was waiting on the highest.
enabled: true
serviceMonitor:
enabled: true
enabled: false
bpf:
# Per-endpoint policy map size: one entry per (identity, direction,
# protocol, port) allowed. When it fills, endpoint regeneration fails
@@ -937,7 +937,7 @@ subjects:
# with no marker. Re-apply this block, or the skew silently returns.
apiVersion: v1
stringData:
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
SIDECAR_IMAGE: ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin-sidecar"}
kind: Secret
metadata:
name: plugin-barman-cloud-kg5c2795f7
@@ -998,7 +998,7 @@ spec:
secretKeyRef:
key: SIDECAR_IMAGE
name: plugin-barman-cloud-kg5c2795f7
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.14.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
image: ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.15.0 # {"$imagepolicy": "flux-system:cnpg-barman-plugin"}
name: barman-cloud
ports:
- containerPort: 9090
@@ -36,6 +36,51 @@ spec:
values:
container_runtime: containerd
# Addresses a community blocklist must never be allowed to ban.
#
# This is the ONLY declarative allowlist the chart offers. `cscli
# allowlists` objects live in the LAPI database, and that database runs
# without persistence here on purpose (see lapi.persistentVolume below),
# so anything created through cscli is gone at the next restart. This file
# is a ConfigMap mount, so it survives by construction. Upstream marks it
# deprecated in favour of centralized allowlists; it is still the only
# thing that works against a LAPI with no PVC.
#
# Scope: CAPI decisions only. Locally generated decisions are not filtered
# here — they currently carry real client IPs (verified), because Traefik
# resolves X-Forwarded-For past the netbird-ingress pod IP.
#
# The home WAN address is deliberately absent and is not needed: LAN
# clients never present it. opentofu/unifi_nat_hairpin.tf DNATs the public
# proxy addresses to the Traefik VIP on the UDM, so on-LAN traffic reaches
# the cluster directly with a LAN source and never traverses the proxies.
# Private ranges cannot appear in a community blocklist either way.
config:
capi_whitelists.yaml: |
cidrs:
# The proxy hosts themselves. Same three addresses as the
# netbird-ingress-egress toCIDRSet and the hairpin endpoints —
# keep all three lists in sync. proxy-1 is the v6-only member,
# proxy-2 the dual-stack one.
- 45.157.177.26/32
- 2a03:4000:4b:c1e:4474:43ff:fedf:8f85/128
- 2a03:4000:28:f6e:a4c7:59ff:fe1e:9a29/128
# netbird.renner.uno — management, signal, relay and TURN.
#
# Not optional once a bouncer is armed. Its chain sits on the INPUT
# hook, the proxy hosts run no other firewall, and there is therefore
# no conntrack rule accepting established flows ahead of it. A ban on
# this address would drop the RETURN traffic of the proxies' own
# outbound control connection and take them off the mesh — with no
# inbound attack involved at all.
#
# Checked when this was added: none of these addresses appear in the
# current decision set. That is the state today, not a guarantee —
# these are rented addresses on a hoster whose ranges do end up on
# community blocklists.
- 162.55.50.97/32
- 2a01:4f8:c2c:4579::1/128
# mTLS between agents and the LAPI. Do not switch back to password auth:
# the LAPI runs without persistence, so its machine-registration DB is
# wiped on every restart, and password-registered agents then get 401
@@ -54,7 +99,7 @@ spec:
# required.
#
# NOTE: enabling this makes the LAPI serve HTTPS-only. The Traefik ingress
# bouncer is currently INERT (detached, see traefik/crowdsec-middleware.yaml)
# bouncer is currently INERT (detached, see post-crds/traefik-crowdsec-middleware.yaml)
# so there is no live impact, but re-enabling it will require pointing the
# plugin at https + trusting this CA (or giving it a bouncer-ou client cert).
tls:
@@ -78,6 +123,24 @@ spec:
lapi:
replicas: 1
service:
# Backend wiring for the public LAPI Ingress (lapi-ingress.yaml).
#
# These belong on the SERVICE, not on the Ingress: under the Kubernetes
# Ingress provider `traefik.ingress.kubernetes.io/service.*` are service
# annotations, and on an Ingress they are silently ignored. The symptom
# is Traefik speaking plaintext to a TLS-only backend, which the LAPI
# answers with "Client sent an HTTP request to an HTTPS server." and a
# 400 — an error that looks like a malformed request rather than a
# scheme mismatch.
#
# With tls.enabled the LAPI serves HTTPS on 8080 and offers no
# plaintext port, so https here is mandatory rather than a hardening
# choice. The ServersTransport carries the chart's own CA, since that
# certificate is self-signed.
annotations:
traefik.ingress.kubernetes.io/service.serversscheme: https
traefik.ingress.kubernetes.io/service.serverstransport: crowdsec-crowdsec-lapi@kubernetescrd
# Far below the chart default, which reserves half a core per LAPI for a
# workload that draws single-digit millicores — enough to leave the
# cluster unschedulable with one node out. Only the reservation shrinks;
@@ -86,10 +149,18 @@ spec:
requests:
cpu: 50m
podAnnotations:
# Reloader restarts the LAPI when crowdsec-bouncer-key changes (it's an
# env-var secret, so a restart is required to pick up a rotated key). See
# Reloader restarts the LAPI when either bouncer key changes. Both are
# consumed as env vars from a secretKeyRef, and env is fixed at pod
# start — without a restart the LAPI keeps trusting the old value while
# the bouncer already presents the new one, which is a 401 at exactly
# the moment a rotation is supposed to be seamless.
#
# crowdsec-bouncer-key rotates on a schedule, see
# kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml.
secret.reloader.stakater.com/reload: crowdsec-bouncer-key
# crowdsec-edge-bouncer-key rotates by hand together with
# ansible/plays/install-netbird-proxy.yml; it still needs to be listed
# here, because the LAPI side of that rotation is this env var.
secret.reloader.stakater.com/reload: crowdsec-bouncer-key,crowdsec-edge-bouncer-key
persistentVolume:
data:
# Kept OFF deliberately. The #337 attempt to set this `true` (persist
@@ -118,6 +189,17 @@ spec:
secretKeyRef:
name: crowdsec-bouncer-key
key: key
# The firewall bouncers on proxy-1/proxy-2, over the public LAPI
# endpoint (lapi-ingress.yaml). The chart's entrypoint re-runs
# `register_bouncer` for every BOUNCER_KEY_* on each start and skips
# names that already exist, so this survives the LAPI's wiped
# registration DB the same way BOUNCER_KEY_ingress does — the env var
# is the source of truth, not the database.
- name: BOUNCER_KEY_edge
valueFrom:
secretKeyRef:
name: crowdsec-edge-bouncer-key
key: key
agent:
# Same reasoning as lapi.resources above, multiplied by three: this is a
@@ -135,6 +217,32 @@ spec:
- namespace: ingress
podName: traefik-*
program: traefik
# Read by polling, not inotify. Every path under
# /var/log/containers is a symlink into /var/log/pods, and kubelet
# rotates the target once it reaches containerLogMaxSize (10Mi,
# roughly every eight hours for Traefik here). An inotify tailer
# does not survive that rotation: it reopens the path, waits for a
# file that is in fact already present, and never reads another
# line. Nothing about that state looks broken from outside — the
# pod stays Running and Ready, the LAPI keeps answering, and the
# cumulative counters keep displaying whatever they reached, so the
# only symptom is that no scenario ever fires again.
#
# CrowdSec detects the hazard itself and logs it at startup, with
# this exact remedy in the message: "is a symlink, but inotify
# polling is enabled. Crowdsec will not be able to detect rotation.
# Consider setting poll_without_inotify to true". Upstream carries
# the same case as crowdsec#2449 (k3s + Traefik), and the chart
# gained this field in helm-charts#109 specifically to fix it.
#
# Orthogonal to the force_inotify: true that the chart hardcodes
# into acquis.yaml and offers no way to unset. That one governs the
# fsnotify watch on the directory, i.e. discovering the log of a
# replacement Traefik pod; this one governs how an already
# discovered file is read. Both true is the supported combination.
#
# Costs CPU per open file per upstream docs — two files per node.
poll_without_inotify: true
env:
- name: COLLECTIONS
value: "crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
@@ -1,5 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- bouncer-key-externalsecret.yaml
- lapi-ingress.yaml
- helmrelease.yaml
@@ -0,0 +1,85 @@
# Public LAPI endpoint for the firewall bouncers on proxy-1/proxy-2.
#
# WHY PUBLIC AT ALL. The bouncers run as host processes on the proxies, and
# the proxies have no mesh access: the netbird-proxy container runs non-root
# (see the ip_unprivileged_port_start task in the netbird-proxy role), so it
# has no NET_ADMIN and no /dev/net/tun, and its embedded NetBird client
# therefore terminates the tunnel in userspace. The tunnel belongs to that
# process, not to the host netns — a second process on the same host cannot
# route through it. The alternative was a host-level NetBird client plus the
# LAPI on a LoadBalancer VIP; this reuses the path every other service already
# takes instead of adding two peers and an ACL policy.
#
# NO IP ALLOWLIST, deliberately. The bouncer key is the access control.
#
# An allowlist was the obvious second factor and it does not work here. A
# request originating on a proxy host does not arrive carrying that host's
# public address: the client connects to the local proxy on 127.0.0.1, the
# reverse-proxy puts what it saw into X-Forwarded-For, and Traefik resolves
# that. Measured against the netbird-proxy-watchdog, which makes exactly this
# kind of request every round:
#
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-immich-...
# 172.18.0.3 - - [02/Sep/2026:10:12:01] "GET /" 200 websecure-home-...
#
# 172.18.0.3 is a Docker bridge address on the proxy side. Allowlisting the
# published proxy addresses would have rejected the bouncer with 403;
# allowlisting 172.18.0.0/16 would be a private range on a host we do not
# control the addressing of, which is not a security control.
#
# What remains is the API key, which is the real control anyway: this endpoint
# serves read-only decision streams to a valid bouncer key and nothing else.
# The admin surface is cscli, in-cluster. If the source address ever becomes
# deterministic, an ipAllowList can be added on top.
---
# The LAPI serves HTTPS only once tls.enabled is set — there is no plaintext
# fallback and no second port; 8080 is the TLS port. Its certificate comes
# from the chart's own self-signed CA, so Traefik needs that CA to verify the
# backend. SANs include crowdsec-service.crowdsec.svc.cluster.local, which is
# what the Service resolves to.
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: crowdsec-lapi
namespace: crowdsec
spec:
serverName: crowdsec-service.crowdsec.svc.cluster.local
rootCAs:
- secret: crowdsec-root-secret
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: crowdsec-lapi
namespace: crowdsec
labels:
# Public DNS via Cloudflare; the proxies resolve it from outside. The UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
# NOT behind the Authentik gate: the client is a daemon with an API key,
# not a browser that can complete an OIDC flow.
#
# The backend scheme and ServersTransport are NOT set here. Under the
# Kubernetes Ingress provider, `traefik.ingress.kubernetes.io/service.*`
# are SERVICE annotations — on an Ingress they are silently ignored, and
# Traefik then talks plaintext to a TLS-only backend. They live on the
# chart's Service instead, via lapi.service.annotations in helmrelease.yaml.
spec:
ingressClassName: traefik
rules:
- host: crowdsec.renner.uno
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: crowdsec-service
port:
number: 8080
tls:
- secretName: crowdsec-lapi-public-tls
hosts:
- crowdsec.renner.uno
@@ -1,5 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- helmrelease.yaml
@@ -96,28 +96,13 @@ The security-scan agent will continue to surface vuln findings on
these as "Skipped / Flux image-automation owns this" even without
a policy.
- `ghcr.io/usetrmnl/terminus` — publishes a usable `0.x.0` tag stream, so
unlike the three above this is **not** a tag-format problem. It is left
unenrolled because the blast radius is hardware: Terminus is pre-1.0,
cuts no GitHub Releases and ships no changelog, and it serves the
firmware and `/api/display` contract that the TRMNL X panel polls. An
unattended bump that changes either can leave a wall-mounted device
dark, with no rollback path that doesn't involve the captive portal.
Bumps are a reviewed, manual act: read the commit range, then edit the
tag in `kubernetes/apps/trmnl/terminus.yaml` (both containers).
The Valkey sidecar alongside it *is* enrolled (`valkey.yaml`) — it holds
only the Sidekiq queue and touches no device-facing contract.
- `ghcr.io/usetrmnl/trmnl-ha-amd64` — the TRMNL screenshot renderer, for
the same reason and one more. It owns the dithering pipeline and the
webhook payload shape, so a bad bump surfaces as a *corrupted or blank
panel*, not as a failed rollout — there is no readiness signal that
catches "the image is now unreadable". Separately, upstream publishes
**no multi-arch manifest**: only the arch-suffixed `trmnl-ha-amd64` and
`trmnl-ha-aarch64` repositories exist, so an ImagePolicy here would also
be pinning us to one architecture in a way the tag does not make
obvious. Bump manually in `kubernetes/apps/trmnl/renderer.yaml`.
Only a tag-format problem belongs on that list. The policy is to run the
latest version and let image-automation hold us there wherever the tags
allow it, so an image with a usable semver stream gets enrolled even when
a bump carries risk. Where a bad bump is hard to detect, the answer is a
note about what to check afterwards — see `apps/trmnl/README.md` for the
panel, whose failure mode is a blank screen rather than a failed rollout
— not an exclusion.
## Re-import hazard: cnpg-barman-plugin
@@ -9,9 +9,26 @@ spec:
---
# v-prefix tags (v19.2.3). Upstream also publishes date-suffixed builds
# (v19.2.3-20250717) and moving major/minor tags (v19, v19.2); the
# filter pins us to the bare three-component release. Range stays at
# 19.x — the 19 → 20 (Tentacle) cutover should be a deliberate
# upgrade tied to the Rook operator version.
# filter pins us to the bare three-component release.
#
# The range was 19.x, to keep the 19 -> 20 (Tentacle) cutover a deliberate
# upgrade tied to the Rook operator version. That coupling had already come
# apart without anyone noticing: Rook's floating constraint pulled v1.20 in
# June, and with it a ceph-csi image whose client libraries are Ceph 20.2.1.
# The cluster stayed on 19.2.6, so every CSI pod has since been talking to
# the 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 'AgB...'".
# Nothing broke only because the four CSI users predate the cipher change and
# still carry legacy aes keys, which Ceph itself flags as insecure. The moment
# one is recreated — ceph auth rotate, a deleted Secret, Rook resyncing — the
# provisioner stops and no PVC binds. A rebuild rehearsal hits it immediately,
# because 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 when read affinity is enabled.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
@@ -25,4 +42,4 @@ spec:
extract: '$ver'
policy:
semver:
range: "19.x"
range: ">=20.2.1 <21.0.0"
@@ -18,7 +18,9 @@ resources:
- mosquitto.yaml
- redis.yaml
- shelly-manager.yaml
- terminus.yaml
- tika.yaml
- trmnl-renderer.yaml
- valkey.yaml
- velero-plugin-for-aws.yaml
- vsftpd.yaml
@@ -0,0 +1,30 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: terminus
namespace: flux-system
spec:
image: ghcr.io/usetrmnl/terminus
interval: 1h
---
# Pre-1.0, so the range is 0.x rather than a major pin: upstream's minors are
# its release stream, and holding at a single minor is how this drifted four
# releases behind while it was excluded from automation.
#
# A bad bump here surfaces as a blank or corrupted panel rather than a failed
# rollout — no readiness probe can tell that a rendered screen became
# unreadable. Check the panel after one lands; revert the tag if it is wrong.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: terminus
namespace: flux-system
spec:
imageRepositoryRef:
name: terminus
filterTags:
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "0.x"
@@ -0,0 +1,29 @@
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImageRepository
metadata:
name: trmnl-renderer
namespace: flux-system
spec:
image: ghcr.io/usetrmnl/trmnl-ha-amd64
interval: 1h
---
# Upstream ships no multi-arch manifest, only the arch-suffixed repositories.
# That does not obstruct a policy: the image reference already names the
# architecture, so this pins nothing the tag did not.
#
# The floating `0.9` / `0.10` series tags are filtered out; only fully
# qualified patch versions are candidates. Same panel caveat as terminus.yaml.
apiVersion: image.toolkit.fluxcd.io/v1
kind: ImagePolicy
metadata:
name: trmnl-renderer
namespace: flux-system
spec:
imageRepositoryRef:
name: trmnl-renderer
filterTags:
pattern: '^(?P<ver>\d+\.\d+\.\d+)$'
extract: '$ver'
policy:
semver:
range: "0.x"
@@ -30,6 +30,11 @@ spec:
labels:
app: cni-plugins-installer
spec:
# Same reasoning as multus-daemonset.yaml. This one installs the macvlan
# binary the ceph-san attachment is made of, so a node that cannot
# schedule it has no plugin to delegate to — the failure the init
# container comment below already describes, reached by a different road.
priorityClassName: system-node-critical
hostNetwork: true
tolerations:
- operator: Exists
@@ -44,7 +49,32 @@ spec:
# at all and put github.com on the critical path of every node rebuild.
# Dated tag so a version change is a reviewable diff here, matching the
# ci-ansible / alpine-jq convention.
image: gitea.renner.uno/gitea_admin/cni-plugins:2026-07-30
#
# konairius/, not gitea_admin/, and the difference is what makes a
# rebuild possible. Gitea runs inside this cluster, so during a rebuild
# it does not exist: a rehearsal sat here in ImagePullBackOff with "no
# route to host", and because the plugins never installed there was no
# macvlan, so Multus could not attach ceph-san, so Rook's network
# canary never started, so the CephCluster hung at "Configuring Ceph
# Mons" and OpenBao waited forever on a PVC that could not bind. The
# failure surfaced four layers from its cause.
#
# This used to point straight at ghcr.io, which fixed the rebuild and
# broke the normal case: every steady-state pull then left the network
# for an image the local registry already holds. The fallback belongs
# to the node, not to the manifest — machine.registries.mirrors names
# Gitea first and ghcr.io second (bootstrap.sh), so nothing here has to
# know about the recovery path.
#
# Containerd keeps the repository path when it falls through, so the
# path must resolve on both registries; the build pushes one digest to
# konairius/ on each, verified equal. Bumping the tag means building
# first — an unpublished tag is unpullable on both.
#
# No imagePullSecret: the ghcr namespace is private and its credential
# is in machine.registries too, because a Secret would have to come
# from OpenBao, which needs Ceph, which needs these binaries.
image: gitea.renner.uno/konairius/cni-plugins:2026-09-04
command:
- sh
- -c
@@ -154,6 +154,40 @@ spec:
app: multus
name: multus
spec:
# Upstream ships this DaemonSet with no priority class, so it runs at 0
# and can preempt nothing. Any event that makes the pod need a fresh
# scheduling decision — a rollout, a node restart, an eviction — then has
# to fit it onto a node that may since have filled up, and if it does not
# fit it simply stays Pending. The node has no multus after that, and
# nothing reports it: pods still start, they just never get their
# secondary attachment, and the annotation everything waits on never
# appears.
#
# Seen on a cold rebuild: two of four nodes sat at 99% memory requests
# when the DaemonSet rolled, both multus pods went Pending, and Rook's
# network canary hung in Init:0/1 waiting for a network-status annotation
# that could never come. The CephCluster stayed in Progressing, which is
# what infrastructure-ceph-cluster gates on, so the whole apps layer
# stopped behind a scheduling decision three layers away.
#
# node-critical, the same class cilium and cni-dhcp-daemon carry, because
# that is what this is: without multus a node cannot attach a secondary
# network, and only that node is affected. Nothing cluster-wide fails.
#
# This does outrank kube-apiserver, which Talos runs as a static pod at
# system-cluster-critical (2000000000, against 2000001000 here), and on a
# control plane that has run out of memory the kubelet will pick the API
# server as the pod to evict. That was observed twice, at 6GiB and at
# 8GiB.
#
# It is not a reason to demote this DaemonSet. Kubernetes puts the CNI
# above the control plane deliberately — a node without its CNI cannot run
# anything at all — and a class name is a statement about what the pod is,
# not a dial for picking a number. The eviction says the control plane is
# starved, and that is where it has to be fixed: enough allocatable
# memory, or kubeReserved/systemReserved so the static pods keep what they
# need. Demoting this to cluster-critical would only move the victim.
priorityClassName: system-node-critical
hostNetwork: true
hostPID: true
tolerations:
@@ -146,6 +146,11 @@ spec:
app: whereabouts
name: whereabouts
spec:
# Same reasoning as multus-daemonset.yaml: this is the IPAM half of the
# secondary-network path, so a node without it cannot hand out an address
# on ceph-san and the attachment fails just as completely as if multus
# itself were missing. Upstream leaves the priority class unset.
priorityClassName: system-node-critical
hostNetwork: true
serviceAccountName: whereabouts
# Every node here is a control plane node, so the NoSchedule toleration
@@ -69,6 +69,8 @@ kind: Namespace
metadata:
name: paperless
---
# A disposable copy of paperless's database, for measuring how the local LLM
# classifies documents without experimenting on the live archive.
apiVersion: v1
kind: Namespace
metadata:
@@ -188,3 +190,31 @@ apiVersion: v1
kind: Namespace
metadata:
name: ai
---
# Where the Woodpecker kubernetes backend spawns pipeline step pods, isolated
# from `apps`. Distinct from `woodpecker`, which holds the service itself.
#
# Here rather than in apps/woodpecker for the reason spelled out above that
# namespace: post-crds ships this namespace's CiliumNetworkPolicies via
# ../network-policies/, and infrastructure-crds runs before every app KS. With
# the namespace declared in the workload KS the order inverted, and a rebuild
# stopped with
#
# CiliumNetworkPolicy/woodpecker-ci/default-deny-ingress not found:
# namespaces "woodpecker-ci" not found
#
# which held infrastructure-crds, and with it all twenty app Kustomizations
# that depend on it. The same reasoning was already written down for
# `woodpecker` and simply not carried across to its sibling.
#
# Image-build steps run a privileged docker/buildx plugin pod (see the server's
# WOODPECKER_PLUGINS_PRIVILEGED). Talos enforces Pod Security; without the
# label the privileged step pods are rejected at admission. The namespace holds
# nothing but ephemeral CI step pods, so the exemption stays off apps/.
apiVersion: v1
kind: Namespace
metadata:
name: woodpecker-ci
labels:
app.kubernetes.io/name: woodpecker
pod-security.kubernetes.io/enforce: privileged
@@ -1,5 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- daemonset.yaml
@@ -34,6 +34,17 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# Home Assistant's llama.cpp conversation agent calls the chat model only —
# it has no use for the embedding endpoint, so unlike paperless above this
# grant does not extend to llama-embed.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": home-assistant
app.kubernetes.io/name: home-assistant
toPorts:
- ports:
- port: "8080"
protocol: TCP
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
@@ -167,6 +167,18 @@ spec:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": influxdb
# The cluster's own chat model, for the llama.cpp conversation agent that
# backs Assist. Scoped to the one pod and port rather than the ai namespace
# as a whole, so this grant cannot widen if something else is placed there —
# the embedding endpoint next to it is deliberately not included.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-server
toPorts:
- ports:
- port: "8080"
protocol: TCP
# traefik for east-west OIDC roundtrips that go through the
# public hostname via k8s_gateway.
- toEndpoints:
@@ -0,0 +1,23 @@
# OpenBao policy: netbird-proxy-services
#
# The reverse-proxy-services controller (Deployment netbird-proxy-services in
# the openbao namespace) reads the NetBird PAT on every reconcile pass and
# calls the management API with it. Read-only on that one path; it shares the
# bootstrap credential with rotator-netbird but not that role's PATCH rights.
#
# NOT applied by Flux. Apply ONCE with an admin token:
#
# bao policy write netbird-proxy-services kubernetes/infrastructure/openbao/policies/netbird-proxy-services.hcl
# bao write auth/kubernetes/role/netbird-proxy-services \
# bound_service_account_names=netbird-proxy-services \
# bound_service_account_namespaces=openbao \
# policies=netbird-proxy-services ttl=15m
#
# The PAT itself is the one rotator-netbird already uses
# (homelab/infrastructure/secret-rotators-bootstrap/netbird, key `pat`).
#
# Do NOT set token_no_default_policy=true (kv-v2 preflight needs the default policy).
path "homelab/data/infrastructure/secret-rotators-bootstrap/netbird" {
capabilities = ["read"]
}
@@ -10,33 +10,8 @@
# name hairpins off the ingress LB from a pod. Needs BOTH netpol halves
# (gitea-allow-flux-rotator + rotator-gitea-flux-egress).
#
# The ExternalSecret MERGES into Flux's existing flux-system secret (does not
# own it) so a sync failure leaves the last-good value in place; Flux's
# source-controller reads it fresh each fetch, no restart. OpenBao policy/role
# rotator-gitea-flux + the gitea-flux bootstrap (flux account password) are
# provisioned by hand (see policies/rotator-gitea-flux.hcl).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: flux-system-git-auth
namespace: flux-system
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: flux-system
creationPolicy: Merge
data:
- secretKey: username
remoteRef:
key: infrastructure/flux-git
property: username
- secretKey: password
remoteRef:
key: infrastructure/flux-git
property: token
# The ExternalSecret that publishes the rotated token to Flux lives in
# ../../post-crds/flux-git-externalsecret.yaml (needs ESO's CRDs).
---
apiVersion: v1
kind: ServiceAccount
@@ -0,0 +1,27 @@
# Bouncer key for the firewall bouncers on proxy-1/proxy-2.
#
# Separate from crowdsec-bouncer-key ON PURPOSE. That key is rotated
# quarterly by kubernetes/infrastructure/openbao/rotation/crowdsec-bouncer-key.yaml,
# and both of its consumers live in the cluster, so ESO propagates a rotation
# within 10m. The edge bouncers are Ansible-managed hosts: nothing there
# re-reads OpenBao on a schedule, so a rotated key would leave them 401ing
# against the LAPI until someone runs the playbook. This key is therefore
# deliberately NOT enrolled in the rotator — it rotates when the playbook runs,
# and the playbook is what carries the new value to the hosts.
#
# The value must match ansible vault_crowdsec_edge_bouncer_key.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: crowdsec-edge-bouncer-key
namespace: crowdsec
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: crowdsec-edge-bouncer-key
dataFrom:
- extract:
key: infrastructure/crowdsec-edge-bouncer-key
@@ -0,0 +1,11 @@
# traefik.io CRD consumer for the public LAPI endpoint (crowdsec/lapi-ingress.yaml
# holds the Ingress). The CA it names is the chart's self-signed LAPI CA.
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: crowdsec-lapi
namespace: crowdsec
spec:
serverName: crowdsec-service.crowdsec.svc.cluster.local
rootCAs:
- secret: crowdsec-root-secret
@@ -0,0 +1,60 @@
# Keeps Flux's flux-system secret supplied with the rotated Gitea credential
# WITHOUT owning it, so a sync failure leaves the last-good value in place;
# Flux's source-controller reads it fresh each fetch, no restart. OpenBao
# policy/role rotator-gitea-flux + the gitea-flux bootstrap (flux account
# password) are provisioned by hand (see ../openbao/policies/rotator-gitea-flux.hcl).
#
# See creationPolicy below for why that is Orphan and not Merge — the
# distinction is what makes a recovery from the break-glass source possible.
#
# Split out of ../openbao/rotation/flux-git.yaml, which still holds the
# rotation CronJob: external-secrets.io CRDs are installed by the ESO
# HelmRelease in the infrastructure Kustomization, so this manifest cannot
# pass server-side dry-run there on a fresh cluster.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: flux-system-git-auth
namespace: flux-system
spec:
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: flux-system
# Orphan, not Merge: create the Secret if it is absent, but never own it.
#
# Merge cannot create — it only writes into a Secret that already exists,
# on the assumption that `flux bootstrap` made one. That assumption breaks
# in exactly the situation this cluster is rebuilt from. The break-glass
# bootstrap pulls from the GitHub mirror and keeps its token in
# flux-system-breakglass (bootstrap.sh), so `flux-system` is never created,
# so this ExternalSecret has nothing to merge into — and reports Ready while
# producing no Secret at all. The operator then performs the documented
# handover back to Gitea and finds the GitRepository pointing at a Secret
# that does not exist.
#
# Owner would create it but attach an ownerReference, so deleting this
# object would take Flux's credential with it. Orphan creates and updates
# without ownership, which is what the paragraph at the top of this file
# actually wanted: a sync failure, or the loss of this object, leaves the
# last-good value in place.
#
# Safe on a live cluster: the Secret holds exactly the two keys written
# below (verified against production), so writing it whole is the same
# result as merging into it.
creationPolicy: Orphan
# Explicit rather than relying on the default, because it is the half that
# matters here: if the OpenBao entry ever disappears, keep serving the
# credential Flux already has rather than leaving it with none.
deletionPolicy: Retain
data:
- secretKey: username
remoteRef:
key: infrastructure/flux-git
property: username
- secretKey: password
remoteRef:
key: infrastructure/flux-git
property: token
@@ -10,4 +10,28 @@ resources:
# configured (2026-07-13): a store that can't turn Ready would block this
# KS's wait gate and freeze the dependsOn chain.
- openbao-clustersecretstore.yaml
# ExternalSecrets. external-secrets.io CRDs come from the ESO HelmRelease in
# the infrastructure Kustomization, so these cannot pass its server-side
# dry-run on a fresh cluster — Flux rejects the whole apply set, including
# the ESO HelmRelease that would have installed the CRDs. They sat next to
# their consumers until a cold-rebuild test hit that deadlock (2026-09-01);
# nothing had ever applied this layer against an empty API server before.
- crowdsec-bouncer-key-externalsecret.yaml
- crowdsec-edge-bouncer-key-externalsecret.yaml
- external-dns-unifi-externalsecret.yaml
- flux-git-externalsecret.yaml
- netbird-ingress-externalsecret.yaml
- velero-externalsecret.yaml
- velero-hetzner-creds-externalsecret.yaml
# traefik.io CRDs, same reasoning — installed by the Traefik HelmRelease in
# infrastructure. Traefik is deliberately absent from that Kustomization's
# healthChecks, so this KS may dry-run before the chart lands; it retries on
# retryInterval rather than deadlocking, because nothing here installs the
# CRDs it is waiting for.
- traefik-crowdsec-middleware.yaml
- traefik-rate-limit-middleware.yaml
- traefik-security-headers-middleware.yaml
# Phase 1 of a two-Kustomization move: also still in crowdsec/lapi-ingress.yaml
# until this layer has applied it once, so prune never runs ahead of apply.
- crowdsec-lapi-serverstransport.yaml
- ../network-policies/
@@ -5,7 +5,7 @@ metadata:
namespace: rook-ceph
spec:
cephVersion:
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v20.2.4 # {"$imagepolicy": "flux-system:ceph"}
allowUnsupported: false
dataDirHostPath: /var/lib/rook
# Defaults to false. With three OSDs and size 3 there is no spare copy, so
@@ -78,16 +78,35 @@ spec:
enabled: true
ssl: false
monitoring:
enabled: true
# false, and this is not a decision about metrics. `metricsDisabled` is the
# switch that turns metrics off; this one only decides whether Rook creates
# the ServiceMonitors — and creating them is fatal to the reconcile when it
# fails. On an empty cluster monitoring.coreos.com/v1 does not exist, so
# Rook's node-daemon reconcile aborted, the CephCluster never left
# status.phase Progressing, and infrastructure-ceph-cluster's healthCheckExpr
# waits on exactly that — with grafana-db → monitoring → those same CRDs
# behind it, closing the loop.
#
# The ceph-exporter DaemonSet and the mgr metrics endpoint are untouched;
# only the scrape wiring moved, to apps/monitoring-rules/
# rook-servicemonitors.yaml, where the CRDs exist by the time it applies.
# Both objects are reproduced there field for field off the live cluster.
enabled: false
# Dedicated Ceph cluster network on VLAN 49, attached to the OSDs through
# Multus. The attachment is san-network-attachment.yaml.
#
# Only `cluster` is selected. Do not add `public`, and do not reach for
# `addressRanges` — that is honoured only under provider: host, which puts
# every daemon including the mons on host networking. A mon's address then
# becomes the node's DHCP lease, and when a lease moves the mon crash-loops
# on "Cannot assign requested address" with no node free to fail over to,
# because all three already hold the mon host ports.
# Only `cluster` is selected. Do not add `public`: under provider: host
# that puts every daemon including the mons on host networking. A mon's
# address then becomes the node's DHCP lease, and when a lease moves the mon
# crash-loops on "Cannot assign requested address" with no node free to
# fail over to, because all three already hold the mon host ports.
#
# addressRanges.cluster is stated, not discovered. Without it Rook runs a
# canary pod on an OSD node to read the CIDR off the attachment on EVERY
# reconcile; the attachment is host-device, so the OSD on that node already
# holds the NIC and the canary can never attach — the reconcile blocks for
# 15 minutes, fails, and repeats. The value must equal the whereabouts range
# in san-network-attachment.yaml.
#
# With `public` unset, client traffic stays on the pod network and VLAN 49
# carries Ceph-internal traffic only — which is also why Rook's Multus
@@ -104,6 +123,9 @@ spec:
provider: multus
selectors:
cluster: rook-ceph/ceph-san
addressRanges:
cluster:
- 10.99.0.0/29
# Nodes listed explicitly, not useAllNodes: under useAllNodes the operator
# re-provisions an OSD on any node whose device still carries a BlueStore
# signature, so purging an OSD before pulling its drive is impossible — it
@@ -131,10 +153,12 @@ spec:
useAllDevices: false
# Two forms while the production drives are migrated:
#
# by-partlabel/r-ceph-osd the target. Talos names the drive in the
# machine config (RawVolumeConfig ceph-osd), so
# by-partlabel/r-osd-data the target. Talos names the drive in the
# machine config (RawVolumeConfig osd-data), so
# this file needs to know nothing about models,
# serials or hostnames.
# serials or hostnames. The label must not
# contain "ceph": ceph-volume rejects any such
# partition as a legacy ceph-disk member.
# by-id/nvme-SAMSUNG… the three MS-01 drives as they are today:
# bluestore written straight onto the raw disk,
# with no partition table and therefore no
@@ -146,7 +170,7 @@ spec:
#
# The second half goes when those three are repartitioned, one node at a
# time. Until then it is what keeps the running OSDs claimed.
devicePathFilter: "^/dev/disk/by-(partlabel/r-ceph-osd|id/nvme-SAMSUNG_MZQLB960HAJR)"
devicePathFilter: "^/dev/disk/by-(partlabel/r-osd-data|id/nvme-SAMSUNG_MZQLB960HAJR)"
config:
osdsPerDevice: "1"
# Highest built-in class: kubelet evicts these last and the kernel gives
@@ -3,33 +3,26 @@
# without every Ceph daemon landing on host networking to get there. The
# alternative and why it was rejected is in cephcluster.yaml.
#
# host-device: the SAN NIC is dedicated to Ceph and has exactly one consumer
# per node — the OSD pod — so it is handed to that pod whole, not virtualised
# under it. The device keeps its identity (MAC, MTU) and moves back to the
# host when the pod goes away.
#
# `device` names the role, not hardware: every machine renames its own SAN
# interface to ceph-san at boot (udev rule in its Talos patch). No kernel
# interface name appears in this file.
#
# ISOLATION
#
# VLAN 49 carries Ceph-internal traffic and nothing else. Two consequences
# are deliberate:
# VLAN 49 carries Ceph-internal traffic and nothing else. No gateway and no
# routes: there is no router on this segment and there must not be one.
# whereabouts adds neither unless asked, so nothing here can leak onto or off
# the SAN.
#
# No gateway and no routes. There is no router on this segment and there
# must not be one. whereabouts adds neither unless asked, so nothing here
# can leak onto or off the SAN. This also means no `sbr` chaining is
# needed — unlike the v30iot attachments, there is no DHCP-supplied default
# route to confine, because there is no DHCP and no default route.
#
# The pool is the whole usable /29, .1 through .6. It was .4-.6 while the
# migration ran, because the nodes still held .1-.3 for as long as Ceph
# needed host networking to reach this link — three addresses for three
# OSDs, no spare. The nodes have since given those up (the link is only a
# macvlan parent now and needs no address of its own), so the reservation
# has nothing left to avoid.
#
# Keeping the pool inside the /29 rather than moving it elsewhere on the
# segment is not cosmetic. There is no router here, so an address outside
# the /29 would be unreachable from anything still using one inside it —
# which is exactly what would break a future rolling change, where migrated
# and un-migrated OSDs have to keep talking to each other.
#
# Six addresses for three OSDs is the headroom the migration could not
# afford: room for a leaked allocation to sit until the reconciler collects
# it, and room to add a second OSD per node without touching this file.
# The pool is the whole usable /29, .1 through .6 — headroom for a leaked
# allocation to sit until the reconciler collects it. One address per node is
# in use; a second OSD on a node would need a second SAN interface, which is a
# hardware change, not a config one.
#
# IPAM
#
@@ -37,29 +30,18 @@
# a server that must not exist here, and host-local keeps its state per node
# with no cluster-wide coordination, so two nodes would hand out the same
# address on shared L2. See infrastructure/multus/whereabouts-daemonset.yaml.
#
# MTU 9000 matches the link. macvlan children inherit the master's MTU, but
# it is stated so that a change to the parent does not silently halve
# replication throughput without anything in the tree recording the
# expectation.
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: ceph-san
namespace: rook-ceph
spec:
# `master` is an interface name. enp2s0f1np1 is the second X710 SFP+ port,
# a busPath-derived predictable name (net.ifnames=1 via the Image Factory
# schematic) that is identical on k8s-1/2/3 and cannot shuffle across
# reboots.
config: |
{
"cniVersion": "0.3.1",
"name": "ceph-san",
"type": "macvlan",
"master": "enp2s0f1np1",
"mode": "bridge",
"mtu": 9000,
"type": "host-device",
"device": "ceph-san",
"ipam": {
"type": "whereabouts",
"range": "10.99.0.0/29",
@@ -38,7 +38,7 @@ spec:
serviceAccountName: rook-ceph-default
containers:
- name: rook-ceph-tools
image: quay.io/ceph/ceph:v19.2.6 # {"$imagepolicy": "flux-system:ceph"}
image: quay.io/ceph/ceph:v20.2.4 # {"$imagepolicy": "flux-system:ceph"}
command:
- /bin/bash
- -c
@@ -2,6 +2,3 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- helmrelease.yaml
- crowdsec-middleware.yaml
- security-headers-middleware.yaml
- rate-limit-middleware.yaml
@@ -1,13 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- hetzner-creds-externalsecret.yaml
- snapshot-readonly-storageclass.yaml
- node-agent-config.yaml
- helmrelease.yaml
- servicemonitor.yaml
- podmonitor.yaml
- restore-test-rbac.yaml
- restore-test-cronjob.yaml
# Velero Schedule CRs live in kubernetes/infrastructure/post-crds/
+69
View File
@@ -0,0 +1,69 @@
# First machine of the throwaway cluster on Hetzner.
#
# enp1s0 is the public NIC; the private networks are enp7s0 and enp8s0, in the
# order bootstrap.sh attaches them.
machine:
install:
disk: /dev/sda
certSANs:
- hcloud-1
nodeLabels:
# Holds a Ceph OSD device; the CephCluster selects OSD nodes by this label.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN NIC to the role name at boot. Matched by PCI bus path
# rather than MAC: the address follows from the order bootstrap.sh attaches
# the networks, while the MAC is assigned by Hetzner at attach time and is
# not known when this file is written.
rules:
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
---
# In the generated schema rather than machine.network.hostname: naming the host
# in both is rejected at apply time. `auto: off` switches off the generated
# `auto: stable`, which is mutually exclusive with a static name.
apiVersion: v1alpha1
kind: HostnameConfig
auto: off
hostname: hcloud-1
---
# Every interface is stated, the public one included. Talos stops running its
# default DHCP operators for the whole machine as soon as one new-style network
# document exists, so an omitted interface gets no address.
apiVersion: v1alpha1
kind: LinkConfig
name: enp1s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp1s0
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp7s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp7s0
---
# Ceph cluster network, renamed to the role name by the udev rule in the
# machine section. The node holds no address here; the link exists for the OSD
# pod. No mtu: a Hetzner network carries 1450, not production's 9000.
apiVersion: v1alpha1
kind: LinkConfig
name: ceph-san
up: true
---
apiVersion: v1alpha1
kind: RawVolumeConfig
name: osd-data
provisioning:
diskSelector:
# Two disks: the system disk and the attached volume. Matching on transport
# as the qemu patches do selects nothing here — a Hetzner volume arrives
# over virtio-scsi, and a selector matching zero disks is skipped silently.
match: '!system_disk'
# bootstrap.sh reads this size and creates the volume to match.
minSize: 50GiB
grow: true
+50
View File
@@ -0,0 +1,50 @@
# Second machine. See hcloud-1.yaml.
machine:
install:
disk: /dev/sda
certSANs:
- hcloud-2
nodeLabels:
storage.renner.uno/ceph-osd: "true"
udev:
# See hcloud-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
---
apiVersion: v1alpha1
kind: HostnameConfig
auto: off
hostname: hcloud-2
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp1s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp1s0
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp7s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp7s0
---
# See hcloud-1.yaml.
apiVersion: v1alpha1
kind: LinkConfig
name: ceph-san
up: true
---
apiVersion: v1alpha1
kind: RawVolumeConfig
name: osd-data
provisioning:
diskSelector:
match: '!system_disk'
minSize: 50GiB
grow: true
+50
View File
@@ -0,0 +1,50 @@
# Third machine. See hcloud-1.yaml.
machine:
install:
disk: /dev/sda
certSANs:
- hcloud-3
nodeLabels:
storage.renner.uno/ceph-osd: "true"
udev:
# See hcloud-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", KERNELS=="0000:08:00.0", NAME="ceph-san"
---
apiVersion: v1alpha1
kind: HostnameConfig
auto: off
hostname: hcloud-3
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp1s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp1s0
---
apiVersion: v1alpha1
kind: LinkConfig
name: enp7s0
up: true
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp7s0
---
# See hcloud-1.yaml.
apiVersion: v1alpha1
kind: LinkConfig
name: ceph-san
up: true
---
apiVersion: v1alpha1
kind: RawVolumeConfig
name: osd-data
provisioning:
diskSelector:
match: '!system_disk'
minSize: 50GiB
grow: true
+21 -6
View File
@@ -14,6 +14,13 @@ machine:
# Disabling v6 on a VLAN parent does not affect the VLAN child.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# That network also carries the mgmt VLAN's ARP. Answer ARP only on the
# link that owns the address, or the UDM learns the node IP against
# enp87s0's MAC and delivers IPv4 to a NIC Cilium is not attached to (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select disks by serial only. A broad selector such as
# `disk.transport == 'nvme'` matches every drive in the machine, and the
@@ -28,6 +35,13 @@ machine:
# this label instead of listing hostnames, so which boxes carry storage is
# stated once, here, by the machine that knows.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port (X710 port 1, matched by its burned-in MAC) to the
# role name at boot, before the network stack opens links — a rule applied
# to a RUNNING node cannot rename (Resource busy) and takes effect at the
# next reboot.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:70:28", NAME="ceph-san"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -124,14 +138,15 @@ parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication and nothing else.
# wire. OSD-to-OSD replication and nothing else. The udev rule in the machine
# section above renames the port to ceph-san at boot, so shared manifests name
# the role and only this file knows the hardware.
#
# The node holds NO address here on purpose. Rook attaches the link to the OSD
# pods through a Multus macvlan, and a macvlan parent only needs to be up.
# Giving the node an address would put it on a segment reserved for
# Ceph-internal traffic.
# The node holds NO address here on purpose: the link is handed whole to the
# OSD pod (host-device attachment). Giving the node an address would put it on
# a segment reserved for Ceph-internal traffic.
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
name: ceph-san
up: true
mtu: 9000
+13 -3
View File
@@ -12,6 +12,11 @@ machine:
# cluster IPv6 egress.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.
@@ -23,6 +28,10 @@ machine:
topology.kubernetes.io/zone: saturn
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port to the role name at boot — see k8s-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:76:6f:74", NAME="ceph-san"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -95,11 +104,12 @@ up: true
---
# Dedicated Ceph cluster network on X710 port 1, VLAN 49, untagged on the
# wire. OSD-to-OSD replication only, not routed off its own segment.
# Renamed to the role name by the udev rule above — see k8s-1.yaml.
#
# The node holds NO address here on purpose. Rook attaches the link to the OSD
# pods through a Multus macvlan, and a macvlan parent only needs to be up.
# The node holds NO address here on purpose: the link is handed whole to the
# OSD pod (host-device attachment).
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
name: ceph-san
up: true
mtu: 9000
+11 -2
View File
@@ -8,6 +8,11 @@ machine:
# k8s-1.yaml.
net.ipv6.conf.enp2s0f0np0.disable_ipv6: "1"
net.ipv6.conf.enp87s0.disable_ipv6: "1"
# Answer ARP only on the link that owns the address — see k8s-1.yaml (#793).
net.ipv4.conf.all.arp_ignore: "1"
net.ipv4.conf.default.arp_ignore: "1"
net.ipv4.conf.all.arp_announce: "2"
net.ipv4.conf.default.arp_announce: "2"
install:
# Select the installer target by serial so it cannot land on the OSD drive
# or on EPHEMERAL, whatever order the kernel enumerates them in.
@@ -19,6 +24,10 @@ machine:
topology.kubernetes.io/zone: neptune
# Holds a Ceph OSD drive — see k8s-1.yaml.
storage.renner.uno/ceph-osd: "true"
udev:
# Renames the SAN port to the role name at boot — see k8s-1.yaml.
rules:
- SUBSYSTEM=="net", ACTION=="add", ATTR{address}=="58:47:ca:74:72:d4", NAME="ceph-san"
cluster:
allowSchedulingOnControlPlanes: true
---
@@ -78,9 +87,9 @@ parent: enp2s0f0np0
up: true
---
# Dedicated Ceph cluster network — see k8s-1.yaml. The node holds NO address
# here on purpose; the link is only a macvlan parent for the OSD pods.
# here on purpose; the link is handed whole to the OSD pod (host-device).
apiVersion: v1alpha1
kind: LinkConfig
name: enp2s0f1np1
name: ceph-san
up: true
mtu: 9000
+25
View File
@@ -0,0 +1,25 @@
# Site patch for the throwaway cluster on Hetzner Cloud.
#
# This site exists for one thing the other two cannot test: whether a node that
# genuinely cannot reach gitea.renner.uno still comes up. At home the registry
# answers, so containerd never reaches the second endpoint in
# machine.registries.mirrors and the fallback stays unproven.
#
# Hetzner Cloud networks are layer 3. A macvlan child carries its own MAC and
# frames from it do not cross between servers — measured: same-node ARP
# resolves, cross-node does not. OSD replication therefore does not run here.
#
# What belongs in this file is what the cluster is. Interfaces, disks and names
# are machine properties and live in the node patches.
machine:
kubelet:
nodeIP:
# The segment bootstrap.sh provisions for node addresses. Unpinned, the
# kubelet can pick the public NIC.
validSubnets:
- 10.90.0.0/16
cluster:
# Three control planes that also carry workloads, which is the production
# shape. The qemu site cannot mirror it: that provisioner gives a second disk
# to workers only, so a Ceph OSD forces 1 control plane and 3 workers there.
allowSchedulingOnControlPlanes: true
@@ -11,6 +11,12 @@ machine:
# kubelet picks the first non-loopback IP, which could land on the v30iot
# NIC (ens19, 10.2.30.0/24). Control-plane traffic and hostNetwork pod IPs
# must stay on mgmt LAN.
# fd40::/64 is the mgmt LAN ULA, and it is listed so the Node object carries
# an IPv6 InternalIP at all. Without one, Cilium falls back to detecting the
# node's IPv6 from mgmt0 and picks the SLAAC address from the dynamic
# provider prefix, which it then probes until the agent restarts (#789).
# The GUA is not a control-plane address: BGP peers on fd40::/64 too.
nodeIP:
validSubnets:
- 10.2.40.0/24
- fd40::/64
+12
View File
@@ -43,3 +43,15 @@ apiVersion: v1alpha1
kind: HostnameConfig
auto: off
hostname: vm-1
---
# Ceph cluster network — see vm-2.yaml for why this is a VLAN, and why the
# DHCPv4Config below is load-bearing.
apiVersion: v1alpha1
kind: VLANConfig
name: ceph-san
parent: enp0s2
vlanID: 30
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp0s2
+26 -2
View File
@@ -21,7 +21,7 @@ auto: off
hostname: vm-2
---
# The OSD device, named by the machine that has it. Ceph then consumes
# /dev/disk/by-partlabel/r-ceph-osd and needs to know nothing about models,
# /dev/disk/by-partlabel/r-osd-data and needs to know nothing about models,
# serials or hostnames — the same CephCluster works here and on the MS-01s.
#
# The selector is a CEL expression, so each machine says whatever identifies its
@@ -29,9 +29,33 @@ hostname: vm-2
# real nodes it will name the Samsung.
apiVersion: v1alpha1
kind: RawVolumeConfig
name: ceph-osd
name: osd-data
provisioning:
diskSelector:
match: disk.transport == "virtio" && !system_disk
minSize: 5GiB
grow: true
---
# The Ceph cluster network link. Production gives it a second physical port;
# this machine has one NIC, so the segment is a VLAN on it.
#
# A dummy link would be simpler and worthless — no traffic between nodes, so
# Ceph would replicate over an interface going nowhere and the test would pass
# having proven nothing. A VLAN rides the provisioner's bridge that every VM is
# on, so the SAN carries real frames on its own tag.
#
# No MTU, unlike production's 9000: the qemu NIC is created with host_mtu=1500.
apiVersion: v1alpha1
kind: VLANConfig
name: ceph-san
parent: enp0s2
vlanID: 30
---
# Load-bearing. Declaring any network document makes Talos configure this link
# explicitly, and the implicit DHCP that gives the node its address stops. A
# first attempt with only the VLANConfig took vm-4 off the network — NotReady,
# unreachable over the Talos API. Verified on a second throwaway node that this
# document keeps the parent addressed.
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp0s2
+13 -1
View File
@@ -15,9 +15,21 @@ hostname: vm-3
---
apiVersion: v1alpha1
kind: RawVolumeConfig
name: ceph-osd
name: osd-data
provisioning:
diskSelector:
match: disk.transport == "virtio" && !system_disk
minSize: 5GiB
grow: true
---
# Ceph cluster network — see vm-2.yaml for why this is a VLAN, and why the
# DHCPv4Config below is load-bearing.
apiVersion: v1alpha1
kind: VLANConfig
name: ceph-san
parent: enp0s2
vlanID: 30
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp0s2
+13 -1
View File
@@ -19,9 +19,21 @@ hostname: vm-4
---
apiVersion: v1alpha1
kind: RawVolumeConfig
name: ceph-osd
name: osd-data
provisioning:
diskSelector:
match: disk.transport == "virtio" && !system_disk
minSize: 5GiB
grow: true
---
# Ceph cluster network — see vm-2.yaml for why this is a VLAN, and why the
# DHCPv4Config below is load-bearing.
apiVersion: v1alpha1
kind: VLANConfig
name: ceph-san
parent: enp0s2
vlanID: 30
---
apiVersion: v1alpha1
kind: DHCPv4Config
name: enp0s2
+134
View File
@@ -0,0 +1,134 @@
# Runs `bootstrap.sh vmtest` inside the cluster instead of on a workstation.
#
# NOT reconciled by Flux — deliberately outside kubernetes/clusters. This is a
# one-shot test, and a Kustomization would recreate it after every completion.
# It is applied by the vmtest pipeline, or by hand for a single run.
#
# Why in-cluster at all: the test wants ~30G for four VMs (one control plane at
# 6G, three OSD workers at 8G). The workstation has 31G with a desktop on it —
# a single run left under 1G free and two overlapping runs OOM-killed the
# session. The nodes have ~94G each with about 70G unused.
#
# The whole script runs here, flux and OpenBao included, so that what a rebuild
# rehearses is what a real recovery would execute. The unseal key is not baked
# in and not stored as a Secret: the operator streams all five secrets into an
# in-memory emptyDir at the moment the run asks for them, and they die with the
# Job without ever reaching etcd or a backup of it.
apiVersion: v1
kind: Namespace
metadata:
name: vmtest
labels:
# The pod is privileged; say so rather than have the admission policy
# reject it for reasons that look unrelated.
pod-security.kubernetes.io/enforce: privileged
---
apiVersion: batch/v1
kind: Job
metadata:
name: vmtest
namespace: vmtest
spec:
backoffLimit: 0 # a failed rebuild test is a result, not something to retry
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: vmtest
spec:
restartPolicy: Never
# Nothing here should be able to reach the cluster it runs in. The phase
# points KUBECONFIG at its own throwaway cluster, and without a mounted
# token a stray kubectl cannot fall back to this one.
automountServiceAccountToken: false
containers:
- name: vmtest
# Pinned by digest, not :latest. Spegel mirrors mutable tags and keeps
# serving what it cached: a run on k8s-3 pulled a ci-vmtest:latest
# three builds old, which predated OVMF, and died with "the required
# flash image was not found" before the first VM booted. The tag is
# carried alongside for readability — containerd resolves the digest.
image: gitea.renner.uno/gitea_admin/ci-vmtest:2026-09-03@sha256:a8145a76896ef9aa6fdb58dc96b9bdc22b53f4e55f04d9e6de289f35b75dbd81
# Privileged for /dev/kvm and for the bridges and taps the provisioner
# builds. It needs no hostNetwork: the provisioner's network lives in
# this pod's own namespace, and the script that talks to 10.5.0.x runs
# in the same pod.
securityContext:
privileged: true
# A hard ceiling, because the failure mode is known: an unbounded run
# is what OOM-killed the workstation. 40G is the VMs (16G control
# plane + 3x8G workers), the rest is qemu overhead and the tooling.
# The workers carry an OSD, a mon and a mgr each; at 4G they ran out
# of room for the CNI DaemonSets themselves. The control plane went
# 6G → 8G → 16G: at both smaller sizes it ran at 99% of allocatable
# memory and the kubelet evicted kube-apiserver to admit a
# node-critical pod — bootstrap.sh carries the full account next to
# the sizing flags. The hosts have ~95GiB each, so this fits.
resources:
requests:
# Less than the 14 guest vCPUs the four VMs declare, on purpose:
# the guests are never all runnable at once (one cluster booting
# in sequence), and there is no CPU limit, so the pod may burst
# past the request when the node has headroom. The request only
# keeps the scheduler from stacking this next to another big
# burst consumer.
cpu: "6"
memory: 42Gi
limits:
memory: 48Gi
env:
# talosctl caches the image and the CNI bundle under $HOME; point it
# at the scratch volume rather than the container layer.
- name: HOME
value: /work
# The VM disks. Short on purpose — the provisioner puts a qemu
# monitor socket under it and sun_path caps the path at 108 bytes.
- name: VM_STATE
value: /work/vm
# Which branch to test. master unless a run is checking a change.
- name: BRANCH
value: master
# Secrets are streamed in here at the moment the run needs them,
# never baked into the image and never stored as a Secret. They live
# in this emptyDir for the length of the Job and go with it — so
# they do not reach etcd, or any backup of it.
- name: OPENBAO_SECRETS_DIR
value: /secrets
command: ["/bin/bash", "-ec"]
args:
- |
# From the GitHub mirror, not in-cluster Gitea. A rebuild test
# that clones from the cluster it is rehearsing the loss of
# cannot start when it matters — and a run already died here on
# resolving gitea.renner.uno.
git clone --depth 1 --branch "$BRANCH" \
https://github.com/konairius/Homelab.git /work/homelab
exec /work/homelab/kubernetes/bootstrap.sh vmtest
volumeMounts:
- name: work
mountPath: /work
- name: secrets
mountPath: /secrets
- name: dev-kvm
mountPath: /dev/kvm
volumes:
# The VM disks and the provisioner state. emptyDir on the node's disk
# rather than a PVC: it is scratch that dies with the Job, and putting it
# on Ceph would have the test's own writes compete with the storage it is
# meant to leave alone.
- name: work
emptyDir:
# The VM images are sparse, so this bounds what is actually written,
# not the 232GiB the geometry declares. 60Gi was already half spent
# by four 10GiB systems disks; the node carries this comfortably with
# ~810GB free on the filesystem behind it.
sizeLimit: 150Gi
# In memory, so the material never touches a node's disk.
- name: secrets
emptyDir:
medium: Memory
sizeLimit: 1Mi
- name: dev-kvm
hostPath:
path: /dev/kvm
type: CharDevice
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# Retrieval evaluation for an embedding model, on German documents of the kind
# this homelab actually files.
#
# Companion to scripts/ai-eval.sh, but it cannot work the same way. 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 builds a
# small labelled corpus, embeds it, and scores real questions against it.
#
# Metrics:
# Recall@1 the correct document ranked first — what Paperless's RAG actually
# feeds to the classifier
# Recall@3 it at least made the shortlist
# MRR mean reciprocal rank, so near-misses are visible rather than
# collapsing to pass/fail
# margin cosine gap 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 deliberately contains confusable pairs — two Allianz policies, four
# documents that are all "Rechnungen", a car insurance and a vehicle
# registration sharing a number plate. An easy corpus would rate everything 100%
# and tell you nothing.
#
# Usage:
# scripts/embed-eval.sh # cluster default
# scripts/embed-eval.sh -m some-model
# scripts/embed-eval.sh -e https://host/v1/embeddings -m model -k KEY
#
# Env equivalents: EMBED_EVAL_ENDPOINT, EMBED_EVAL_MODEL, EMBED_EVAL_KEY.
#
# To compare two models on our llama-embed, -m is not enough: swap --hf-repo in
# kubernetes/apps/llama-server/embed.yaml, let it roll out, then re-run. Note
# that changing the model also invalidates any vectors Paperless has stored, so
# comparisons are best made before an index exists.
#
# Exit code is the number of queries whose correct document did not rank first.
set -uo pipefail
ENDPOINT="${EMBED_EVAL_ENDPOINT:-http://llama-embed.ai.svc.cluster.local:8080/v1/embeddings}"
MODEL="${EMBED_EVAL_MODEL:-qwen3-embedding-0.6b}"
APIKEY="${EMBED_EVAL_KEY:-}"
# Run curl from inside this pod when the endpoint is a cluster-internal name.
# llama-embed has no ingress by design, so there is nothing to reach from a
# workstation.
VIA_POD="${EMBED_EVAL_VIA_POD:-}"
while [ $# -gt 0 ]; do
case "$1" in
-e|--endpoint) ENDPOINT="$2"; shift 2 ;;
-m|--model) MODEL="$2"; shift 2 ;;
-k|--key) APIKEY="$2"; shift 2 ;;
-p|--via-pod) VIA_POD="$2"; shift 2 ;;
-h|--help) sed -n '2,34p' "$0"; exit 0 ;;
*) echo "unknown argument: $1 (try --help)" >&2; exit 2 ;;
esac
done
HERE="$(cd "$(dirname "$0")" && pwd)"
CASES="$HERE/embed-eval"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
for f in "$CASES/corpus.jsonl" "$CASES/queries.jsonl"; do
[ -r "$f" ] || { echo "missing fixture: $f" >&2; exit 2; }
done
# Default to reaching the Service from a pod that is allowed to, unless the
# caller pointed us somewhere externally reachable.
if [ -z "$VIA_POD" ] && [ "${ENDPOINT#*svc.cluster.local}" != "$ENDPOINT" ]; then
VIA_POD="deploy/llama-server -n ai"
fi
# embed <text> -> the vector as a JSON array on stdout
embed() {
local payload out
payload=$(jq -nc --arg m "$MODEL" --arg t "$1" '{model:$m,input:$t}')
if [ -n "$VIA_POD" ]; then
# stderr is kept and surfaced on failure rather than discarded: a
# silently swallowed kubectl or curl error is indistinguishable from a
# model that returned nothing.
# shellcheck disable=SC2086
out=$(kubectl exec $VIA_POD -- curl -sS -m 120 \
-H 'Content-Type: application/json' \
${APIKEY:+-H "Authorization: Bearer $APIKEY"} \
-d "$payload" "$ENDPOINT" 2>"$WORK/err.txt")
else
out=$(curl -sS -m 120 -H 'Content-Type: application/json' \
${APIKEY:+-H "Authorization: Bearer $APIKEY"} \
-d "$payload" "$ENDPOINT")
fi
printf '%s' "$out" | jq -c '.data[0].embedding // empty'
}
echo "endpoint: $ENDPOINT"
echo "model: $MODEL"
[ -n "$VIA_POD" ] && echo "via pod: $VIA_POD"
echo ""
# ---- embed the corpus ------------------------------------------------------
DOCS=$(wc -l < "$CASES/corpus.jsonl" | tr -d ' ')
echo "embedding $DOCS documents..."
: > "$WORK/vecs.jsonl"
start=$(date +%s)
while IFS= read -r line; do
id=$(printf '%s' "$line" | jq -r '.id')
text=$(printf '%s' "$line" | jq -r '.text')
vec=$(embed "$text")
if [ -z "$vec" ]; then
echo "FAILED to embed '$id' — endpoint returned no vector." >&2
[ -s "$WORK/err.txt" ] && { echo "stderr:" >&2; cat "$WORK/err.txt" >&2; }
exit 2
fi
jq -nc --arg id "$id" --argjson v "$vec" '{id:$id, vec:$v}' >> "$WORK/vecs.jsonl"
done < "$CASES/corpus.jsonl"
elapsed=$(( $(date +%s) - start ))
DIM=$(head -1 "$WORK/vecs.jsonl" | jq '.vec | length')
echo " $DOCS documents, ${DIM} dimensions, ${elapsed}s"
echo ""
# ---- score the queries -----------------------------------------------------
# Cosine similarity in jq. The vectors llama.cpp returns are normalised, but the
# full form costs nothing and keeps this correct against servers that do not.
rank() { # queryvec -> "id score" lines, best first
jq -s -r --argjson q "$1" '
def dot($a; $b): reduce range(0; $a|length) as $i (0; . + ($a[$i] * $b[$i]));
def norm($a): (reduce ($a[]) as $x (0; . + ($x * $x))) | sqrt;
map({id: .id, s: (dot(.vec; $q) / ((norm(.vec) * norm($q)) + 1e-12))})
| sort_by(-.s)[]
| "\(.id) \(.s)"
' "$WORK/vecs.jsonl"
}
MISS=0; TOTAL=0; HIT1=0; HIT3=0; MRRSUM=0
printf '%-46s %-22s %5s %8s\n' QUERY EXPECTED RANK MARGIN
printf '%s\n' "------------------------------------------------------------------------------------"
while IFS= read -r line; do
q=$(printf '%s' "$line" | jq -r '.q')
want=$(printf '%s' "$line" | jq -r '.want')
qvec=$(embed "$q")
if [ -z "$qvec" ]; then
echo "FAILED to embed query: $q" >&2
[ -s "$WORK/err.txt" ] && { echo "stderr:" >&2; cat "$WORK/err.txt" >&2; }
exit 2
fi
rank "$qvec" > "$WORK/ranked.txt"
pos=$(grep -n "^$want " "$WORK/ranked.txt" | cut -d: -f1)
pos=${pos:-999}
TOTAL=$((TOTAL + 1))
[ "$pos" -eq 1 ] && HIT1=$((HIT1 + 1)) || MISS=$((MISS + 1))
[ "$pos" -le 3 ] && HIT3=$((HIT3 + 1))
MRRSUM=$(awk -v s="$MRRSUM" -v p="$pos" 'BEGIN{printf "%.6f", s + 1/p}')
# Margin: the correct document's score minus the best incorrect one. On a
# miss this is negative, which says how badly it lost.
want_s=$(awk -v w="$want" '$1==w{print $2; exit}' "$WORK/ranked.txt")
best_other=$(awk -v w="$want" '$1!=w{print $2; exit}' "$WORK/ranked.txt")
margin=$(awk -v a="${want_s:-0}" -v b="${best_other:-0}" 'BEGIN{printf "%+.4f", a-b}')
if [ "$pos" -eq 1 ]; then mark=" "; else mark="!"; fi
printf '%s%-45.45s %-22s %5s %8s\n' "$mark" "$q" "$want" "$pos" "$margin"
if [ "$pos" -ne 1 ]; then
printf ' got instead: %s\n' "$(head -1 "$WORK/ranked.txt" | cut -d' ' -f1)"
fi
done < "$CASES/queries.jsonl"
R1=$(awk -v h="$HIT1" -v t="$TOTAL" 'BEGIN{printf "%.0f", 100*h/t}')
R3=$(awk -v h="$HIT3" -v t="$TOTAL" 'BEGIN{printf "%.0f", 100*h/t}')
MRR=$(awk -v s="$MRRSUM" -v t="$TOTAL" 'BEGIN{printf "%.3f", s/t}')
echo ""
echo "========================================"
printf ' RESULT model=%s dim=%s recall@1=%s%% recall@3=%s%% mrr=%s\n' \
"$MODEL" "$DIM" "$R1" "$R3" "$MRR"
echo "========================================"
exit "$MISS"
+12
View File
@@ -0,0 +1,12 @@
{"id":"stadtwerke-strom","text":"Stadtwerke Muenchen GmbH. Jahresrechnung Strom. Rechnungsnummer 4711. Abrechnungszeitraum 01.01.2025 bis 31.12.2025. Zaehlernummer 1EMH0012345678. Verbrauch 2450 kWh zum Arbeitspreis von 31,2 Cent je kWh. Grundpreis 9,90 EUR monatlich. Zu zahlender Betrag 812,40 EUR faellig am 15.02.2026."}
{"id":"telekom-mobilfunk","text":"Telekom Deutschland GmbH. Ihre Mobilfunkrechnung fuer Januar. Kundennummer 0891234567. Rufnummer 0170 1234567. Tarif MagentaMobil M mit 20 GB Datenvolumen. Grundpreis 39,95 EUR. Verbindungen ins Ausland 4,20 EUR. Rechnungsbetrag 44,15 EUR wird per Lastschrift eingezogen."}
{"id":"finanzamt-steuer","text":"Finanzamt Muenchen Abteilung II. Bescheid ueber Einkommensteuer und Solidaritaetszuschlag fuer 2025. Steuernummer 143/256/78901. Zu versteuerndes Einkommen 54.320 EUR. Festgesetzte Einkommensteuer 8.412 EUR. Bereits geleistete Vorauszahlungen 9.100 EUR. Erstattungsbetrag 688 EUR. Rechtsbehelfsbelehrung siehe Seite 3."}
{"id":"allianz-hausrat","text":"Allianz Versicherungs-AG. Versicherungsschein Hausratversicherung. Policennummer HR-88231. Versicherte Wohnflaeche 96 Quadratmeter. Versicherungssumme 65.000 EUR. Eingeschlossen sind Feuer, Leitungswasser, Sturm und Einbruchdiebstahl. Fahrraddiebstahl bis 1.500 EUR mitversichert. Jahresbeitrag 214 EUR."}
{"id":"allianz-kfz","text":"Allianz Versicherungs-AG. Beitragsrechnung Kraftfahrtversicherung. Policennummer KFZ-55190. Amtliches Kennzeichen M-AB 1234. Fahrzeug VW Golf VIII. Vollkasko mit 500 EUR Selbstbeteiligung, Teilkasko 150 EUR. Schadenfreiheitsklasse SF 12. Jahresbeitrag 638 EUR."}
{"id":"mietvertrag","text":"Mietvertrag ueber Wohnraum. Vermieter Immobilien Wagner GbR, Mieter Konstantin Renner. Mietobjekt Dreizimmerwohnung im zweiten Obergeschoss, 78 Quadratmeter, Lindenstrasse 14. Mietbeginn 01.04.2024. Kaltmiete 1.180 EUR monatlich, Betriebskostenvorauszahlung 240 EUR. Kaution drei Nettokaltmieten."}
{"id":"arbeitsvertrag","text":"Arbeitsvertrag zwischen der Muster Technik GmbH und dem Arbeitnehmer. Taetigkeit als Softwareentwickler ab 01.09.2024. Bruttojahresgehalt 78.000 EUR zahlbar in zwoelf Monatsraten. Woechentliche Arbeitszeit 40 Stunden. Urlaubsanspruch 30 Arbeitstage im Kalenderjahr. Probezeit sechs Monate."}
{"id":"aok-krankenkasse","text":"AOK Bayern. Beitragsbescheid zur gesetzlichen Kranken- und Pflegeversicherung. Versichertennummer A123456789. Beitragspflichtige Einnahmen 4.500 EUR monatlich. Allgemeiner Beitragssatz 14,6 Prozent zuzueglich Zusatzbeitrag 1,7 Prozent. Pflegeversicherung 3,4 Prozent. Monatlicher Gesamtbeitrag 887,50 EUR."}
{"id":"sparkasse-kontoauszug","text":"Stadtsparkasse Muenchen. Kontoauszug Nummer 3 zum Girokonto IBAN DE89 7015 0000 0012 3456 78. Alter Kontostand 2.340,12 EUR. Gutschrift Gehalt 4.210,00 EUR. Lastschrift Miete 1.420,00 EUR. Dauerauftrag Sparplan 300,00 EUR. Neuer Kontostand 4.830,12 EUR."}
{"id":"amazon-bestellung","text":"Amazon EU S.a.r.l. Bestellbestaetigung. Bestellnummer 302-4455667-8899001. Artikel Logitech MX Master 3S Wireless Maus, Menge 1, Preis 89,99 EUR. Versand durch Amazon, Lieferung voraussichtlich am 04.09. Zahlungsart Kreditkarte. Rechnungsbetrag inklusive Mehrwertsteuer 89,99 EUR."}
{"id":"handwerker-rechnung","text":"Sanitaer Bergmeister GmbH. Rechnung fuer erbrachte Leistungen. Rechnungsnummer 2026-0417. Austausch eines defekten Heizungsthermostats sowie Entlueftung von drei Heizkoerpern. Arbeitszeit 2,5 Stunden zu 68 EUR. Material 42,30 EUR. Nettobetrag 212,30 EUR, Umsatzsteuer 19 Prozent, Gesamtbetrag 252,64 EUR."}
{"id":"kfz-zulassung","text":"Landeshauptstadt Muenchen Kreisverwaltungsreferat. Zulassungsbescheinigung Teil I. Amtliches Kennzeichen M-AB 1234. Fahrzeugklasse Personenkraftwagen. Hersteller Volkswagen, Handelsbezeichnung Golf. Erstzulassung 14.03.2022. Hubraum 1498 Kubikzentimeter, Leistung 110 kW. Naechste Hauptuntersuchung 03.2028."}
+12
View File
@@ -0,0 +1,12 @@
{"q":"Wie hoch war mein Stromverbrauch im letzten Jahr?","want":"stadtwerke-strom","note":"paraphrase: asks about consumption, the document says Verbrauch kWh"}
{"q":"Was kostet mein Handyvertrag monatlich?","want":"telekom-mobilfunk","note":"paraphrase: Handyvertrag vs Mobilfunkrechnung/Tarif"}
{"q":"Bescheid vom Finanzamt ueber die Einkommensteuer","want":"finanzamt-steuer","note":"near-literal, should be easy"}
{"q":"Welchen Versicherungsschutz habe ich fuer mein Auto?","want":"allianz-kfz","note":"HARD: two Allianz policies. Must not pick the Hausrat one."}
{"q":"Ist mein Fahrrad gegen Diebstahl versichert?","want":"allianz-hausrat","note":"HARD: same sender as kfz, and bicycles are a detail deep in the text"}
{"q":"Wie hoch ist die Kaltmiete meiner Wohnung?","want":"mietvertrag","note":"must beat sparkasse, which also mentions Miete as a Lastschrift"}
{"q":"Wie viele Urlaubstage stehen mir zu?","want":"arbeitsvertrag","note":"paraphrase: Urlaubsanspruch"}
{"q":"Was zahle ich monatlich an die Krankenkasse?","want":"aok-krankenkasse","note":"Krankenkasse vs Kranken- und Pflegeversicherung"}
{"q":"Wie viel Geld ist auf meinem Girokonto?","want":"sparkasse-kontoauszug","note":"Kontostand"}
{"q":"Rechnung fuer die Reparatur der Heizung","want":"handwerker-rechnung","note":"HARD: several documents are Rechnungen; must pick the Sanitaer one"}
{"q":"Wann muss mein Auto zum TUEV?","want":"kfz-zulassung","note":"HARD: TUEV never appears; the document says Hauptuntersuchung. Pure semantics, and must not be pulled to the Kfz-Versicherung by the shared plate number."}
{"q":"Wann wurde meine Maus geliefert?","want":"amazon-bestellung","note":"colloquial vs Bestellbestaetigung/Lieferung"}

Some files were not shown because too many files have changed in this diff Show More