WIP: My first Operator and CRDs
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.