Files
helm-charts/convertx/templates/convertx.sfs.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

202 lines
11 KiB
YAML

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
replicas: {{ include "convertx.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.convertx.replicas) }}
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
annotations:
# Hash of the rendered OnePasswordItem, so that repointing
# secrets.jwtSecretRef at a DIFFERENT vault item really rolls the pod
# instead of leaving the old signing key in the running environment.
# A change of the item's CONTENT inside the vault is not covered -
# Helm never sees it; see the comment on secrets in values.yaml.
# The name differs from checksum/config on purpose: that one promises
# "the config changed", this one only "the config source changed".
#
# operator.1password.io/auto-restart is deliberately NOT set: the
# 1Password operator only restarts Deployments, so on a StatefulSet it
# is a silent no-op that advertises a guarantee it does not give.
checksum/secret-ref: {{ include (print $.Template.BasePath "/convertx.secret.yaml") . | sha256sum }}
spec:
automountServiceAccountToken: false
securityContext:
{{- include "convertx.podSecurityContext" .Values.convertx.securityContext | nindent 8 }}
containers:
- name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
image: "ghcr.io/c4illin/convertx:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
# THE IMAGE ENTRYPOINT IS BYPASSED ON PURPOSE. READ THIS BEFORE THE
# NEXT APP-VERSION BUMP - IT IS THE ONE BET THIS CHART MAKES.
#
# ghcr.io/c4illin/convertx declares
# ENTRYPOINT ["/entrypoint.sh"]
# CMD ["bun", "run", "dist/src/index.js"]
# and carries no USER, so the image runs as root by default and
# /entrypoint.sh is what drops it to uid 1000 with gosu. Setting
# "command" here replaces the ENTRYPOINT (and discards the CMD), so
# the application is started directly and Kubernetes does the
# privilege work instead: runAsUser/runAsGroup 1000 in the pod
# security context, and fsGroup 1000 in place of the entrypoint's
# recursive chown of /app/data. The hardening baseline of this repo
# stays intact - no root, no capabilities, read-only root fs.
#
# What the entrypoint of v0.19.0 actually does, read rather than
# assumed: nothing but uid/gid plumbing. No migration, no config
# generation, no secret generation. ConvertX creates and migrates
# its SQLite schema in-process at import time, so starting it
# directly is migration-safe. Its very first branch is
# if [ -z "$PUID" ] || [ "$PUID" -eq 0 ]; then ... exec "$@"
# i.e. with PUID unset it is already a pure pass-through - which is
# why bypassing it costs nothing TODAY and is exactly why the risk
# below is about tomorrow. The one line we do lose is
# "export HOME=/home/convertx", and this chart sets HOME itself
# below rather than relying on the runtime's passwd lookup.
#
# THE RISK: if upstream ever moves real work into the entrypoint - a
# data migration is the obvious candidate - this chart skips it
# silently. There is no error, no log line and no failed probe; the
# application simply starts against an unmigrated volume. So
# RE-READ entrypoint.sh AT EVERY APP-VERSION UPDATE, not only on
# major jumps, and if it has grown anything beyond uid/gid handling,
# stop and reconsider this override.
command:
- bun
- run
- dist/src/index.js
# Not decoration. The application resolves its data paths relative
# to the working directory - uploadsDir is "./data/uploads/" and
# outputDir is "./data/output/", hardcoded and configurable by no
# environment variable. The image sets WORKDIR /app, so this only
# restates it; it is here because a different working directory
# would put every upload somewhere other than the mounted PVC, and
# the application would report no error at all.
workingDir: /app
env:
# The entrypoint would have exported this; the image declares no
# ENV HOME, so without this line HOME comes from the container
# runtime's /etc/passwd lookup for uid 1000. That happens to
# resolve to /home/convertx in this image - and "happens to" is
# not a basis for the directory LibreOffice, Calibre, Inkscape and
# fontconfig write their profiles into on the first conversion.
# Set explicitly, and mounted writable below.
- name: HOME
value: "/home/convertx"
# The port the application binds. containerPort, the Service's
# targetPort and the three probes all name the same 3000; moving
# it means moving all four, which is why it is pinned here rather
# than exposed as a value.
- name: PORT
value: "3000"
# Signs the session cookies. Without it ConvertX falls back to
# randomUUID() per process, so every restart logs everyone out -
# see secrets.jwtSecretRef in values.yaml.
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
key: jwt-secret
# The three booleans go through convertx.boolString, which renders
# the literal "true"/"false" the application's strict comparison
# needs and aborts the render on anything else.
- name: ACCOUNT_REGISTRATION
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.accountRegistration "default" false "path" "convertx.accountRegistration") }}"
- name: ALLOW_UNAUTHENTICATED
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.allowUnauthenticated "default" false "path" "convertx.allowUnauthenticated") }}"
- name: HTTP_ALLOWED
value: "{{ include "convertx.boolString" (dict "value" .Values.convertx.httpAllowed "default" false "path" "convertx.httpAllowed") }}"
- name: AUTO_DELETE_EVERY_N_HOURS
value: "{{ include "convertx.autoDeleteEveryNHours" .Values.convertx.autoDeleteEveryNHours }}"
{{- range $value := .Values.convertx.env }}
{{- if $value.value }}
- name: {{ $value.name | quote }}
# 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.
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 }}
ports:
- containerPort: 3000
protocol: TCP
name: http
# Probes, resources and both security contexts go through the
# null-safe helpers (#99): an erased block means "chart defaults
# apply" instead of a vanished probe or an unhardened container.
# Never wrap these in an "if" - a guard hides the erased case
# completely instead of rendering a visible null.
livenessProbe:
{{- include "convertx.livenessProbe" .Values.convertx.livenessProbe | nindent 12 }}
readinessProbe:
{{- include "convertx.readinessProbe" .Values.convertx.readinessProbe | nindent 12 }}
startupProbe:
{{- include "convertx.startupProbe" .Values.convertx.startupProbe | nindent 12 }}
resources:
{{- include "convertx.effectiveResources" .Values.convertx.resources | nindent 12 }}
securityContext:
{{- include "convertx.containerSecurityContext" .Values.convertx.securityContext | nindent 12 }}
volumeMounts:
# SQLite database, uploads and converted output - everything the
# application persists lives under this one path.
- mountPath: /app/data
name: data
# Two writable emptyDirs so the root filesystem can stay
# read-only. Neither is a precaution: ConvertX itself writes only
# under /app/data, but it runs its conversions as subprocesses
# (soffice, ebook-convert, magick, gs, xelatex, ffmpeg...) that
# inherit its environment, and those need scratch space.
#
# /home/convertx is HOME. LibreOffice creates its user profile
# there on first use - it is invoked without
# -env:UserInstallation, so there is no way to point it elsewhere
# - and Calibre, Inkscape, ImageMagick and fontconfig do the same
# with their own config and cache directories. The directory
# exists in the image (useradd -m) and is owned by 1000:1000, but
# it sits on the read-only root filesystem, so it has to be
# overmounted to be usable.
- mountPath: /home/convertx
name: home
# /tmp is the scratch directory of the toolchain: Ghostscript,
# TeX Live and Calibre all use it during a conversion.
#
# Both of these count against the container's ephemeral-storage
# limit - see the resources comment in values.yaml.
- mountPath: /tmp
name: tmp
restartPolicy: Always
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
- name: home
emptyDir: {}
- name: tmp
emptyDir: {}