sure: new chart for the we-promise Sure app (1.0.0+up0.7.4)
One StatefulSet with two containers - the Puma web process and a Sidekiq worker - sharing one ReadWriteOnce PVC at /rails/storage. That shared volume is the reason they live in one pod: splitting them would need an RWX volume. Only the web container migrates, because the image entrypoint runs db:prepare solely for the './bin/rails server' command; the worker is started through args: so the entrypoint (and its jemalloc preload) stays in place and the two can never race. Probes are chosen for what they prove: liveness and startup use Rails' /up, which is a boot check and touches no database, while readiness uses '/', which runs User.count through authenticate_user! in self-hosted mode and therefore fails while Postgres is unreachable. The worker speaks no HTTP, so all three of its probes ask sidekiqmon whether this pod still holds a Sidekiq heartbeat in Redis. Three OnePasswordItem CRs (app, database, redis), ingress.domain kept required outside the Ingress because APP_DOMAIN and the WebAuthn relying party read it, strict values.schema.json, CI fixtures with throwaway Postgres 16 and Redis, and a Renovate manager on semver so the -alpha/-rc/-hotfix channel and the moving tags are never selected.
This commit is contained in:
1 parent
819d7b2cf7
commit
93fdb3eaea
21 files changed
+1565
-3
No files matched your search
@@ -0,0 +1,165 @@
|
||||
{{- $replicas := include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) -}}
|
||||
{{- $webStartup := fromYaml (include "sure.webStartupProbe" .Values.sure.web.startupProbe) -}}
|
||||
{{- $workerStartup := fromYaml (include "sure.workerStartupProbe" .Values.sure.worker.startupProbe) -}}
|
||||
{{- $workerLiveness := fromYaml (include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe) -}}
|
||||
Sure {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
ACCESS
|
||||
https://{{ .Values.ingress.domain }}
|
||||
|
||||
The certificate is requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}"
|
||||
cluster issuer. Until it has been issued the browser shows a warning; follow it with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
|
||||
The first account created on a fresh instance becomes the super admin — sign
|
||||
in yourself before handing the URL to anyone.
|
||||
{{- else }}
|
||||
|
||||
ACCESS — NO INGRESS
|
||||
ingress.enabled is false, so this release has no Ingress and no certificate.
|
||||
ingress.domain is still required: it becomes APP_DOMAIN (the host in every
|
||||
mail link) and the WebAuthn relying party the app binds passkeys to. Reach
|
||||
the instance inside the cluster with
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ .Release.Name }}-{{ .Chart.Name }}-service 8080:80
|
||||
|
||||
and open http://localhost:8080. With the Ingress off the chart also renders
|
||||
RAILS_ASSUME_SSL and RAILS_FORCE_SSL as "false", which is what makes that
|
||||
plain-HTTP session able to log in at all; mail links still point at
|
||||
https://{{ .Values.ingress.domain }}/.
|
||||
{{- end }}
|
||||
{{ if eq $replicas "0" }}
|
||||
SCALED TO ZERO
|
||||
{{ if .Values.scaleDown }}scaleDown: true{{ else }}sure.replicas: 0{{ end }} — the StatefulSet runs with replicas: 0, so neither the web nor
|
||||
the worker process is running. PVC, Secrets, Service{{ if .Values.ingress.enabled }}, Ingress and the
|
||||
certificate stay{{ else }} stay{{ end }} in place; set it back and upgrade to bring both containers up.
|
||||
{{ end }}
|
||||
WHAT THE THREE 1PASSWORD ITEMS MUST CONTAIN
|
||||
The chart creates OnePasswordItem CRs; the operator turns each into a
|
||||
Kubernetes Secret whose keys are the FIELD NAMES of the 1Password item. A
|
||||
missing field does not fail the install — the release goes through and the
|
||||
pod then hangs in CreateContainerConfigError naming the key it could not
|
||||
find (kubectl -n {{ .Release.Namespace }} describe pod).
|
||||
|
||||
secrets.appSecretRef → {{ .Values.secrets.appSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-secret, field:
|
||||
secret-key-base SECRET_KEY_BASE, 64 random bytes hex
|
||||
(openssl rand -hex 64)
|
||||
|
||||
secrets.dbSecretRef → {{ .Values.secrets.dbSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-db-secret, fields:
|
||||
host DB_HOST, the PostgreSQL hostname
|
||||
port DB_PORT, usually 5432
|
||||
database POSTGRES_DB
|
||||
username POSTGRES_USER
|
||||
password POSTGRES_PASSWORD
|
||||
|
||||
secrets.redisSecretRef → {{ .Values.secrets.redisSecretRef }}
|
||||
Secret {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret, field:
|
||||
connection-url REDIS_URL, e.g. redis://:password@host:6379/0
|
||||
|
||||
ROTATING SECRET_KEY_BASE DESTROYS MORE THAN SESSIONS
|
||||
In self-hosted mode Sure derives its Active Record encryption keys from
|
||||
SECRET_KEY_BASE (config/initializers/active_record_encryption.rb: three
|
||||
SHA-256 digests over that one value) unless
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY and its two companions are set. Every
|
||||
encrypted column in the database — provider tokens, OAuth credentials, MFA
|
||||
secrets — is therefore readable only with the SECRET_KEY_BASE it was written
|
||||
under. Replacing that value does not just log everyone out: it makes the
|
||||
existing encrypted data undecryptable, and no rotation of the item brings it
|
||||
back. Treat this field as permanent for the life of the database, or pin the
|
||||
three encryption keys explicitly through sure.web.env / sure.worker.env
|
||||
before the first start.
|
||||
|
||||
CHANGING A SECRET'S CONTENT RESTARTS NOTHING
|
||||
Helm never sees the content of a OnePasswordItem, so it cannot hash it. The
|
||||
pod template carries checksum/secret-ref annotations over the rendered
|
||||
OnePasswordItems, so pointing a *SecretRef at a DIFFERENT item rolls the
|
||||
pod — but editing a value INSIDE the same item does not: the operator
|
||||
updates the Kubernetes Secret while both containers keep the old value in
|
||||
their environment. After such a change:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} rollout restart statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
|
||||
The operator's auto-restart annotation would not help here and is
|
||||
deliberately not set: it restarts Deployments only, and this is a
|
||||
StatefulSet.
|
||||
|
||||
POSTGRES AND REDIS ARE NOT PART OF THIS CHART
|
||||
Both must exist and be reachable before the first start. Redis is not a
|
||||
cache the app can do without — it holds the Sidekiq queues, the Rails cache
|
||||
store and the rate limiter.
|
||||
|
||||
What the probes do with that, per container:
|
||||
web liveness and startup use /up, which is Rails' boot check and
|
||||
touches no database — a Postgres outage therefore does not restart
|
||||
the pod. Readiness uses "/", which runs a User.count query, so the
|
||||
pod leaves the Service while the database is gone and returns on
|
||||
its own afterwards.
|
||||
worker all three probes run "sidekiqmon processes" and look for this
|
||||
pod's own entry, i.e. a live Sidekiq heartbeat in Redis. There is
|
||||
no HTTP and no dependency-free alternative: with Redis gone the
|
||||
worker has nothing to do, and liveness only restarts after
|
||||
{{ $workerLiveness.failureThreshold }} x {{ $workerLiveness.periodSeconds }}s = {{ mul $workerLiveness.failureThreshold $workerLiveness.periodSeconds }}s of missing heartbeat.
|
||||
|
||||
A pod that never turns Ready is usually a connection problem, not a chart
|
||||
problem: read the container logs, not the pod events.
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} logs statefulset/{{ .Release.Name }}-{{ .Chart.Name }}-sfs -c {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||
|
||||
THE FIRST START MIGRATES, AND THE STARTUP PROBE IS THE CLOCK
|
||||
The image entrypoint runs "./bin/rails db:prepare" — but only when the
|
||||
command is "./bin/rails server". That is the web container, and only the web
|
||||
container: the worker runs "bundle exec sidekiq" and never migrates, so the
|
||||
two can never race. Puma binds the port after the migration, which means the
|
||||
whole schema creation happens inside the startup probe's budget:
|
||||
|
||||
web {{ $webStartup.failureThreshold }} x {{ $webStartup.periodSeconds }}s = {{ mul $webStartup.failureThreshold $webStartup.periodSeconds }}s
|
||||
worker {{ $workerStartup.failureThreshold }} x {{ $workerStartup.periodSeconds }}s = {{ mul $workerStartup.failureThreshold $workerStartup.periodSeconds }}s (it waits for the schema the web container creates)
|
||||
|
||||
Raise sure.web.startupProbe.failureThreshold BEFORE a large app-version jump
|
||||
or a first import, not after the crash loop has started — a restart lands in
|
||||
the middle of a migration. Back up the database before any app-version bump:
|
||||
migrations are one-way, and a helm rollback returns the old image to a
|
||||
database the new one has already rewritten.
|
||||
|
||||
ONE POD, ONE VOLUME, TWO CONTAINERS
|
||||
Uploads, imports and exports live on the ReadWriteOnce PVC
|
||||
{{ .Release.Name }}-{{ .Chart.Name }}-pvc, mounted at /rails/storage in both containers. That is
|
||||
the reason web and worker share a pod at all: a background job writes the
|
||||
same files the web process serves, and one RWO volume cannot be attached to
|
||||
two pods.
|
||||
{{- if gt (int $replicas) 1 }}
|
||||
|
||||
sure.replicas is {{ $replicas }}. Expect the extra pods to stay Pending on
|
||||
"Multi-Attach error for volume" unless the storage class is genuinely RWX.
|
||||
{{- end }}
|
||||
|
||||
Backup is two things at the same time: the database holds the accounts and
|
||||
one row per attachment, the PVC holds the bytes. Restoring one without the
|
||||
other gives an app full of broken references. Because the PVC is rendered by
|
||||
the chart, helm uninstall deletes it — the external database survives that,
|
||||
which makes an accidental uninstall look recoverable while the files are
|
||||
already gone.
|
||||
|
||||
THE CHART CONFIGURES NO MAIL AND NO AI
|
||||
Sure sends invitations and notifications over SMTP and can run an assistant
|
||||
against an OpenAI-compatible endpoint. This chart sets neither: no SMTP_*
|
||||
variable, no OPENAI_* variable. Mails are therefore quietly never sent, and
|
||||
the AI features stay off — nothing in the UI says so.
|
||||
|
||||
Both are plain environment variables, so they go into sure.web.env and, for
|
||||
anything a background job needs (the assistant runs in the worker), into
|
||||
sure.worker.env as well. The secretKeyRef form is available for the API key
|
||||
and the SMTP password. Note that upstream sizes AI usage at 1 GB of memory
|
||||
per process; the chart's computed memory limit is 3x its request.
|
||||
{{- if or .Values.sure.web.env .Values.sure.worker.env }}
|
||||
|
||||
Currently set: {{ len .Values.sure.web.env }} extra variable(s) on the web container,
|
||||
{{ len .Values.sure.worker.env }} on the worker, rendered after the ones the chart sets itself.
|
||||
An entry repeating a name the chart already sets wins at install time and is
|
||||
silently lost on the next helm upgrade — read it back off the StatefulSet.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,431 @@
|
||||
{{/*
|
||||
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" <string|number> "factor" <float64>.
|
||||
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" <dict> "given" <dict|nil> "path" <string, optional
|
||||
values path used in error messages>. 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" <dict|nil> "key" <string> "path" <string>.
|
||||
Returns YAML of dict "value" <the sub-block, may be nil>, 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.webReadinessProbe" -}}
|
||||
{{- $defaults := dict
|
||||
"httpGet" (dict "path" "/" "port" "http")
|
||||
"periodSeconds" 10
|
||||
"timeoutSeconds" 5
|
||||
"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" "10Mi") -}}
|
||||
{{- $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" "10Mi") -}}
|
||||
{{- $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"
|
||||
- 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" <root context> "env" <the container's env list>.
|
||||
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 -}}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the database must be provided (secrets.dbSecretRef)" .Values.secrets.dbSecretRef }}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-redis-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for Redis must be provided (secrets.redisSecretRef)" .Values.secrets.redisSecretRef }}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
rules:
|
||||
- host: {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
port:
|
||||
name: http
|
||||
path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ required "A Domain/Host must be provided (ingress.domain)" .Values.ingress.domain }}
|
||||
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.storage.amount }}
|
||||
{{- if .Values.storage.storageClass }}
|
||||
storageClassName: {{ .Values.storage.storageClass }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: onepassword.com/v1
|
||||
kind: OnePasswordItem
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-secret
|
||||
spec:
|
||||
itemPath: {{ required "A Secret-Reference for the app secret must be provided (secrets.appSecretRef)" .Values.secrets.appSecretRef }}
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
spec:
|
||||
selector:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,161 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
||||
replicas: {{ include "sure.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.sure.replicas) }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
template:
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
labels:
|
||||
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
||||
annotations:
|
||||
# The chart has no ConfigMap, so there is no checksum/config here. What
|
||||
# these annotations cover is the SOURCE of the secrets, not their
|
||||
# content: the Kubernetes Secrets are materialized by the 1Password
|
||||
# operator, so Helm never sees what is in them. Repointing one of the
|
||||
# secrets.*SecretRef values at a different item therefore changes the
|
||||
# pod template and rolls the pod, instead of silently leaving the old
|
||||
# credentials in the running container. A change of an item's CONTENT
|
||||
# inside the vault is not covered - see the comment on secrets in
|
||||
# values.yaml for why that gap cannot be closed here.
|
||||
# The name differs from checksum/config on purpose - that one promises
|
||||
# "the config changed", this one only "the config source changed".
|
||||
checksum/secret-ref: {{ include (print $.Template.BasePath "/sure.secret.yaml") . | sha256sum }}
|
||||
checksum/secret-ref-db: {{ include (print $.Template.BasePath "/sure-db.secret.yaml") . | sha256sum }}
|
||||
checksum/secret-ref-redis: {{ include (print $.Template.BasePath "/sure-redis.secret.yaml") . | sha256sum }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "sure.podSecurityContext" .Values.sure.securityContext | nindent 8 }}
|
||||
containers:
|
||||
# The web process. Its command is the image's default
|
||||
# (CMD ["./bin/rails", "server"]), which is exactly what makes the
|
||||
# entrypoint run "./bin/rails db:prepare" first - the migration only
|
||||
# happens for that command, so it happens here and nowhere else.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-web
|
||||
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||
{{- include "sure.env" (dict "root" $ "env" .Values.sure.web.env) | nindent 12 }}
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
# Probes, resources and both security contexts go through the
|
||||
# null-safe helpers (#99): an erased block means "chart defaults
|
||||
# apply" instead of a vanished probe or an unhardened container.
|
||||
# Never wrap these in an "if" - a guard hides the erased case
|
||||
# completely instead of rendering a visible null.
|
||||
livenessProbe:
|
||||
{{- include "sure.webLivenessProbe" .Values.sure.web.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "sure.webReadinessProbe" .Values.sure.web.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "sure.webStartupProbe" .Values.sure.web.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "sure.webResources" .Values.sure.web.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.web.securityContext "path" "sure.web.securityContext") | nindent 12 }}
|
||||
volumeMounts:
|
||||
# Active Storage's local disk service, i.e. every uploaded file and
|
||||
# import, lives here (config/storage.yml: Rails.root.join("storage")).
|
||||
- name: storage
|
||||
mountPath: /rails/storage
|
||||
# Writable paths, so readOnlyRootFilesystem: true can stay on.
|
||||
# Each container gets its own emptyDirs: nothing has to be shared
|
||||
# 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.
|
||||
- name: web-tmp
|
||||
mountPath: /tmp
|
||||
# /rails/tmp: Rails' own scratch directory - the Bootsnap cache,
|
||||
# Puma's pidfile when PIDFILE is set, and the combined CA bundle the
|
||||
# 00_ssl initializer writes when SSL_CA_FILE is given. Note the
|
||||
# trade-off, read off the Dockerfile: the image ships a Bootsnap
|
||||
# cache precompiled at build time under this path, and mounting an
|
||||
# emptyDir over it hides that cache, so every pod start recompiles
|
||||
# it. Boot time cost, not correctness; the alternative is a
|
||||
# writable image layer, which the hardening standard rules out.
|
||||
- name: web-rails-tmp
|
||||
mountPath: /rails/tmp
|
||||
# /rails/log: production logs go to STDOUT (config/environments/
|
||||
# production.rb), so under normal operation nothing is written here
|
||||
# at all. It stays writable because Rails' default logger path is
|
||||
# log/<env>.log and any rake or console path that falls back to it
|
||||
# would otherwise hit a read-only filesystem. Not measured.
|
||||
- name: web-rails-log
|
||||
mountPath: /rails/log
|
||||
# The Sidekiq process. Same image, different command - and because that
|
||||
# command is NOT "./bin/rails server", the image entrypoint skips
|
||||
# db:prepare here, so the two containers never migrate against each
|
||||
# other.
|
||||
#
|
||||
# args, NOT command, and that distinction is load-bearing: Kubernetes'
|
||||
# "command" replaces the image's ENTRYPOINT, which here is
|
||||
# /rails/bin/docker-entrypoint - the script that preloads jemalloc.
|
||||
# Upstream counts jemalloc among the three things that make the
|
||||
# documented memory figures hold, so replacing the entrypoint would
|
||||
# silently give this container a different memory profile than the one
|
||||
# its requests are sized for. "args" replaces only the CMD, which is
|
||||
# what upstream's compose file does too.
|
||||
- name: {{ .Release.Name }}-{{ .Chart.Name }}-worker
|
||||
image: "ghcr.io/we-promise/sure:{{ .Chart.AppVersion }}"
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- bundle
|
||||
- exec
|
||||
- sidekiq
|
||||
env:
|
||||
{{- include "sure.commonEnv" . | nindent 12 }}
|
||||
{{- include "sure.env" (dict "root" $ "env" .Values.sure.worker.env) | nindent 12 }}
|
||||
livenessProbe:
|
||||
{{- include "sure.workerLivenessProbe" .Values.sure.worker.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- include "sure.workerReadinessProbe" .Values.sure.worker.readinessProbe | nindent 12 }}
|
||||
startupProbe:
|
||||
{{- include "sure.workerStartupProbe" .Values.sure.worker.startupProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- include "sure.workerResources" .Values.sure.worker.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- include "sure.containerSecurityContext" (dict "block" .Values.sure.worker.securityContext "path" "sure.worker.securityContext") | nindent 12 }}
|
||||
volumeMounts:
|
||||
# The worker reads and writes the same Active Storage files as the
|
||||
# web process (imports are processed as background jobs), which is
|
||||
# why upstream's compose file mounts one volume into both and why
|
||||
# both containers live in one pod here: a single ReadWriteOnce PVC
|
||||
# cannot be attached to two pods.
|
||||
- name: storage
|
||||
mountPath: /rails/storage
|
||||
- name: worker-tmp
|
||||
mountPath: /tmp
|
||||
- name: worker-rails-tmp
|
||||
mountPath: /rails/tmp
|
||||
- name: worker-rails-log
|
||||
mountPath: /rails/log
|
||||
restartPolicy: Always
|
||||
volumes:
|
||||
- name: storage
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Release.Name }}-{{ .Chart.Name }}-pvc
|
||||
- name: web-tmp
|
||||
emptyDir: {}
|
||||
- name: web-rails-tmp
|
||||
emptyDir: {}
|
||||
- name: web-rails-log
|
||||
emptyDir: {}
|
||||
- name: worker-tmp
|
||||
emptyDir: {}
|
||||
- name: worker-rails-tmp
|
||||
emptyDir: {}
|
||||
- name: worker-rails-log
|
||||
emptyDir: {}
|
||||
Reference in new issue
Block a user