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.
202 lines
11 KiB
YAML
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: {}
|