Help us shape the future of Go! Take the 2026 Go Developer Survey

controller

package
v1.0.12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: Apache-2.0 Imports: 36 Imported by: 0

Documentation

Overview

Package controller provides opinionated runtime helpers for controller-runtime reconcilers. It is the operator-flavored sibling of forge/pkg/contractkit: a runtime library that the thin shims emitted by `forge scaffold crd <name>` delegate to.

The motivation is the same as contractkit. The previous operator scaffold emitted ~80 lines per CRD of mostly-mechanical reconciler boilerplate (fetch object, NotFound check, finalizer add/remove, status update, manager wiring). That logic is the same across every CRD a project owns, and once it lives in the project tree it is frozen — bumping the implementation needs a re-scaffold of every controller. By moving it into a runtime library, the per-CRD generated file shrinks to ~30 lines (a struct that embeds the generic Reconciler[T], plus the user's domain-logic methods) and the shared reconcile lifecycle can be evolved by bumping forge/pkg.

The library carries:

  • Reconciler[T] — the typed base reconciler. It owns fetch / NotFound / finalizer-add / finalizer-cleanup / dispatch-to-user and exposes ReconcileFunc / FinalizeFunc callbacks for the user's domain logic.

  • Result + Done / Requeue / Stop helper constructors over ctrl.Result, so user code reads as `return controller.Requeue(5*time.Second), nil`.

  • Common predicates: SkipDeletion, HasAnnotation, HasLabel, AnnotationChanged. Each is exported so generated SetupWithManager code can compose them without re-implementing.

  • ClusterClientManager — multi-cluster client cache lifted from control-plane-next/operators/workspace_controller. Generic over scheme; safe for concurrent use.

  • Backoff — capped-exponential helper used by reconcilers that track per-object retry counts.

  • IsTransient — classifies errors that are requeued with bounded exponential backoff for Reconciler.TransientGrace before a persistent outage is returned for normal controller-runtime reporting.

  • UpdateStatus — a status write that retries the conflict a reconciler gets when the informer cache trails its own previous write.

  • controllertest — small envtest harness with skip-friendly New() (returns nil + a Skip if envtest binaries are missing) so unit tests are hermetic by default.

Behavioural fingerprints preserved from the workspace-controller reference:

  • NotFound on fetch maps to (ctrl.Result{}, nil), not an error.
  • Finalizer add does an Update + re-fetch to avoid stale resourceVersion.
  • Finalizer cleanup removes the finalizer with a final Update.
  • When SetupOptions.SkipDeletion is true, deletion events are filtered out by predicate.

Index

Constants

View Source
const DefaultTransientGrace = 5 * time.Minute

DefaultTransientGrace is how long Reconciler.Run tolerates one object's reconcile failing with transient errors before it reports them.

Variables

This section is empty.

Functions

func AnnotationChanged

func AnnotationChanged(key string) predicate.Predicate

AnnotationChanged returns a predicate that fires on Update events where the named annotation's value differs between old and new objects, and on Create events where the annotation is present. Delete and Generic events pass through unchanged.

func HasAnnotation

func HasAnnotation(key string) predicate.Predicate

HasAnnotation returns a predicate that admits an event only if the involved object's metadata.annotations carry `key`. Useful for gating reconciles on opt-in annotations (e.g. "myorg.dev/managed").

func HasLabel

func HasLabel(selector labels.Selector) predicate.Predicate

HasLabel returns a predicate that admits an event only if the involved object's metadata.labels match selector.

func IsTransient

func IsTransient(err error) bool

IsTransient reports whether a reconcile error is one the next attempt can be expected to clear without anybody changing anything.

The distinction it draws is the one an operator's error reporting has to draw. A reconcile that failed because an admission webhook's pod was restarting, because the informer cache was a write behind the API server, or because the API server shed load, failed for a reason that is gone by the next requeue. Reporting each of those as an error pages someone for a blip the controller was already recovering from. A reconcile that failed because the spec is invalid, a referenced object is missing, or Kubernetes RBAC denies the write will fail identically next time, and that is the failure worth reporting.

Classified by type wherever the error carries one:

  • optimistic-concurrency conflicts ("the object has been modified");
  • the API server's own retry signals: ServerTimeout, Timeout (504), TooManyRequests and ServiceUnavailable;
  • transport failures between this process and an API server: connection refused or reset, an EOF on a dropped connection, a network timeout;
  • context cancellation or deadline, which is the manager shutting down or a call outliving its budget;
  • a gRPC status of ResourceExhausted or Unavailable anywhere in the chain: a gRPC backend shedding load or briefly unreachable;
  • an HTTP/2 stream the server reset with ENHANCE_YOUR_CALM or REFUSED_STREAM, its way of telling a client to back off.

And two text matches, for failures the API server reports without a type of their own:

  • an admission webhook the API server could not call is an InternalError whose message reads `failed calling webhook "<name>": ...`;
  • etcd throttling the API server. The API server relays an etcd failure it has no Status for as a 500 whose message is the etcd client's gRPC error text — `rpc error: code = ResourceExhausted desc = stream terminated by RST_STREAM with error code: ENHANCE_YOUR_CALM`, or `etcdserver: too many requests`. Nothing typed survives the hop, so the message is all there is (see isRelayedStorageThrottling).

"Transient" is a claim about one attempt, not about the condition. A webhook misconfigured for good also fails to be called, which is why Reconciler.Run reports a transient error that has persisted past TransientGrace.

func SkipDeletion

func SkipDeletion() predicate.Predicate

SkipDeletion returns a predicate that filters out Delete events. Use this on watches where deletion is handled via the deletion-timestamp path (i.e., a finalizer is in play) — in that case the controller observes the deletion as an Update with non-zero metadata.deletionTimestamp, and the synthesized Delete event that fires after finalizer removal is uninteresting.

func UpdateStatus

func UpdateStatus(ctx context.Context, c client.Client, obj client.Object) error

UpdateStatus writes obj's status subresource, absorbing optimistic- concurrency conflicts.

Why a plain Status().Update is not enough

A reconciler reads its object through the manager's informer cache, and the cache trails the API server. Write status at the end of one reconcile, get requeued a few hundred milliseconds later, and the object handed to the next reconcile can still carry the resourceVersion from BEFORE that write. Its Status().Update is then refused with "the object has been modified" — not because anyone else touched the object, but because the reconciler is racing its own previous write. A reconciler that requeues quickly (a failing reconcile under backoff, a status poll) hits this routinely.

What a retry does, and why it is safe

On a conflict this re-reads the object's CURRENT resourceVersion and retries the same Update with it, after a short backoff that gives the cache time to catch up. It does not merge: the status sent is exactly the status the caller computed.

That is the right semantics for a status subresource because the reconciler that calls this is its only author. The API server ignores everything outside .status on a status write, so taking a newer resourceVersion cannot carry a stale spec or metadata anywhere; and the status itself was computed from what this reconcile observed, with ObservedGeneration saying which generation that was. If the spec moved on meanwhile, the generation change enqueues another reconcile, which writes the status for the new spec.

A conflict that survives the retries is returned. IsTransient classifies it as transient, so a reconciler returning it through Run is requeued rather than reported.

An object deleted in the meantime returns the API server's NotFound, which callers writing the status of an object that may be going away usually wrap in client.IgnoreNotFound.

Types

type Backoff

type Backoff struct {
	// Initial is the delay for attempt 0. Required.
	Initial time.Duration

	// Max is the upper bound on the returned delay. Required.
	Max time.Duration

	// Factor is the multiplicative factor between attempts. Values
	// <= 1 collapse the backoff to Initial.
	Factor float64
}

Backoff is a simple capped-exponential backoff helper. Reconcilers that track per-object retry counts (typically in CRD status) can use it to compute the next requeue delay without importing k8s.io/client-go/util/workqueue.

The zero value is NOT useful; callers should set Initial, Max, and Factor explicitly. A typical configuration:

b := controller.Backoff{
    Initial: 1 * time.Second,
    Max:     5 * time.Minute,
    Factor:  2.0,
}
delay := b.Next(ws.Status.RetryCount)

func (Backoff) Next

func (b Backoff) Next(attempt int) time.Duration

Next returns the backoff delay for `attempt` (0-indexed). attempt 0 returns Initial; each subsequent attempt multiplies by Factor up to Max. Negative attempt is treated as 0.

type ClusterClientManager

type ClusterClientManager struct {
	// contains filtered or unexported fields
}

ClusterClientManager creates and caches controller-runtime clients for multiple Kubernetes clusters. Safe for concurrent use.

Lifted from control-plane-next/operators/workspace_controller/cluster_client_manager.go where it was the production implementation; the only change here is that ClusterConfig is now a parameter type the library owns rather than a project-level config struct.

func NewClusterClientManager

func NewClusterClientManager(scheme *runtime.Scheme, logger *slog.Logger) *ClusterClientManager

NewClusterClientManager returns a ClusterClientManager that uses the provided scheme when building clients for remote clusters. logger may be nil (defaults to slog.Default).

func (*ClusterClientManager) Get

Get returns a cached client for the given cluster config. On first access for a given cluster ID the client is created and cached.

func (*ClusterClientManager) GetDefault

func (m *ClusterClientManager) GetDefault() (client.Client, error)

GetDefault returns the in-cluster client (empty ClusterConfig).

func (*ClusterClientManager) Refresh

func (m *ClusterClientManager) Refresh(clusterID string) error

Refresh evicts the cached client for clusterID, forcing the next Get() to rebuild. Returns nil even if no entry was cached — the caller's intent is "next access should be fresh", and the empty- cache case satisfies that trivially.

type ClusterConfig

type ClusterConfig struct {
	// ID is a stable identifier for the cluster. Used as the cache
	// key. Empty means default / in-cluster.
	ID string

	// KubeconfigPath, when non-empty, is loaded via clientcmd to
	// build the rest config. Empty means in-cluster.
	KubeconfigPath string

	// Context, when non-empty, overrides the current-context in the
	// loaded kubeconfig.
	Context string
}

ClusterConfig describes a remote Kubernetes cluster the operator can schedule workloads onto. The zero value (empty ID, empty KubeconfigPath) means "use the manager's in-cluster config" and is what GetDefaultClient passes.

type FinalizeFunc

type FinalizeFunc[T client.Object] func(ctx context.Context, obj T) error

FinalizeFunc is the optional user-supplied callback invoked when the object is being deleted and Finalizer is set. It runs BEFORE the finalizer is removed; if it returns an error, the finalizer is left in place and controller-runtime requeues.

type ReconcileFunc

type ReconcileFunc[T client.Object] func(ctx context.Context, obj T) (Result, error)

ReconcileFunc is the user-supplied domain-logic callback invoked by Reconciler[T].Reconcile after the object has been fetched and finalizer/deletion bookkeeping has been performed. Returning a non-nil error causes controller-runtime to requeue with backoff.

type Reconciler

type Reconciler[T client.Object] struct {
	// Client is the controller-runtime client used to fetch and
	// patch the reconciled object. Populated by SetupWithManager.
	Client client.Client

	// Scheme is the runtime scheme used by the manager. Populated
	// by SetupWithManager.
	Scheme *runtime.Scheme

	// Recorder is an optional event recorder. When non-nil,
	// Reconciler emits a "Reconciling" / "Reconciled" event around
	// each reconcile attempt.
	Recorder record.EventRecorder

	// Log is the structured logger used by the base. Defaults to
	// slog.Default() when nil.
	Log *slog.Logger

	// Finalizer is the optional finalizer string. When non-empty,
	// Reconcile auto-adds the finalizer on first reconcile and
	// invokes FinalizeFunc on deletion before removing the
	// finalizer.
	Finalizer string

	// TransientGrace is how long one object may keep failing with transient
	// errors (see IsTransient) before Run reports them. During this grace the
	// object is requeued with exponential backoff; once exceeded, the error is
	// returned to controller-runtime so its rate limiter and error reporting
	// take over. Zero uses DefaultTransientGrace. Negative disables the grace.
	TransientGrace time.Duration
	// contains filtered or unexported fields
}

Reconciler is the generic base for typed controller-runtime reconcilers. Per-CRD shims embed it:

type WorkspaceController struct {
    controller.Reconciler[*v1alpha1.Workspace]
    DB        *sql.DB
    Publisher EventPublisher
}

func (r *WorkspaceController) ReconcileSpec(ctx context.Context, w *v1alpha1.Workspace) (controller.Result, error) {
    // user implements domain logic
    return controller.Done(), nil
}

func (r *WorkspaceController) FinalizeSpec(ctx context.Context, w *v1alpha1.Workspace) error {
    // user implements cleanup
    return nil
}

The Reconcile method on the embedded Reconciler is wired by calling SetupWithManager + a small Reconcile entry point (typically generated by `forge scaffold crd`) that delegates to ReconcileFunc / FinalizeFunc.

The zero value is NOT ready to use: at minimum Client and Scheme must be populated by SetupWithManager.

func (*Reconciler[T]) Run

func (r *Reconciler[T]) Run(
	ctx context.Context,
	req ctrl.Request,
	blank T,
	reconcile ReconcileFunc[T],
	finalize FinalizeFunc[T],
) (Result, error)

Run is the entry-point invoked by per-CRD shims. The shim's Reconcile method calls Run, passing the object's blank-T template and the user's ReconcileFunc / FinalizeFunc.

Lifecycle:

  1. Fetch the resource. NotFound → (Done(), nil).
  2. If the object is being deleted AND r.Finalizer is set AND the object carries the finalizer: invoke finalize, remove the finalizer, return.
  3. If the object is NOT being deleted AND r.Finalizer is set AND the finalizer is missing: add the finalizer, re-fetch, then dispatch to reconcile.
  4. Otherwise dispatch to reconcile and return its result.

blank is a freshly-allocated zero value of T (e.g. &v1alpha1.Workspace{}). The shim creates it because Go generics can't allocate concrete types from the type parameter alone (T may be an interface satisfied by a pointer-to-struct).

func (*Reconciler[T]) SetupWithManager

func (r *Reconciler[T]) SetupWithManager(
	mgr ctrl.Manager,
	concrete T,
	rec reconcile.Reconciler,
	opts SetupOptions,
) error

SetupWithManager wires a per-CRD reconciler shim into the manager. The shim must implement reconcile.Reconciler — typically by exposing a Reconcile method that calls Reconciler[T].Run with its own ReconcileSpec / FinalizeSpec callbacks.

Concrete is a freshly-allocated zero value of T (e.g. &v1alpha1.Workspace{}) — see Run's documentation.

type Result

type Result = ctrl.Result

Result is a thin alias of ctrl.Result. The library exports it so user code never has to import sigs.k8s.io/controller-runtime/pkg/reconcile directly — the helpers (Done, Requeue, Stop) are the canonical constructors and they all return Result.

func Done

func Done() Result

Done returns the success / no-requeue Result. Equivalent to ctrl.Result{}.

func Requeue

func Requeue(after time.Duration) Result

Requeue returns a Result that asks controller-runtime to re-enqueue the object after `after`. A zero or negative `after` becomes Requeue: true (the controller-runtime convention for "as soon as possible").

func Stop

func Stop() Result

Stop returns a Result that signals the controller should NOT re-enqueue the object — semantically identical to Done(), but reads more clearly at the call site when the reconciler is deliberately giving up (e.g., because the object is in a terminal failed state and further reconciles would be no-ops).

type SetupOptions

type SetupOptions struct {
	// SkipDeletion, when true, filters Delete events out of the
	// watch. Use this when Finalizer is set and you want the
	// reconcile loop to handle cleanup via the deletion-timestamp
	// path rather than via Delete events.
	SkipDeletion bool

	// ExtraPredicates are AND-composed with any default predicate
	// the library applies. Empty means no extra filtering.
	ExtraPredicates []predicate.Predicate

	// Owns lists secondary resource types whose changes should
	// trigger a reconcile of the primary T. This is the standard
	// controller-runtime "Owns(&corev1.Pod{})" pattern.
	Owns []client.Object

	// Watches lets the operator declare cross-namespace or label-mapped
	// watches that don't fit the parent-owns-child controller-runtime
	// model (where owner references work). Each WatchSpec adds a
	// Watches(...) call to the controller builder with the supplied
	// EventHandler — typically handler.EnqueueRequestsFromMapFunc(...).
	//
	// Use this when secondary resources live in a different namespace
	// than the primary CR (owner references can't cross namespaces) or
	// when a label/annotation is the only link back to the parent.
	Watches []WatchSpec
}

SetupOptions configures how Reconciler[T] is wired into a manager.

type WatchSpec

type WatchSpec struct {
	// Object is a typed empty value (e.g. &corev1.Pod{}) used for
	// scheme registration of the watched GVK.
	Object client.Object

	// Handler converts secondary-resource events into reconcile
	// requests for the primary type. Typically constructed via
	// handler.EnqueueRequestsFromMapFunc.
	Handler handler.EventHandler
}

WatchSpec describes a secondary-resource watch to register on the controller builder. The Object identifies the GVK to watch and the Handler determines how each event maps back to one or more reconcile.Requests on the primary type.

Directories

Path Synopsis
Package controllertest provides a small envtest harness that generated `<crd>_controller_test.go` files use in their TestMain setup.
Package controllertest provides a small envtest harness that generated `<crd>_controller_test.go` files use in their TestMain setup.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL