-
Notifications
You must be signed in to change notification settings - Fork 0
Context
The Context component is the heart of how the Controller Framework handles your custom resources safely. Think of it as a smart wrapper that not only gives you access to your custom resource, but also handles all the tricky concurrency and status patching logic that Kubernetes controllers need.
Building Kubernetes controllers properly is harder than it looks. You're dealing with concurrent reconciliation loops, complex status patching requirements, and the need to share data between different steps in your reconciliation process.
The concurrency problem is real. Multiple reconciliation requests can be running at the same time for different resources, and even for the same resource in some cases. Without careful management, you end up with race conditions where one reconciliation overwrites changes from another, or worse, corrupts the resource state entirely.
Status patching is another headache. Kubernetes wants you to patch the status subresource rather than just updating the whole object, which means you need to track what the resource looked like originally versus what changes you've made. Getting this wrong leads to lost status updates or conflicts with other controllers.
Sharing data between steps becomes essential as your controller grows more sophisticated. Maybe you need to validate something with an external API in one step, then use that validation result in three other steps. Without a clean way to pass data around, you end up with messy global variables or repeated expensive operations.
The Framework's Context handles all of this complexity behind the scenes. When you call ctx.GetCustomResource(), you're getting concurrency-safe access to your resource without worrying about concurrent modifications. The Context automatically tracks the original "clean" state of your resource so that when you're ready to update the status, it can generate the perfect patch for you. And if you need to share data between steps, Context provides a type-safe way to do that without breaking the abstraction.
type Context[K client.Object] interface {
context.Context // Standard Go context
ImplementsCustomResource[K] // Custom resource management
}
type ContextWithData[K client.Object, D any] struct {
Context[K] // All Context functionality
Data D
}
func (c *ContextWithData[K, D]) Data() D {
return c.Data
}The Context wraps a standard Go context.Context and embeds CustomResource[K] for resource management. When you need additional data, ContextWithData extends this with type-safe access to your custom data through direct field access.
The Framework now uses a fully generic approach where both the custom resource type K and the data type D are carried through the type system. This means that when you create a ContextWithData[*MyResource, *MyDataType], every function that accepts this context gets complete compile-time type safety for both the resource and the data.
Unlike the previous design that required runtime type conversions, the new approach gives you direct access to your typed data without any reflection or type assertions. When you access ctx.Data, you get back exactly the type you specified, and the compiler enforces this throughout your entire reconciliation pipeline.
Using the full generic types everywhere is possible, but it's highly encouraged to define type aliases in your API package to simplify usage throughout your codebase. You can define three key alias types that make working with the Framework much cleaner:
// In your api/v1/types.go or similar
package v1
import ctrlfwk "github.com/u-ctf/controller-fwk"
// TestContext is the context type used for Test controllers
// This is an alias to simplify usage in other packages
type TestContext = *ctrlfwk.ContextWithData[*Test, int]
// TestDependency is the dependency type used for Test controllers
// This is an alias to simplify usage in other packages
type TestDependency = ctrlfwk.GenericDependency[*Test, TestContext]
// TestResource is the resource type used for Test controllers
// This is an alias to simplify usage in other packages
type TestResource = ctrlfwk.GenericResource[*Test, TestContext]
// TestStep is the step type used for Test controllers
// This is an alias to simplify usage in other packages
type TestStep = ctrlfwk.Step[*Test, TestContext]With these aliases, you can write clean, readable code throughout your controller. Notice how the data type can be as simple as an int for tracking state across reconciliation steps.
func (r *TestReconciler) GetDependencies(ctx TestContext, req ctrl.Request) ([]TestDependency, error) {
// Clean, typed access to both resource and data
test := ctx.GetCustomResource()
step := ctx.Data // Direct access to int data
return []TestDependency{
NewSecretDependency(ctx, r),
}, nil
}
func validateStep() TestStep {
return TestStep{
Name: "validate",
Step: func(ctx TestContext, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
// Direct typed access without conversions
resource := ctx.GetCustomResource()
currentStep := ctx.Data
// Your validation logic here
logger.Info("Processing", "step", currentStep)
return ctrlfwk.ResultSuccess()
},
}
}Most controllers start simple, and Context makes that easy. When you create a new Framework context from your standard Go context, you're getting all the concurrency safety and status patching capabilities without any additional complexity.
For simple examples without custom data, you can also define basic step aliases:
// Basic aliases for controllers without custom data
type BasicTestContext = ctrlfwk.Context[*testv1.Test]
type BasicTestStep = ctrlfwk.Step[*testv1.Test, BasicTestContext]func (reconciler *TestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// Create framework context from standard Go context
fwkCtx := ctrlfwk.NewContext[*testv1.Test](ctx, reconciler)
// Use in stepper
stepper := ctrlfwk.NewStepperFor(fwkCtx, logger).
WithStep(ctrlfwk.NewFindControllerCustomResourceStep(fwkCtx, reconciler)).
WithStep(myCustomStep()).
Build()
return stepper.Execute(fwkCtx, req)
}Once you have a Framework context, accessing your custom resource is straightforward:
func myValidationStep() BasicTestStep {
return BasicTestStep{
Name: "validate-test",
Step: func(ctx BasicTestContext, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
// Concurrency-safe access to the custom resource
test := ctx.GetCustomResource()
// Modify the resource safely
test.Status.Ready = false
// The framework handles concurrency internally
return ctrlfwk.ResultSuccess()
},
}
}The real magic happens when you need to update the status. Context has been tracking your changes behind the scenes, so generating the right patch is effortless:
func updateStatusStep(reconciler *TestReconciler) BasicTestStep {
return BasicTestStep{
Name: "update-status",
Step: func(ctx BasicTestContext, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
test := ctx.GetCustomResource()
// Modify status
test.Status.Ready = true
test.Status.Phase = "Running"
// Patch status using tracked changes
if err := ctrlfwk.PatchStatus(ctx, reconciler, test); err != nil {
return ctrlfwk.ResultInError(err)
}
return ctrlfwk.ResultSuccess()
},
}
}As your controller becomes more sophisticated, you'll find yourself needing to share information between steps. Maybe you're validating with multiple external APIs, or you need to coordinate several resource creations, or you want to collect metrics throughout the reconciliation process. This is where ContextWithData comes in.
The idea is simple: alongside your custom resource, you can attach any data structure you want to the context. This data travels with the context through all your steps, giving you a clean way to share state without resorting to global variables or complex parameter passing.
Let's look at a practical example from the Framework's own test suite. In this case, we use a simple integer to track processing steps across dependency reconciliation:
// From the real test implementation
func NewSecretDependency(ctx TestContext, reconciler ReconcilerWithEventRecorder[*Test]) TestDependency {
cr := ctx.GetCustomResource()
return ctrlfwk.NewDependencyBuilder(ctx, &corev1.Secret{}).
WithName(cr.Spec.Dependencies.Secret.Name).
WithNamespace(cr.Spec.Dependencies.Secret.Namespace).
WithOptional(false).
WithIsReadyFunc(func(secret *corev1.Secret) bool {
return secret.Data["ready"] != nil
}).
WithWaitForReady(true).
WithAfterReconcile(func(ctx TestContext, resource *corev1.Secret) error {
// Directly modify the context data
ctx.Data = 3
if resource.Name == "" {
reconciler.Eventf(cr, "Warning", "SecretNotFound", "The required Secret was not found")
return SetConditionNotFound(ctx, reconciler)
}
if !isSecretReady(resource) {
reconciler.Eventf(cr, "Warning", "SecretNotReady", "The required Secret is not ready")
return SetConditionNotReady(ctx, reconciler)
}
return CleanupStatusOnOK(ctx, reconciler)
}).
Build()
}Here's how you set up a context with custom data in your reconciler:
func (reconciler *TestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// Initialize your shared data (in this case, a simple int to track steps)
data := 0
// Create context with custom data
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, data)
stepper := ctrlfwk.NewStepperFor(fwkCtx, logger).
WithStep(ctrlfwk.NewFindControllerCustomResourceStep(fwkCtx, reconciler)).
WithStep(processingStep()).
WithStep(finalizeStep()).
Build()
return stepper.Execute(fwkCtx, req)
}For more complex scenarios, you might want a richer data structure:
// Define your shared data structure
type ReconciliationData struct {
DatabaseConnection *sql.DB
ExternalAPIClient *http.Client
ValidationResults map[string]bool
ProcessingStats struct {
StartTime time.Time
StepsExecuted int
ErrorCount int
}
}
func (reconciler *TestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// Initialize your shared data
data := &ReconciliationData{
DatabaseConnection: reconciler.dbConnection,
ExternalAPIClient: reconciler.apiClient,
ValidationResults: make(map[string]bool),
ProcessingStats: struct {
StartTime time.Time
StepsExecuted int
ErrorCount int
}{
StartTime: time.Now(),
},
}
// Create context with custom data
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, data)
// Continue with your stepper setup...
}In your steps, accessing this shared data is now fully typed and requires no conversions:
// Define a type alias for cleaner signatures
type TestContextWithData = *ctrlfwk.ContextWithData[*testv1.Test, int]
func processingStep() TestStep {
return TestStep{
Name: "processing",
Step: func(ctx TestContextWithData, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
// Direct access to both custom resource and shared data
test := ctx.GetCustomResource()
currentStep := ctx.Data // Direct field access, no method call
logger.Info("Processing", "currentStep", currentStep, "testName", test.Name)
// Modify shared data
ctx.Data = currentStep + 1
return ctrlfwk.ResultSuccess()
},
}
}For the more complex data structure:
type ComplexContextWithData = *ctrlfwk.ContextWithData[*testv1.Test, *ReconciliationData]
func validateWithExternalAPIStep() TestStep {
return TestStep{
Name: "validate-external-api",
Step: func(ctx ComplexContextWithData, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
// Direct access to both custom resource and shared data
test := ctx.GetCustomResource()
data := ctx.Data // Direct field access to struct
// Use shared data
client := data.ExternalAPIClient
resp, err := client.Get(fmt.Sprintf("/validate/%s", test.Spec.Image))
if err != nil {
data.ProcessingStats.ErrorCount++
return ctrlfwk.ResultInError(err)
}
defer resp.Body.Close()
// Store validation result for other steps
data.ValidationResults["image"] = resp.StatusCode == 200
data.ProcessingStats.StepsExecuted++
return ctrlfwk.ResultSuccess()
},
}
}Understanding the concurrency safety guarantees helps you write confident controller code. Within a single reconciliation run, you can modify your custom resource freely. The Context ensures you're always working with the same instance, and different reconciliation runs are completely isolated from each other.
The "clean" resource that Context maintains is your safety net for status patching. Every time you call GetCleanCustomResource(), you get a deep copy of how the resource looked when the reconciliation started. This means you can't accidentally modify it, and the Framework can always generate accurate patches by comparing your current resource state with this clean baseline.
Custom data follows the same isolation rules. Within one reconciliation, all your steps share the same data instance, so you don't need any locking or synchronization. Different reconciliations get completely separate data instances, so there's no cross-talk between different resource processing.
The Context still behaves like a normal Go context for everything else. Cancellation, deadlines, and context values all work exactly as you'd expect.
Start simple. If your controller only needs basic custom resource access, stick with the basic Context. You can always upgrade to ContextWithData later when your needs grow. There's no point in over-engineering from day one.
Think about your data structure. When you do need custom data, organize it thoughtfully. Group related things together, separate external dependencies from processing state, and avoid the temptation to stuff everything into one giant interface{}. Future you will thank present you for the clarity.
Initialize everything upfront. Don't create your custom data structure with half the fields nil and hope you remember to initialize them later. Set up maps, slices, and external connections right when you create the context data. This prevents confusing nil pointer panics deep in your step functions.
// Initialize all necessary fields
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
data := &ReconciliationData{
APIClients: make(map[string]*http.Client),
ValidationResults: make(map[string]ValidationResult),
ResourceStates: make(map[string]ResourceState),
Metrics: ProcessingMetrics{
StartTime: time.Now(),
},
Trace: make([]StepTrace, 0),
}
// Populate external dependencies
data.APIClients["auth"] = r.authClient
data.APIClients["billing"] = r.billingClient
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, data)
// ...
}Use consistent context types. With the new fully generic approach, you should be consistent about which context type your steps expect. If you create a ContextWithData, all your steps should expect the same typed context:
// Define your context and step type aliases once
type MyResourceContextWithData = *ctrlfwk.ContextWithData[*MyResource, *ReconciliationData]
type MyResourceStep = ctrlfwk.Step[*MyResource, MyResourceContextWithData]
func stepThatUsesData() MyResourceStep {
return MyResourceStep{
Name: "data-aware-step",
Step: func(ctx MyResourceContextWithData, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
resource := ctx.GetCustomResource()
data := ctx.Data // Direct field access
logger.Info("Using custom data", "stepCount", data.Metrics.StepsExecuted)
return ctrlfwk.ResultSuccess()
},
}
}Progressive enhancement is your friend. Start with the basic Context, add shared state when you need it, then evolve into richer data structures as your controller grows. There's no shame in refactoring your context usage as requirements change.
External resource management is a great use case for custom data. Instead of passing database connections and API clients through individual function parameters, stick them in the context data where all your steps can reach them easily:
type ExternalResources struct {
S3Client *s3.S3
DatabaseConn *sql.DB
CacheClient *redis.Client
}
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
resources := &ExternalResources{
S3Client: r.s3Client,
DatabaseConn: r.dbConnection,
CacheClient: r.redisClient,
}
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, resources)
// Steps can now access all external resources safely
}Conditional processing coordination becomes much cleaner when you can set flags in early steps and check them in later steps:
type ProcessingFlags struct {
SkipValidation bool
ForceUpdate bool
DryRun bool
DebugMode bool
}
// Define context and step type aliases
type MyResourceWithFlags = *ctrlfwk.ContextWithData[*MyResource, *ProcessingFlags]
type MyResourceStep = ctrlfwk.Step[*MyResource, MyResourceWithFlags]
// Early step sets flags
func analyzeResourceStep() MyResourceStep {
return MyResourceStep{
Name: "analyze",
Step: func(ctx MyResourceWithFlags, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
resource := ctx.GetCustomResource()
flags := ctx.Data // Direct field access
// Set flags based on resource annotations
flags.DebugMode = resource.Annotations["debug"] == "true"
flags.DryRun = resource.Annotations["dry-run"] == "true"
return ctrlfwk.ResultSuccess()
},
}
}
// Later steps use flags
func processResourceStep() MyResourceStep {
return MyResourceStep{
Name: "process",
Step: func(ctx MyResourceWithFlags, logger logr.Logger, req ctrl.Request) ctrlfwk.StepResult {
flags := ctx.Data
if flags.DryRun {
logger.Info("Dry run mode, skipping actual processing")
return ctrlfwk.ResultSuccess()
}
// Normal processing...
return ctrlfwk.ResultSuccess()
},
}
}The most common mistake is inconsistent context types. If you create a ContextWithData[*MyResource, *MyDataType] but your steps expect a basic Context[*MyResource], you'll get compilation errors. Make sure all your steps that work together use the same context type signature.
Another frequent issue is forgetting to initialize your custom data properly. Passing nil as your data will cause problems later when steps try to use it. Even if you're not sure what data you need yet, create an empty struct. You can always add fields later.
// Wrong, passing nil data
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, nil) // Will cause panics later
// Right, initialize empty data structure
data := &ReconciliationData{
ValidationResults: make(map[string]bool),
ProcessingStats: ProcessingStats{},
}
fwkCtx := ctrlfwk.NewContextWithData(ctx, reconciler, data)Finally, don't mix up the clean resource with the current resource. The clean resource is read-only and exists purely for patch generation. The current resource is what you modify. Trying to modify the clean resource won't work and will confuse you when your changes don't stick.
// Wrong, modifying the clean resource
clean := ctx.GetCleanCustomResource()
clean.Status.Ready = true // This won't persist
// Right, modifying the current resource
current := ctx.GetCustomResource()
current.Status.Ready = true // This will be included in status patchesContext is designed to grow with your needs. Start simple with basic typed contexts, add custom data when you need to share state between steps, and use type aliases to keep your code clean and readable. The fully generic design ensures you get compile-time safety throughout your reconciliation pipeline while the Framework handles all the hard concurrency and patching problems behind the scenes. With this foundation, you can focus on your business logic rather than wrestling with Kubernetes controller mechanics.