From 5c97639c3e1fc5c7568bf893efa5eb8bfe9ba205 Mon Sep 17 00:00:00 2001 From: Pradeep Mishra Date: Thu, 13 Aug 2026 17:15:27 +0200 Subject: [PATCH] Added Helm chart for Kubernetes deployment Help to deploy Terminus to Kubernetes as a single configurable release instead of hand-applying the manifests described in the Kubernetes documentation. The chart lives at `charts/terminus` and is linted on every change, with tagged pushes packaged to `oci://ghcr.io/usetrmnl/charts`. --- .github/workflows/helm.yml | 110 ++++ README.adoc | 5 + charts/terminus/Chart.yaml | 6 + charts/terminus/ci/default-values.yaml | 11 + charts/terminus/templates/NOTES.txt | 51 ++ charts/terminus/templates/_helpers.tpl | 134 +++++ .../terminus/templates/configmap-migrate.yaml | 24 + charts/terminus/templates/configmap.yaml | 13 + charts/terminus/templates/database.yaml | 139 +++++ charts/terminus/templates/deployment-web.yaml | 199 +++++++ charts/terminus/templates/hpa.yaml | 33 ++ charts/terminus/templates/httproute.yaml | 28 + charts/terminus/templates/ingress.yaml | 31 ++ charts/terminus/templates/job-migrate.yaml | 90 ++++ charts/terminus/templates/keyvalue.yaml | 150 ++++++ charts/terminus/templates/pvc.yaml | 31 ++ charts/terminus/templates/secret-migrate.yaml | 22 + charts/terminus/templates/secret.yaml | 13 + charts/terminus/templates/service.yaml | 16 + .../templates/serviceaccount-migrate.yaml | 22 + charts/terminus/templates/serviceaccount.yaml | 12 + charts/terminus/values.yaml | 269 ++++++++++ doc/helm.adoc | 500 ++++++++++++++++++ 23 files changed, 1909 insertions(+) create mode 100644 .github/workflows/helm.yml create mode 100644 charts/terminus/Chart.yaml create mode 100644 charts/terminus/ci/default-values.yaml create mode 100644 charts/terminus/templates/NOTES.txt create mode 100644 charts/terminus/templates/_helpers.tpl create mode 100644 charts/terminus/templates/configmap-migrate.yaml create mode 100644 charts/terminus/templates/configmap.yaml create mode 100644 charts/terminus/templates/database.yaml create mode 100644 charts/terminus/templates/deployment-web.yaml create mode 100644 charts/terminus/templates/hpa.yaml create mode 100644 charts/terminus/templates/httproute.yaml create mode 100644 charts/terminus/templates/ingress.yaml create mode 100644 charts/terminus/templates/job-migrate.yaml create mode 100644 charts/terminus/templates/keyvalue.yaml create mode 100644 charts/terminus/templates/pvc.yaml create mode 100644 charts/terminus/templates/secret-migrate.yaml create mode 100644 charts/terminus/templates/secret.yaml create mode 100644 charts/terminus/templates/service.yaml create mode 100644 charts/terminus/templates/serviceaccount-migrate.yaml create mode 100644 charts/terminus/templates/serviceaccount.yaml create mode 100644 charts/terminus/values.yaml create mode 100644 doc/helm.adoc diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 000000000..bee860ca7 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,110 @@ +name: Helm + +on: + push: + branches: [main] + tags: ["*"] + paths: + - "charts/**" + - ".github/workflows/helm.yml" + pull_request: + branches: [main] + paths: + - "charts/**" + - ".github/workflows/helm.yml" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Helm Setup + uses: azure/setup-helm@v5.0.0 + + - name: Python Setup + uses: actions/setup-python@v5 + with: + python-version: ">=3.13" + + - name: Install chart-testing + env: + CT_VERSION: "3.11.0" + run: | + pip install yamllint yamale --quiet + curl -sSLo ct.tar.gz "https://github.com/helm/chart-testing/releases/download/v${CT_VERSION}/chart-testing_${CT_VERSION}_linux_amd64.tar.gz" + tar -xzf ct.tar.gz ct + sudo mv ct /usr/local/bin/ct + rm ct.tar.gz + + - name: List changed charts + id: list-changed + run: | + changed=$(ct list-changed \ + --chart-dirs charts \ + --target-branch "${{ github.event.repository.default_branch }}") + if [ -n "$changed" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Lint changed charts + if: steps.list-changed.outputs.changed == 'true' + run: | + ct lint \ + --chart-dirs charts \ + --target-branch "${{ github.event.repository.default_branch }}" + + - name: Render templates + run: | + helm template terminus charts/terminus \ + --set secrets.databaseUrl="postgres://user:pass@db:5432/terminus" \ + --set secrets.keyvalueUrl="redis://:pass@redis:6379/0" \ + --set secrets.appSecret="ci0000000000000000000000000000000000000000000000000000000000000000" \ + --set config.apiUri="https://terminus.example.com" + + release: + name: Release + runs-on: ubuntu-latest + needs: lint + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Helm Setup + uses: azure/setup-helm@v5.0.0 + + - name: Extract Version + id: version + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Registry Login + run: | + echo "${{ github.token }}" | helm registry login ghcr.io \ + --username "${{ github.actor }}" \ + --password-stdin + + - name: Update Dependencies + run: helm dependency update charts/terminus + + - name: Package Chart + run: | + helm package charts/terminus \ + --version "${{ steps.version.outputs.version }}" \ + --app-version "${{ steps.version.outputs.version }}" + + - name: Push Chart + run: | + helm push "terminus-${{ steps.version.outputs.version }}.tgz" \ + oci://ghcr.io/${{ github.repository_owner }}/charts diff --git a/README.adoc b/README.adoc index 2532f51b3..ea30e90a6 100644 --- a/README.adoc +++ b/README.adoc @@ -10,6 +10,7 @@ :doc_development_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/development.adoc[Development] :doc_docker_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/docker.adoc[Docker] :doc_extensions_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/extensions.adoc[Extensions] +:doc_helm_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/helm.adoc[Helm Chart] :doc_jobs_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/jobs.adoc[Jobs] :doc_kubernetes_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/kubernetes.adoc[Kubernetes] :doc_raspberry_pi_link: link:https://github.com/usetrmnl/terminus/blob/main/doc/raspberry_pi.adoc[Raspberry PI] @@ -223,6 +224,10 @@ See {doc_raspberry_pi_link} documentation for details. See {doc_kubernetes_link} documentation for details. +=== Helm Chart + +See {doc_helm_link} documentation for details. + === Render See {doc_render_link} documentation for details. diff --git a/charts/terminus/Chart.yaml b/charts/terminus/Chart.yaml new file mode 100644 index 000000000..2fe9250b1 --- /dev/null +++ b/charts/terminus/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: terminus +description: A Helm chart for Terminus - a self-hosted TRMNL server +type: application +version: 0.1.0 +appVersion: "latest" diff --git a/charts/terminus/ci/default-values.yaml b/charts/terminus/ci/default-values.yaml new file mode 100644 index 000000000..1c297791c --- /dev/null +++ b/charts/terminus/ci/default-values.yaml @@ -0,0 +1,11 @@ +# Values used by chart-testing (ct lint) only. +# The chart requires non-empty connection settings and an app secret, so +# linting against bare defaults would fail the validation guards in +# _helpers.tpl. These are throwaway values and are never deployed. +config: + apiUri: "https://terminus.example.com" + +secrets: + databaseUrl: "postgres://user:pass@postgres:5432/terminus" + keyvalueUrl: "redis://redis:6379/0" + appSecret: "ci0000000000000000000000000000000000000000000000000000000000000000" diff --git a/charts/terminus/templates/NOTES.txt b/charts/terminus/templates/NOTES.txt new file mode 100644 index 000000000..f2f21e09a --- /dev/null +++ b/charts/terminus/templates/NOTES.txt @@ -0,0 +1,51 @@ +Terminus has been deployed successfully. + +Web URL: +{{- if .Values.route.enabled }} + http{{ if .Values.route.host }}s{{ end }}://{{ .Values.route.host }} +{{- else if .Values.ingress.enabled }} + http{{ if .Values.ingress.tls }}s{{ end }}://{{ .Values.ingress.host }} +{{- else }} + export POD=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "terminus.name" . }},app.kubernetes.io/component=web" -o jsonpath="{.items[0].metadata.name}") + kubectl --namespace {{ .Release.Namespace }} port-forward $POD 2345:{{ .Values.config.hanamiPort }} + Visit http://127.0.0.1:2345 +{{- end }} + +Components deployed: + - Deployment {{ include "terminus.fullname" . }}-web + - web (Puma) + - worker (Sidekiq sidecar) +{{- if .Values.migrate.enabled }} + - Job {{ include "terminus.fullname" . }}-migrate (pre-install/pre-upgrade hook) +{{- end }} +{{- if .Values.database.enabled }} + - Deployment {{ include "terminus.fullname" . }}-database (bundled PostgreSQL, single instance) +{{- end }} +{{- if .Values.keyvalue.enabled }} + - Deployment {{ include "terminus.fullname" . }}-keyvalue (bundled Valkey, single instance) +{{- end }} +{{- if or .Values.database.enabled .Values.keyvalue.enabled }} + +Bundled datastores have no replication, backups or failover. Take your own +backups, and prefer managed services for production. +{{- end }} + +{{- if .Values.vault.enabled }} + +Secrets: managed by Vault Agent Injector + Path: {{ .Values.vault.secretPath }} + Role: {{ .Values.vault.role }} +{{- else }} +Secrets: stored in Kubernetes Secret + Name: {{ include "terminus.fullname" . }} +{{- end }} + +{{- if .Values.autoscaling.enabled }} + +Autoscaling: enabled (scales the pod — web + worker sidecar scale together) + Min: {{ .Values.autoscaling.minReplicas }} / Max: {{ .Values.autoscaling.maxReplicas }} +{{- end }} + +Troubleshooting: + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "terminus.fullname" . }}-web -c web + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "terminus.fullname" . }}-web -c worker diff --git a/charts/terminus/templates/_helpers.tpl b/charts/terminus/templates/_helpers.tpl new file mode 100644 index 000000000..124a2fe38 --- /dev/null +++ b/charts/terminus/templates/_helpers.tpl @@ -0,0 +1,134 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "terminus.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "terminus.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart label +*/}} +{{- define "terminus.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "terminus.labels" -}} +helm.sh/chart: {{ include "terminus.chart" . }} +{{ include "terminus.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "terminus.selectorLabels" -}} +app.kubernetes.io/name: {{ include "terminus.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name +*/}} +{{- define "terminus.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "terminus.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Connection URLs. A bundled service wins over the corresponding secrets.* value, +which is documented as ignored in that case. +*/}} +{{- define "terminus.databaseUrl" -}} +{{- if .Values.database.enabled -}} +{{- $a := .Values.database.auth -}} +{{- printf "postgres://%s:%s@%s-database:%v/%s" $a.username $a.password (include "terminus.fullname" .) .Values.database.port $a.database -}} +{{- else -}} +{{- .Values.secrets.databaseUrl -}} +{{- end -}} +{{- end }} + +{{- define "terminus.keyvalueUrl" -}} +{{- if .Values.keyvalue.enabled -}} +{{- $pass := .Values.keyvalue.auth.password -}} +{{- printf "redis://%s%s-keyvalue:%v/%s" (ternary (printf ":%s@" $pass) "" (ne $pass "")) (include "terminus.fullname" .) .Values.keyvalue.port .Values.keyvalue.database -}} +{{- else -}} +{{- .Values.secrets.keyvalueUrl -}} +{{- end -}} +{{- end }} + +{{/* +Render a map as annotations or labels. + +Values are quoted rather than passed through toYaml: the API server requires +string values, and toYaml would emit an unquoted true/false or a bare number +for anything the values file did not already quote. +*/}} +{{- define "terminus.stringMap" -}} +{{- range $key, $value := . }} +{{ $key }}: {{ $value | quote }} +{{- end }} +{{- end }} + +{{/* +Validate mutually exclusive values +*/}} +{{- define "terminus.validateValues" -}} +{{- if and .Values.ingress.enabled .Values.route.enabled }} +{{- fail "ingress.enabled and route.enabled are mutually exclusive; enable only one." }} +{{- end }} +{{- if and .Values.migrate.enabled .Values.vault.enabled (not .Values.vault.migrateInject) (not .Values.migrate.databaseUrl) }} +{{- fail "migrate.databaseUrl is required when vault.enabled and migrate.enabled; the migration job runs without the Vault Agent. Set vault.migrateInject to read it from Vault instead." }} +{{- end }} +{{- if not .Values.config.apiUri }} +{{- fail "config.apiUri is required; Hanami constrains api_uri to be non-empty and it has no default." }} +{{- end }} +{{/* +Empty env vars override the application defaults rather than falling back to +them, so an unset secret surfaces as a settings validation crash at boot. +*/}} +{{- if and .Values.vault.enabled (or .Values.database.enabled .Values.keyvalue.enabled) }} +{{- fail "database.enabled and keyvalue.enabled cannot be combined with vault.enabled; Vault supplies the connection URLs, so a bundled service has nothing to provide them to." }} +{{- end }} +{{- if and .Values.database.enabled .Values.migrate.enabled }} +{{- fail "database.enabled requires migrate.enabled=false; the migration job is a pre-install hook and runs before the bundled database exists. Migrations run at pod startup instead via config.appSetup." }} +{{- end }} +{{- if and .Values.database.enabled (not .Values.database.auth.password) }} +{{- fail "database.auth.password is required when database.enabled." }} +{{- end }} +{{- if not .Values.vault.enabled }} +{{- if not (include "terminus.databaseUrl" .) }} +{{- fail "secrets.databaseUrl is required when vault.enabled is false and database.enabled is false." }} +{{- end }} +{{- if not (include "terminus.keyvalueUrl" .) }} +{{- fail "secrets.keyvalueUrl is required when vault.enabled is false and keyvalue.enabled is false." }} +{{- end }} +{{- if lt (len .Values.secrets.appSecret) 64 }} +{{- fail "secrets.appSecret is required when vault.enabled is false and must be at least 64 characters (try: openssl rand -hex 64)." }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/terminus/templates/configmap-migrate.yaml b/charts/terminus/templates/configmap-migrate.yaml new file mode 100644 index 000000000..fdd639088 --- /dev/null +++ b/charts/terminus/templates/configmap-migrate.yaml @@ -0,0 +1,24 @@ +{{- if .Values.migrate.enabled -}} +{{/* +Hook-scoped copy of the main ConfigMap. The migrate Job runs as a pre-install +hook, which executes before ordinary release resources exist, so it cannot +reference the release-managed ConfigMap. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "terminus.fullname" . }}-migrate + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation +data: + API_URI: {{ .Values.config.apiUri | quote }} + TZ: {{ .Values.config.tz | quote }} + # The Job invokes `hanami db migrate` directly rather than going through the + # entrypoint, so the startup setup routine must not run here as well. + APP_SETUP: "false" +{{- end }} diff --git a/charts/terminus/templates/configmap.yaml b/charts/terminus/templates/configmap.yaml new file mode 100644 index 000000000..9379054f6 --- /dev/null +++ b/charts/terminus/templates/configmap.yaml @@ -0,0 +1,13 @@ +{{- include "terminus.validateValues" . }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "terminus.fullname" . }} + labels: + {{- include "terminus.labels" . | nindent 4 }} +data: + HANAMI_PORT: {{ .Values.config.hanamiPort | quote }} + API_URI: {{ .Values.config.apiUri | quote }} + APP_SETUP: {{ .Values.config.appSetup | quote }} + TZ: {{ .Values.config.tz | quote }} + RACK_ATTACK_ALLOWED_SUBNETS: {{ .Values.config.rackAttackAllowedSubnets | quote }} diff --git a/charts/terminus/templates/database.yaml b/charts/terminus/templates/database.yaml new file mode 100644 index 000000000..fd053aa59 --- /dev/null +++ b/charts/terminus/templates/database.yaml @@ -0,0 +1,139 @@ +{{- if .Values.database.enabled -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "terminus.fullname" . }}-database + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: database +type: Opaque +data: + POSTGRES_USER: {{ .Values.database.auth.username | b64enc | quote }} + POSTGRES_DB: {{ .Values.database.auth.database | b64enc | quote }} + POSTGRES_PASSWORD: {{ .Values.database.auth.password | b64enc | quote }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "terminus.fullname" . }}-database + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: database +spec: + accessModes: + - ReadWriteOnce + {{- with .Values.database.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.database.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "terminus.fullname" . }}-database + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: database +spec: + type: ClusterIP + ports: + - port: {{ .Values.database.port }} + targetPort: postgres + protocol: TCP + name: postgres + selector: + {{- include "terminus.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: database +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "terminus.fullname" . }}-database + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: database +spec: + replicas: 1 + # Recreate, not RollingUpdate: the data PVC is ReadWriteOnce and two + # postgres processes must never share one data directory. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "terminus.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: database + template: + metadata: + labels: + {{- include "terminus.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: database + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.database.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: postgres + image: "{{ .Values.database.image.repository }}:{{ .Values.database.image.tag }}" + imagePullPolicy: {{ .Values.database.image.pullPolicy }} + args: ["-p", "{{ .Values.database.port }}"] + ports: + - name: postgres + containerPort: {{ .Values.database.port }} + protocol: TCP + envFrom: + - secretRef: + name: {{ include "terminus.fullname" . }}-database + livenessProbe: + exec: + command: + - pg_isready + - --username={{ .Values.database.auth.username }} + - --dbname={{ .Values.database.auth.database }} + - --port={{ .Values.database.port }} + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + exec: + command: + - pg_isready + - --username={{ .Values.database.auth.username }} + - --dbname={{ .Values.database.auth.database }} + - --port={{ .Values.database.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + volumeMounts: + # Matches the mount path used by compose.yml. + - name: data + mountPath: /var/lib/postgresql + {{- with .Values.database.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "terminus.fullname" . }}-database + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/terminus/templates/deployment-web.yaml b/charts/terminus/templates/deployment-web.yaml new file mode 100644 index 000000000..c12a11bb4 --- /dev/null +++ b/charts/terminus/templates/deployment-web.yaml @@ -0,0 +1,199 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "terminus.fullname" . }}-web + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + # Recreate required — uploads PVC is ReadWriteOnce; RollingUpdate would + # leave the old pod holding the volume while the new pod tries to mount it. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "terminus.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: web + template: + metadata: + labels: + {{- include "terminus.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: web + {{- with .Values.podLabels }} + {{- include "terminus.stringMap" . | trim | nindent 8 }} + {{- end }} + annotations: + {{- with .Values.podAnnotations }} + {{- include "terminus.stringMap" . | trim | nindent 8 }} + {{- end }} + {{- if .Values.vault.enabled }} + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: {{ .Values.vault.role | quote }} + vault.hashicorp.com/agent-pre-populate: "false" + vault.hashicorp.com/agent-inject-status: "update" + vault.hashicorp.com/agent-run-as-user: {{ .Values.vault.agentRunAsUser | quote }} + vault.hashicorp.com/agent-inject-secret-terminus-env: {{ .Values.vault.secretPath | quote }} + vault.hashicorp.com/agent-inject-template-terminus-env: | + {{`{{- with secret "`}}{{ .Values.vault.secretPath }}{{`" -}}`}} + export DATABASE_URL="{{`{{ .Data.data.database_url }}`}}" + export KEYVALUE_URL="{{`{{ .Data.data.keyvalue_url }}`}}" + export APP_SECRET="{{`{{ .Data.data.app_secret }}`}}" + {{`{{- end }}`}} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "terminus.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- $waitDb := and .Values.database.enabled .Values.database.waitForReady }} +{{- $waitKv := and .Values.keyvalue.enabled .Values.keyvalue.waitForReady }} + {{- if or $waitDb $waitKv }} + # Kubernetes has no equivalent of compose's depends_on/service_healthy, + # and scripts/docker/entrypoint ignores the exit status of `db migrate`. + # Without these gates the app would start against an unready database + # and silently serve an unmigrated schema. + # + # Init containers cannot reach the mesh: they run before istio-proxy + # starts, so their egress is redirected to an Envoy that is not + # listening. See database.waitForReady in values.yaml. + initContainers: + {{- if $waitDb }} + - name: wait-for-database + image: "{{ .Values.database.image.repository }}:{{ .Values.database.image.tag }}" + imagePullPolicy: {{ .Values.database.image.pullPolicy }} + command: ["/bin/sh", "-c"] + args: + - >- + until pg_isready + --host={{ include "terminus.fullname" . }}-database.{{ .Release.Namespace }}.svc.cluster.local + --port={{ .Values.database.port }} + --username={{ .Values.database.auth.username }} + --dbname={{ .Values.database.auth.database }}; + do echo "waiting for database"; sleep 2; done + {{- end }} + {{- if $waitKv }} + - name: wait-for-keyvalue + image: "{{ .Values.keyvalue.image.repository }}:{{ .Values.keyvalue.image.tag }}" + imagePullPolicy: {{ .Values.keyvalue.image.pullPolicy }} + command: ["/bin/sh", "-c"] + args: + - >- + until valkey-cli + -h {{ include "terminus.fullname" . }}-keyvalue.{{ .Release.Namespace }}.svc.cluster.local + -p {{ .Values.keyvalue.port }} ping; + do echo "waiting for keyvalue"; sleep 2; done + {{- end }} + {{- end }} + containers: + # ── Web (Puma) ──────────────────────────────────────────────────────── + - name: web + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.vault.enabled }} + command: ["/bin/sh", "-c"] + args: + - ". /vault/secrets/terminus-env && exec scripts/docker/entrypoint bundle exec puma --config ./config/puma.rb" + {{- end }} + ports: + - name: http + containerPort: {{ .Values.config.hanamiPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "terminus.fullname" . }} + {{- if not .Values.vault.enabled }} + - secretRef: + name: {{ include "terminus.fullname" . }} + {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe.web }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe.web }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: uploads + mountPath: /app/public/uploads + - name: fonts + mountPath: /app/public/fonts + - name: fonts + mountPath: /usr/share/fonts/terminus + {{- with .Values.resources.web }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + + # ── Worker (Sidekiq sidecar) ────────────────────────────────────────── + - name: worker + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.vault.enabled }} + command: ["/bin/sh", "-c"] + args: + - ". /vault/secrets/terminus-env && exec bundle exec sidekiq -r ./config/sidekiq.rb" + {{- else }} + args: ["bundle", "exec", "sidekiq", "-r", "./config/sidekiq.rb"] + {{- end }} + env: + - name: APP_SETUP + value: "false" + envFrom: + - configMapRef: + name: {{ include "terminus.fullname" . }} + {{- if not .Values.vault.enabled }} + - secretRef: + name: {{ include "terminus.fullname" . }} + {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe.worker }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: uploads + mountPath: /app/public/uploads + - name: fonts + mountPath: /app/public/fonts + - name: fonts + mountPath: /usr/share/fonts/terminus + {{- with .Values.resources.worker }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + + volumes: + - name: uploads + persistentVolumeClaim: + claimName: {{ include "terminus.fullname" . }}-uploads + - name: fonts + persistentVolumeClaim: + claimName: {{ include "terminus.fullname" . }}-fonts + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/terminus/templates/hpa.yaml b/charts/terminus/templates/hpa.yaml new file mode 100644 index 000000000..5232e1279 --- /dev/null +++ b/charts/terminus/templates/hpa.yaml @@ -0,0 +1,33 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "terminus.fullname" . }}-web + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: web +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "terminus.fullname" . }}-web + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/terminus/templates/httproute.yaml b/charts/terminus/templates/httproute.yaml new file mode 100644 index 000000000..c0619601c --- /dev/null +++ b/charts/terminus/templates/httproute.yaml @@ -0,0 +1,28 @@ +{{- if .Values.route.enabled -}} +{{- $fullName := include "terminus.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include "terminus.labels" . | nindent 4 }} + {{- with .Values.route.annotations }} + annotations: + {{- include "terminus.stringMap" . | trim | nindent 4 }} + {{- end }} +spec: + parentRefs: + - name: {{ .Values.route.gateway.name }} + namespace: {{ .Values.route.gateway.namespace }} + hostnames: + - {{ .Values.route.host | quote }} + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} +{{- end }} diff --git a/charts/terminus/templates/ingress.yaml b/charts/terminus/templates/ingress.yaml new file mode 100644 index 000000000..8e0250c14 --- /dev/null +++ b/charts/terminus/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "terminus.fullname" . }} + labels: + {{- include "terminus.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- include "terminus.stringMap" . | trim | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "terminus.fullname" . }} + port: + name: http +{{- end }} diff --git a/charts/terminus/templates/job-migrate.yaml b/charts/terminus/templates/job-migrate.yaml new file mode 100644 index 000000000..c3589c9f6 --- /dev/null +++ b/charts/terminus/templates/job-migrate.yaml @@ -0,0 +1,90 @@ +{{- if .Values.migrate.enabled -}} +{{- $vaultInject := and .Values.vault.enabled .Values.vault.migrateInject -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "terminus.fullname" . }}-migrate + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-1" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 3 + template: + metadata: + labels: + {{- include "terminus.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + {{- if or $vaultInject .Values.migrate.podAnnotations }} + annotations: + {{- with .Values.migrate.podAnnotations }} + {{- include "terminus.stringMap" . | trim | nindent 8 }} + {{- end }} + {{- end }} + {{- if $vaultInject }} + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/role: {{ .Values.vault.role | quote }} + # Init container only, no sidecar. A sidecar never exits and would keep + # the Job from ever reaching Completed. + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-run-as-user: {{ .Values.vault.agentRunAsUser | quote }} + vault.hashicorp.com/agent-inject-secret-terminus-env: {{ .Values.vault.secretPath | quote }} + vault.hashicorp.com/agent-inject-template-terminus-env: | + {{`{{- with secret "`}}{{ .Values.vault.secretPath }}{{`" -}}`}} + export DATABASE_URL="{{`{{ .Data.data.database_url }}`}}" + export KEYVALUE_URL="{{`{{ .Data.data.keyvalue_url }}`}}" + export APP_SECRET="{{`{{ .Data.data.app_secret }}`}}" + {{`{{- end }}`}} + {{- end }} + spec: + restartPolicy: Never + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if $vaultInject }} + # The Vault Agent authenticates with this ServiceAccount's token, so the + # token must be mounted. A dedicated hook-scoped ServiceAccount is used + # because the chart's own is an ordinary release resource that does not + # exist yet at pre-install time. + serviceAccountName: {{ include "terminus.fullname" . }}-migrate + {{- else }} + # Migrations make no Kubernetes API calls, so the Job deliberately uses + # the namespace default ServiceAccount. The chart's own ServiceAccount is + # an ordinary release resource and does not exist yet at pre-install time. + automountServiceAccountToken: false + {{- end }} + containers: + - name: migrate + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if $vaultInject }} + command: ["/bin/sh", "-c"] + args: + - ". /vault/secrets/terminus-env && exec bundle exec hanami db migrate" + {{- else }} + command: ["bundle", "exec", "hanami", "db", "migrate"] + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- if and .Values.vault.enabled (not $vaultInject) }} + # No Vault Agent here: values are supplied directly instead. Enable + # vault.migrateInject to read them from Vault at run time. + env: + - name: DATABASE_URL + value: {{ .Values.migrate.databaseUrl | quote }} + # Unused by migrations, but Hanami validates it during boot. + - name: KEYVALUE_URL + value: {{ .Values.migrate.keyvalueUrl | quote }} + {{- end }} + envFrom: + - configMapRef: + name: {{ include "terminus.fullname" . }}-migrate + {{- if not .Values.vault.enabled }} + - secretRef: + name: {{ include "terminus.fullname" . }}-migrate + {{- end }} +{{- end }} diff --git a/charts/terminus/templates/keyvalue.yaml b/charts/terminus/templates/keyvalue.yaml new file mode 100644 index 000000000..052a56aca --- /dev/null +++ b/charts/terminus/templates/keyvalue.yaml @@ -0,0 +1,150 @@ +{{- if .Values.keyvalue.enabled -}} +{{- $fullName := include "terminus.fullname" . -}} +{{- if .Values.keyvalue.auth.password }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ $fullName }}-keyvalue + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: keyvalue +type: Opaque +data: + VALKEY_PASSWORD: {{ .Values.keyvalue.auth.password | b64enc | quote }} +--- +{{- end }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ $fullName }}-keyvalue + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: keyvalue +spec: + accessModes: + - ReadWriteOnce + {{- with .Values.keyvalue.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.keyvalue.persistence.size }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }}-keyvalue + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: keyvalue +spec: + type: ClusterIP + ports: + - port: {{ .Values.keyvalue.port }} + targetPort: valkey + protocol: TCP + name: valkey + selector: + {{- include "terminus.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: keyvalue +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }}-keyvalue + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: keyvalue +spec: + replicas: 1 + # Recreate, not RollingUpdate: the data PVC is ReadWriteOnce. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "terminus.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: keyvalue + template: + metadata: + labels: + {{- include "terminus.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: keyvalue + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.keyvalue.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: valkey + image: "{{ .Values.keyvalue.image.repository }}:{{ .Values.keyvalue.image.tag }}" + imagePullPolicy: {{ .Values.keyvalue.image.pullPolicy }} + {{- if .Values.keyvalue.auth.password }} + # The password is read from the environment at startup rather than + # passed as an argument, which would expose it in the process list. + command: ["/bin/sh", "-c"] + args: + - >- + exec valkey-server + --requirepass "$VALKEY_PASSWORD" + --maxmemory {{ .Values.keyvalue.maxmemory }} + --maxmemory-policy {{ .Values.keyvalue.maxmemoryPolicy }} + --port {{ .Values.keyvalue.port }} + envFrom: + - secretRef: + name: {{ $fullName }}-keyvalue + {{- else }} + args: + - valkey-server + - --maxmemory + - {{ .Values.keyvalue.maxmemory | quote }} + - --maxmemory-policy + - {{ .Values.keyvalue.maxmemoryPolicy | quote }} + - --port + - {{ .Values.keyvalue.port | quote }} + {{- end }} + ports: + - name: valkey + containerPort: {{ .Values.keyvalue.port }} + protocol: TCP + livenessProbe: + tcpSocket: + port: valkey + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: valkey + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + volumeMounts: + - name: data + mountPath: /data + {{- with .Values.keyvalue.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ $fullName }}-keyvalue + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/terminus/templates/pvc.yaml b/charts/terminus/templates/pvc.yaml new file mode 100644 index 000000000..10eada476 --- /dev/null +++ b/charts/terminus/templates/pvc.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "terminus.fullname" . }}-uploads + labels: + {{- include "terminus.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- with .Values.persistence.uploads.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.uploads.size }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "terminus.fullname" . }}-fonts + labels: + {{- include "terminus.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- with .Values.persistence.fonts.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.fonts.size }} diff --git a/charts/terminus/templates/secret-migrate.yaml b/charts/terminus/templates/secret-migrate.yaml new file mode 100644 index 000000000..3fd1bd841 --- /dev/null +++ b/charts/terminus/templates/secret-migrate.yaml @@ -0,0 +1,22 @@ +{{- if and .Values.migrate.enabled (not .Values.vault.enabled) -}} +{{/* +Hook-scoped copy of the main Secret. See configmap-migrate.yaml for why the +migrate Job needs its own copy. +*/}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "terminus.fullname" . }}-migrate + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation +type: Opaque +data: + DATABASE_URL: {{ include "terminus.databaseUrl" . | b64enc | quote }} + KEYVALUE_URL: {{ include "terminus.keyvalueUrl" . | b64enc | quote }} + APP_SECRET: {{ .Values.secrets.appSecret | b64enc | quote }} +{{- end }} diff --git a/charts/terminus/templates/secret.yaml b/charts/terminus/templates/secret.yaml new file mode 100644 index 000000000..b2d6ae874 --- /dev/null +++ b/charts/terminus/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if not .Values.vault.enabled -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "terminus.fullname" . }} + labels: + {{- include "terminus.labels" . | nindent 4 }} +type: Opaque +data: + DATABASE_URL: {{ include "terminus.databaseUrl" . | b64enc | quote }} + KEYVALUE_URL: {{ include "terminus.keyvalueUrl" . | b64enc | quote }} + APP_SECRET: {{ .Values.secrets.appSecret | b64enc | quote }} +{{- end }} diff --git a/charts/terminus/templates/service.yaml b/charts/terminus/templates/service.yaml new file mode 100644 index 000000000..85dc11edd --- /dev/null +++ b/charts/terminus/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "terminus.fullname" . }} + labels: + {{- include "terminus.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "terminus.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: web diff --git a/charts/terminus/templates/serviceaccount-migrate.yaml b/charts/terminus/templates/serviceaccount-migrate.yaml new file mode 100644 index 000000000..3972a0a68 --- /dev/null +++ b/charts/terminus/templates/serviceaccount-migrate.yaml @@ -0,0 +1,22 @@ +{{- if and .Values.migrate.enabled .Values.vault.enabled .Values.vault.migrateInject -}} +{{/* +Hook-scoped ServiceAccount for the migrate Job's Vault Agent init container. +It cannot share a name with the release-managed ServiceAccount: Helm rejects a +hook-owned and a release-owned object sharing a name. This name must therefore +appear in the Vault Kubernetes auth role's bound_service_account_names. +*/}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "terminus.fullname" . }}-migrate + labels: + {{- include "terminus.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation + {{- with .Values.serviceAccount.annotations }} + {{- include "terminus.stringMap" . | trim | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/terminus/templates/serviceaccount.yaml b/charts/terminus/templates/serviceaccount.yaml new file mode 100644 index 000000000..274d08fa0 --- /dev/null +++ b/charts/terminus/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "terminus.serviceAccountName" . }} + labels: + {{- include "terminus.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- include "terminus.stringMap" . | trim | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/terminus/values.yaml b/charts/terminus/values.yaml new file mode 100644 index 000000000..b65d3b353 --- /dev/null +++ b/charts/terminus/values.yaml @@ -0,0 +1,269 @@ +replicaCount: 1 + +image: + repository: ghcr.io/usetrmnl/terminus + pullPolicy: Always + tag: "" + +imagePullSecrets: [] + +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} + +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + +securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + +service: + type: ClusterIP + port: 2345 + +ingress: + enabled: false + className: "" + annotations: {} + host: "" + tls: [] + +# HTTPRoute for Envoy Gateway / Gateway API (mutually exclusive with ingress) +route: + enabled: false + host: "" + annotations: {} + gateway: + name: ingress-gateway + namespace: istio-system + +# Non-sensitive env vars injected via ConfigMap +config: + hanamiPort: "2345" + apiUri: "" + tz: "UTC" + rackAttackAllowedSubnets: "" + # Set to "true" to run db migrate + asset compile on every pod startup. + # Equivalent to compose.yml APP_SETUP behaviour. Set to "false" and rely + # on the pre-upgrade migration job when running multiple replicas. + appSetup: "true" + +# Optional Vault Agent Injector integration. +# When enabled, secrets are written as env exports to /vault/secrets/terminus-env +# and sourced before the app starts. No k8s Secret is created. +# The Vault secret must contain keys: database_url, keyvalue_url, app_secret +vault: + enabled: false + role: "terminus" + secretPath: "terminus/prod/secret" + agentRunAsUser: "1000" + # Read the migration job's secrets from Vault too, via an Agent init + # container (no sidecar — a sidecar would stop the Job from completing). + # Requires the Vault Kubernetes auth role named above to include the + # "-migrate" ServiceAccount in its bound_service_account_names, + # otherwise the init container fails to authenticate and the Job hangs. + # When false, migrate.databaseUrl and migrate.keyvalueUrl are used instead. + migrateInject: false + +# Optional bundled PostgreSQL. A single instance with no replication, backups +# or failover — intended for small self-hosted deployments, mirroring the +# database service in compose.yml. Point secrets.databaseUrl at a managed +# database instead for anything you care about losing. +# +# Passwords are interpolated into a connection URL, so avoid characters that +# need percent-encoding there (@ : / ? # [ ] and whitespace). +database: + enabled: false + image: + repository: postgres + tag: "18.4-alpine" + pullPolicy: IfNotPresent + auth: + username: terminus + database: terminus + password: "" + port: 5432 + # Gate the web pod on the database accepting connections, via an init + # container. Set false in an Istio mesh: init containers run before + # istio-proxy, so their traffic is redirected to an Envoy that is not + # listening yet and the gate spins on "connection refused" forever. + # + # Turning this off is not free. scripts/docker/entrypoint ignores the exit + # status of `hanami db migrate`, so a web pod that starts before the + # database is ready will serve an unmigrated schema without erroring. + # Prefer keeping the gate and excluding the port from mesh redirection: + # + # podAnnotations: + # traffic.sidecar.istio.io/excludeOutboundPorts: "5432,6379" + waitForReady: true + # postgres:*-alpine creates its postgres user as 70:70 (the Debian-based + # tags use 999 instead). Running as that user rather than root matters: + # the image entrypoint chowns the data directory only when it starts as + # uid 0, which fails outright on NFS root_squash or without CAP_CHOWN. + podSecurityContext: + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + runAsNonRoot: true + persistence: + storageClass: "" + size: 8Gi + resources: + limits: + memory: 2Gi + cpu: "1" + requests: + memory: 512Mi + cpu: "250m" + +# Optional bundled Valkey (Redis-compatible), mirroring the keyvalue service +# in compose.yml. Same caveats as the bundled database above. +keyvalue: + enabled: false + image: + repository: valkey/valkey + tag: "9.1-alpine" + pullPolicy: IfNotPresent + auth: + # Leave empty to run without authentication. In-cluster traffic to a + # ClusterIP service is not reachable from outside the cluster. + password: "" + port: 6379 + database: "0" + # noeviction is deliberate: under any eviction policy Valkey discards queued + # Sidekiq jobs when memory fills, rather than refusing new writes. + maxmemory: "512mb" + maxmemoryPolicy: "noeviction" + # See database.waitForReady. Lower stakes here: Sidekiq reconnects to Valkey + # on its own, so a web pod that starts early recovers without intervention. + waitForReady: true + # The valkey user is 999:1000 — note the differing gid. As with the database + # above, running non-root makes the entrypoint skip its chown of /data. + podSecurityContext: + runAsUser: 999 + runAsGroup: 1000 + fsGroup: 1000 + runAsNonRoot: true + persistence: + storageClass: "" + size: 2Gi + resources: + limits: + memory: 512Mi + cpu: "500m" + requests: + memory: 128Mi + cpu: "100m" + +# Used only when vault.enabled: false. +# databaseUrl is ignored when database.enabled, as is keyvalueUrl when +# keyvalue.enabled — both are derived from the bundled service instead. +secrets: + databaseUrl: "" + keyvalueUrl: "" + appSecret: "" + +migrate: + enabled: true + # Annotations for the migration Job's pod. + # + # In an Istio mesh the sidecar breaks this Job twice over: init containers + # run before istio-proxy starts, so the Vault Agent's egress is redirected to + # an Envoy that is not listening yet, and the proxy never exits, so the Job + # never reaches Completed. Opting the Job out of the mesh solves both: + # + # podAnnotations: + # sidecar.istio.io/inject: "false" + # + # Only do this if the database is reachable without mesh mTLS. If it is not, + # keep the sidecar and exclude just the Vault traffic from redirection: + # + # podAnnotations: + # traffic.sidecar.istio.io/excludeOutboundPorts: "8200" + podAnnotations: {} + # Required when vault.enabled: true — provide the DATABASE_URL directly + # so the migration job can connect without the Vault sidecar. + databaseUrl: "" + # Used only when vault.enabled: true. Migrations never talk to Redis, but + # Hanami validates keyvalue_url at boot, so a reachable-looking value must + # be present. Override if your environment rejects the placeholder. + keyvalueUrl: "redis://localhost:6379/0" + +persistence: + uploads: + storageClass: "" + size: 5Gi + fonts: + storageClass: "" + size: 500Mi + +resources: + web: + limits: + memory: 1Gi + cpu: "1" + requests: + memory: 512Mi + cpu: "250m" + worker: + limits: + memory: 512Mi + cpu: "500m" + requests: + memory: 256Mi + cpu: "100m" + +livenessProbe: + web: + httpGet: + path: /up + port: http + initialDelaySeconds: 45 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + worker: + exec: + command: ["pgrep", "-f", "sidekiq"] + initialDelaySeconds: 45 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + web: + httpGet: + path: /up + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + +# HPA targets the web deployment (worker runs as sidecar in the same pod). +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: "" + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/doc/helm.adoc b/doc/helm.adoc new file mode 100644 index 000000000..86e3693ef --- /dev/null +++ b/doc/helm.adoc @@ -0,0 +1,500 @@ +:toc: macro +:toclevels: 5 +:figure-caption!: + += Helm Chart + +The Helm chart is use to deploy Terminus to Kubernetes. It wraps the manifests described in the {doc_kubernetes_link} documentation into a single, configurable release. + +toc::[] + +== Requirements + +* Helm 3.8+ (OCI registry support) +* A running Kubernetes cluster (v1.25+) +* A storage class for persistent volumes (e.g., Longhorn, local-path, or cloud provider storage) +* A PostgreSQL database — either external (`secrets.databaseUrl`) or the chart's optional bundled instance (`database.enabled`) +* A Redis-compatible store for Sidekiq — either external (`secrets.keyvalueUrl`) or the chart's optional bundled Valkey (`keyvalue.enabled`) +* Optionally, link:https://gateway.envoyproxy.io[Envoy Gateway] (or another Gateway API implementation) or an Ingress controller for external access +* Optionally, link:https://www.vaultproject.io[HashiCorp Vault] with the Agent Injector webhook, if you don't want secrets stored as a Kubernetes `Secret` + + +**Note**: Use external PostgreSQL and Redis installation for production use. + +== Chart Structure + +The chart lives at `charts/terminus`: + +[source] +---- +charts/terminus/ +├── Chart.yaml # Chart metadata (name, version, appVersion) +├── values.yaml # Default configuration values +├── ci/ +│ └── default-values.yaml # Values used by `ct lint` in CI (bare defaults fail validation) +└── templates/ + ├── _helpers.tpl # Named templates: names, labels, connection URLs, annotation maps, value validation + ├── NOTES.txt # Post-install/upgrade usage hints + ├── serviceaccount.yaml # ServiceAccount (optional, created by default) + ├── configmap.yaml # Non-sensitive env vars (HANAMI_PORT, API_URI, APP_SETUP, TZ, RACK_ATTACK_ALLOWED_SUBNETS) + ├── secret.yaml # DATABASE_URL / KEYVALUE_URL / APP_SECRET (only when vault.enabled is false) + ├── pvc.yaml # PersistentVolumeClaims for uploads and fonts + ├── deployment-web.yaml # Deployment running the "web" (Puma) and "worker" (Sidekiq) containers in one pod + ├── service.yaml # ClusterIP (or other) Service in front of the web container + ├── ingress.yaml # Optional networking.k8s.io/v1 Ingress + ├── httproute.yaml # Optional Gateway API HTTPRoute (Envoy Gateway, etc.) + ├── hpa.yaml # Optional HorizontalPodAutoscaler targeting the Deployment + ├── database.yaml # Optional bundled PostgreSQL: Secret, PVC, Service, Deployment + ├── keyvalue.yaml # Optional bundled Valkey: Secret, PVC, Service, Deployment + ├── job-migrate.yaml # pre-install/pre-upgrade Helm hook Job running `hanami db migrate` + ├── configmap-migrate.yaml # Hook-scoped ConfigMap for the migration Job + ├── secret-migrate.yaml # Hook-scoped Secret for the migration Job + └── serviceaccount-migrate.yaml # Hook-scoped ServiceAccount (only when vault.migrateInject) +---- + +[cols="1,3"] +|=== +|Component |Purpose + +|Deployment (`*-web`) +|Single pod with two containers: `web` (Puma, serves HTTP) and `worker` (Sidekiq, background jobs). They share the `uploads` and `fonts` volumes. + +|Service +|ClusterIP Service exposing the `web` container's HTTP port. + +|PersistentVolumeClaim ×2 +|`uploads` (generated images/content) and `fonts` (custom fonts), both `ReadWriteOnce`. + +|ConfigMap / Secret +|Non-sensitive and sensitive environment variables injected into both containers via `envFrom`. Replaced by Vault Agent injection when `vault.enabled: true`. + +|Job (`*-migrate`) +|Runs `hanami db migrate` as a `pre-install,pre-upgrade` Helm hook before the Deployment is updated. + +|ConfigMap / Secret / ServiceAccount (`*-migrate`) +|Hook-scoped copies at `hook-weight: "-5"`, created before the Job at `-1`. See <>. + +|Deployment (`*-database`) +|Optional bundled PostgreSQL, with its own Secret, `ReadWriteOnce` PVC and ClusterIP Service. Disabled by default. + +|Deployment (`*-keyvalue`) +|Optional bundled Valkey, with its own PVC and ClusterIP Service (plus a Secret when a password is set). Disabled by default. + +|Ingress / HTTPRoute +|Optional external access via a standard Ingress or a Gateway API `HTTPRoute`. + +|HorizontalPodAutoscaler +|Optional CPU/memory-based autoscaling of the web Deployment (which also scales the worker sidecar, since they share a pod). + +|ServiceAccount +|Created by default; can be disabled or pointed at an existing one. +|=== + +== Installing the Chart + +=== From the OCI registry + +Tagged releases are packaged and pushed to GitHub Container Registry by `.github/workflows/helm.yml`: + +[source,bash] +---- +helm install terminus oci://ghcr.io/usetrmnl/charts/terminus \ + --version 0.1.0 \ + --namespace terminus --create-namespace \ + --set config.apiUri="https://terminus.example.com" \ + --set secrets.databaseUrl="postgres://user:password@postgres-host:5432/terminus" \ + --set secrets.keyvalueUrl="redis://:password@valkey-host:6379/0" \ + --set secrets.appSecret="$(openssl rand -hex 64)" +---- + +=== From source + +[source,bash] +---- +git clone https://github.com/usetrmnl/terminus.git +cd terminus + +helm install terminus ./charts/terminus \ + --namespace terminus --create-namespace \ + -f my-values.yaml +---- + +💡 Prefer a values file over `--set` for anything beyond a quick test — secrets in particular are easy to leak into shell history via `--set`. + +Example `my-values.yaml`: + +[source,yaml] +---- +image: + tag: "0.42.0" # pin a real tag; the chart default is "latest" + +config: + apiUri: "https://terminus.example.com" + tz: "America/New_York" + rackAttackAllowedSubnets: "10.42.0.0/16" + +secrets: + databaseUrl: "postgres://user:password@postgres-host:5432/terminus" + keyvalueUrl: "redis://:password@valkey-host:6379/0" + appSecret: "CHANGE_ME" # 64+ characters; generate with: openssl rand -hex 64 + +persistence: + uploads: + storageClass: "local-path" + size: 5Gi + fonts: + storageClass: "local-path" + size: 500Mi + +ingress: + enabled: true + className: "nginx" + host: "terminus.example.com" + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + tls: + - hosts: ["terminus.example.com"] + secretName: terminus-tls +---- + +Then verify the release: + +[source,bash] +---- +helm upgrade --install terminus ./charts/terminus -n terminus -f my-values.yaml +kubectl get pods -n terminus +kubectl logs -n terminus deployment/terminus-web -c web +kubectl logs -n terminus deployment/terminus-web -c worker +---- + +You can also render the manifests locally without installing anything, which is what CI does to lint the chart: + +[source,bash] +---- +helm template terminus ./charts/terminus -f charts/terminus/ci/default-values.yaml +---- + +⚠️ Rendering with bare defaults fails on purpose — `config.apiUri` and the `secrets.*` values have no usable defaults, so the chart calls `fail` rather than deploying an app that crashes at boot. `ci/default-values.yaml` exists so `ct lint` has a valid set of throwaway values. See <>. + +== Bundled Datastores + +By default the chart deploys neither PostgreSQL nor Valkey, and you point `secrets.databaseUrl` / `secrets.keyvalueUrl` at your own. Setting `database.enabled` or `keyvalue.enabled` makes the chart deploy a single-instance datastore instead, mirroring the `database` and `keyvalue` services in `compose.yml`: + +[source,yaml] +---- +config: + apiUri: "https://terminus.example.com" + +secrets: + appSecret: "CHANGE_ME" # 64+ characters + +migrate: + enabled: false # required when database.enabled — see below + +database: + enabled: true + auth: + username: terminus + database: terminus + password: "CHANGE_ME" + persistence: + storageClass: "local-path" + size: 8Gi + +keyvalue: + enabled: true + auth: + password: "CHANGE_ME" # optional; omit to run without authentication + persistence: + storageClass: "local-path" + size: 2Gi +---- + +When a datastore is bundled, its connection URL is derived by the chart (`terminus.databaseUrl` / `terminus.keyvalueUrl` in `_helpers.tpl`) and the corresponding `secrets.*` value is ignored: + +[source] +---- +postgres://:@-database:5432/ +redis://[:@]-keyvalue:6379/0 +---- + +⚠️ Passwords are interpolated into those URLs without percent-encoding. Avoid characters that need escaping there: `@ : / ? # [ ]` and whitespace. + +=== Readiness gating + +Kubernetes has no equivalent of Compose's `depends_on: condition: service_healthy`, and `scripts/docker/entrypoint` ignores the exit status of `hanami db migrate`. A web pod that starts before PostgreSQL is ready therefore migrates nothing, logs nothing, and serves an unmigrated schema. + +To prevent that, `database.waitForReady` and `keyvalue.waitForReady` (both `true` by default) add init containers to the web pod that block on `pg_isready` and `valkey-cli ping`. See <> for when you have to turn them off. + +=== Why `migrate.enabled` must be false + +The migration Job is a `pre-install` hook, so it runs *before* the bundled database Deployment exists. Making the database hook-scoped would take a stateful Deployment and PVC out of release management and orphan them on `helm uninstall`, which is worse. So `database.enabled` requires `migrate.enabled: false`, enforced by a validation guard. + +Migrations then run at pod startup via `config.appSetup: "true"` (the default), correctly gated by the `wait-for-database` init container. + +=== Security context + +Both bundled images start as root and drop privileges in their entrypoint, and that entrypoint chowns the data directory only when running as uid 0 — which fails outright on NFS `root_squash` or without `CAP_CHOWN`. The chart therefore runs both as their non-root image user so the chown is skipped entirely: + +[cols="2,1,1,2"] +|=== +|Image |uid |gid |Note + +|`postgres:18.4-alpine` +|`70` +|`70` +|Debian-based tags (e.g. `18.4`) use `999:999` instead — update `database.podSecurityContext` if you change the tag. + +|`valkey/valkey:9.1-alpine` +|`999` +|`1000` +|Note the differing gid. +|=== + +`fsGroup` matches the gid so the mounted volume is group-writable. + +⚠️ Bundled datastores have no replication, backups or failover, and `database.enabled` cannot be combined with `vault.enabled`. Use a managed database for anything you care about losing. + +== Configuration Reference + +The most commonly overridden values (see `charts/terminus/values.yaml` for the full, commented list): + +[cols="2,3,1"] +|=== +|Key |Description |Default + +|`image.repository` / `image.tag` +|Container image and tag. Tag defaults to `.Chart.AppVersion` ("latest") when unset. +|`ghcr.io/usetrmnl/terminus` / `""` + +|`replicaCount` +|Replicas for the web+worker Deployment. Ignored when `autoscaling.enabled` is `true`. +|`1` + +|`service.type` / `service.port` +|Service exposing the web container. +|`ClusterIP` / `2345` + +|`ingress.*` +|Standard `networking.k8s.io/v1` Ingress. +|`enabled: false` + +|`route.*` +|Gateway API `HTTPRoute` (e.g., for Envoy Gateway). +|`enabled: false` + +|`config.*` +|Non-sensitive env vars written to the ConfigMap (`apiUri`, `tz`, `rackAttackAllowedSubnets`, `appSetup`, `hanamiPort`). `apiUri` is required. +|see `values.yaml` + +|`secrets.*` +|`databaseUrl`, `keyvalueUrl`, `appSecret` — written to a `Secret` when `vault.enabled` is `false`. `appSecret` must be 64+ characters. Ignored per-datastore when that datastore is bundled. +|`""` + +|`podAnnotations` / `podLabels` +|Annotations and labels for the web pod. Values are coerced to strings, so an unquoted `true` in your values file is safe. +|`{}` + +|`vault.*` +|Enables Vault Agent Injector annotations instead of a Kubernetes `Secret`. +|`enabled: false` + +|`vault.migrateInject` +|Read the migration Job's secrets from Vault via an Agent init container, instead of `migrate.databaseUrl`. Requires Vault-side role changes — see <>. +|`false` + +|`migrate.enabled` +|Toggles the pre-install/pre-upgrade migration Job. Must be `false` when `database.enabled`. +|`true` + +|`migrate.databaseUrl` / `migrate.keyvalueUrl` +|Connection values for the migration Job when `vault.enabled` and `migrateInject` is off. `databaseUrl` is required in that combination. +|`""` / `redis://localhost:6379/0` + +|`migrate.podAnnotations` +|Annotations for the migration Job's pod, e.g. to opt it out of a service mesh. +|`{}` + +|`database.*` +|Optional bundled PostgreSQL. `auth.password` is required when enabled. +|`enabled: false` + +|`keyvalue.*` +|Optional bundled Valkey. `auth.password` is optional. +|`enabled: false` + +|`database.waitForReady` / `keyvalue.waitForReady` +|Gate the web pod on the bundled datastore accepting connections, via init containers. +|`true` + +|`persistence.uploads.*` / `persistence.fonts.*` +|Storage class and size for the two `ReadWriteOnce` PVCs. +|`5Gi` / `500Mi` + +|`resources.web.*` / `resources.worker.*` +|Resource requests/limits per container. +|see `values.yaml` + +|`autoscaling.*` +|HPA for the web Deployment (also scales the worker sidecar). +|`enabled: false` +|=== + +=== Value Validation + +`terminus.validateValues` in `_helpers.tpl` fails the render rather than letting a misconfiguration surface as a crash loop. The current guards: + +[cols="3,4"] +|=== +|Condition |Reason + +|`ingress.enabled` and `route.enabled` both set +|Would silently create two sets of routing resources. + +|`config.apiUri` empty +|Hanami constrains `api_uri` to be non-empty with no default (`config/settings.rb`). + +|`secrets.databaseUrl` empty, no Vault, no bundled database +|Nothing would supply `DATABASE_URL`. + +|`secrets.keyvalueUrl` empty, no Vault, no bundled Valkey +|Nothing would supply `KEYVALUE_URL`. + +|`secrets.appSecret` shorter than 64 characters +|Hanami constrains `app_secret` to `min_size: 64`. + +|`vault.enabled` with `database.enabled` or `keyvalue.enabled` +|Vault supplies the connection URLs, so a bundled datastore has nothing to provide them to. + +|`database.enabled` with `migrate.enabled` +|The migration hook runs before the bundled database exists. + +|`database.enabled` without `database.auth.password` +|The PostgreSQL image requires `POSTGRES_PASSWORD`. + +|`vault.enabled` and `migrate.enabled` without `migrate.databaseUrl` or `vault.migrateInject` +|The migration Job would have no way to reach the database. +|=== + +💡 An empty environment variable *overrides* the application's own default rather than falling back to it, which is why these are hard failures at template time instead of best-effort defaults. + +== Migration Job + +The Job runs `hanami db migrate` as a `pre-install,pre-upgrade` hook at `hook-weight: "-1"`. Because hooks execute before ordinary release resources exist, the Job cannot reference the release-managed ConfigMap, Secret or ServiceAccount — on a fresh install none of them exist yet. + +It therefore carries hook-scoped copies of its dependencies at `hook-weight: "-5"`, so they are created first: + +* `configmap-migrate.yaml` — `API_URI`, `TZ`, and `APP_SETUP: "false"` (the Job calls `hanami db migrate` directly, bypassing the entrypoint) +* `secret-migrate.yaml` — `DATABASE_URL`, `KEYVALUE_URL`, `APP_SECRET` (omitted when `vault.enabled`) +* `serviceaccount-migrate.yaml` — only when `vault.migrateInject`, since the Vault Agent needs a mounted token + +Otherwise the Job sets `automountServiceAccountToken: false` and uses the namespace `default` ServiceAccount, because migrations make no Kubernetes API calls. + +💡 These hook resources use `hook-delete-policy: before-hook-creation` only, deliberately without `hook-succeeded`: Helm deletes each hook resource as soon as it is ready, and a ConfigMap is ready the instant it is created — `hook-succeeded` would remove it before the Job at `-1` ever started. The consequence is that they persist after install and, being hook resources, are not removed by `helm uninstall`. + +== Secrets Management + +By default (`vault.enabled: false`), `DATABASE_URL`, `KEYVALUE_URL`, and `APP_SECRET` are stored base64-encoded in a Kubernetes `Secret` and injected via `envFrom`. + +Setting `vault.enabled: true` switches to link:https://developer.hashicorp.com/vault/docs/platform/k8s/injector[Vault Agent Injector]: no `Secret` is created, and the pod is annotated so Vault writes an env file to `/vault/secrets/terminus-env`, which is sourced before the `web` and `worker` entrypoints run. The Vault secret at `vault.secretPath` must contain `database_url`, `keyvalue_url`, and `app_secret` keys. + +=== The migration Job and Vault + +The migration Job cannot take the ordinary Vault Agent sidecar: the agent never exits, so the Job would never reach `Completed`. There are two ways to handle it. + +*Explicit values (default).* Set `migrate.databaseUrl`, meaning a plaintext database URL still needs to reach the cluster separately from Vault. `migrate.keyvalueUrl` also has to be present — migrations never touch Redis, but Hanami validates `keyvalue_url` at boot — and its placeholder default is usually fine. + +*Vault Agent init container (`vault.migrateInject: true`).* The Job is annotated with `agent-pre-populate-only: "true"`, which injects the agent as an init container only. It authenticates, writes the env file, and exits, so the Job can complete. `migrate.databaseUrl` and `migrate.keyvalueUrl` are then unused. + +⚠️ `migrateInject` requires a Vault-side change. The agent authenticates with a hook-scoped ServiceAccount named `-migrate`, which cannot share a name with the release-managed one (Helm rejects a hook-owned and a release-owned object sharing a name). That name must appear in the Vault Kubernetes auth role's `bound_service_account_names`, or the init container fails to authenticate and the Job sits in `Init:0/1`: + +[source,bash] +---- +vault write auth/kubernetes/role/terminus \ + audience=https://kubernetes.default.svc.cluster.local \ + bound_service_account_names=terminus,terminus-migrate \ + bound_service_account_namespaces=terminus \ + policies=terminus \ + ttl=1h +---- + +`vault write` replaces the whole role, so carry over your existing bindings. Vault v1.21+ requires `audience` on Kubernetes auth roles; it must match the `aud` claim of the token the agent presents, which for the default projected ServiceAccount token is your API server's default audience. + +== Example: Install with Vault + +[source,yaml] +---- +vault: + enabled: true + role: "terminus" + secretPath: "terminus/prod/secret" + migrateInject: true # or set migrate.databaseUrl instead + +config: + apiUri: "https://terminus.example.com" +---- + +== Service Mesh Caveats + +Init containers run before the mesh sidecar starts, so their traffic is redirected to a proxy that is not listening yet. In Istio this affects three things the chart uses: the migration Job's Vault Agent init container, and both `waitForReady` init containers. + +The symptom is `connection refused` — or, once Istio's DNS capture is also in play, `Name has no usable address`. Neither is a DNS or naming problem; the mesh is simply unavailable that early. + +Additionally, a classic (non-native) Istio sidecar never exits, so a Job pod stays `NotReady` and never reaches `Completed` — the migration Job would hang even after migrations succeed. + +*Opt the migration Job out of the mesh* (both problems at once): + +[source,yaml] +---- +migrate: + podAnnotations: + sidecar.istio.io/inject: "false" +---- + +Only do this if the database is reachable without mesh mTLS. + +*Disable the readiness gates* if they cannot reach the datastores: + +[source,yaml] +---- +database: + waitForReady: false +keyvalue: + waitForReady: false +---- + +⚠️ These are not equally safe. Dropping the Valkey gate is low risk — Sidekiq reconnects on its own. Dropping the database gate means a web pod may start before PostgreSQL is ready and serve an unmigrated schema silently. Verify afterwards, and restart the Deployment if needed: + +[source,bash] +---- +kubectl exec -n terminus deploy/terminus-web -c web -- bundle exec hanami db version +kubectl rollout restart deploy/terminus-web -n terminus +---- + +💡 The clean fix is Istio's native sidecar mode (`ENABLE_NATIVE_SIDECARS=true` on istiod), which runs `istio-proxy` as an init container with `restartPolicy: Always`. It starts before other init containers, so mesh-aware init containers and Jobs both work normally and none of the workarounds above are needed. This is cluster configuration, outside the chart. + +== Limitations + +* *Bundled datastores are minimal.* `database.enabled` / `keyvalue.enabled` deploy a single replica with no replication, backups, failover or connection pooling, cannot be combined with `vault.enabled`, and force `migrate.enabled: false`. They exist for small self-hosted deployments, not production. +* *Web and worker cannot scale independently.* They run as two containers in the same pod (`deployment-web.yaml`), so `replicaCount`/HPA scales both together — there's no way to run more Sidekiq workers than Puma processes (or vice versa) via chart values alone. +* *`ReadWriteOnce` storage forces `Recreate` deployments.* The `uploads` and `fonts` PVCs are `RWO`, so the Deployment uses `strategy: Recreate` — upgrades briefly take the app down, and pods can't be scheduled across nodes without a `ReadWriteMany`-capable storage class. The bundled datastore Deployments use `Recreate` for the same reason. +* *Hook resources are not cleaned up.* The `*-migrate` ConfigMap, Secret and ServiceAccount are hook-scoped, so `helm uninstall` leaves them behind; delete them manually. +* *No TLS automation.* The chart does not create `Certificate` resources; `ingress.tls` / `route` listener certs and any `cert-manager` annotations are entirely bring-your-own. +* *No `PodDisruptionBudget`, `NetworkPolicy`, or `ServiceMonitor` templates.* +* *No `helm test` hooks* (no `templates/tests/`), so `helm test` has nothing to run. +* *Floating image tag by default.* `image.tag` defaults to empty (falls back to `appVersion: "latest"`) with `pullPolicy: Always` — fine for trying it out, but pin `image.tag` for reproducible production deploys. +* *No PVC retention protection.* Deleting the release (or its PVCs) has no chart-managed backup/retention policy — `uploads`, `fonts` and bundled datastore data can be lost. +* *Single migration attempt window.* The migrate Job's `backoffLimit: 3` runs before the Deployment is touched; if migrations fail, the install/upgrade fails and must be retried from scratch rather than resumed. +* *Chart declares no dependencies.* `Chart.yaml` has no `dependencies`, so CI's `helm dependency update` is a no-op today. + +== Troubleshooting + +See the Troubleshooting section of {doc_kubernetes_link} — the same failure modes (CrashLoopBackOff from a bad `DATABASE_URL`/`KEYVALUE_URL`, missing `APP_SECRET`, permission errors on `/app/public/uploads`) apply here. To inspect a chart-managed release specifically: + +[source,bash] +---- +helm status terminus -n terminus +helm get values terminus -n terminus +kubectl logs -n terminus job/terminus-migrate +kubectl logs -n terminus pod/terminus-web -c --follow +----