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