Lab 2.4: Building Hello World Operator

Related Lesson: Lesson 2.4: Your First Operator
Navigation: ← Previous Lab: Dev Environment | Module Overview

Objectives

  • Build your first complete operator
  • Understand operator project structure
  • Run operator locally
  • Create and manage Custom Resources
  • Observe reconciliation in action

Prerequisites

  • Complete development environment from Lab 2.3
  • Kind cluster running
  • Understanding of CRDs from Module 1

Exercise 1: Initialize Project

Task 1.1: Create Project Directory

# Create project directory
mkdir -p ~/hello-world-operator
cd ~/hello-world-operator

# Initialize git (optional but recommended)
git init

Task 1.2: Initialize Kubebuilder Project

# Initialize kubebuilder project
kubebuilder init --domain example.com --repo github.com/example/hello-world-operator

Observe:

  • What files were created?
  • What’s the project structure?

Task 1.3: Verify Project Structure

# List files
ls -la

# Check main.go
head -20 main.go

# Check Makefile
head -30 Makefile

Exercise 2: Create API

Task 2.1: Create HelloWorld API

# Create API
kubebuilder create api --group hello --version v1 --kind HelloWorld

When prompted:

  • Create Resource [y/n]: y
  • Create Controller [y/n]: y

Task 2.2: Examine Generated Files

# Check API types
cat api/v1/helloworld_types.go

# Check controller
cat internal/controller/helloworld_controller.go

Exercise 3: Define API Types

Task 3.1: Edit API Types

Edit api/v1/helloworld_types.go:

package v1

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// HelloWorldSpec defines the desired state of HelloWorld
type HelloWorldSpec struct {
    // Message is the message to display
    // +kubebuilder:validation:Required
    Message string `json:"message"`
    
    // Count is the number of times to display the message
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=100
    Count int32 `json:"count,omitempty"`
}

// HelloWorldStatus defines the observed state of HelloWorld
type HelloWorldStatus struct {
    // Phase represents the current phase
    Phase string `json:"phase,omitempty"`
    
    // ConfigMapCreated indicates if the ConfigMap was created
    ConfigMapCreated bool `json:"configMapCreated,omitempty"`
    
    // LastUpdated is when the status was last updated
    LastUpdated *metav1.Time `json:"lastUpdated,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".spec.message"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"

// HelloWorld is the Schema for the helloworlds API
type HelloWorld struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   HelloWorldSpec   `json:"spec,omitempty"`
    Status HelloWorldStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// HelloWorldList contains a list of HelloWorld
type HelloWorldList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []HelloWorld `json:"items"`
}

func init() {
    SchemeBuilder.Register(&HelloWorld{}, &HelloWorldList{})
}

Task 3.2: Generate Code

# Generate code
make generate

# Generate manifests
make manifests

Task 3.3: Verify CRD

# Check CRD was generated
ls -la config/crd/bases/

# Examine CRD
cat config/crd/bases/hello.example.com_helloworlds.yaml | head -50

Exercise 4: Implement Controller

Task 4.1: Edit Controller

Edit internal/controller/helloworld_controller.go:

package controller

import (
    "context"
    "fmt"
    "time"
    
    "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"
    
    hellov1 "github.com/example/hello-world-operator/api/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// HelloWorldReconciler reconciles a HelloWorld object
type HelloWorldReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=hello.example.com,resources=helloworlds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=hello.example.com,resources=helloworlds/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=hello.example.com,resources=helloworlds/finalizers,verbs=update
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete

// Reconcile is part of the main kubernetes reconciliation loop
func (r *HelloWorldReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
    
    logger.Info("Reconciling HelloWorld", "name", req.NamespacedName)
    
    // Fetch the HelloWorld instance
    helloWorld := &hellov1.HelloWorld{}
    if err := r.Get(ctx, req.NamespacedName, helloWorld); err != nil {
        if errors.IsNotFound(err) {
            // Object not found, return
            logger.Info("HelloWorld not found, ignoring", "name", req.NamespacedName)
            return ctrl.Result{}, nil
        }
        // Error reading the object
        logger.Error(err, "Failed to get HelloWorld")
        return ctrl.Result{}, err
    }
    
    // Define the ConfigMap
    configMapName := helloWorld.Name + "-config"
    configMap := &corev1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:      configMapName,
            Namespace: helloWorld.Namespace,
        },
        Data: map[string]string{
            "message": helloWorld.Spec.Message,
            "count":   fmt.Sprintf("%d", helloWorld.Spec.Count),
        },
    }
    
    // Set owner reference
    if err := ctrl.SetControllerReference(helloWorld, configMap, r.Scheme); err != nil {
        logger.Error(err, "Failed to set controller reference")
        return ctrl.Result{}, err
    }
    
    // Check if ConfigMap already exists
    existingConfigMap := &corev1.ConfigMap{}
    err := r.Get(ctx, client.ObjectKey{
        Name:      configMap.Name,
        Namespace: configMap.Namespace,
    }, existingConfigMap)
    
    if err != nil && errors.IsNotFound(err) {
        // ConfigMap doesn't exist, create it
        logger.Info("Creating ConfigMap", "name", configMap.Name)
        if err := r.Create(ctx, configMap); err != nil {
            logger.Error(err, "Failed to create ConfigMap")
            return ctrl.Result{}, err
        }
    } else if err != nil {
        logger.Error(err, "Failed to get ConfigMap")
        return ctrl.Result{}, err
    } else {
        // ConfigMap exists, update it if needed
        if existingConfigMap.Data["message"] != configMap.Data["message"] ||
           existingConfigMap.Data["count"] != configMap.Data["count"] {
            logger.Info("Updating ConfigMap", "name", configMap.Name)
            existingConfigMap.Data = configMap.Data
            if err := r.Update(ctx, existingConfigMap); err != nil {
                logger.Error(err, "Failed to update ConfigMap")
                return ctrl.Result{}, err
            }
        }
    }
    
    // Update status
    now := metav1.Now()
    helloWorld.Status.Phase = "Ready"
    helloWorld.Status.ConfigMapCreated = true
    helloWorld.Status.LastUpdated = &now
    
    if err := r.Status().Update(ctx, helloWorld); err != nil {
        logger.Error(err, "Failed to update status")
        return ctrl.Result{}, err
    }
    
    logger.Info("Successfully reconciled HelloWorld", "name", req.NamespacedName)
    return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *HelloWorldReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&hellov1.HelloWorld{}).
        Complete(r)
}

Task 4.2: Regenerate Manifests

# Regenerate RBAC (controller has new permissions)
make manifests

Exercise 5: Install and Run Operator

Task 5.1: Install CRD

# Install CRD to cluster
make install

# Verify CRD was created
kubectl get crd helloworlds.hello.example.com

# Examine CRD
kubectl get crd helloworlds.hello.example.com -o yaml | head -30

Task 5.2: Run Operator Locally

In one terminal, run the operator:

# Run operator (connects to kind cluster)
make run

Observe:

  • Operator starts up
  • Logs show it’s ready
  • It’s watching for HelloWorld resources

Task 5.3: Create HelloWorld Resource

In another terminal, create a HelloWorld:

# Create HelloWorld resource
cat <<EOF | kubectl apply -f -
apiVersion: hello.example.com/v1
kind: HelloWorld
metadata:
  name: hello-example
spec:
  message: "Hello from my first operator!"
  count: 5
EOF

Task 5.4: Observe Reconciliation

Watch what happens:

# Check HelloWorld resource
kubectl get helloworld hello-example

# Get detailed view
kubectl get helloworld hello-example -o yaml

# Check ConfigMap was created
kubectl get configmap hello-example-config

# View ConfigMap data
kubectl get configmap hello-example-config -o jsonpath='{.data}'

# Watch operator logs (in the terminal running make run)
# You should see reconciliation logs

Exercise 6: Test Updates

Task 6.1: Update HelloWorld

# Update the message
kubectl patch helloworld hello-example --type merge -p '{"spec":{"message":"Updated message!"}}'

# Watch operator logs
# Check ConfigMap was updated
kubectl get configmap hello-example-config -o jsonpath='{.data.message}'

Task 6.2: Verify Status Updates

# Check status was updated
kubectl get helloworld hello-example -o jsonpath='{.status}'

Exercise 7: Test Deletion

Task 7.1: Delete HelloWorld

# Delete HelloWorld
kubectl delete helloworld hello-example

# Check ConfigMap (should be deleted due to owner reference)
kubectl get configmap hello-example-config

Expected: ConfigMap should be automatically deleted (owner reference from Module 1)

Exercise 8: Create Multiple Resources

Task 8.1: Create Multiple HelloWorlds

# Create multiple HelloWorld resources
cat <<EOF | kubectl apply -f -
apiVersion: hello.example.com/v1
kind: HelloWorld
metadata:
  name: hello-1
spec:
  message: "First hello"
  count: 3
---
apiVersion: hello.example.com/v1
kind: HelloWorld
metadata:
  name: hello-2
spec:
  message: "Second hello"
  count: 7
EOF

Task 8.2: Verify All Resources

# List all HelloWorlds
kubectl get helloworlds

# Check all ConfigMaps
kubectl get configmaps | grep hello

Cleanup

# Delete all HelloWorld resources
kubectl delete helloworlds --all

# Uninstall CRD
make uninstall

# Stop operator (Ctrl+C in the terminal running make run)

Lab Summary

In this lab, you:

  • Created a complete operator project
  • Defined Custom Resource types
  • Implemented reconciliation logic
  • Ran operator locally
  • Created and managed Custom Resources
  • Observed reconciliation in action
  • Tested updates and deletions

Key Learnings

  1. Kubebuilder scaffolds complete operator projects
  2. You define API types (spec and status)
  3. You implement the Reconcile function
  4. Operator follows reconciliation pattern from Module 1
  5. Owner references manage resource lifecycle
  6. Status updates reflect actual state
  7. Operators run locally but connect to cluster

Congratulations!

You’ve built your first operator! This demonstrates:

  • ✅ CRD creation and management
  • ✅ Controller implementation
  • ✅ Reconciliation pattern
  • ✅ Resource creation and updates
  • ✅ Status management
  • ✅ Owner references

Solutions

Complete working solutions for this lab are available in the solutions directory:

  • main.go - Complete operator entry point
  • Controller - Complete controller implementation
  • API Types - Complete API type definitions

Next Steps

In Module 3, you’ll learn to build more sophisticated controllers with advanced patterns!