Compare commits

...

7 Commits

Author SHA1 Message Date
Stéphane Tailland 80a2a27ca9 Stop versioning the real secret.yaml
k8s/secret.yaml held the actual PostgreSQL password, Keycloak admin
password and OIDC client secrets, and .gitignore only covered .DS_Store
and *.tmp. Untrack it and ignore it, matching what infra-librechat already
does, and extend the ignore list to TLS key material.

kustomization.yaml still references the file, so the README now documents
creating it from secret.example.yaml — whose key set is identical — before
the first apply.

This only stops future leaks. The values remain in commit 6d3431c and on
the remote, so they must still be treated as compromised and rotated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:30:20 +02:00
Stéphane Tailland 01d2082f3b Send traces to Tempo and expose metrics to Prometheus
Point the OTel Java agent at Tempo instead of Jaeger, so Keycloak's traces
land in the same backend as the rest of the stack and correlate with the
Loki logs of the same requests.

Annotate the pod for scraping. KC_METRICS_ENABLED already served /metrics
on the management port, so this only makes Prometheus discover it — on
port 9000, never on the application port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:27:14 +02:00
Stéphane Tailland aac85b2f7a Update of classic models realm config 2026-08-23 17:22:57 +02:00
Stéphane Tailland cf456f10a1 fix(realm): Persist client secret and realm CaC 2026-08-23 15:54:16 +02:00
Stéphane Tailland d5242f68ca fix(secrets): Update kong-token-exchange-broker secret to match Vault/ExternalSecret 2026-08-23 08:05:23 +02:00
Stéphane Tailland b9287761bf feat(telemetry): Enable OpenTelemetry distributed tracing and JSON logging with MDC 2026-08-23 07:45:23 +02:00
Stéphane Tailland 08200a7df0 feat(k8s): Add Keycloak 26.6.0 with PostgreSQL 16 and classic-models realm CaC 2026-08-23 07:40:40 +02:00
13 changed files with 434 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.DS_Store
*.tmp
# Real secrets: only secret.example.yaml is versioned.
# k8s/kustomization.yaml still references secret.yaml, so a fresh clone must
# create it first — see README.
k8s/secret.yaml
# TLS material
*.crt
*.key
*.pem
+59
View File
@@ -0,0 +1,59 @@
# Infra Keycloak (Kubernetes / CaC)
Déploiement de **Keycloak 26.6.0** et **PostgreSQL 16** en Configuration-as-Code (CaC) sous Kubernetes dans le namespace `keycloak`.
## Structure du Répertoire
```text
k8s/
├── kustomization.yaml # Manifeste Kustomize principal
├── namespace.yaml # Namespace keycloak
├── secret.yaml # Secrets (Admin, DB, OIDC secrets)
├── secret.example.yaml # Gabarit des secrets
├── postgresql/
│ ├── pvc.yaml # Stockage persistant PostgreSQL (2Gi)
│ ├── statefulset.yaml # Base de données PostgreSQL 16
│ └── service.yaml # Service PostgreSQL (port 5432)
├── keycloak/
│ ├── configmap-realm.yaml # Realm classic-models (Configuration-as-Code)
│ ├── deployment.yaml # Déploiement Keycloak 26.6.0 (--import-realm)
│ └── service.yaml # Service Keycloak (ports 8080, 9000)
└── ingress/
├── tls.yaml # Secret TLS wildcard (*.sttlab.local)
└── ingress.yaml # Ingress NGINX (keycloak.sttlab.local)
```
## Fonctionnalités Clés
- **Version Keycloak** : `quay.io/keycloak/keycloak:26.6.0` (Runtime Quarkus).
- **Import Automatique du Realm (CaC)** : Le realm `classic-models` est chargé au démarrage via l'option `--import-realm` depuis le `ConfigMap` monté dans `/opt/keycloak/data/import/`.
- **Token Exchange RFC 8693** : Activé via `KC_FEATURES: "token-exchange,admin-fine-grained-authz"`.
- **Clients Configurés** :
- `librechat` : Client OIDC pour le login et le flux OAuth MCP.
- `mcp-catalog` : Audience du serveur MCP.
- `kong-token-exchange-broker` : Échangeur de jetons RFC 8693 de Kong Gateway.
- `catalog-api` & `classic-models-api` : Audiences des services backend.
- **Utilisateurs & Rôles** :
- `admin-user` (`admin`)
- `pm-user` (`product_manager`)
- `ro-user` (`read_only`)
- `customer-user` (`customer`)
- `support-user` (`support`)
## Déploiement
`k8s/secret.yaml` n'est pas versionné : il porte les mots de passe réels (base
PostgreSQL, admin Keycloak, secrets clients OIDC). Le créer une fois depuis le
gabarit, puis renseigner chaque `<CHANGE_ME>` :
```bash
cp k8s/secret.example.yaml k8s/secret.yaml
$EDITOR k8s/secret.yaml
```
`k8s/kustomization.yaml` le référence, donc le déploiement échoue tant qu'il
est absent.
```bash
kubectl apply -k k8s/
```
+30
View File
@@ -0,0 +1,30 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak
namespace: keycloak
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
nginx.ingress.kubernetes.io/proxy-busy-buffers-size: "256k"
nginx.ingress.kubernetes.io/proxy-body-size: "20m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
spec:
ingressClassName: nginx
tls:
- hosts:
- keycloak.sttlab.local
secretName: keycloak-tls
rules:
- host: keycloak.sttlab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 8080
+9
View File
@@ -0,0 +1,9 @@
apiVersion: v1
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURQRENDQWlTZ0F3SUJBZ0lVZC91dERmK2YweVFSM3YzcVRqWnF4K3d2VFpNd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0dURVhNQlVHQTFVRUF3d09LaTV6ZEhSc1lXSXViRzlqWVd3d0hoY05NalV4TVRJME1UVXpPRFExV2hjTgpNall4TVRJME1UVXpPRFExV2pBWk1SY3dGUVlEVlFRRERBNHFMbk4wZEd4aFlpNXNiMk5oYkRDQ0FTSXdEUVlKCktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUxqMHNyeXE4cVh0a1NoVC9CKy9FalZKTk4vVExISW4KUUpZYzZ4dVVIL2Y3cTNkWWRMbTRpSDBYbkZnclNRTFVvNGlxZC9NY3plZjVpV0NZd1Q1RkZhN1AxNy9UY0xWZgpnc28wajQxcU5MQmpHeERRR1E2WjFWQ3hpNm9hbjhvdzk4cnVtalZqMEUwUWdzNUNYL3JOK2djRW1aL1RaRldVCnF1ZFZvenRwZWlWcUowZHhDSXErS011dHpkMUJVN0JYeDRWUk85WSt0MWlkVWM3b1k3QlBtUzJxUEFON1R0WFAKajFLaEgyQmdMc21GNUdSaFlITlM1NzhlQ2x0Y21TMnNiUTNjN0d3T1Y5NmxwZ0Y5VGl4RXZGWGVONHhrNDNFNgoxQ09VRDdDUmlXQVowWEk4Rk1scFpHZU5aZVY0YmJWL01xVmlnMXliV05PejlNREtZajNlb1hVQ0F3RUFBYU44Ck1Ib3dIUVlEVlIwT0JCWUVGRGJ6WVdPUHI0dnhhK1ZPTnM3eEhiY054NzBZTUI4R0ExVWRJd1FZTUJhQUZEYnoKWVdPUHI0dnhhK1ZPTnM3eEhiY054NzBZTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3SndZRFZSMFJCQ0F3SG9JTwpLaTV6ZEhSc1lXSXViRzlqWVd5Q0RITjBkR3hoWWk1c2IyTmhiREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBCnRVNHA1L09OWG03eDRZRHpTZUw4TmRUaFZMZ3VpRnVyMGpjVHh2dlBVUzRORk5zblFKazRPWHhCZ2p5MVl3cEwKM2k1Ni80bUNFZDlPQ1FGL0hsMGVGd0daQnpOS2xNb1RBMDhBQy9sQkR2UHJuVmp1VXUwcWUvYi8yS09raEVTYQp4OHJRTnAwbzhwY2owZjI5bnUwSmZScVlHa3p5MExoMnFnWGhQdTJpQyt0MlBmZGxlUkFreTF0dDY4d29nZEZMClR2SERzSmJWby9paHNQcE9ObFN5SGZ4RzZiYi8wTWt6NjRQSDQ2VFR0OUd2SmZsRUtNcUR3aXZORlVBMVA1Uy8KVTRJYzhRYXBueGM3aDIrV2JSam1Zcm51eWdDMndNQkU2ckw5T0YzZ3dDcjFma2dHR1JyNWxWUlVxMlgySFpWNQo1anFWaXY2ZXVycThPNjFOM3ZXR0NRPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQzQ5TEs4cXZLbDdaRW8KVS93ZnZ4STFTVFRmMHl4eUowQ1dIT3NibEIvMys2dDNXSFM1dUloOUY1eFlLMGtDMUtPSXFuZnpITTNuK1lsZwptTUUrUlJXdXo5ZS8wM0MxWDRMS05JK05halN3WXhzUTBCa09tZFZRc1l1cUdwL0tNUGZLN3BvMVk5Qk5FSUxPClFsLzZ6Zm9IQkptZjAyUlZsS3JuVmFNN2FYb2xhaWRIY1FpS3ZpakxyYzNkUVZPd1Y4ZUZVVHZXUHJkWW5WSE8KNkdPd1Q1a3RxandEZTA3Vno0OVNvUjlnWUM3SmhlUmtZV0J6VXVlL0hncGJYSmt0ckcwTjNPeHNEbGZlcGFZQgpmVTRzUkx4VjNqZU1aT054T3RRamxBK3drWWxnR2RGeVBCVEphV1JualdYbGVHMjFmektsWW9OY20xalRzL1RBCnltSTkzcUYxQWdNQkFBRUNnZ0VBSjBEZnNrVUJySStIOEN3cTVUM3pDbFlZSDZ5ODBSbGRqcjVpYzM3emJmaisKd2h5RzRXZTFwLzdiMHFhVkgwR3IxM1IrY3AxWllmTkc0YlpweE9KNm9KaEI3TmRlSTFBNi9Oazg0a0Y1SHpwWgpZeThYTWs4Wnc3OTZxY1p1eFVXeFFXTjBYUUpyRWdBQUV2QnBXRnhneFI2SDBERFlLWVdjNCszNzc3OEtDbWhwCmVpUWdoMWhpaEZ5OWF6QnVJN0VsTm02QzQ4VktrU1dwQUMyZHNNN2FvVkJjd1RHcjVjWjh6SVBWT2dTWitUVXMKelBINkV6b0VqRUQzajNFemRESENKMCtVdVNBTDh0L3F4ZGNEZjREdkdFeWR4S3J4OWpxSXJnalNab1UvOC94WApMUy9rRnk0cE9URnFsSDc2UUU4TytrbDRDTHZDQTdKTzNCN1p3dzM2d1FLQmdRRHJVaHdmNkQ5b3lsT3hPL3U1Cm5ndjl2YW04SGs0QlBRRjRHYi8zTU9DdzRGWnhIclFQRWg4Q1BLVXBndnhDRUM3UVNHOTJybVJjYUZXcDlqaCsKTWQyOFc5NHBlWW9wRnlkc0ZFa3F6bjFTb05qNkFFM3JhbTgwZ1JWempMZUFQNUxiRENXUVZtTmpnTTRySjJVZwpzVVhSZk5Kd3RIcEZwUkE0dmM4MUcvQk84UUtCZ1FESk5ZNGpGTmxoYUF5UytraHVJcm9PY0l2KzBoZElZcStpCktjeTJYbnBHSEd5b0s4S0pIckp2c1Via3NnTVlKVTJlT0g3cWlkNEo4Y1BDbmUwSjIvL0FvZytleEkvNnJOZzMKYll4UDNHWlp1aHNNclFEMmZ1WkxhM2pFZGJtaUxuM1A0cFNiZVJ4cDd5VitadlBlZ1dsZ0dsMHgwRE9Uc3NpNApDKzUxMnBRQ3hRS0JnRWhzYXU5L0FSVDFRRERKN3owL1lSMWs0L3Bic1ZJOFBsbUNRMHlHcElDTGFzOUJ6ZFRkCmxPR1JYSnVjRHBzZWMrbnlnU3VibkpPOElMRkFjRDZXaGxIVVRGbWphRndoSGliclQyRjNUNmJBamtiVEZTZ2gKQjd5M0JhMGNYOVYrdTJuaklTUjlFZldyek1nSFJkNTc4S3I2cWFHNlNlOXBUOGJHMUNEYVNtbGhBb0dBVlNqLwpvY1YvUkFUZjNZMWsvb2hnS3lCamdGUzNXZ0c2eUczY29RdFh3VGppbkVMNU1VUDJ6ZU5BV0NjbnZCNTl2WXUzCjI5akNTMzh5RmJyMXlvaDh0TEZpM3hYZXdvQkpleHc0bU14MTNXQXhkcW42a0ZtaGF5WnZsT2NteklmVTVCbkkKUUEyQWd5MmV0dU1vTUhLaGdQVm5vRis0bExqRXozRHRXcUJLcU9rQ2dZQlN6SHo3N1RkR0pRUVVMajJRVUFDOQozSXlaaXJmL0k4YnhPSnZrQnA3dktWODcxdHJmQXBaMDQwWDhIb1ZwZUErdjVZM3N4WkNKbUhwclhMNXZ1T1NaCnJoaHVvbTZuNHhUMi9qa2YybnR5c0UveFZGQUVrS2hVNEZISXNPR091V21YSWlSSzFLa2hJQXFhNzVBeVhESU4KdEVycWpTaGtFNHArdkQ0T0QzSmNqZz09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K
kind: Secret
metadata:
name: keycloak-tls
namespace: keycloak
type: kubernetes.io/tls
File diff suppressed because one or more lines are too long
+169
View File
@@ -0,0 +1,169 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: keycloak
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/component: idp
app.kubernetes.io/part-of: keycloak
spec:
replicas: 1
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
app.kubernetes.io/name: keycloak
app.kubernetes.io/component: idp
app.kubernetes.io/part-of: keycloak
annotations:
# Discovered by the Prometheus in the observability namespace.
# Endpoint exposed by KC_METRICS_ENABLED=true, on the management
# port (9000), never on the application port.
prometheus.io/scrape: "true"
prometheus.io/port: "9000"
prometheus.io/path: "/metrics"
spec:
initContainers:
- name: download-otel-agent
image: curlimages/curl:8.10.0
command:
- "sh"
- "-c"
- "curl -sSL -o /otel/opentelemetry-javaagent.jar https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.31.0/opentelemetry-javaagent.jar"
volumeMounts:
- name: otel-agent
mountPath: /otel
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.6.0
args:
- "start"
- "--import-realm"
ports:
- name: http
containerPort: 8080
- name: https
containerPort: 8443
- name: management
containerPort: 9000
env:
- name: KC_DB
value: "postgres"
- name: KC_DB_URL_HOST
value: "keycloak-postgresql"
- name: KC_DB_URL_PORT
value: "5432"
- name: KC_DB_URL_DATABASE
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_DB
- name: KC_DB_USERNAME
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_DB_USERNAME
- name: KC_DB_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_DB_PASSWORD
- name: KC_BOOTSTRAP_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_BOOTSTRAP_ADMIN_USERNAME
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KC_BOOTSTRAP_ADMIN_PASSWORD
- name: KEYCLOAK_ADMIN
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KEYCLOAK_ADMIN
- name: KEYCLOAK_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: KEYCLOAK_ADMIN_PASSWORD
- name: KC_HTTP_ENABLED
value: "true"
- name: KC_PROXY_HEADERS
value: "xforwarded"
- name: KC_HOSTNAME
value: "https://keycloak.sttlab.local"
- name: KC_HOSTNAME_STRICT
value: "false"
- name: KC_HOSTNAME_BACKCHANNEL_DYNAMIC
value: "false"
- name: KC_FEATURES
value: "token-exchange,admin-fine-grained-authz,log-mdc"
- name: JAVA_OPTS_APPEND
value: "-javaagent:/otel/opentelemetry-javaagent.jar"
- name: OTEL_SERVICE_NAME
value: "keycloak"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://tempo.observability.svc.cluster.local:4318"
- name: OTEL_EXPORTER_OTLP_PROTOCOL
value: "http/protobuf"
- name: OTEL_TRACES_EXPORTER
value: "otlp"
- name: OTEL_METRICS_EXPORTER
value: "none"
- name: OTEL_LOGS_EXPORTER
value: "none"
- name: KC_HTTP_ACCESS_LOG_ENABLED
value: "true"
- name: KC_LOG_LEVEL
value: "INFO,org.keycloak.events:DEBUG"
- name: KC_LOG_CONSOLE_OUTPUT
value: "json"
- name: KC_LOG_MDC_ENABLED
value: "true"
- name: KC_LOG_SERVICE_NAME
value: "keycloak"
- name: KC_LOG_SERVICE_ENVIRONMENT
value: "local"
- name: KC_HEALTH_ENABLED
value: "true"
- name: KC_METRICS_ENABLED
value: "true"
readinessProbe:
httpGet:
path: /health/ready
port: 9000
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 5
livenessProbe:
httpGet:
path: /health/live
port: 9000
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 5
resources:
requests:
cpu: 500m
memory: 1024Mi
limits:
cpu: 2000m
memory: 2048Mi
volumeMounts:
- name: realm-import
mountPath: /opt/keycloak/data/import
readOnly: true
- name: otel-agent
mountPath: /otel
volumes:
- name: realm-import
configMap:
name: keycloak-realm-classic-models
- name: otel-agent
emptyDir: {}
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak
app.kubernetes.io/component: idp
app.kubernetes.io/part-of: keycloak
spec:
ports:
- name: http
port: 8080
targetPort: 8080
- name: management
port: 9000
targetPort: 9000
selector:
app: keycloak
+14
View File
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- secret.yaml
- postgresql/pvc.yaml
- postgresql/statefulset.yaml
- postgresql/service.yaml
- keycloak/configmap-realm.yaml
- keycloak/deployment.yaml
- keycloak/service.yaml
- ingress/tls.yaml
- ingress/ingress.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: keycloak
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgresql-data
namespace: keycloak
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: keycloak-postgresql
namespace: keycloak
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/component: database
app.kubernetes.io/part-of: keycloak
spec:
ports:
- port: 5432
targetPort: 5432
name: postgresql
selector:
app: keycloak-postgresql
+61
View File
@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: keycloak-postgresql
namespace: keycloak
labels:
app.kubernetes.io/name: postgresql
app.kubernetes.io/component: database
app.kubernetes.io/part-of: keycloak
spec:
serviceName: keycloak-postgresql
replicas: 1
selector:
matchLabels:
app: keycloak-postgresql
template:
metadata:
labels:
app: keycloak-postgresql
app.kubernetes.io/name: postgresql
app.kubernetes.io/component: database
app.kubernetes.io/part-of: keycloak
spec:
containers:
- name: postgresql
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgresql
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-secrets
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: postgresql-data
+19
View File
@@ -0,0 +1,19 @@
apiVersion: v1
kind: Secret
metadata:
name: keycloak-secrets
namespace: keycloak
type: Opaque
stringData:
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: <CHANGE_ME>
POSTGRES_DB: keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: <CHANGE_ME>
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: <CHANGE_ME>
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: <CHANGE_ME>
LIBRECHAT_OIDC_SECRET: <CHANGE_ME>
CLIENT_SECRET: <CHANGE_ME>
USER_PASSWORD: <CHANGE_ME>