feat(paperless-ngx)!: quote every operator scalar, pin the consume mover, drop oidcSecretRef

Three issues on one chart, bundled because they share values.yaml,
values.schema.json and the version line, and because the first half of #11
lives in the same template as #101.

Gitea #11, first half: the consume CronJob's schedule was rendered bare, so a
schedule in the commonest cron notation there is - a star, a slash, an interval
- aborted the render with a YAML parse error pointing at the template while the
operator's value appeared nowhere in the message. Quoting it in the values file
does not help; Helm passes the content through. Reproduced before the fix, at
the line the issue names, and fixed with | quote.

Gitea #11, second half: secrets.oidcSecretRef is removed, values and schema
both. No template ever read it, so no OnePasswordItem was created and nothing
reached the pod - the quiet kind of defect that looks like successful
configuration. It is not reimplemented, and the reason is in paperless: the
whole OIDC configuration travels in one json environment variable with no _FILE
variant, so a secretKeyRef cannot substitute a fragment into it. The
operational question is answered by #83 - Keycloak marks a client public per
client, so nothing forces a confidential one here.

In the same breath, oidc.provider.tokenAuthMethod is restricted to "none". Its
comment named client_secret_basic as the example, which asks the provider for
an authentication this chart cannot perform: it configures no client secret and
cannot. The two values read together looked like a working secret setup, and the
symptom was a login dying at the callback with invalid_client. No live release
sets it (measured against all three instances).

Gitea #112 asked for an extraEnv and the chart already had the standard
additive env block - the issue was measured against a deployed 12.2.0 and read
only the top-level keys. What was actually broken is the block itself: it
rendered its operand bare, so a "*"-leading value aborted the render and "true"
or "0755" arrived as a bool or an integer, which the API server rejects after a
green render. It is brought to the repository's documented quoted form
(template-chart carries the rule), which is what makes an arbitrary PAPERLESS_*
variable settable in practice. PAPERLESS_LOGGING_DIR is documented as the
worked example, with the cross-reference it needs: the root cause is the
NFS-exported RWX data volume in jk-cluster-config #360 and this does not fix it
- it only moves the log off that volume. The two Ingress upload-size
annotations had the same bare render and are quoted too.

Gitea #101: the consume mover now runs on the node of the StatefulSet pod, via
a required podAffinity on its label. It mounts the RWX consume volume, and
Longhorn's CSI driver refuses that mount unless it finds a workload pod in
Pending, so a mover on another node left the StatefulSet node's kubelet
retrying every two minutes - 291 events in 10 hours in both releases with SMB
on, none in the release without the CronJob. A static nodeSelector would be
wrong because the StatefulSet pod is not pinned either, and preferred would
reproduce the bug more rarely and harder to find.

The cost of a required term is handled rather than left open:
concurrencyPolicy: Forbid, a startingDeadlineSeconds that also keeps the
controller away from its 100-missed-schedules stop, and an activeDeadlineSeconds
that fails a job which cannot be placed instead of leaving one Pending pod per
schedule. scaleDown already suspends the CronJob; what remains is
paperless.replicas: 0 with SMB on, which now reports a bounded, visible failure.
successfulJobsHistoryLimit is deliberately untouched: Longhorn's check asks for
the existence of a Pending pod, not the absence of Succeeded ones, so those
three pods are the error message's evidence and not its cause.

Side defect fixed on the way: every read of smbConnection.cron dereferenced the
block directly, so an override that had erased it aborted with a nil-pointer
error naming an internal template path instead of falling back to the defaults
this chart promises everywhere else. Measured on origin/main; all reads now go
through a merged-defaults helper that preserves an explicit false.

Major: a value is removed and fails the strict schema afterwards, and runtime
placement changes.

Fixes #11
Fixes #101
Fixes #112
This commit is contained in:
jklein committed 2026-10-13 19:09:45 +00:00
1 parent 59d2614384
commit 4b2bde505d
8 files changed
+427 -42

No files matched your search

