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"
- 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
+70 -2
View File
@@ -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,