Files
helm-charts/convertx/values.yaml
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

308 lines
15 KiB
YAML

# Scale the whole chart down without uninstalling it: scaleDown: true renders
# every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and
# takes precedence over any per-workload replicas value. PVC, Secret, Service
# and Ingress stay in place; scaleDown: false brings the workload back up.
scaleDown: false
secrets:
# op:// item path for the JWT signing key. REQUIRED - the chart aborts the
# render without it, and that is deliberate rather than convenient.
#
# ConvertX signs its session cookies with JWT_SECRET and falls back to
# randomUUID() when the variable is unset (src/services/user.ts:
# "secret: process.env.JWT_SECRET ?? randomUUID()"). That fallback is
# per PROCESS, not per install: every restart - a node drain, an image
# update, an OOM kill - mints a fresh secret and silently invalidates every
# session. Nothing logs it; users simply find themselves logged out. A value
# that has to survive a restart is a secret, so it is one here.
#
# The item must carry this field, spelled exactly like this, because the
# 1Password operator copies an item's FIELD NAMES into the Secret's keys:
#
# jwt-secret a long random string (openssl rand -hex 32)
#
# A field spelled differently does not fail the install: the operator writes
# a Secret without that key and the pod stays in CreateContainerConfigError,
# which looks like a broken chart rather than a typo in a vault item.
jwtSecretRef: null
convertx:
# Replica count for this workload; 0 is allowed and scales it down.
# The chart-global scaleDown flag takes precedence.
#
# Raising this above 1 does not work and the chart does not stop you, so the
# reason is here: the SQLite database, the uploads and the converted output
# all live on the single ReadWriteOnce PVC below, which is a plain chart
# object rather than a volumeClaimTemplate. A second pod normally never
# schedules ("Multi-Attach error for volume"); on a genuinely RWX storage
# class it would schedule and then corrupt the WAL-mode SQLite file that two
# processes are now writing. On top of that each pod would sign cookies with
# its own JWT_SECRET only if the secret were unset - which is exactly why
# secrets.jwtSecretRef above is required.
replicas: 1
# --- Two switches that open the instance up. Both default to false. ---
#
# UPSTREAM WARNS EXPLICITLY AGAINST ENABLING EITHER ON AN INTERNET-FACING
# INSTANCE, and this chart puts an Ingress with a public DNS name in front
# by default, so that warning applies to the standard install here.
#
# Both are parsed strictly by the application: it compares the lowercased
# string against "true" and treats everything else as false. The chart
# therefore accepts only a boolean or the strings "true"/"false" (any case)
# and ABORTS THE RENDER on anything else - "yes" or "1" would otherwise be
# filed under "off" without a word, which is the wrong direction to fail in
# for a value whose whole job is to open the service up.
# Lets anyone with the URL create an account. Note what it does NOT gate:
# the FIRST account is always creatable, because a ConvertX with no users
# serves a setup form to whoever opens the page first. Keeping this false
# protects accounts two and onwards, not account one.
accountRegistration: false
# Drops authentication entirely - anyone reaching the Service can upload
# files and read every conversion. Upstream describes this as local-only.
allowUnauthenticated: false
# Clears the "secure" flag on the session cookies (the app computes
# "secure: !HTTP_ALLOWED"). Only needed when ConvertX is served over plain
# http, and that is NOT our case: the Ingress terminates TLS, so the browser
# speaks https and sets the cookie happily while only the in-cluster hop
# between ingress controller and pod is http - which the browser never sees.
# Turning this on behind our Ingress weakens the cookies for no gain.
# (Same shape as outline's forceHttps, and the same reason to leave it off.)
httpAllowed: false
# How often ConvertX deletes jobs - uploads and converted output - older
# than this many hours. 0 disables the cleanup entirely.
#
# This is the retention policy for the PVC below, which is why it is a value
# and not left at the upstream default. With cleanup disabled the volume
# grows with every conversion and nothing reclaims it; size storage.amount
# for that case, or accept that the pod eventually fills its volume.
#
# An explicit 0 is honoured (the chart uses a nil check, not Helm's
# "default", which would swallow the 0 and silently restore 24-hour
# deletion on a value set precisely to switch it off).
autoDeleteEveryNHours: 24
# DOCUMENTED DEVIATION FROM THE HARDENING BASELINE - runAsUser/runAsGroup.
#
# The deviation is about the NUMBER only: nothing in the baseline is
# relaxed. The repo default is an arbitrary uid (20000); this chart pins
# 1000, and the reason is the image, not a preference.
#
# ghcr.io/c4illin/convertx carries NO USER instruction - verified in the
# published v0.19.0 image config, where "User" is empty - so without a
# securityContext the pod would run as root. Its Dockerfile creates
# "convertx" with uid 1000, gid 1000 and home /home/convertx (useradd -u
# 1000 -g convertx -d /home/convertx -m), and the image's entrypoint exists
# only to drop to that user with gosu.
#
# 1000 rather than 20000 for two reasons that both have to hold:
# - /home/convertx belongs to 1000:1000 in the image, and HOME is where
# LibreOffice, Calibre and fontconfig put their profiles. An arbitrary
# uid gets a HOME of "/" from the runtime's passwd lookup instead.
# - the uid owns data on a PersistentVolume, so changing it later makes
# existing conversions inaccessible. This number is not free to tidy.
securityContext:
pod:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
# fsGroup is what replaces the entrypoint's recursive chown of
# /app/data: the kubelet applies the group to the mounted volume, so
# the container can write it as uid 1000 without any privileged step.
fsGroup: 1000
fsGroupChangePolicy: Always
# RuntimeDefault applies the container runtime's default seccomp filter,
# which blocks the syscalls a normal workload never makes. It sits in the
# pod context on purpose: from here it covers every container in the pod
# including init containers, while a container-level seccompProfile would
# have to be repeated for each one (and silently missed on the next one
# added). A container that genuinely needs a different filter can still
# override it under securityContext.container, which wins over this value.
seccompProfile:
type: RuntimeDefault
container:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
add: []
# ConvertX serves GET /healthcheck: a static 200 with {"status":"ok"},
# explicitly exempted from authentication (src/pages/healthcheck.tsx,
# "auth: false"). All three probes use it.
#
# Why NOT "/", the obvious default: with the chart defaults "/" is never a
# 200. It answers 302 to /setup while no user exists and 302 to /login
# afterwards, so a probe against it would pass on a redirect whose target
# changes the moment somebody registers - a green signal that means
# something different on day two than on day one. httpGet does count 3xx as
# success, which is what makes that the quiet kind of wrong.
#
# What the endpoint proves, and what it does not: the handler touches
# neither the database nor the volume, so it cannot report a broken PVC.
# It is still the right readiness target here, because the SQLite database
# is opened at module import - before app.listen() is reached - so a process
# that answers /healthcheck at all has already opened its database. There is
# no external dependency in this chart to hang readiness on; the "database"
# is a file on the pod's own volume.
#
# WEBROOT SHIFTS THIS PATH. The application mounts every route under the
# WEBROOT prefix, /healthcheck included. This chart does not offer WEBROOT
# as a value (see the env block below), and setting it through env without
# also moving these three paths leaves the pod probing a 404 forever.
livenessProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness, so those need no
# initialDelaySeconds. Budget = periodSeconds * failureThreshold = 300s.
#
# Generous on purpose. At import time - under NODE_ENV=production, which
# the image sets - ConvertX shells out to roughly twenty converter binaries
# to log their versions (soffice, ebook-convert, xelatex, magick, ffmpeg,
# markitdown and the rest). Those are fire-and-forget and do not block
# app.listen(), but they all contend for the pod's CPU at exactly the moment
# the first probe arrives, and the first soffice/ebook-convert run also
# builds its profile under HOME. A tight budget turns a slow node into a
# crash loop.
startupProbe:
httpGet:
path: /healthcheck
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
# Extra environment variables, appended to the ones the chart always sets.
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
# forms are supported.
#
# This is where the upstream variables this chart does not expose as values
# belong - BRANDING, LANGUAGE, HIDE_HISTORY, MAX_CONVERT_PROCESS,
# UNAUTHENTICATED_USER_SHARING, TZ, FFMPEG_ARGS, FFMPEG_OUTPUT_ARGS. They
# change the UI or a converter's behaviour and nothing this chart renders,
# so they need no value of their own.
#
# THREE VARIABLES DO NOT BELONG HERE, and they fail in three different ways:
#
# WEBROOT moves EVERY route, /healthcheck included, so the three
# probes above start failing against a 404 while the app is
# perfectly healthy - and this chart's Ingress still maps "/".
# Serving under a sub-path needs the probe paths and the
# Ingress path changed together; the chart does not do that
# for you.
# PUID/PGID are read by the image entrypoint, which this chart bypasses
# /UMASK (see the command: override in the StatefulSet). They reach
# nothing and configure nothing here. Set the uid through
# securityContext.pod above, which is what actually applies.
# PORT the app would listen on it, but containerPort, the Service
# and the probes all still name 3000. Moving the port means
# moving all four, so the chart pins it instead.
#
# Additive means additive: an entry repeating a name the chart already sets
# does not replace it. At install the later entry - yours - wins; on an
# EXISTING release 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 rather than trusting a green upgrade.
env: []
resources:
# Limits are optional: memory and ephemeral-storage limits default to
# limitFactor x the request, an explicitly set limit always wins, and a
# CPU limit is only set when configured explicitly under "limits".
#
# The numbers are sized for conversions, not for an idle web server, and
# each one has a reason:
#
# cpu 0.25 An idle Bun process uses almost nothing, but a
# conversion saturates whatever it can get and the startup
# fires ~20 version probes at once. Per repo convention no
# CPU LIMIT is computed, so this is the scheduling floor
# and the throttling share under contention, not a cap -
# a conversion may burst well past it on an idle node.
#
# memory 1Gi soffice --headless is a few hundred MB on its own,
# ebook-convert pulls in Qt WebEngine, and ImageMagick on
# a large raster can want more than either. 1Gi requested,
# 3Gi limit (limitFactor 3). Memory is NOT compressible:
# a conversion that crosses the limit is an OOM kill of
# the whole pod, so raise the request before feeding the
# instance large video or raw-image work.
#
# ephemeral- 1Gi, and this one is easy to get wrong: EMPTYDIR VOLUMES
# storage COUNT AGAINST IT. The chart mounts two (/tmp and
# /home/convertx) for the converter toolchain, and
# Ghostscript, TeX and Calibre all scratch to /tmp. The
# uploads and the converted output do NOT count - those
# live on the PVC - so the figure covers scratch space
# only. Crossing the 3Gi limit evicts the pod.
limitFactor: 3
requests:
cpu: 0.25
memory: 1Gi
ephemeralStorage: 1Gi
# The volume behind /app/data: the SQLite database, every uploaded file and
# every converted result. There is no second store and no object storage.
#
# Not null-safe on purpose, following the repo's documented exception: an
# erased block yields a PVC without a size, which the API server rejects at
# apply time. That is louder than a volume quietly sized by the chart.
storage:
amount: 10Gi
storageClass: null
ingress:
# Gates the whole Ingress template. Checked in both directions as the style
# guide requires: NOTHING outside the Ingress reads ingress.domain in this
# chart - ConvertX has no base-URL, public-URL or redirect-URI variable at
# all (verified against the complete env list in src/helpers/env.ts) - so
# switching the Ingress off frees BOTH domain and clusterIssuer here.
enabled: true
# THE ONLY UPLOAD CEILING THERE IS.
#
# ConvertX sets no size limit of its own: it starts Elysia with
# maxRequestBodySize: Number.MAX_SAFE_INTEGER, and v0.19.0 has no upload
# variable of any kind. So unlike outline - where an app limit and an
# ingress limit sat a factor of ten apart and the smaller one won
# confusingly - there is exactly one number here, and it is this one.
#
# Two consequences worth knowing before changing it:
# - A file above this limit never reaches ConvertX. The user gets the
# ingress controller's 413 page, not a message from the application,
# and nothing appears in the pod log.
# - With ingress.enabled: false there is NO ceiling from this chart at
# all, and whatever proxy you put in front brings its own - usually
# ingress-nginx's 1m default, which is small enough to look like a
# broken upload button.
#
# Defaulted generously because the point of the application is converting
# video and large documents. What it costs: the body transits the ingress
# controller and then lands on the PVC, so raising it means checking
# storage.amount as well.
maxUploadFileSize: 1024m
clusterIssuer: null
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer and proxy-body-size annotations the chart always sets.
annotations: {}