Files
helm-charts/convertx/templates/NOTES.txt
T
jklein 5be10251f4
Validate Helm Charts / Detect changed charts (pull_request) Successful in 3s
Validate Helm Charts / Resource quantities (pull_request) Successful in 7s
Validate Helm Charts / Build ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} (pull_request) Successful in 3s
Validate Helm Charts / Trivy config ${{ fromJSON(needs.detect-changed-charts.outputs.scan-charts) }} (pull_request) Successful in 10s
Install-test / Install-test ${{ fromJSON(inputs.charts) }} (pull_request) Successful in 2m35s
Validate Helm Charts / Install-test (pull_request) Successful in 2m35s
feat(convertx): new chart for ConvertX (1.0.0+upv0.19.0)
ConvertX is a self-hosted file converter with a web UI. StatefulSet with one
replica, a PVC at /app/data holding the SQLite database, the uploads and the
converted output, a ClusterIP Service and the standard Ingress. No external
database, cache or object storage.

The chart runs non-root as uid/gid 1000 and bypasses the image entrypoint.
The image carries no USER, so it starts as root, and /entrypoint.sh exists
only to drop to uid 1000 with gosu. Overriding command with the image's own
CMD lets Kubernetes do that work instead - runAsUser/runAsGroup 1000 and
fsGroup in place of the entrypoint's recursive chown - so the repo's
hardening baseline stays intact rather than being relaxed for this image.

What that costs is named at the command: line in the StatefulSet: if upstream
ever moves real work into the entrypoint, this chart skips it silently, with
no error and no failed probe. The entrypoint of v0.19.0 does nothing but
uid/gid plumbing (and with PUID unset its first branch is already a plain
exec "$@"), and ConvertX migrates its SQLite schema in-process at import, so
the bypass is safe today. It is to be re-read at every app-version update.

The one line the bypass does lose is "export HOME=/home/convertx", so the
chart sets HOME itself instead of relying on the runtime's passwd lookup, and
mounts an emptyDir there: LibreOffice, Calibre, Inkscape and fontconfig build
their profiles under HOME on the first conversion, and it sits on the
read-only root filesystem. /tmp is the second emptyDir, for the toolchain's
scratch files. Both count against the container's ephemeral-storage limit,
which is why that request is sized for them.

uid 1000 rather than the repo's arbitrary 20000, deliberately: the image owns
/home/convertx with it, and the uid owns data on a PersistentVolume, so the
number is not free to tidy later. The fallback defaults in _helpers.tpl
reproduce the deviation rather than the repo standard.

All three probes use /healthcheck, a static 200 that the application exempts
from authentication. "/" is never a 200 with this chart's defaults: it
answers 302 to /setup while no account exists and to /login afterwards, so a
probe against it would pass on a redirect whose meaning changes on day two.
The startup budget is 300s because ConvertX shells out to ~20 converter
binaries at import just to log their versions.

Values:

- secrets.jwtSecretRef, required, field "jwt-secret". Without JWT_SECRET the
  application mints a random one per process, so every restart invalidates
  every session - silently.
- accountRegistration, allowUnauthenticated and httpAllowed, all false.
  Upstream warns explicitly against the first two on an internet-facing
  instance; the warning is carried into values.yaml and NOTES.txt. They go
  through a helper that renders the literal "true"/"false" the application's
  strict comparison needs and aborts the render on anything else - "yes" would
  otherwise be filed under "off" without a word.
- autoDeleteEveryNHours, default 24, 0 disables. It governs retention on the
  PVC. The 0 is honoured through an explicit nil check; Helm's "default" would
  swallow it and restore 24-hour deletion on the install that asked for none.
- ingress.maxUploadFileSize, default 1024m. ConvertX enforces no size limit of
  its own (maxRequestBodySize is MAX_SAFE_INTEGER), so unlike outline there is
  exactly one ceiling here and this is it.

