controller

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 21 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 add 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.

  • 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

This section is empty.

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 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.

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
}

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 add 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