Build an AI controller with Crossplane
In this tutorial, you run a Kubernetes controller whose reconciliation logic is
written in plain English. A Crossplane WatchOperation watches an nginx
Deployment and calls a local LLM whenever it changes. The LLM reads the
current state, applies the rule in its systemPrompt, and returns a corrected
manifest. Crossplane applies it.
By the end of this tutorial, you can:
- Run a Crossplane
WatchOperationthat calls a local LLM - Watch the controller detect and correct a policy violation automatically
- Update the enforcement rule by editing a single field in YAML
The model running in this tutorial is qwen2.5:1.5b via Ollama — running
entirely on your local machine. No cloud API key is required.
Prerequisites
Install the following before starting:
Install the up CLI
This tutorial requires up CLI v0.44.3.
curl -sL "https://cli.upbound.io" | VERSION=v0.44.3 sh
Move the binary into your PATH:
sudo mv up /usr/local/bin/
If you don't have sudo access, install to a user-local directory instead:
mkdir -p ~/.local/bin && mv up ~/.local/bin/
Then add it to your shell profile (~/.bashrc, ~/.zshrc, or equivalent):
export PATH="$HOME/.local/bin:$PATH"
Verify the installation:
up version
Create the project
Create the project directory
mkdir english-controller
cd english-controller
All commands from this point run from inside the english-controller directory.
Create the project manifest
The upbound.yaml file declares the project and its function dependencies.
up project run --local reads this file to know which packages to install into
the cluster. Create it with:
cat > upbound.yaml <<'EOF'
apiVersion: meta.dev.upbound.io/v2alpha1
kind: Project
metadata:
name: english-controller
spec:
dependsOn:
- apiVersion: pkg.crossplane.io/v1
kind: Function
# function-auto-ready marks composed resources as ready automatically;
# required by Crossplane's composition machinery even when not used directly.
package: xpkg.upbound.io/crossplane-contrib/function-auto-ready
version: '>=v0.0.0'
- apiVersion: pkg.crossplane.io/v1
kind: Function
# function-openai is the function the WatchOperation calls to reach the LLM.
package: xpkg.upbound.io/upbound/function-openai
version: v0.3.0
description: A Kubernetes controller whose enforcement logic is written in plain English.
EOF
Create the WatchOperation
The WatchOperation is the controller — it defines what to watch and what
function to call when the watched resource changes.
mkdir -p operations/replicas
cat > operations/replicas/operation.yaml <<'EOF'
apiVersion: ops.crossplane.io/v1alpha1
kind: WatchOperation
metadata:
name: replicas
spec:
concurrencyPolicy: Forbid
successfulHistoryLimit: 3
failedHistoryLimit: 1
operationTemplate:
spec:
mode: Pipeline
pipeline:
- functionRef:
name: upbound-function-openai
input:
apiVersion: openai.fn.upbound.io/v1alpha1
kind: Prompt
systemPrompt: |-
You are a Kubernetes controller. Output raw YAML only — no markdown, no code fences, no backticks, no explanations.
Rule: if spec.replicas is less than 3, set it to 3. Otherwise keep it unchanged.
userPrompt: |-
Inspect the nginx Deployment and output the corrected manifest.
Output only the Deployment manifest with the correct spec.replicas value.
Include apiVersion, kind, metadata (name: nginx, namespace: default), and spec.
Start your response with 'apiVersion:'
step: deployment-analysis
credentials:
- name: gpt
source: Secret
secretRef:
namespace: crossplane-system
name: gpt
watch:
apiVersion: apps/v1
kind: Deployment
namespace: default
EOF
With a larger model like gpt-4o or gpt-oss:20b, the systemPrompt can be
much simpler — just the rule itself, without the output format instructions.
The explicit YAML output guidance in userPrompt is needed specifically for
qwen2.5:1.5b.
Set up Ollama
Ollama runs the LLM locally. Install it and pull the model before starting the cluster — the model is ~1 GB.
Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
If the install script doesn't work for your OS, download directly from ollama.com/download.
Start Ollama
On Linux, the install script registers a systemd service that starts Ollama
automatically. On macOS, Ollama may not start automatically after installation.
If ollama list returns "could not connect to ollama server", start it manually
in a separate terminal before continuing:
ollama serve
Verify it's ready:
ollama list
Pull the model
ollama pull qwen2.5:1.5b
Confirm the model downloaded:
ollama list
You should see qwen2.5:1.5b in the output.
Start the project
Run up project run --local from inside the english-controller directory.
This command creates a kind cluster, installs UXP, and deploys all packages and
APIs defined in the project. It exits when the cluster is ready.
up project run --local --control-plane-version=2.1.4-up.2
The --control-plane-version flag pins the UXP version installed into the kind
cluster. This tutorial was tested with 2.1.4-up.2. If you need a different
version, find available version strings in the UXP release notes.
This takes several minutes on first run — it pulls provider packages and sets up the cluster. Subsequent runs are faster.
If up project run --local exits non-zero and prints traces export: context deadline exceeded, check whether providers were installed:
kubectl get providers
If providers appear, provisioning succeeded despite the telemetry error.
If the list is empty, provisioning failed. Run
kind delete cluster --name up-app-w-db and retry. Verify your network
allows outbound connections to xpkg.upbound.io on port 443.
Once the command completes, set your kubeconfig. up project run --local names
the kind cluster up-app-w-db by default:
kind get kubeconfig --name up-app-w-db > ~/.kube/config
This overwrites your existing ~/.kube/config. To preserve existing contexts,
merge instead:
kind get kubeconfig --name up-app-w-db > ~/.kube/config-upbound
KUBECONFIG=~/.kube/config:~/.kube/config-upbound \
kubectl config view --flatten > ~/.kube/config.merged
mv ~/.kube/config.merged ~/.kube/config
Verify the connection:
kubectl get nodes
Wire Ollama into the cluster
The kind cluster's pods need to reach Ollama running on your host. This step
creates a Kubernetes Service and Endpoints resource that route cluster
traffic to your host machine.
-
Get the host IP on the kind bridge network:
Linux:
HOST_IP=$(docker network inspect kind -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}')
echo "Host IP: $HOST_IP"macOS (Docker Desktop):
HOST_IP=$(docker run --rm alpine sh -c 'getent hosts host.docker.internal' 2>/dev/null | awk '{print $1}')
echo "Host IP: $HOST_IP" -
Create the
ollamanamespace and register Ollama as a cluster service:kubectl create namespace ollama --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
name: ollama
namespace: ollama
spec:
ports:
- port: 11434
targetPort: 11434
---
apiVersion: v1
kind: Endpoints
metadata:
name: ollama
namespace: ollama
subsets:
- addresses:
- ip: ${HOST_IP}
ports:
- port: 11434
EOF -
Create the credentials secret that
function-openaiuses to reach Ollama:kubectl apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: gpt
namespace: crossplane-system
stringData:
OPENAI_API_KEY: ollama
OPENAI_BASE_URL: http://${HOST_IP}:11434/v1
OPENAI_MODEL: qwen2.5:1.5b
EOFThe
OPENAI_BASE_URLpoints to Ollama's OpenAI-compatible API. To switch to a cloud model, replace this URL and updateOPENAI_API_KEYandOPENAI_MODEL— theWatchOperationworks identically.
Verify the setup
Wait for function-openai to become healthy:
kubectl get functions
Wait until upbound-function-openai shows HEALTHY: True.
If kubectl get functions returns No resources found, up project run --local did not complete successfully. Check the output from that command,
delete the cluster with kind delete cluster --name up-app-w-db, and restart
from the Start the project step.
Apply the starting state
Apply the nginx Deployment at 1 replica — the AI controller will correct
this:
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx
name: nginx
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
EOF
Verify it's running:
kubectl get deployment nginx
You should see READY: 1/1.
Part 1: Run the AI controller
A Crossplane WatchOperation fires every time a specific resource changes.
Here, it watches the nginx Deployment in the default namespace. When it
fires, it calls upbound-function-openai, which sends the current state to
the LLM along with the rule in systemPrompt. The LLM returns a corrected
manifest. Crossplane applies it.
Apply the WatchOperation
kubectl apply -f operations/replicas/operation.yaml
The WatchOperation fires immediately because the Deployment exists.
Watch it act
kubectl get deployment nginx -w
Within 60–90 seconds, replicas jump from 1 to 3. The LLM read the
Deployment, decided it violated the rule, and patched it.
Press Ctrl+C when the replicas reach 3.
Explore the controller
Open operations/replicas/operation.yaml. That file is the entire controller.
The systemPrompt is the reconciliation logic:
systemPrompt: |-
You are a Kubernetes controller. Output raw YAML only.
Rule: if spec.replicas is less than 3, set it to 3. Otherwise keep it unchanged.
The watch block defines the trigger:
watch:
apiVersion: apps/v1
kind: Deployment
namespace: default
Every time any Deployment in default changes, the operation fires.
Inspect the operation records
Each Operation object is a record of a single invocation — what fired, what
the model returned, and what the controller applied.
kubectl get watchoperations
kubectl get operations
Pick one of the operation names and describe it:
kubectl describe operation <name>
The Events section shows the exact YAML the model returned.
Part 2: Watch it self-heal
The controller re-evaluates on every change. If something modifies the
Deployment — a human, a CI pipeline, a rollout — the rule re-applies.
This is drift detection with reasoning.
Trigger a violation
Scale nginx down to 1 replica:
kubectl scale deployment nginx --replicas=1
Watch it recover
kubectl get deployment nginx -w
Within 30–60 seconds, replicas climb back to 3. The WatchOperation fired
because the Deployment changed. The LLM saw 1 replica, decided it violated
the rule, and patched it.
Press Ctrl+C when replicas are back at 3.
Inspect what fired
kubectl get operations
Each entry is a new record. The most recent one captured the scale-down event and the correction.
Part 3: Update the rules
The enforcement logic is a text field. To change the policy, edit systemPrompt
and re-apply.
Open the operation
cat operations/replicas/operation.yaml
Change the minimum replicas to 5
Find the systemPrompt and update the rule line. Change:
Rule: if spec.replicas is less than 3, set it to 3. Otherwise keep it unchanged.
To:
Rule: if spec.replicas is less than 5, set it to 5. Otherwise keep it unchanged.
Edit the file directly:
macOS:
sed -i '' 's/less than 3, set it to 3/less than 5, set it to 5/' \
operations/replicas/operation.yaml
Linux:
sed -i 's/less than 3, set it to 3/less than 5, set it to 5/' \
operations/replicas/operation.yaml
Apply the updated operation
kubectl apply -f operations/replicas/operation.yaml
Trigger and observe
Scale nginx down to 1 to trigger the new rule:
kubectl scale deployment nginx --replicas=1
Watch the updated rule enforce 5 replicas:
kubectl get deployment nginx -w
This takes 30–45 seconds. Press Ctrl+C when you see 5 ready replicas.
Verify
kubectl get watchoperations
kubectl get operations
Same architecture, different policy — changed by editing a text field.
Try adding a condition to the rule:
If the deployment name contains 'prod', require at least 5 replicas.
Otherwise, require at least 2.
The model interprets natural language conditions the same way it interprets simple numeric rules.
Clean up
Delete the demo resources:
kubectl delete watchoperation replicas
kubectl delete operations --all
kubectl delete deployment nginx
Delete the cluster:
kind delete cluster --name up-app-w-db
Next steps
In this tutorial, you:
- Created a Crossplane project with
upbound.yamland aWatchOperation - Deployed a controller that calls a local LLM on every
Deploymentchange - Watched the controller detect and correct a replica count violation
- Updated the enforcement policy by editing a single field in YAML
Continue with:
- WatchOperations reference — triggers, concurrency, history limits, and output handling
- Composition functions — build custom logic for any resource
- Provider authentication — connect providers to your own cloud account
- Upbound Marketplace — functions and providers for AWS, Azure, GCP, and more