Switching the Ingress off frees BOTH domain and clusterIssuer, checked in
both directions: ConvertX has no base-URL, public-URL or redirect-URI
variable, so nothing outside the Ingress reads the domain.

WEBROOT is deliberately not a value. It shifts every route including
/healthcheck, which would break all three probes while the application stayed
healthy, and this chart's Ingress still maps "/". PUID/PGID/UMASK are not
values either - they configure the bypassed entrypoint and nothing else.

Wiring: publish-convertx.yml, the change-app-version.yml choice list,
ci/convertx/ (test-values, the JWT Secret fixture the stub OnePasswordItem
CRD cannot materialize, and INSTALL_TIMEOUT=900 for a ~1.4 GiB image), a
renovate.json5 block anchored on the v prefix so the moving latest, main and
pr-<n> tags cannot be selected, and the README chart row.

Verified locally: helm lint --strict clean, render against the CI fixtures,
the null-safe fallbacks for every erased block, the resource-quantity render
test, and NOTES.txt through the define/ConfigMap trick since helm template
does not render it. Runtime behaviour is unverified here - this workspace has
no Docker daemon and no k3d, so the PR install-test is the only runtime proof.
2026-09-30 15:26:33 +00:00

225 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{- $replicas := include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) -}}
{{- $startup := fromYaml (include "convertx.startupProbe" .Values.convertx.startupProbe) -}}
{{- $autoDelete := include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours -}}
{{- $registration := include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") -}}
{{- $unauthenticated := include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") -}}
{{- $httpAllowed := include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") -}}
ConvertX {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
ACCESS
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
{{- else }}
ACCESS — NO INGRESS
ingress.enabled is false, so this release has no Ingress and no certificate,
and neither ingress.clusterIssuer nor ingress.domain is needed. ConvertX has
no base-URL setting of its own — it builds no absolute links — so nothing
outside the Ingress reads the domain in this chart.
Reach the release inside the cluster with
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
and open http://localhost:8080. Note that the session cookie is marked
"secure"{{ if eq $httpAllowed "true" }} — except that convertx.httpAllowed is true in this release, which
clears that flag, so logging in over plain http works{{ else }}, so logging in over plain http will NOT work: the browser
refuses to store the cookie. convertx.httpAllowed: true clears that flag{{ end }}.
{{- end }}
{{ if eq $replicas "0" }}
SCALED TO ZERO
{{ if .Values.scaleDown }}scaleDown: true{{ else }}convertx.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so the endpoint
above does not answer until you set it back and upgrade again. PVC, Secret{{ if .Values.ingress.enabled }},
Service and Ingress stay in place, so neither the conversions nor the
certificate are touched.{{ else }} and
Service stay in place, so the conversions on the PVC are untouched.{{ end }}
{{ end }}
WHOEVER OPENS THE PAGE FIRST BECOMES THE ADMINISTRATOR
A fresh ConvertX has no users, and with no users it serves a SETUP FORM to
every visitor — no password, no invitation, nothing to bypass. The first
account created that way owns the instance, and convertx.accountRegistration
does not gate it: that value decides whether accounts TWO and onwards can be
created, not the first one.
So on a fresh install, register yourself before the DNS name is reachable by
anyone else. There is no second chance and no "reset admin" command; the
repair is deleting the users row from the SQLite database on the PVC.
{{- if eq $unauthenticated "true" }}
This release has convertx.allowUnauthenticated: true, so the point above is
moot in the worst way — see the warning further down.
{{- end }}
THE 1PASSWORD ITEM MUST CARRY THE FIELD THE CHART READS
secrets.jwtSecretRef points at
{{ .Values.secrets.jwtSecretRef }}
The 1Password operator materializes that item as the Kubernetes Secret
{{ .Release.Name }}-{{ .Chart.Name }}-secret, with one Secret key per FIELD of the item. So the field
name is not free-form: this chart reads exactly one field, and it must be
spelled
jwt-secret a long random string — openssl rand -hex 32
An item without it does not fail the install. The release goes through and
the pod then hangs in CreateContainerConfigError naming the key it could not
find. Watch the materialization with
kubectl -n {{ .Release.Namespace }} get onepassworditem {{ .Release.Name }}-{{ .Chart.Name }}-secret
kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-{{ .Chart.Name }}-secret
WHY THIS VALUE IS REQUIRED RATHER THAN OPTIONAL: ConvertX signs its session
cookies with JWT_SECRET, and when the variable is unset it quietly generates
a random one PER PROCESS. Every restart — a node drain, an image update, an
OOM kill — would then invalidate every session, with no log line and no
error. Rotating the field has the same effect on purpose; after a rotation:
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
Editing the value INSIDE the item does not roll the pod by itself (Helm
never sees a secret's content). Pointing secrets.jwtSecretRef at a DIFFERENT
item does — the pod template carries a checksum over the rendered
OnePasswordItem.
TWO SWITCHES THAT OPEN THE INSTANCE UP
convertx.accountRegistration {{ $registration }}
convertx.allowUnauthenticated {{ $unauthenticated }}
Upstream warns explicitly against enabling either on an instance reachable
from the internet, and this chart puts a public DNS name in front by
default. accountRegistration lets anyone with the URL create an account;
allowUnauthenticated removes the login entirely, so anyone who reaches the
Service can upload files and read every conversion on the volume.
{{- if or (eq $registration "true") (eq $unauthenticated "true") }}
*** {{ if and (eq $registration "true") (eq $unauthenticated "true") }}BOTH OF THEM ARE ON{{ else }}ONE OF THEM IS ON{{ end }} IN THIS RELEASE. ***
{{- if eq $unauthenticated "true" }}
convertx.allowUnauthenticated: true — there is no login. Anyone who can
reach {{ if .Values.ingress.enabled }}https://{{ .Values.ingress.domain }}{{ else }}the Service{{ end }} can use it and read other people's files.
{{- end }}
{{- if eq $registration "true" }}
convertx.accountRegistration: true — anyone with the URL can sign up.
{{- end }}
If this instance is reachable from the internet, that is almost certainly
not what you wanted.
{{- end }}
RETENTION IS A VALUE, AND IT IS THE ONLY THING THAT EMPTIES THE VOLUME
{{- if eq $autoDelete "0" }}
convertx.autoDeleteEveryNHours is 0 — THE CLEANUP IS OFF. Uploads and
converted output accumulate on the PVC for as long as the release lives and
nothing reclaims them. Watch the volume, or set a non-zero number of hours.
{{- else }}
convertx.autoDeleteEveryNHours is {{ $autoDelete }} — ConvertX deletes the uploads and the
output of any job older than {{ $autoDelete }} hours. That is a hard delete, not an
archive: a conversion nobody downloaded in time is gone. Set it to 0 to
switch the cleanup off, and then size storage.amount for unbounded growth.
{{- end }}
STORAGE
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, {{ .Values.storage.amount }}, {{ if .Values.storage.storageClass }}storage class "{{ .Values.storage.storageClass }}"{{ else }}on the cluster's default storage class
(storage.storageClass is unset){{ end }}, mounted at /app/data.
Everything this release persists is on that one volume: the SQLite database
(users, jobs, file names), every uploaded file and every converted result.
There is no external database and no object storage, so one snapshot of the
PVC is a complete backup — and losing it loses the accounts along with the
files.
Because the PVC is rendered by the chart, helm uninstall deletes it and the
data with it, unless the storage class reclaim policy says otherwise.
{{- if gt (int $replicas) 1 }}
convertx.replicas is {{ $replicas }}, and that does not work. The PVC is ReadWriteOnce, so
the extra pods normally stay Pending on "Multi-Attach error for volume"; on
a genuinely RWX storage class they would start and then corrupt the SQLite
database two processes are now writing. Set it back to 1.
{{- end }}
{{- if .Values.ingress.enabled }}
THE INGRESS IS THE ONLY UPLOAD CEILING
ingress.maxUploadFileSize {{ .Values.ingress.maxUploadFileSize }}
ConvertX enforces no size limit of its own — none, at any setting — so this
annotation is the whole story. A file above it never reaches the
application: the user gets the ingress controller's 413 page and nothing
appears in the pod log, which makes it look like a broken upload button
rather than a limit.
Raising it has a second cost: the upload lands on the PVC above, so a
ceiling far beyond storage.amount just moves the failure from a 413 to a
full volume.
{{- else }}
UPLOAD CEILING — NONE FROM THIS CHART
ingress.enabled is false, so the chart sets no body-size limit anywhere, and
ConvertX enforces none of its own. Whatever proxy you put in front brings
its ceiling with it — ingress-nginx defaults to 1m, small enough to look
like a broken upload button.
{{- end }}
WHAT A GREEN POD PROVES
All three probes are HTTP GETs against /healthcheck, which ConvertX answers
with a static 200 and no authentication. It touches neither the database nor
the volume, so a green pod proves the process is serving — not that a
conversion works and not that the volume is writable.
It is deliberately NOT "/": with the defaults of this chart "/" answers 302,
to /setup while no account exists and to /login afterwards. A probe would
pass on that redirect, and pass on a different one from day two onwards.
The startup probe grants {{ $startup.failureThreshold }} × {{ $startup.periodSeconds }}s = {{ mul $startup.failureThreshold $startup.periodSeconds }}s. That budget is generous because
ConvertX shells out to about twenty converter binaries at startup just to
log their versions.
THE FIRST PULL IS SLOW, AND THAT IS NORMAL
The image is roughly 1.4 GiB compressed — LibreOffice, Calibre, ImageMagick,
ffmpeg, Inkscape and a TeX Live installation are all inside it. On a node
that has not seen it before, expect several minutes in ContainerCreating
before the startup probe budget above even begins. A pod that looks stuck
there is usually still pulling:
kubectl -n {{ .Release.Namespace }} describe pod {{ .Release.Name }}-{{ .Chart.Name }}-sfs-0
THIS CHART BYPASSES THE IMAGE ENTRYPOINT
The image runs as root and drops privileges itself; this chart starts the
application directly (command: bun run dist/src/index.js) as uid 1000, with
fsGroup doing what the entrypoint's chown would have done. PUID, PGID and
UMASK therefore configure NOTHING here — setting them through convertx.env
has no effect at all, and the uid belongs in convertx.securityContext.pod.
The cost to know about: if upstream moves real work into that entrypoint,
this chart skips it silently. Re-read the upstream entrypoint.sh at every
app-version update.
{{- if .Values.convertx.env }}
EXTRA ENVIRONMENT VARIABLES ARE ACTIVE
convertx.env adds {{ len .Values.convertx.env }} variable(s), rendered after the ones the chart sets
itself. Two warnings that go with that block:
- WEBROOT moves every route of the application, /healthcheck included, so
it breaks all three probes while the app is perfectly healthy — and this
chart's Ingress still maps "/". Do not set it here.
- An entry repeating a name the chart already sets is additive, not a
replacement. At install yours wins; on an EXISTING release Kubernetes
merges env by name and the chart's own value survives instead, while helm
upgrade still reports success. Read it back off the StatefulSet.
{{- end }}
{{- if .Values.ingress.enabled }}
{{- with .Values.ingress.annotations }}
EXTRA INGRESS ANNOTATIONS ARE ACTIVE
{{- range $key, $value := . }}
{{ $key }}: {{ $value }}
{{- end }}
These are merged with the cert-manager and proxy-body-size annotations the
chart always sets. Annotations are interpreted by the ingress controller, so
a misspelled key is silently ignored — it does not fail the render.
{{- end }}
{{- end }}