{{- $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) -}} {{- $httpRoute := .Values.httpRoute | default dict -}} {{- $hostnames := $httpRoute.hostnames | default list -}} {{- if not $hostnames -}} {{- $hostnames = list .Values.ingress.domain -}} {{- end -}} Sure {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. {{- if .Values.ingress.enabled }} ACCESS — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false) 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 if $httpRoute.enabled }} ACCESS — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false) {{- range $hostnames }} https://{{ . }} {{- end }} The first account created on a fresh instance becomes the super admin — sign in yourself before handing the URL to anyone. This release renders an HTTPRoute ({{ .Release.Name }}-{{ .Chart.Name }}-httproute) and NOTHING else of the routing path — no Gateway, no class, no Certificate. Three things therefore belong to the cluster and not to this chart: 1. The Gateway-API CRDs. Without them this install would have failed. 2. The Gateway named in httpRoute.parentRefs {{- range $httpRoute.parentRefs }} {{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }} {{- end }} must exist AND its listener must allow routes from this namespace ({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows up only in the route's status: kubectl -n {{ .Release.Namespace }} get httproute {{ .Release.Name }}-{{ .Chart.Name }}-httproute -o yaml Read status.parents[].conditions — "Accepted: True" and "ResolvedRefs: True" is a working attachment. An empty status means no controller has looked at the route at all. 3. The TLS certificate for the hostname, on the Gateway's listener. This is the one to get right here: the chart renders RAILS_ASSUME_SSL and RAILS_FORCE_SSL as "true" in this mode, i.e. Rails TRUSTS the forwarded scheme, sets secure cookies and sends HSTS. A Gateway listener that is plain HTTP would therefore hand out cookies the browser never returns. ingress.clusterIssuer is NOT used and not required — the chart renders no Certificate, so there is no tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress secret. APP_DOMAIN, WEBAUTHN_RP_ID and WEBAUTHN_ALLOWED_ORIGINS still follow ingress.domain ({{ .Values.ingress.domain }}), not httpRoute.hostnames. Passkeys are bound to the relying party id, so publishing under a name that ingress.domain does not carry gives you passkeys registered against the wrong party — and changing ingress.domain afterwards invalidates every credential already registered. ingress.annotations is inert in this mode: the Gateway API has no annotation channel. Nothing this chart needs lives there. {{- else }} ACCESS — NEITHER INGRESS NOR HTTPROUTE ingress.enabled and httpRoute.enabled are both false, so this release has no Ingress, no HTTPRoute 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 nothing published 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 if $httpRoute.enabled }}, and the HTTPRoute 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 }} {{- if .Values.networkPolicy.enabled }} NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods. Inbound: TCP 3000 only, declared by the web container. Outbound: DNS, TCP 5432 (Postgres), 6379 (Redis) and 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }} — and those egress rules apply to the WORKER container as well, because a NetworkPolicy selects pods and not containers. 5432 AND 6379 ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES: the Postgres host and port are fields of the 1Password item, passed through without being read. A dependency elsewhere is blocked; add its port to networkPolicy.egress.extraPorts. Three limits belong in the same breath, because none of them is visible in the object itself: - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and selectors and cannot express a hostname, so every egress rule permits its port to ANY address. That is a limit of the object, not a shortcut — this cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An outbound port the chart does not know about is BLOCKED: add it to networkPolicy.egress.extraPorts, and expect to find out through an application-level connection error rather than through this object. {{- if .Values.networkPolicy.probeSources }} - THE PROBE PATH IS RESTRICTED in this release, because networkPolicy.probeSources is set to {{ join ", " .Values.networkPolicy.probeSources }} Probes come from the IP of the WORKER NODE running the pod. If none of those CIDRs covers the node this pod lands on, the kubelet's probes are dropped and THE POD NEVER BECOMES READY — the rollout then times out and the symptom looks like a broken application. Compare kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs against `kubectl get node -o wide`. {{- else }} - THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is empty, so that rule carries no source selector at all and any pod the CNI routes to can reach it. Not from the internet — but this is NOT pod-level isolation. Narrowing it means listing the worker node IPs in networkPolicy.probeSources, because the kubelet probes from the node's own address and the chart cannot know it; a wrong guess there leaves the pod permanently un-Ready. {{- end }} - IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not, this object is accepted and filters nothing, so everything reads as configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy` shows the object exists; only a connection attempt from an unrelated namespace shows that it bites. Switch it off with networkPolicy.enabled: false. {{- end }}