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>
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>
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>
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>
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>
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>
#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>
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>
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>
`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>
#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>
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>
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>
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>
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
18 changed files with 871 additions and 19 deletions
# 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
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.