Before diving in, make sure you have finished part 1 of the series. →
Implement Your Business Logic: The Controller
Now that you’ve completed the first part of this series, you’re ready to implement your core business logic within the controller. Your kubebuilder
command has already generated a controller stub for you; all you need to do is edit the internal/controller/task_controller.go
file. In this section, you’ll learn how to react to different events within your cluster and create new events in Kubernetes to signal important state changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
| package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
krand "k8s.io/apimachinery/pkg/util/rand"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
examplev1 "example.com/my-project/api/v1"
)
// TaskReconciler reconciles a Task object
type TaskReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=example.example.com,resources=tasks,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=example.example.com,resources=tasks/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=example.example.com,resources=tasks/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=events,verbs=create
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *TaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
task := examplev1.Task{}
if err := r.Get(ctx, req.NamespacedName, &task); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
event := corev1.Event{
ObjectMeta: ctrl.ObjectMeta{
Name: krand.String(40),
Namespace: task.Namespace,
},
InvolvedObject: corev1.ObjectReference{
Kind: task.Kind,
Name: task.Name,
Namespace: task.Namespace,
UID: task.UID,
APIVersion: examplev1.GroupVersion.String(),
ResourceVersion: task.ResourceVersion,
},
}
switch {
case task.Generation == 1:
event.Reason = "TaskCreated"
event.Message = "Task has been created"
event.Type = corev1.EventTypeNormal
case task.DeletionTimestamp.IsZero():
event.Reason = "TaskUpdated"
event.Message = "Task has been updated"
event.Type = corev1.EventTypeNormal
case !controllerutil.ContainsFinalizer(&task, "example.example.com/finalizer"):
return ctrl.Result{}, nil
default:
event.Reason = "TaskDeleted"
event.Message = "Task has been deleted"
event.Type = corev1.EventTypeWarning
controllerutil.RemoveFinalizer(&task, "example.example.com/finalizer")
if err := r.Update(ctx, &task); err != nil {
return ctrl.Result{}, err
}
}
if err := r.Create(ctx, &event); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *TaskReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&examplev1.Task{}).
Named("task").
Complete(r)
}
|
Once you finished editing files, you can build your OCI image by executing the following command.
1
2
| export IMG=controller:dev
make docker-build
|
In the next step you have to make the image available on Kubernetes cluster.
1
| ../bin/kind load docker-image $IMG
|
And finally deploy the application to the cluster.
1
2
| make deploy
kubectl delete pod -n my-project-system -l control-plane=controller-manager
|
Create your Task
, update it and finally delete the resource.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| cat | kubectl apply -f - <<EOF
apiVersion: example.example.com/v1
kind: Task
metadata:
name: task-sample-2
labels:
example.example.com/priority: "2"
example.example.com/deadline: "1755615135"
finalizers:
- example.example.com/finalizer
spec:
priority: 2
details: Sample task details
deadline: "2025-08-19T16:52:15Z"
EOF
kubectl patch tasks task-sample-2 --type='json' -p='[{"op":"replace", "path":"/spec/taskState", "value":"Finished"}]'
kubectl delete tasks task-sample-2
|
Validate business logic by fetching events of the Task
object.
1
2
| kubectl get events \
--field-selector involvedObject.kind=Task,involvedObject.name=task-sample-2
|
LAST SEEN TYPE REASON OBJECT MESSAGE
<unknown> Normal TaskCreated task/task-sample-2 Task has been created
<unknown> Normal TaskUpdated task/task-sample-2 Task has been updated
<unknown> Warning TaskDeleted task/task-sample-2 Task has been deleted
Awesome! Now that you’ve finished your first simple controller, you’ve seen how to implement the core logic of an Operator. While this was just a basic example, it’s a huge first step and the foundation for building more complex applications. You’re now ready to move on to more advanced topics, like validating or mutation your resources.
Custom Validation of Resources via Webhook
While the Kubernetes API has built-in validators for its core and custom resources, these are often not enough to enforce the complex business logic and custom constraints required by your applications, which is why you sometimes need to write a custom validator.
1
| ../bin/kubebuilder create webhook --group example --version v1 --kind Task --programmatic-validation --defaulting
|
Now you can implement your custom validation at internal/webhook/v1/task_webhook.go
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Task.
func (v *TaskCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) {
task, ok := obj.(*examplev1.Task)
if !ok {
return nil, fmt.Errorf("expected a Task object but got %T", obj)
}
// TODO(user): fill in your validation logic upon object creation.
return nil, nil
}
// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Task.
func (v *TaskCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
task, ok := newObj.(*examplev1.Task)
if !ok {
return nil, fmt.Errorf("expected a Task object for the newObj but got %T", newObj)
}
// TODO(user): fill in your validation logic upon object update.
return nil, nil
}
// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Task.
func (v *TaskCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
task, ok := obj.(*examplev1.Task)
if !ok {
return nil, fmt.Errorf("expected a Task object but got %T", obj)
}
// TODO(user): fill in your validation logic upon object deletion.
return nil, nil
}
|
Mutating Resources via Webhook
A mutation webhook is a powerful Kubernetes mechanism for automatically changing or injecting resources before they are persisted, perfect for adding default values or make any conversion before they are written to the database.
Open the same internal/webhook/v1/task_webhook.go
file to implement your mutation logic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Task.
func (d *TaskCustomDefaulter) Default(_ context.Context, obj runtime.Object) error {
task, ok := obj.(*examplev1.Task)
if !ok {
return fmt.Errorf("expected an Task object but got %T", obj)
}
if task.Labels == nil {
task.Labels = make(map[string]string)
}
task.Labels["example.example.com/priority"] = strconv.Itoa(int(task.Spec.Priority))
task.Labels["example.example.com/deadline"] = strconv.Itoa(int(task.Spec.Deadline.Unix()))
if task.Generation == 0 {
controllerutil.AddFinalizer(task, "example.example.com/finalizer")
}
return nil
}
|
If you recall, the Task
custom resource we created earlier included labels for filtering and a finalizer to control object deletion. Now, with this simple mutation webhook, you can automate the process of adding these fields, so users don’t have to.
Deploying Webhooks
First you have to deploy cert-manager
to enable automatic certificate generation of the webhooks.
1
| kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.16.3/cert-manager.yaml
|
Ucomment the following lines in config/crd/kustomization.yaml
file.
1
2
| configurations:
- kustomizeconfig.yaml
|
Ucomment the following lines in config/default/kustomization.yaml
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| - source: # Uncomment the following block if you have any webhook
kind: Service
version: v1
name: webhook-service
fieldPath: .metadata.name # Name of the service
targets:
- select:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPaths:
- .spec.dnsNames.0
- .spec.dnsNames.1
options:
delimiter: '.'
index: 0
create: true
- source:
kind: Service
version: v1
name: webhook-service
fieldPath: .metadata.namespace # Namespace of the service
targets:
- select:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPaths:
- .spec.dnsNames.0
- .spec.dnsNames.1
options:
delimiter: '.'
index: 1
create: true
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert # This name should match the one in certificate.yaml
fieldPath: .metadata.namespace # Namespace of the certificate CR
targets:
- select:
kind: ValidatingWebhookConfiguration
fieldPaths:
- .metadata.annotations.[cert-manager.io/inject-ca-from]
options:
delimiter: '/'
index: 0
create: true
- source:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPath: .metadata.name
targets:
- select:
kind: ValidatingWebhookConfiguration
fieldPaths:
- .metadata.annotations.[cert-manager.io/inject-ca-from]
options:
delimiter: '/'
index: 1
create: true
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPath: .metadata.namespace # Namespace of the certificate CR
targets:
- select:
kind: MutatingWebhookConfiguration
fieldPaths:
- .metadata.annotations.[cert-manager.io/inject-ca-from]
options:
delimiter: '/'
index: 0
create: true
- source:
kind: Certificate
group: cert-manager.io
version: v1
name: serving-cert
fieldPath: .metadata.name
targets:
- select:
kind: MutatingWebhookConfiguration
fieldPaths:
- .metadata.annotations.[cert-manager.io/inject-ca-from]
options:
delimiter: '/'
index: 1
create: true
|
Re-deploy the application.
1
2
3
4
| make docker-build
../bin/kind load docker-image $IMG
make deploy
kubectl delete pod -n my-project-system -l control-plane=controller-manager
|
Create your Task
without metadata.
1
2
3
4
5
6
7
8
9
10
| cat | kubectl apply -f - <<EOF
apiVersion: example.example.com/v1
kind: Task
metadata:
name: task-sample-3
spec:
priority: 5
details: Sample task details
deadline: "2025-08-19T16:52:15Z"
EOF
|
Validate mutation webhook by fetching details of the Task
object.
1
| kubectl get tasks task-sample-3 -o yaml
|
apiVersion: example.example.com/v1
kind: Task
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"example.example.com/v1","kind":"Task","metadata":{"annotations":{},"name":"task-sample-3","namespace":"default"},"spec":{"deadline":"2025-08-19T16:52:15Z","details":"Sample task details","priority":5}}
creationTimestamp: "2025-08-19T17:02:47Z"
finalizers:
- example.example.com/finalizer
generation: 1
labels:
example.example.com/deadline: "1755622335"
example.example.com/priority: "5"
name: task-sample-3
namespace: default
resourceVersion: "8867"
uid: 7b4649e5-4f0d-4929-880a-516ede0bdde6
spec:
deadline: "2025-08-19T16:52:15Z"
details: Sample task details
priority: 5
taskState: Pending
The controller handles the core business logic, reconciling the desired state, while webhooks provide a way to enforce rules and automatically modify resources. This combination of reconciliation and admission control is what truly elevates an operator from a simple manager to a robust, self-managing application.
Ready for the next step? Learn how to implement advanced data filtering in controller. →
That’s it! Imagine your own data topology and enhance your Kubernetes experience. Enjoy lower latency, higher throughput, data isolation, virtually unlimited storage, and simplified development. HariKube supports both flat and hierarchical topologies, allowing you to organize your databases like leaves on a tree.
Thank you for reading, and feel free to share your thoughts.