Watcher Interface
The watcher integration lets the framework register watches dynamically for resource types discovered during reconciliation.
This removes most manual .Owns(...) and .Watches(...) calls when you use the framework's built-in resource and dependency steps.
Core types
type Watcher interface {
ctrl.Manager
AddWatchSource(key WatchCacheKey)
IsWatchingSource(key WatchCacheKey) bool
GetController() controller.TypedController[reconcile.Request]
}
type WatchCache struct {
cache map[WatchCacheKey]bool
controller controller.TypedController[reconcile.Request]
ctrl.Manager
}
Create it with:
WatchCache: ctrlfwk.NewWatchCache(mgr),
Cache options and scope
The dynamic watch cache is still backed by controller-runtime's manager cache.
That means watch scope is decided in two layers:
- The framework decides which GVKs to watch at runtime.
- The manager cache decides which objects of those GVKs are actually cached and observed.
Use the watch-cache option builders when you want to reduce the cache scope for performance, tenancy, or security reasons.
These options are passed when creating the manager, before NewWatchCache(mgr) is used.
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithScheme(scheme).
Build()
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Cache: cacheOptions,
})
if err != nil {
return err
}
Watching a specific namespace
The most common use case is a namespace-scoped operator that should not cache objects cluster-wide.
Use WithDefaultNamespaces(...) when most watched objects should be restricted to one or more namespaces:
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithDefaultNamespaces(map[string]cache.Config{
"tenant-a": {},
}).
Build()
Use this when:
- the operator is deployed per tenant or per environment
- dependencies and managed resources are expected to live in the same namespace set
- you want to avoid creating cluster-wide informers for common types like Secrets or ConfigMaps
You can also scope only one object type to a namespace while leaving others broader:
secretCache := ctrlfwk.NewWatchCacheByObjectBuilder().
WithNamespace("shared-secrets", cache.Config{}).
Build()
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithByObjectFor(&corev1.Secret{}, secretCache).
Build()
Use this when a dependency type lives in a dedicated namespace, such as:
- shared credentials in
shared-secrets - platform-owned ConfigMaps in
platform-system - certificates managed in a central namespace
Watching objects with specific labels
Use WithDefaultLabelSelector(...) to apply a label filter to all cached objects by default:
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithDefaultLabelSelector(labels.SelectorFromSet(labels.Set{
"app.kubernetes.io/part-of": "widget-operator",
})).
Build()
This is useful when the cluster contains many objects of the same type, but your controller should only react to the subset it manages.
For object-specific filtering, combine WithByObjectFor(...) with NewWatchCacheByObjectBuilder():
configMapCache := ctrlfwk.NewWatchCacheByObjectBuilder().
WithLabelSelector(labels.SelectorFromSet(labels.Set{
"tenant": "tenant-a",
"component": "api",
})).
Build()
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithByObjectFor(&corev1.ConfigMap{}, configMapCache).
Build()
Use this when:
- only labelled ConfigMaps or Secrets should trigger reconciles
- the resource type is shared by many controllers in the same cluster
- you already stamp resources with tenancy or ownership labels in your mutators
Combining namespace and label filters
Per-object options can combine both constraints:
secretCache := ctrlfwk.NewWatchCacheByObjectBuilder().
WithNamespace("shared-secrets", cache.Config{
LabelSelector: labels.SelectorFromSet(labels.Set{
"tenant": "tenant-a",
}),
}).
Build()
cacheOptions := ctrlfwk.NewWatchCacheOptionsBuilder().
WithByObjectFor(&corev1.Secret{}, secretCache).
Build()
This is the right pattern when the controller should only watch a narrow subset of a high-cardinality type.
Typical examples:
- only Secrets for one tenant in a shared namespace
- only ConfigMaps for one controller component
- only platform resources carrying an opt-in label
Defaults vs per-object rules
Choose the option level based on how broad the rule is:
WithDefaultNamespaces(...)andWithDefaultLabelSelector(...)for cluster-wide defaults across watched typesWithByObjectFor(...)when only one resource type should be narrowedNewWatchCacheByObjectBuilder()when a specific type needs its own namespace or label rules
In practice, per-object rules are usually safer because they avoid accidentally filtering out unrelated custom resources or dependencies.
Practical caveats
- Cache filters affect what dynamic watches can observe. If an object is outside the configured namespace or label selector, a runtime watch will not see it.
- Configure the manager cache before constructing the reconciler's
WatchCache. - Use label filters only on labels your controller or platform consistently applies.
- When debugging a missing reconcile, verify both the dynamic watch registration and the cache selector scope.
Reconciler setup
Embed WatchCache and implement ReconcilerWithWatcher[K].
type WidgetReconciler struct {
client.Client
ctrlfwk.WatchCache
RuntimeScheme *runtime.Scheme
}
var _ ctrlfwk.ReconcilerWithWatcher[*myv1.Widget] = &WidgetReconciler{}
In SetupWithManager, use Build so you can save the controller back to the watch cache:
func (reconciler *WidgetReconciler) SetupWithManager(mgr ctrl.Manager) error {
ctrler, err := ctrl.NewControllerManagedBy(mgr).
For(&myv1.Widget{}).
Named("widget").
Build(reconciler)
if err != nil {
return err
}
reconciler.WatchCache.SetController(ctrler)
return nil
}
How dynamic watches are added
The framework calls SetupWatch(...) from built-in steps:
NewReconcileResourceStepregisters watches for managed resources.NewResolveDependencyStepregisters watches for dependencies only when managed-by annotations are enabled.
Watches are deduplicated by WatchCacheKey, which is built from the GVK and the watch type.
Metadata-only watching
Watches are registered against metav1.PartialObjectMetadata, not full objects.
That keeps the cache lighter and avoids holding entire resource specs in memory for every watched type.
The default predicate only reacts to meaningful changes:
- update when
resourceVersionchanges - delete always
- create never
Dependencies vs resources
Managed resources and dependencies are wired differently:
- Managed resources enqueue owner requests.
- Dependencies enqueue requests by reading the
dependencies.ctrlfwk.com/managed-byannotation.
That means dependency-triggered reconciles only work when the dependency was configured with WithAddManagedByAnnotation(true).
Example reconciliation pipeline
func (reconciler *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := logf.FromContext(ctx)
fwkCtx := ctrlfwk.NewContext[*myv1.Widget](ctx, reconciler)
stepper := ctrlfwk.NewStepperFor[*myv1.Widget](fwkCtx, logger).
WithStep(ctrlfwk.NewFindControllerCustomResourceStep(fwkCtx, reconciler)).
WithStep(ctrlfwk.NewResolveDynamicDependenciesStep(fwkCtx, reconciler)).
WithStep(ctrlfwk.NewReconcileResourcesStep(fwkCtx, reconciler)).
WithFinalStep(ctrlfwk.NewReadyConditionFinalStep(fwkCtx, reconciler, ctrlfwk.SetReadyConditionFromResult(reconciler))).
Build()
return stepper.Execute(fwkCtx, req)
}
Practical rules
- Use
Build, notComplete, when you need to callWatchCache.SetController. - Dependencies only become watch sources when managed-by annotations are enabled.
- Resource watches work automatically once the reconciler implements
ReconcilerWithWatcher[K].