Study guide

  

Go Study Plan — Complete Curriculum for Controller Development

Scope: every Go concept you'll actually touch in controller-runtime code, in the order client-go/kubebuilder code assumes you already know them. Format: each topic has (1) what it is, (2) why it matters specifically for controllers, (3) a program to write yourself. Solutions aren't included on purpose — writing it wrong first and fixing it is where the learning happens. Length: 14 focused sessions. At ~45–60 min/session that's 2–3 weeks; compress to the original 1-week slot by doing 2 sessions/day.


Session 1 — Variables, Types, Constants

Concepts: var, :=, basic types (int, string, bool, float64), const, iota, type conversion

Why it matters: k8s API types are just structs full of these primitives. iota shows up in enum-like constants (e.g. resource phases).

Program to write: resource_status.go

  • Define constants for a resource's phase using iota: Pending, Running, Failed, Succeeded
  • Write a function func describePhase(p int) string that returns a human string for each phase
  • Print all four phases in a loop

Session 2 — Control Flow

Concepts: if/else (with the init-statement form: if err := doThing(); err != nil), for (Go's only loop, all four forms), switch (including type switches)

Why it matters: the if err := f(); err != nil pattern appears in nearly every line of reconcile code. Type switches show up when handling different k8s object kinds.

Program to write: classify.go

  • Write a function that takes an interface{} and uses a type switch to print whether it's an int, string, bool, or []string
  • Write a loop using the if x := compute(); x > threshold init-statement pattern at least twice

Session 3 — Functions

Concepts: multiple return values, named returns, variadic functions, closures, functions as values, defer

Why it matters: every controller-runtime function returns (Result, error). defer is how cleanup (closing files, unlocking mutexes) is done idiomatically — you'll see it in test setup constantly.

Program to write: divide.go

  • Write func divide(a, b float64) (float64, error) that returns an error on divide-by-zero instead of panicking
  • Write a variadic func sum(nums ...int) int
  • Write a function that returns a closure (a counter generator: makeCounter() func() int)
  • Use defer to print "done" after a function's main logic runs, and prove it runs even when the function returns early

Session 4 — Structs and Methods

Concepts: struct definition, methods with value vs pointer receivers, embedded structs, struct comparison

Why it matters: this is the k8s API. ObjectMeta, Spec, Status — all embedded structs. Pointer receivers vs value receivers is the #1 source of "why didn't my mutation stick" bugs for Go beginners.

Program to write: bank_account.go

  • Define a BankAccount struct with Balance float64 and Owner string
  • Write func (a *BankAccount) Deposit(amount float64) (pointer receiver — must mutate)
  • Write func (a BankAccount) String() string (value receiver, just formats for printing)
  • Prove to yourself in code/comments why Deposit must use a pointer receiver by writing a version with a value receiver and showing the balance doesn't change
  • Embed a ContactInfo struct (email, phone) inside BankAccount and access its fields directly through the outer struct

Session 5 — Interfaces

Concepts: implicit interface satisfaction, the empty interface interface{}/any, interface composition, the Stringer interface, type assertions

Why it matters: client.Client, runtime.Object — the entire controller-runtime API is interfaces. Nothing is declared "implements X" explicitly; it just has to have the right methods.

Program to write: shapes.go

  • Define a Shape interface with Area() float64 and Perimeter() float64
  • Implement it for Rectangle and Circle structs (don't declare "implements Shape" anywhere — just write the methods)
  • Write a function func describe(s Shape) that works on both types
  • Add a slice []Shape containing both types and loop over it calling describe
  • Bonus: use a type assertion (if r, ok := s.(Rectangle); ok) to print something extra only for rectangles

Session 6 — Error Handling (deep dive)

Concepts: errors.New, fmt.Errorf with %w for wrapping, errors.Is, errors.As, custom error types (implementing the error interface), sentinel errors

Why it matters: controller code constantly checks if apierrors.IsNotFound(err) — that's errors.As/type-assertion territory. Wrapping errors with context (%w) is how you get useful stack-trace-like debugging in Go without exceptions.

Program to write: file_processor.go

  • Define a custom error type NotFoundError with a Name string field, implementing Error() string
  • Write a function that returns this custom error when a lookup fails
  • Write a wrapping function that calls it and wraps the error with fmt.Errorf("processing failed: %w", err)
  • At the top level, use errors.As to detect whether the root cause was a NotFoundError, even though it went through a wrap

Session 7 — Slices and Maps

Concepts: slice internals (length vs capacity), append, slicing syntax, maps (make, comma-ok idiom, deleting keys), iterating with range, nil vs empty slice/map

Why it matters: List() calls return slices of objects constantly. Maps are how labels/annotations (map[string]string) are represented on every single k8s object.

Program to write: label_filter.go

  • Given a map[string]string representing labels (e.g. {"app": "web", "env": "prod"}), write a function that returns true if all key-value pairs in a "selector" map are present in the labels map (this is literally how label selectors work)
  • Write a function that takes []string of object names and returns a new slice with duplicates removed
  • Demonstrate the classic append-in-a-loop bug: append to a slice inside a loop using an index-by-reference pattern, show what goes wrong, then fix it

Session 8 — Pointers

Concepts: & and *, pointer vs value semantics, when to use pointers (mutation, avoiding copies, "optional" fields), nil pointer dereference panics

Why it matters: almost every k8s API object is passed around as a pointer (*corev1.Pod). Spec.Replicas *int32 uses a pointer specifically to distinguish "not set" from "set to zero" — this exact pattern trips up every Go beginner working with k8s types.

Program to write: optional_fields.go

  • Define a struct Config with a field Timeout *int (pointer, so nil means "not set" vs 0 meaning "explicitly zero")
  • Write a function applyDefault(c *Config) that sets Timeout to a default value only if it's nil
  • Deliberately write code that dereferences a nil pointer and observe the panic, then fix it with a nil check
  • Explain in a comment why *int32 is used for optional numeric fields instead of just int32

Session 9 — Packages and Modules

Concepts: go mod init, import paths, exported vs unexported identifiers (capitalization rule), organizing code into multiple files/packages, go.sum

Why it matters: every kubebuilder project is a multi-package Go module. Understanding why Reconciler is capitalized (exported) and client fields aren't matters for reading generated code.

Program to write: a small multi-file project

  • Create a new module: go mod init example.com/mathutils
  • Create a package mathutils in its own directory with an exported Add function and an unexported roundInternal helper
  • Import and use it from a main package
  • Add one external dependency (anything simple from pypi... no — from pkg.go.dev, e.g. a small utility package) and observe what happens to go.mod/go.sum

Session 10 — Goroutines and Channels

Concepts: go keyword, unbuffered vs buffered channels, select, sync.WaitGroup, sync.Mutex, the "don't communicate by sharing memory" idiom

Why it matters: controller-runtime runs many reconciles concurrently, workers pull off a shared queue. You don't need to be a concurrency expert, but you need to recognize a data race when you see one and know why a Mutex protects shared state.

Program to write: worker_pool.go

  • Write a simple worker pool: a channel of "jobs" (just integers), 3 goroutines consuming from it and printing job * 2, a sync.WaitGroup to wait for all workers to finish
  • Add a shared counter incremented by all workers, protected by a sync.Mutex — run it and confirm the final count is correct (do it once without the mutex first and see the race, if you have -race available: go run -race worker_pool.go)

Session 11 — The context Package

Concepts: context.Context, context.WithTimeout/WithCancel, passing context as the first argument by convention, ctx.Done()

Why it matters: Reconcile(ctx context.Context, req ctrl.Request) — this is the literal signature of every controller's core function. Context is how cancellation and deadlines propagate through the whole call chain.

Program to write: context_demo.go

  • Write a function that simulates slow work (a time.Sleep) but respects ctx.Done() and returns early with an error if the context is cancelled
  • Call it with context.WithTimeout(ctx, 2*time.Second) against a function that takes 5 seconds, and confirm it returns early
  • Call it again with a longer timeout and confirm it completes normally

Session 12 — JSON, Struct Tags, and YAML

Concepts: encoding/json (Marshal/Unmarshal), struct tags (json:"name,omitempty"), and a look at how YAML maps onto the same structs (via sigs.k8s.io/yaml, which round-trips through JSON)

Why it matters: every CRD spec you define is (de)serialized this way. omitempty is why some fields disappear from kubectl get -o yaml output when unset.

Program to write: serialize.go

  • Define a struct mirroring a mini CRD spec: Name string, Replicas *int32, Labels map[string]string, with correct json tags including omitempty where appropriate
  • Marshal an instance to JSON and print it; unmarshal it back and confirm equality
  • Set Replicas to nil vs 0 and observe the difference in the marshaled output — this is the pointer-for-optionality lesson from Session 8, now visible in real serialization

Session 13 — Testing in Go

Concepts: the testing package, go test, table-driven tests, t.Run for subtests, t.Fatal vs t.Error

Why it matters: this is the exact style used in envtest-based controller tests later. Table-driven tests are the idiomatic Go pattern you'll see in literally every serious Go codebase.

Program to write: mathutils_test.go

  • Go back to your mathutils package from Session 9
  • Write a table-driven test for Add covering: positive numbers, negative numbers, zero, and one edge case you think of yourself
  • Run go test -v ./... and confirm all cases pass
  • Deliberately break Add and confirm the test catches it

Session 14 — Capstone: Put It Together

Goal: one program using everything above, shaped like a miniature reconcile loop — this is deliberately similar in spirit to what you'll write in Week 3 of the main course.

Program to write: mini_reconciler.go

  • Define a Desired struct and an Actual struct (both with Name string, Replicas *int32)
  • Write func reconcile(ctx context.Context, desired Desired, actual *Actual) (Actual, error) that:
    • Respects context cancellation
    • Compares desired vs actual, "creates" (just constructs) the actual state if it doesn't match, using proper nil-pointer handling for Replicas
    • Returns a wrapped error if desired.Name is empty (using your custom error type from Session 6)
  • Write table-driven tests for it covering: matching state (no-op), mismatched replicas, and the empty-name error case
  • This one file should compile cleanly, pass go vet, and pass all your tests — that combination is the actual bar for "ready to touch a real Reconciler"

Self-Check Before Moving to Kubebuilder

You should be able to answer these without looking anything up:

  • Why does Deposit need a pointer receiver but String() doesn't?
  • Why is Replicas a *int32 and not an int32?
  • What's the difference between errors.Is and errors.As, and when do you reach for each?
  • Why does Reconcile(ctx, req) take a context as its first argument?
  • What actually happens if you range over a nil slice vs a nil map?

If any of those are shaky, redo that session before starting the Kubebuilder tutorial — the whole point of this list is that none of it is optional vocabulary once you're reading real controller code.

Comments

Popular posts from this blog

Another Explanation

Kubernetes

useful blog