Compare commits

..
Author SHA1 Message Date
claude-botandClaude Opus 5 e30d833624 fix(llama-embed): raise the memory limit to match its own context and batch
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
#739 raised the context from 2048 to 4096 and the micro-batch from an effective
512 to 1024, both to match how Paperless chunks documents. It left memory at
2Gi/4Gi, which was sized for the smaller values.

The pod ran fine interactively and was OOMKilled (exit 137) partway through the
first sustained run, around 300 documents into an embedding pass. Nothing in the
server log marked it — the process was killed outright, so the last line is a
half-finished task and the reason is only visible in the container's
lastState.terminated.

This would have surfaced when Paperless first indexed the archive, i.e. on the
first real use rather than under test.

The weights are only ~600 MiB. What sets the footprint is four slots at 4096
context with a 1024-token micro-batch, plus Vulkan allocating the iGPU working
set from system RAM where it counts against this cgroup — the same effect that
put llama-server at 32Gi for 19 GiB of weights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 08:14:19 +02:00
k.renner 35a8a8d975 Merge pull request 'fix(paperless): restart on paperless-config changes, not just secrets' (#741) from paperless-reloader into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#741
2026-09-02 05:20:35 +00:00
claude-botandClaude Opus 5 a003df0ef8 fix(paperless): restart on paperless-config changes, not just secrets
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 AI settings from #739 applied cleanly to git and to the ConfigMap in the
cluster, and the application never saw them: the pod was 13 minutes old with no
PAPERLESS_AI_* in its environment at all.

paperless-config is consumed with envFrom, and Kubernetes does not restart a pod
when a ConfigMap read that way changes — the container keeps the environment it
started with. The Deployment carried a Reloader annotation for paperless-secrets
only, so secret rotations restarted it but settings changes did not.

Adding the ConfigMap to Reloader closes that, and makes the annotation pair
match how the ConfigMap is actually consumed rather than only the Secret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:19:22 +02:00
k.renner f0e48b226a Merge pull request 'feat(paperless): AI suggestions against the cluster's own LLM' (#739) from paperless-llm into master
Reviewed-on: k.renner/homelab#739
2026-09-02 05:16:01 +00:00
k.renner 669ef1c5ee Merge pull request 'refactor(paperless): retire paperless-data, the CephFS predecessor volume' (#740) from close-paperless-data-migration into master
Reviewed-on: k.renner/homelab#740
2026-09-02 05:15:31 +00:00
claude-botandClaude Opus 5 11cd02ec44 refactor(paperless): retire paperless-data, the CephFS predecessor volume
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 `data` volume moved from paperless-data (RWX, ceph-filesystem) to
paperless-data-block (RWO, ceph-block) on 2026-08-28. paperless-data was
kept as the rollback, on the condition that search be confirmed working
first — the point being to prove the Whoosh index survived the copy, not
merely that paperless started.

Both halves of that condition are now settled. index_optimize succeeded on
every night between the repoint and 2026-09-01, and that task opens and
rewrites the index rather than just reading it. Then the 3.1.2 upgrade
replaced Whoosh with Tantivy outright, so the index paperless-data was
preserving no longer exists in a form anything can read.

Removed: the PVC, the migration record, and the comments that described the
volume as in-flight. The rationale for RWO-on-RBD stays where it is useful,
on the PVC and on the Deployment's volume list, since it is the reason not
to move this back onto a shared filesystem later.

paperless-data carries kustomize.toolkit.fluxcd.io/prune: disabled, so this
commit does not delete anything by itself. The PVC, its Retain-policy PV and
the backing CephFS subvolume have to be removed by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:13:07 +02:00
claude-botandClaude Opus 5 68865e15c5 feat(paperless): AI suggestions against the cluster's own LLM
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
Paperless 3.x has LLM suggestions built in, so this is configuration rather
than a sidecar: no extra image, no second database, no Paperless API token to
mint. Both endpoints are ClusterIP Services, so document text never leaves the
cluster and there is no key to rotate.

The make-or-break requirement was verified against the live endpoint before any
of this was written. Paperless's openai-like client calls chat_with_tools with
tool_required=True and error_on_no_tool_call=True — there is no free-text JSON
fallback, so the model must emit real tool_calls. It does: finish_reason
"tool_calls", a populated tool_calls array, empty content. A German utility
bill came back tagged Rechnung / Strom / Versorger.

That same probe showed the tags in German but document_types in English, which
is the documented weak spot and exactly what OUTPUT_LANGUAGE's second
localisation pass addresses. The value must be "de-de": it is matched against
Django's settings.LANGUAGES, where a bare "de" does not resolve.

llama-embed needed three fixes to serve as the RAG embedder:

  - --alias, so the model is not advertised as its whole --hf-repo string.
    That string would otherwise sit verbatim in this ConfigMap and break on the
    next model swap.
  - --pooling last. Qwen3-Embedding is trained for it; unspecified,
    llama-server takes the GGUF default, which is invisible from outside. Set
    now because nothing is indexed yet — changing pooling later invalidates
    every stored vector.
  - --batch-size/--ubatch-size 1024. The logs showed llama-server clamping both
    to 512 under --embeddings, below Paperless's 1024-token chunk size. 2048 is
    avoided deliberately: that is where this Vulkan stack fails with
    ErrorDeviceLost.

Both network policies are scoped to the two pods and the one port on each side,
so neither grant widens if something else is placed in either namespace.

REQUEST_TIMEOUT goes to 300s. At ~11 tok/s a classification is 2-4k prompt
tokens plus a second round trip for localisation, and the 120s default is
marginal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:11:36 +02:00
k.renner be2f8b9a18 Merge pull request 'feat(paperless): upgrade to v3.1.2 (image policy 2.x -> 3.x)' (#738) from paperless-3x into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#738
2026-09-02 05:01:49 +00:00
claude-botandClaude Opus 5 7dacb6320c feat(paperless): upgrade to v3.1.2 (image policy 2.x -> 3.x)
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
paperless-ngx released 3.0.0 on 2026-07-22; the ImagePolicy was deliberately
gated at semver 2.x, so the deployment stayed on 2.20.15 while upstream moved
to 3.1.2. No 2.x release has appeared since 3.0.0, so the gate now holds the
deployment on a branch that receives no patches.

Preflight for the major bump, against docs/migration-v3.md:

- 2.20.15 is the only valid source version, and it is enforced rather than
  advised: src/paperless/checks.py raises paperless.E002 unless the documents
  app has 1075_workflowaction_order applied, and init-migrations runs
  `manage.py check --tag compatibility` before migrate. We are on 2.20.15
  exactly, so there is no intermediate hop.
- CONSUMER_POLLING is renamed to CONSUMER_POLLING_INTERVAL. Renamed here; the
  10s value and the CephFS-inotify reason for it are unchanged.
- CONSUMER_BARCODE_SCANNER is removed — pyzbar is gone and zxing-cpp is the
  only backend. We set ZXING, which is what remains, so dropping the variable
  keeps current behaviour.
- SECRET_KEY becomes mandatory. Already set from the paperless-secrets Secret.
- DBENGINE becomes required rather than inferred, and must read `postgresql`,
  not `postgres` — a distinction 3.0.1 shipped a fix for. Already `postgresql`.
- v3 stops rejecting duplicates by default; CONSUMER_DELETE_DUPLICATES=true
  preserves the current behaviour and is already set.
- Nothing else in the removed set applies: no encrypted documents, no pre- or
  post-consume scripts, no OCR_MODE or OCR_SKIP_ARCHIVE_FILE, no
  CONSUMER_IGNORE_PATTERNS relying on fnmatch, no CONSUMER_INOTIFY_DELAY. Tika
  and Gotenberg settings are unchanged in 3.x, as are the remote-user auth
  variables Authentik depends on.
- API v1 is gone and the minimum is version 9. No consumer in this repo uses
  the API: docs.renner.uno is a Traefik redirect to paperless.renner.uno,
  Gatus probes the URL root, and Authentik holds only proxy-provider config.
- PostgreSQL 16 clears the documented minimum of 14. Redis has no documented
  minimum; 8.10.1 is current.

A startupProbe is added because the first 3.x boot is long. s6 makes
svc-webserver depend on init-migrations and init-search-index, so nothing is
served until both finish, and on this boot they are the SHA256 checksum
backfill — which opens and reads every original and every archive file — and
a full Tantivy index build, because the Whoosh index cannot be converted.
Against the existing probes alone the pod would be killed 210s in, part-way
through a migration, and the next boot would start over.

This upgrade is one way. The 3.x tree squashes the entire pre-3.0 migration
history into 0001_squashed and deletes the individual files, the checksum
migration's reverse is RunPython.noop so the SHA256 values would survive a
schema reversal that 2.x cannot read, and task history is dropped outright.
Upstream states none of this as policy, but rollback should be treated as
restore-from-backup and redeploy 2.20.15, not a tag revert against a migrated
database.

Policy gate moves to 3.x; a 4.x cutover needs manual review again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 06:49:52 +02:00
security-bot 8a00b7735d chore(image-automation): bump rhasspy/wyoming-whisper:3.7.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-01 22:22:01 +00:00
k.renner 2c4aad858c Merge pull request 'fix(llama-gate): name the authentication flow, as Audiobookshelf does' (#737) from llama-gate-flow into master
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#737
2026-09-01 19:54:16 +00:00
claude-botandClaude Opus 5 3f014a0020 fix(llama-gate): name the authentication flow, as Audiobookshelf does
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 gate set only authorization_flow, so authentication fell back to
Authentik's default flow and the step-up ran on top of a password prompt
instead of replacing it — a clumsy sign-in for a UI meant to be opened often.

Audiobookshelf and immich set both halves, and that is the pattern to copy for
a user-facing app:

  authentication_flow: passkey-passwordless-authentication
  authorization_flow:  passkey-stepup-authorization

Both are referenced with !Find rather than !KeyOf because they are defined in
the passkey-stepup document of this ConfigMap, and !KeyOf resolves only within
a single document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:52:54 +02:00
k.renner a056f8f67f Merge pull request 'feat(llama-server): public WebUI behind an Authentik gate' (#736) from llama-authentik-gate into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
Reviewed-on: k.renner/homelab#736
2026-09-01 19:44:15 +00:00
claude-botandClaude Opus 5 aa53335c2b feat(llama-server): public WebUI behind an Authentik gate
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
llm.renner.uno becomes public and is fronted by a dedicated Authentik proxy
outpost. The gate is the entire access control: llama-server has no
authentication of its own and serves the OpenAI API on the same port as the
chat UI, which is exactly why the hostname was LAN-only until now.

Follows the vscode-gate pattern, including its passkey step-up, for the same
reason — what sits behind the gate is unprotected, so the gate is not a second
factor on top of an app login, it is the only one.

The API stays in-cluster. Home Assistant, Paperless and CI address
llama-server.ai.svc.cluster.local:8080 directly, never traverse Traefik or the
outpost, and so need no credentials. Nothing about them changes.

Traefik is deliberately removed from the ai namespace's ingress policy. It
routes the hostname to the outpost and never to llama-server, and dropping the
rule makes that structural: an Ingress pointing straight at the Service, by
accident or otherwise, would simply not connect.

Two details taken from prior gate failures rather than rediscovered:

  - No /outpost.goauthentik.io Ingress on auth.renner.uno. Only paperless-gate
    has one; the other gates serve those paths on their own host. Adding a
    second would have claimed a host+path paperless already owns and broken its
    login.
  - skip_path_regex covers manifest.webmanifest and sw.js, both of which the
    WebUI serves. A manifest is fetched with credentials omitted per the W3C
    spec, so it carries no cookie and the gate answers with a redirect, turning
    every page load into a round-trip. That is the failure that forced
    paperless-gate's carve-out and retired music-assistant-gate outright.

No group and no policy binding: any user who can authenticate gets in, as with
Immich, Audiobookshelf, Home Assistant and Music Assistant. The gates that pin
membership to a named user in git are admin interfaces — code-server over HA
secrets, esphome, zigbee2mqtt, prometheus — where the allowlist is the point. A
chat UI is not one of those, and its membership belongs in the user directory
rather than in a manifest.

The e2e checks move to the Service. They used to drive the public hostname,
which now answers with an Authentik redirect; going in-cluster is also how every
real consumer reaches it. A new check asserts the public hostname bounces an
anonymous request, so a regression that let Traefik reach llama-server directly
would fail rather than pass quietly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:41:08 +02:00
k.renner 5129ba7713 Merge pull request 'fix: [alert] KubeJobFailed (velero) (#726)' (#734) from alert-fix/issue-726 into master
ci/woodpecker/cron/alert-monitor Pipeline was successful
ci/woodpecker/cron/issue-fix Pipeline was successful
Reviewed-on: k.renner/homelab#734
2026-09-01 19:22:17 +00:00
k.renner 2b27d6c599 Merge pull request 'fix(llama-server): one name, since a second cannot win the id' (#733) from llama-single-alias into master
Reviewed-on: k.renner/homelab#733
2026-09-01 19:21:01 +00:00
issue-fix-bot 8b18fba424 fix: [alert] KubeJobFailed (velero) (#726)
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-01 16:45:10 +00:00
16 changed files with 421 additions and 209 deletions
@@ -877,6 +877,105 @@ data:
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
# llama-server WebUI gate
#
# llama-server has no authentication of its own — it serves its chat UI
# and a full OpenAI-compatible API to anyone who can reach the port. That
# is why it was LAN-only until now. This gate is what makes a public
# hostname defensible: the outpost terminates every request, enforces the
# session, and only then forwards to llama-server.
#
# Same reasoning as vscode-gate, and the same passkey step-up: what sits
# behind the gate is entirely unprotected, so the gate is the whole of the
# access control rather than a second factor on top of an app login.
#
# In-cluster consumers (Home Assistant, Paperless, CI) are unaffected:
# they address llama-server.ai.svc.cluster.local directly and never pass
# through Traefik or this outpost.
#
# No group and no policy binding: any user who can authenticate gets in,
# as with Immich, Audiobookshelf, Home Assistant and Music Assistant. The
# gates that pin membership to a named user in git — vscode, esphome,
# zigbee2mqtt, shelly-manager, prometheus — are admin interfaces, where
# the allowlist is the point. A chat UI is not one of those, and deciding
# its membership here would put an access-control choice in a manifest
# instead of leaving it where the user directory belongs.
# ------------------------------------------------------------------
- id: llama-gate-provider
model: authentik_providers_proxy.proxyprovider
identifiers:
name: llama-gate
state: present
attrs:
# Both flows, mirroring the Audiobookshelf provider. Setting only
# authorization_flow leaves authentication on Authentik's default
# flow, which is what made the first login clumsy — the step-up ran
# on top of a password prompt instead of replacing it. With the
# passwordless flow named explicitly, signing in is the passkey
# itself.
#
# Referenced by !Find rather than !KeyOf: both flows are defined in
# the passkey-stepup document of this ConfigMap, and !KeyOf only
# resolves within one document.
authentication_flow: !Find [authentik_flows.flow, [slug, passkey-passwordless-authentication]]
authorization_flow: !Find [authentik_flows.flow, [slug, passkey-stepup-authorization]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
mode: proxy
internal_host: http://llama-server.ai.svc.cluster.local:8080
internal_host_ssl_validation: false
external_host: https://llm.renner.uno
cookie_domain: renner.uno
access_token_validity: hours=24
refresh_token_validity: days=30
intercept_header_auth: true
# The WebUI ships `<link rel="manifest">` and registers a service
# worker. Per the W3C spec a manifest is fetched with
# `credentials: 'omit'`, so it carries no session cookie and the gate
# answers it with a 302 to auth.renner.uno — which turns every page
# load into a redirect round-trip. This is the failure that forced
# paperless-gate's own carve-out and that retired music-assistant-gate
# outright. Both paths serve no authenticated content, so letting them
# through anonymously costs nothing.
skip_path_regex: ^/(manifest\.webmanifest|sw\.js)$
- model: authentik_core.application
identifiers:
slug: llama-gate
state: present
attrs:
name: Local LLM (Gate)
slug: llama-gate
provider: !KeyOf llama-gate-provider
meta_launch_url: https://llm.renner.uno
meta_description: "Forward-auth gate in front of the local llama-server WebUI"
- model: authentik_outposts.outpost
identifiers:
name: llama-gate
state: present
attrs:
type: proxy
service_connection: !KeyOf k8s-conn
providers:
- !KeyOf llama-gate-provider
config:
log_level: info
authentik_host: "http://authentik-server.authentik.svc.cluster.local"
authentik_host_insecure: false
authentik_host_browser: "https://auth.renner.uno/"
refresh_interval: "minutes=5"
container_image: null
object_naming_template: "ak-outpost-%(name)s"
kubernetes_namespace: authentik
kubernetes_replicas: 1
kubernetes_service_type: ClusterIP
kubernetes_disabled_components: ["ingress"]
kubernetes_image_pull_secrets: []
kubernetes_ingress_annotations: {}
kubernetes_ingress_class_name: null
kubernetes_ingress_secret_name: "authentik-outpost-tls"
kubernetes_json_patches: null
- model: authentik_outposts.outpost
identifiers:
name: authentik Embedded Outpost
@@ -15,6 +15,7 @@ resources:
- alertmanager-gate-egress.yaml
- prometheus-gate-egress.yaml
- wordpress-gate-egress.yaml
- llama-gate-egress.yaml
- outpost-servicemonitors.yaml
- ldap-outpost-cert.yaml
- ldap-outpost-lb.yaml
@@ -0,0 +1,30 @@
---
# Lets the llama-gate outpost reach llama-server. The cluster baseline CNP
# (kubernetes/infrastructure/network-policies/authentik-ns.yaml) otherwise stops
# any authentik-namespace pod from entering the ai namespace.
#
# The outpost is the trust boundary: it forwards to llama-server only after
# validating the Authentik session, and the matching ingress rule in
# ai-ns.yaml admits nothing else — so nothing in the cluster can route around
# the gate to reach the unauthenticated API through the public hostname.
#
# Scoped to this outpost's pod label rather than the whole namespace, so the
# other gates cannot reach llama-server either.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: llama-gate-egress
namespace: authentik
spec:
endpointSelector:
matchLabels:
goauthentik.io/outpost-name: llama-gate
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-server
toPorts:
- ports:
- port: "8080"
protocol: TCP
+38 -3
View File
@@ -44,12 +44,37 @@ spec:
- Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0
# Serves POST /v1/embeddings.
- --embeddings
# A short, stable id for consumers. Without it the model is
# advertised as the whole --hf-repo string, which would end up
# verbatim in Paperless's config and break on the next model swap.
# One name only: llama-server keeps aliases in a sorted set and
# reports the first as the id, so a second name can steal it.
- --alias
- qwen3-embedding-0.6b
# Qwen3-Embedding is trained for last-token pooling. Left
# unspecified llama-server takes the GGUF's default, which is not
# visible from outside and would silently produce vectors the model
# was not trained to emit. Set before anything is indexed: changing
# pooling later invalidates every stored embedding.
- --pooling
- last
- --n-gpu-layers
- "99"
- --threads
- "2"
# Paperless chunks documents at 1024 tokens. The defaults are
# batch 2048 / ubatch 512, and with --embeddings llama-server
# clamps both down to 512 rather than run them mismatched — below
# the chunk size. Matching both to 1024 fits a whole chunk in one
# pass; 2048 is deliberately avoided, since that is where this
# Vulkan stack fails with ErrorDeviceLost.
- --batch-size
- "1024"
- --ubatch-size
- "1024"
# Room for a full chunk plus overhead rather than exactly one.
- --ctx-size
- "2048"
- "4096"
# No browser ever talks to this Service, so the WebUI is dead weight
# and one more unauthenticated surface.
- --no-webui
@@ -66,14 +91,24 @@ spec:
capabilities:
drop:
- ALL
# The weights are only ~600 MiB, but that is not what sets the
# footprint: four slots at 4096 context with a 1024-token micro-batch,
# and Vulkan allocating the iGPU's working set from system RAM where
# it counts against this cgroup.
#
# 4Gi was sized for the earlier 2048 context and 512 micro-batch. Both
# were raised to match Paperless's chunking without raising this, and
# the pod then survived interactive use and was OOMKilled (137) partway
# through the first sustained run — roughly 300 documents in. Indexing
# the archive would have hit it on day one.
resources:
requests:
cpu: 250m
memory: 2Gi
memory: 4Gi
gpu.intel.com/i915: "1"
limits:
cpu: "2"
memory: 4Gi
memory: 10Gi
gpu.intel.com/i915: "1"
# ~600 MiB rather than ~5 GiB, so a much shorter budget than the chat
# model needs, but still sized for a cold download.
@@ -0,0 +1,23 @@
---
# ExternalName mirror so the ai-namespace Ingress can backend to the llama-gate
# outpost that Authentik deploys into the authentik namespace (cross-namespace
# Service references are not supported in a standard Ingress).
#
# Traffic path:
# browser → traefik (llm.renner.uno)
# → ak-outpost-llama-gate (ExternalName → authentik ns)
# → outpost validates the session + passkey step-up
# → http://llama-server.ai.svc.cluster.local:8080
#
# In-cluster consumers skip all of this and address llama-server directly.
apiVersion: v1
kind: Service
metadata:
name: ak-outpost-llama-gate
namespace: ai
spec:
type: ExternalName
externalName: ak-outpost-llama-gate.authentik.svc.cluster.local
ports:
- port: 9000
targetPort: 9000
+24 -5
View File
@@ -1,12 +1,31 @@
---
# LAN-only: no `dns.renner.uno/public` label, so the Cloudflare external-dns
# instance skips it and only the UDM instance publishes the record. The API is
# unauthenticated, so it must not be reachable from the internet.
# llm.renner.uno — the chat WebUI, public and behind Authentik.
#
# Traefik no longer reaches llama-server directly. This Ingress backends the
# llama-gate proxy outpost (ExternalName → authentik ns), which enforces a
# passkey step-up and forwards only after the session validates. The ai
# CiliumNetworkPolicy is tightened so ONLY that outpost pod may reach
# llama-server, so nothing in the cluster can route around the gate. Pattern
# mirrors kubernetes/apps/home-assistant/vscode-ingress.yaml.
#
# The gate is the entire access control here: llama-server has no
# authentication of its own and serves a full OpenAI-compatible API on the same
# port as the UI. That is what kept this hostname LAN-only until now.
#
# In-cluster consumers — Home Assistant, Paperless, CI — address
# llama-server.ai.svc.cluster.local:8080 directly and never pass through
# Traefik or the outpost, so they need no credentials and are unaffected by the
# gate.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llama-server
namespace: ai
labels:
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
# external-dns instance skips anything carrying this, and LAN clients reach
# the same hostname through the hairpin NAT.
dns.renner.uno/public: "true"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster
spec:
@@ -23,6 +42,6 @@ spec:
pathType: Prefix
backend:
service:
name: llama-server
name: ak-outpost-llama-gate
port:
number: 8080
number: 9000
@@ -4,4 +4,5 @@ resources:
- pvc.yaml
- deployment.yaml
- embed.yaml
- gate-service.yaml
- ingress.yaml
@@ -1,97 +0,0 @@
# paperless-data: CephFS → RBD
## Why
`paperless-data` is `ReadWriteMany` on `ceph-filesystem`, but **only the paperless
Deployment mounts it**. Verified 2026-08-20:
```
$ pods mounting paperless-data
paperless-68cb8b8744-7hjb8 <- one, and only one
paperless-data ReadWriteMany ceph-filesystem
paperless-media ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
paperless-consume ReadWriteMany ceph-filesystem <- genuinely shared (ftp-scanner)
```
Its contents are single-writer state — the 50 MB Whoosh index,
`classification_model.pickle`, `celerybeat-schedule.db`, `log/`,
`migration_lock`, `protonmail.pem`. 75 files. Nothing else reads or writes them.
It is on CephFS by default, not by requirement.
## What it costs
Every access goes through the MDS. Measured with **zero CI pipelines running**:
```
paperless-data client request_load_avg 22 (all other sessions 0-1)
MDS over 180s 4,052 client requests = 22.5/s
2,718 journal events = 15/s
2 dir commits <- almost none: not file writes
```
So it is cap/inode churn from repeated access, not data being written. That is
most of the constant **7-8 GB/day** written to `cephfs-metadata`, and since that
pool is `size: 3`, roughly **21-24 GB/day** reaches the drives.
`cephfs-metadata` deliberately stays at size 3. Losing it destroys the entire
filesystem rather than the objects on one OSD, and the 275 GiB in `cephfs-data0`
depends on it. Cutting the writes at source is correct; cutting the replication
is not.
## Sequence
Staged deliberately: the PVC lands first and empty, so merging it changes
nothing. The copy and the repoint are separate.
### Phase 1 — provision (this PR)
`paperless-data-block` (RWO, `ceph-block`, 10Gi) is created and mounted by
nothing. Inert.
Verify: `kubectl -n paperless get pvc paperless-data-block` → Bound.
### Phase 2 — copy
The Whoosh index must come across intact. Paperless *can* rebuild it
(`document_index reindex`), but that reprocesses every document, so copying is
both faster and less disruptive.
1. Scale paperless to 0. The source must be quiescent — `celerybeat-schedule.db`
and the Whoosh index are both live-written, and copying them under an active
writer gives a torn read.
2. Run a one-off pod that mounts BOTH PVCs and copies:
```
cp -a /src/. /dst/
```
The RWO target pins the pod to one node; the RWX source will follow.
3. Verify file count and index size match:
```
find /src -type f | wc -l == find /dst -type f | wc -l
du -sh /src/index == du -sh /dst/index
```
### Phase 3 — repoint
Change the Deployment's `data` volume to `claimName: paperless-data-block`,
merge, let it roll.
Verify afterwards:
- paperless starts, search returns results (proves the Whoosh index survived)
- `ceph tell mds.cephfs-b session ls` no longer shows a busy `paperless-data`
client
- `rate(ceph_pool_wr_bytes{pool_id="3"})` drops from its 7-8 GB/day baseline
**Do not delete `paperless-data` until search has been confirmed working.** It is
the rollback, and it is `prune: disabled` so Flux will not remove it.
## Expected effect
~7-8 GB/day off `cephfs-metadata`, so ~21-24 GB/day off the drives once
replication is counted. Against ~432 GB/day cluster-wide that is roughly 5% —
worth doing, but it is a correctness fix (a single-writer volume does not belong
on a shared filesystem) more than a large win.
It also removes 22 req/sec of MDS load, which benefits the workloads that
genuinely need CephFS — Woodpecker CI workspaces share the same MDS.
@@ -57,6 +57,20 @@ spec:
protocol: UDP
- port: "53"
protocol: TCP
# The cluster's own LLM, for AI suggestions and RAG embeddings. Scoped to
# the two pods and the one port rather than the ai namespace as a whole, so
# this grant cannot widen if something else is ever placed there.
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-server
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
app.kubernetes.io/name: llama-embed
toPorts:
- ports:
- port: "8080"
protocol: TCP
# External HTTPS only — Tika model downloads, runtime image
# fetches, etc. RFC1918 + ULA are excluded from `world` via the
# CIDR-deny pattern. `world` covers public IPs / DNS-resolvable
+76 -16
View File
@@ -31,12 +31,14 @@ data:
PAPERLESS_CONSUMER_DELETE_DUPLICATES: "true"
PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
PAPERLESS_CONSUMER_BARCODE_SCANNER: "ZXING"
# No CONSUMER_BARCODE_SCANNER: zxing-cpp is the only backend, so the
# setting no longer exists. pyzbar and libzbar are gone from the image.
#
# CephFS does not propagate inotify across nodes — files written
# via the RWX consume PVC from a pod on a different node than
# paperless never fire IN_CREATE in paperless's kernel. Polling
# is the reliable backstop. See wiki: cephfs-inotify-no-cross-node-propagation.
PAPERLESS_CONSUMER_POLLING: "10"
PAPERLESS_CONSUMER_POLLING_INTERVAL: "10"
PAPERLESS_REDIS: "redis://redis:6379"
PAPERLESS_DBENGINE: "postgresql"
PAPERLESS_DBHOST: "pprl-pg-rw"
@@ -44,6 +46,44 @@ data:
PAPERLESS_TIKA_ENABLED: "1"
PAPERLESS_TIKA_ENDPOINT: "http://tika:9998"
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: "http://gotenberg:3000"
# AI suggestions against the cluster's own LLM (kubernetes/apps/llama-server).
# Nothing leaves the cluster: both endpoints are ClusterIP Services, so
# document text is never sent to a third party and there is no API key to
# rotate — llama-server has no authentication, and the ai namespace's network
# policy is what restricts who may reach it.
#
# CAVEAT, and it is a real one: paperless reads these from an
# ApplicationConfiguration row in the database and falls back to the
# environment only when that row is empty. A value set in the admin UI
# therefore overrides this ConfigMap silently. Configure AI in ONE place —
# here — and leave those fields untouched in the UI.
PAPERLESS_AI_ENABLED: "true"
PAPERLESS_AI_LLM_BACKEND: "openai-like"
PAPERLESS_AI_LLM_ENDPOINT: "http://llama-server.ai.svc.cluster.local:8080/v1"
# Must match --alias in kubernetes/apps/llama-server/deployment.yaml. The
# server ignores the field on the wire, but paperless sends it and the value
# is what appears in its logs.
PAPERLESS_AI_LLM_MODEL: "qwen3.6-35b-a3b"
# ClusterIP addresses are private, and paperless blocks non-public endpoints
# unless this is set. Default is already true; explicit because the whole
# integration silently stops working if it ever flips.
PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS: "true"
# Under llama-server's own 16384, leaving room for the response.
PAPERLESS_AI_LLM_CONTEXT_SIZE: "12288"
# The 120s default is too tight at ~11 tok/s: a classification is roughly
# 2-4k prompt tokens plus a second round trip for localisation.
PAPERLESS_AI_LLM_REQUEST_TIMEOUT: "300"
# Matched against Django's settings.LANGUAGES, so the region is required —
# "de" alone does not resolve. Without it the model returns German tags but
# English document types, which was reproducible against this endpoint.
PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de-de"
# RAG embeddings from llama-embed. Its Qwen3-Embedding covers 100+ languages,
# where paperless's default all-MiniLM-L6-v2 is documented as suited to
# primarily English archives — the better fit for a German corpus.
PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "http://llama-embed.ai.svc.cluster.local:8080/v1"
PAPERLESS_AI_LLM_EMBEDDING_MODEL: "qwen3-embedding-0.6b"
PAPERLESS_AI_LLM_EMBEDDING_CHUNK_SIZE: "1024"
---
apiVersion: apps/v1
kind: Deployment
@@ -56,6 +96,13 @@ metadata:
# Reloader restarts paperless when paperless-secrets changes (e.g. after a
# secret-key rotation). See kubernetes/infrastructure/openbao/rotation/paperless.yaml.
secret.reloader.stakater.com/reload: paperless-secrets
# And when paperless-config changes. The ConfigMap is consumed with
# envFrom, and Kubernetes does not restart a pod when a ConfigMap it reads
# that way is updated — the running container keeps the environment it
# started with. Without this, a settings change applies cleanly in git and
# in the cluster while the application never sees it, which is how the AI
# settings landed with no effect at all.
configmap.reloader.stakater.com/reload: paperless-config
spec:
replicas: 1
strategy:
@@ -120,7 +167,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:2.20.15 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.1.2 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -172,6 +219,25 @@ spec:
# Safe against ALLOWED_HOSTS: the kubelet sends the pod IP as Host,
# and PAPERLESS_ALLOWED_HOSTS is "*". Narrowing that value later
# means giving these probes an explicit Host header.
# The container does not serve until its init sequence finishes:
# s6 makes svc-webserver depend on init-migrations and
# init-search-index. On the first 3.x start those are the SHA256
# checksum backfill, which opens and reads every original and every
# archive file, and a full Tantivy index build, since the Whoosh
# index cannot be converted. Both scale with the corpus.
#
# startupProbe holds liveness and readiness off until the webserver
# answers, giving that boot 30 * 20s = 10 minutes. Without it the
# 120s liveness delay plus three 30s periods kills the pod 210s in,
# part-way through a migration, and the next boot starts over.
startupProbe:
httpGet:
path: /accounts/login/
port: http
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /accounts/login/
@@ -220,22 +286,16 @@ spec:
seccompProfile:
type: RuntimeDefault
volumes:
# paperless-data-block (RWO, ceph-block), NOT paperless-data
# (RWX, ceph-filesystem). Only this Deployment ever mounted the volume,
# and its contents are single-writer state — the Whoosh index,
# classification model, celerybeat schedule, logs. Being on CephFS meant
# every access went through the MDS: 22 requests/sec sustained with zero
# CI running, ~15 journal events/sec against 2 directory commits per
# 180s, i.e. cap/inode churn rather than file writes. That was most of
# the constant 7-8 GB/day on cephfs-metadata, and that pool is size 3.
# `data` is RWO on ceph-block, not RWX on ceph-filesystem. Only this
# Deployment mounts it and its contents are single-writer state — the
# search index, classification model, celerybeat schedule, logs. On
# CephFS every access went through the MDS: 22 requests/sec sustained
# with zero CI running, ~15 journal events/sec against 2 directory
# commits per 180s, i.e. cap/inode churn rather than file writes. That
# was most of the constant 7-8 GB/day on cephfs-metadata, a size 3 pool.
#
# `consume` and `media` below STAY on CephFS: the ftp-scanner writes to
# those from another node, so they genuinely need RWX.
#
# paperless-data is retained (prune: disabled) as the rollback. Do not
# delete it until search has been confirmed working — that is what
# proves the Whoosh index survived the copy, not merely that paperless
# starts. Migration record: MIGRATION-data-to-block.md.
- name: data
persistentVolumeClaim:
claimName: paperless-data-block
+10 -29
View File
@@ -3,33 +3,18 @@
# (e.g. when the seed initContainer or the Garage migration creds are
# removed once we're done with the migration phase). Velero's
# bootstrap-restore then owns their contents.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data
namespace: paperless
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
spec:
accessModes:
- ReadWriteMany
storageClassName: ceph-filesystem
resources:
requests:
storage: 10Gi
---
# Replacement for paperless-data, on RBD instead of CephFS.
# paperless's single-writer state, on RBD rather than CephFS.
#
# WHY: paperless-data is declared ReadWriteMany on ceph-filesystem, but only ONE
# pod ever mounts it — the paperless Deployment itself. Its contents are all
# single-writer state: the 50 MB Whoosh index, classification_model.pickle,
# celerybeat-schedule.db, logs, migration_lock. Nothing shares it. Only `consume`
# and `media` genuinely need RWX, because the FTP scanner writes to those from a
# different node.
# WHY RWO: only ONE pod mounts it — the paperless Deployment itself. Its
# contents are all single-writer state: the Tantivy search index,
# classification_model.pickle, celerybeat-schedule.db, logs, migration_lock.
# Nothing shares it. Only `consume` and `media` genuinely need RWX, because the
# FTP scanner writes to those from a different node.
#
# COST OF LEAVING IT: every access goes through the MDS. Measured 2026-08-20 with
# zero CI running, this volume's client was doing 22 MDS requests/sec sustained,
# and the MDS logged ~15 journal events/sec against only 2 directory commits per
# WHY NOT CEPHFS: every access goes through the MDS. With zero CI running, this
# volume's client sustained 22 MDS requests/sec, and the MDS logged ~15 journal
# events/sec against only 2 directory commits per
# 180s — i.e. cap and inode churn from repeated access, not file writes. That is
# most of the constant 7-8 GB/day written to cephfs-metadata, which is a size 3
# pool, so ~21-24 GB/day actually hits the drives.
@@ -38,10 +23,6 @@ spec:
# the metadata pool destroys the whole filesystem, not just the objects on one
# OSD, so the 275 GiB in cephfs-data0 rides on it. Reducing the writes at source
# is the right fix; reducing the replication is not.
#
# MIGRATION: this PVC is created EMPTY and nothing mounts it yet. The data copy
# and the deployment repoint are a separate, staged step — see
# MIGRATION-data-to-block.md. Merging this alone is inert.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
spec:
containers:
- name: whisper
image: rhasspy/wyoming-whisper:3.6.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
image: rhasspy/wyoming-whisper:3.7.0 # {"$imagepolicy": "flux-system:wyoming-whisper"}
args:
- --model=base-int8
- --language=de
+35 -41
View File
@@ -530,24 +530,6 @@ check "alertmanager: no severity=critical alerts active" bash -c '
# manifests were valid in each case and the endpoint still misbehaved.
# ---------------------------------------------------------------------------
# llm_chat: POST a chat completion through the ingress. Body lands in
# /tmp/llm_body for the caller to assert against. Generous --max-time: the
# model runs on an iGPU at ~12 tok/s, so a short answer still takes seconds.
llm_chat() {
local model="$1" prompt="$2" max="${3:-64}" out body code payload
payload=$(jq -nc --arg m "$model" --arg p "$prompt" --argjson n "$max" \
'{model:$m,messages:[{role:"user",content:$p}],max_tokens:$n,temperature:0}')
out=$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 300 \
-H 'Content-Type: application/json' -d "$payload" \
-w $'\n__CODE__:%{http_code}' \
"https://llm.renner.uno/v1/chat/completions" 2>&1)
code=${out##*__CODE__:}
body=${out%$'\n'__CODE__:*}
printf 'code=%s body=%s\n' "$code" "${body:0:300}"
[[ "$code" == "200" ]] || return 1
printf '%s' "$body" > /tmp/llm_body
}
export -f llm_chat
export LB_IP
# helper_post: POST JSON from inside e2e-helper, for Services with no ingress.
@@ -571,54 +553,66 @@ helper_post() {
}
export -f helper_post
# llm_chat: POST a chat completion to llama-server from inside the cluster.
# It goes to the Service, not the public hostname: llm.renner.uno now answers
# with an Authentik redirect, and the API is deliberately in-cluster only —
# which is also how every real consumer reaches it. Result lands in
# /tmp/helper_body.
llm_chat() {
local prompt="$1" max="${2:-64}"
helper_post "http://llama-server.ai.svc.cluster.local:8080/v1/chat/completions" \
"$(jq -nc --arg p "$prompt" --argjson n "$max" \
'{model:"qwen3.6-35b-a3b",messages:[{role:"user",content:$p}],max_tokens:$n,temperature:0}')"
}
export -f llm_chat
echo "=== Local AI inference ==="
check "llama-server /health returns 200" \
http_code "https://llm.renner.uno/health" 200
# The gate is the whole of the access control on the public hostname:
# llama-server has no authentication and serves the API on the same port as the
# UI. An anonymous request must be bounced to Authentik, never answered. A 200
# here would mean the ingress is reaching llama-server directly again.
check "public hostname is gated by authentik" \
bash -c 'code=$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
-o /dev/null -w "%{http_code}" "https://llm.renner.uno/v1/models")
echo "got=$code"; [[ "$code" =~ ^(302|307|401|403)$ ]]'
# The chat model is named for what it is, so a picker and a log line say which
# model answered. `chat` stays valid alongside it as a stable handle, so a
# consumer's config survives the next model swap.
# Asserts the id specifically, not merely that the string appears somewhere:
# every configured name shows up in the `aliases` array regardless of order, so
# a substring match passes even when the picker is showing the wrong one.
check "descriptive name is the advertised id" \
http_body "https://llm.renner.uno/v1/models" '"id":"qwen3.6-35b-a3b"'
bash -c 'helper_curl "http://llama-server.ai.svc.cluster.local:8080/v1/models" &&
jq -e ".data[0].id == \"qwen3.6-35b-a3b\"" /tmp/helper_body >/dev/null'
# Only one name is configured. A second would be advertised alongside it and,
# if it sorted first, would take the id — which is what a picker displays.
check "no second name competes for the id" \
bash -c 'jq -e ".data[0].aliases | length == 1" /tmp/helper_body >/dev/null'
# Regression: the embedding model used to share this port. It appeared in the
# chat picker, and selecting it returned fluent nonsense ("capital capital
# capital ...") instead of an error, because --embeddings does not refuse chat
# completions. It now lives on a ClusterIP-only Service; if it ever reappears
# here, it is selectable again and this must fail.
# completions. It now lives on a separate Service; if it ever reappears here,
# it is selectable again and this must fail.
check "embedding model absent from chat endpoint" \
bash -c '! curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
"https://llm.renner.uno/v1/models" | grep -qi "embed"'
check "embeddings not exposed through the ingress" \
http_code "https://llm.renner.uno/v1/embeddings" 501
bash -c '! grep -qi embed /tmp/helper_body'
# Regression: with thinking enabled by default the model spent its whole token
# budget on reasoning_content and returned an EMPTY content with
# finish_reason "length" — a 200 response that answers nothing. Asserting on
# the status code alone would have passed.
check "chat completion returns non-empty content" \
bash -c 'llm_chat chat "Nenne die Hauptstadt von Frankreich." 64 &&
jq -e ".choices[0].message.content | length > 0" /tmp/llm_body >/dev/null'
bash -c 'llm_chat "Nenne die Hauptstadt von Frankreich." 64 &&
jq -e ".choices[0].message.content | length > 0" /tmp/helper_body >/dev/null'
check "chat completion stops rather than truncating" \
bash -c 'jq -e ".choices[0].finish_reason == \"stop\"" /tmp/llm_body >/dev/null'
bash -c 'jq -e ".choices[0].finish_reason == \"stop\"" /tmp/helper_body >/dev/null'
# Coherence, not just liveness. This is what catches a model answering from the
# wrong weights or a numerically broken backend — llama-bench measures
# throughput and would report excellent numbers while emitting garbage.
check "chat answer is coherent (knows Paris)" \
bash -c 'jq -re ".choices[0].message.content" /tmp/llm_body | grep -qi paris'
# Only one name is configured. A second would be advertised alongside it and,
# if it sorted first, would take the id — which is what a picker displays.
check "no second name competes for the id" \
bash -c 'test "$(curl -sk --resolve "llm.renner.uno:443:$LB_IP" --max-time 15 \
"https://llm.renner.uno/v1/models" | jq -r ".data[0].aliases | length")" = "1"'
bash -c 'jq -re ".choices[0].message.content" /tmp/helper_body | grep -qi paris'
# Reached from the monitoring namespace, which ai-ns.yaml admits; there is no
# ingress for this Service by design.
@@ -7,7 +7,7 @@ spec:
image: ghcr.io/paperless-ngx/paperless-ngx
interval: 1h
---
# Allow only patch + minor bumps within the 2.x line. A 3.0 release
# Allow only patch + minor bumps within the 3.x line. A 4.0 release
# requires manual policy widening — major upgrades typically need a
# migration plan and shouldn't land via auto-merge.
apiVersion: image.toolkit.fluxcd.io/v1
@@ -20,4 +20,4 @@ spec:
name: paperless-ngx
policy:
semver:
range: "2.x"
range: "3.x"
@@ -9,10 +9,31 @@ spec:
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ai
# Traefik is deliberately NOT admitted. llm.renner.uno is public and
# llama-server has no authentication of its own, so the Authentik llama-gate
# outpost is the only thing standing in front of it. Traefik routes the
# hostname to that outpost and never to llama-server, and this policy is
# what makes that structural rather than a matter of configuration: with no
# ingress rule for traefik, an Ingress that pointed straight at the Service
# — by accident or otherwise — simply would not connect.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": ingress
app.kubernetes.io/name: traefik
"k8s:io.kubernetes.pod.namespace": authentik
goauthentik.io/outpost-name: llama-gate
toPorts:
- ports:
- port: "8080"
protocol: TCP
# Paperless calls both models for AI suggestions and RAG embeddings. The
# matching egress grant on its side is scoped to these two pods on :8080.
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": paperless
app.kubernetes.io/name: paperless
toPorts:
- ports:
- port: "8080"
protocol: TCP
- fromEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": monitoring
@@ -5,8 +5,9 @@
# 1. Velero Restore reaches phase=Completed against yesterday's
# scheduled Backup (real artifact, not a synthetic one).
# 2. Each restored PVC is mountable and its file count is within ±10%
# of the live source volume, ignoring the transient subtrees listed
# in volatile_paths_for().
# (or ±ABS_PARITY_SLACK files, whichever is wider) of the live
# source volume, ignoring the transient subtrees listed in
# volatile_paths_for().
#
# What this does NOT exercise:
# - CNPG database restore (barman-cloud, separate code path).
@@ -317,11 +318,37 @@ EOF
count_files_in_pvc "$src_ns" "$tmp_pvc" "src-${pvc:0:40}-${RUN_ID}" "$excludes"
}
# Restored count must be within ±10% of the source's current file count.
# That tolerance absorbs day-of drift since the backup ran without
# letting "lost half the data" pass silently. Volatile subtrees are pruned
# from BOTH sides — keyed off the source identity, since the PVC name is
# preserved across the restore but the destination namespace is not.
# Restored count must be within max(±10%, ±ABS_PARITY_SLACK files) of the
# source's current file count. That tolerance absorbs day-of drift since
# the backup ran without letting "lost half the data" pass silently.
# Volatile subtrees are pruned from BOTH sides — keyed off the source
# identity, since the PVC name is preserved across the restore but the
# destination namespace is not.
#
# Why the absolute floor exists. A purely relative window is structurally
# unable to pass a small volume: ±10% of 9 files is ±0.9, i.e. a budget of
# ZERO files, and ±10% of 2 files is ±0.2. The 2026-09-01 run failed on
# exactly that — audiobookshelf/libation-config source=9 restored=8 and
# trmnl/trmnl-renderer-data source=2 restored=3, both off by one file and
# in opposite directions, with all 12 restores at phase=Completed and the
# other 46 checks green. Parity compares a backup taken up to ~24 h ago
# against the live volume *now*, so a single config write on a tiny volume
# between those two moments is expected, not a defect in the backup.
#
# The floor only governs volumes below ~10*ABS_PARITY_SLACK files; above
# that the 10% window still binds, so real data loss on the volumes that
# carry data keeps failing loudly. Two files of slack rather than one
# because a single logical write on a config volume routinely moves more
# than one file (an app writing a settings file plus a transient sidecar),
# and a floor that buys exactly one month is not worth the change.
#
# What the floor must NOT swallow, and does not: a non-empty source that
# restored to an *empty* volume fails unconditionally below, before any
# tolerance is applied. The old relative-only check caught that case as a
# side effect (delta = 1.0 > 0.10); it is now explicit so the floor cannot
# erode it on a volume of ABS_PARITY_SLACK files or fewer.
ABS_PARITY_SLACK=${ABS_PARITY_SLACK:-2}
verify_pvc_file_count_parity() {
local src_ns="$1" dst_ns="$2" pvc="$3"
local restored source excludes
@@ -339,11 +366,15 @@ verify_pvc_file_count_parity() {
if [[ "$source" -eq 0 && "$restored" -eq 0 ]]; then
return 0
fi
# Use awk to compute a 10% window without bc.
awk -v s="$source" -v r="$restored" 'BEGIN {
if (s == 0) { exit !(r == 0) }
delta = (r - s) / s; if (delta < 0) delta = -delta
exit !(delta <= 0.10)
# Empty on either side while the other has data: never tolerable.
if [[ "$source" -eq 0 || "$restored" -eq 0 ]]; then
return 1
fi
# Use awk to compute the window without bc.
awk -v s="$source" -v r="$restored" -v slack="$ABS_PARITY_SLACK" 'BEGIN {
delta = r - s; if (delta < 0) delta = -delta
tol = 0.10 * s; if (tol < slack) tol = slack
exit !(delta <= tol)
}'
}
@@ -492,7 +523,7 @@ EOF
check "${src_ns}: at least one PVC was restored" false
else
for pvc in "${PVCS[@]}"; do
check "${src_ns}: PVC ${pvc} file count parity (±10%)" \
check "${src_ns}: PVC ${pvc} file count parity (±10% / ±${ABS_PARITY_SLACK})" \
verify_pvc_file_count_parity "$src_ns" "$dst_ns" "$pvc"
done
fi