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.
308 lines
15 KiB
YAML
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: {}
|