Compare commits

..
Author SHA1 Message Date
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 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
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
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
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
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
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
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 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
39 changed files with 527 additions and 866 deletions
+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
@@ -95,7 +95,7 @@ spec:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.9.0 # {"$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
@@ -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
@@ -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:
+5
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:
@@ -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
@@ -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
-116
View File
@@ -1,116 +0,0 @@
---
# A read-only view of paperless's backup store, used as a recovery source and
# nothing else.
#
# It points at the production prefix, which is the whole point — the eval
# instance is a copy of the real archive's metadata — and that makes what is
# ABSENT here the safety-critical part:
#
# no retentionPolicy The production store has "30d". Without one here
# there is no policy to enforce, and enforcement is not
# tied to taking a backup: the plugin runs it from the
# instance sidecar on a timer
# (instanceSidecarConfiguration.retentionPolicyIntervalSeconds,
# default 1800). So "this cluster never backs up" would
# not have been enough on its own.
#
# A second property makes it belt-and-braces. Retention runs
# barman-cloud-backup-delete, which takes a server name and works inside
# <destinationPath>/<serverName> — the store's own status keys its recovery
# window by "pprl-pg", confirming that layout. This cluster is pprleval-pg, so
# even an accidental retention policy would address a prefix that holds nothing
# rather than production's backups.
#
# The cluster below completes the guarantee with isWALArchiver: false, so
# nothing is written back either. It does load the plugin — that is required
# for the restore to work at all — and read-only comes from the flag rather
# than from leaving the plugin out.
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
name: pprleval-pg-source
namespace: paperless-eval
spec:
configuration:
destinationPath: s3://renner-uno-k8s-cnpg/pprl-pg
endpointURL: https://fsn1.your-objectstorage.com
s3Credentials:
accessKeyId:
name: hetzner-s3-creds
key: ACCESS_KEY_ID
secretAccessKey:
name: hetzner-s3-creds
key: ACCESS_SECRET_KEY
region:
name: hetzner-s3-creds
key: REGION
wal:
compression: gzip
data:
compression: gzip
---
# A disposable clone of the paperless database, for measuring how well the
# local LLM classifies documents without running the experiment against the
# live archive.
#
# Losing this cluster costs a re-restore and nothing else, which is the intent
# — it is rebuilt whenever a test needs a clean starting point.
#
# One instance, not two. Replication buys availability, and an eval instance
# that is down is simply an eval that waits.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pprleval-pg
namespace: paperless-eval
spec:
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16
enableSuperuserAccess: true
storage:
# Delete-reclaim, unlike the Retain default: this volume is a copy that
# gets rebuilt, so it should vanish with the cluster rather than leave a
# Released PV and an orphaned RBD image behind (issue #262).
storageClass: ceph-block-ephemeral
size: 10Gi
# Declared with isWALArchiver: false, which is what kubernetes/RECOVERY.md
# arrived at after an earlier version of that runbook said to omit it: the
# instance manager loads this plugin to replay WAL during the restore, and
# without it the instance never starts ("Unknown plugin:
# barman-cloud.cloudnative-pg.io").
#
# externalClusters below only says WHERE the backup is; it does not get the
# plugin injected into the pod. So "no plugin" is not the way to keep this
# read-only — "plugin, not archiving" is, and it is the combination verified
# on im-pg on 2026-07-25.
plugins:
- name: barman-cloud.cloudnative-pg.io
enabled: true
isWALArchiver: false
parameters:
barmanObjectName: pprleval-pg-source
bootstrap:
recovery:
source: pprl-pg
database: paperless
owner: paperless
externalClusters:
- name: pprl-pg
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: pprleval-pg-source
serverName: pprl-pg
postgresql:
parameters:
shared_buffers: "256MB"
max_connections: "100"
# The production tuning for write reduction is deliberately not copied.
# It exists to spare the SSDs under a database that runs for years; this
# one is read-only for hours at a time and then deleted.
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
@@ -1,13 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: paperless-eval
# The instance only. The measurement harness lives in scripts/paperless-eval/
# and is applied by hand — a Job is immutable, re-running one means deleting and
# recreating it, and Flux would just put the old one back. Nothing here should
# converge on a finished measurement.
resources:
- secrets.yaml
- cluster.yaml
- pvcs.yaml
- redis.yaml
- paperless.yaml
@@ -1,203 +0,0 @@
---
# Deliberately much smaller than the production ConfigMap. Everything that
# exists to serve people is gone: no remote-user auth, no CSRF origins, no
# barcode or mail consumption, no Tika/Gotenberg. Nothing consumes documents
# here, and nothing signs in.
#
# What remains is the database connection and the AI settings, because those
# are what the evaluation measures.
apiVersion: v1
kind: ConfigMap
metadata:
name: paperless-eval-config
namespace: paperless-eval
data:
PAPERLESS_URL: "http://paperless.paperless-eval.svc.cluster.local"
PAPERLESS_ALLOWED_HOSTS: "*"
PAPERLESS_TIME_ZONE: "Europe/Berlin"
PAPERLESS_REDIS: "redis://redis:6379"
PAPERLESS_DBENGINE: "postgresql"
PAPERLESS_DBHOST: "pprleval-pg-rw"
PAPERLESS_DBNAME: "paperless"
# Polling off, so a pod that finds an empty consume volume does not spend a
# thread looking at it every ten seconds for the length of a run.
PAPERLESS_CONSUMER_POLLING_INTERVAL: "0"
# The AI settings under test. The endpoints are placeholders that each eval
# job overrides for the model it is measuring — a job runs its own
# llama-server beside paperless in the same pod, so the value that matters at
# run time is the one in the job, not the one here.
#
# Same caveat as production, and sharper here: paperless reads these from an
# ApplicationConfiguration row and falls back to the environment only when
# that row is empty. The row comes across in the restored database, so
# anything configured in the production admin UI arrives with it and silently
# wins over this ConfigMap — an eval would then measure production's settings
# while appearing to measure the job's.
#
# Check it before trusting any result, and after every fresh restore:
#
# select llm_backend, llm_model, llm_embedding_backend, llm_embedding_model
# from paperless_applicationconfiguration;
#
# Every column must be null for the environment to apply.
PAPERLESS_AI_ENABLED: "true"
PAPERLESS_AI_LLM_BACKEND: "openai-like"
PAPERLESS_AI_LLM_ENDPOINT: "http://127.0.0.1:8080/v1"
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "900"
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
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: "bge-m3"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
# A base instance that proves the restored database is usable and that the
# search index and classifier build against it. The evaluation itself does not
# run here — each job brings its own paperless container so it can point at its
# own model — but this is what makes a broken clone visible before a job spends
# hours on it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
namespace: paperless-eval
labels:
app.kubernetes.io/name: paperless
annotations:
configmap.reloader.stakater.com/reload: paperless-eval-config
# Both, like production: the secret refreshes on the same 10m cycle, and
# without this the pod would keep a rotated key until something else
# restarted it — which defeats the point of matching the interval.
secret.reloader.stakater.com/reload: paperless-eval-secrets
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless
template:
metadata:
labels:
app.kubernetes.io/name: paperless
spec:
enableServiceLinks: false
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: paperless
# No $imagepolicy marker, unlike apps/paperless: a measurement is
# only comparable against a fixed version, so this tag moves when
# somebody decides to re-measure, not when a release lands.
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: paperless-eval-config
env:
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-eval-secrets
key: secret-key
- name: PAPERLESS_DBUSER
valueFrom:
secretKeyRef:
name: pprleval-pg-app
key: username
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: pprleval-pg-app
key: password
volumeMounts:
- name: data
mountPath: /usr/src/paperless/data
- name: media
mountPath: /usr/src/paperless/media
- name: consume
mountPath: /usr/src/paperless/consume
# 60 minutes, against production's 10, and matching this app's Flux
# Kustomization timeout.
#
# Production's budget was sized for a from-scratch boot on this same
# corpus and held, so 10 would probably do — but there the volume is
# Retain and the index survives, making that a one-off. Here the
# volume is ephemeral and the index is rebuilt on every re-test, so
# the expensive path is the normal one. A killed pod starts the work
# over rather than resuming, which turns a slow boot into a
# crash-loop that stalls Flux behind wait: true.
#
# The LLM index is not in this budget: it is a celery task, and boot
# only runs init-llmindex-migrate, a migration check.
startupProbe:
httpGet:
path: /accounts/login/
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 180
livenessProbe:
httpGet:
path: /accounts/login/
port: http
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /accounts/login/
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 2Gi
# Production's context verbatim, and it has to be: the paperless-ngx
# entrypoint starts as root, chowns the mounted data/media/consume
# volumes and then drops to uid 1000 via setgid/setuid. Without these
# the same image fails that sequence with EPERM.
#
# It bites harder here than in production, where those volumes were
# chowned once years ago and keep their ownership. These are fresh on
# every rebuild, so the chown is not a no-op.
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
capabilities:
drop: ["ALL"]
add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
volumes:
- name: data
persistentVolumeClaim:
claimName: paperless-eval-golden
- name: media
persistentVolumeClaim:
claimName: paperless-eval-media
- name: consume
persistentVolumeClaim:
claimName: paperless-eval-consume
---
apiVersion: v1
kind: Service
metadata:
name: paperless
namespace: paperless-eval
spec:
selector:
app.kubernetes.io/name: paperless
ports:
- port: 80
targetPort: http
-129
View File
@@ -1,129 +0,0 @@
---
# All four use ceph-block-ephemeral rather than the Retain default. Everything
# here is derived — a restored database, an index built from it, a cache — so
# the volumes should go when the namespace does instead of leaving Released PVs
# and orphaned RBD images to reclaim by hand (issue #262).
#
# /data holds the Tantivy search index and the classifier pickle, both rebuilt
# from the database, and the LLM index — which is the expensive one at roughly
# three hours for the full corpus.
#
# Measured on production for the same ~3900 documents: 36 MiB search index,
# 125 MiB LLM index. 10Gi is far more than that needs, and deliberately so —
# an embedding model with wider vectors than bge-m3's 1024 dimensions is
# exactly what phase two varies, and running out of space mid-run would cost
# the whole rebuild.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
# Named for its role rather than its mount point: the smoke-test deployment
# builds /data here, and every eval job copies it out. Replaces the earlier
# RWO paperless-eval-data, whose access mode could not be widened in place —
# a bound claim's spec is immutable.
name: paperless-eval-golden
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
# RWX, because the eval jobs read it too — each copies it into its own
# emptyDir rather than mounting it live. The vector store is SQLite, and
# SQLite locking across pods over CephFS is not something to rely on.
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem-ephemeral
resources:
requests:
storage: 10Gi
---
# Empty, and stays that way. Paperless refuses to start without the path, but
# the AI path reads title, content and filename out of the database and never
# opens a file — so the documents themselves are not copied here. That is what
# keeps this instance cheap and keeps a second copy of the archive off the
# cluster.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-eval-media
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block-ephemeral
resources:
requests:
storage: 1Gi
---
# Also empty: nothing is consumed here, but the path has to exist.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-eval-consume
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block-ephemeral
resources:
requests:
storage: 1Gi
---
# The eval jobs' model cache, RWX so several jobs can read the same weights at
# once — that is the whole point of measuring two candidates in parallel.
#
# Separate from the ai namespace's llama-server-models despite holding some of
# the same files: PVCs do not cross namespaces. A model therefore downloads
# once here and is then shared by every job, which is the cost a new candidate
# carries anyway.
#
# NOT ephemeral, unlike everything else in this namespace: re-downloading tens
# of gigabytes on a slow uplink is a worse outcome than an orphaned volume.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: llama-models
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 120Gi
---
# Results outlive the jobs that produce them: a Job is deleted and recreated to
# re-run a configuration, and comparing configurations is the entire exercise.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-eval-results
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-eval-redis
namespace: paperless-eval
labels:
velero.io/exclude-from-backup: "true"
spec:
accessModes:
- ReadWriteOnce
storageClassName: ceph-block-ephemeral
resources:
requests:
storage: 1Gi
-81
View File
@@ -1,81 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: paperless-eval
labels:
app.kubernetes.io/name: redis
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: redis
template:
metadata:
labels:
app.kubernetes.io/name: redis
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: redis
# No $imagepolicy marker; see the note on the paperless image.
image: redis:8.10.1-alpine
# No --save: this holds the celery queue and the suggestion cache,
# both of which are regenerated. Persisting them across a restart of
# a throwaway instance would only preserve stale suggestions.
args:
- redis-server
- --save
- ""
- --loglevel
- warning
ports:
- name: redis
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
livenessProbe:
tcpSocket:
port: redis
initialDelaySeconds: 10
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: paperless-eval-redis
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: paperless-eval
spec:
selector:
app.kubernetes.io/name: redis
ports:
- port: 6379
targetPort: redis
@@ -1,45 +0,0 @@
---
# The same Hetzner S3 key every CNPG-using namespace pulls, needed here only so
# the recovery source can be read. See apps/paperless for why one shared key
# rather than per-app paths.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: hetzner-s3-creds
namespace: paperless-eval
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: hetzner-s3-creds
dataFrom:
- extract:
key: infrastructure/hetzner-s3
---
# Django's secret key, shared with the production instance rather than given
# its own vault entry.
#
# It signs sessions and CSRF tokens, and this instance has no ingress and no
# users — nothing signs in, so nothing is at stake in sharing it. A separate
# entry would mean a vault write before the namespace can start, for a
# throwaway instance that gets rebuilt.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: paperless-eval-secrets
namespace: paperless-eval
spec:
# 10m, matching apps/paperless rather than the 1h default: the point of
# sharing the key is that the two hold the same value, and a slower refresh
# here would let them diverge for up to an hour after a rotation.
refreshInterval: 10m
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: paperless-eval-secrets
dataFrom:
- extract:
key: apps/paperless
+1 -2
View File
@@ -170,7 +170,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
@@ -325,7 +325,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
+5 -9
View File
@@ -227,16 +227,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:
+10 -2
View File
@@ -159,8 +159,16 @@ if [[ "$PHASE" == "vmtest" ]]; then
# sun_path caps a unix socket path at 108 bytes and the provisioner puts a
# qemu monitor socket under the state directory, so this has to stay short.
# A path under the repo or a long mktemp dir silently fails to boot.
VM_STATE=/tmp/bootstrap-vmtest
# A path under the repo or a long mktemp dir silently fails to boot. The
# caller may override it to aim the qcow2 images at a sized volume; the
# guard turns "too long" from a silent boot failure into an error.
VM_STATE="${VM_STATE:-/tmp/bootstrap-vmtest}"
[[ ${#VM_STATE} -le 50 ]] \
|| fail "VM_STATE '$VM_STATE' is ${#VM_STATE} bytes; keep it under 50 so the qemu monitor socket fits in sun_path's 108"
# cleanup_vmtest runs `sudo rm -rf "$VM_STATE"`, so refuse anything that is
# not a nested absolute path.
[[ $VM_STATE == /*/?* ]] \
|| fail "VM_STATE '$VM_STATE' must be an absolute path at least two levels deep"
VM_CLUSTER=bootstrap-vmtest
VM_DIR=$(mktemp -d)
TALOSCTL_BIN=$(command -v talosctl)
@@ -1,34 +0,0 @@
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: paperless-eval
namespace: flux-system
spec:
# A throwaway copy of paperless for measuring the local LLM against the real
# corpus. Its Cluster restores from paperless's backup store and archives
# nothing back; see kubernetes/apps/paperless-eval/cluster.yaml.
#
# prune: true and no prune-disabled annotations anywhere underneath — the
# data here is a copy, and being able to delete the whole namespace by
# removing this file is the point.
interval: 10m
retryInterval: 1m
# 30m is not enough: the first start restores the database and then builds
# the search index over ~3900 documents before the webserver answers.
timeout: 60m
sourceRef:
kind: GitRepository
name: flux-system
path: ./kubernetes/apps/paperless-eval
prune: true
wait: true
dependsOn:
- name: infrastructure
- name: infrastructure-crds
- name: infrastructure-ceph-cluster
- name: infrastructure-cnpg-barman
healthCheckExprs:
- apiVersion: postgresql.cnpg.io/v1
kind: Cluster
failed: "has(status.phase) && status.phase.startsWith('Failed')"
current: "has(status.phase) && (status.phase == 'Cluster in healthy state' || status.readyInstances > 0)"
@@ -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
@@ -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"
@@ -73,11 +73,6 @@ metadata:
# classifies documents without experimenting on the live archive.
apiVersion: v1
kind: Namespace
metadata:
name: paperless-eval
---
apiVersion: v1
kind: Namespace
metadata:
name: audiobookshelf
---
@@ -34,22 +34,6 @@ spec:
- ports:
- port: "8080"
protocol: TCP
# The paperless-eval namespace reaches the embedding endpoint for the same
# reason paperless does; its chat model runs inside each eval job's own pod
# and needs no grant here.
#
# Scoped to the namespace rather than to 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 be a trap that presents as an
# embedding call silently dropped.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless-eval
toPorts:
- 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.
@@ -6,7 +6,6 @@ resources:
- ingress-isolation.yaml
- authentik-ns.yaml
- paperless-ns.yaml
- paperless-eval-ns.yaml
- audiobookshelf-ns.yaml
- home-assistant-ns.yaml
- spegel-ns.yaml
@@ -1,141 +0,0 @@
# Narrower than paperless-ns.yaml, because this namespace serves nobody.
#
# There is no ingress, no gate outpost and no hostname: the only things that
# talk to it are its own pods. Monitoring is admitted so a run can be watched,
# and host/remote-node for the kubelet probes.
#
# Egress reaches the ai namespace for the embedding endpoint, which is the one
# dependency an eval job has outside its own pod, and world:443 so the CNPG
# recovery can read the backup store on Hetzner.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny-ingress
namespace: paperless-eval
spec:
endpointSelector: {}
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless-eval
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
- fromEntities:
- host
- remote-node
# ICMP — all standard ICMPv4 + ICMPv6 from any cluster pod. See
# home-assistant-ns.yaml for the rationale.
- fromEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless-eval
# The embedding endpoint. The chat model runs inside each job's own pod, so
# it needs no grant; the embedder is shared with production because it is
# read-only and cheap, and swapping it is a phase-two concern.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-embed
toPorts:
- ports:
- port: "8080"
protocol: TCP
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": rook-ceph
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- toEntities:
- host
- remote-node
# world:443 — the CNPG recovery reads the backup store on Hetzner Object
# Storage, and image pulls.
- toEntities:
- world
toPorts:
- ports:
- port: "443"
protocol: TCP
# ICMP — egress mirror; see ingress block above.
- toEndpoints:
- matchExpressions:
- key: io.kubernetes.pod.namespace
operator: Exists
icmps:
- fields:
- { type: 0, family: IPv4 }
- { type: 3, family: IPv4 }
- { type: 8, family: IPv4 }
- { type: 11, family: IPv4 }
- { type: 12, family: IPv4 }
- { type: 1, family: IPv6 }
- { type: 2, family: IPv6 }
- { type: 3, family: IPv6 }
- { type: 4, family: IPv6 }
- { type: 128, family: IPv6 }
- { type: 129, family: IPv6 }
- { type: 133, family: IPv6 }
- { type: 134, family: IPv6 }
- { type: 135, family: IPv6 }
- { type: 136, family: IPv6 }
- { type: 143, family: IPv6 }
---
# The CNPG operator polls each instance's status endpoint over HTTPS
# (https://<pod-ip>:8000/pg/status) to populate Cluster status. Without it the
# Cluster reports "Instance Status Extraction Error: HTTP communication issue"
# while Postgres serves normally — issue #433, where the operator's dial timed
# out because the apps baseline never allowed the database namespace.
#
# It matters here even for a throwaway: the Flux Kustomization waits on
# Cluster status, and a smoke test that cannot report its own health is not one.
#
# Scoped to the pprleval-pg endpoints and that one port, like the equivalent
# rules for wp-pg and ha-pg: the barman and mariadb operators sharing the
# database namespace have no reason to reach anything here.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: pprleval-pg-allow-cnpg-operator
namespace: paperless-eval
spec:
endpointSelector:
matchLabels:
cnpg.io/cluster: pprleval-pg
ingress:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": database
app.kubernetes.io/name: cloudnative-pg
toPorts:
- ports:
- port: "8000"
protocol: TCP
@@ -27,7 +27,6 @@ spec:
- ingress
- monitoring
- paperless
- paperless-eval # same apps/paperless key plus the shared barman S3 creds
- trmnl
- velero
- woodpecker # wp-pg barman creds; woodpecker moving out of `apps` (#433)
@@ -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.1 # {"$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
@@ -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.1 # {"$imagepolicy": "flux-system:ceph"}
command:
- /bin/bash
- -c
@@ -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
+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"}