From e92286b4208347747ce5c860c3981c44188363a8 Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Tue, 22 Sep 2026 20:19:16 +0000 Subject: [PATCH] sure: give Ruby a temporary directory it accepts The first install-test run failed with both containers crash-looping during boot: system temporary path is world-writable: /tmp /tmp is world-writable: /tmp . is not writable: /rails ArgumentError: could not find a temporary directory raised from MiniMagick, which Active Storage loads while Rails boots. Ruby's Dir.tmpdir skips a candidate that is world-writable without a sticky bit, and a Kubernetes emptyDir is created mode 0777 with no sticky bit - so with a read-only root filesystem there was no candidate left. A tiny init container, the app's own image running as the same non-root uid and with no capability added, now creates one private subdirectory (mode 0700) inside each tmp emptyDir, and both containers run with TMPDIR pointing at it. Creating an entry in a 0777 directory and chmod'ing what one just created needs no privilege, so the hardening standard holds unchanged. --- sure/templates/_helpers.tpl | 7 ++++ sure/templates/sure.sfs.yaml | 72 +++++++++++++++++++++++++++++++++++- sure/values.yaml | 16 ++++++-- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/sure/templates/_helpers.tpl b/sure/templates/_helpers.tpl index 86387fb..a86ea0a 100644 --- a/sure/templates/_helpers.tpl +++ b/sure/templates/_helpers.tpl @@ -336,6 +336,13 @@ probe on "/" reach the database at all (see values.yaml). value: "true" - name: RAILS_ENV value: "production" +# Not cosmetic and not optional: /tmp is an emptyDir, which Kubernetes creates +# mode 0777 without a sticky bit, and Ruby skips exactly that shape when it +# looks for a temporary directory. Without this the app aborts during boot with +# "could not find a temporary directory". The directory is created by the +# init container in the StatefulSet, which explains the whole case. +- name: TMPDIR + value: "/tmp/ruby" - name: PORT value: "3000" # Sure's production.rb defaults BOTH of these to true. The chart renders them diff --git a/sure/templates/sure.sfs.yaml b/sure/templates/sure.sfs.yaml index 8d668a3..27ba61f 100644 --- a/sure/templates/sure.sfs.yaml +++ b/sure/templates/sure.sfs.yaml @@ -34,6 +34,71 @@ spec: automountServiceAccountToken: false securityContext: {{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }} + initContainers: + # Ruby refuses a world-writable directory that has no sticky bit as its + # temporary directory (ruby 3.4, tmpdir.rb: a candidate is skipped when + # "world_writable? && !sticky?"), and a Kubernetes emptyDir is created + # mode 0777 WITHOUT the sticky bit. With /tmp mounted as an emptyDir and + # the image's root filesystem read-only, Ruby then finds no candidate at + # all and both containers die during boot - measured in this chart's + # first install-test, which logged + # + # system temporary path is world-writable: /tmp + # /tmp is world-writable: /tmp + # . is not writable: /rails + # ArgumentError: could not find a temporary directory + # + # raised from MiniMagick, which Active Storage loads while Rails boots. + # Note where it does NOT come from: nothing in the app asked for a temp + # file yet. + # + # The fix is a directory that is writable without being world-writable. + # This init container creates one per app container inside the same + # emptyDirs (mode 0700, owned by the pod's uid) and both containers get + # TMPDIR pointed at it. It runs as the same non-root uid as the app and + # needs no capability at all: the emptyDir root is 0777, so creating an + # entry in it is allowed, and chmod on a directory one has just created + # needs no privilege. That is deliberately weaker than the chown-as-root + # init container paperless-ngx needs. + # + # The image is the app's own, so there is no second image to pin, no + # Renovate block to maintain for it and nothing extra to pull. + - name: {{ .Release.Name }}-{{ .Chart.Name }}-init-tmp + image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + # command, not args: the image entrypoint would start the application. + command: + - /bin/bash + - -ec + - | + for dir in /web-tmp/ruby /worker-tmp/ruby; do + mkdir -p "$dir" + chmod 700 "$dir" + done + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL + volumeMounts: + - name: web-tmp + mountPath: /web-tmp + - name: worker-tmp + mountPath: /worker-tmp + # Fixed, deliberately small: this container runs two mkdirs and two + # chmods. Not values-configurable, for the same reason as the init + # container in paperless-ngx - there is nothing here to tune. + resources: + requests: + cpu: 10m + memory: 32Mi + ephemeral-storage: 1Mi + limits: + cpu: 100m + memory: 64Mi + ephemeral-storage: 10Mi containers: # The web process. Its command is the image's default # (CMD ["./bin/rails", "server"]), which is exactly what makes the @@ -74,8 +139,11 @@ spec: # between the two processes, and a shared scratch directory would # only create coupling nobody asked for. # - # /tmp: Rack writes multipart uploads (CSV imports, attachments) to - # Dir.tmpdir before Active Storage ever sees them. + # /tmp: Rack writes multipart uploads (CSV imports, attachments) + # here before Active Storage ever sees them. The app does not use + # this path directly, though - TMPDIR points at the private + # subdirectory the init container creates inside it, because Ruby + # rejects a world-writable emptyDir as a temporary directory. - name: web-tmp mountPath: /tmp # /rails/tmp: Rails' own scratch directory - the Bootsnap cache, diff --git a/sure/values.yaml b/sure/values.yaml index b9d7271..b8eae6b 100644 --- a/sure/values.yaml +++ b/sure/values.yaml @@ -36,10 +36,18 @@ sure: # need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on # "Multi-Attach error for volume" - see NOTES.txt. replicas: 1 - # Pod-level hardening, shared by both containers. The upstream image declares - # USER 1000:1000 (group and user "rails", created in the Dockerfile), so the - # chart needs no deviation from the repo hardening standard; fsGroup 1000 - # makes the storage PVC writable for that uid. + # Pod-level hardening, shared by both containers and by the init container. + # The upstream image declares USER 1000:1000 (group and user "rails", created + # in the Dockerfile), so the chart needs no deviation from the repo hardening + # standard; fsGroup 1000 makes the storage PVC writable for that uid. + # + # readOnlyRootFilesystem: true holds, and the writable paths are emptyDirs: + # /rails/tmp, /rails/log and /tmp, one set per container. /tmp needs one extra + # step that is easy to mistake for decoration - a tiny init container creates + # a private subdirectory in it and the containers run with TMPDIR pointing + # there, because Ruby refuses a world-writable directory without a sticky bit + # as its temporary directory and an emptyDir is exactly that. Without it the + # app aborts during boot; the StatefulSet carries the measured evidence. securityContext: pod: runAsNonRoot: true