What And Why

In my current home server setup I have Keycloak to handle SSO, and as far as I know there are not a clean way to configure Realms and to create new clients without using the WebUI or manually using the REST-API

This is a nice opportunity to get some experience implementing an operator and some CRDs

Getting started

Since I do not have any prior experience writing CRDs, I will ask qwen-coder for assistance.

Starting with CRDs

Asking qwen “Can you help me create a custom Kubernetes crd for managing keycloak realms and oidc clients?” Spits out custom resource definitions and manifests to test them out.

With a bit of editing, mostly names and schema, we have a working first draft of two CRDs one for Realms and one for Clients

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: realms.keycloak.organiccode.net
spec:
  group: keycloak.organiccode.net
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                realmName:
                  type: string
                displayName:
                  type: string
                enabled:
                  type: boolean
  scope: Cluster
  names:
    plural: realms
    singular: realm
    kind: KeycloakRealm
    shortNames:
      - kr
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: clients.keycloak.organiccode.net
spec:
  group: keycloak.organiccode.net
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required:
                - clientID
                - clientType
                - rootUrl
              properties:
                clientID: 
                  type: string
                dispalyName:
                  type: string
                clientType:
                  type: string
                  enum:
                    - SAML
                    - OIDC
                  default: OIDC
                displayName:
                  type: string
                rootUrl:
                  type: string
                homeUrl:
                  type: string
                webOrigins:
                  type: array
                  items:
                    type: string
                validPostLogoutRedirectUris:
                  type: array
                  items:
                    type: string
  scope: Namespaced
  names:
    plural: clients
    singular: client
    kind: KeycloakClient
    shortNames:
      - kc

The Operator

As I want to make the operator in Python, because I know Python, Python is a straight forward PoCing language, and it has a client library for integrating with Keycloak, qwen proposes to use Kopf (Kubernetes Operators in Python Framework)

import kopf
from kubernetes import client, config
import requests
import os
from urllib.parse import urljoin

# Load kube config
config.load_incluster_config()

# Keycloak configuration
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "http://keycloak:8080")
KEYCLOAK_ADMIN_USER = os.getenv("KEYCLOAK_ADMIN_USER", "admin")
KEYCLOAK_ADMIN_PASSWORD = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")

# Function to get Keycloak admin token
def get_keycloak_token():
    url = urljoin(KEYCLOAK_URL, "/auth/realms/master/protocol/openid-connect/token")
    data = {
        "grant_type": "password",
        "client_id": "admin-cli",
        "username": KEYCLOAK_ADMIN_USER,
        "password": KEYCLOAK_ADMIN_PASSWORD
    }
    response = requests.post(url, data=data)
    response.raise_for_status()
    return response.json()["access_token"]

# Function to manage Keycloak realms
@kopf.on.create('keycloak.organiccode.net', 'v1alpha1', 'realms')
def create_realm(spec, name, namespace, **kwargs):
    token = get_keycloak_token()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    data = {
        "realm": spec["realmName"],
        "displayName": spec.get("displayName", spec["realmName"]),
        "enabled": spec.get("enabled", True)
    }
    url = urljoin(KEYCLOAK_URL, "/auth/admin/realms")
    response = requests.post(url, headers=headers, json=data)
    response.raise_for_status()
    return {'message': f'Realm {name} created successfully'}

@kopf.on.delete('keycloak.organiccode.net', 'v1alpha1', 'realms')
def delete_realm(spec, name, namespace, **kwargs):
    token = get_keycloak_token()
    headers = {
        "Authorization": f"Bearer {token}"
    }
    url = urljoin(KEYCLOAK_URL, f"/auth/admin/realms/{spec['realmName']}")
    response = requests.delete(url, headers=headers)
    response.raise_for_status()
    return {'message': f'Realm {name} deleted successfully'}

