Adds StatefulSet support (#549)

* Adds StatefulSet support

# Conflicts:
#	helm/opengist/templates/pvc.yaml

* Adds statefulset support for replicaCount gt 1

* Improves the setup of multiple replicas in a stateful set

* Adds config wrangling logic to the secret template

* Adds shared PV functionality

* Adds missing pvc-shared template

* Adds stateful set and documentation

---------

Co-authored-by: Guillem Riera <guillem@rieragalm.es>
This commit is contained in:
Guillem Riera Galmés
2026-01-21 02:22:44 +01:00
committed by GitHub
parent 03420e4f91
commit 4ae25144a0
8 changed files with 746 additions and 11 deletions

View File

@@ -1,3 +1,4 @@
{{- if not .Values.statefulSet.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -124,3 +125,5 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,48 @@
{{- /*
This template creates a standalone PersistentVolumeClaim for shared persistence mode.
Rendering conditions:
- statefulSet.enabled=true
- persistence.enabled=true
- persistence.mode=shared
- persistence.existingClaim is empty/unset
- persistence.create.enabled=true
When rendered, this PVC is mounted by ALL replicas in the StatefulSet (typically with ReadWriteMany
access mode for multi-replica deployments). This avoids per-replica volumeClaimTemplates and enables
horizontal scaling with a single shared storage backend.
If persistence.existingClaim is set, this template does NOT render; the StatefulSet instead references
the existing claim name directly.
*/}}
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (ne (default "" .Values.persistence.existingClaim) "") | not }}{{- end }}
{{- if and .Values.statefulSet.enabled .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") (eq (default "" .Values.persistence.existingClaim) "") .Values.persistence.create.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- with .Values.persistence.create.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.persistence.create.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
{{- if .Values.persistence.create.accessModes }}
{{- toYaml .Values.persistence.create.accessModes | nindent 4 }}
{{- else }}
- ReadWriteMany
{{- end }}
resources:
requests:
storage: {{ default .Values.persistence.size .Values.persistence.create.size }}
volumeMode: Filesystem
{{- $sc := default .Values.persistence.storageClass .Values.persistence.create.storageClass }}
{{- if $sc }}
storageClassName: {{ $sc | quote }}
{{- end }}
{{- end }}

View File

@@ -1,4 +1,4 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
{{- if and .Values.persistence.enabled (not .Values.statefulSet.enabled) (not .Values.persistence.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
@@ -25,4 +25,4 @@ spec:
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}
{{- end }}

View File

@@ -1,4 +1,53 @@
{{- if (not .Values.configExistingSecret) }}
{{- $cfg := deepCopy .Values.config }}
{{- /* Backward compatibility: map db-uri (deprecated) to db-uri key still expected by app, also accept dbUri coming from user */}}
{{- if and (hasKey $cfg "dbUri") (not (hasKey $cfg "db-uri")) }}
{{- $_ := set $cfg "db-uri" (index $cfg "dbUri") }}
{{- end }}
{{- $dburi := default "" (index $cfg "db-uri") }}
{{- /* Flatten possible nested index.meili.* structure if user passed --set config.index.meili.host=... */}}
{{- if and (hasKey $cfg "index") (kindIs "map" (index $cfg "index")) }}
{{- $indexMap := (index $cfg "index") }}
{{- if hasKey $indexMap "type" }}
{{- $_ := set $cfg "index" (index $indexMap "type") }}
{{- end }}
{{- if hasKey $indexMap "meili" }}
{{- $meili := (index $indexMap "meili") }}
{{- if hasKey $meili "host" }}
{{- $_ := set $cfg "index.meili.host" (index $meili "host") }}
{{- end }}
{{- if hasKey $meili "api-key" }}
{{- $_ := set $cfg "index.meili.api-key" (index $meili "api-key") }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.postgresql.enabled (eq $dburi "") }}
{{- $user := default "" .Values.postgresql.global.postgresql.auth.username }}
{{- $pass := default "" .Values.postgresql.global.postgresql.auth.password }}
{{- $db := default "" .Values.postgresql.global.postgresql.auth.database }}
{{- $port := default 5432 .Values.postgresql.global.postgresql.service.ports.postgresql }}
{{- if or (eq $user "") (eq $pass "") (eq $db "") }}
{{- fail "postgresql.enabled=true requires username/password/database (postgresql.global.postgresql.auth.*) or set config.db-uri manually" }}
{{- end }}
{{- $autoHost := printf "%s-postgresql" (include "opengist.fullname" .) }}
{{- $autoUri := printf "postgres://%s:%s@%s:%d/%s" $user $pass $autoHost $port $db }}
{{- $_ := set $cfg "db-uri" $autoUri }}
{{- end }}
{{- $replicas := int .Values.replicaCount }}
{{- $index := default "" (index $cfg "index") }}
{{- /* Auto-set Meilisearch host if subchart enabled and host missing */}}
{{- $meiliHost := default "" (index $cfg "index.meili.host") }}
{{- if and .Values.meilisearch.enabled (eq $meiliHost "") }}
{{- $autoMeiliHost := printf "http://%s-meilisearch:7700" (include "opengist.fullname" .) }}
{{- $_ := set $cfg "index.meili.host" $autoMeiliHost }}
{{- if or (eq $index "") (ne $index "meilisearch") }}
{{- $_ := set $cfg "index" "meilisearch" }}
{{- $index = "meilisearch" }}
{{- end }}
{{- end }}
{{- if and (gt $replicas 1) (or (eq $index "") (eq $index "bleve")) }}
{{- fail "replicaCount>1 requires index set to 'meilisearch' (bleve not supported with multiple replicas)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
@@ -9,5 +58,5 @@ metadata:
type: Opaque
stringData:
config.yml: |-
{{- .Values.config | toYaml | nindent 4 }}
{{- $cfg | toYaml | nindent 4 }}
{{- end }}

View File

@@ -0,0 +1,262 @@
{{- if .Values.statefulSet.enabled }}
{{- /*
========================================
VALIDATION BLOCK: Multi-replica requirements
========================================
Enforces constraints for scaling beyond 1 replica:
1. Database: Must use PostgreSQL/MySQL (not SQLite)
2. Persistence: Must be enabled
3. Storage sharing: Must use either existingClaim or mode=shared with create.enabled
4. Access mode: For mode=shared + create, must specify ReadWriteMany
*/}}
{{- $replicas := int .Values.replicaCount }}
{{- $dburi := "" }}
{{- if and .Values.config (hasKey .Values.config "dbUri") }}
{{- $dburi = (index .Values.config "dbUri") }}
{{- else if and .Values.config (hasKey .Values.config "db-uri") }}
{{- $dburi = (index .Values.config "db-uri") }}
{{- end }}
{{- $scheme := "" }}
{{- if ne $dburi "" }}
{{- $parts := splitList "://" $dburi }}
{{- if gt (len $parts) 0 }}
{{- $scheme = lower (index $parts 0) }}
{{- end }}
{{- end }}
{{- $multiAllowed := or (eq $scheme "postgres") (eq $scheme "postgresql") (eq $scheme "mysql") (eq $scheme "mariadb") }}
{{- $p := .Values.persistence }}
{{- $mode := default "perReplica" $p.mode }}
{{- $hasExisting := ne (default "" $p.existingClaim) "" }}
{{- $isShared := eq $mode "shared" }}
{{- /* Fail fast: Database validation */}}
{{- if and (gt $replicas 1) (not $multiAllowed) }}
{{- fail (printf "replicaCount=%d requires PostgreSQL/MySQL config.db-uri; scheme '%s' unsupported" $replicas $scheme) }}
{{- end }}
{{- /* Fail fast: Persistence must be enabled */}}
{{- if and (gt $replicas 1) (not $p.enabled) }}
{{- fail (printf "replicaCount=%d requires persistence.enabled=true" $replicas) }}
{{- end }}
{{- /* Fail fast: Prevent per-replica PVC divergence */}}
{{- if and (gt $replicas 1) (not (or $hasExisting $isShared)) }}
{{- fail (printf "replicaCount=%d requires either persistence.existingClaim (shared RWX PVC) OR persistence.mode=shared to create one; perReplica PVCs would diverge" $replicas) }}
{{- end }}
{{- /* Fail fast: Shared mode requires PVC source */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) (hasKey $p "create") (not (get $p.create "enabled")) }}
{{- fail (printf "persistence.mode=shared but neither existingClaim nor create.enabled=true provided") }}
{{- end }}
{{- /* Fail fast: Auto-created shared PVC must be RWX */}}
{{- if and (gt $replicas 1) $isShared (not $hasExisting) $p.create.enabled }}
{{- $am := list }}
{{- if hasKey $p.create "accessModes" }}
{{- $am = $p.create.accessModes }}
{{- end }}
{{- $rwxOk := false }}
{{- range $am }}
{{- if or (eq . "ReadWriteMany") (eq . "RWX") }}
{{- $rwxOk = true }}
{{- end }}
{{- end }}
{{- if not $rwxOk }}
{{- fail "persistence.mode=shared create.accessModes must include ReadWriteMany for multi-replica" }}
{{- end }}
{{- end }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "opengist.fullname" . }}
namespace: {{ .Values.namespace | default .Release.Namespace }}
labels:
{{- include "opengist.labels" . | nindent 4 }}
{{- if .Values.deployment.labels }}
{{- toYaml .Values.deployment.labels | nindent 4 }}
{{- end }}
{{- with .Values.deployment.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
serviceName: {{ include "opengist.fullname" . }}-http
podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }}
updateStrategy:
{{- toYaml .Values.statefulSet.updateStrategy | nindent 2 }}
selector:
matchLabels:
{{- include "opengist.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "opengist.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- if .Values.deployment.terminationGracePeriodSeconds }}
terminationGracePeriodSeconds: {{ .Values.deployment.terminationGracePeriodSeconds }}
{{- end }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "opengist.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
initContainers:
- name: init-config
image: busybox:1.37
imagePullPolicy: IfNotPresent
command: ['sh', '-c', 'cp /init/config/config.yml /config-volume/config.yml']
volumeMounts:
- name: config-secret
mountPath: /init/config
- name: config-volume
mountPath: /config-volume
{{- if .Values.deployment.env }}
env:
{{- toYaml .Values.deployment.env | nindent 12 }}
{{- end }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.http.port }}
protocol: TCP
{{- if .Values.service.ssh.enabled }}
- name: ssh
containerPort: {{ .Values.service.ssh.port }}
protocol: TCP
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
{{- toYaml (omit .Values.livenessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
{{- toYaml (omit .Values.readinessProbe "enabled") | nindent 12 }}
httpGet:
port: http
path: /healthcheck
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config-volume
mountPath: /config.yml
subPath: config.yml
- name: opengist-data
mountPath: /opengist
{{- if gt (len .Values.extraVolumeMounts) 0 }}
{{- toYaml .Values.extraVolumeMounts | nindent 12 }}
{{- end }}
volumes:
- name: config-secret
secret:
secretName: {{ include "opengist.secretName" . }}
defaultMode: 511
- name: config-volume
emptyDir: {}
{{- /*
========================================
VOLUME MOUNTING DECISION TREE
========================================
Priority order:
1. existingClaim (user-provided PVC) → mount directly
2. mode=shared (chart-created PVC) → mount shared PVC
3. mode=perReplica → use volumeClaimTemplates (defined below)
4. persistence disabled → use emptyDir (ephemeral)
*/}}
{{- if .Values.persistence.enabled }}
{{- if ne (default "" .Values.persistence.existingClaim) "" }}
{{- /* User-provided existing claim: mount directly */}}
- name: opengist-data
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim }}
{{- else if eq (default "perReplica" .Values.persistence.mode) "shared" }}
{{- /* Chart creates shared PVC (via pvc-shared.yaml), reference by name */}}
- name: opengist-data
persistentVolumeClaim:
claimName: {{ include "opengist.fullname" . }}-{{ default "shared" .Values.persistence.create.nameSuffix }}
{{- else if not .Values.persistence.enabled }}
- name: opengist-data
emptyDir: {}
{{- end }}
{{- else }}
- name: opengist-data
emptyDir: {}
{{- end }}
{{- if gt (len .Values.extraVolumes) 0 }}
{{- toYaml .Values.extraVolumes | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- /*
========================================
VOLUMECLAIMTEMPLATES DECISION TREE
========================================
volumeClaimTemplates are ONLY used for perReplica mode when:
- persistence.enabled=true
- persistence.existingClaim is empty
- persistence.mode=perReplica (default)
This creates one PVC per replica (RWO typically).
NOT used when:
- existingClaim is set (PVC already exists, referenced in volumes above)
- mode=shared (standalone PVC created via pvc-shared.yaml)
- persistence disabled (emptyDir used)
WARNING: perReplica + replicaCount>1 causes data divergence. Use shared mode for multi-replica.
*/}}
{{- if and .Values.persistence.enabled (ne (default "" .Values.persistence.existingClaim) "") }}
{{- /* existingClaim path: no volumeClaimTemplates, already mounted above */}}
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "shared") }}
{{- /* shared mode: no volumeClaimTemplates, standalone PVC rendered via pvc-shared.yaml */}}
{{- else if and .Values.persistence.enabled (eq (default "perReplica" .Values.persistence.mode) "perReplica") }}
volumeClaimTemplates:
- metadata:
name: opengist-data
labels:
{{- include "opengist.labels" . | nindent 10 }}
{{- with .Values.persistence.annotations }}
annotations:
{{- toYaml . | nindent 10 }}
{{- end }}
spec:
accessModes:
{{- .Values.persistence.accessModes | toYaml | nindent 10 }}
volumeMode: Filesystem
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size | default "10Gi" }}
{{- end }}
{{- end }}