The component package provides a structured way to manage logical features in a Kubernetes operator by grouping
related resources into Components.
A Component acts as a single behavioral unit: it reconciles multiple resources, manages their shared lifecycle, and reports their aggregate health through one condition on the owner CRD.
In complex operators, the same reconciliation patterns get reimplemented for every feature. Each controller coordinates its own resources, manages its own lifecycle (rollout, suspension, degradation), and reports status in its own way. The logic is duplicated but never identical, because there is no shared structure enforcing consistency.
Most teams do try to organize. Resource construction moves into pkg/ and concerns get split into separate files:
controllers/
├── frontend_controller.go # Orchestrates create/update/delete/suspend/status for frontend
└── backend_controller.go # Orchestrates create/update/delete/suspend/status for backend
pkg/
├── frontend/
│ ├── deployment.go # Constructs the Deployment
│ ├── service.go # Constructs the Service
│ └── resources.go # Wires resources together
└── backend/
├── deployment.go
└── configmap.go
This moves files around but doesn't change the underlying problem. Each controller still reimplements the same lifecycle
patterns in slightly different ways. Version-specific behavior and feature flags compound things further: a probe format
changes in v1.3, so pkg/frontend/deployment.go gains an if version < "1.3" branch. A tracing sidecar is
feature-gated, so that lands in the same file, or the controller, or a new features.go, wherever the last author
decided. Conditional logic accumulates until the only way to know what a resource actually looks like is to run the
operator and inspect the output.
The component model replaces this with a layout where each concern has exactly one home:
controllers/
├── frontend_controller.go # Builds components, calls Reconcile
└── backend_controller.go
pkg/components/
├── web-interface/
│ ├── component.go # Assembles primitives into a component
│ ├── resources/
│ │ ├── deployment.go # Baseline Deployment definition
│ │ └── service.go # Baseline Service definition
│ └── features/
│ ├── tracing.go # Mutation: adds tracing sidecar
│ ├── tracing_test.go
│ ├── legacy_probes.go # Mutation: version-gated probe adjustment
│ └── legacy_probes_test.go
└── api-server/
├── component.go
├── resources/
│ ├── deployment.go
│ └── configmap.go
└── features/
├── rate_limiting.go
└── rate_limiting_test.go
Lifecycle behavior (rollout, suspension, status reporting) is handled by the framework, so controllers no longer reimplement it independently. Version-specific behavior and feature flags are expressed as isolated mutations, each in its own file with its own tests, rather than conditional branches layered into resource definitions. The baseline definition for each resource is always the canonical desired state, readable on its own without tracing through every mutation that might apply to it.
Components are constructed through a builder. The builder collects resource registrations, configuration, and lifecycle
flags, then produces an immutable Component ready for reconciliation.
comp, err := component.NewComponentBuilder().
WithName("web-interface").
WithConditionType("WebInterfaceReady").
WithResource(deployment, component.ResourceOptions{}).
WithResource(configMap, component.ResourceOptions{ReadOnly: true}).
WithResource(oldService, component.ResourceOptions{Delete: true}).
WithGracePeriod(5 * time.Minute).
Suspend(owner.Spec.Suspended).
Build()
if err != nil {
return err
}Each resource is registered with a ResourceOptions struct that controls how the component interacts with it:
| Option | Behavior |
|---|---|
ResourceOptions{} (default) |
Managed: created or updated; health contributes to condition |
ResourceOptions{ReadOnly: true} |
Read-only: fetched but never modified; health still contributes |
ResourceOptions{Delete: true} |
Delete-only: removed from the cluster if present; does not contribute to health |
ResourceOptions{ParticipationMode: ParticipationModeAuxiliary} |
The resource's health does not contribute to the component condition. The component can become Ready regardless of this resource's state |
comp.Reconcile(ctx, recCtx) runs a six-phase process on every call:
Phase 1: Suspension check. If the component is marked suspended, it calls Suspend() on all managed resources that
support suspension (create/update resources, not read-only ones), updates the condition, then processes any pending
deletions and returns. The remaining phases are skipped.
Phase 2: Resource synchronization. All managed resources are created or updated to match their desired state. Each resource gets a controller owner reference pointing to the owner CRD, unless the resource is cluster-scoped and the owner is namespace-scoped, in which case the reference is automatically skipped (see Cluster-Scoped Resources).
Phase 3: Read-only resource fetching. Read-only resources are fetched from the cluster so their current state is available for health evaluation.
Phase 4: Data extraction. Any resource implementing DataExtractable has ExtractData() called to harvest data
from the synchronized cluster state before condition evaluation.
Phase 5: Status aggregation and condition update. The health of each resource is collected, the grace period is consulted, and a single aggregate condition is written to the owner object's status.
Phase 6: Resource deletion. Resources registered for deletion are removed from the cluster.
When a component manages cluster-scoped resources (e.g., ClusterRole, PersistentVolume) and the owner CRD is
namespace-scoped, the framework automatically skips setting a controller owner reference on those resources. This is
a Kubernetes API constraint: a namespace-scoped object cannot own a cluster-scoped object.
The scope of both the owner and the resource is determined at reconcile time using the cluster's REST mapper. No configuration is needed; the framework detects the incompatibility and logs an info-level message.
Garbage collection caveat: Without an owner reference, cluster-scoped resources are not automatically deleted when the owner is removed. To ensure cleanup, either:
- Register the resource with
ResourceOptions{Delete: true}so it is removed during reconciliation when no longer needed. - Use a finalizer on the owner CRD to clean up cluster-scoped resources before the owner is deleted.
If the owner CRD is itself cluster-scoped, owner references are set normally on all resources regardless of their scope.
The status values a component reports depend on which lifecycle interfaces its resources implement. The component aggregates across all registered resources and surfaces the most critical state.
Reported by long-running workloads (Deployments, StatefulSets, DaemonSets):
| State | Meaning |
|---|---|
Healthy |
The resource has reached its desired state |
Creating |
The resource is being provisioned for the first time |
Updating |
The resource is being modified with new configuration |
Scaling |
The resource is changing its replica count |
Failing |
The resource is failing to converge to its desired state |
Reported by run-to-completion resources (Jobs, tasks):
| State | Meaning |
|---|---|
Completed |
The resource finished successfully |
TaskRunning |
The resource is currently executing |
TaskPending |
The resource is waiting to start |
TaskFailing |
The resource finished with an error |
Reported by integration resources whose readiness depends on external systems (Services, Ingresses, Gateways, CronJobs):
| State | Meaning |
|---|---|
Operational |
The resource is fully operational |
OperationPending |
The resource is waiting on an external dependency |
OperationFailing |
The resource failed to reach an operational state |
Resources that implement none of the above interfaces are considered ready as long as they exist in the cluster.
When a component has a grace period configured and a Graceful resource has not reached its target state within that
period, the Graceful interface determines the post-expiry severity:
| State | Meaning |
|---|---|
Healthy |
The resource is healthy (grace period expired without issue) |
Degraded |
The resource is partially functional or convergence is taking longer than expected |
Down |
The resource is completely non-functional |
Reported during intentional deactivation:
| State | Meaning |
|---|---|
PendingSuspension |
Suspension is acknowledged but has not started |
Suspending |
Resources are actively being scaled down or cleaned up |
Suspended |
All resources have reached their suspended state |
When aggregating across multiple resources, the most critical state wins:
Error/Down/Degraded: something is wrong- Suspension states: the component is intentionally inactive
- Converging states (
Creating,Updating,Scaling,TaskRunning,TaskPending,OperationPending): the component is progressing Healthy/Completed/Operational: all resources are in their target state
The grace period defines how long a component may remain in a converging state (Creating, Updating, Scaling)
before transitioning to Degraded or Down.
component.NewComponentBuilder().
WithGracePeriod(5 * time.Minute).
// ...During the grace period the component reports its real converging state, not a failure. After the period expires, if the
component is still not Ready, the framework escalates to Degraded or Down based on resource health.
This prevents spurious failure alerts during normal operations like rolling updates.
Suspension allows a component to be intentionally deactivated without deleting its configuration. When Suspend(true)
is set on the builder:
- The component calls
Suspend()on allSuspendableresources. - Each resource performs its suspension behavior, typically scaling to zero replicas.
- The component polls
SuspensionStatus()on each resource. - Once all resources report
Suspended, the condition transitions toSuspended.
Resources that do not yet exist in the cluster are created in their suspended state (with suspension mutations already applied). For example, a Deployment is created with zero replicas. This ensures the resource is immediately available when suspension ends.
Resources with DeleteOnSuspend enabled are not created if they are already absent. Their absence is treated as
already suspended. This avoids a create→delete churn loop on every reconcile while the component remains suspended.
Resources that are not Suspendable are left in place.
ReconcileContext carries all dependencies for a reconciliation pass. Pass it from your controller on each call:
recCtx := component.ReconcileContext{
Client: r.Client, // sigs.k8s.io/controller-runtime/pkg/client
Scheme: r.Scheme, // *runtime.Scheme
Recorder: r.Recorder, // record.EventRecorder
Metrics: r.Metrics, // component.Recorder (condition metrics)
Owner: owner, // the CRD that owns this component
}
err = comp.Reconcile(ctx, recCtx)Dependencies are passed explicitly so components remain testable and decoupled from global state.
The Metrics field is required. The framework records Prometheus metrics for every condition state transition during
reconciliation. The recorder implementation is provided by
go-crd-condition-metrics.
Keep controllers thin. The controller's job is to fetch the owner CRD, decide which components should exist, and
call Reconcile on each. Resource-level logic belongs in the component and its primitives.
One component per user-visible feature. If you want a WebInterfaceReady and a DatabaseReady condition on your
CRD, those are two separate components.
Group by lifecycle. Resources that must live and die together belong in the same component. If they have independent lifecycles, split them.
Use ParticipationModeAuxiliary for non-critical resources. A metrics exporter sidecar should not block your
primary component from becoming Ready. All resource types default to ParticipationModeRequired, so set
ParticipationModeAuxiliary explicitly when a resource's health should not gate the component condition.