Files
helm-charts/keycloak/values.schema.json
T
jklein 4c1cff1744 feat(keycloak): new chart for Keycloak (1.0.0+up26.8.0)
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 rather than StatefulSet - Keycloak 26
keeps everything in the database, user sessions included.

The chart's defining property is that it has TWO INIT CONTAINERS and needs
both. db, health-enabled and metrics-enabled are Keycloak BUILD options
(verified in the 26.8.0 option table), the published image is augmented with
their defaults, and supplying different ones at runtime 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". Not setting db is no way out: the probes alone force it, because
health-enabled is a build option too. An empty emptyDir over that path fails as
well, 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, with no documented deviation to carry.

Build options therefore exist in exactly one place, and the asymmetry is
enforced rather than trusted: the serving container passes no build option at
all, and a build option's variable name in keycloak.env fails the render with a
message naming it.

Hostname handling is the security-relevant part. KC_HOSTNAME is derived from
ingress.domain as a full URL (which upstream requires alongside hostname-admin),
hostname-strict stays at its default true, and proxy-headers is set rather than
optional. The admin hostname is NOT presented as an access control: upstream
states that hostname-admin "does not prevent accessing the Administration REST
API endpoints via the frontend URL specified by the hostname option", so the
chart renders no /admin deny rule and values.yaml, NOTES.txt and the wiki all
say in the same words that restricting that API is reverse-proxy work.

