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) stringthat 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 anint,string,bool, or[]string - Write a loop using the
if x := compute(); x > thresholdinit-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
deferto 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
BankAccountstruct withBalance float64andOwner 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
Depositmust use a pointer receiver by writing a version with a value receiver and showing the balance doesn't change - Embed a
ContactInfostruct (email, phone) insideBankAccountand 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
Shapeinterface withArea() float64andPerimeter() float64 - Implement it for
RectangleandCirclestructs (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
[]Shapecontaining both types and loop over it callingdescribe - 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
NotFoundErrorwith aName stringfield, implementingError() 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.Asto detect whether the root cause was aNotFoundError, 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]stringrepresenting labels (e.g.{"app": "web", "env": "prod"}), write a function that returnstrueif 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
[]stringof 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
Configwith a fieldTimeout *int(pointer, so nil means "not set" vs 0 meaning "explicitly zero") - Write a function
applyDefault(c *Config)that setsTimeoutto 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
*int32is used for optional numeric fields instead of justint32
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
mathutilsin its own directory with an exportedAddfunction and an unexportedroundInternalhelper - Import and use it from a
mainpackage - Add one external dependency (anything simple from pypi... no — from
pkg.go.dev, e.g. a small utility package) and observe what happens togo.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, async.WaitGroupto 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-raceavailable: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 respectsctx.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 correctjsontags includingomitemptywhere appropriate - Marshal an instance to JSON and print it; unmarshal it back and confirm equality
- Set
Replicastonilvs0and 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
mathutilspackage from Session 9 - Write a table-driven test for
Addcovering: positive numbers, negative numbers, zero, and one edge case you think of yourself - Run
go test -v ./...and confirm all cases pass - Deliberately break
Addand 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
Desiredstruct and anActualstruct (both withName 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
Depositneed a pointer receiver butString()doesn't? - Why is
Replicasa*int32and not anint32? - What's the difference between
errors.Isanderrors.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
rangeover anilslice vs anilmap?
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
Post a Comment