# Function to manage Keycloak OIDC clients
@kopf.on.create('keycloak.organiccode.net', 'v1alpha1', 'clients')
def create_oidc_client(spec, name, namespace, **kwargs):
    token = get_keycloak_token()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }
    realm_name = os.getenv("DEFAULT_REALM", "master")
    data = {
        "clientId": spec["clientId"],
        "clientSecret": spec.get("clientSecret"),
        "redirectUris": spec["redirectUris"],
        "webOrigins": spec["webOrigins"],
        "publicClient": spec.get("publicClient", True)
    }
    url = urljoin(KEYCLOAK_URL, f"/auth/admin/realms/{realm_name}/clients")
    response = requests.post(url, headers=headers, json=data)
    response.raise_for_status()
    return {'message': f'OIDC Client {name} created successfully'}

@kopf.on.delete('keycloak.organiccode.net', 'v1alpha1', 'clients')
def delete_oidc_client(spec, name, namespace, **kwargs):
    token = get_keycloak_token()
    headers = {
        "Authorization": f"Bearer {token}"
    }
    realm_name = os.getenv("DEFAULT_REALM", "master")
    url = urljoin(KEYCLOAK_URL, f"/auth/admin/realms/{realm_name}/clients")
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    clients = response.json()
    client_id = next((client["id"] for client in clients if client["clientId"] == spec["clientId"]), None)
    if client_id:
        url = urljoin(KEYCLOAK_URL, f"/auth/admin/realms/{realm_name}/clients/{client_id}")
        response = requests.delete(url, headers=headers)
        response.raise_for_status()
    return {'message': f'OIDC Client {name} deleted successfully'}
Looks good I guess.. question is how do we test this in a simple and fast manner? Worst case we need to spin up a minikube cluster and deploy Keycloak. Lets push on and and worry about testing later :

Creating a dockerfile

Once again qwen provides. The dockerfile was a bit more complicated than what we need right now, and with an old version of python, but it does work. Every line is also commented :P

# Use the official Python image as a parent image
FROM python:3.9-alpine

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Set the working directory in the container
WORKDIR /app

# Copy the requirements file into the container and install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the operator script into the container
COPY main.py .

# Expose the port if needed (e.g., for metrics or webhooks)
EXPOSE 80

# Command to run the Kopf operator
CMD ["kopf", "run", "main.py"]

Bundling it up with Helm

First let’s run helm create keycloak-relm-operator to get us up and running with the default helm project structure:

keycloak-relm-operator/
├── Chart.yaml
├── charts/
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   └── ...
└── values.yaml

Asking qwen to package the operator with helm spits out a lot of ugly curly bracket soup, but I guess that is the world of templating.

After adding some files, removing some files, cleaning up some inconsistencies in the template variable names, we get something like this:

keycloak-realm-operator/
├── charts
├── Chart.yaml
├── templates
│   ├── crds
│   │   ├── client.yaml
│   │   └── realm.yaml
│   ├── deployment.yaml
│   ├── _helpers.tpl
│   ├── roles.yaml
│   └── serviceaccount.yaml
└── values.yaml

Taking it for a test drive

To test it out we get our hands on a fresh install of minikube.

curl -LO https://github.com/kubernetes/minikube/releases/latest/download/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube && rm minikube-linux-amd64

minikube start

And our cluster is up and running.

Now to make out image and install the helm charts.

docker build -t keycloak-realm-operator:latest .

minikube image load keycloak-realm-operator:latest

helm install keycloak-realm-operator keycloak-realm-operator

We have now have a running pod

NAME                                           READY   STATUS    RESTARTS   AGE
pod/keycloak-realm-operator-59b4976b55-xvnh8   1/1     Running   0          16m

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   16m

NAME                                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/keycloak-realm-operator   1/1     1            1           16m

NAME                                                 DESIRED   CURRENT   READY   AGE
replicaset.apps/keycloak-realm-operator-59b4976b55   1         1         1       16m

Looking at the pod logs, there are some exceptions due to missing permissions for the service account, but nothing that should be too hard to hammer out.