From 806f0c0791bcf37bfa0b6e559f4ef337697cfe6c Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:12:24 +0100 Subject: [PATCH 01/12] feat(keycloak): add Keycloak Helm chart using upstream image Add a production-grade Keycloak chart that deploys the upstream quay.io/keycloak/keycloak image with native KC_* environment variable configuration. Supports PostgreSQL, MySQL, MSSQL, and dev (H2) databases, jdbc-ping and kubernetes (dns-ping) clustering, optional build optimization init container, TLS passthrough, Prometheus ServiceMonitor, and all standard Kubernetes deployment knobs. Closes #39 Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/Chart.yaml | 34 ++ charts/keycloak/ci/e2e-values.yaml | 14 + charts/keycloak/templates/NOTES.txt | 34 ++ charts/keycloak/templates/_helpers.tpl | 161 ++++++ charts/keycloak/templates/configmap.yaml | 9 + charts/keycloak/templates/deployment.yaml | 189 +++++++ .../keycloak/templates/headless-service.yaml | 21 + charts/keycloak/templates/ingress.yaml | 42 ++ charts/keycloak/templates/pvc.yaml | 22 + charts/keycloak/templates/service.yaml | 24 + charts/keycloak/templates/serviceaccount.yaml | 17 + charts/keycloak/templates/servicemonitor.yaml | 25 + .../templates/tests/test-connection.yaml | 46 ++ charts/keycloak/tests/configmap_test.yaml | 232 +++++++++ charts/keycloak/tests/deployment_test.yaml | 476 ++++++++++++++++++ .../keycloak/tests/headless-service_test.yaml | 49 ++ charts/keycloak/tests/ingress_test.yaml | 102 ++++ charts/keycloak/tests/pvc_test.yaml | 57 +++ charts/keycloak/tests/service_test.yaml | 79 +++ .../keycloak/tests/serviceaccount_test.yaml | 57 +++ .../keycloak/tests/servicemonitor_test.yaml | 63 +++ charts/keycloak/values.yaml | 235 +++++++++ 22 files changed, 1988 insertions(+) create mode 100644 charts/keycloak/Chart.yaml create mode 100644 charts/keycloak/ci/e2e-values.yaml create mode 100644 charts/keycloak/templates/NOTES.txt create mode 100644 charts/keycloak/templates/_helpers.tpl create mode 100644 charts/keycloak/templates/configmap.yaml create mode 100644 charts/keycloak/templates/deployment.yaml create mode 100644 charts/keycloak/templates/headless-service.yaml create mode 100644 charts/keycloak/templates/ingress.yaml create mode 100644 charts/keycloak/templates/pvc.yaml create mode 100644 charts/keycloak/templates/service.yaml create mode 100644 charts/keycloak/templates/serviceaccount.yaml create mode 100644 charts/keycloak/templates/servicemonitor.yaml create mode 100644 charts/keycloak/templates/tests/test-connection.yaml create mode 100644 charts/keycloak/tests/configmap_test.yaml create mode 100644 charts/keycloak/tests/deployment_test.yaml create mode 100644 charts/keycloak/tests/headless-service_test.yaml create mode 100644 charts/keycloak/tests/ingress_test.yaml create mode 100644 charts/keycloak/tests/pvc_test.yaml create mode 100644 charts/keycloak/tests/service_test.yaml create mode 100644 charts/keycloak/tests/serviceaccount_test.yaml create mode 100644 charts/keycloak/tests/servicemonitor_test.yaml create mode 100644 charts/keycloak/values.yaml diff --git a/charts/keycloak/Chart.yaml b/charts/keycloak/Chart.yaml new file mode 100644 index 0000000..25be5c5 --- /dev/null +++ b/charts/keycloak/Chart.yaml @@ -0,0 +1,34 @@ +apiVersion: v2 +name: keycloak +description: A Helm chart for deploying Keycloak IAM using the upstream quay.io/keycloak/keycloak image on Kubernetes +type: application +version: 0.1.0 +appVersion: "26.5.0" +keywords: + - keycloak + - iam + - identity + - authentication + - oauth2 + - oidc + - sso + - self-hosted + - kubernetes +home: https://www.keycloak.org +sources: + - https://github.com/keycloak/keycloak + - https://github.com/KitStream/helms +maintainers: + - name: KitStream + url: https://github.com/KitStream +icon: https://www.keycloak.org/resources/images/keycloak_icon_512px.svg +annotations: + artifacthub.io/license: Apache-2.0 + artifacthub.io/links: | + - name: Keycloak + url: https://www.keycloak.org + - name: Source + url: https://github.com/KitStream/helms + artifacthub.io/changes: | + - kind: added + description: Initial Keycloak chart with upstream image support diff --git a/charts/keycloak/ci/e2e-values.yaml b/charts/keycloak/ci/e2e-values.yaml new file mode 100644 index 0000000..920d2f1 --- /dev/null +++ b/charts/keycloak/ci/e2e-values.yaml @@ -0,0 +1,14 @@ +database: + type: dev + +service: + type: ClusterIP + +startupProbe: + httpGet: + path: /health/started + port: management + initialDelaySeconds: 30 + periodSeconds: 5 + failureThreshold: 120 + timeoutSeconds: 3 diff --git a/charts/keycloak/templates/NOTES.txt b/charts/keycloak/templates/NOTES.txt new file mode 100644 index 0000000..21d1fbc --- /dev/null +++ b/charts/keycloak/templates/NOTES.txt @@ -0,0 +1,34 @@ +Keycloak has been deployed successfully! + +Components: + HTTP Service: {{ include "keycloak.fullname" . }}:{{ .Values.service.httpPort }} + Management Service: {{ include "keycloak.fullname" . }}:{{ .Values.service.managementPort }} (health + metrics) + Headless Service: {{ include "keycloak.fullname" . }}-headless:{{ .Values.headlessService.jgroupsPort }} (JGroups clustering) + +{{- if .Values.ingress.enabled }} + + Ingress: + {{- range .Values.ingress.hosts }} + https://{{ .host }}{{ (index .paths 0).path }} + {{- end }} +{{- end }} + + Database: {{ .Values.database.type }} + Cache Stack: {{ .Values.cache.stack }} + Replicas: {{ .Values.replicaCount }} + +{{- if and .Values.admin.username .Values.admin.password.secretName }} + + Admin Console: + Username: {{ .Values.admin.username }} + Password stored in Secret: {{ .Values.admin.password.secretName }} (key: {{ .Values.admin.password.secretKey }}) + Retrieve with: + kubectl get secret {{ .Values.admin.password.secretName }} -n {{ .Release.Namespace }} -o jsonpath='{.data.{{ .Values.admin.password.secretKey }}}' | base64 -d +{{- end }} + +{{- if eq .Values.database.type "dev" }} + + NOTE: You are using the embedded H2 development database. + This is NOT suitable for production use. Configure an external + PostgreSQL, MySQL, or MSSQL database for production deployments. +{{- end }} diff --git a/charts/keycloak/templates/_helpers.tpl b/charts/keycloak/templates/_helpers.tpl new file mode 100644 index 0000000..1427495 --- /dev/null +++ b/charts/keycloak/templates/_helpers.tpl @@ -0,0 +1,161 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "keycloak.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "keycloak.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 "keycloak.chart" -}} + {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "keycloak.labels" -}} +helm.sh/chart: {{ include "keycloak.chart" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "keycloak.selectorLabels" -}} +app.kubernetes.io/name: {{ include "keycloak.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +All labels (common + selector) +*/}} +{{- define "keycloak.allLabels" -}} +{{ include "keycloak.labels" . }} +{{ include "keycloak.selectorLabels" . }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "keycloak.serviceAccountName" -}} + {{- if .Values.serviceAccount.create }} +{{- default (include "keycloak.fullname" .) .Values.serviceAccount.name }} + {{- else }} +{{- default "default" .Values.serviceAccount.name }} + {{- end }} +{{- end }} + +{{/* +Headless service FQDN for dns-ping JGroups discovery +*/}} +{{- define "keycloak.headlessServiceFQDN" -}} + {{- printf "%s-headless.%s.svc.cluster.local" (include "keycloak.fullname" .) .Release.Namespace }} +{{- end }} + +{{/* ===== Database helpers ===== */}} + +{{/* +keycloak.database.isExternal — true when database.type is not dev. +*/}} +{{- define "keycloak.database.isExternal" -}} +{{- ne .Values.database.type "dev" -}} +{{- end }} + +{{/* +keycloak.database.vendor — maps database.type to KC_DB value. +*/}} +{{- define "keycloak.database.vendor" -}} + {{- if eq .Values.database.type "postgresql" -}}postgres + {{- else if eq .Values.database.type "mysql" -}}mysql + {{- else if eq .Values.database.type "mssql" -}}mssql + {{- else -}}dev-file + {{- end -}} +{{- end }} + +{{/* +keycloak.database.port — resolves the effective database port. +*/}} +{{- define "keycloak.database.port" -}} + {{- if .Values.database.port -}} +{{- .Values.database.port -}} + {{- else if eq .Values.database.type "postgresql" -}}5432 + {{- else if eq .Values.database.type "mysql" -}}3306 + {{- else if eq .Values.database.type "mssql" -}}1433 + {{- else -}}0 + {{- end -}} +{{- end }} + +{{/* +keycloak.envConfigData — renders the ConfigMap data block for KC_* environment +variables. Used both in the ConfigMap template and as a checksum source for +rolling deployment updates. +*/}} +{{- define "keycloak.envConfigData" -}} +KC_HEALTH_ENABLED: {{ .Values.healthEnabled | quote }} +KC_METRICS_ENABLED: {{ .Values.metrics.enabled | quote }} +KC_LOG_LEVEL: {{ .Values.logLevel | quote }} +KC_HTTP_ENABLED: {{ .Values.httpEnabled | quote }} +{{- if .Values.hostname }} +KC_HOSTNAME: {{ .Values.hostname | quote }} +{{- end }} +{{- if .Values.hostnameAdmin }} +KC_HOSTNAME_ADMIN: {{ .Values.hostnameAdmin | quote }} +{{- end }} +KC_HOSTNAME_STRICT: {{ .Values.hostnameStrict | quote }} +{{- if .Values.proxyHeaders }} +KC_PROXY_HEADERS: {{ .Values.proxyHeaders | quote }} +{{- end }} +{{- if .Values.features }} +KC_FEATURES: {{ .Values.features | quote }} +{{- end }} +{{- if .Values.tls.enabled }} +KC_HTTPS_CERTIFICATE_FILE: "/opt/keycloak/conf/tls/tls.crt" +KC_HTTPS_CERTIFICATE_KEY_FILE: "/opt/keycloak/conf/tls/tls.key" +{{- end }} +{{- if eq (include "keycloak.database.isExternal" .) "true" }} +KC_DB: {{ include "keycloak.database.vendor" . | quote }} +KC_DB_URL_HOST: {{ .Values.database.host | quote }} +KC_DB_URL_PORT: {{ include "keycloak.database.port" . | quote }} +KC_DB_URL_DATABASE: {{ .Values.database.name | quote }} +KC_DB_USERNAME: {{ .Values.database.user | quote }} + {{- if .Values.database.poolMinSize }} +KC_DB_POOL_MIN_SIZE: {{ .Values.database.poolMinSize | quote }} + {{- end }} + {{- if .Values.database.poolInitialSize }} +KC_DB_POOL_INITIAL_SIZE: {{ .Values.database.poolInitialSize | quote }} + {{- end }} + {{- if .Values.database.poolMaxSize }} +KC_DB_POOL_MAX_SIZE: {{ .Values.database.poolMaxSize | quote }} + {{- end }} + {{- if .Values.database.sslMode }} + {{- if eq .Values.database.type "postgresql" }} +KC_DB_URL_PROPERTIES: {{ printf "?sslmode=%s" .Values.database.sslMode | quote }} + {{- end }} + {{- end }} +{{- end }} +KC_CACHE: "ispn" +KC_CACHE_STACK: {{ .Values.cache.stack | quote }} +{{- if eq .Values.cache.stack "kubernetes" }} +KC_CACHE_CONFIG_FILE: "cache-ispn.xml" +JAVA_OPTS_APPEND: {{ printf "-Djgroups.dns.query=%s" (include "keycloak.headlessServiceFQDN" .) | quote }} +{{- end }} +{{- end }} diff --git a/charts/keycloak/templates/configmap.yaml b/charts/keycloak/templates/configmap.yaml new file mode 100644 index 0000000..85d33a8 --- /dev/null +++ b/charts/keycloak/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "keycloak.fullname" . }}-env + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} +data: + {{- include "keycloak.envConfigData" . | nindent 2 }} diff --git a/charts/keycloak/templates/deployment.yaml b/charts/keycloak/templates/deployment.yaml new file mode 100644 index 0000000..d5573f0 --- /dev/null +++ b/charts/keycloak/templates/deployment.yaml @@ -0,0 +1,189 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "keycloak.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/env: {{ include "keycloak.envConfigData" . | sha256sum }} +{{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} +{{- end }} + labels: + {{- include "keycloak.selectorLabels" . | nindent 8 }} +{{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} +{{- end }} + spec: + serviceAccountName: {{ include "keycloak.serviceAccountName" . }} +{{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} +{{- end }} +{{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} +{{- end }} +{{- if .Values.build.enabled }} + initContainers: + - name: build + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/opt/keycloak/bin/kc.sh"] + args: ["build"] + envFrom: + - configMapRef: + name: {{ include "keycloak.fullname" . }}-env + env: +{{- if eq (include "keycloak.database.isExternal" .) "true" }} + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.database.password.secretName }} + key: {{ .Values.database.password.secretKey }} +{{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + volumeMounts: + - name: build-cache + mountPath: /opt/keycloak/lib/quarkus +{{- end }} + containers: + - name: keycloak + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: +{{- if eq .Values.database.type "dev" }} + - start-dev +{{- else }} + - start + {{- if .Values.build.enabled }} + - --optimized + {{- end }} +{{- end }} + envFrom: + - configMapRef: + name: {{ include "keycloak.fullname" . }}-env +{{- if .Values.extraEnvVarsSecret }} + - secretRef: + name: {{ .Values.extraEnvVarsSecret }} +{{- end }} +{{- $hasAdmin := and .Values.admin.username .Values.admin.password.secretName }} +{{- $hasExternalDB := eq (include "keycloak.database.isExternal" .) "true" }} +{{- if or $hasAdmin $hasExternalDB .Values.extraEnvVars }} + env: + {{- if $hasAdmin }} + - name: KEYCLOAK_ADMIN + value: {{ .Values.admin.username | quote }} + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.admin.password.secretName }} + key: {{ .Values.admin.password.secretKey }} + {{- end }} + {{- if $hasExternalDB }} + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.database.password.secretName }} + key: {{ .Values.database.password.secretKey }} + {{- end }} + {{- with .Values.extraEnvVars }} + {{- toYaml . | nindent 12 }} + {{- end }} +{{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: management + containerPort: 9000 + protocol: TCP + - name: jgroups + containerPort: 7800 + protocol: TCP +{{- if .Values.tls.enabled }} + - name: https + containerPort: 8443 + protocol: TCP +{{- end }} + volumeMounts: +{{- if .Values.build.enabled }} + - name: build-cache + mountPath: /opt/keycloak/lib/quarkus + readOnly: true +{{- end }} +{{- if .Values.tls.enabled }} + - name: tls-certs + mountPath: /opt/keycloak/conf/tls + readOnly: true +{{- end }} +{{- if .Values.persistence.enabled }} + - name: providers + mountPath: /opt/keycloak/providers +{{- end }} +{{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.startupProbe }} + startupProbe: + {{- toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} +{{- end }} +{{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} +{{- end }} + volumes: +{{- if .Values.build.enabled }} + - name: build-cache + emptyDir: {} +{{- end }} +{{- if .Values.tls.enabled }} + - name: tls-certs + secret: + secretName: {{ .Values.tls.secretName }} +{{- end }} +{{- if .Values.persistence.enabled }} + - name: providers + persistentVolumeClaim: + claimName: {{ include "keycloak.fullname" . }} +{{- end }} +{{- with .Values.extraVolumes }} + {{- toYaml . | 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 }} diff --git a/charts/keycloak/templates/headless-service.yaml b/charts/keycloak/templates/headless-service.yaml new file mode 100644 index 0000000..0c2d544 --- /dev/null +++ b/charts/keycloak/templates/headless-service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.fullname" . }}-headless + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.headlessService.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + clusterIP: None + ports: + - name: jgroups + port: {{ .Values.headlessService.jgroupsPort }} + targetPort: jgroups + protocol: TCP + selector: + {{- include "keycloak.selectorLabels" . | nindent 4 }} diff --git a/charts/keycloak/templates/ingress.yaml b/charts/keycloak/templates/ingress.yaml new file mode 100644 index 0000000..a1d593e --- /dev/null +++ b/charts/keycloak/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - secretName: {{ .secretName }} + hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ include "keycloak.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/pvc.yaml b/charts/keycloak/templates/pvc.yaml new file mode 100644 index 0000000..e07205a --- /dev/null +++ b/charts/keycloak/templates/pvc.yaml @@ -0,0 +1,22 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/charts/keycloak/templates/service.yaml b/charts/keycloak/templates/service.yaml new file mode 100644 index 0000000..f0b7faf --- /dev/null +++ b/charts/keycloak/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.httpPort }} + targetPort: http + protocol: TCP + - name: management + port: {{ .Values.service.managementPort }} + targetPort: management + protocol: TCP + selector: + {{- include "keycloak.selectorLabels" . | nindent 4 }} diff --git a/charts/keycloak/templates/serviceaccount.yaml b/charts/keycloak/templates/serviceaccount.yaml new file mode 100644 index 0000000..c1d7c40 --- /dev/null +++ b/charts/keycloak/templates/serviceaccount.yaml @@ -0,0 +1,17 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "keycloak.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} + {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/servicemonitor.yaml b/charts/keycloak/templates/servicemonitor.yaml new file mode 100644 index 0000000..42187fb --- /dev/null +++ b/charts/keycloak/templates/servicemonitor.yaml @@ -0,0 +1,25 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "keycloak.selectorLabels" . | nindent 6 }} + endpoints: + - port: management + path: /metrics + {{- with .Values.metrics.serviceMonitor.interval }} + interval: {{ . }} + {{- end }} + {{- with .Values.metrics.serviceMonitor.scrapeTimeout }} + scrapeTimeout: {{ . }} + {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/tests/test-connection.yaml b/charts/keycloak/templates/tests/test-connection.yaml new file mode 100644 index 0000000..eab9554 --- /dev/null +++ b/charts/keycloak/templates/tests/test-connection.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "keycloak.fullname" . }}-test" + namespace: {{ .Release.Namespace }} + labels: + {{- include "keycloak.allLabels" . | nindent 4 }} + annotations: + helm.sh/hook: test + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + restartPolicy: Never + containers: + - name: test + image: busybox:1.37 + command: ['sh', '-c'] + args: + - | + set -e + FAIL=0 + + echo "==> Testing Keycloak health endpoint..." + RESPONSE=$(wget -qO- --timeout=10 http://{{ include "keycloak.fullname" . }}:{{ .Values.service.managementPort }}/health/ready 2>&1) || { + echo "FAIL: could not reach health endpoint" + FAIL=1 + } + + if [ "$FAIL" -eq 0 ]; then + echo "Response: $RESPONSE" + case "$RESPONSE" in + *UP*) + echo "PASS: Keycloak health check returned UP" + ;; + *) + echo "FAIL: unexpected health response: $RESPONSE" + FAIL=1 + ;; + esac + fi + + echo "" + if [ "$FAIL" -ne 0 ]; then + echo "Some tests FAILED" + exit 1 + fi + echo "All tests PASSED" diff --git a/charts/keycloak/tests/configmap_test.yaml b/charts/keycloak/tests/configmap_test.yaml new file mode 100644 index 0000000..4106f51 --- /dev/null +++ b/charts/keycloak/tests/configmap_test.yaml @@ -0,0 +1,232 @@ +suite: configmap tests +templates: + - templates/configmap.yaml +tests: + - it: should render a ConfigMap + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + + - it: should include health and metrics by default + asserts: + - equal: + path: data.KC_HEALTH_ENABLED + value: "true" + - equal: + path: data.KC_METRICS_ENABLED + value: "true" + + - it: should set log level + set: + logLevel: "debug" + asserts: + - equal: + path: data.KC_LOG_LEVEL + value: "debug" + + - it: should set hostname when configured + set: + hostname: keycloak.example.com + asserts: + - equal: + path: data.KC_HOSTNAME + value: "keycloak.example.com" + + - it: should not set hostname when empty + asserts: + - notExists: + path: data.KC_HOSTNAME + + - it: should set admin hostname when configured + set: + hostnameAdmin: admin.example.com + asserts: + - equal: + path: data.KC_HOSTNAME_ADMIN + value: "admin.example.com" + + - it: should set hostname strict + asserts: + - equal: + path: data.KC_HOSTNAME_STRICT + value: "true" + + - it: should set proxy headers when configured + set: + proxyHeaders: xforwarded + asserts: + - equal: + path: data.KC_PROXY_HEADERS + value: "xforwarded" + + - it: should not set proxy headers when empty + asserts: + - notExists: + path: data.KC_PROXY_HEADERS + + - it: should set features when configured + set: + features: "token-exchange,admin-fine-grained-authz" + asserts: + - equal: + path: data.KC_FEATURES + value: "token-exchange,admin-fine-grained-authz" + + - it: should set TLS certificate paths when enabled + set: + tls.enabled: true + tls.secretName: kc-tls + asserts: + - equal: + path: data.KC_HTTPS_CERTIFICATE_FILE + value: "/opt/keycloak/conf/tls/tls.crt" + - equal: + path: data.KC_HTTPS_CERTIFICATE_KEY_FILE + value: "/opt/keycloak/conf/tls/tls.key" + + - it: should not set TLS paths when disabled + asserts: + - notExists: + path: data.KC_HTTPS_CERTIFICATE_FILE + + # ── Database ────────────────────────────────────────────────────────── + + - it: should not set DB vars for dev database + asserts: + - notExists: + path: data.KC_DB + + - it: should set PostgreSQL database vars + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + asserts: + - equal: + path: data.KC_DB + value: "postgres" + - equal: + path: data.KC_DB_URL_HOST + value: "pg.example.com" + - equal: + path: data.KC_DB_URL_PORT + value: "5432" + - equal: + path: data.KC_DB_URL_DATABASE + value: "keycloak" + - equal: + path: data.KC_DB_USERNAME + value: "keycloak" + + - it: should set MySQL database vars + set: + database.type: mysql + database.host: mysql.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: mysql-secret + asserts: + - equal: + path: data.KC_DB + value: "mysql" + - equal: + path: data.KC_DB_URL_PORT + value: "3306" + + - it: should set MSSQL database vars + set: + database.type: mssql + database.host: mssql.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: mssql-secret + asserts: + - equal: + path: data.KC_DB + value: "mssql" + - equal: + path: data.KC_DB_URL_PORT + value: "1433" + + - it: should use custom database port + set: + database.type: postgresql + database.host: pg.example.com + database.port: 5433 + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + asserts: + - equal: + path: data.KC_DB_URL_PORT + value: "5433" + + - it: should set connection pool sizes when configured + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + database.poolMinSize: "5" + database.poolInitialSize: "10" + database.poolMaxSize: "50" + asserts: + - equal: + path: data.KC_DB_POOL_MIN_SIZE + value: "5" + - equal: + path: data.KC_DB_POOL_INITIAL_SIZE + value: "10" + - equal: + path: data.KC_DB_POOL_MAX_SIZE + value: "50" + + - it: should set PostgreSQL SSL mode + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + database.sslMode: "verify-full" + asserts: + - equal: + path: data.KC_DB_URL_PROPERTIES + value: "?sslmode=verify-full" + + # ── Clustering ──────────────────────────────────────────────────────── + + - it: should set jdbc-ping cache stack by default + asserts: + - equal: + path: data.KC_CACHE + value: "ispn" + - equal: + path: data.KC_CACHE_STACK + value: "jdbc-ping" + + - it: should configure kubernetes cache stack with dns query + set: + cache.stack: kubernetes + asserts: + - equal: + path: data.KC_CACHE_STACK + value: "kubernetes" + - equal: + path: data.KC_CACHE_CONFIG_FILE + value: "cache-ispn.xml" + - equal: + path: data.JAVA_OPTS_APPEND + value: "-Djgroups.dns.query=RELEASE-NAME-keycloak-headless.NAMESPACE.svc.cluster.local" + + - it: should not set dns query for jdbc-ping + asserts: + - notExists: + path: data.JAVA_OPTS_APPEND + - notExists: + path: data.KC_CACHE_CONFIG_FILE diff --git a/charts/keycloak/tests/deployment_test.yaml b/charts/keycloak/tests/deployment_test.yaml new file mode 100644 index 0000000..a845b45 --- /dev/null +++ b/charts/keycloak/tests/deployment_test.yaml @@ -0,0 +1,476 @@ +suite: deployment tests +templates: + - templates/deployment.yaml +tests: + - it: should render a Deployment + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Deployment + + - it: should use default image from appVersion + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "quay.io/keycloak/keycloak:26.5.0" + + - it: should use custom image tag when set + set: + image.tag: "25.0.0" + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "quay.io/keycloak/keycloak:25.0.0" + + - it: should use custom image repository + set: + image.repository: my-registry/keycloak + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "my-registry/keycloak:26.5.0" + + - it: should set replica count + set: + replicaCount: 3 + asserts: + - equal: + path: spec.replicas + value: 3 + + - it: should include selector labels + asserts: + - isSubset: + path: spec.selector.matchLabels + content: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: RELEASE-NAME + + - it: should include pod labels + asserts: + - isSubset: + path: spec.template.metadata.labels + content: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: RELEASE-NAME + + - it: should add custom pod annotations + set: + podAnnotations: + prometheus.io/scrape: "true" + asserts: + - isSubset: + path: spec.template.metadata.annotations + content: + prometheus.io/scrape: "true" + + - it: should add custom pod labels + set: + podLabels: + custom-label: custom-value + asserts: + - isSubset: + path: spec.template.metadata.labels + content: + custom-label: custom-value + + - it: should include env checksum annotation + asserts: + - exists: + path: spec.template.metadata.annotations.checksum/env + + # ── Ports ────────────────────────────────────────────────────────────── + + - it: should expose http, management, and jgroups ports + asserts: + - contains: + path: spec.template.spec.containers[0].ports + content: + name: http + containerPort: 8080 + protocol: TCP + - contains: + path: spec.template.spec.containers[0].ports + content: + name: management + containerPort: 9000 + protocol: TCP + - contains: + path: spec.template.spec.containers[0].ports + content: + name: jgroups + containerPort: 7800 + protocol: TCP + + - it: should expose https port when TLS is enabled + set: + tls.enabled: true + tls.secretName: kc-tls + asserts: + - contains: + path: spec.template.spec.containers[0].ports + content: + name: https + containerPort: 8443 + protocol: TCP + + - it: should not expose https port when TLS is disabled + asserts: + - notContains: + path: spec.template.spec.containers[0].ports + content: + name: https + any: true + + # ── Args ─────────────────────────────────────────────────────────────── + + - it: should use start-dev for dev database + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: start-dev + + - it: should use start for external database + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: start + - notContains: + path: spec.template.spec.containers[0].args + content: start-dev + + - it: should add --optimized flag when build is enabled + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + build.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --optimized + + # ── Environment Variables ────────────────────────────────────────────── + + - it: should mount env configmap via envFrom + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + configMapRef: + name: RELEASE-NAME-keycloak-env + + - it: should inject admin credentials when configured + set: + admin.username: admin + admin.password.secretName: admin-secret + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: KEYCLOAK_ADMIN + value: "admin" + - contains: + path: spec.template.spec.containers[0].env + content: + name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: admin-secret + key: password + + - it: should not inject admin credentials when not configured + asserts: + - notExists: + path: spec.template.spec.containers[0].env + + - it: should inject DB_PASSWORD for external database + set: + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + database.password.secretKey: dbpass + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: pg-secret + key: dbpass + + - it: should not inject DB_PASSWORD for dev database + asserts: + - notExists: + path: spec.template.spec.containers[0].env + + - it: should mount extraEnvVarsSecret when set + set: + extraEnvVarsSecret: my-extra-secret + asserts: + - contains: + path: spec.template.spec.containers[0].envFrom + content: + secretRef: + name: my-extra-secret + + - it: should include extra env vars + set: + extraEnvVars: + - name: MY_VAR + value: my-value + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: MY_VAR + value: my-value + + # ── Build Init Container ────────────────────────────────────────────── + + - it: should not have init containers by default + asserts: + - notExists: + path: spec.template.spec.initContainers + + - it: should add build init container when enabled + set: + build.enabled: true + asserts: + - equal: + path: spec.template.spec.initContainers[0].name + value: build + - equal: + path: spec.template.spec.initContainers[0].args[0] + value: build + + - it: should use same image for build init container + set: + build.enabled: true + asserts: + - equal: + path: spec.template.spec.initContainers[0].image + value: "quay.io/keycloak/keycloak:26.5.0" + + - it: should set hardened securityContext on build init container + set: + build.enabled: true + asserts: + - equal: + path: spec.template.spec.initContainers[0].securityContext.runAsNonRoot + value: true + - equal: + path: spec.template.spec.initContainers[0].securityContext.allowPrivilegeEscalation + value: false + + - it: should inject DB_PASSWORD into build init container for external database + set: + build.enabled: true + database.type: postgresql + database.host: pg.example.com + database.user: keycloak + database.name: keycloak + database.password.secretName: pg-secret + asserts: + - contains: + path: spec.template.spec.initContainers[0].env + content: + name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: pg-secret + key: password + + # ── Volumes ──────────────────────────────────────────────────────────── + + - it: should mount TLS certs when enabled + set: + tls.enabled: true + tls.secretName: kc-tls + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: tls-certs + mountPath: /opt/keycloak/conf/tls + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: tls-certs + secret: + secretName: kc-tls + + - it: should mount PVC when persistence is enabled + set: + persistence.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: providers + mountPath: /opt/keycloak/providers + - contains: + path: spec.template.spec.volumes + content: + name: providers + persistentVolumeClaim: + claimName: RELEASE-NAME-keycloak + + - it: should mount extra volumes + set: + extraVolumes: + - name: my-theme + configMap: + name: my-theme-cm + extraVolumeMounts: + - name: my-theme + mountPath: /opt/keycloak/themes/my-theme + asserts: + - contains: + path: spec.template.spec.volumes + content: + name: my-theme + configMap: + name: my-theme-cm + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: my-theme + mountPath: /opt/keycloak/themes/my-theme + + # ── Probes ───────────────────────────────────────────────────────────── + + - it: should configure startup probe on management port + asserts: + - equal: + path: spec.template.spec.containers[0].startupProbe.httpGet.path + value: /health/started + - equal: + path: spec.template.spec.containers[0].startupProbe.httpGet.port + value: management + + - it: should configure liveness probe on management port + asserts: + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.path + value: /health/live + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.port + value: management + + - it: should configure readiness probe on management port + asserts: + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.path + value: /health/ready + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.port + value: management + + # ── Scheduling ───────────────────────────────────────────────────────── + + - it: should set resource limits when specified + set: + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + asserts: + - equal: + path: spec.template.spec.containers[0].resources + value: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + + - it: should set node selector + set: + nodeSelector: + disktype: ssd + asserts: + - equal: + path: spec.template.spec.nodeSelector + value: + disktype: ssd + + - it: should set tolerations + set: + tolerations: + - key: "dedicated" + operator: "Equal" + value: "keycloak" + effect: "NoSchedule" + asserts: + - contains: + path: spec.template.spec.tolerations + content: + key: "dedicated" + operator: "Equal" + value: "keycloak" + effect: "NoSchedule" + + - it: should set affinity + set: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/name: keycloak + topologyKey: kubernetes.io/hostname + asserts: + - exists: + path: spec.template.spec.affinity.podAntiAffinity + + - it: should set pod security context + set: + podSecurityContext: + runAsUser: 1000 + fsGroup: 2000 + asserts: + - equal: + path: spec.template.spec.securityContext + value: + runAsUser: 1000 + fsGroup: 2000 + + - it: should set container security context + set: + securityContext: + readOnlyRootFilesystem: true + asserts: + - equal: + path: spec.template.spec.containers[0].securityContext + value: + readOnlyRootFilesystem: true + + - it: should set imagePullSecrets + set: + imagePullSecrets: + - name: my-registry + asserts: + - contains: + path: spec.template.spec.imagePullSecrets + content: + name: my-registry diff --git a/charts/keycloak/tests/headless-service_test.yaml b/charts/keycloak/tests/headless-service_test.yaml new file mode 100644 index 0000000..fc4b308 --- /dev/null +++ b/charts/keycloak/tests/headless-service_test.yaml @@ -0,0 +1,49 @@ +suite: headless service tests +templates: + - templates/headless-service.yaml +tests: + - it: should render a headless Service + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Service + - equal: + path: spec.clusterIP + value: None + + - it: should expose jgroups port + asserts: + - contains: + path: spec.ports + content: + name: jgroups + port: 7800 + targetPort: jgroups + protocol: TCP + + - it: should use custom jgroups port + set: + headlessService.jgroupsPort: 7801 + asserts: + - contains: + path: spec.ports + content: + name: jgroups + port: 7801 + targetPort: jgroups + protocol: TCP + + - it: should include selector labels + asserts: + - isSubset: + path: spec.selector + content: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: RELEASE-NAME + + - it: should have -headless suffix in name + asserts: + - equal: + path: metadata.name + value: RELEASE-NAME-keycloak-headless diff --git a/charts/keycloak/tests/ingress_test.yaml b/charts/keycloak/tests/ingress_test.yaml new file mode 100644 index 0000000..7137e4f --- /dev/null +++ b/charts/keycloak/tests/ingress_test.yaml @@ -0,0 +1,102 @@ +suite: ingress tests +templates: + - templates/ingress.yaml +tests: + - it: should not render when disabled + asserts: + - hasDocuments: + count: 0 + + - it: should render when enabled + set: + ingress.enabled: true + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + pathType: Prefix + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Ingress + + - it: should set ingress class name + set: + ingress.enabled: true + ingress.className: nginx + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + asserts: + - equal: + path: spec.ingressClassName + value: nginx + + - it: should set annotations + set: + ingress.enabled: true + ingress.annotations: + cert-manager.io/cluster-issuer: letsencrypt + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + asserts: + - isSubset: + path: metadata.annotations + content: + cert-manager.io/cluster-issuer: letsencrypt + + - it: should set host and paths + set: + ingress.enabled: true + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + pathType: Prefix + - path: /admin + pathType: Prefix + asserts: + - equal: + path: spec.rules[0].host + value: keycloak.example.com + - equal: + path: spec.rules[0].http.paths[0].path + value: / + - equal: + path: spec.rules[0].http.paths[1].path + value: /admin + + - it: should default pathType to Prefix + set: + ingress.enabled: true + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + asserts: + - equal: + path: spec.rules[0].http.paths[0].pathType + value: Prefix + + - it: should set TLS + set: + ingress.enabled: true + ingress.hosts: + - host: keycloak.example.com + paths: + - path: / + ingress.tls: + - secretName: keycloak-tls + hosts: + - keycloak.example.com + asserts: + - equal: + path: spec.tls[0].secretName + value: keycloak-tls + - contains: + path: spec.tls[0].hosts + content: keycloak.example.com diff --git a/charts/keycloak/tests/pvc_test.yaml b/charts/keycloak/tests/pvc_test.yaml new file mode 100644 index 0000000..3b8c1e9 --- /dev/null +++ b/charts/keycloak/tests/pvc_test.yaml @@ -0,0 +1,57 @@ +suite: pvc tests +templates: + - templates/pvc.yaml +tests: + - it: should not render by default + asserts: + - hasDocuments: + count: 0 + + - it: should render when persistence is enabled + set: + persistence.enabled: true + asserts: + - hasDocuments: + count: 1 + - isKind: + of: PersistentVolumeClaim + + - it: should set default access mode and size + set: + persistence.enabled: true + asserts: + - contains: + path: spec.accessModes + content: ReadWriteOnce + - equal: + path: spec.resources.requests.storage + value: 1Gi + + - it: should use custom storage class + set: + persistence.enabled: true + persistence.storageClass: fast-ssd + asserts: + - equal: + path: spec.storageClassName + value: fast-ssd + + - it: should set custom size + set: + persistence.enabled: true + persistence.size: 10Gi + asserts: + - equal: + path: spec.resources.requests.storage + value: 10Gi + + - it: should add annotations + set: + persistence.enabled: true + persistence.annotations: + volume.beta.kubernetes.io/storage-class: "standard" + asserts: + - isSubset: + path: metadata.annotations + content: + volume.beta.kubernetes.io/storage-class: "standard" diff --git a/charts/keycloak/tests/service_test.yaml b/charts/keycloak/tests/service_test.yaml new file mode 100644 index 0000000..12f3cf3 --- /dev/null +++ b/charts/keycloak/tests/service_test.yaml @@ -0,0 +1,79 @@ +suite: service tests +templates: + - templates/service.yaml +tests: + - it: should render a Service + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Service + + - it: should use ClusterIP type by default + asserts: + - equal: + path: spec.type + value: ClusterIP + + - it: should expose http and management ports + asserts: + - contains: + path: spec.ports + content: + name: http + port: 8080 + targetPort: http + protocol: TCP + - contains: + path: spec.ports + content: + name: management + port: 9000 + targetPort: management + protocol: TCP + + - it: should use custom service type + set: + service.type: NodePort + asserts: + - equal: + path: spec.type + value: NodePort + + - it: should use custom ports + set: + service.httpPort: 8081 + service.managementPort: 9001 + asserts: + - contains: + path: spec.ports + content: + name: http + port: 8081 + targetPort: http + protocol: TCP + - contains: + path: spec.ports + content: + name: management + port: 9001 + targetPort: management + protocol: TCP + + - it: should include selector labels + asserts: + - isSubset: + path: spec.selector + content: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: RELEASE-NAME + + - it: should add annotations when set + set: + service.annotations: + service.beta.kubernetes.io/aws-load-balancer-type: nlb + asserts: + - isSubset: + path: metadata.annotations + content: + service.beta.kubernetes.io/aws-load-balancer-type: nlb diff --git a/charts/keycloak/tests/serviceaccount_test.yaml b/charts/keycloak/tests/serviceaccount_test.yaml new file mode 100644 index 0000000..82f1c5f --- /dev/null +++ b/charts/keycloak/tests/serviceaccount_test.yaml @@ -0,0 +1,57 @@ +suite: serviceaccount tests +templates: + - templates/serviceaccount.yaml +tests: + - it: should create a ServiceAccount by default + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ServiceAccount + - equal: + path: metadata.name + value: RELEASE-NAME-keycloak + + - it: should not create a ServiceAccount when disabled + set: + serviceAccount.create: false + asserts: + - hasDocuments: + count: 0 + + - it: should use custom name when set + set: + serviceAccount.name: my-sa + asserts: + - equal: + path: metadata.name + value: my-sa + + - it: should add annotations + set: + serviceAccount.annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::role/my-role + asserts: + - isSubset: + path: metadata.annotations + content: + eks.amazonaws.com/role-arn: arn:aws:iam::role/my-role + + - it: should include imagePullSecrets from global level + set: + imagePullSecrets: + - name: global-registry + asserts: + - contains: + path: imagePullSecrets + content: + name: global-registry + + - it: should include common labels + asserts: + - isSubset: + path: metadata.labels + content: + helm.sh/chart: keycloak-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/version: "26.5.0" diff --git a/charts/keycloak/tests/servicemonitor_test.yaml b/charts/keycloak/tests/servicemonitor_test.yaml new file mode 100644 index 0000000..e9a1d28 --- /dev/null +++ b/charts/keycloak/tests/servicemonitor_test.yaml @@ -0,0 +1,63 @@ +suite: servicemonitor tests +templates: + - templates/servicemonitor.yaml +tests: + - it: should not render by default + asserts: + - hasDocuments: + count: 0 + + - it: should not render when only metrics are enabled + set: + metrics.enabled: true + asserts: + - hasDocuments: + count: 0 + + - it: should render when serviceMonitor is enabled + set: + metrics.enabled: true + metrics.serviceMonitor.enabled: true + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ServiceMonitor + + - it: should target management port + set: + metrics.enabled: true + metrics.serviceMonitor.enabled: true + asserts: + - equal: + path: spec.endpoints[0].port + value: management + - equal: + path: spec.endpoints[0].path + value: /metrics + + - it: should add custom labels + set: + metrics.enabled: true + metrics.serviceMonitor.enabled: true + metrics.serviceMonitor.labels: + release: prometheus + asserts: + - isSubset: + path: metadata.labels + content: + release: prometheus + + - it: should set interval and scrapeTimeout + set: + metrics.enabled: true + metrics.serviceMonitor.enabled: true + metrics.serviceMonitor.interval: 30s + metrics.serviceMonitor.scrapeTimeout: 10s + asserts: + - equal: + path: spec.endpoints[0].interval + value: 30s + - equal: + path: spec.endpoints[0].scrapeTimeout + value: 10s diff --git a/charts/keycloak/values.yaml b/charts/keycloak/values.yaml new file mode 100644 index 0000000..0d5066e --- /dev/null +++ b/charts/keycloak/values.yaml @@ -0,0 +1,235 @@ +# -- Override the chart name +nameOverride: "" +# -- Override the full resource name +fullnameOverride: "" + +# -- Global image pull secrets applied to all pods +imagePullSecrets: [] + +# ── Service Account ────────────────────────────────────────────────────── +serviceAccount: + # -- Create a dedicated ServiceAccount + create: true + # -- Annotations for the ServiceAccount (e.g. IAM role bindings) + annotations: {} + # -- Override the ServiceAccount name (defaults to fullname) + name: "" + +# ── Image ──────────────────────────────────────────────────────────────── +image: + # -- Keycloak container image repository + repository: quay.io/keycloak/keycloak + # -- Image tag (defaults to Chart appVersion) + tag: "" + # -- Image pull policy + pullPolicy: IfNotPresent + +# ── Replica Count ──────────────────────────────────────────────────────── +# -- Number of Keycloak replicas +replicaCount: 1 + +# ── Database ───────────────────────────────────────────────────────────── +database: + # -- Database type: "postgresql", "mysql", "mssql", or "dev" (embedded H2) + type: "dev" + # -- Database hostname (required for postgresql/mysql/mssql) + host: "" + # -- Database port (auto-defaults: 5432 for postgresql, 3306 for mysql, 1433 for mssql) + port: "" + # -- Database name + name: "keycloak" + # -- Database username + user: "" + # -- Database password via Kubernetes Secret reference + password: + # -- Name of the Secret containing the database password + secretName: "" + # -- Key within the Secret + secretKey: "password" + # -- Connection pool minimum size + poolMinSize: "" + # -- Connection pool initial size + poolInitialSize: "" + # -- Connection pool maximum size + poolMaxSize: "" + # -- SSL/TLS mode for database connection (e.g. "verify-full", "require", "disable") + sslMode: "" + +# ── Hostname ───────────────────────────────────────────────────────────── +# -- Public hostname for Keycloak (maps to KC_HOSTNAME) +hostname: "" +# -- Separate admin console hostname (maps to KC_HOSTNAME_ADMIN) +hostnameAdmin: "" +# -- Enforce strict hostname checking (maps to KC_HOSTNAME_STRICT) +hostnameStrict: true + +# ── Proxy / TLS ────────────────────────────────────────────────────────── +# -- Proxy header mode: "xforwarded" or "forwarded" (maps to KC_PROXY_HEADERS) +proxyHeaders: "" +# -- Enable HTTP listener (maps to KC_HTTP_ENABLED); required for edge-terminated TLS +httpEnabled: true +# -- TLS passthrough configuration +tls: + # -- Enable TLS passthrough (mounts certificate and key) + enabled: false + # -- Name of the Secret containing tls.crt and tls.key + secretName: "" + +# ── Clustering ─────────────────────────────────────────────────────────── +cache: + # -- Cache stack: "jdbc-ping" (default, uses the database) or "kubernetes" (dns-ping via headless service) + stack: "jdbc-ping" + +# ── Health ─────────────────────────────────────────────────────────────── +# -- Enable Keycloak health endpoints (KC_HEALTH_ENABLED) +healthEnabled: true + +# ── Observability ──────────────────────────────────────────────────────── +metrics: + # -- Enable Prometheus metrics endpoint on /metrics (KC_METRICS_ENABLED) + enabled: true + # -- Deploy a ServiceMonitor for Prometheus Operator + serviceMonitor: + # -- Create the ServiceMonitor resource + enabled: false + # -- Additional labels for the ServiceMonitor + labels: {} + # -- Scrape interval + interval: "" + # -- Scrape timeout + scrapeTimeout: "" + +# ── Logging ────────────────────────────────────────────────────────────── +# -- Keycloak log level (maps to KC_LOG_LEVEL) +logLevel: "info" + +# ── Features ───────────────────────────────────────────────────────────── +# -- Comma-separated list of Keycloak features to enable (maps to KC_FEATURES) +features: "" + +# ── Admin Credentials ──────────────────────────────────────────────────── +admin: + # -- Admin username (maps to KEYCLOAK_ADMIN). Leave empty to skip initial admin creation + username: "" + # -- Admin password via Kubernetes Secret reference + password: + # -- Name of the Secret containing the admin password + secretName: "" + # -- Key within the Secret + secretKey: "password" + +# ── Build Optimization ────────────────────────────────────────────────── +build: + # -- Run `kc.sh build` in an init container for faster startup + enabled: false + +# ── Extra Configuration ───────────────────────────────────────────────── +# -- Additional environment variables as a list of {name, value} or {name, valueFrom} objects +extraEnvVars: [] + +# -- Name of an existing Secret to mount as additional environment variables +extraEnvVarsSecret: "" + +# -- Additional volumes to add to the pod +extraVolumes: [] + +# -- Additional volume mounts to add to the Keycloak container +extraVolumeMounts: [] + +# ── Persistent Storage ────────────────────────────────────────────────── +persistence: + # -- Enable persistent volume for custom themes/providers + enabled: false + # -- Storage class name + storageClass: "" + # -- Access modes + accessModes: + - ReadWriteOnce + # -- Volume size + size: 1Gi + # -- Annotations for the PVC + annotations: {} + +# ── Service ────────────────────────────────────────────────────────────── +service: + # -- Service type + type: ClusterIP + # -- HTTP port + httpPort: 8080 + # -- Management port (health + metrics) + managementPort: 9000 + # -- Service annotations + annotations: {} + +# ── Headless Service (clustering) ──────────────────────────────────────── +headlessService: + # -- JGroups port for cluster communication + jgroupsPort: 7800 + # -- Annotations for the headless service + annotations: {} + +# ── Ingress ────────────────────────────────────────────────────────────── +ingress: + # -- Enable Ingress resource + enabled: false + # -- Ingress class name + className: "" + # -- Ingress annotations + annotations: {} + # -- Ingress hosts + hosts: [] + # - host: keycloak.example.com + # paths: + # - path: / + # pathType: Prefix + # -- TLS configuration + tls: [] + # - secretName: keycloak-tls + # hosts: + # - keycloak.example.com + +# ── Probes ─────────────────────────────────────────────────────────────── +startupProbe: + httpGet: + path: /health/started + port: management + initialDelaySeconds: 30 + periodSeconds: 5 + failureThreshold: 60 + timeoutSeconds: 3 + +livenessProbe: + httpGet: + path: /health/live + port: management + initialDelaySeconds: 0 + periodSeconds: 15 + failureThreshold: 3 + timeoutSeconds: 3 + +readinessProbe: + httpGet: + path: /health/ready + port: management + initialDelaySeconds: 0 + periodSeconds: 10 + failureThreshold: 3 + timeoutSeconds: 3 + +# ── Pod Scheduling & Metadata ──────────────────────────────────────────── +# -- Resource requests and limits +resources: {} +# -- Node selector +nodeSelector: {} +# -- Tolerations +tolerations: [] +# -- Affinity rules +affinity: {} +# -- Pod annotations +podAnnotations: {} +# -- Pod labels +podLabels: {} +# -- Pod-level security context +podSecurityContext: {} +# -- Container-level security context +securityContext: {} From 543a7f4a868fd0853fa050f921d04bceeb89568b Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:17:21 +0100 Subject: [PATCH 02/12] feat(keycloak): add E2E tests and improve admin credential docs Add comprehensive E2E test infrastructure for the Keycloak chart: - ci/scripts/e2e-keycloak.sh with dev, postgres, and replicas scenarios - E2E value files for each scenario (dev H2, PostgreSQL, multi-replica) - Tests verify health, metrics, OIDC discovery, admin token acquisition, realm/client/user creation via REST API, and multi-replica clustering - Makefile targets: e2e-keycloak-dev, e2e-keycloak-postgres, e2e-keycloak-replicas, e2e-keycloak (all three) Improve NOTES.txt with detailed admin credential setup instructions, REST API quick start guide, and production database guidance. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 19 +- charts/keycloak/ci/e2e-values-postgres.yaml | 29 ++ charts/keycloak/ci/e2e-values-replicas.yaml | 34 +++ charts/keycloak/ci/e2e-values.yaml | 14 +- charts/keycloak/templates/NOTES.txt | 109 ++++++- ci/scripts/e2e-keycloak.sh | 312 ++++++++++++++++++++ 6 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 charts/keycloak/ci/e2e-values-postgres.yaml create mode 100644 charts/keycloak/ci/e2e-values-replicas.yaml create mode 100755 ci/scripts/e2e-keycloak.sh diff --git a/Makefile b/Makefile index 473d7d6..4766788 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-setup e2e-teardown test compat-matrix +.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-keycloak-dev e2e-keycloak-postgres e2e-keycloak-replicas e2e-keycloak e2e-setup e2e-teardown test compat-matrix CHARTS := $(wildcard charts/*) @@ -41,12 +41,29 @@ e2e-oidc-keycloak: e2e-setup e2e-oidc-zitadel: e2e-setup ci/scripts/e2e-oidc.sh zitadel +e2e-keycloak-dev: e2e-setup + ci/scripts/e2e-keycloak.sh dev + +e2e-keycloak-postgres: e2e-setup + ci/scripts/e2e-keycloak.sh postgres + +e2e-keycloak-replicas: e2e-setup + ci/scripts/e2e-keycloak.sh replicas + +e2e-keycloak: e2e-setup + ci/scripts/e2e-keycloak.sh dev + ci/scripts/e2e-keycloak.sh postgres + ci/scripts/e2e-keycloak.sh replicas + e2e: e2e-setup ci/scripts/e2e.sh sqlite ci/scripts/e2e.sh postgres ci/scripts/e2e.sh mysql ci/scripts/e2e-oidc.sh keycloak ci/scripts/e2e-oidc.sh zitadel + ci/scripts/e2e-keycloak.sh dev + ci/scripts/e2e-keycloak.sh postgres + ci/scripts/e2e-keycloak.sh replicas e2e-teardown: kind delete cluster --name $(E2E_CLUSTER) 2>/dev/null || true diff --git a/charts/keycloak/ci/e2e-values-postgres.yaml b/charts/keycloak/ci/e2e-values-postgres.yaml new file mode 100644 index 0000000..a3b059d --- /dev/null +++ b/charts/keycloak/ci/e2e-values-postgres.yaml @@ -0,0 +1,29 @@ +# E2E test values — PostgreSQL backend in a kind cluster. +# +# The e2e script deploys postgres via a simple Deployment + Service +# and creates the password secret before chart install. + +database: + type: postgresql + host: "postgres.keycloak-e2e.svc.cluster.local" + port: 5432 + user: keycloak + name: keycloak + password: + secretName: keycloak-db-password + secretKey: password + +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password + +startupProbe: + httpGet: + path: /health/started + port: management + initialDelaySeconds: 30 + periodSeconds: 5 + failureThreshold: 120 + timeoutSeconds: 3 diff --git a/charts/keycloak/ci/e2e-values-replicas.yaml b/charts/keycloak/ci/e2e-values-replicas.yaml new file mode 100644 index 0000000..9fd65ab --- /dev/null +++ b/charts/keycloak/ci/e2e-values-replicas.yaml @@ -0,0 +1,34 @@ +# E2E test values — multi-replica with kubernetes (dns-ping) cache stack. +# +# Tests that multiple Keycloak pods can form a cluster using the +# headless service for JGroups DNS discovery. + +replicaCount: 2 + +database: + type: postgresql + host: "postgres.keycloak-e2e.svc.cluster.local" + port: 5432 + user: keycloak + name: keycloak + password: + secretName: keycloak-db-password + secretKey: password + +cache: + stack: kubernetes + +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password + +startupProbe: + httpGet: + path: /health/started + port: management + initialDelaySeconds: 30 + periodSeconds: 5 + failureThreshold: 120 + timeoutSeconds: 3 diff --git a/charts/keycloak/ci/e2e-values.yaml b/charts/keycloak/ci/e2e-values.yaml index 920d2f1..1c0aac6 100644 --- a/charts/keycloak/ci/e2e-values.yaml +++ b/charts/keycloak/ci/e2e-values.yaml @@ -1,8 +1,18 @@ +# E2E test values — dev mode (embedded H2) in a kind cluster. +# +# This configuration: +# - Uses the embedded H2 dev database (no external DB) +# - Sets admin credentials for API testing +# - Increases startup probe tolerance for slow CI environments + database: type: dev -service: - type: ClusterIP +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password startupProbe: httpGet: diff --git a/charts/keycloak/templates/NOTES.txt b/charts/keycloak/templates/NOTES.txt index 21d1fbc..2603683 100644 --- a/charts/keycloak/templates/NOTES.txt +++ b/charts/keycloak/templates/NOTES.txt @@ -17,18 +17,111 @@ Components: Cache Stack: {{ .Values.cache.stack }} Replicas: {{ .Values.replicaCount }} +───────────────────────────────────────────────────────────────────────── +Admin Credentials: {{- if and .Values.admin.username .Values.admin.password.secretName }} - Admin Console: - Username: {{ .Values.admin.username }} - Password stored in Secret: {{ .Values.admin.password.secretName }} (key: {{ .Values.admin.password.secretKey }}) - Retrieve with: - kubectl get secret {{ .Values.admin.password.secretName }} -n {{ .Release.Namespace }} -o jsonpath='{.data.{{ .Values.admin.password.secretKey }}}' | base64 -d + The initial admin user is created automatically on first startup. + + Username: {{ .Values.admin.username }} + Password: stored in Kubernetes Secret "{{ .Values.admin.password.secretName }}" (key: {{ .Values.admin.password.secretKey }}) + + To retrieve the admin password: + + kubectl get secret {{ .Values.admin.password.secretName }} -n {{ .Release.Namespace }} \ + -o jsonpath='{.data.{{ .Values.admin.password.secretKey }}}' | base64 -d + + To set up admin credentials, create a Secret before installing the chart: + + kubectl create secret generic keycloak-admin-password \ + --from-literal=password='YOUR_SECURE_PASSWORD' \ + -n {{ .Release.Namespace }} + + Then reference it in your values.yaml: + + admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password + + IMPORTANT: The admin user is only created on the first startup of a + fresh Keycloak instance. If the database already contains users, the + KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD environment variables are + ignored. To reset credentials on an existing instance, use the + Keycloak CLI (kcadm.sh) or the Admin REST API. +{{- else }} + + WARNING: No admin credentials are configured. Keycloak will start + without an initial admin user. To create one, set these values: + + admin: + username: admin + password: + secretName: + secretKey: password + + The Secret must exist in the release namespace and contain the + admin password. Create it with: + + kubectl create secret generic keycloak-admin-password \ + --from-literal=password='YOUR_SECURE_PASSWORD' \ + -n {{ .Release.Namespace }} {{- end }} +───────────────────────────────────────────────────────────────────────── +Accessing the Admin Console: +{{- if .Values.ingress.enabled }} + {{- range .Values.ingress.hosts }} + + Open https://{{ .host }}/admin in your browser. + {{- end }} +{{- else }} + + Port-forward the HTTP service to access the admin console locally: + + kubectl port-forward svc/{{ include "keycloak.fullname" . }} 8080:{{ .Values.service.httpPort }} -n {{ .Release.Namespace }} + + Then open http://localhost:8080/admin in your browser. +{{- end }} + +───────────────────────────────────────────────────────────────────────── +REST API Quick Start: + + 1. Obtain an admin access token: + + TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/master/protocol/openid-connect/token \ + -d "client_id=admin-cli" \ + -d "username={{ .Values.admin.username | default "admin" }}" \ + -d "password=" \ + -d "grant_type=password" | jq -r '.access_token') + + 2. Use the token to call the Admin REST API: + + curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/admin/realms + {{- if eq .Values.database.type "dev" }} - NOTE: You are using the embedded H2 development database. - This is NOT suitable for production use. Configure an external - PostgreSQL, MySQL, or MSSQL database for production deployments. +───────────────────────────────────────────────────────────────────────── +WARNING: Development Database + + You are using the embedded H2 development database (database.type=dev). + This is NOT suitable for production use: + - Data is not persisted across pod restarts + - Clustering is not supported with H2 + - Performance is significantly lower than a dedicated database + + For production deployments, configure an external database: + + database: + type: postgresql # or: mysql, mssql + host: your-db-host.example.com + port: 5432 + name: keycloak + user: keycloak + password: + secretName: keycloak-db-password + secretKey: password {{- end }} diff --git a/ci/scripts/e2e-keycloak.sh b/ci/scripts/e2e-keycloak.sh new file mode 100755 index 0000000..52073a8 --- /dev/null +++ b/ci/scripts/e2e-keycloak.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# +# E2E test runner for the keycloak Helm chart. +# +# Usage: +# ci/scripts/e2e-keycloak.sh +# +# Scenarios: +# dev — embedded H2 dev mode (single replica, no external DB) +# postgres — PostgreSQL backend (single replica) +# replicas — multi-replica with kubernetes (dns-ping) cache stack +# +set -euo pipefail + +SCENARIO="${1:-dev}" +RELEASE="keycloak-e2e" +NAMESPACE="keycloak-e2e" +CHART="charts/keycloak" +TIMEOUT="10m" +ADMIN_USER="admin" +ADMIN_PASSWORD="e2e-test-password-123" + +log() { echo "==> $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +# ── Cleanup function ─────────────────────────────────────────────────── +cleanup() { + log "Cleaning up..." + helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found 2>/dev/null || true + kubectl delete namespace "$NAMESPACE" --ignore-not-found 2>/dev/null || true +} +trap cleanup EXIT + +# ── Create namespace ─────────────────────────────────────────────────── +log "Creating namespace $NAMESPACE..." +kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + +# ── Create admin password secret ───────────────────────────────────── +log "Creating admin password secret..." +kubectl -n "$NAMESPACE" create secret generic keycloak-admin-password \ + --from-literal=password="$ADMIN_PASSWORD" + +# ── Deploy PostgreSQL (if needed) ──────────────────────────────────── +deploy_postgres() { + log "Deploying PostgreSQL..." + kubectl -n "$NAMESPACE" apply -f - <<'EOF' +apiVersion: v1 +kind: Secret +metadata: + name: postgres-credentials +type: Opaque +stringData: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: "test-db-password" +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + envFrom: + - secretRef: + name: postgres-credentials + ports: + - containerPort: 5432 + readinessProbe: + exec: + command: ["pg_isready", "-U", "keycloak"] + initialDelaySeconds: 5 + periodSeconds: 3 +EOF + + log "Waiting for PostgreSQL to be ready..." + kubectl -n "$NAMESPACE" rollout status deployment/postgres --timeout=120s + + # Create the password secret for keycloak + kubectl -n "$NAMESPACE" create secret generic keycloak-db-password \ + --from-literal=password='test-db-password' +} + +# ── Scenario dispatch ──────────────────────────────────────────────── +EXTRA_SETS=() +case "$SCENARIO" in + dev) + log "Using dev mode — no external database" + VALUES_FILE="$CHART/ci/e2e-values.yaml" + ;; + postgres) + deploy_postgres + VALUES_FILE="$CHART/ci/e2e-values-postgres.yaml" + ;; + replicas) + deploy_postgres + VALUES_FILE="$CHART/ci/e2e-values-replicas.yaml" + ;; + *) + fail "Unknown scenario: $SCENARIO (expected: dev, postgres, replicas)" + ;; +esac + +# ── Install keycloak chart ─────────────────────────────────────────── +log "Installing keycloak chart with $SCENARIO scenario..." +if ! helm install "$RELEASE" "$CHART" \ + -n "$NAMESPACE" \ + -f "$VALUES_FILE" \ + "${EXTRA_SETS[@]}" \ + --timeout "$TIMEOUT"; then + log "Helm install failed — dumping logs..." + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-keycloak --all-containers --tail=100 2>/dev/null || true + fail "Helm install failed" +fi + +# ── Verify rollout ─────────────────────────────────────────────────── +log "Verifying deployment..." +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-keycloak --timeout=600s + +log "Pod status:" +kubectl -n "$NAMESPACE" get pods -o wide + +# ── Run helm test ──────────────────────────────────────────────────── +log "Running helm test..." +helm test "$RELEASE" -n "$NAMESPACE" --timeout 5m + +# ── Verify REST API access ─────────────────────────────────────────── +log "Verifying Keycloak REST API..." +SVC_URL="http://$RELEASE-keycloak.$NAMESPACE.svc.cluster.local" +MGMT_URL="$SVC_URL:9000" +HTTP_URL="$SVC_URL:8080" + +kubectl -n "$NAMESPACE" run api-test --image=alpine:3.20 --restart=Never \ + --env="HTTP_URL=$HTTP_URL" \ + --env="MGMT_URL=$MGMT_URL" \ + --env="ADMIN_USER=$ADMIN_USER" \ + --env="ADMIN_PASSWORD=$ADMIN_PASSWORD" \ + --command -- sh -c ' + apk add --no-cache curl jq >/dev/null 2>&1 + sleep 3 + + echo "==> Test 1: Health endpoint returns UP..." + HEALTH=$(curl -sf "$MGMT_URL/health/ready" 2>/dev/null || echo "") + if echo "$HEALTH" | grep -q "UP"; then + echo "PASS: Health check returned UP" + else + echo "FAIL: Health check did not return UP" + echo "Response: $HEALTH" + exit 1 + fi + + echo "" + echo "==> Test 2: Metrics endpoint is accessible..." + METRICS=$(curl -sf "$MGMT_URL/metrics" 2>/dev/null | head -5 || echo "") + if [ -n "$METRICS" ]; then + echo "PASS: Metrics endpoint returned data" + else + echo "FAIL: Metrics endpoint returned empty response" + exit 1 + fi + + echo "" + echo "==> Test 3: OIDC discovery endpoint..." + OIDC=$(curl -sf "$HTTP_URL/realms/master/.well-known/openid-configuration" 2>/dev/null || echo "") + ISSUER=$(echo "$OIDC" | jq -r ".issuer // empty" 2>/dev/null || echo "") + if [ -n "$ISSUER" ]; then + echo "PASS: OIDC discovery returned issuer: $ISSUER" + else + echo "FAIL: OIDC discovery did not return issuer" + echo "Response: $(echo "$OIDC" | head -c 300)" + exit 1 + fi + + echo "" + echo "==> Test 4: Obtain admin access token..." + TOKEN_RESP=$(curl -sf -X POST \ + "$HTTP_URL/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli" \ + -d "username=$ADMIN_USER" \ + -d "password=$ADMIN_PASSWORD" \ + -d "grant_type=password" 2>/dev/null || echo "") + ACCESS_TOKEN=$(echo "$TOKEN_RESP" | jq -r ".access_token // empty" 2>/dev/null || echo "") + if [ -z "$ACCESS_TOKEN" ]; then + echo "FAIL: Could not obtain admin access token" + echo "Response: $(echo "$TOKEN_RESP" | head -c 500)" + exit 1 + fi + echo "PASS: Admin token obtained" + + echo "" + echo "==> Test 5: Admin REST API — list realms..." + REALMS=$(curl -sf -H "Authorization: Bearer $ACCESS_TOKEN" \ + "$HTTP_URL/admin/realms" 2>/dev/null || echo "") + REALM_COUNT=$(echo "$REALMS" | jq "length" 2>/dev/null || echo "0") + if [ "$REALM_COUNT" -ge 1 ]; then + echo "PASS: Admin API returned $REALM_COUNT realm(s)" + else + echo "FAIL: Admin API returned no realms" + echo "Response: $(echo "$REALMS" | head -c 500)" + exit 1 + fi + + echo "" + echo "==> Test 6: Admin REST API — create a test realm..." + CREATE_RESP=$(curl -s -o /tmp/create-body -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + "$HTTP_URL/admin/realms" \ + -d "{\"realm\":\"e2e-test\",\"enabled\":true}") + if [ "$CREATE_RESP" = "201" ]; then + echo "PASS: Created e2e-test realm (HTTP 201)" + else + echo "FAIL: Could not create realm (HTTP $CREATE_RESP)" + cat /tmp/create-body | head -c 300 + exit 1 + fi + + echo "" + echo "==> Test 7: Admin REST API — create a client in test realm..." + CLIENT_RESP=$(curl -s -o /tmp/client-body -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + "$HTTP_URL/admin/realms/e2e-test/clients" \ + -d "{\"clientId\":\"e2e-client\",\"publicClient\":true,\"directAccessGrantsEnabled\":true}") + if [ "$CLIENT_RESP" = "201" ]; then + echo "PASS: Created e2e-client (HTTP 201)" + else + echo "FAIL: Could not create client (HTTP $CLIENT_RESP)" + cat /tmp/client-body | head -c 300 + exit 1 + fi + + echo "" + echo "==> Test 8: Admin REST API — create a user in test realm..." + USER_RESP=$(curl -s -o /tmp/user-body -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + "$HTTP_URL/admin/realms/e2e-test/users" \ + -d "{\"username\":\"testuser\",\"enabled\":true,\"credentials\":[{\"type\":\"password\",\"value\":\"testpass\",\"temporary\":false}]}") + if [ "$USER_RESP" = "201" ]; then + echo "PASS: Created testuser (HTTP 201)" + else + echo "FAIL: Could not create user (HTTP $USER_RESP)" + cat /tmp/user-body | head -c 300 + exit 1 + fi + + echo "" + echo "All API tests PASSED" + exit 0 + ' + +log "Waiting for API test pod..." +kubectl -n "$NAMESPACE" wait --for=condition=Ready pod/api-test --timeout=60s 2>/dev/null || true +kubectl -n "$NAMESPACE" wait --for=jsonpath='{.status.phase}'=Succeeded pod/api-test --timeout=180s || { + log "API test pod logs:" + kubectl -n "$NAMESPACE" logs api-test || true + log "Keycloak pod logs (last 50 lines):" + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-keycloak --tail=50 || true + fail "API tests failed" +} +log "API test pod logs:" +kubectl -n "$NAMESPACE" logs api-test || true + +# ── Multi-replica verification ─────────────────────────────────────── +if [ "$SCENARIO" = "replicas" ]; then + log "Verifying multi-replica deployment..." + READY_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE"-keycloak \ + -o jsonpath='{.status.readyReplicas}') + DESIRED_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE"-keycloak \ + -o jsonpath='{.spec.replicas}') + log "Replicas: $READY_REPLICAS/$DESIRED_REPLICAS ready" + if [ "$READY_REPLICAS" != "$DESIRED_REPLICAS" ]; then + fail "Not all replicas are ready: $READY_REPLICAS/$DESIRED_REPLICAS" + fi + log "PASS: All $DESIRED_REPLICAS replicas are ready" + + # Verify that each pod is healthy individually + log "Checking health of each replica..." + PODS=$(kubectl -n "$NAMESPACE" get pods -l app.kubernetes.io/name=keycloak -o jsonpath='{.items[*].metadata.name}') + for POD in $PODS; do + POD_IP=$(kubectl -n "$NAMESPACE" get pod "$POD" -o jsonpath='{.status.podIP}') + kubectl -n "$NAMESPACE" run "health-check-${POD##*-}" --image=alpine:3.20 --restart=Never --rm -i \ + --command -- sh -c " + wget -q -O - --timeout=10 http://$POD_IP:9000/health/ready 2>/dev/null | grep -q UP && echo 'PASS: $POD is healthy' || { echo 'FAIL: $POD is not healthy'; exit 1; } + " || fail "Health check failed for pod $POD" + done + log "PASS: All replicas are individually healthy" +fi + +log "E2E test with $SCENARIO scenario PASSED!" From f28763988aadf7d76851aa6f64cc7820ad8a42dc Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:21:50 +0100 Subject: [PATCH 03/12] ci: add path-based filtering for chart E2E tests Use dorny/paths-filter to detect which charts changed and only run the relevant E2E test jobs. NetBird E2E tests only run when charts/netbird/** or its CI scripts change; Keycloak E2E tests only run when charts/keycloak/** or its CI scripts change. Changes to CI config files (workflow, Makefile, dprint, helmfmt) trigger all E2E tests. Format-check and lint+unittest always run. Adds three new Keycloak E2E jobs: dev, postgres, and replicas. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 166 +++++++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9b1b119..d0892fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,6 +7,39 @@ on: branches: [main] jobs: + # ── Detect which charts changed ────────────────────────────────────── + detect-changes: + name: Detect Changes + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + netbird: ${{ steps.filter.outputs.netbird }} + keycloak: ${{ steps.filter.outputs.keycloak }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect changed paths + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + netbird: + - 'charts/netbird/**' + - 'ci/scripts/e2e.sh' + - 'ci/scripts/e2e-oidc.sh' + keycloak: + - 'charts/keycloak/**' + - 'ci/scripts/e2e-keycloak.sh' + ci: + - '.github/workflows/ci.yaml' + - 'Makefile' + - 'dprint.json' + - '.helmfmt' + + # ── Format check (always runs) ────────────────────────────────────── format-check: name: Format Check runs-on: ubuntu-latest @@ -39,6 +72,7 @@ jobs: exit 1 fi + # ── Lint & unit test (always runs) ────────────────────────────────── lint-and-unit-test: name: Lint & Unit Test runs-on: ubuntu-latest @@ -70,10 +104,12 @@ jobs: fi done + # ── NetBird E2E tests (only when netbird chart or CI config changes) ─ e2e-sqlite: - name: E2E — SQLite + name: "E2E — NetBird: SQLite" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -104,9 +140,10 @@ jobs: kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true e2e-postgres: - name: E2E — PostgreSQL + name: "E2E — NetBird: PostgreSQL" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -139,9 +176,10 @@ jobs: kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true e2e-mysql: - name: E2E — MySQL + name: "E2E — NetBird: MySQL" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -174,9 +212,10 @@ jobs: kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true e2e-oidc-embedded: - name: E2E — OIDC (Embedded IdP) + name: "E2E — NetBird: OIDC (Embedded IdP)" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -207,9 +246,10 @@ jobs: kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true e2e-oidc-keycloak: - name: E2E — OIDC (Keycloak) + name: "E2E — NetBird: OIDC (Keycloak)" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -242,9 +282,10 @@ jobs: kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true e2e-oidc-zitadel: - name: E2E — OIDC (Zitadel) + name: "E2E — NetBird: OIDC (Zitadel)" runs-on: ubuntu-latest - needs: lint-and-unit-test + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.netbird == 'true' || needs.detect-changes.outputs.ci == 'true' steps: - name: Checkout uses: actions/checkout@v4 @@ -277,3 +318,104 @@ jobs: kubectl -n netbird-e2e logs deployment/zitadel-db --tail=50 || true echo "=== Events ===" kubectl -n netbird-e2e get events --sort-by='.lastTimestamp' || true + + # ── Keycloak E2E tests (only when keycloak chart or CI config changes) ─ + e2e-keycloak-dev: + name: "E2E — Keycloak: Dev" + runs-on: ubuntu-latest + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.keycloak == 'true' || needs.detect-changes.outputs.ci == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (keycloak dev) + run: ci/scripts/e2e-keycloak.sh dev + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n keycloak-e2e get pods -o wide || true + echo "=== Keycloak logs ===" + kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + echo "=== Events ===" + kubectl -n keycloak-e2e get events --sort-by='.lastTimestamp' || true + + e2e-keycloak-postgres: + name: "E2E — Keycloak: PostgreSQL" + runs-on: ubuntu-latest + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.keycloak == 'true' || needs.detect-changes.outputs.ci == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (keycloak postgres) + run: ci/scripts/e2e-keycloak.sh postgres + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n keycloak-e2e get pods -o wide || true + echo "=== Keycloak logs ===" + kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + echo "=== PostgreSQL logs ===" + kubectl -n keycloak-e2e logs deployment/postgres --tail=50 || true + echo "=== Events ===" + kubectl -n keycloak-e2e get events --sort-by='.lastTimestamp' || true + + e2e-keycloak-replicas: + name: "E2E — Keycloak: Replicas" + runs-on: ubuntu-latest + needs: [detect-changes, lint-and-unit-test] + if: needs.detect-changes.outputs.keycloak == 'true' || needs.detect-changes.outputs.ci == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + with: + version: v4.0.2 + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: helms-e2e + + - name: Run e2e test (keycloak replicas) + run: ci/scripts/e2e-keycloak.sh replicas + + - name: Show debug info on failure + if: failure() + run: | + echo "=== Pod status ===" + kubectl -n keycloak-e2e get pods -o wide || true + echo "=== Keycloak logs ===" + kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + echo "=== PostgreSQL logs ===" + kubectl -n keycloak-e2e logs deployment/postgres --tail=50 || true + echo "=== Events ===" + kubectl -n keycloak-e2e get events --sort-by='.lastTimestamp' || true From ded3206caadb909f759ef3fb9c6c20eaa426db3a Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:26:38 +0100 Subject: [PATCH 04/12] refactor: reorganize ci/scripts into chart-specific subdirectories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move E2E and utility scripts into per-chart directories: - ci/scripts/netbird/ — e2e.sh, e2e-oidc.sh, compat-matrix.sh - ci/scripts/keycloak/ — e2e.sh - ci/scripts/upstream-check.sh stays at root (repo-level) Update all references in CI workflow, Makefile, and script headers. Update paths-filter to use glob patterns for the new directory structure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 23 +++++---- Makefile | 48 ++++++++++--------- .../{e2e-keycloak.sh => keycloak/e2e.sh} | 2 +- ci/scripts/{ => netbird}/compat-matrix.sh | 2 +- ci/scripts/{ => netbird}/e2e-oidc.sh | 2 +- ci/scripts/{ => netbird}/e2e.sh | 2 +- 6 files changed, 40 insertions(+), 39 deletions(-) rename ci/scripts/{e2e-keycloak.sh => keycloak/e2e.sh} (99%) rename ci/scripts/{ => netbird}/compat-matrix.sh (99%) rename ci/scripts/{ => netbird}/e2e-oidc.sh (99%) rename ci/scripts/{ => netbird}/e2e.sh (99%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0892fa..316f946 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,11 +28,10 @@ jobs: filters: | netbird: - 'charts/netbird/**' - - 'ci/scripts/e2e.sh' - - 'ci/scripts/e2e-oidc.sh' + - 'ci/scripts/netbird/**' keycloak: - 'charts/keycloak/**' - - 'ci/scripts/e2e-keycloak.sh' + - 'ci/scripts/keycloak/**' ci: - '.github/workflows/ci.yaml' - 'Makefile' @@ -125,7 +124,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (sqlite) - run: ci/scripts/e2e.sh sqlite + run: ci/scripts/netbird/e2e.sh sqlite - name: Show debug info on failure if: failure() @@ -159,7 +158,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (postgres) - run: ci/scripts/e2e.sh postgres + run: ci/scripts/netbird/e2e.sh postgres - name: Show debug info on failure if: failure() @@ -195,7 +194,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (mysql) - run: ci/scripts/e2e.sh mysql + run: ci/scripts/netbird/e2e.sh mysql - name: Show debug info on failure if: failure() @@ -231,7 +230,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (oidc-embedded) - run: ci/scripts/e2e-oidc.sh embedded + run: ci/scripts/netbird/e2e-oidc.sh embedded - name: Show debug info on failure if: failure() @@ -265,7 +264,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (oidc-keycloak) - run: ci/scripts/e2e-oidc.sh keycloak + run: ci/scripts/netbird/e2e-oidc.sh keycloak - name: Show debug info on failure if: failure() @@ -301,7 +300,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (oidc-zitadel) - run: ci/scripts/e2e-oidc.sh zitadel + run: ci/scripts/netbird/e2e-oidc.sh zitadel - name: Show debug info on failure if: failure() @@ -340,7 +339,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (keycloak dev) - run: ci/scripts/e2e-keycloak.sh dev + run: ci/scripts/keycloak/e2e.sh dev - name: Show debug info on failure if: failure() @@ -372,7 +371,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (keycloak postgres) - run: ci/scripts/e2e-keycloak.sh postgres + run: ci/scripts/keycloak/e2e.sh postgres - name: Show debug info on failure if: failure() @@ -406,7 +405,7 @@ jobs: cluster_name: helms-e2e - name: Run e2e test (keycloak replicas) - run: ci/scripts/e2e-keycloak.sh replicas + run: ci/scripts/keycloak/e2e.sh replicas - name: Show debug info on failure if: failure() diff --git a/Makefile b/Makefile index 4766788..b4e229c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: lint unittest e2e e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-keycloak-dev e2e-keycloak-postgres e2e-keycloak-replicas e2e-keycloak e2e-setup e2e-teardown test compat-matrix +.PHONY: lint unittest e2e e2e-netbird e2e-sqlite e2e-postgres e2e-mysql e2e-oidc-keycloak e2e-oidc-zitadel e2e-keycloak e2e-keycloak-dev e2e-keycloak-postgres e2e-keycloak-replicas e2e-setup e2e-teardown test compat-matrix CHARTS := $(wildcard charts/*) @@ -26,51 +26,53 @@ e2e-setup: kind create cluster --name $(E2E_CLUSTER) --wait 60s 2>/dev/null || true kubectl cluster-info --context kind-$(E2E_CLUSTER) +# ── NetBird E2E ───────────────────────────────────────────────────────── e2e-sqlite: e2e-setup - ci/scripts/e2e.sh sqlite + ci/scripts/netbird/e2e.sh sqlite e2e-postgres: e2e-setup - ci/scripts/e2e.sh postgres + ci/scripts/netbird/e2e.sh postgres e2e-mysql: e2e-setup - ci/scripts/e2e.sh mysql + ci/scripts/netbird/e2e.sh mysql e2e-oidc-keycloak: e2e-setup - ci/scripts/e2e-oidc.sh keycloak + ci/scripts/netbird/e2e-oidc.sh keycloak e2e-oidc-zitadel: e2e-setup - ci/scripts/e2e-oidc.sh zitadel + ci/scripts/netbird/e2e-oidc.sh zitadel +e2e-netbird: e2e-setup + ci/scripts/netbird/e2e.sh sqlite + ci/scripts/netbird/e2e.sh postgres + ci/scripts/netbird/e2e.sh mysql + ci/scripts/netbird/e2e-oidc.sh keycloak + ci/scripts/netbird/e2e-oidc.sh zitadel + +# ── Keycloak E2E ──────────────────────────────────────────────────────── e2e-keycloak-dev: e2e-setup - ci/scripts/e2e-keycloak.sh dev + ci/scripts/keycloak/e2e.sh dev e2e-keycloak-postgres: e2e-setup - ci/scripts/e2e-keycloak.sh postgres + ci/scripts/keycloak/e2e.sh postgres e2e-keycloak-replicas: e2e-setup - ci/scripts/e2e-keycloak.sh replicas + ci/scripts/keycloak/e2e.sh replicas e2e-keycloak: e2e-setup - ci/scripts/e2e-keycloak.sh dev - ci/scripts/e2e-keycloak.sh postgres - ci/scripts/e2e-keycloak.sh replicas - -e2e: e2e-setup - ci/scripts/e2e.sh sqlite - ci/scripts/e2e.sh postgres - ci/scripts/e2e.sh mysql - ci/scripts/e2e-oidc.sh keycloak - ci/scripts/e2e-oidc.sh zitadel - ci/scripts/e2e-keycloak.sh dev - ci/scripts/e2e-keycloak.sh postgres - ci/scripts/e2e-keycloak.sh replicas + ci/scripts/keycloak/e2e.sh dev + ci/scripts/keycloak/e2e.sh postgres + ci/scripts/keycloak/e2e.sh replicas + +# ── All E2E ───────────────────────────────────────────────────────────── +e2e: e2e-netbird e2e-keycloak e2e-teardown: kind delete cluster --name $(E2E_CLUSTER) 2>/dev/null || true # ── Compatibility Matrix ────────────────────────────────────────────── compat-matrix: e2e-setup - ci/scripts/compat-matrix.sh + ci/scripts/netbird/compat-matrix.sh # ── Run all tests ────────────────────────────────────────────────────── test: lint unittest diff --git a/ci/scripts/e2e-keycloak.sh b/ci/scripts/keycloak/e2e.sh similarity index 99% rename from ci/scripts/e2e-keycloak.sh rename to ci/scripts/keycloak/e2e.sh index 52073a8..b28e726 100755 --- a/ci/scripts/e2e-keycloak.sh +++ b/ci/scripts/keycloak/e2e.sh @@ -3,7 +3,7 @@ # E2E test runner for the keycloak Helm chart. # # Usage: -# ci/scripts/e2e-keycloak.sh +# ci/scripts/keycloak/e2e.sh # # Scenarios: # dev — embedded H2 dev mode (single replica, no external DB) diff --git a/ci/scripts/compat-matrix.sh b/ci/scripts/netbird/compat-matrix.sh similarity index 99% rename from ci/scripts/compat-matrix.sh rename to ci/scripts/netbird/compat-matrix.sh index 5926801..88c7987 100755 --- a/ci/scripts/compat-matrix.sh +++ b/ci/scripts/netbird/compat-matrix.sh @@ -6,7 +6,7 @@ # Each run fills the current chart version's row; older rows are preserved. # # Usage: -# ci/scripts/compat-matrix.sh +# ci/scripts/netbird/compat-matrix.sh # # Requires: helm, kubectl, gh, yq (or sed/grep for Chart.yaml parsing) # diff --git a/ci/scripts/e2e-oidc.sh b/ci/scripts/netbird/e2e-oidc.sh similarity index 99% rename from ci/scripts/e2e-oidc.sh rename to ci/scripts/netbird/e2e-oidc.sh index d7dcbac..c56a09e 100755 --- a/ci/scripts/e2e-oidc.sh +++ b/ci/scripts/netbird/e2e-oidc.sh @@ -3,7 +3,7 @@ # E2E test runner for the netbird Helm chart — OIDC integration. # # Usage: -# ci/scripts/e2e-oidc.sh +# ci/scripts/netbird/e2e-oidc.sh # # Providers: # keycloak — Keycloak deployed in-cluster (quay.io/keycloak/keycloak:26.0) diff --git a/ci/scripts/e2e.sh b/ci/scripts/netbird/e2e.sh similarity index 99% rename from ci/scripts/e2e.sh rename to ci/scripts/netbird/e2e.sh index 60b1e73..02a9fc9 100755 --- a/ci/scripts/e2e.sh +++ b/ci/scripts/netbird/e2e.sh @@ -3,7 +3,7 @@ # E2E test runner for the netbird Helm chart (with PAT seeding). # # Usage: -# ci/scripts/e2e.sh +# ci/scripts/netbird/e2e.sh # # Backends: # sqlite — default SQLite storage (no external DB) From 6145e02e03d711d1e3aa10dc5a9124ff13e20427 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:29:40 +0100 Subject: [PATCH 05/12] refactor: move compatibility.md into charts/netbird/docs Move docs/compatibility.md to charts/netbird/docs/compatibility.md so it lives alongside the chart it documents. Add .helmignore to exclude docs/, tests/, ci/, and README.md from the packaged chart. Update compat-matrix.sh and release skill references. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/release/SKILL.md | 2 +- charts/netbird/.helmignore | 5 +++++ {docs => charts/netbird/docs}/compatibility.md | 0 ci/scripts/netbird/compat-matrix.sh | 4 ++-- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 charts/netbird/.helmignore rename {docs => charts/netbird/docs}/compatibility.md (100%) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2098cc1..da8cc2d 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -53,7 +53,7 @@ If the version bump includes a **minor** (or major) version change, run: make compat-matrix ``` -This tests the chart against the last 5 NetBird server minor versions and updates `docs/compatibility.md` with a new row for the new chart minor. The updated file will be included in the release commit. +This tests the chart against the last 5 NetBird server minor versions and updates `charts/netbird/docs/compatibility.md` with a new row for the new chart minor. The updated file will be included in the release commit. Skip this step for patch-only bumps — the existing row already covers the current chart minor. diff --git a/charts/netbird/.helmignore b/charts/netbird/.helmignore new file mode 100644 index 0000000..83754b9 --- /dev/null +++ b/charts/netbird/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +docs/ +tests/ +ci/ +README.md diff --git a/docs/compatibility.md b/charts/netbird/docs/compatibility.md similarity index 100% rename from docs/compatibility.md rename to charts/netbird/docs/compatibility.md diff --git a/ci/scripts/netbird/compat-matrix.sh b/ci/scripts/netbird/compat-matrix.sh index 88c7987..997e3ae 100755 --- a/ci/scripts/netbird/compat-matrix.sh +++ b/ci/scripts/netbird/compat-matrix.sh @@ -2,7 +2,7 @@ # # Compatibility matrix: test the current chart against recent NetBird server versions. # -# Produces/updates docs/compatibility.md with a 2D table (chart minors × server minors). +# Produces/updates charts/netbird/docs/compatibility.md with a 2D table (chart minors × server minors). # Each run fills the current chart version's row; older rows are preserved. # # Usage: @@ -13,7 +13,7 @@ set -uo pipefail CHART="charts/netbird" -COMPAT_FILE="docs/compatibility.md" +COMPAT_FILE="charts/netbird/docs/compatibility.md" NUM_MINORS=5 log() { echo "==> $*"; } From e3fe5581407e16aea6be636ebdf43cdd97c3df04 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:35:37 +0100 Subject: [PATCH 06/12] fix: correct Keycloak deployment name in E2E scripts The keycloak.fullname helper returns "keycloak-e2e" (not "keycloak-e2e-keycloak") because the chart name is contained in the release name. Fix all references in the E2E script and CI workflow debug steps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yaml | 6 +++--- ci/scripts/keycloak/e2e.sh | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 316f946..0e99523 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -347,7 +347,7 @@ jobs: echo "=== Pod status ===" kubectl -n keycloak-e2e get pods -o wide || true echo "=== Keycloak logs ===" - kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + kubectl -n keycloak-e2e logs deployment/keycloak-e2e --tail=100 || true echo "=== Events ===" kubectl -n keycloak-e2e get events --sort-by='.lastTimestamp' || true @@ -379,7 +379,7 @@ jobs: echo "=== Pod status ===" kubectl -n keycloak-e2e get pods -o wide || true echo "=== Keycloak logs ===" - kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + kubectl -n keycloak-e2e logs deployment/keycloak-e2e --tail=100 || true echo "=== PostgreSQL logs ===" kubectl -n keycloak-e2e logs deployment/postgres --tail=50 || true echo "=== Events ===" @@ -413,7 +413,7 @@ jobs: echo "=== Pod status ===" kubectl -n keycloak-e2e get pods -o wide || true echo "=== Keycloak logs ===" - kubectl -n keycloak-e2e logs deployment/keycloak-e2e-keycloak --tail=100 || true + kubectl -n keycloak-e2e logs deployment/keycloak-e2e --tail=100 || true echo "=== PostgreSQL logs ===" kubectl -n keycloak-e2e logs deployment/postgres --tail=50 || true echo "=== Events ===" diff --git a/ci/scripts/keycloak/e2e.sh b/ci/scripts/keycloak/e2e.sh index b28e726..ab82057 100755 --- a/ci/scripts/keycloak/e2e.sh +++ b/ci/scripts/keycloak/e2e.sh @@ -130,13 +130,13 @@ if ! helm install "$RELEASE" "$CHART" \ "${EXTRA_SETS[@]}" \ --timeout "$TIMEOUT"; then log "Helm install failed — dumping logs..." - kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-keycloak --all-containers --tail=100 2>/dev/null || true + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE" --all-containers --tail=100 2>/dev/null || true fail "Helm install failed" fi # ── Verify rollout ─────────────────────────────────────────────────── log "Verifying deployment..." -kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE"-keycloak --timeout=600s +kubectl -n "$NAMESPACE" rollout status deployment/"$RELEASE" --timeout=600s log "Pod status:" kubectl -n "$NAMESPACE" get pods -o wide @@ -147,7 +147,7 @@ helm test "$RELEASE" -n "$NAMESPACE" --timeout 5m # ── Verify REST API access ─────────────────────────────────────────── log "Verifying Keycloak REST API..." -SVC_URL="http://$RELEASE-keycloak.$NAMESPACE.svc.cluster.local" +SVC_URL="http://$RELEASE.$NAMESPACE.svc.cluster.local" MGMT_URL="$SVC_URL:9000" HTTP_URL="$SVC_URL:8080" @@ -277,7 +277,7 @@ kubectl -n "$NAMESPACE" wait --for=jsonpath='{.status.phase}'=Succeeded pod/api- log "API test pod logs:" kubectl -n "$NAMESPACE" logs api-test || true log "Keycloak pod logs (last 50 lines):" - kubectl -n "$NAMESPACE" logs deployment/"$RELEASE"-keycloak --tail=50 || true + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE" --tail=50 || true fail "API tests failed" } log "API test pod logs:" @@ -286,9 +286,9 @@ kubectl -n "$NAMESPACE" logs api-test || true # ── Multi-replica verification ─────────────────────────────────────── if [ "$SCENARIO" = "replicas" ]; then log "Verifying multi-replica deployment..." - READY_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE"-keycloak \ + READY_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o jsonpath='{.status.readyReplicas}') - DESIRED_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE"-keycloak \ + DESIRED_REPLICAS=$(kubectl -n "$NAMESPACE" get deployment "$RELEASE" \ -o jsonpath='{.spec.replicas}') log "Replicas: $READY_REPLICAS/$DESIRED_REPLICAS ready" if [ "$READY_REPLICAS" != "$DESIRED_REPLICAS" ]; then From ead5ca9cd38ea1059e81525f4b050b7281c22c01 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:37:14 +0100 Subject: [PATCH 07/12] chore: add .helmignore for keycloak chart Exclude docs/, tests/, ci/, and README.md from the packaged chart, matching the netbird chart convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/.helmignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 charts/keycloak/.helmignore diff --git a/charts/keycloak/.helmignore b/charts/keycloak/.helmignore new file mode 100644 index 0000000..83754b9 --- /dev/null +++ b/charts/keycloak/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +docs/ +tests/ +ci/ +README.md From 99aea73db78f4bf55e27679b950d38e613685024 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:52:23 +0100 Subject: [PATCH 08/12] chore(keycloak): sync chart version with appVersion (26.5.0) Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/Chart.yaml | 2 +- charts/keycloak/tests/serviceaccount_test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/keycloak/Chart.yaml b/charts/keycloak/Chart.yaml index 25be5c5..918678d 100644 --- a/charts/keycloak/Chart.yaml +++ b/charts/keycloak/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: keycloak description: A Helm chart for deploying Keycloak IAM using the upstream quay.io/keycloak/keycloak image on Kubernetes type: application -version: 0.1.0 +version: 26.5.0 appVersion: "26.5.0" keywords: - keycloak diff --git a/charts/keycloak/tests/serviceaccount_test.yaml b/charts/keycloak/tests/serviceaccount_test.yaml index 82f1c5f..b658eb3 100644 --- a/charts/keycloak/tests/serviceaccount_test.yaml +++ b/charts/keycloak/tests/serviceaccount_test.yaml @@ -52,6 +52,6 @@ tests: - isSubset: path: metadata.labels content: - helm.sh/chart: keycloak-0.1.0 + helm.sh/chart: keycloak-26.5.0 app.kubernetes.io/managed-by: Helm app.kubernetes.io/version: "26.5.0" From 719b5d46dd1080336585039e95d8108a5606dd8f Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 16:54:03 +0100 Subject: [PATCH 09/12] chore: add keycloak to upstream monitor and update release skill Add keycloak/keycloak to .upstream-monitor.yaml with both version and appVersion as targets (they are kept in sync). Update release skill to document that the keycloak chart version is synced with the upstream Keycloak appVersion. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/release/SKILL.md | 2 ++ .upstream-monitor.yaml | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index da8cc2d..ce91d68 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -31,6 +31,8 @@ Apply conventional commit rules to determine the bump: Use the highest applicable bump. Parse the current version from `charts//Chart.yaml`. +**Keycloak chart exception**: The keycloak chart version is synced with the upstream Keycloak appVersion (e.g. `26.5.0`). Do not bump the chart version independently — it always matches `appVersion`. When a new upstream Keycloak version is released, bump both `version` and `appVersion` together to the new upstream version. + Present the proposed version bump to the user (current version -> new version) along with the commit list, and ask for confirmation before proceeding. The user may override the bump level. ## 3. Update Chart.yaml diff --git a/.upstream-monitor.yaml b/.upstream-monitor.yaml index c1db149..dc24c44 100644 --- a/.upstream-monitor.yaml +++ b/.upstream-monitor.yaml @@ -29,3 +29,15 @@ charts: targets: - file: Chart.yaml yaml_path: .appVersion + + - name: keycloak + path: charts/keycloak + sources: + - name: keycloak + github: keycloak/keycloak + strip_v_prefix: false + targets: + - file: Chart.yaml + yaml_path: .appVersion + - file: Chart.yaml + yaml_path: .version From 018cf0f2b1ce18cbe34b2428a27c1f56cdcbeee4 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 18:44:15 +0100 Subject: [PATCH 10/12] fix(keycloak): use local cache in dev mode and fix empty template fields Dev mode (H2 embedded DB) was forcing KC_CACHE=ispn with jdbc-ping, which requires a shared database for cluster discovery. Use local cache instead, matching Keycloak's default for start-dev. Also fix deployment template rendering empty volumeMounts: and volumes: fields when no volumes are configured, and improve e2e script to capture debug info (pod status, logs, events) before cleanup on failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/templates/_helpers.tpl | 4 +++ charts/keycloak/templates/deployment.yaml | 32 +++++++++++++---------- charts/keycloak/tests/configmap_test.yaml | 24 ++++++++++++++++- ci/scripts/keycloak/e2e.sh | 17 +++++++++++- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/charts/keycloak/templates/_helpers.tpl b/charts/keycloak/templates/_helpers.tpl index 1427495..ba7ca84 100644 --- a/charts/keycloak/templates/_helpers.tpl +++ b/charts/keycloak/templates/_helpers.tpl @@ -152,6 +152,9 @@ KC_DB_URL_PROPERTIES: {{ printf "?sslmode=%s" .Values.database.sslMode | quote } {{- end }} {{- end }} {{- end }} +{{- if eq .Values.database.type "dev" }} +KC_CACHE: "local" +{{- else }} KC_CACHE: "ispn" KC_CACHE_STACK: {{ .Values.cache.stack | quote }} {{- if eq .Values.cache.stack "kubernetes" }} @@ -159,3 +162,4 @@ KC_CACHE_CONFIG_FILE: "cache-ispn.xml" JAVA_OPTS_APPEND: {{ printf "-Djgroups.dns.query=%s" (include "keycloak.headlessServiceFQDN" .) | quote }} {{- end }} {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/deployment.yaml b/charts/keycloak/templates/deployment.yaml index d5573f0..6515413 100644 --- a/charts/keycloak/templates/deployment.yaml +++ b/charts/keycloak/templates/deployment.yaml @@ -119,23 +119,25 @@ spec: containerPort: 8443 protocol: TCP {{- end }} +{{- if or .Values.build.enabled .Values.tls.enabled .Values.persistence.enabled .Values.extraVolumeMounts }} volumeMounts: -{{- if .Values.build.enabled }} + {{- if .Values.build.enabled }} - name: build-cache mountPath: /opt/keycloak/lib/quarkus readOnly: true -{{- end }} -{{- if .Values.tls.enabled }} + {{- end }} + {{- if .Values.tls.enabled }} - name: tls-certs mountPath: /opt/keycloak/conf/tls readOnly: true -{{- end }} -{{- if .Values.persistence.enabled }} + {{- end }} + {{- if .Values.persistence.enabled }} - name: providers mountPath: /opt/keycloak/providers -{{- end }} -{{- with .Values.extraVolumeMounts }} + {{- end }} + {{- with .Values.extraVolumeMounts }} {{- toYaml . | nindent 12 }} + {{- end }} {{- end }} {{- with .Values.securityContext }} securityContext: @@ -157,23 +159,25 @@ spec: readinessProbe: {{- toYaml . | nindent 12 }} {{- end }} +{{- if or .Values.build.enabled .Values.tls.enabled .Values.persistence.enabled .Values.extraVolumes }} volumes: -{{- if .Values.build.enabled }} + {{- if .Values.build.enabled }} - name: build-cache emptyDir: {} -{{- end }} -{{- if .Values.tls.enabled }} + {{- end }} + {{- if .Values.tls.enabled }} - name: tls-certs secret: secretName: {{ .Values.tls.secretName }} -{{- end }} -{{- if .Values.persistence.enabled }} + {{- end }} + {{- if .Values.persistence.enabled }} - name: providers persistentVolumeClaim: claimName: {{ include "keycloak.fullname" . }} -{{- end }} -{{- with .Values.extraVolumes }} + {{- end }} + {{- with .Values.extraVolumes }} {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: diff --git a/charts/keycloak/tests/configmap_test.yaml b/charts/keycloak/tests/configmap_test.yaml index 4106f51..3c580b8 100644 --- a/charts/keycloak/tests/configmap_test.yaml +++ b/charts/keycloak/tests/configmap_test.yaml @@ -201,7 +201,20 @@ tests: # ── Clustering ──────────────────────────────────────────────────────── - - it: should set jdbc-ping cache stack by default + - it: should use local cache in dev mode + asserts: + - equal: + path: data.KC_CACHE + value: "local" + - notExists: + path: data.KC_CACHE_STACK + + - it: should set jdbc-ping cache stack with external database + set: + database.type: postgresql + database.host: db.example.com + database.user: keycloak + database.password.secretName: db-secret asserts: - equal: path: data.KC_CACHE @@ -212,6 +225,10 @@ tests: - it: should configure kubernetes cache stack with dns query set: + database.type: postgresql + database.host: db.example.com + database.user: keycloak + database.password.secretName: db-secret cache.stack: kubernetes asserts: - equal: @@ -225,6 +242,11 @@ tests: value: "-Djgroups.dns.query=RELEASE-NAME-keycloak-headless.NAMESPACE.svc.cluster.local" - it: should not set dns query for jdbc-ping + set: + database.type: postgresql + database.host: db.example.com + database.user: keycloak + database.password.secretName: db-secret asserts: - notExists: path: data.JAVA_OPTS_APPEND diff --git a/ci/scripts/keycloak/e2e.sh b/ci/scripts/keycloak/e2e.sh index ab82057..e437881 100755 --- a/ci/scripts/keycloak/e2e.sh +++ b/ci/scripts/keycloak/e2e.sh @@ -23,8 +23,23 @@ ADMIN_PASSWORD="e2e-test-password-123" log() { echo "==> $*"; } fail() { echo "FAIL: $*" >&2; exit 1; } -# ── Cleanup function ─────────────────────────────────────────────────── +# ── Debug & Cleanup functions ────────────────────────────────────────── +dump_debug() { + echo "=== Pod status ===" + kubectl -n "$NAMESPACE" get pods -o wide 2>/dev/null || true + echo "=== Pod describe ===" + kubectl -n "$NAMESPACE" describe pods 2>/dev/null || true + echo "=== Keycloak logs ===" + kubectl -n "$NAMESPACE" logs deployment/"$RELEASE" --all-containers --tail=200 2>/dev/null || true + echo "=== Events ===" + kubectl -n "$NAMESPACE" get events --sort-by='.lastTimestamp' 2>/dev/null || true +} cleanup() { + local exit_code=$? + if [ "$exit_code" -ne 0 ]; then + log "Test failed — dumping debug info..." + dump_debug + fi log "Cleaning up..." helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found 2>/dev/null || true kubectl delete namespace "$NAMESPACE" --ignore-not-found 2>/dev/null || true From dd86f48ff166e4833153f70e13f0a1a0de2cd8a2 Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 19:09:37 +0100 Subject: [PATCH 11/12] fix(keycloak): fix hostname-strict default breaking Keycloak 26.5 startup Keycloak 26.5 requires either KC_HOSTNAME to be set or KC_HOSTNAME_STRICT=false. The chart defaulted hostnameStrict to true without a hostname, causing Keycloak to crash on startup with: "hostname is not configured; either configure hostname, or set hostname-strict to false" Change the default to false so Keycloak starts without requiring a hostname. Users setting a hostname should also set hostnameStrict: true. Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/templates/_helpers.tpl | 2 +- charts/keycloak/tests/configmap_test.yaml | 11 ++++++++++- charts/keycloak/values.yaml | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/charts/keycloak/templates/_helpers.tpl b/charts/keycloak/templates/_helpers.tpl index ba7ca84..b016692 100644 --- a/charts/keycloak/templates/_helpers.tpl +++ b/charts/keycloak/templates/_helpers.tpl @@ -117,10 +117,10 @@ KC_HTTP_ENABLED: {{ .Values.httpEnabled | quote }} {{- if .Values.hostname }} KC_HOSTNAME: {{ .Values.hostname | quote }} {{- end }} +KC_HOSTNAME_STRICT: {{ .Values.hostnameStrict | quote }} {{- if .Values.hostnameAdmin }} KC_HOSTNAME_ADMIN: {{ .Values.hostnameAdmin | quote }} {{- end }} -KC_HOSTNAME_STRICT: {{ .Values.hostnameStrict | quote }} {{- if .Values.proxyHeaders }} KC_PROXY_HEADERS: {{ .Values.proxyHeaders | quote }} {{- end }} diff --git a/charts/keycloak/tests/configmap_test.yaml b/charts/keycloak/tests/configmap_test.yaml index 3c580b8..4a7b037 100644 --- a/charts/keycloak/tests/configmap_test.yaml +++ b/charts/keycloak/tests/configmap_test.yaml @@ -47,7 +47,16 @@ tests: path: data.KC_HOSTNAME_ADMIN value: "admin.example.com" - - it: should set hostname strict + - it: should default hostname strict to false + asserts: + - equal: + path: data.KC_HOSTNAME_STRICT + value: "false" + + - it: should set hostname strict to true when configured + set: + hostname: keycloak.example.com + hostnameStrict: true asserts: - equal: path: data.KC_HOSTNAME_STRICT diff --git a/charts/keycloak/values.yaml b/charts/keycloak/values.yaml index 0d5066e..4797fda 100644 --- a/charts/keycloak/values.yaml +++ b/charts/keycloak/values.yaml @@ -60,8 +60,8 @@ database: hostname: "" # -- Separate admin console hostname (maps to KC_HOSTNAME_ADMIN) hostnameAdmin: "" -# -- Enforce strict hostname checking (maps to KC_HOSTNAME_STRICT) -hostnameStrict: true +# -- Enforce strict hostname checking (maps to KC_HOSTNAME_STRICT). Set to true when hostname is configured. +hostnameStrict: false # ── Proxy / TLS ────────────────────────────────────────────────────────── # -- Proxy header mode: "xforwarded" or "forwarded" (maps to KC_PROXY_HEADERS) From ad427377d187e07ce4c2d787d2edecf438de976e Mon Sep 17 00:00:00 2001 From: mikkeldamsgaard Date: Sat, 14 Mar 2026 19:22:38 +0100 Subject: [PATCH 12/12] docs(keycloak): add comprehensive README with values reference Co-Authored-By: Claude Opus 4.6 (1M context) --- charts/keycloak/README.md | 346 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 charts/keycloak/README.md diff --git a/charts/keycloak/README.md b/charts/keycloak/README.md new file mode 100644 index 0000000..204758b --- /dev/null +++ b/charts/keycloak/README.md @@ -0,0 +1,346 @@ +# Keycloak Helm Chart + +[![CI](https://github.com/KitStream/helms/actions/workflows/ci.yaml/badge.svg)](https://github.com/KitStream/helms/actions/workflows/ci.yaml) +[![Chart Version](https://img.shields.io/badge/chart-26.5.0-blue)](https://github.com/KitStream/helms/releases) +[![App Version](https://img.shields.io/badge/keycloak-26.5.0-green)](https://github.com/keycloak/keycloak) + +A Helm chart for deploying [Keycloak](https://www.keycloak.org) IAM using the upstream `quay.io/keycloak/keycloak` image on Kubernetes. + +## Overview + +This chart deploys Keycloak directly from the upstream container image with native `KC_*` environment variable configuration. It supports: + +- **Databases**: PostgreSQL, MySQL, MSSQL, or embedded H2 (dev mode) +- **Clustering**: JDBC-PING (default) or Kubernetes DNS-PING via headless service +- **Build optimization**: Optional init container for `kc.sh build` to speed up startup +- **Observability**: Health endpoints on a dedicated management port, Prometheus metrics, and optional ServiceMonitor + +## Prerequisites + +- Kubernetes 1.24+ +- Helm 3.x +- An external database for production use (PostgreSQL recommended) + +## Installation + +### From OCI Registry (recommended) + +```bash +helm install keycloak oci://ghcr.io/kitstream/helms/keycloak \ + --version 26.5.0 \ + -n keycloak --create-namespace \ + -f my-values.yaml +``` + +### From Source + +```bash +helm install keycloak ./charts/keycloak \ + -n keycloak --create-namespace \ + -f my-values.yaml +``` + +## Minimal Configuration Examples + +### Development (embedded H2) + +No external database required. **Not suitable for production.** + +```yaml +database: + type: dev + +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password +``` + +### PostgreSQL (production) + +```yaml +hostname: keycloak.example.com +hostnameStrict: true + +database: + type: postgresql + host: postgres.database.svc.cluster.local + port: 5432 + user: keycloak + name: keycloak + password: + secretName: keycloak-db-password + secretKey: password + +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password + +ingress: + enabled: true + className: nginx + hosts: + - host: keycloak.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: keycloak-tls + hosts: + - keycloak.example.com +``` + +### Multi-Replica with Kubernetes DNS-PING + +```yaml +replicaCount: 3 + +hostname: keycloak.example.com +hostnameStrict: true + +database: + type: postgresql + host: postgres.database.svc.cluster.local + port: 5432 + user: keycloak + name: keycloak + password: + secretName: keycloak-db-password + secretKey: password + +cache: + stack: kubernetes + +admin: + username: admin + password: + secretName: keycloak-admin-password + secretKey: password +``` + +## Creating Secrets + +### Admin Password + +```bash +kubectl create secret generic keycloak-admin-password \ + --from-literal=password='YOUR_SECURE_PASSWORD' \ + -n keycloak +``` + +### Database Password + +```bash +kubectl create secret generic keycloak-db-password \ + --from-literal=password='YOUR_DB_PASSWORD' \ + -n keycloak +``` + +## Hostname Configuration + +Keycloak 26 requires explicit hostname configuration for production mode: + +- **`hostname`**: Set to your public Keycloak URL (e.g. `keycloak.example.com`) +- **`hostnameStrict`**: Set to `true` when `hostname` is configured to prevent dynamic hostname resolution from request headers. Defaults to `false` so Keycloak can start without a hostname (dev/testing). + +If `hostnameStrict` is `true` and no `hostname` is set, Keycloak will refuse to start. + +## Clustering + +The chart supports two cache stacks for multi-replica deployments: + +| Stack | Description | Default | +| ------------ | ------------------------------------------------------------------------ | ------- | +| `jdbc-ping` | Uses the configured database for node discovery. No extra config needed. | Yes | +| `kubernetes` | Uses DNS-PING via the headless service for JGroups discovery. | No | + +In dev mode (`database.type: dev`), clustering is disabled (`KC_CACHE=local`) since the embedded H2 database cannot be shared across replicas. + +## Build Optimization + +By default, Keycloak runs an auto-build at startup in production mode (`start`), which can take 2-5 minutes. To speed up startup, enable the build init container: + +```yaml +build: + enabled: true +``` + +This runs `kc.sh build` in an init container and passes `--optimized` to the main container, reducing startup time to seconds. + +## Values Reference + +### Global + +| Key | Type | Default | Description | +| ---------------------------- | ------ | ------- | -------------------------------- | +| `nameOverride` | string | `""` | Override the chart name | +| `fullnameOverride` | string | `""` | Fully override the resource name | +| `imagePullSecrets` | list | `[]` | Global image pull secrets | +| `serviceAccount.create` | bool | `true` | Create a ServiceAccount | +| `serviceAccount.annotations` | object | `{}` | ServiceAccount annotations | +| `serviceAccount.name` | string | `""` | ServiceAccount name override | + +### Image + +| Key | Type | Default | Description | +| ------------------ | ------ | ----------------------------- | --------------------------- | +| `image.repository` | string | `"quay.io/keycloak/keycloak"` | Container image repository | +| `image.tag` | string | `""` (appVersion) | Image tag | +| `image.pullPolicy` | string | `"IfNotPresent"` | Image pull policy | +| `replicaCount` | int | `1` | Number of Keycloak replicas | + +### Database + +| Key | Type | Default | Description | +| ------------------------------ | ------ | ------------ | ------------------------------------------------------- | +| `database.type` | string | `"dev"` | Database type (`postgresql`, `mysql`, `mssql`, `dev`) | +| `database.host` | string | `""` | Database hostname (required for external databases) | +| `database.port` | string | `""` | Database port (auto-defaults: 5432/3306/1433) | +| `database.name` | string | `"keycloak"` | Database name | +| `database.user` | string | `""` | Database username | +| `database.password.secretName` | string | `""` | Secret containing the database password | +| `database.password.secretKey` | string | `"password"` | Key in the Secret | +| `database.poolMinSize` | string | `""` | Connection pool minimum size | +| `database.poolInitialSize` | string | `""` | Connection pool initial size | +| `database.poolMaxSize` | string | `""` | Connection pool maximum size | +| `database.sslMode` | string | `""` | SSL mode for PostgreSQL (e.g. `verify-full`, `require`) | + +### Hostname & Proxy + +| Key | Type | Default | Description | +| ---------------- | ------ | ------- | ------------------------------------------------------------------------ | +| `hostname` | string | `""` | Public hostname (maps to `KC_HOSTNAME`) | +| `hostnameAdmin` | string | `""` | Separate admin console hostname (maps to `KC_HOSTNAME_ADMIN`) | +| `hostnameStrict` | bool | `false` | Disable dynamic hostname from request headers (set `true` with hostname) | +| `proxyHeaders` | string | `""` | Proxy header mode: `xforwarded` or `forwarded` | +| `httpEnabled` | bool | `true` | Enable HTTP listener (required for edge-terminated TLS) | + +### TLS + +| Key | Type | Default | Description | +| ---------------- | ------ | ------- | ------------------------------------------- | +| `tls.enabled` | bool | `false` | Enable TLS passthrough (mount cert and key) | +| `tls.secretName` | string | `""` | Secret containing `tls.crt` and `tls.key` | + +### Clustering + +| Key | Type | Default | Description | +| ------------- | ------ | ------------- | -------------------------------------------------- | +| `cache.stack` | string | `"jdbc-ping"` | Cache stack: `jdbc-ping` (default) or `kubernetes` | + +### Admin Credentials + +| Key | Type | Default | Description | +| --------------------------- | ------ | ------------ | ----------------------------------------- | +| `admin.username` | string | `""` | Admin username (maps to `KEYCLOAK_ADMIN`) | +| `admin.password.secretName` | string | `""` | Secret containing the admin password | +| `admin.password.secretKey` | string | `"password"` | Key in the Secret | + +### Observability + +| Key | Type | Default | Description | +| -------------------------------------- | ------ | -------- | ----------------------------------- | +| `healthEnabled` | bool | `true` | Enable health endpoints | +| `metrics.enabled` | bool | `true` | Enable Prometheus metrics | +| `metrics.serviceMonitor.enabled` | bool | `false` | Create a ServiceMonitor resource | +| `metrics.serviceMonitor.labels` | object | `{}` | Additional ServiceMonitor labels | +| `metrics.serviceMonitor.interval` | string | `""` | Scrape interval | +| `metrics.serviceMonitor.scrapeTimeout` | string | `""` | Scrape timeout | +| `logLevel` | string | `"info"` | Log level (`debug`, `info`, `warn`) | + +### Build Optimization + +| Key | Type | Default | Description | +| --------------- | ---- | ------- | ----------------------------------- | +| `build.enabled` | bool | `false` | Run `kc.sh build` in init container | + +### Extra Configuration + +| Key | Type | Default | Description | +| -------------------- | ------ | ------- | ------------------------------------------- | +| `extraEnvVars` | list | `[]` | Additional environment variables | +| `extraEnvVarsSecret` | string | `""` | Existing Secret to mount as env vars | +| `extraVolumes` | list | `[]` | Additional volumes | +| `extraVolumeMounts` | list | `[]` | Additional volume mounts | +| `features` | string | `""` | Comma-separated Keycloak features to enable | + +### Persistent Storage + +| Key | Type | Default | Description | +| -------------------------- | ------ | ------------------- | -------------------------------------- | +| `persistence.enabled` | bool | `false` | Enable PVC for custom themes/providers | +| `persistence.storageClass` | string | `""` | Storage class | +| `persistence.accessModes` | list | `["ReadWriteOnce"]` | PVC access modes | +| `persistence.size` | string | `"1Gi"` | Volume size | +| `persistence.annotations` | object | `{}` | PVC annotations | + +### Services + +| Key | Type | Default | Description | +| ----------------------------- | ------ | ------------- | ---------------------------------- | +| `service.type` | string | `"ClusterIP"` | Service type | +| `service.httpPort` | int | `8080` | HTTP port | +| `service.managementPort` | int | `9000` | Management port (health + metrics) | +| `service.annotations` | object | `{}` | Service annotations | +| `headlessService.jgroupsPort` | int | `7800` | JGroups clustering port | +| `headlessService.annotations` | object | `{}` | Headless service annotations | + +### Ingress + +| Key | Type | Default | Description | +| --------------------- | ------ | ------- | ----------------------- | +| `ingress.enabled` | bool | `false` | Create Ingress resource | +| `ingress.className` | string | `""` | Ingress class name | +| `ingress.annotations` | object | `{}` | Ingress annotations | +| `ingress.hosts` | list | `[]` | Ingress host rules | +| `ingress.tls` | list | `[]` | TLS configuration | + +### Probes + +| Key | Type | Default | Description | +| ---------------- | ------ | --------------------------------------------- | --------------- | +| `startupProbe` | object | HTTP GET `/health/started` on management:9000 | Startup probe | +| `livenessProbe` | object | HTTP GET `/health/live` on management:9000 | Liveness probe | +| `readinessProbe` | object | HTTP GET `/health/ready` on management:9000 | Readiness probe | + +### Pod Scheduling & Metadata + +| Key | Type | Default | Description | +| -------------------- | ------ | ------- | -------------------------------- | +| `resources` | object | `{}` | CPU/memory requests and limits | +| `nodeSelector` | object | `{}` | Node selector labels | +| `tolerations` | list | `[]` | Pod tolerations | +| `affinity` | object | `{}` | Pod affinity rules | +| `podAnnotations` | object | `{}` | Pod annotations | +| `podLabels` | object | `{}` | Additional pod labels | +| `podSecurityContext` | object | `{}` | Pod-level security context | +| `securityContext` | object | `{}` | Container-level security context | + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ Ingress Controller │ +│ │ +│ / ────────────────────────► Keycloak Pod │ +│ ├─ :8080 HTTP │ +│ ├─ :9000 Management │ +│ │ (health + metrics) │ +│ └─ :7800 JGroups │ +│ (clustering) │ +└──────────────────────────────────────────────────────┘ + │ │ + Headless Service Main Service + (JGroups DNS-PING) (HTTP + Management) +``` + +## Upstream Source + +This chart deploys the upstream [Keycloak](https://github.com/keycloak/keycloak) image from `quay.io/keycloak/keycloak`. See the `sources` field in `Chart.yaml` for details. + +## License + +Apache License 2.0 — see [LICENSE](../../LICENSE).