The rule allowed outbound 80, the port the apache-tika and gotenberg Services listen on, and justified it in a comment: an egress rule names the port on the destination as the client dials it, and the client dials the Service. That is wrong. kube-proxy does a ClusterIP Service's destination NAT BEFORE the egress policy is evaluated at the pod's network interface, so the policy sees podIP:targetPort and never clusterIP:port - and those two charts forward 80 to 9998 and 3000. Deployed as 14.0.0+up3.3.1 it cut Paperless off from both services on 2026-10-15: mail import, EML, HTML and Office documents stopped, plain PDFs and images kept working, and the error was httpx.ConnectTimeout with no mention of a NetworkPolicy. The rule now names 9998, 3000 and 80. The 80 stays because it costs nothing and covers a foreign Tika that really does listen on 80. The refuted sentence is quoted rather than deleted: it reads perfectly, which is how it survived review, and the timeout-not-refused symptom is written down with it. No policy switch of its own: the rule stays gated on paperless.files.tikaGotenberg.enabled, because a second value that can drift from the feature re-arms this very outage. New networkPolicy.egress.mailImport opens IMAPS 993, off by default - paperless' mail accounts are rows in its database, so the chart cannot derive the rule. Plain IMAP (143) and POP3 stay in extraPorts, and 993 is noted as a real destination port, because a mail server outside the cluster has no DNAT in the path. Minor: one additive value, and a fix that widens a rule. Nothing breaks, no value becomes required, appVersion unchanged. Part of #136
615 lines
35 KiB
Plaintext
615 lines
35 KiB
Plaintext
{{- $replicas := include "paperless-ngx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.paperless.replicas) -}}
|
|
{{- $startup := fromYaml (include "paperless-ngx.startupProbe" .Values.paperless.startupProbe) -}}
|
|
{{- $barcodes := fromYaml (include "paperless-ngx.barcodes" .Values.paperless.files.barcodes) -}}
|
|
{{- $pod := printf "%s-%s-sfs-0" .Release.Name .Chart.Name -}}
|
|
{{- $scripts := .Values.paperless.scripts | default dict -}}
|
|
{{- $valkeySub := (.Values.valkeySubchart | default dict).enabled -}}
|
|
{{- $valkeyAuth := ((.Values.valkey | default dict).secrets | default dict).authSecretRef -}}
|
|
{{- $valkeySvc := printf "%s-valkey-service" .Release.Name -}}
|
|
{{- $pre := default (list) $scripts.preConsumptionScripts -}}
|
|
{{- $post := default (list) $scripts.postConsumptionScripts -}}
|
|
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
|
|
{{- $cronPinned := $cron.pinToServerNode -}}
|
|
{{- $cronDeadline := include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds -}}
|
|
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
|
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
|
{{- if not $hostnames -}}
|
|
{{- $hostnames = list .Values.ingress.domain -}}
|
|
{{- end -}}
|
|
Paperless-ngx {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
|
|
|
{{- if .Values.ingress.enabled }}
|
|
|
|
ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
|
https://{{ .Values.ingress.domain }}
|
|
|
|
The certificate is requested by cert-manager through the
|
|
"{{ .Values.ingress.clusterIssuer }}" cluster issuer; until it has been issued the browser
|
|
shows a warning. Follow it with
|
|
|
|
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
|
|
|
ingress.domain is more than the Ingress host: the chart also hands it to the
|
|
container as PAPERLESS_URL ("https://{{ .Values.ingress.domain }}"), and that is the only
|
|
origin this release declares. While DNS or the certificate are still missing:
|
|
|
|
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8000:80
|
|
|
|
Pages then load over http://localhost:8000, but expect the login POST to be
|
|
refused as a CSRF failure — that origin is not PAPERLESS_URL. Forward the
|
|
port to look around, use the domain to log in, or add
|
|
PAPERLESS_CSRF_TRUSTED_ORIGINS through paperless.env for as long as you need
|
|
it. Changing ingress.domain later changes PAPERLESS_URL with it, and the
|
|
pods have to restart before they notice.
|
|
{{- else if $httpRoute.enabled }}
|
|
|
|
ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
|
{{- range $hostnames }}
|
|
https://{{ . }}
|
|
{{- end }}
|
|
|
|
This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING
|
|
else of the routing path — no Gateway, no class, no Certificate. Three things
|
|
therefore belong to the cluster and not to this chart, and if any is missing
|
|
the address above does not answer:
|
|
|
|
1. The Gateway-API CRDs. Without them this install would have failed.
|
|
2. The Gateway named in httpRoute.parentRefs
|
|
{{- range $httpRoute.parentRefs }}
|
|
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
|
{{- end }}
|
|
must exist AND its listener must allow routes from this namespace
|
|
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows
|
|
up only in the route's status:
|
|
|
|
kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml
|
|
|
|
Read status.parents[].conditions — "Accepted: True" and
|
|
"ResolvedRefs: True" is a working attachment. An empty status means no
|
|
controller has looked at the route at all.
|
|
3. The TLS certificate for the hostname, on the Gateway's listener.
|
|
ingress.clusterIssuer is NOT used in this mode and is not required: the
|
|
chart renders no Certificate, so there is no
|
|
tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret and nothing for cert-manager to do.
|
|
|
|
ingress.domain is still required in this mode and is {{ .Values.ingress.domain }}: it is also
|
|
PAPERLESS_URL ("https://{{ .Values.ingress.domain }}"), the only origin this release declares to
|
|
Django, and the route does not replace it. If you set httpRoute.hostnames to
|
|
a name ingress.domain does not carry, the route publishes one host while
|
|
Django trusts another — pages load and every login POST is refused as a CSRF
|
|
failure. Set both together, and remember the pods have to restart before they
|
|
notice a changed domain.
|
|
|
|
YOUR WEB-UI UPLOAD LIMIT IS NOT paperless.files.maxUploadFileSize ANY MORE.
|
|
That value ({{ .Values.paperless.files.maxUploadFileSize }}) is NOT ENFORCED in this mode. The chart renders it
|
|
into nginx.ingress.kubernetes.io/proxy-body-size, and the Gateway API has NO
|
|
counterpart — it knows no body size at all, that is implementation-specific
|
|
policy — so this chart deliberately renders no policy object, which would
|
|
bind it to one implementation. Whatever limit the Gateway enforces caps
|
|
uploads through the web UI, and the symptom is the proxy's error rather than a
|
|
message from paperless. Set it at the Gateway or through its
|
|
implementation's own policy object, next to the Gateway and not here.
|
|
|
|
The consume directory is unaffected: it passes no proxy and has no size limit
|
|
in any mode, so a document too large for the web UI still gets in that way.
|
|
|
|
ingress.annotations is inert in this mode for the same reason — the Gateway
|
|
API has no annotation channel.
|
|
{{- else }}
|
|
|
|
ACCESS — NEITHER INGRESS NOR HTTPROUTE
|
|
ingress.enabled and httpRoute.enabled are both false, so this release is not
|
|
published: no Ingress, no HTTPRoute and no certificate, and
|
|
ingress.clusterIssuer is not needed.
|
|
|
|
ingress.domain IS still required and is {{ .Values.ingress.domain }}: the chart hands it to the
|
|
container as PAPERLESS_URL ("https://{{ .Values.ingress.domain }}"), the only origin this
|
|
release declares. Reach the instance inside the cluster with
|
|
|
|
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8000:80
|
|
|
|
Pages then load over http://localhost:8000, but expect the login POST to be
|
|
refused as a CSRF failure — that origin is not PAPERLESS_URL. Add
|
|
PAPERLESS_CSRF_TRUSTED_ORIGINS through paperless.env for as long as you need
|
|
it. paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) does nothing in this mode:
|
|
it exists only as an ingress-nginx annotation.
|
|
{{- end }}
|
|
{{ if eq $replicas "0" }}
|
|
SCALED TO ZERO
|
|
{{ if .Values.scaleDown }}scaleDown: true{{ else }}paperless.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the URL above
|
|
does not answer until you set it back and upgrade again. PVCs, Secrets,
|
|
Service{{ if .Values.ingress.enabled }} and Ingress stay in place, so neither the documents nor the
|
|
certificate are touched.{{ else if $httpRoute.enabled }} and HTTPRoute stay in place, so the documents are not touched —
|
|
the route then resolves to a Service with no endpoints and the Gateway
|
|
answers its own error.{{ else }} stay in place, so the documents are not touched.{{ end }}
|
|
{{- if .Values.smbConnection.enabled }}
|
|
{{- if .Values.scaleDown }}
|
|
scaleDown also suspends the SMB consume CronJob, so nothing is moved off the
|
|
share while the release is down.
|
|
{{- else }}
|
|
The SMB consume CronJob is NOT suspended by this: only scaleDown sets
|
|
suspend: true. It keeps moving PDFs off the share onto a consume volume that
|
|
nothing is reading — they are gone from the share and not yet in paperless.
|
|
{{- end }}
|
|
{{- end }}
|
|
{{ end }}
|
|
WHAT THE 1PASSWORD ITEMS MUST CONTAIN
|
|
The chart creates OnePasswordItem CRs that carry an item path only; the
|
|
operator turns each into a Kubernetes Secret whose keys are the FIELD NAMES
|
|
of the item. Those names are decided here and appear nowhere in 1Password,
|
|
so they are exactly what you would otherwise read out of the templates. A
|
|
missing field does not fail the install — the release goes through and the
|
|
pod then hangs in CreateContainerConfigError naming the key it could not
|
|
find (kubectl -n {{ .Release.Namespace }} describe pod {{ $pod }}).
|
|
|
|
secrets.databaseSecretRef → {{ .Values.secrets.databaseSecretRef }}
|
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret, fields:
|
|
server database host — the field is called "server", not "host"
|
|
port database port
|
|
database database name
|
|
username database user
|
|
password database password
|
|
The engine is pinned to postgresql; there is no value to change it.
|
|
|
|
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret, fields:
|
|
redis-connection-url e.g. redis://:PASSWORD@redis:6379
|
|
key-prefix PAPERLESS_REDIS_PREFIX — set this per release when
|
|
several releases share one Redis, or two instances
|
|
will consume each other's task queue
|
|
auth-conf the COMPLETE line "requirepass PASSWORD", not a
|
|
bare password. Read by the bundled Valkey only, so
|
|
it is {{ if $valkeySub }}REQUIRED for this release — see the Valkey
|
|
section below{{ else }}unused while valkeySubchart.enabled is
|
|
false, and the item needs it only once you switch
|
|
the bundled Valkey on{{ end }}.
|
|
|
|
secrets.paperlessSecretRef → {{ .Values.secrets.paperlessSecretRef }}
|
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, fields:
|
|
username used TWICE: as PAPERLESS_ADMIN_USER and as
|
|
PAPERLESS_ADMIN_MAIL, so it has to work as both a
|
|
login name and an address
|
|
password PAPERLESS_ADMIN_PASSWORD
|
|
paperless-secret-key Django's SECRET_KEY
|
|
PAPERLESS_ADMIN_* is only acted on while that superuser does not exist yet.
|
|
Editing the password field after the first start changes nothing; reset it
|
|
in the pod instead:
|
|
|
|
kubectl -n {{ .Release.Namespace }} exec -it {{ $pod }} -- \
|
|
python3 /usr/src/paperless/src/manage.py changepassword <user>
|
|
{{- if .Values.smbConnection.enabled }}
|
|
|
|
secrets.smbConnectionSecretRef → {{ .Values.secrets.smbConnectionSecretRef }}
|
|
Secret {{ .Release.Name }}-{{ .Chart.Name }}-smb-secret. This one is not read by any pod: it is
|
|
wired into the PersistentVolume as nodeStageSecretRef, and the
|
|
smb.csi.k8s.io driver reads the fields itself — usually username and
|
|
password, plus domain where the server wants one. The chart names no key
|
|
here, so a wrong field name surfaces as a mount failure on the CronJob's
|
|
pod — a describe on that pod, never a missing variable.
|
|
{{- end }}
|
|
{{- /*
|
|
The warning that used to stand here - "secrets.oidcSecretRef IS SET AND IS READ
|
|
BY NOTHING" - is gone with the value itself (13.0.0, Gitea #11). It cannot be
|
|
printed any more and would not be reached: the key is out of values.schema.json
|
|
too, so a release that still sets it fails BEFORE any template renders, with
|
|
"additional properties 'oidcSecretRef' not allowed". The fix is to delete the
|
|
key; values.yaml says why it is not coming back.
|
|
*/}}
|
|
|
|
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
|
|
Helm never sees the content of a OnePasswordItem, so it cannot hash it, and
|
|
this chart carries no checksum annotation at all. Pointing a *SecretRef at a
|
|
DIFFERENT item rolls the pods; editing a value INSIDE the same item does not
|
|
— the operator updates the Kubernetes Secret while the running container
|
|
keeps the old value in its environment. After such a rotation:
|
|
|
|
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
|
|
Rotating paperless-secret-key logs every session out. Rotating the database
|
|
fields points the release at whatever that new endpoint contains, empty or
|
|
not, and the next start migrates it.
|
|
|
|
POSTGRES IS NOT PART OF THIS CHART{{ if not $valkeySub }}, AND NEITHER IS REDIS{{ end }}
|
|
{{ if $valkeySub }}The database{{ else }}Both{{ end }} ha{{ if $valkeySub }}s{{ else }}ve{{ end }} to exist and be reachable before the first start.{{ if not $valkeySub }} They fail in
|
|
opposite ways, and only one of them is loud:{{ end }}
|
|
|
|
- Without the database paperless does not come up at all. All three probes
|
|
ask for "/" on port 8000, so you get no useful error: the pod never turns
|
|
Ready and the startup probe kills it after
|
|
{{ $startup.periodSeconds }}s x {{ $startup.failureThreshold }} = {{ mul $startup.periodSeconds $startup.failureThreshold }}s. Read the container log, not the pod events.
|
|
- Without Redis the web UI comes up and looks healthy. Redis is the Celery
|
|
broker: consumption, OCR, the scheduled tasks and the index updates simply
|
|
never run, and documents dropped into the consume directory stay there.
|
|
A wrong redis-connection-url costs you the whole processing side and
|
|
nothing turns red.{{ if $valkeySub }} This release brings its own Valkey (below), so
|
|
the address is in your hands — which is exactly why the url is the one
|
|
thing to check first.{{ end }}
|
|
{{ if $valkeySub }}
|
|
THIS RELEASE BRINGS ITS OWN VALKEY — ONE MANDATORY STEP IS IN THE VAULT
|
|
valkeySubchart.enabled is true, so the release contains a Valkey of its own:
|
|
StatefulSet {{ .Release.Name }}-valkey-sfs, Service {{ $valkeySvc }}, its own
|
|
NetworkPolicy, and a PersistentVolumeClaim (persistence is on — this is a
|
|
queue, see below).
|
|
|
|
1. THE CONNECTION URL MUST POINT AT IT. This is a step, not a hint: the url
|
|
lives in the vault and the chart cannot write it. In the item
|
|
"{{ .Values.secrets.redisSecretRef }}", field redis-connection-url:
|
|
|
|
redis://:PASSWORD@{{ $valkeySvc }}:6379
|
|
|
|
Pointing elsewhere is not an error anywhere: paperless talks to that other
|
|
Redis, the Valkey next to it runs along unused, and nothing reports it.
|
|
|
|
2. THE PASSWORD IS IN THE ITEM TWICE — once inside redis-connection-url and
|
|
once in the "requirepass PASSWORD" line of auth-conf. Both are the same
|
|
password, and a rotation has to touch BOTH fields. Change only one and
|
|
paperless can no longer authenticate against its own Valkey. After any
|
|
such change, restart both workloads by hand (Helm cannot see inside a
|
|
1Password item, so nothing rolls automatically):
|
|
|
|
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-valkey-sfs
|
|
|
|
3. AUTH-CONF IS A CONFIGURATION LINE, NOT A PASSWORD. The Valkey mounts the
|
|
field as a file and includes it, so the password never reaches a process
|
|
list or an environment variable. An init container refuses to start the
|
|
pod unless a line beginning with "requirepass " is really there — without
|
|
that check a typo would leave a Valkey running with NO password and
|
|
looking perfectly healthy.
|
|
{{- if and $valkeyAuth (ne ($valkeyAuth | toString) ($.Values.secrets.redisSecretRef | toString)) }}
|
|
|
|
NOTE: valkey.secrets.authSecretRef points at a DIFFERENT item than
|
|
secrets.redisSecretRef:
|
|
valkey.secrets.authSecretRef → {{ $valkeyAuth }}
|
|
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
|
That is supported and may well be deliberate — the bundled Valkey is then fed
|
|
from its own item. It is only worth knowing that you now keep TWO items in
|
|
step: the password in auth-conf over there and the one inside
|
|
redis-connection-url here have to stay identical.
|
|
{{- end }}
|
|
|
|
BOTH ITEMS' FIELDS LAND IN BOTH SECRETS. A OnePasswordItem materializes every
|
|
field of the item it names, so with one shared item the Valkey's Secret also
|
|
carries redis-connection-url and key-prefix, and this chart's redis Secret
|
|
also carries auth-conf. Untidy and harmless — nothing mounts a field it does
|
|
not reference. It is mentioned so it does not get "cleaned up".
|
|
|
|
IT IS CONFIGURED AS A QUEUE, and that is a decision: persistence on, eviction
|
|
policy "noeviction". PAPERLESS_REDIS is the Celery broker, so its contents are
|
|
work that must not vanish — "allkeys-lru" would evict across the whole
|
|
keyspace and a backlog of waiting tasks is exactly what LRU reads as cold.
|
|
The price: the same instance also serves the Django cache, so when it fills,
|
|
writes are REFUSED rather than old keys dropped — new tasks included. The
|
|
answer is to give it more memory (valkey.valkey.resources.requests.memory),
|
|
never to switch the policy. values.yaml argues this in full.
|
|
|
|
SCALEDOWN DOES NOT REACH IT. scaleDown: true scales paperless to zero and
|
|
leaves the Valkey running, which is the right way round for a queue: the
|
|
backlog stays where it is.
|
|
{{- end }}
|
|
|
|
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
|
|
Every start runs the database migrations before the server answers, and the
|
|
first start after an upgrade to 3.x also rebuilds the search index from
|
|
scratch (Whoosh → Tantivy). The startup probe grants
|
|
{{ $startup.failureThreshold }} x {{ $startup.periodSeconds }}s = {{ mul $startup.periodSeconds $startup.failureThreshold }}s before the kubelet restarts the container — and a
|
|
restart lands in the middle of a migration. On a large archive, raise
|
|
paperless.startupProbe.failureThreshold BEFORE the app-version jump, not
|
|
after the crash loop has started.
|
|
|
|
Migrations are one-way. "helm rollback" puts the old image back on a database
|
|
the new one has already rewritten. Back up before bumping the app version,
|
|
and back up both halves — the metadata lives in Postgres, the documents on
|
|
the PVC.
|
|
|
|
HOW DOCUMENTS GET IN
|
|
Everything in /usr/src/paperless/consume is picked up by the consumer. That
|
|
directory is its own volume, {{ .Release.Name }}-{{ .Chart.Name }}-consume-volume-claim, and its size is
|
|
NOT paperless.files.storage: the claim is hardcoded to 1Gi (ReadWriteMany)
|
|
in the template, and both PVCs are hardcoded to storage class "longhorn".
|
|
paperless.files.storage ({{ .Values.paperless.files.storage }}) sizes only the main volume, which holds
|
|
data/, media/ and export/ as subPaths.
|
|
|
|
{{- if .Values.ingress.enabled }}
|
|
Uploads through the web UI take the second route and pass the Ingress, where
|
|
the chart sets a body-size cap of {{ .Values.paperless.files.maxUploadFileSize }} (paperless.files.maxUploadFileSize,
|
|
rendered into both the nginx.ingress and nginx.org annotations). A larger
|
|
file is rejected by the ingress controller with a 413 before paperless ever
|
|
sees it — the consume directory has no such limit.
|
|
{{- else if $httpRoute.enabled }}
|
|
Uploads through the web UI take the second route, and in GATEWAY MODE
|
|
paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) is NOT ENFORCED: it exists only as
|
|
an ingress-nginx annotation and the Gateway API has no body-size field, so
|
|
the cap is whatever the Gateway enforces. The consume directory has no limit
|
|
in either mode.
|
|
{{- else }}
|
|
Uploads through the web UI take the second route, but nothing publishes this
|
|
release and paperless.files.maxUploadFileSize ({{ .Values.paperless.files.maxUploadFileSize }}) is rendered nowhere:
|
|
it exists only as an ingress-nginx annotation. The consume directory has no
|
|
limit either way.
|
|
{{- end }}
|
|
{{- if .Values.smbConnection.enabled }}
|
|
|
|
SMB CONSUME IS ON — AND IT MOVES ONLY TOP-LEVEL PDFs
|
|
CronJob {{ .Release.Name }}-{{ .Chart.Name }}-consume-cronjob, schedule "{{ .Values.smbConnection.cronJob }}", mounts
|
|
//{{ .Values.smbConnection.server }} and runs, once per firing:
|
|
|
|
find /mnt/smb -maxdepth 1 -name "*.pdf" → mv into the consume volume
|
|
|
|
Read that literally. Anything in a SUBDIRECTORY of the share is never
|
|
touched, and anything that is not *.pdf — an image, an .eml, a .PDF in
|
|
capitals — stays on the share forever with nothing reporting it. It is a
|
|
move, not a copy: once the job has run, the share no longer holds the file
|
|
and the only copy is on the consume volume.
|
|
|
|
The schedule is quoted on the way into the manifest since 13.0.0, so any
|
|
cron notation works — one starting with "*" or an "@daily" macro included.
|
|
Before that such a schedule aborted the render with a YAML parse error
|
|
pointing at the template, and the advice here was to avoid it; if you are
|
|
carrying a workaround schedule from that era ("5,20,35,50 * * * *" instead
|
|
of every 15 minutes), you can write what you meant now.
|
|
{{- if $cronPinned }}
|
|
|
|
PLACEMENT: the mover runs on the NODE OF THE STATEFULSET POD, via a
|
|
required podAffinity on its label (Gitea #101). Without that it lands
|
|
anywhere, and because it mounts the consume volume — RWX, from Longhorn —
|
|
the kubelet of the StatefulSet's node then retries the mount every two
|
|
minutes with "no Pending workload pods for volume ... to be mounted". It
|
|
was measured at 291 such events in 10 hours.
|
|
|
|
THE PRICE, and it is the one thing to know before scaling down: with no
|
|
StatefulSet pod the mover CANNOT BE SCHEDULED. scaleDown: true suspends
|
|
this CronJob outright, so that case is covered — but paperless.replicas: 0
|
|
with SMB still on is not, and there the job fails once per schedule after
|
|
activeDeadlineSeconds ({{ $cronDeadline }}s) with reason DeadlineExceeded. That is
|
|
deliberate: a bounded, visible failure rather than a Pending pod per
|
|
schedule that nobody sees. Switch smbConnection.cron.pinToServerNode to
|
|
false to go back to unpinned placement — and to the mount errors.
|
|
|
|
What the pinning does NOT fix: the consume volume stays RWX with a
|
|
share-manager behind it. Co-locating the two consumers removes the
|
|
cross-node mount, not the second consumer.
|
|
{{- else }}
|
|
|
|
PLACEMENT IS OFF (smbConnection.cron.pinToServerNode: false). The mover
|
|
will be scheduled wherever there is room, and when that is not the node of
|
|
the StatefulSet pod, Longhorn refuses to mount the shared RWX consume
|
|
volume there: the kubelet of the StatefulSet's node retries every two
|
|
minutes with "no Pending workload pods for volume ... to be mounted"
|
|
(Gitea #101, measured at 291 events in 10 hours). The errors stop when the
|
|
StatefulSet pod is deleted and comes back Pending, and return with the next
|
|
firing of this job.
|
|
{{- end }}
|
|
|
|
The job has NO probes (the documented exception to the chart conventions),
|
|
and it cannot fail usefully either: with nothing to move it exits 0, which
|
|
is indistinguishable from a successful run. Judge it by what arrives, not
|
|
by the Job's status:
|
|
|
|
kubectl -n {{ .Release.Namespace }} get jobs --sort-by=.metadata.creationTimestamp
|
|
kubectl -n {{ .Release.Namespace }} logs job/<name-of-the-last-one>
|
|
|
|
The share is reached through the smb.csi.k8s.io driver, which this chart
|
|
does not install. The PersistentVolume {{ .Release.Name }}-{{ .Chart.Name }}-smb-pv is CLUSTER-scoped
|
|
with reclaim policy Retain and its volumeHandle is derived from
|
|
smbConnection.server alone — two releases pointing at the same server
|
|
collide on that handle, and a Retained PV outlives helm uninstall and
|
|
blocks the next install of the same name until it is deleted by hand.
|
|
{{- else }}
|
|
|
|
smbConnection.enabled is false, so nothing fills that directory on its own —
|
|
the web UI is the only way in. To hand a file over directly:
|
|
|
|
kubectl -n {{ .Release.Namespace }} cp <file.pdf> {{ $pod }}:/usr/src/paperless/consume/
|
|
{{- end }}
|
|
|
|
WHAT A GREEN POD DOES NOT PROVE
|
|
One container runs the web server and the Celery worker and scheduler side
|
|
by side under s6-overlay, and all three probes only ask the web server for
|
|
"/". A worker that died, a consumer that is not watching and a broken Redis
|
|
connection all leave the pod Ready and the rollout green. The honest check is
|
|
a document going in and coming out the other side, plus:
|
|
|
|
kubectl -n {{ .Release.Namespace }} logs {{ $pod }} | grep -i -e error -e celery
|
|
|
|
A read-only root filesystem is part of that picture too: only /tmp and /run
|
|
are writable. Anything the app would install at runtime — an extra tesseract
|
|
language pack, for instance — cannot be written, no matter which variable
|
|
asks for it.
|
|
|
|
OCR AND BARCODES AS THIS RELEASE HAS THEM
|
|
language "{{ .Values.paperless.ocr.languages }}" · mode {{ .Values.paperless.ocr.mode }} · archive {{ .Values.paperless.ocr.archiveFileGeneration }} · output {{ .Values.paperless.ocr.output_type }} · rotate {{ .Values.paperless.ocr.rotate_pages }}
|
|
|
|
paperless.ocr.languages sets PAPERLESS_OCR_LANGUAGE — which language to use.
|
|
The chart does not set the plural PAPERLESS_OCR_LANGUAGES that makes the
|
|
image fetch further packages at start, and with the read-only rootfs above it
|
|
could not work anyway: a language the image does not already carry needs a
|
|
different image, not a different value.
|
|
|
|
In 3.x the OCR mode values "skip" and "skip_noarchive" no longer exist —
|
|
"auto" replaces them, and archive generation is a separate setting.
|
|
|
|
barcodes: {{ if $barcodes.enabled }}enabled, separator "{{ $barcodes.barcodeString }}", split pages {{ if $barcodes.retainBarcodePage }}retained{{ else }}discarded{{ end }}, ASN {{ if $barcodes.asn.enabled }}on{{ else }}off{{ end }}{{ else }}disabled{{ end }}
|
|
A childless "barcodes:" key in a values file is YAML null and means "chart
|
|
defaults apply" here (issue #61) — it does NOT switch barcodes off. Deleting
|
|
the sub-keys gets you the defaults back; only barcodes.enabled: false
|
|
disables it.
|
|
{{- if .Values.paperless.files.deleteDuplicates }}
|
|
|
|
paperless.files.deleteDuplicates is on: a document that matches one already
|
|
in the archive is DELETED on consume instead of being kept. That is the 2.x
|
|
behaviour, which 3.0 dropped as a default.
|
|
{{- end }}
|
|
{{- if .Values.paperless.files.tikaGotenberg.enabled }}
|
|
|
|
TIKA AND GOTENBERG — THE ENDPOINTS CARRY NO SCHEME
|
|
tika http://{{ .Values.paperless.files.tikaGotenberg.tikaEndpoint }}
|
|
gotenberg http://{{ .Values.paperless.files.tikaGotenberg.gotenbergEndpoint }}
|
|
|
|
The chart prefixes "http://" itself, so these two values are bare host:port.
|
|
A value written with a scheme renders as http://http://… and every Office
|
|
document fails to convert while everything else keeps working. Neither
|
|
service is part of this chart or checked at startup.
|
|
{{- end }}
|
|
{{- if .Values.oidc.enabled }}
|
|
|
|
OIDC IS ON, AND IT IS PKCE-ONLY
|
|
Register this redirect URI at the provider:
|
|
|
|
https://{{ .Values.ingress.domain }}/accounts/oidc/{{ .Values.oidc.provider.id }}/login/callback/
|
|
|
|
The chart enables PKCE and configures NO client secret — the client has to be
|
|
registered as one that can complete the flow without one. Nothing about OIDC
|
|
is exercised before someone actually signs in, so a wrong discovery URL,
|
|
client id or redirect URI surfaces as a failed login on a perfectly healthy
|
|
pod.
|
|
{{- if not .Values.oidc.provider.tokenAuthMethod }}
|
|
|
|
oidc.provider.tokenAuthMethod is unset. Since the allauth update in 3.0 some
|
|
providers need it spelled out, and the symptom when they do is a login that
|
|
dies at the callback with invalid_client. The only value this chart accepts is
|
|
"none" — it states the public client explicitly. A method that authenticates
|
|
WITH a client secret cannot work here and is rejected by the schema (13.0.0,
|
|
Gitea #11): this release configures no client secret and cannot, because
|
|
paperless reads the whole OIDC configuration out of one json environment
|
|
variable with no _FILE variant.
|
|
{{- end }}
|
|
{{- if or .Values.oidc.autoLoginWithProvider .Values.oidc.disableRegularLogin }}
|
|
|
|
{{ if .Values.oidc.autoLoginWithProvider }}autoLoginWithProvider{{ end }}{{ if and .Values.oidc.autoLoginWithProvider .Values.oidc.disableRegularLogin }} and {{ end }}{{ if .Values.oidc.disableRegularLogin }}disableRegularLogin{{ end }} — the app's own login form is
|
|
bypassed or gone. Before you rely on that, make sure at least one local
|
|
superuser still has a usable password and check that Django's admin login at
|
|
/admin/ still lets that account in; it is the only way back when the provider
|
|
is unreachable.
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- if or $pre $post }}
|
|
|
|
paperless.scripts IS SET AND DOES NOTHING
|
|
{{ len $pre }} pre- and {{ len $post }} post-consumption script(s) are configured. The
|
|
ConfigMap that would carry them is disabled in this chart ({{ "{{- if false }}" }} at the
|
|
top of the template), no volume mounts it, and neither
|
|
PAPERLESS_PRE_CONSUME_SCRIPT nor PAPERLESS_POST_CONSUME_SCRIPT is set on the
|
|
container. These values render no object and change no behaviour.
|
|
{{- end }}
|
|
{{- if .Values.paperless.env }}
|
|
|
|
paperless.env ADDS {{ len .Values.paperless.env }} VARIABLE(S) — AND AN OVERRIDE ONLY BITES AT INSTALL
|
|
{{ range $i, $e := .Values.paperless.env }}{{ if $i }}, {{ end }}{{ $e.name }}{{ end }}
|
|
|
|
These are appended AFTER the variables the chart sets itself, so at INSTALL an
|
|
entry repeating one of those names wins. On an upgrade of an EXISTING release
|
|
the same entry is lost: Kubernetes merges env by name, the pair collapses to
|
|
the chart's own value, and helm upgrade still reports success. Read the value
|
|
back off the StatefulSet, never off the upgrade's exit code:
|
|
|
|
kubectl -n {{ .Release.Namespace }} get statefulset {{ .Release.Name }}-{{ .Chart.Name }}-sfs \
|
|
-o jsonpath='{.spec.template.spec.containers[0].env}'
|
|
{{- end }}
|
|
{{- if gt (int $replicas) 1 }}
|
|
|
|
paperless.replicas IS {{ $replicas }} — EXPECT ONE POD TO WORK AND THE REST TO WAIT
|
|
The main volume is a plain ReadWriteOnce PVC shared by the whole
|
|
StatefulSet, not a volumeClaimTemplate, so the second pod stays Pending on
|
|
"Multi-Attach error for volume" unless the storage class is genuinely RWX —
|
|
and where it does start, two consumers watch the same directory and two
|
|
schedulers run the same periodic tasks.
|
|
{{- end }}
|
|
|
|
BACKUP IS TWO THINGS AT THE SAME TIME
|
|
Postgres holds the metadata, the tags and one row per document; the PVC
|
|
{{ .Release.Name }}-{{ .Chart.Name }}-volume-claim holds the files under data/, media/ and export/.
|
|
Restoring one without the other gives you an archive full of references to
|
|
documents that are not there. Snapshot them together.
|
|
|
|
Both PVCs are rendered by this chart, so helm uninstall deletes them and the
|
|
documents with them unless the storage class reclaim policy says otherwise.
|
|
The external database survives that — which makes an accidental uninstall
|
|
look recoverable while the files are already gone.
|
|
{{- if .Values.paperless.env }}
|
|
|
|
paperless.env adds {{ len .Values.paperless.env }} variable(s), rendered after the ones the chart
|
|
sets itself, so they win on a name the chart also sets.
|
|
{{- end }}
|
|
{{- if .Values.networkPolicy.enabled }}
|
|
|
|
NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT
|
|
{{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods.
|
|
Inbound: TCP 8000 only. Outbound: DNS, TCP 5432 (Postgres), 6379
|
|
(Redis/Valkey), 443{{ if .Values.paperless.files.tikaGotenberg.enabled }}, 9998 and 3000 (the Tika and Gotenberg POD ports) plus 80{{ end }}{{ with .Values.networkPolicy.egress }}{{ if .mailImport }}, 993 (IMAPS, mail import){{ end }}{{ with .extraPorts }}, plus TCP
|
|
{{ join ", " . }}{{ end }}{{ end }}.
|
|
{{- if .Values.paperless.files.tikaGotenberg.enabled }}
|
|
|
|
THE TIKA/GOTENBERG RULE NAMES POD PORTS, AND THAT IS THE FIX FROM GITEA #136.
|
|
It named the Services' 80 alone until chart 14.1.0, which cut Paperless off
|
|
from both services: kube-proxy does a ClusterIP Service's destination NAT
|
|
BEFORE the egress policy is evaluated at the pod's interface, so the policy
|
|
sees podIP:targetPort. The symptom was a TIMEOUT and not "connection refused",
|
|
and no error mentioned a NetworkPolicy. If this release reaches a Tika or
|
|
Gotenberg that listens on some other port, that port goes into
|
|
networkPolicy.egress.extraPorts.
|
|
{{- end }}
|
|
{{- if (.Values.networkPolicy.egress).mailImport }}
|
|
|
|
MAIL IMPORT IS PERMITTED on 993 (IMAPS), because
|
|
networkPolicy.egress.mailImport is on. Plain IMAP (143) and POP3 are NOT
|
|
covered — add them to networkPolicy.egress.extraPorts if an account uses them.
|
|
{{- else }}
|
|
|
|
MAIL FETCHING IS NOT PERMITTED. Paperless' mail accounts are rows in its
|
|
database, configured in the web UI, so this chart cannot see them. A release
|
|
that fetches mail needs networkPolicy.egress.mailImport: true for IMAPS (993),
|
|
or 143/POP3 in networkPolicy.egress.extraPorts — otherwise a mail rule
|
|
silently stops importing, with a healthy pod behind it.
|
|
{{- end }}
|
|
|
|
AND TWO PODS OF THIS RELEASE ARE NOT COVERED BY THIS OBJECT:
|
|
- the valkey subchart's pod, which carries its own labels. It needs no
|
|
cover from here: the valkey chart ships its own NetworkPolicy, and a
|
|
tighter one.
|
|
- the consume CronJob's pods, whose pod template carries NO labels at all,
|
|
so no podSelector can match them. They move files between mounted
|
|
volumes and reach the SMB share through a CSI volume the NODE mounts, so
|
|
the pod opens no connection of its own — but the gap is real and is named
|
|
here rather than left to be discovered.
|
|
|
|
Three limits belong in the same breath, because none of them is visible in
|
|
the object itself:
|
|
|
|
- EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and
|
|
selectors and cannot express a hostname, so every egress rule permits its
|
|
port to ANY address. That is a limit of the object, not a shortcut — this
|
|
cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An
|
|
outbound port the chart does not know about is BLOCKED: add it to
|
|
networkPolicy.egress.extraPorts, and expect to find out through an
|
|
application-level connection error rather than through this object.
|
|
{{- if .Values.networkPolicy.probeSources }}
|
|
- THE PROBE PATH IS RESTRICTED in this release, because
|
|
networkPolicy.probeSources is set to
|
|
{{ join ", " .Values.networkPolicy.probeSources }}
|
|
Probes come from the IP of the WORKER NODE running the pod. If none of those
|
|
CIDRs covers the node this pod lands on, the kubelet's probes are dropped
|
|
and THE POD NEVER BECOMES READY — the rollout then times out and the symptom
|
|
looks like a broken application. Compare
|
|
|
|
kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
|
|
against `kubectl get node -o wide`.
|
|
{{- else }}
|
|
- THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is
|
|
empty, so that rule carries no source selector at all and any pod the CNI
|
|
routes to can reach it. Not from the internet — but this is NOT pod-level
|
|
isolation. Narrowing it means listing the worker node IPs in
|
|
networkPolicy.probeSources, because the kubelet probes from the node's own
|
|
address and the chart cannot know it; a wrong guess there leaves the pod
|
|
permanently un-Ready.
|
|
{{- end }}
|
|
- IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not,
|
|
this object is accepted and filters nothing, so everything reads as
|
|
configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy`
|
|
shows the object exists; only a connection attempt from an unrelated
|
|
namespace shows that it bites.
|
|
|
|
Switch it off with networkPolicy.enabled: false.
|
|
{{- end }}
|