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
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.
225 lines
12 KiB
Plaintext
225 lines
12 KiB
Plaintext
{{- $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 }}
|