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: {}