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.
This commit is contained in:
jklein authored and Justin Klein committed 2026-09-26 21:08:55 +00:00
1 parent 93fdb3eaea
commit a414af089d
3 files changed
+89 -6

No files matched your search

+7
View File
@@ -336,6 +336,13 @@ probe on "/" reach the database at all (see values.yaml).
value: "true" value: "true"
- name: RAILS_ENV - name: RAILS_ENV
value: "production" 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 - name: PORT
value: "3000" value: "3000"
# Sure's production.rb defaults BOTH of these to true. The chart renders them # Sure's production.rb defaults BOTH of these to true. The chart renders them
+70 -2
View File
@@ -34,6 +34,71 @@ spec:
automountServiceAccountToken: false automountServiceAccountToken: false
securityContext: securityContext:
{{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }} {{- 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: containers:
# The web process. Its command is the image's default # The web process. Its command is the image's default
# (CMD ["./bin/rails", "server"]), which is exactly what makes the # (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 # between the two processes, and a shared scratch directory would
# only create coupling nobody asked for. # only create coupling nobody asked for.
# #
# /tmp: Rack writes multipart uploads (CSV imports, attachments) to # /tmp: Rack writes multipart uploads (CSV imports, attachments)
# Dir.tmpdir before Active Storage ever sees them. # 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 - name: web-tmp
mountPath: /tmp mountPath: /tmp
# /rails/tmp: Rails' own scratch directory - the Bootsnap cache, # /rails/tmp: Rails' own scratch directory - the Bootsnap cache,
+12 -4
View File
@@ -36,10 +36,18 @@ sure:
# need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on # need a ReadWriteMany volume. Above 1 the extra pods normally stay Pending on
# "Multi-Attach error for volume" - see NOTES.txt. # "Multi-Attach error for volume" - see NOTES.txt.
replicas: 1 replicas: 1
# Pod-level hardening, shared by both containers. The upstream image declares # Pod-level hardening, shared by both containers and by the init container.
# USER 1000:1000 (group and user "rails", created in the Dockerfile), so the # The upstream image declares USER 1000:1000 (group and user "rails", created
# chart needs no deviation from the repo hardening standard; fsGroup 1000 # in the Dockerfile), so the chart needs no deviation from the repo hardening
# makes the storage PVC writable for that uid. # 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: securityContext:
pod: pod:
runAsNonRoot: true runAsNonRoot: true