+70 -17
View File
@@ -8,6 +8,9 @@
{{- $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 -}}
@@ -184,15 +187,14 @@ WHAT THE 1PASSWORD ITEMS MUST CONTAIN
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 }}
{{- if .Values.secrets.oidcSecretRef }}
secrets.oidcSecretRef IS SET AND IS READ BY NOTHING
"{{ .Values.secrets.oidcSecretRef }}" — no template in this chart references
that value. No OnePasswordItem is created for it and no Secret appears. The
OIDC client id travels as plain env from oidc.provider.clientId, and no
client secret is configured at all (see the OIDC section below). Whatever
you put in that item has no effect on this release.
{{- 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
@@ -340,12 +342,44 @@ HOW DOCUMENTS GET IN
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.
One thing to know before you edit that schedule: it is rendered into the
manifest unquoted, so a schedule that STARTS with "*" ("*/15 * * * *")
aborts the render with a YAML parse error on this template — YAML reads the
leading * as an alias. Quoting it in your values file does not help, the
quotes are gone by then. Use an equivalent that starts with a digit
("5,20,35,50 * * * *"); a "*" anywhere but in first position is fine.
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
@@ -432,8 +466,13 @@ OIDC IS ON, AND IT IS PKCE-ONLY
{{- if not .Values.oidc.provider.tokenAuthMethod }}
oidc.provider.tokenAuthMethod is unset. Since the allauth update in 3.0 some
providers need it spelled out (client_secret_basic); the symptom is a login
that dies at the callback with invalid_client.
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 }}
@@ -453,6 +492,20 @@ paperless.scripts IS SET AND DOES NOTHING
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
+38
View File
@@ -340,3 +340,41 @@ The same three fallbacks for the SMB consume-mover CronJob. It has no probes
{{- $merged := fromYaml (include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron.resources")) -}}
{{- include "paperless-ngx.resources" $merged -}}
{{- end -}}
{{/*
The whole smbConnection.cron block, merged onto its defaults (YAML; consume
with fromYaml).
Input: .Values.smbConnection.cron (may be nil, empty or partial).
WHY EVERY READ OF THAT BLOCK GOES THROUGH HERE. The CronJob template used to
dereference .Values.smbConnection.cron.<key> directly, so an override that had
erased the block - the leftover childless
cron:
that issue #61 is about - did not fall back to the defaults the way this chart
promises everywhere else. It aborted the render with
nil pointer evaluating interface {}.securityContext
naming an internal template path and neither the value nor the cause. Measured
on origin/main before 13.0.0, so this is a fix and not a precaution.
The scalar defaults below MUST stay in sync with the smbConnection.cron block
in values.yaml, which stays the documented, user-facing place for them; this
copy is only the fallback. The sub-blocks (securityContext, resources, env)
keep their own defaults in the helpers above and are only passed through here.
An explicit "pinToServerNode: false" survives this, which is the whole reason
it is not a "default true" pipe: sprig's default would read false as unset and
silently turn the pinning back on - the one value whose explicit false has to
be obeyed, since it exists to switch off a hard placement rule.
*/}}
{{- define "paperless-ngx.cron.scheduling" -}}
{{- $defaults := dict
"pinToServerNode" true
"startingDeadlineSeconds" 600
"activeDeadlineSeconds" 3600 -}}
{{- include "paperless-ngx.defaultedDict" (dict "defaults" $defaults "given" . "path" "smbConnection.cron") -}}
{{- end -}}
@@ -1,26 +1,165 @@
{{- if .Values.smbConnection.enabled }}
{{- /*
The cron block, merged onto its defaults, so that an override which erased it
falls back instead of aborting with a nil-pointer error naming an internal
template path - which is what every direct .Values.smbConnection.cron.<key>
read did before 13.0.0. The helper carries the measurement.
*/}}
{{- $cron := fromYaml (include "paperless-ngx.cron.scheduling" .Values.smbConnection.cron) -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-consume-cronjob
spec:
schedule: {{ .Values.smbConnection.cronJob }}
{{- /*
QUOTED, AND THAT IS THE WHOLE OF GITEA #11's FIRST HALF. The default
"0 0,12 * * *" renders safely only because it begins with a digit. The most
common cron notation of all - a star, a slash and an interval, every 15
minutes - begins with "*", which YAML reads as an alias indicator, and the
render ABORTS:
(and the notation cannot be written out in this comment, because the two
characters that start it would end a Go template comment right here)
Error: YAML parse error on paperless-ngx/templates/paperless-consume.cronjob.yaml:
error converting YAML to JSON: yaml: line 6: did not find expected
alphabetic or numeric character
The trap is that quoting it in the VALUES does not help: Helm passes the
string's content through, and the star stands bare in the manifest again. So
the operator writes a correctly quoted value and gets an error that points at
this template instead of at their value. "@daily" fails the same way ("@" is
a reserved YAML indicator).
ci/run-yaml-scalar-render-test.sh is the test that would have caught it, and
ci/yaml-scalar/paperless-ngx.yaml is the overlay that feeds it a star-leading
schedule - needed because this whole template sits behind
smbConnection.enabled, which is false in every other fixture, so nothing else
in the repository renders this object at all.
*/}}
schedule: {{ .Values.smbConnection.cronJob | quote }}
{{- /*
Forbid, hard-wired rather than a value: two movers running at once race on
the same files ("mv /mnt/smb/*.pdf" twice over one directory), and the loser
reports a file that is already gone. There is no configuration in which
overlapping runs of this job are wanted, so there is nothing to configure.
*/}}
concurrencyPolicy: Forbid
{{- /*
Bounds how late a missed run may still start, and it is here for a second
reason that has nothing to do with lateness: while the CronJob is suspended
(scaleDown) or its runs are being suppressed, the controller counts missed
schedules, and above 100 it STOPS SCHEDULING ALTOGETHER with only
"Cannot determine if job needs to be started: too many missed start times" in
the controller log. With this field set, the controller only counts misses
inside this window, so that state cannot be reached. The cost is stated in
values.yaml: a run that could not start inside the window is skipped, not
queued.
*/}}
startingDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.startingDeadlineSeconds }}
{{- if .Values.scaleDown }}
suspend: true
{{- end }}
jobTemplate:
spec:
{{- /*
THE SAFETY NET UNDER THE AFFINITY BELOW (Gitea #101). activeDeadlineSeconds
counts from the job's start, not from the pod's, so it also bounds a pod
that NEVER gets scheduled: when the StatefulSet pod is absent and the
required affinity cannot be satisfied, the job fails after this many
seconds with reason DeadlineExceeded instead of sitting Pending forever
and gaining a sibling every schedule. That turns the one new failure mode
this issue's fix could introduce into a visible, self-clearing one -
failedJobsHistoryLimit (Kubernetes' default, 1) keeps the evidence.
*/}}
activeDeadlineSeconds: {{ include "paperless-ngx.formatNumber" $cron.activeDeadlineSeconds }}
template:
spec:
{{- if $cron.pinToServerNode }}
{{- /*
GITEA #101: this pod mounts the consume volume, which is RWX and comes
from Longhorn. Without a placement rule the scheduler puts it on any
node, and Longhorn's CSI driver then refuses the mount on the node
where the StatefulSet pod runs:
MountVolume.SetUp failed ... code = Aborted desc = no Pending
workload pods for volume pvc-... to be mounted:
map[Running:[...-sfs-0] Succeeded:[...-consume-cronjob-...]]
Measured at every two minutes, 291 events in 10 hours, in both
releases that have SMB switched on; the release with
smbConnection.enabled: false and therefore no CronJob produced none of
them on identically built volumes.
WHY podAffinity AND NOT nodeSelector: the StatefulSet pod is not
pinned either - it sits wherever the scheduler put it and moves on
every restart. A static nodeSelector would be correct exactly until
then and silently wrong afterwards. The affinity follows the pod.
WHY required AND NOT preferred: preferred falls back to another node
in the very situation this exists for, which reproduces the bug more
rarely and therefore harder to find. The fallback is what went
unnoticed for ten hours.
The label is the one the StatefulSet puts on its pod template
(paperless-server.sfs.yaml, metadata.labels.app) and is read off the
running pod, not assumed.
WHAT THIS DOES NOT FIX: the volume stays RWX and keeps its
share-manager; co-locating the two consumers removes the CROSS-NODE
attach, not the second consumer. The design that removes it is moving
the mv into the StatefulSet pod, which cannot be done for the running
installations because RWX cannot be taken back off an existing PVC.
Gitea #101 names it as the alternative and not as the proposal.
*/}}
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
topologyKey: kubernetes.io/hostname
{{- end }}
automountServiceAccountToken: false
securityContext:
{{- include "paperless-ngx.cron.podSecurityContext" .Values.smbConnection.cron.securityContext | nindent 12 }}
{{- include "paperless-ngx.cron.podSecurityContext" $cron.securityContext | nindent 12 }}
containers:
- name: paperless-consume-cronjob
image: "{{ .Values.paperless.busybox.image.repository }}:{{ .Values.paperless.busybox.image.tag }}"
imagePullPolicy: IfNotPresent
securityContext:
{{- include "paperless-ngx.cron.containerSecurityContext" .Values.smbConnection.cron.securityContext | nindent 16 }}
{{- include "paperless-ngx.cron.containerSecurityContext" $cron.securityContext | nindent 16 }}
{{- if $cron.env }}
env:
{{- range $value := $cron.env }}
{{- if $value.value }}
{{- /*
quote is required, not cosmetic: a value like "8080" or "true"
renders unquoted as a YAML number/bool, and the API server then
rejects the whole manifest with "cannot unmarshal number into
Go struct field EnvVar.value of type string". A template
comment and not a "#" one, because a "#" line here is part of
the rendered manifest.
*/}}
- name: {{ $value.name | quote }}
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
command:
- /bin/sh
- -c
@@ -33,7 +172,7 @@ spec:
mountPath: "/mnt/consume"
readOnly: false
resources:
{{- include "paperless-ngx.cron.effectiveResources" .Values.smbConnection.cron.resources | nindent 16 }}
{{- include "paperless-ngx.cron.effectiveResources" $cron.resources | nindent 16 }}
restartPolicy: Never
volumes:
- name: smb-share
@@ -42,4 +181,4 @@ spec:
- name: consume-folder
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-consume-volume-claim
{{- end }}
{{- end }}
@@ -204,23 +204,41 @@ spec:
- name: PAPERLESS_DISABLE_REGULAR_LOGIN
value: "{{ .Values.oidc.disableRegularLogin }}"
{{- end }}
{{- /*
QUOTED SINCE 13.0.0, and it is a fix rather than a tidy-up (Gitea
#112, same genus as #11). This block is the chart's additive env
interface, and until 13.0.0 it rendered its operand bare. Two
measured consequences, both of them the operator's value arriving
as something else:
- a value beginning with "*" aborted the render, with the error
pointing at THIS template rather than at the value;
- "true" and "0755" rendered as a YAML bool and an integer, and
the API server rejects those with "cannot unmarshal number
into Go struct field EnvVar.value of type string" - the chart
renders, the install fails.
The quoted form is the repository's documented one
(template-chart/templates/xxx.deployment.yaml carries the rule);
this chart was one of seven that had not been brought to it.
*/}}
{{- range $value := .Values.paperless.env }}
{{- if $value.value }}
- name: {{ $value.name }}
value: {{ tpl $value.value $ }}
- name: {{ $value.name | quote }}
value: {{ tpl $value.value $ | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $ }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ }}
name: {{ tpl $value.valueFrom.secretKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $ | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ }}
name: {{ tpl $value.valueFrom.configMapKeyRef.name $ | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $ | quote }}
{{- end }}
{{- end }}
{{- end }}
+10 -2
View File
@@ -23,8 +23,16 @@ metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize }}
{{- /*
Quoted for the same reason as the env block in the StatefulSet and the
schedule in the CronJob (Gitea #11's genus): an annotation value must be a
string, maxUploadFileSize is free operator text, and a unit-less "100" -
schema-valid, since the schema types it as a string - renders bare as an
integer and the API server rejects the whole Ingress. The default "10m"
happens to be safe, which is exactly how this survived.
*/}}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
nginx.org/client-max-body-size: {{ .Values.paperless.files.maxUploadFileSize | quote }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}