{{/* Render the body of a container "resources:" block from a values dict of the form resources: limitFactor: 3 # optional, default 3 requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... } limits: { ... } # optional, every entry optional Rules (variant B, see issue #19): - requests are rendered as given (camelCase "ephemeralStorage" is normalized to the Kubernetes key "ephemeral-storage"). - an explicitly set limit always wins. - a missing memory / ephemeral-storage limit is computed as limitFactor x the corresponding request. - a missing cpu limit is NOT set at all (the kubelet then treats CPU as unlimited; CPU is compressible, so this cannot kill the workload). NOTE: this helper is copied into every chart's templates/_helpers.tpl with the chart's name as define prefix rather than shared through a library chart; the reference implementation is template-chart/templates/_helpers.tpl. */}} {{- define "sure.resources" -}} {{- $resources := . | default dict -}} {{- $factor := 3.0 -}} {{- if not (kindIs "invalid" $resources.limitFactor) -}} {{- $factor = float64 $resources.limitFactor -}} {{- end -}} {{- $requests := dict -}} {{- range $key, $value := ($resources.requests | default dict) -}} {{- if not (kindIs "invalid" $value) -}} {{- $_ := set $requests (include "sure.resourceKey" $key) $value -}} {{- end -}} {{- end -}} {{- $limits := dict -}} {{- range $key, $value := ($resources.limits | default dict) -}} {{- if not (kindIs "invalid" $value) -}} {{- $_ := set $limits (include "sure.resourceKey" $key) $value -}} {{- end -}} {{- end -}} {{- range $key, $request := $requests -}} {{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}} {{- $_ := set $limits $key (include "sure.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}} {{- end -}} {{- end -}} {{- $out := dict -}} {{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}} {{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}} {{- toYaml $out -}} {{- end -}} {{/* Normalize a resource name from values to its Kubernetes key. */}} {{- define "sure.resourceKey" -}} {{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}} {{- end -}} {{/* Multiply a Kubernetes quantity by a factor, keeping its suffix. Input: dict "quantity" "factor" . Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary ("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer results are printed without a decimal point. */}} {{- define "sure.multiplyQuantity" -}} {{- $quantity := include "sure.formatNumber" .quantity -}} {{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} {{- if not $number -}} {{- fail (printf "cannot parse resource quantity %q" $quantity) -}} {{- end -}} {{- $suffix := trimPrefix $number $quantity -}} {{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}} {{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}} {{- end -}} {{- $product := mulf (float64 $number) .factor -}} {{- if eq $product (floor $product) -}} {{- printf "%d%s" (int64 $product) $suffix -}} {{- else -}} {{- printf "%s%s" (include "sure.formatNumber" $product) $suffix -}} {{- end -}} {{- end -}} {{/* Render a number as a plain decimal string, never in exponential notation. Why this exists (issue #157): the null-safe fallback helpers pass values through fromYaml, which parses every number as float64. Go's "%v" switches float64 to exponential notation from about 1e6 upwards, so a bare byte count like 1048576 became "1.048576e+06" - not a valid Kubernetes quantity, and the render aborted. Strings ("512Mi") are passed through untouched. */}} {{- define "sure.formatNumber" -}} {{- if kindIs "float64" . -}} {{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} {{- else -}} {{- printf "%v" . -}} {{- end -}} {{- end -}} {{/* Render the replica count for the StatefulSet. Input: dict "scaleDown" the chart-global .Values.scaleDown flag (may be nil) "replicas" the workload's replicas value (may be nil) Rules (issue #25): - scaleDown: true takes precedence and always yields 0. - otherwise an explicitly set replicas value is used as given, including an explicit 0 - Helm's "default" would swallow 0, hence the explicit kindIs "invalid" nil check. - a missing replicas value defaults to 1. */}} {{- define "sure.replicas" -}} {{- if .scaleDown -}} 0 {{- else if not (kindIs "invalid" .replicas) -}} {{- .replicas -}} {{- else -}} 1 {{- end -}} {{- end -}} {{/* Merge a user-supplied values sub-tree onto a dict of chart defaults so that a null / empty override falls back to the defaults instead of erasing them. Input: dict "defaults" "given" "path" . Output: YAML of the merged dict (consume it with fromYaml). Why this exists (issues #61 and #99): Helm merges values maps recursively, but an override key written without children - e.g. a leftover securityContext: after deleting its last remaining sub-key - is YAML null. Where the chart defines a default, Helm treats that null as a deletion of the default; the template then renders "securityContext: null": no readOnlyRootFilesystem, no dropped capabilities, no allowPrivilegeEscalation: false. The workload starts and reports healthy while being unhardened, so nothing points at the mistake. The same shape erases a probe (it disappears) or the resources block (no requests, no computed limits). This helper defines the opposite semantics: an empty block means "chart defaults apply". */}} {{- define "sure.defaultedDict" -}} {{- $defaults := .defaults | default dict -}} {{- $given := .given -}} {{- $path := .path | default "value" -}} {{- if kindIs "invalid" $given -}} {{- $given = dict -}} {{- end -}} {{- if not (kindIs "map" $given) -}} {{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} {{- end -}} {{- $out := deepCopy $defaults -}} {{- range $key, $value := $given -}} {{- if not (kindIs "invalid" $value) -}} {{- $default := index $defaults $key -}} {{- if and (kindIs "map" $value) (kindIs "map" $default) -}} {{- $_ := set $out $key (fromYaml (include "sure.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} {{- else if kindIs "map" $default -}} {{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} {{- else -}} {{- $_ := set $out $key $value -}} {{- end -}} {{- end -}} {{- end -}} {{- toYaml $out -}} {{- end -}} {{/* Pick one sub-block out of a values block that may itself be null. Input: dict "block" "key" "path" . Returns YAML of dict "value" , after rejecting a non-mapping block with a message that names the values path. Needed because ".Values.sure.web.securityContext" can itself be erased, and indexing into nil inside a template is not an error but silently yields nothing. */}} {{- define "sure.subBlock" -}} {{- $block := .block -}} {{- if kindIs "invalid" $block -}} {{- $block = dict -}} {{- end -}} {{- if not (kindIs "map" $block) -}} {{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} {{- end -}} {{- toYaml (dict "value" (index $block .key)) -}} {{- end -}} {{/* The effective security contexts, probes and resources (issue #99). The defaults below MUST stay in sync with the corresponding blocks in values.yaml, which stays the documented, user-facing place for them; this copy is only the fallback for the case where an override has erased the block. Every reason behind a value is written down there, including what each probe actually proves - a probe default without its path, port name or exec command would leave the workload with no working probe at all (README, guideline 12.5). */}} {{- define "sure.podSecurityContext" -}} {{- $given := (fromYaml (include "sure.subBlock" (dict "block" . "key" "pod" "path" "sure.securityContext"))).value -}} {{- $defaults := dict "runAsNonRoot" true "runAsUser" 1000 "runAsGroup" 1000 "fsGroup" 1000 "fsGroupChangePolicy" "Always" "seccompProfile" (dict "type" "RuntimeDefault") -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" "sure.securityContext.pod") -}} {{- end -}} {{- define "sure.containerSecurityContext" -}} {{- $given := (fromYaml (include "sure.subBlock" (dict "block" .block "key" "container" "path" .path))).value -}} {{- $defaults := dict "allowPrivilegeEscalation" false "readOnlyRootFilesystem" true "runAsNonRoot" true "capabilities" (dict "drop" (list "ALL")) -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" $given "path" (printf "%s.container" .path)) -}} {{- end -}} {{/* Web container probes. /up is Rails' boot check and touches neither Postgres nor Redis, so liveness and startup use it; readiness uses "/", which runs User.count through authenticate_user! in self-hosted mode and therefore fails while the database is unreachable. The long startup budget covers the "./bin/rails db:prepare" the entrypoint runs before Puma binds the port. */}} {{- define "sure.webLivenessProbe" -}} {{- $defaults := dict "httpGet" (dict "path" "/up" "port" "http") "periodSeconds" 10 "timeoutSeconds" 5 "failureThreshold" 3 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.livenessProbe") -}} {{- end -}} {{- define "sure.webReadinessCommand" -}} {{- list "/bin/bash" "-c" "code=\"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3000/)\"; test \"$code\" -ge 200 -a \"$code\" -lt 400" | toJson -}} {{- end -}} {{- define "sure.webReadinessProbe" -}} {{- $defaults := dict "exec" (dict "command" (include "sure.webReadinessCommand" . | fromJsonArray)) "periodSeconds" 10 "timeoutSeconds" 10 "failureThreshold" 3 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.readinessProbe") -}} {{- end -}} {{- define "sure.webStartupProbe" -}} {{- $defaults := dict "httpGet" (dict "path" "/up" "port" "http") "periodSeconds" 10 "timeoutSeconds" 5 "failureThreshold" 60 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.startupProbe") -}} {{- end -}} {{/* Worker container probes. Sidekiq serves no HTTP, so all three ask its own monitor whether THIS process still holds a heartbeat in Redis; the exec command is part of the default, not decoration (README, guideline 12.5). */}} {{- define "sure.workerProbeCommand" -}} {{- list "/bin/bash" "-c" "bundle exec sidekiqmon processes | grep -q \"^${HOSTNAME}:\"" | toJson -}} {{- end -}} {{- define "sure.workerLivenessProbe" -}} {{- $defaults := dict "exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray)) "periodSeconds" 60 "timeoutSeconds" 15 "failureThreshold" 5 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.livenessProbe") -}} {{- end -}} {{- define "sure.workerReadinessProbe" -}} {{- $defaults := dict "exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray)) "periodSeconds" 30 "timeoutSeconds" 15 "failureThreshold" 3 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.readinessProbe") -}} {{- end -}} {{- define "sure.workerStartupProbe" -}} {{- $defaults := dict "exec" (dict "command" (include "sure.workerProbeCommand" . | fromJsonArray)) "periodSeconds" 20 "timeoutSeconds" 15 "failureThreshold" 30 -}} {{- include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.startupProbe") -}} {{- end -}} {{/* The effective resources blocks, fed into the resources helper above so that an erased "resources:" still yields the chart's requests and computed limits instead of a container without any resource accounting. Both containers run the same image and carry the same request; see values.yaml for where the number comes from. */}} {{- define "sure.webResources" -}} {{- $defaults := dict "limitFactor" 3 "requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}} {{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.web.resources")) -}} {{- include "sure.resources" $merged -}} {{- end -}} {{- define "sure.workerResources" -}} {{- $defaults := dict "limitFactor" 3 "requests" (dict "cpu" 0.1 "memory" "512Mi" "ephemeral-storage" "256Mi") -}} {{- $merged := fromYaml (include "sure.defaultedDict" (dict "defaults" $defaults "given" . "path" "sure.worker.resources")) -}} {{- include "sure.resources" $merged -}} {{- end -}} {{/* The environment both containers share: the same image, the same database, the same Redis, the same secrets. Only the command differs. Input: the root context. ingress.domain is read HERE, outside the Ingress template, three times over (APP_DOMAIN, WEBAUTHN_RP_ID, WEBAUTHN_ALLOWED_ORIGINS), so it carries its own required() and stays mandatory when ingress.enabled is false - README, guideline 12.1. An empty value would not fail the render: it would register passkeys against an empty relying party and build mail links without a host. SELF_HOSTED=true is not optional for this chart: it is what turns Sure into the single-family, no-subscription mode, and it is also what makes the readiness probe on "/" reach the database at all (see values.yaml). */}} {{- define "sure.commonEnv" -}} {{- $domain := required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain -}} - name: SELF_HOSTED 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" # The image's home directory (/home/rails) is part of the read-only root # filesystem, and Bundler reacts to that by creating a throwaway home under # TMPDIR on EVERY invocation ("`/home/rails` is not writable. Bundler will use # `/tmp/ruby/bundler2026...` as your home directory temporarily", measured in # the install-test). That is one directory per `bundle exec` - including every # worker probe, every 30 seconds. Pointing HOME at a writable directory turns # that into one stable path. - name: HOME value: "/tmp/home" - name: PORT value: "3000" # Sure's production.rb defaults BOTH of these to true. The chart renders them # explicitly from ingress.enabled instead: with the Ingress terminating TLS, # assume_ssl makes Rails treat the forwarded request as secure (so force_ssl # adds HSTS and secure cookies without redirecting), while without an Ingress # both have to be off - a port-forwarded plain-HTTP session would otherwise # either be redirected to https or hand out cookies the browser never returns. - name: RAILS_ASSUME_SSL value: "{{ .Values.ingress.enabled }}" - name: RAILS_FORCE_SSL value: "{{ .Values.ingress.enabled }}" # Action Mailer builds absolute URLs from this host, and it is the fallback the # WebAuthn initializer uses when WEBAUTHN_RP_ID is unset. - name: APP_DOMAIN value: {{ $domain | quote }} # Passkeys are bound to the relying party id; changing it later invalidates # every registered credential (upstream docs/hosting/webauthn.md). - name: WEBAUTHN_RP_ID value: {{ $domain | quote }} - name: WEBAUTHN_ALLOWED_ORIGINS value: {{ printf "https://%s" $domain | quote }} - name: SECRET_KEY_BASE valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-secret key: secret-key-base - name: DB_HOST valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: host - name: DB_PORT valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: port - name: POSTGRES_DB valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: database - name: POSTGRES_USER valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: username - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret key: password - name: REDIS_URL valueFrom: secretKeyRef: name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret key: connection-url {{- end -}} {{/* The additive env block of one container (README, guideline 10). Input: dict "root" "env" . Entries are rendered through tpl, so a value may contain Helm templating, and the value, secretKeyRef and configMapKeyRef forms are supported. */}} {{- define "sure.env" -}} {{- $root := .root -}} {{- range $value := .env }} {{- if $value.value }} - name: {{ $value.name | quote }} # quote is required, not cosmetic: a value like "3" or "true" renders # unquoted as a YAML number/bool, and the API server then rejects the whole # manifest with "cannot unmarshal number into Go struct field EnvVar.value of # type string". value: {{ tpl $value.value $root | quote }} {{- else if $value.valueFrom }} {{- if $value.valueFrom.secretKeyRef }} - name: {{ $value.name }} valueFrom: secretKeyRef: name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }} key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }} {{- else if $value.valueFrom.configMapKeyRef }} - name: {{ $value.name }} valueFrom: configMapKeyRef: name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }} key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }} {{- end }} {{- end }} {{- end }} {{- end -}}