The management port 9000 is a containerPort and a ClusterIP Service for
in-cluster scraping, and is in no Ingress: it serves /health/* and optionally
/metrics without authentication. Liveness uses /health/live and deliberately
not /health or /health/ready - those include the database, and a liveness probe
on them would restart the whole cluster for as long as the database is away.

Also in this change:
- First PodDisruptionBudget in this repository, which makes it a convention
  (README section 11): minAvailable as a value, and rendered only while the
  computed replica count is >= 2, because minAvailable: 1 with one replica
  blocks every node drain forever with a message that names the PDB rather
  than the replica count.
- First quay.io image, so quay.io joins the trusted registries in
  ci/trivy/data/ksv0125.yaml - configured rather than suppressed, so
  AVD-KSV-0125 keeps being able to say something.
- Renovate block with NO version constraint, which is the counter-case to
  garage and stalwart and is measured rather than assumed: docker versioning
  already excludes the -N rebuild tags, the floating minors and latest/nightly.
  Three dry-run fixtures are recorded at the block, and against the committed
  config 26.7.4 offers exactly 26.7.5 (patch) and 26.8.0 (minor).
- No automerge exception, by decision (Gitea #83): the global automerge applies,
  and the minor-upgrade procedure plus `kc.sh update-compatibility` is
  documented in NOTES.txt instead.
- Two upstream defaults deviated from, each with its reason at the value:
  db-pool-max-size 20 instead of 100 (two replicas at 100 ask a default
  PostgreSQL for twice its max_connections), and uid 1000 / gid 0 instead of
  this repo's 20000/1000, because the image declares keycloak:x:1000:0 and
  chowns its install tree to that pair.

Part of #83
2026-10-04 18:00:20 +00:00

271 lines
16 KiB
JSON

{
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "keycloak values",
"description": "Strict values schema (issue #68). additionalProperties is false on every chart-owned object level, so a key this chart does not read is rejected at render time instead of being silently ignored; the objects that go verbatim into the Kubernetes pod spec (probes, security contexts, global, free-form annotation maps) stay open, because the chart does not interpret their contents. The blocks the chart dereferences additionally accept null, because an erased block means 'chart defaults apply' (issue #99) rather than a vanished setting. Note what this schema CANNOT enforce: secrets.databaseSecretRef, keycloak.db.host, ingress.domain and ingress.admin.domain all ship as null and JSON Schema's 'required' tests presence, not null-ness, so a 'required' array would never fire - the enforcement is required() in the templates (issue #18).",
"type": "object",
"additionalProperties": false,
"properties": {
"global": {
"description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.",
"type": "object"
},
"scaleDown": {
"description": "true renders the Deployment with replicas: 0 and takes precedence over keycloak.replicas. Also the first half of the minor-upgrade procedure, because Keycloak supports a rolling update only inside a minor line.",
"type": "boolean"
},
"secrets": {
"type": "object",
"additionalProperties": false,
"properties": {
"databaseSecretRef": {
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the PostgreSQL role, which has to be able to create its own schema. Always required - the chart has no default database.",
"type": ["string", "null"]
},
"bootstrapAdminSecretRef": {
"description": "op:// item path. The item must carry the fields 'username' and 'password' of the TEMPORARY bootstrap administrator. Required while keycloak.bootstrapAdmin.enabled is true.",
"type": ["string", "null"]
}
}
},
"keycloak": {
"type": "object",
"additionalProperties": false,
"properties": {
"replicas": {
"description": "Default 2, because an SSO outage takes every dependent login with it. An explicit 0 is allowed and is the per-workload way to stop the release. Above 1 the rolling-update restriction applies: a minor version jump needs the whole cluster down.",
"type": "integer",
"minimum": 0
},
"db": {
"description": "May be null / empty: the chart then falls back to its defaults. The host has no default and is enforced by required() in the Deployment.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"vendor": {
"description": "A Keycloak BUILD option - it is passed to `kc.sh build` in the init container, never to the serving container. dev-file and dev-mem are rejected on purpose: they are Keycloak's development defaults, and an identity provider on an embedded database works until the pod moves. Only 'postgres' is exercised by this chart's CI.",
"type": "string",
"enum": ["postgres", "mariadb", "mysql", "mssql", "oracle"]
},
"host": {
"description": "Host of the external PostgreSQL. Required - enforced by required() in the Deployment, because a null here would render an empty KC_DB_URL_HOST and a pod that waits on a connection it can never make.",
"type": ["string", "null"]
},
"port": { "$ref": "#/definitions/port" },
"database": {
"type": ["string", "null"],
"minLength": 1
},
"pool": {
"description": "Connection pool sizes, PER REPLICA. Upstream's db-pool-max-size default is 100; this chart ships 20, because two replicas at 100 ask a default PostgreSQL (max_connections = 100) for twice what it has.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"initialSize": { "type": ["integer", "null"], "minimum": 0 },
"minSize": { "type": ["integer", "null"], "minimum": 0 },
"maxSize": { "type": ["integer", "null"], "minimum": 1 }
}
}
}
},
"bootstrapAdmin": {
"description": "The temporary bootstrap administrator. Switch this off once a permanent administrator exists - it also stops the per-start log line about the initial administrator not being created.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" }
}
},
"metrics": {
"description": "A Keycloak BUILD option (metrics-enabled). Off, because the management port is unauthenticated in this chart's configuration and metrics add realm and client names to that surface.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" }
}
},
"features": {
"description": "Additional Keycloak feature flags (--features), a BUILD option. A list rather than a comma-separated string, so two entries cannot arrive as one unknown feature name - which Keycloak rejects at build time, inside the init container. The pattern keeps a value out that could not be a feature name.",
"type": ["array", "null"],
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]*$"
}
},
"cache": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"clusterName": {
"description": "--cache-embedded-cluster-name. null means Keycloak's default, 'ISPN'. Read the comment in values.yaml before setting it: two releases sharing one database MERGE into one cluster unless the names differ, but upstream also warns that cache invalidation does not work between differently-named clusters.",
"type": ["string", "null"],
"minLength": 1
}
}
},
"ports": {
"description": "May be null / empty: the chart then falls back to its defaults. The Deployment renders these as containerPorts and the Services address them by name, so both follow one source.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"http": { "$ref": "#/definitions/port" },
"management": {
"description": "The management port. It carries the unauthenticated /health/* endpoints and optionally /metrics, and all three probes address it by name. It is deliberately in no Ingress.",
"$ref": "#/definitions/port"
},
"jgroups": {
"description": "JGroups transport port. Declared as a containerPort for the record; jdbc-ping opens direct pod-to-pod connections on it.",
"$ref": "#/definitions/port"
}
}
},
"resources": {
"description": "Resources of the SERVING container. The memory request is not only about scheduling here: the image sizes the JVM heap relative to the container limit (-XX:MaxRAMPercentage=70), so request x limitFactor decides how much memory the application believes it has.",
"$ref": "#/definitions/resources"
},
"build": {
"description": "The build init container, which runs the one-shot Quarkus augmentation. Its resources are transient - it is gone before the serving container starts - but they count for scheduling, because a pod's effective request is the maximum of its init containers' and the sum of its containers'.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"resources": { "$ref": "#/definitions/resources" }
}
},
"livenessProbe": { "$ref": "#/definitions/probe" },
"readinessProbe": { "$ref": "#/definitions/probe" },
"startupProbe": { "$ref": "#/definitions/probe" },
"securityContext": { "$ref": "#/definitions/securityContext" },
"podDisruptionBudget": {
"description": "The first PodDisruptionBudget in this repository. It is rendered only while the effective replica count is >= 2: minAvailable 1 with a single replica forbids every voluntary eviction, so a node drain would block forever on a pod nothing may move.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"minAvailable": { "type": "integer", "minimum": 1 }
}
},
"env": { "$ref": "#/definitions/env" }
}
},
"ingress": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"description": "Gates the public Ingress object only (issue #93). Checked in both directions for this chart: ingress.domain is ALSO read as KC_HOSTNAME on the Deployment, so switching the Ingress off frees clusterIssuer but NOT domain - Keycloak refuses to start in production mode without a hostname.",
"type": "boolean"
},
"clusterIssuer": { "type": ["string", "null"] },
"domain": {
"description": "The public hostname, a plain host - the chart renders it as the full URL https://<domain> for KC_HOSTNAME, which upstream requires as soon as hostname-admin is in play. Required even with enabled: false.",
"type": ["string", "null"]
},
"className": { "type": ["string", "null"] },
"annotations": {
"description": "Extra annotations for the public Ingress, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
"type": "object"
},
"admin": {
"description": "The administration console on a hostname of its own. NOT AN ACCESS CONTROL: hostname-admin decides which URLs the server generates; upstream states that it does not prevent reaching the Administration REST API through the public hostname, and that restricting that API is reverse-proxy work. The chart renders no deny rule for /admin.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"domain": {
"description": "The admin hostname, a plain host - rendered as https://<domain> for KC_HOSTNAME_ADMIN. Required while enabled.",
"type": ["string", "null"]
},
"className": {
"description": "null falls back to ingress.className. An internal ingress class here keeps the console off the public controller, which is the useful half of the admin hostname.",
"type": ["string", "null"]
},
"annotations": {
"description": "Extra annotations for the admin Ingress, merged with the cert-manager annotation. Free-form keys by nature, so this object stays open.",
"type": "object"
}
}
}
}
}
},
"definitions": {
"port": {
"type": ["integer", "null"],
"minimum": 1,
"maximum": 65535
},
"resources": {
"description": "May be null / empty: the chart then falls back to its default requests and computed limits (issue #99) instead of rendering a container without resource accounting.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"limitFactor": {
"type": "number",
"exclusiveMinimum": 0
},
"requests": { "$ref": "#/definitions/resourceList" },
"limits": { "$ref": "#/definitions/resourceList" }
}
},
"resourceList": {
"description": "Both spellings of the ephemeral-storage key are accepted because the resources helper normalizes camelCase to the Kubernetes key.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"cpu": { "type": ["string", "number", "null"] },
"memory": { "type": ["string", "number", "null"] },
"ephemeral-storage": { "type": ["string", "number", "null"] },
"ephemeralStorage": { "type": ["string", "number", "null"] }
}
},
"env": {
"description": "Additive container env entries. An entry repeating a name the chart already sets does not replace it - on an existing release the pair collapses to the chart's own value while helm upgrade reports success. A BUILD option's variable name (KC_DB, KC_HEALTH_ENABLED, KC_METRICS_ENABLED, KC_FEATURES, ...) is refused by the template with a fail: on the serving container it would disagree with what the init container built, and Keycloak answers that by re-augmenting onto a read-only path.",
"type": ["array", "null"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"valueFrom": {
"type": "object",
"additionalProperties": false,
"properties": {
"secretKeyRef": { "$ref": "#/definitions/keyRef" },
"configMapKeyRef": { "$ref": "#/definitions/keyRef" }
}
}
}
}
},
"keyRef": {
"type": "object",
"additionalProperties": false,
"required": ["name", "key"],
"properties": {
"name": { "type": "string" },
"key": { "type": "string" }
}
},
"securityContext": {
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (issue #99). This chart's fallback reproduces ITS defaults, which deviate from the repo's usual 20000/1000 pair - the published image declares keycloak:x:1000:0 in /etc/passwd, puts that user in the ROOT GROUP and copies the whole install tree with --chown=1000:0, so uid 1000 / gid 0 is what the image expects and what makes the mounted build volume writable. Every container in the pod, both init containers included, uses the same container context: readOnlyRootFilesystem: true holds throughout, which is the reason the chart has two init containers rather than one writable one.",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"pod": {
"description": "Merged onto the chart's pod hardening defaults and rendered as the pod's securityContext; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
},
"container": {
"description": "Merged onto the chart's container hardening defaults and rendered as the securityContext of every container in the pod; kept open because the chart does not interpret the individual fields.",
"type": ["object", "null"]
}
}
},
"probe": {
"description": "Merged onto the chart's probe defaults and rendered as a container probe; kept open because the chart does not interpret the individual fields. May be null / empty, which means the defaults apply (issue #99) rather than the probe disappearing. The defaults carry the probe's CONTEXT: the port NAME 'management' for all three, and three DIFFERENT paths - /health/live for liveness (no database check), /health/ready for readiness (with one), /health/started for startup. Pointing liveness at /health or /health/ready would restart every pod of the cluster for as long as the database is away.",
"type": ["object", "null"]
}
}
}