diff --git a/.github/workflows/change-app-version.yml b/.github/workflows/change-app-version.yml index 57f6815..7043f83 100644 --- a/.github/workflows/change-app-version.yml +++ b/.github/workflows/change-app-version.yml @@ -42,6 +42,7 @@ on: - valkey - stalwart - garage + - keycloak # freesailarr is deliberately absent: none of its ten images takes # its tag from appVersion (they are all pinned in its values.yaml), # so this workflow would rewrite appVersion and republish the chart diff --git a/.github/workflows/publish-keycloak.yml b/.github/workflows/publish-keycloak.yml new file mode 100644 index 0000000..a522e87 --- /dev/null +++ b/.github/workflows/publish-keycloak.yml @@ -0,0 +1,16 @@ +name: Publish Keycloak Helm Chart +on: + workflow_dispatch: + push: + branches: + - main + paths: + - keycloak/** +jobs: + publish-keycloak: + name: Publish Keycloak Helm Chart + uses: workflow-util/pipelines/.gitea/workflows/publish-helm-chart.yml@v3 + with: + chart-name: 'keycloak' + registry-owner: ${{ github.repository_owner }} + secrets: inherit diff --git a/README.md b/README.md index 090854c..09aa1d4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Every chart has a wiki page of its own — `https://git.jk-dev.de/jlab-cluster/h | `garage` | Garage (S3-compatible object store) | **One** node, `replication_factor = 1` — a deliberate trade with its price named (Gitea #81): the volume layer underneath already replicates and encrypts, so Garage replication on top would be nine times the raw data across two layers that know nothing about each other. What that costs is that the factor applies to the **metadata** too, where a corrupted database is upstream-documented as "generally not recoverable" and a replicated volume replicates the corruption. Hence three settings that are mandatory rather than optional: `metadata_fsync = true` (**off** in Garage's defaults), the automatic metadata snapshots, and those snapshots on a **second PVC** — a file-system snapshot of a live metadata database is not a restore point, so the volume backup covers the blocks and not the index. The first PVC carries `metadata_dir` **and** `data_dir` as two subdirectories, because volume snapshots are atomic per volume: that is what keeps index and blocks consistent with each other. Two further properties shape the whole chart: the image is **`FROM scratch`** and contains exactly one file, `/garage` — no shell, so **exec probes are impossible here** and an init container would need a second image — and the RPC secret has to arrive as an **environment variable**, because `rpc_secret_file` rejects any file with a group bit set while the kubelet sets one (the reasoning sits at the value). Started with `garage server --single-node`, which creates the cluster layout in-process before any listener opens and is idempotent on the layout version, and with `--default-access-key`/`--default-bucket`, which **import** key and bucket from the 1Password item — so the vault is the source of truth instead of a copy of what the server generated. Readiness is the admin API's `/health`, liveness and startup deliberately are **not**: `/health` answers 503 until a layout exists, so a node waiting for one would be restarted forever | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) | | `homeassistant` | Home Assistant | StatefulSet; the only chart carrying Helm tests (`templates/tests/`) | +| `keycloak` | Keycloak (OpenID Connect / SAML identity provider) | **Two** replicas with `cache=ispn`, clustered through Keycloak's default `jdbc-ping` stack — discovery runs over the PostgreSQL that is there anyway, so no headless Service and no JGroups wiring. Deployment, **no PVC** (Keycloak 26 keeps everything in the database, user sessions included). **The chart has two init containers and needs both** (Gitea #83): `db`, `health-enabled` and `metrics-enabled` are Keycloak *build* options, the published image is built with their defaults, and a runtime change makes Keycloak rewrite `/opt/keycloak/lib/quarkus` at start — which `readOnlyRootFilesystem: true` forbids and which upstream describes as a start that "creates an image that is mutable". An empty `emptyDir` over that path does not work either, because `QuarkusEntryPoint` loads `quarkus/quarkus-application.dat` before it dispatches any command, `build` included. So one init container **seeds** the directory into a volume, the next runs `kc.sh build` into it, and the serving container runs `start --optimized` — every container keeping the full hardening baseline. Build options therefore exist in exactly **one** place and a build option in `keycloak.env` fails the render. First chart with a `PodDisruptionBudget` (§11) and first `quay.io` image (hence the entry in `ci/trivy/data/ksv0125.yaml`). Two Services (8080, and the management port 9000 **never** published — it serves `/health/*` and optionally `/metrics` unauthenticated) and two Ingresses. **The admin hostname is not an access control**: `hostname-admin` only decides which URLs the server generates, and upstream states that it "does not prevent accessing the Administration REST API endpoints via the frontend URL" — restricting that API is reverse-proxy work, and the chart renders no `/admin` deny rule. `ingress.domain` is read as `KC_HOSTNAME` (as a full URL, which upstream requires alongside `hostname-admin`), so it stays required with the Ingress off. Liveness is `/health/live` and deliberately not `/health` or `/health/ready` — those include the database, and a liveness probe on them restarts the whole cluster for as long as the database is away. Two traps at the values: upstream's `db-pool-max-size` default of 100 would ask a default PostgreSQL for 200 connections across two replicas (the chart ships 20), and two releases sharing one database **merge into one Infinispan cluster** unless the cluster names differ | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | | `minecraft-router` | itzg/mc-router | Deployment + routing ConfigMap + NodePort Service (TCP only, no ingress); `mappings` is a required value | | `minecraft-server` | itzg/minecraft-server | StatefulSet + PVC + Service + init-script ConfigMap (no ingress); service type is a value (`ClusterIP` by default, routed via `minecraft-router`). **Only the newest Java line is maintained** (chart 5.0.0, #158): the directory carries one line, older ones stay published as frozen artifacts (java8/java17 at `4.0.0`, java21 at `4.1.0`) and receive no further chart changes — publishing once per line out of one directory is what made the lines drift. To run an older JVM with the current chart, set `image.tagOverride`; the chart then checks that `minecraft.version` fits the Java line of the **effective** tag and fails the render with both values named. Two cases pass unchecked on purpose: a tag without a `java` part and a `minecraft.version` that is not a plain numeric `x[.y[.z]]`. `image.tagOverride` is deliberately outside Renovate's reach — `renovate.json5` tracks `appVersion` only. Since chart 2.0.0 the tag is an **immutable dated upstream release** (`appVersion: -java8` / `-java17` / `-java21` / `-java25`, e.g. `2026.8.2-java8`) instead of the continuously rebuilt rolling line tag. Pilot for the strict `values.schema.json` (#68), which is now binding for every chart | @@ -385,6 +386,8 @@ These guidelines are **binding for every current and future chart** in this repo - Every Deployment/StatefulSet has a `replicas` value (default `1`, the values path per workload matches its section) that also accepts an explicit `0` for per-workload fine control. - Every chart has a global `scaleDown` flag (default `false`): `scaleDown: true` takes precedence over all `replicas` values, renders every Deployment/StatefulSet with `replicas: 0` and suspends CronJobs. It **stops** the app but does not uninstall it — PVCs, Secrets, Services and Ingress stay in place; setting it back to `false` brings the workloads back up. - Implemented via a per-chart `.replicas` helper in `_helpers.tpl` (reference in `template-chart`); the helper uses an explicit nil check (`kindIs "invalid"`) because Helm's `default` would swallow an explicit `0`. +- **A chart whose default is more than one replica ships a `PodDisruptionBudget`** — `policy/v1`, named `--pdb`, `minAvailable` as a value (default `1`), selector matching the workload's `app` label. `keycloak` (Gitea #83) is the first and sets the convention. The reason it is a rule and not a per-chart taste: a node drain is the most common reason a pod moves — it is what a node upgrade does — and without a budget the drain is free to take every replica at once, so the second replica buys nothing against the failure it was added for. +- **And it is rendered only while the effective replica count is `>= 2`.** `minAvailable: 1` with exactly one replica forbids every voluntary eviction, so `kubectl drain` blocks forever on a pod nothing may move — and the message names the PDB, not the replica count, which sends the reader to the wrong file. The same applies to `scaleDown: true`, where there are no pods and the object would describe a promise about nothing. So the condition is on the **computed** replica count (the `.replicas` helper), not on the raw value, and `NOTES.txt` prints which of the two happened rather than leaving it to be inferred from `kubectl get pdb`. ### 12. Values that must not lie (#68, #93, #99, #215) diff --git a/ci/keycloak/fixtures.yaml b/ci/keycloak/fixtures.yaml new file mode 100644 index 0000000..976bd2e --- /dev/null +++ b/ci/keycloak/fixtures.yaml @@ -0,0 +1,96 @@ +# Fixtures for the keycloak install-test: a throwaway PostgreSQL plus the two +# Secrets the 1Password operator would otherwise materialize from the +# OnePasswordItems the chart renders (release name: ci, stub CRD in ci/common/). +# +# WHY A REAL DATABASE AND NOT A STUB: Keycloak does nothing without one. It +# creates its entire schema through Liquibase on first start, and - the reason +# this fixture is the valuable half of the test - jdbc-ping discovers the second +# replica THROUGH this database. A stub would turn a cluster test into a +# templating test. +# +# The image is pinned to a major line like every other fixture here +# (postgres:16-alpine); ci/** is excluded from Renovate on purpose, because +# this is test scaffolding and churning it only risks breaking the +# install-test. +apiVersion: v1 +kind: Secret +metadata: + name: ci-keycloak-db-secret +stringData: + # The field NAMES are what matters here, not the values: the operator + # materializes one Secret key per item field, and the Deployment references + # these two by name. A rename on either side leaves the pod in + # CreateContainerConfigError, which is a state no render can show. + # + # They must agree with the POSTGRES_USER / POSTGRES_PASSWORD below, because + # Keycloak really connects. + username: keycloak + password: ci-keycloak-db-password +--- +apiVersion: v1 +kind: Secret +metadata: + name: ci-keycloak-bootstrap-admin-secret +stringData: + # The temporary bootstrap administrator. Keycloak creates it while the master + # realm is created, so these values are exercised on the very first start - + # and the variables are named KC_BOOTSTRAP_ADMIN_* only since Keycloak 26. + # + # A visibly throwaway password: nothing here survives the run, the cluster is + # deleted with the job, and the only thing it protects is a realm that exists + # for a few minutes. + username: ci-temp-admin + password: ci-bootstrap-admin-password +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres + labels: + app: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: public.ecr.aws/docker/library/postgres:16-alpine + env: + - name: POSTGRES_USER + value: keycloak + - name: POSTGRES_PASSWORD + value: ci-keycloak-db-password + - name: POSTGRES_DB + value: keycloak + ports: + - containerPort: 5432 + readinessProbe: + exec: + command: ["pg_isready", "-U", "keycloak"] + periodSeconds: 5 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + volumes: + # emptyDir: the database exists for the length of one job. Nothing here + # is meant to survive, and a PVC would only add a provisioner to the + # list of things that can fail a chart's test. + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 diff --git a/ci/keycloak/settings.env b/ci/keycloak/settings.env new file mode 100644 index 0000000..d992de2 --- /dev/null +++ b/ci/keycloak/settings.env @@ -0,0 +1,17 @@ +# The default 300s is not enough here, and the three parts that fill the budget +# are worth naming so the number can be lowered again if one of them changes: +# +# 1. TWO INIT CONTAINERS PER POD. The build one runs a full Quarkus +# augmentation - upstream's own issue reports measure "Quarkus +# augmentation completed in" at roughly 13-23 seconds, and a CI runner is +# not a fast machine. +# 2. THE FIRST START CREATES THE WHOLE SCHEMA through Liquibase, and with two +# replicas the second pod waits on the first's migration lock. So the two +# pods do not become Ready in parallel. +# 3. The rollout is only complete when BOTH pods are Ready, which is +# deliberate (it is what makes cluster formation part of the assertion) +# and which means the slow parts add up rather than overlap. +# +# The chart's own startupProbe budget is 600s (10s x 60); this timeout has to +# be at least that, or the test gives up before the probe would have. +INSTALL_TIMEOUT=900 diff --git a/ci/keycloak/test-values.yaml b/ci/keycloak/test-values.yaml new file mode 100644 index 0000000..17cf543 --- /dev/null +++ b/ci/keycloak/test-values.yaml @@ -0,0 +1,127 @@ +# CI values for the keycloak install-test. +# +# WHY THIS CHART IS TESTED RATHER THAN EXCLUDED, and what a green run is worth. +# Keycloak has no volume, needs one throwaway PostgreSQL and reaches Ready on +# its own, so an exclusion would be hard to justify. More importantly, this is +# the only place where the four riskiest assumptions of the chart are exercised +# at all - none of them is observable in a render. +# +# WHAT A GREEN RUN PROVES, point by point (the measurement points of Gitea #83): +# +# 1. THE TWO INIT CONTAINERS WORK AT ALL. The seed container copies the +# image's own augmentation output into the volume, the build container +# mounts that volume at /opt/keycloak/lib/quarkus and runs `kc.sh build` +# into it - both with readOnlyRootFilesystem: true. If the seeding +# assumption were wrong (QuarkusEntryPoint loads +# quarkus/quarkus-application.dat before it dispatches any command, +# `build` included), the build container could not start, and the pod +# would sit in Init with the reason only in its log. +# 2. readOnlyRootFilesystem: true HOLDS FOR THE SERVING CONTAINER, with +# emptyDirs only at /opt/keycloak/lib/quarkus, /opt/keycloak/data and +# /tmp - and NOT at /opt/keycloak/conf, which would hide the +# cache-ispn.xml that cache=ispn reads. +# 3. `start --optimized` ACCEPTS THE PRE-BUILT INSTALLATION and does not +# re-augment. A disagreement between the build options and the serving +# container would show up here as a crash loop rather than as a slow +# start, because the path it would want to rewrite is read-only. +# 4. jdbc-ping FINDS THE SECOND REPLICA from cache=ispn and the database +# configuration alone - which is why this test runs TWO replicas (see +# below) and why that is the single most valuable line in this file. +# Plus the mechanical half: the Secret key names match what the Deployment +# references, /health/started, /health/live and /health/ready all answer on +# the management port (so health-enabled really was baked in by the init +# container), the resource quantities are valid, and the schema accepts a +# realistic non-default values file. +# +# WHAT IT DOES NOT PROVE, and no install-test here could: +# - that hostname-admin serves the console cleanly on a second hostname. No +# ingress controller answers in CI and nothing drives a browser, so the +# chart's own measurement point 5 stays open. What IS checked is that both +# Ingress objects are accepted and that the Service port they name exists. +# - anything about an upgrade. The rolling-update restriction between minor +# versions is a property of two versions meeting, and this test installs +# one. +# - that the PostgreSQL here resembles production. It is one pod with an +# emptyDir, so nothing about durability, connection limits or latency +# transfers. + +secrets: + # Materialized by ci/keycloak/fixtures.yaml as ci-keycloak-db-secret and + # ci-keycloak-bootstrap-admin-secret (the OnePasswordItem stub CRD comes from + # ci/common/). + databaseSecretRef: op://ci/keycloak-db + bootstrapAdminSecretRef: op://ci/keycloak-bootstrap-admin + +keycloak: + # TWO, i.e. the chart's default, and the whole reason this fixture is not a + # one-replica shortcut. With one replica the install-test would be green + # without ever showing whether the second node is found - and "a path the + # test does not walk is a path we have not proven" is the lesson Gitea #80 + # paid for. The install-test script waits for the Deployment's rollout, and a + # rollout is only complete when BOTH pods are Ready, so cluster formation is + # inside the assertion rather than next to it. + replicas: 2 + + db: + # The throwaway PostgreSQL from fixtures.yaml. Plain Service name: both are + # in the same namespace. + host: postgres + database: keycloak + # Small but not degenerate: 2 replicas x 5 = 10 connections against + # postgres:16-alpine's default max_connections of 100, which leaves the + # fixture comfortable while still exercising the pool options. + pool: + initialSize: 1 + minSize: 1 + maxSize: 5 + + # ON against the chart's default of off, because it is a BUILD option: with + # this true the init container has to build an installation that differs from + # the published image in TWO options rather than one, and /metrics has to + # appear on the management port. A green run therefore proves the build + # arguments really reach `kc.sh build` - with metrics off, a build that + # silently did nothing would look identical. + metrics: + enabled: true + + # Set on purpose, although one release cannot collide with itself: it + # exercises the KC_CACHE_EMBEDDED_CLUSTER_NAME path, and a name Keycloak + # rejected would fail the start rather than pass unnoticed. + cache: + clusterName: ci-keycloak + + resources: + # Below the chart's defaults to stay inside the runner's budget, and the + # memory number is chosen rather than rounded: the image sizes the heap at + # 70% of the LIMIT, so 600Mi x limitFactor 3 = 1800Mi gives a ~1260Mi heap. + # Upstream's floor is "at least 750 MB" for the limit; going much below + # this makes the JVM the reason a run fails. + requests: + cpu: 250m + memory: 600Mi + ephemeral-storage: 256Mi + + build: + resources: + requests: + cpu: 250m + memory: 512Mi + ephemeral-storage: 256Mi + +ingress: + # Left ON although no ingress controller answers in CI: the point is that + # both objects are accepted and that the Service port they name BY NAME + # ("http") actually exists - a typo there is otherwise invisible until + # somebody opens the URL. + enabled: true + domain: keycloak.ci.example.com + clusterIssuer: ci-selfsigned + admin: + # The second hostname, on as well, so the chart renders KC_HOSTNAME_ADMIN + # and Keycloak has to accept it as a full URL. That is the half of + # measurement point 5 a cluster-less test can reach: Keycloak validates + # both hostname values at start ("verified that full URL is used, incl. + # scheme and hostname"), so a chart that built a bare host here would fail + # the start instead of serving a broken console later. + enabled: true + domain: keycloak-admin.ci.example.com diff --git a/ci/trivy/data/ksv0125.yaml b/ci/trivy/data/ksv0125.yaml index 11e4a07..78dd092 100644 --- a/ci/trivy/data/ksv0125.yaml +++ b/ci/trivy/data/ksv0125.yaml @@ -20,3 +20,9 @@ ksv0125: - cr.jk-dev.de - ghcr.io - docker.io + # quay.io, added with the keycloak chart (Gitea #83) - Keycloak publishes + # its official image only there (quay.io/keycloak/keycloak), and it is so + # far the only chart in this repo using that registry. Listed rather than + # suppressed, for the reason the comment above gives: the check keeps being + # able to say something about an image from a genuinely foreign source. + - quay.io diff --git a/keycloak/Chart.yaml b/keycloak/Chart.yaml new file mode 100644 index 0000000..0752bf9 --- /dev/null +++ b/keycloak/Chart.yaml @@ -0,0 +1,50 @@ +apiVersion: v2 +name: keycloak +description: A Helm chart to deploy Keycloak, an OpenID Connect and SAML identity provider, as a two-node cluster on Kubernetes +icon: https://www.keycloak.org/resources/images/icon.svg + +type: application + +# appVersion is the exact image tag of quay.io/keycloak/keycloak. No "v" +# prefix here - upstream tags are plain three-part versions (26.8.0). +# +# THE TAG LANDSCAPE, AND WHY THIS CHART NEEDS NO VERSION CONSTRAINT. Measured +# on 2026-10-04 against quay.io's tag API, this registry carries four shapes +# side by side: +# +# 1. release tags 26.8.0, 26.7.5, ... <- what we track +# 2. REBUILD tags 26.8.0-0, 26.7.3-1, 26.7.2-2 - the same app +# version rebuilt (base image or CVE fix) without an app-version bump. +# 26.8.0-0 resolves to the SAME manifest digest as 26.8.0. +# 3. floating minor tags 26.8, 26.7, 26.6, ... - they move +# 4. floating names latest, nightly - they move daily +# +# This is the same GENUS of trap as garage's commit-SHA tags and stalwart's +# moving names, and it is the one case in this repo where the answer came out +# the other way: plain `docker` versioning already excludes all three unwanted +# shapes, so the renovate.json5 block carries NO versioningTemplate. Docker +# versioning splits a tag at the first dash and only ever offers candidates +# with the SAME suffix - our value has none, so the -N rebuilds are invisible +# to it - and 26.8 fails on the missing third part while latest/nightly parse +# as no version at all. +# +# THAT IS MEASURED, NOT ASSUMED, and the measurement is written down because +# the next round will otherwise add a constraint "to be safe" (renovate 44.132.5, +# --platform=local --dry-run=lookup against the real registry): +# appVersion 26.8.0 -> updates [], warnings [] +# appVersion 26.7.0 -> exactly one: 26.8.0 (minor) +# appVersion 26.7.4 -> exactly two: 26.7.5 (patch), 26.8.0 (minor) +# No run offered a rebuild tag, a floating minor, latest or nightly. +# +# AND THE OTHER HALF, WHICH THE VERSION NUMBER CANNOT CARRY: Keycloak supports +# a rolling update only WITHIN a minor line. A minor jump needs the cluster +# shut down. The global `automerge: true` in renovate.json5 applies here +# anyway - a deliberate acceptance of that risk, not an oversight - so the +# procedure lives in NOTES.txt and on the wiki page instead of in a +# packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before +# merging an app-version PR that changes the minor. +version: 1.0.0+up26.8.0 +appVersion: "26.8.0" + +maintainers: + - name: jklein diff --git a/keycloak/templates/NOTES.txt b/keycloak/templates/NOTES.txt new file mode 100644 index 0000000..c9d9111 --- /dev/null +++ b/keycloak/templates/NOTES.txt @@ -0,0 +1,193 @@ +{{- $settings := fromYaml (include "keycloak.settings" .) -}} +{{- $replicas := int (include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas)) -}} +{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" -}} +{{- $deploy := printf "%s-%s-deployment" .Release.Name .Chart.Name -}} +{{- $svc := printf "%s-%s-service" .Release.Name .Chart.Name -}} +Keycloak {{ .Chart.AppVersion }} installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. + +{{ $replicas }} replica(s), cache=ispn with jdbc-ping discovery over the database, +external PostgreSQL at {{ $settings.db.host | default "" }}:{{ $settings.db.port | int64 }}/{{ $settings.db.database }}. + +ENDPOINTS +{{ if .Values.ingress.enabled }} Public: {{ include "keycloak.frontendUrl" . }} +{{ else }} Public: no Ingress (ingress.enabled: false) - in-cluster only +{{ end }}{{ if $adminHostname }} Admin: {{ include "keycloak.adminUrl" . }}/admin +{{ else }} Admin: no admin hostname (ingress.admin.enabled: false) - the console is + served on the public host and Keycloak generates its URLs for it +{{ end }} In-cluster: http://{{ $svc }}.{{ .Release.Namespace }}.svc.cluster.local:{{ $settings.ports.http | int64 }} + + Management port {{ $settings.ports.management | int64 }} is NOT published and must never be: it serves + /health, /health/live, /health/ready, /health/started{{ if $settings.metrics.enabled }} and /metrics{{ end }} + WITHOUT authentication. It has a ClusterIP Service + ({{ .Release.Name }}-{{ .Chart.Name }}-management-service) for in-cluster scraping only. + +THE 1PASSWORD ITEMS, AND WHICH FIELDS THEY MUST CARRY +The operator materializes one Secret key per item FIELD, so a missing or +misnamed field leaves the pod in CreateContainerConfigError - with no message +naming the field. + + {{ .Values.secrets.databaseSecretRef | default "" }} + username the PostgreSQL role + password that role's password + The role must be able to CREATE its schema: Keycloak builds and migrates + the whole schema itself on first start. +{{ if $settings.bootstrapAdmin.enabled }} + {{ .Values.secrets.bootstrapAdminSecretRef | default "" }} + username the TEMPORARY bootstrap administrator (upstream default name: + temp-admin) + password its password +{{ else }} + (no bootstrap admin item - keycloak.bootstrapAdmin.enabled is false, which is + the correct state once a permanent administrator exists) +{{ end }} + Both arrive as ENVIRONMENT VARIABLES, not as files, and that is forced: + Keycloak 26 has no _FILE form for an option's value. The price: the password + is in the container's process environment. + +FIRST INSTALL - AND STEP 4 IS MANDATORY, NOT ADVICE + + 1. kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ $deploy }} + Ready means /health/ready answered 200, which means the database is + reachable and the schema is in place. The first start is the slow one: it + creates the entire schema through Liquibase, and with {{ $replicas }} replica(s) + the second waits on the first's migration lock. + + 2. Check that the cluster really formed - this is what the second replica is + for, and a silent failure here leaves two independent islands: + kubectl -n {{ .Release.Namespace }} logs deployment/{{ $deploy }} | grep -i "ISPN000094\|view accepted\|members" + Look for a view with {{ $replicas }} members. One member per pod means + jdbc-ping did not find the peer. + + 3. Log in at {{ if $adminHostname }}{{ include "keycloak.adminUrl" . }}/admin{{ else if .Values.ingress.enabled }}{{ include "keycloak.frontendUrl" . }}/admin{{ else }}the in-cluster address, port-forwarded{{ end }} + with the bootstrap credentials from the vault item above. + + 4. CREATE A PERMANENT ADMINISTRATOR, then remove the temporary one. + The bootstrap account is TEMPORARY by design - upstream: "the account + should exist only for the duration necessary to perform operations needed + to gain permanent and more secure admin access. After that, the account + needs to be removed manually." This is different from Keycloak before 26, + where KEYCLOAK_ADMIN created a regular administrator and there was nothing + to clean up. + a) In the master realm, create a user, set a password, assign the + realm-admin / admin role. + b) Verify you can log in as that user. + c) Delete the temp-admin user. + d) helm upgrade with keycloak.bootstrapAdmin.enabled: false + + 5. Until step 4d, EVERY START LOGS a line about the initial administrator not + being created, because the master realm already exists and the bootstrap + path is skipped. It is harmless. It is also the kind of message somebody + eventually investigates, which is why 4d is part of the install and not a + tidy-up. + +MINOR UPGRADES NEED THE CLUSTER DOWN - AND THE AUTOMERGE DOES NOT KNOW THAT + + Keycloak supports a rolling update only WITHIN a minor line. A minor jump + (26.8 -> 26.9) requires every node shut down before any new node starts; + mixing two minor versions in one Infinispan cluster is not a supported + configuration, and nothing in the cluster reports that it happened. + + THIS REPOSITORY'S RENOVATE AUTOMERGES EVERY UPDATE TYPE, MAJORS INCLUDED. + That is a deliberate, accepted risk and not an oversight (Gitea #83), but it + means an app-version PR that moves the minor can reach main, publish the + chart and be rolled out as a rolling update without anybody deciding to. + Watch app-version PRs for a changed minor, and if one has landed, take this + path rather than letting the rollout happen: + + 1. Decide it with the tool instead of the version number. With the OLD + version and the OLD configuration: + kc.sh update-compatibility metadata --file=/tmp/kc-compat.json + then with the NEW version and the NEW configuration: + kc.sh update-compatibility check --file=/tmp/kc-compat.json + Pass EVERY configuration option on both calls - upstream: "Omitting any + configuration options results in incomplete metadata, and could lead to + a wrong reported result". + Exit codes, which is what to automate on: 0 rolling update possible, + 1 unexpected error, 2 invalid CLI option, 3 and 4 not possible - the + deployment must be shut down. + + 2. If it says shut down: + helm upgrade ... --set scaleDown=true # every pod stops + # back up the database AND the configuration before anything else + helm upgrade ... --set scaleDown=false # with the new version + + A NOTE ON WHAT IS NOT SETTLED: upstream's 26.6 release notes call patch-level + rolling updates supported and active by default, while the 26.8.0 page for + update-compatibility says a rolling update is possible "when the Keycloak + version is the same" and uses a PATCH pair (26.2.0 -> 26.2.1) as its example + of an incompatible one. Those two cannot both be current. Until it is clear, + run the check for patch updates too and trust its exit code rather than the + size of the version step. + +TRAPS WORTH KNOWING BEFORE YOU NEED THEM + + - THE ADMIN HOSTNAME IS NOT AN ACCESS CONTROL. hostname-admin decides which + URLs the server generates, nothing more. Upstream: "Using the + hostname-admin option does not prevent accessing the Administration REST + API endpoints via the frontend URL specified by the hostname option. If you + want to restrict access to the Administration REST API, you need to do it + on the reverse proxy level." So /admin/realms/... is reachable through the + public host whatever you put on the admin host, and an IP allow-list on the + admin Ingress restricts nothing on its own. This chart renders no deny rule + for /admin - see values.yaml for why not. + + - A SHARED DATABASE MERGES TWO CLUSTERS. Node discovery runs through the + database, so - upstream - "clusters that share the same database but should + remain separate will merge unless they have distinct cluster names. The + default cluster name is ISPN." Two releases of this chart on one database + therefore form ONE cluster, silently. + {{ with $settings.cache.clusterName }}This release sets cache-embedded-cluster-name={{ . }}.{{ else }}This release uses the default name (ISPN) - keycloak.cache.clusterName is + unset. Read its comment in values.yaml before setting it: upstream also + warns about cache invalidation between differently-named clusters.{{ end }} + + - THERE IS NO ConfigMap IN THIS CHART, so no checksum/config annotation. That + is not an omission: Keycloak is configured entirely through options and + KC_* variables, so there is no config file whose change could fail to + restart anything. + + - A CHANGE INSIDE A VAULT ITEM RESTARTS NOTHING. Helm never sees it. After + rotating a field: + kubectl -n {{ .Release.Namespace }} rollout restart deployment/{{ $deploy }} + (Repointing a secrets.*SecretRef at a different item DOES roll the pods - + that is covered by checksum/secret-ref.) + + - THE POD HAS TWO INIT CONTAINERS AND NEEDS BOTH. db, health-enabled and + metrics-enabled are Keycloak BUILD options, the published image is built + with their defaults, and a runtime change makes Keycloak rewrite + /opt/keycloak/lib/quarkus at start - which readOnlyRootFilesystem: true + forbids. So one init container seeds that directory into a volume and the + next runs `kc.sh build` into it; the serving container then runs + `start --optimized`. If a pod is stuck in Init, read those logs first: + kubectl -n {{ .Release.Namespace }} logs -c {{ .Release.Name }}-{{ .Chart.Name }}-build + Build options are set ONLY through keycloak.db.vendor, + keycloak.metrics.enabled and keycloak.features. Putting one into + keycloak.env fails the render on purpose. + + - POOL SIZES ARE PER REPLICA. keycloak.db.pool.maxSize is {{ $settings.db.pool.maxSize | int64 }}, so with the + current {{ $replicas }} replica(s) this release can open up to {{ mul ($settings.db.pool.maxSize | int64) $replicas }} connections. + Upstream's default is 100 per node, which with two nodes exceeds a default + PostgreSQL's max_connections of 100. Raise it together with the server, + never alone. +{{ if and $settings.podDisruptionBudget.enabled (ge $replicas 2) }} + - A PodDisruptionBudget IS in place ({{ .Release.Name }}-{{ .Chart.Name }}-pdb, minAvailable + {{ $settings.podDisruptionBudget.minAvailable | int64 }}), so a node drain cannot take all replicas at once. +{{ else if $settings.podDisruptionBudget.enabled }} + - NO PodDisruptionBudget was rendered, because the effective replica count is + {{ $replicas }}. That is deliberate: minAvailable 1 with a single replica forbids every + voluntary eviction, so `kubectl drain` would block forever on a pod nothing + may move - and the error would name the PDB rather than the replica count. + Run two replicas to get the budget. +{{ else }} + - NO PodDisruptionBudget (keycloak.podDisruptionBudget.enabled is false). A + node drain may take every replica at once. +{{ end }} +WHAT THIS CHART DOES NOT RENDER + + - No PVC and no StatefulSet: Keycloak 26 keeps everything in the database, + user sessions included (persistent sessions are the default; volatile ones + are deprecated as of 26.8). There is no disk state to keep. + - No NetworkPolicy. jdbc-ping opens direct pod-to-pod connections on port + {{ $settings.ports.jgroups | int64 }}, so any policy added later has to allow that between the + replicas, or the cluster silently becomes two islands. + - No deny rule for /admin on the public Ingress, and no restriction of the + administration REST API. That is reverse-proxy work - see the first trap. diff --git a/keycloak/templates/_helpers.tpl b/keycloak/templates/_helpers.tpl new file mode 100644 index 0000000..40445e0 --- /dev/null +++ b/keycloak/templates/_helpers.tpl @@ -0,0 +1,531 @@ +{{/* +Render the body of a container "resources:" block from a values dict of the form + + resources: + limitFactor: 3 # optional, default 3 + requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... } + limits: { ... } # optional, every entry optional + +Rules (variant B, see issue #19): + - requests are rendered as given (camelCase "ephemeralStorage" is normalized + to the Kubernetes key "ephemeral-storage"). + - an explicitly set limit always wins. + - a missing memory / ephemeral-storage limit is computed as + limitFactor x the corresponding request. + - a missing cpu limit is NOT set at all (the kubelet then treats CPU as + unlimited; CPU is compressible, so this cannot kill the workload). + +NOTE: this helper is duplicated into every chart's templates/_helpers.tpl with +the chart's name as define prefix, because sharing it would mean a library +chart, and a dependency is admissible but expensive (README, guideline 14) - +this chart takes none. The reference implementation is +template-chart/templates/_helpers.tpl. +*/}} +{{- define "keycloak.resources" -}} +{{- $resources := . | default dict -}} +{{- $factor := 3.0 -}} +{{- if not (kindIs "invalid" $resources.limitFactor) -}} +{{- $factor = float64 $resources.limitFactor -}} +{{- end -}} +{{- $requests := dict -}} +{{- range $key, $value := ($resources.requests | default dict) -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $_ := set $requests (include "keycloak.resourceKey" $key) $value -}} +{{- end -}} +{{- end -}} +{{- $limits := dict -}} +{{- range $key, $value := ($resources.limits | default dict) -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $_ := set $limits (include "keycloak.resourceKey" $key) $value -}} +{{- end -}} +{{- end -}} +{{- range $key, $request := $requests -}} +{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}} +{{- $_ := set $limits $key (include "keycloak.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}} +{{- end -}} +{{- end -}} +{{- $out := dict -}} +{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}} +{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Normalize a resource name from values to its Kubernetes key. +*/}} +{{- define "keycloak.resourceKey" -}} +{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}} +{{- end -}} + +{{/* +Multiply a Kubernetes quantity by a factor, keeping its suffix. +Input: dict "quantity" "factor" . +*/}} +{{- define "keycloak.multiplyQuantity" -}} +{{- $quantity := include "keycloak.formatNumber" .quantity -}} +{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}} +{{- if not $number -}} +{{- fail (printf "cannot parse resource quantity %q" $quantity) -}} +{{- end -}} +{{- $suffix := trimPrefix $number $quantity -}} +{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}} +{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}} +{{- end -}} +{{- $product := mulf (float64 $number) .factor -}} +{{- if eq $product (floor $product) -}} +{{- printf "%d%s" (int64 $product) $suffix -}} +{{- else -}} +{{- printf "%s%s" (include "keycloak.formatNumber" $product) $suffix -}} +{{- end -}} +{{- end -}} + +{{/* +Render a number as a plain decimal string, never in exponential notation +(issue #157): the null-safe fallback helpers pass values through fromYaml, +which parses every number as float64, and Go's "%v" switches float64 to +exponential notation from about 1e6 upwards - "1.048576e+06" is not a valid +Kubernetes quantity. Strings ("700Mi") are passed through untouched. +*/}} +{{- define "keycloak.formatNumber" -}} +{{- if kindIs "float64" . -}} +{{- regexReplaceAll "\\.?0+$" (printf "%.10f" .) "" -}} +{{- else -}} +{{- printf "%v" . -}} +{{- end -}} +{{- end -}} + +{{/* +Merge a user-supplied values sub-tree onto a dict of chart defaults so that a +null / empty override falls back to the defaults instead of erasing them +(issues #61, #99). Input: dict "defaults" "given" "path" +. Output: YAML of the merged dict (consume it with fromYaml). + +An override key written without children is YAML null, and Helm treats that +null as a deletion of the chart's default. This helper defines the opposite +semantics - an empty block means "chart defaults apply" - while an explicitly +set value still wins, including false and 0. +*/}} +{{- define "keycloak.defaultedDict" -}} +{{- $defaults := .defaults | default dict -}} +{{- $given := .given -}} +{{- $path := .path | default "value" -}} +{{- if kindIs "invalid" $given -}} +{{- $given = dict -}} +{{- end -}} +{{- if not (kindIs "map" $given) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" $path (kindOf $given) $given) -}} +{{- end -}} +{{- $out := deepCopy $defaults -}} +{{- range $key, $value := $given -}} +{{- if not (kindIs "invalid" $value) -}} +{{- $default := index $defaults $key -}} +{{- if and (kindIs "map" $value) (kindIs "map" $default) -}} +{{- $_ := set $out $key (fromYaml (include "keycloak.defaultedDict" (dict "defaults" $default "given" $value "path" (printf "%s.%s" $path $key)))) -}} +{{- else if kindIs "map" $default -}} +{{- fail (printf "%s.%s must be a mapping or null, got %s (%v)" $path $key (kindOf $value) $value) -}} +{{- else -}} +{{- $_ := set $out $key $value -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- toYaml $out -}} +{{- end -}} + +{{/* +Pick one sub-block out of a values block that may itself be null. +Input: dict "block" "key" "path" . +Returns YAML of dict "value" . +*/}} +{{- define "keycloak.subBlock" -}} +{{- $block := .block -}} +{{- if kindIs "invalid" $block -}} +{{- $block = dict -}} +{{- end -}} +{{- if not (kindIs "map" $block) -}} +{{- fail (printf "%s must be a mapping or null, got %s (%v)" .path (kindOf $block) $block) -}} +{{- end -}} +{{- toYaml (dict "value" (index $block .key)) -}} +{{- end -}} + +{{/* +THE EFFECTIVE KEYCLOAK SETTINGS - everything the Deployment, the Services, the +PodDisruptionBudget and NOTES.txt read out of .Values.keycloak, merged onto this +chart's defaults. + +Why the whole settings block goes through the null-safe helper and not only the +three usual suspects (guideline 12.2: "the rule is about the shape, not about +those three block kinds"): every value below is dereferenced by a template, and +an erased one would not abort the render - it would render a containerPort with +no number, a `--db=` with an empty right-hand side, or a database host that is +the empty string. The first is rejected by the API server at apply time, the +second fails the BUILD inside the init container, and the third is a pod waiting +on a connection that cannot be made. All three arrive after the render looked +fine, which is exactly the class #99 is about. satisfactory shipped the same +shape with half its blocks null-safe and half not (Gitea #16), and the half that +was not took the Service's port with it. + +The defaults here MUST stay in sync with values.yaml, which remains the +documented, user-facing place for them; this copy is only the fallback. + +Usage: {{ $settings := fromYaml (include "keycloak.settings" .) }} +*/}} +{{- define "keycloak.settings" -}} +{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" .Values "key" "keycloak" "path" ".Values"))).value -}} +{{- $defaults := dict + "replicas" 2 + "db" (dict + "vendor" "postgres" + "host" nil + "port" 5432 + "database" "keycloak" + "pool" (dict "initialSize" 2 "minSize" 2 "maxSize" 20)) + "bootstrapAdmin" (dict "enabled" true) + "metrics" (dict "enabled" false) + "features" (list) + "cache" (dict "clusterName" nil) + "ports" (dict "http" 8080 "management" 9000 "jgroups" 7800) + "podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak") -}} +{{- end -}} + +{{/* +Render the replica count for the Deployment. + +Input: dict "scaleDown" "replicas" . + + - scaleDown: true takes precedence and always yields 0. + - otherwise an explicitly set replicas value is used as given, including an + explicit 0 - Helm's "default" would swallow 0, hence the kindIs "invalid" + nil check. + - a missing replicas value defaults to 2. + +A NOTE SPECIFIC TO THIS CHART: the default is 2, not 1, and that is the point +of the chart rather than a tuning choice - an SSO outage takes every dependent +login with it. Two replicas are cheap here because node discovery runs over the +database that is there anyway (cache=ispn plus Keycloak's default jdbc-ping +stack). What two replicas cost is the rolling-update restriction: upstream +supports a rolling update only inside a minor line, so a minor jump becomes a +procedure instead of a restart. See NOTES.txt. +*/}} +{{- define "keycloak.replicas" -}} +{{- if .scaleDown -}} +0 +{{- else if not (kindIs "invalid" .replicas) -}} +{{- .replicas -}} +{{- else -}} +2 +{{- end -}} +{{- end -}} + +{{/* +THE BUILD OPTIONS, IN ONE PLACE - and this helper being the ONLY place is what +keeps the chart startable (Gitea #83). + +Keycloak separates BUILD options from runtime options. Verified against the +26.8.0 "All configuration" table, where a build option carries the tool icon: +db, health-enabled, metrics-enabled, features and features-disabled are build +options; db-url-host, db-username, db-password, the db-pool-* sizes, hostname, +hostname-admin, hostname-strict, proxy-headers, http-enabled, http-port, +http-management-port, cache, cache-stack and the cache-embedded-* options are +not. + +The published image is augmented with the build options' DEFAULTS (db=dev-file, +health-enabled=false, metrics-enabled=false). Supplying different ones at +runtime makes Keycloak re-augment itself at start, and the augmentation +rewrites /opt/keycloak/lib/quarkus - inside the root filesystem, which +readOnlyRootFilesystem: true makes read-only. Upstream's container guide calls +that start what it is: it "creates an image that is mutable". + +So the BUILD init container receives exactly these options and the serving +container runs `start --optimized` and passes NO build option at all. That +asymmetry is deliberate: with `--optimized` Keycloak expects a pre-built +installation and a build option handed to it alongside is an error, and - more +importantly - there is no second list that could drift out of step with this +one. If a build option must be added, it is added HERE and nowhere else. + +THEY ARE RENDERED AS ENVIRONMENT VARIABLES, NOT AS CLI ARGUMENTS, and that is a +safety decision rather than a style one. `kc.sh build` reads KC_* exactly as it +reads flags, and as variables the values are handed over by the kubelet instead +of being parsed by a shell - so a feature name containing a space cannot become +two arguments, and nothing here has to be shell-quoted. + +health-enabled is hard-coded true rather than exposed as a value: all three +probes address /health/* on the management port, so a release with it switched +off would have no working probe at all - the same defect teamspeak-server's +port name exists to prevent (guideline 12.5). +*/}} +{{- define "keycloak.buildEnv" -}} +{{- $settings := fromYaml (include "keycloak.settings" .) -}} +- name: KC_DB + value: {{ required "A database vendor must be provided (keycloak.db.vendor)" $settings.db.vendor | quote }} +- name: KC_HEALTH_ENABLED + value: "true" +- name: KC_METRICS_ENABLED + value: {{ $settings.metrics.enabled | toString | quote }} +{{- with $settings.features }} +- name: KC_FEATURES + value: {{ join "," . | quote }} +{{- end -}} +{{- end -}} + +{{/* +The variable names of Keycloak BUILD options, used to refuse them in the +additive env block (see keycloak.env below). + +Read off the 26.8.0 option table. It is not claimed to be exhaustive - upstream +can add a build option tomorrow and this list would not know - which is why it +is the SECOND line of defence and not the first. The first is structural: the +serving container is rendered with `start --optimized` and no build option, so +there is no list of options on it to disagree with the init container's. This +guard only stops somebody re-introducing one through values. +*/}} +{{- define "keycloak.buildOptionEnvNames" -}} +- KC_DB +- KC_DB_KIND +- KC_DB_DRIVER +- KC_HEALTH_ENABLED +- KC_HTTP_MANAGEMENT_HEALTH_ENABLED +- KC_METRICS_ENABLED +- KC_FEATURES +- KC_FEATURES_DISABLED +- KC_HTTP_RELATIVE_PATH +- KC_HTTP_MANAGEMENT_RELATIVE_PATH +- KC_HTTPS_CLIENT_AUTH +- KC_HTTPS_MANAGEMENT_CLIENT_AUTH +- KC_TRANSACTION_XA_ENABLED +- KC_LEGACY_OBSERVABILITY_INTERFACE +{{- end -}} + +{{/* +The frontend URL (KC_HOSTNAME) and the admin URL (KC_HOSTNAME_ADMIN). + +BOTH ARE FULL URLs, NOT BARE HOSTS, and that is upstream's requirement: "If +hostname-admin is configured, hostname must be set to a URL (not just +hostname). Otherwise Keycloak would not know what is the correct frontend URL +(incl. port etc.)", and both values are "verified that full URL is used, incl. +scheme and hostname". https:// is the only scheme this chart renders - the +Ingress always terminates TLS, so the browser-facing scheme is https even +though the pod itself serves plain HTTP. + +ingress.domain is required HERE and not only in the Ingress template, because +this is the read that makes it required even with ingress.enabled: false +(guideline 12.1): Keycloak refuses to start in production mode without a +hostname, hostname-strict being true by default. +*/}} +{{- define "keycloak.frontendUrl" -}} +https://{{ required "A Domain/Host must be provided (ingress.domain) - Keycloak derives KC_HOSTNAME from it and refuses to start without a hostname, so it stays required even with ingress.enabled: false" .Values.ingress.domain }} +{{- end -}} + +{{- define "keycloak.adminUrl" -}} +https://{{ required "An admin Domain/Host must be provided (ingress.admin.domain) while ingress.admin.enabled is true" (((.Values.ingress | default dict).admin | default dict).domain) }} +{{- end -}} + +{{/* +Is the admin hostname in play? Only then are KC_HOSTNAME_ADMIN and the second +Ingress rendered. + +WHAT IT IS NOT: an access control. hostname-admin decides which URLs the server +GENERATES; upstream states plainly that it "does not prevent accessing the +Administration REST API endpoints via the frontend URL specified by the hostname +option" and that restricting that API is reverse-proxy work. The full reasoning +sits at ingress.admin.enabled in values.yaml, and the same sentence is in +NOTES.txt and on the wiki page - three places on purpose, because the value's +name suggests a boundary and the next reader will assume one. +*/}} +{{- define "keycloak.adminHostnameEnabled" -}} +{{- $ingress := .Values.ingress | default dict -}} +{{- $admin := $ingress.admin | default dict -}} +{{- if kindIs "invalid" $admin.enabled -}} +true +{{- else -}} +{{- $admin.enabled -}} +{{- end -}} +{{- end -}} + +{{/* +The effective security contexts, probes and resources (issue #99). + +The defaults below MUST stay in sync with values.yaml. They reproduce THIS +chart's defaults, not the repo standard - see the uid/gid note. +*/}} +{{- define "keycloak.podSecurityContext" -}} +{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "pod" "path" "keycloak.securityContext"))).value -}} +{{- $defaults := dict + "runAsNonRoot" true + "runAsUser" 1000 + "runAsGroup" 0 + "fsGroup" 1000 + "fsGroupChangePolicy" "Always" + "seccompProfile" (dict "type" "RuntimeDefault") -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.pod") -}} +{{- end -}} + +{{- define "keycloak.containerSecurityContext" -}} +{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "container" "path" "keycloak.securityContext"))).value -}} +{{- $defaults := dict + "allowPrivilegeEscalation" false + "readOnlyRootFilesystem" true + "runAsNonRoot" true + "capabilities" (dict "drop" (list "ALL") "add" (list)) -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.securityContext.container") -}} +{{- end -}} + +{{/* +LIVENESS: /health/live on the management port, and deliberately NOT /health or +/health/ready. + +Guideline 12.3 in the one shape where upstream has already separated the +checks: /health/live reports whether this process is alive and contains no +database check, while /health/ready and the aggregate /health do. A liveness +probe on either of those restarts every pod in the cluster for as long as the +database is away - which cannot fix a database, and which turns a degraded login +service into no login service. Same lesson as zipline; here it is one path +away, which is also why the wrong path would look entirely plausible in a diff. +*/}} +{{- define "keycloak.livenessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/health/live" "port" "management") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.livenessProbe") -}} +{{- end -}} + +{{/* +READINESS: /health/ready - which DOES include the database, and must. A node +that cannot reach its database answers every login with an error, so no Service +should route to it. This is the other half of guideline 12.3: a dependency-free +liveness path is only correct when readiness really does hang on the +dependency. +*/}} +{{- define "keycloak.readinessProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/health/ready" "port" "management") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 3 -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.readinessProbe") -}} +{{- end -}} + +{{/* +STARTUP: /health/started, Keycloak's own "finished booting" endpoint. It gates +liveness and readiness, which is why neither carries an initialDelaySeconds. + +Budget = periodSeconds * failureThreshold = 10 * 60 = 600s. What has to fit in +it is not only the JVM and Infinispan: on a FIRST install Keycloak creates its +whole schema through Liquibase, and with two replicas starting together the +second waits on the first's migration lock. Too short a budget there is a +CrashLoopBackOff that reads as an application fault and is really a stopwatch. + +The Quarkus augmentation is NOT inside this budget - it runs in the init +container, which has no probe and no deadline. That is the second thing the +init container buys, after the read-only root filesystem. +*/}} +{{- define "keycloak.startupProbe" -}} +{{- $defaults := dict + "httpGet" (dict "path" "/health/started" "port" "management") + "periodSeconds" 10 + "timeoutSeconds" 5 + "failureThreshold" 60 -}} +{{- include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.startupProbe") -}} +{{- end -}} + +{{/* +The effective resources blocks, fed into the resources helper above so that an +erased "resources:" still yields the chart's requests and computed limits. + +Two blocks, because the init container does something different from the +serving container: a one-shot, CPU-bound Quarkus augmentation that is gone +before the serving container starts. +*/}} +{{- define "keycloak.effectiveResources" -}} +{{- $defaults := dict + "limitFactor" 3 + "requests" (dict "cpu" "500m" "memory" "700Mi" "ephemeral-storage" "256Mi") -}} +{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" . "path" "keycloak.resources")) -}} +{{- include "keycloak.resources" $merged -}} +{{- end -}} + +{{- define "keycloak.buildResources" -}} +{{- $given := (fromYaml (include "keycloak.subBlock" (dict "block" . "key" "resources" "path" "keycloak.build"))).value -}} +{{- $defaults := dict + "limitFactor" 3 + "requests" (dict "cpu" "500m" "memory" "512Mi" "ephemeral-storage" "256Mi") -}} +{{- $merged := fromYaml (include "keycloak.defaultedDict" (dict "defaults" $defaults "given" $given "path" "keycloak.build.resources")) -}} +{{- include "keycloak.resources" $merged -}} +{{- end -}} + +{{/* +The additive env block, rendered through tpl with the value, secretKeyRef and +configMapKeyRef forms (guideline 10). + +ADDITIVE MEANS ADDITIVE: an entry repeating a name this chart already sets does +not replace it. At install time both reach the pod and the later one wins; on an +EXISTING release the pair collapses to the chart's own value, because Kubernetes +merges env by name - and helm upgrade still reports success. So do not use this +block to override KC_HOSTNAME or the database variables; change the values that +produce them. + +AND ONE CLASS OF ENTRY IS REFUSED OUTRIGHT: a BUILD option's variable name. A +build option in the serving container's environment disagrees with what the init +container built, and Keycloak answers a disagreement by re-augmenting - onto a +read-only path. The result is a crash loop whose cause is nowhere near the +error message, so the render fails here instead, naming the variable. This is a +`fail`, not a silent drop, for the reason guideline 12.6 gives: a value that +cannot be honoured belongs in an abort, not in an implicit branch. +*/}} +{{- define "keycloak.env" -}} +{{- $root := .root -}} +{{- $buildNames := fromYamlArray (include "keycloak.buildOptionEnvNames" .) -}} +{{- range $value := .env }} +{{- if has (upper ($value.name | toString)) $buildNames }} +{{- fail (printf "keycloak.env may not set %q: it is a Keycloak BUILD option, and a build option on the serving container disagrees with what the init container built - Keycloak then re-augments itself onto the read-only /opt/keycloak/lib/quarkus and crash-loops. Build options are set through the chart's own values (keycloak.db.vendor, keycloak.metrics.enabled, keycloak.features), which feed `kc.sh build` in the init container. See values.yaml, point 2." $value.name) }} +{{- end }} +{{- if $value.value }} +- name: {{ $value.name | quote }} + # quote is required, not cosmetic: a value like "8080" or "true" renders + # unquoted as a YAML number/bool, and the API server then rejects the whole + # manifest with "cannot unmarshal number into Go struct field EnvVar.value of + # type string". + value: {{ tpl $value.value $root | quote }} +{{- else if $value.valueFrom }} +{{- if $value.valueFrom.secretKeyRef }} +- name: {{ $value.name }} + valueFrom: + secretKeyRef: + name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }} + key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }} +{{- else if $value.valueFrom.configMapKeyRef }} +- name: {{ $value.name }} + valueFrom: + configMapKeyRef: + name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }} + key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }} +{{- end }} +{{- end }} +{{- end }} +{{- end -}} + +{{/* +The three writable paths, defined once because two containers have to agree on +them. + +/opt/keycloak/lib/quarkus is the one that matters and the one that is not +guessable: it is where the Quarkus augmentation writes, i.e. where `kc.sh +build` puts generated-bytecode.jar, transformed-bytecode.jar, +quarkus-application.dat and build-system.properties. Read out of the published +image's layer, not inferred. The init container writes it, the serving +container reads it, and because the directory is also the only place a +re-augmentation would write, mounting it writable is what lets the rest of the +root filesystem stay read-only. + +/opt/keycloak/data is needed because Quarkus places its temporary directory +under the Keycloak home rather than honouring java.io.tmpdir (upstream issue +#37533). It is EMPTY in the image, so an emptyDir over it hides nothing. +/opt/keycloak/lib/quarkus is NOT empty in the image - which is exactly why it +is seeded by the init container rather than merely mounted; an unseeded +emptyDir there hides quarkus-application.dat, and QuarkusEntryPoint loads that +before any Keycloak code runs. +*/}} +{{- define "keycloak.quarkusBuildDir" -}}/opt/keycloak/lib/quarkus{{- end -}} +{{- define "keycloak.dataDir" -}}/opt/keycloak/data{{- end -}} diff --git a/keycloak/templates/keycloak.deployment.yaml b/keycloak/templates/keycloak.deployment.yaml new file mode 100644 index 0000000..6bdb618 --- /dev/null +++ b/keycloak/templates/keycloak.deployment.yaml @@ -0,0 +1,373 @@ +{{- $settings := fromYaml (include "keycloak.settings" .) }} +{{- $adminHostname := eq (include "keycloak.adminHostnameEnabled" .) "true" }} +{{- /* +WHY THIS WORKLOAD HAS TWO INIT CONTAINERS, read before changing either of them +(Gitea #83). Three measured facts force the shape: + + 1. db, health-enabled and metrics-enabled are BUILD options (verified in the + 26.8.0 option table, where each carries the tool icon). The published + image is augmented with their DEFAULTS - db=dev-file, health-enabled=false. + 2. Supplying different build options at runtime makes Keycloak re-augment + itself at start, rewriting four files in /opt/keycloak/lib/quarkus. kc.sh + says so itself: "only exit code 10 means that implicit reaugmentation + occurred and a relaunch is needed". Upstream's container guide calls that + start what it is - it "creates an image that is mutable", which is exactly + what readOnlyRootFilesystem: true forbids. And not setting db is no way + out: THE PROBES ALONE force it, because health-enabled is a build option. + 3. So the build has to happen before the serving container starts, into a + volume. But an EMPTY emptyDir over /opt/keycloak/lib/quarkus does not + work either, and this is the part that is not guessable: + QuarkusEntryPoint - the main class kc.sh runs for EVERY command, `build` + included - loads "quarkus/quarkus-application.dat" (the literal string is + in QuarkusEntryPoint.class) before it dispatches anything. With that file + hidden by a fresh mount, `kc.sh build` cannot start at all. + +Hence: SEED copies the image's own augmentation output into the volume (reading +the real directory, writing only into the volume - so a read-only root +filesystem is fine), BUILD then mounts the now-seeded volume at the real path +and re-augments INTO it (again writing only into the volume), and the serving +container mounts the finished volume and runs `start --optimized`. + +What that buys, and the reason it is worth two containers rather than one with +readOnlyRootFilesystem: false: every container in this pod keeps the full +hardening baseline. There is no documented deviation to carry, and no Trivy +suppression that would also silence the serving container. +*/ -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment +spec: + replicas: {{ include "keycloak.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.keycloak.replicas) }} + selector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + template: + metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + annotations: + # NO checksum/config HERE, AND THAT IS NOT AN OVERSIGHT. This chart + # renders no ConfigMap at all - Keycloak is configured entirely through + # options and KC_* variables - so there is no file whose change could + # fail to restart the workload. The rule (#82) has nothing to attach + # to. values.yaml states this at the top, because an absent annotation + # looks identical to a forgotten one. + # + # Hash of the rendered OnePasswordItems. This covers the SOURCE of the + # secrets, not their content: repointing secrets.databaseSecretRef or + # secrets.bootstrapAdminSecretRef at a different vault item rolls the + # pods instead of leaving the old credentials in the running processes. + # A change INSIDE an item cannot be covered - Helm never sees it (see + # keycloak.secret.yaml) - and NOTES.txt names the manual restart. + checksum/secret-ref: {{ include (print $.Template.BasePath "/keycloak.secret.yaml") . | sha256sum }} + spec: + automountServiceAccountToken: false + securityContext: + {{- include "keycloak.podSecurityContext" .Values.keycloak.securityContext | nindent 8 }} + initContainers: + # --- 1. SEED ------------------------------------------------------- + # Copy the image's own augmentation output into the shared volume, so + # that the BUILD container below can bootstrap at all (fact 3 in the + # comment at the top of this file). + # + # It reads /opt/keycloak/lib/quarkus from the image and writes only + # into the volume, which is why readOnlyRootFilesystem: true holds + # here without any special handling. + # + # `command` is overridden because the image's entrypoint is kc.sh and + # this step runs no Keycloak command - the only other chart in this + # repo that overrides an entrypoint is convertx, and the same + # obligation applies: this line has to be re-read whenever the app + # version moves, because it depends on the image shipping a shell and + # coreutils. Both are present (usr/bin/sh -> bash, usr/bin/cp, verified + # in the image layer at 26.8.0). + # + # `cp -R` and not `cp -a`: -a preserves ownership, which a non-root + # process cannot do - it would fail with a diagnostic and a non-zero + # exit. Only the content and the directory structure matter here. + - name: {{ .Release.Name }}-{{ .Chart.Name }}-seed + image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + command: + - /bin/sh + - -c + args: + - | + set -eu + cp -R /opt/keycloak/lib/quarkus/. /quarkus-build/ + ls -l /quarkus-build/ + resources: + {{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }} + securityContext: + {{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }} + volumeMounts: + - name: quarkus-build + mountPath: /quarkus-build + # --- 2. BUILD ------------------------------------------------------ + # The Quarkus augmentation, i.e. `kc.sh build`. It writes into the + # seeded volume, which is mounted at the real path here - so the root + # filesystem stays read-only and the output survives into the serving + # container. + # + # THE BUILD OPTIONS ARRIVE AS ENVIRONMENT VARIABLES, NOT AS CLI + # ARGUMENTS, and that is a safety decision rather than a style one: + # `command` is a fixed shell string here, and interpolating chart + # values into a shell string is how a feature name with a space in it + # becomes two arguments. As variables they are passed by the kubelet + # and never parsed by a shell. `kc.sh build` reads KC_* exactly as it + # reads flags. + # + # This is the ONE place build options exist in this chart. The serving + # container passes `start --optimized` and no build option at all, so + # there is no second list to drift out of step - and keycloak.env + # refuses a build option's variable name outright (see _helpers.tpl). + - name: {{ .Release.Name }}-{{ .Chart.Name }}-build + image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + args: + - build + env: + {{- include "keycloak.buildEnv" . | nindent 12 }} + resources: + {{- include "keycloak.buildResources" .Values.keycloak.build | nindent 12 }} + securityContext: + {{- include "keycloak.containerSecurityContext" .Values.keycloak.securityContext | nindent 12 }} + volumeMounts: + - name: quarkus-build + mountPath: {{ include "keycloak.quarkusBuildDir" . }} + # Quarkus puts its temporary directory under the Keycloak home + # rather than honouring java.io.tmpdir (upstream issue #37533), so + # the augmentation needs both of these writable. + - name: data + mountPath: {{ include "keycloak.dataDir" . }} + - name: tmp + mountPath: /tmp + containers: + - name: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + # Fully qualified: an unqualified reference would resolve to Docker + # Hub by convention rather than by rule. quay.io is Keycloak's own + # registry and the first quay.io image in this repository - which is + # why ci/trivy/data/ksv0125.yaml had to learn about it. + image: "quay.io/keycloak/keycloak:{{ .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + # `start`, not `start-dev` - the development mode has insecure + # defaults and a local cache. The image declares no CMD, only + # ENTRYPOINT ["/opt/keycloak/bin/kc.sh"], so the subcommand has to + # come from here. + # + # --optimized says "this installation is already built, do not check + # and do not augment". It is what makes the read-only root filesystem + # possible, and it is also why NO build option may be passed to this + # container: with --optimized a build option is an error, and without + # it the container would rebuild on a path it cannot write. + args: + - start + - --optimized + env: + # THE HOSTNAME, DERIVED AND NOT LEFT OPEN - this is the + # security-relevant pair of this chart. Upstream on proxy-headers: + # "Misconfiguration might leave the server exposed to security + # vulnerabilities." The failure mode is Host header injection: with + # a hostname resolved from request headers, an attacker controls the + # URLs the server generates, which is how tokens and password + # resets end up pointed at somebody else's host. + # + # Two things keep that shut. hostname-strict stays at its default + # true, so nothing is taken from the request. And hostname is a + # FULL URL rather than a bare host, which upstream requires as soon + # as hostname-admin is in play: "If hostname-admin is configured, + # hostname must be set to a URL (not just hostname). Otherwise + # Keycloak would not know what is the correct frontend URL". + - name: KC_HOSTNAME + value: {{ include "keycloak.frontendUrl" . | quote }} + {{- if $adminHostname }} + # THE ADMIN HOSTNAME - and it is NOT an access boundary. It decides + # which URLs the server generates for the administration console + # (redirects, assets, the API address the console itself calls). + # Upstream, verbatim: "Using the hostname-admin option does not + # prevent accessing the Administration REST API endpoints via the + # frontend URL specified by the hostname option. If you want to + # restrict access to the Administration REST API, you need to do it + # on the reverse proxy level." + # + # So /admin/realms/... stays reachable through ingress.domain. This + # chart renders no deny rule for it: a path rule on /admin that has + # not been tested against a running console breaks redirects and + # asset loading, and a measure that only looks like one is worse + # than none. The full argument is at ingress.admin in values.yaml. + - name: KC_HOSTNAME_ADMIN + value: {{ include "keycloak.adminUrl" . | quote }} + {{- end }} + # Behind the Ingress the TLS ends at the controller, so the pod + # itself serves plain HTTP - which Keycloak only does when told to. + - name: KC_HTTP_ENABLED + value: "true" + # xforwarded, because the ingress-nginx controller sets + # X-Forwarded-* and not RFC 7239 Forwarded. This is not optional + # decoration: without it Keycloak sees the controller's address and + # scheme instead of the client's. + - name: KC_PROXY_HEADERS + value: "xforwarded" + - name: KC_HTTP_PORT + value: {{ $settings.ports.http | int64 | quote }} + - name: KC_HTTP_MANAGEMENT_PORT + value: {{ $settings.ports.management | int64 | quote }} + # CLUSTERING. ispn is already the default for `start` in production + # mode; it is set explicitly because it is the decision that makes + # the second replica a cluster member rather than a second island, + # and an explicit value cannot be lost to a change of upstream + # defaults. + # + # cache-stack is deliberately NOT set: the default is jdbc-ping, + # which discovers peers through the database that is there anyway. + # The alternative, cache-stack=kubernetes with DNS_PING, is marked + # "(deprecated)" upstream and would need a headless Service and a + # jgroups.dns.query - none of which this chart renders. + - name: KC_CACHE + value: "ispn" + {{- with $settings.cache.clusterName }} + # Only when set. The trap it addresses: "clusters that share the + # same database but should remain separate will merge unless they + # have distinct cluster names". See values.yaml - the default is + # upstream's ISPN on purpose. + - name: KC_CACHE_EMBEDDED_CLUSTER_NAME + value: {{ . | quote }} + {{- end }} + # A stable node name, from the pod name. Upstream's default is "a + # random name ... generated on each start, which makes it difficult + # to correlate metrics and logs across restarts" - and with two + # replicas that correlation is the first thing anybody needs when + # the cluster misbehaves. + - name: KC_CACHE_EMBEDDED_NODE_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + # THE DATABASE. Note what is NOT here: KC_DB. The vendor is a build + # option and is baked in by the init container above; setting it + # here would make this container re-augment on a read-only path. + - name: KC_DB_URL_HOST + value: {{ required "A database host must be provided (keycloak.db.host)" $settings.db.host | quote }} + - name: KC_DB_URL_PORT + value: {{ $settings.db.port | int64 | quote }} + - name: KC_DB_URL_DATABASE + value: {{ required "A database name must be provided (keycloak.db.database)" $settings.db.database | quote }} + - name: KC_DB_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Release.Name }}-{{ .Chart.Name }}-db-secret + key: username + # AS A VARIABLE, NOT A FILE, and that is forced: Keycloak 26 has no + # _FILE indirection for an option's value - no KC_DB_PASSWORD_FILE + # and no generic KC_