Custom Resources¶
This guide is for operator authors who need to manage a Kubernetes object that the built-in primitives do not cover. The built-in set handles the common kinds (Deployments, StatefulSets, ConfigMaps, Services, and more) and is highly customizable through status handlers, suspension logic, mutations, and data extractors. Reach for a custom resource only when the kind you manage has no matching primitive:
- A custom CRD defined by your project or a third-party operator.
- A standard Kubernetes kind that the built-in set does not yet wrap.
The pkg/generic package provides the building blocks: it handles reconciliation mechanics, the plan-and-apply mutation
flow, suspension, guards, and data extraction. Your package wraps a generic resource with kind-specific identity,
status, and mutator logic, exactly the way the built-in primitives do.
If your CRD has no typed Go struct
You can manage any CRD without writing a wrapper at all by using the unstructured static primitive
(pkg/primitives/unstructured/static). See Unstructured Primitives. This
guide covers the wrapper pattern, which gives you a typed, self-documenting API for a kind you manage often.
Steps¶
- Choose a resource category
- Define the mutation type alias
- Implement the mutator
- Implement status handlers
- Implement the builder
- Implement the resource
- Define feature mutations
- Register with a component
A custom resource is three wrapped pieces. The builder configures and validates, producing a resource; the resource delegates lifecycle methods to a generic base; the mutator records and applies changes to the Kubernetes object.
flowchart LR
Builder -->|Build| Resource
Resource -->|owns base| Base["generic.*Resource"]
Resource -->|Mutate constructs| Mutator
Mutator -->|Apply| Object["Kubernetes object"]
| Your type | Wraps |
|---|---|
Builder |
generic.WorkloadBuilder[T, *Mutator] (or one per category) |
Resource |
generic.WorkloadResource[T, *Mutator] (or one per category) |
Mutator |
Implements generic.FeatureMutator |
The examples below build a MessageQueue CRD (messagequeues.example.io/v1), a long-running broker with replica-based
health, so it is a workload. Step 4 and the
category notes show the other categories.
1. Choose a resource category¶
The framework defines four resource categories. Each maps to a generic resource type with a different set of lifecycle interfaces. For the full description of each interface and the runtime string values it reports, see Lifecycle Interfaces.
| Category | Generic type | Lifecycle interfaces | Use when |
|---|---|---|---|
| Workload | generic.WorkloadResource |
Alive, Graceful, Suspendable, Guardable, DataExtractable |
Long-running processes with replica-based health |
| Static | generic.StaticResource |
Guardable, DataExtractable |
Configuration objects with no runtime health semantics |
| Task | generic.TaskResource |
Completable, Suspendable, Guardable, DataExtractable |
Run-to-completion workloads |
| Integration | generic.IntegrationResource |
Operational, Graceful, Suspendable, Guardable, DataExtractable |
External-dependency objects (services, ingresses) |
In addition to the category-specific interfaces, every generic resource also satisfies
concepts.Previewable and concepts.MutationInspector, and your wrapper exposes
both. They are covered in Step 6.
The rest of the guide uses Workload as the primary example. The pattern is identical for the other categories, with fewer handlers to implement.
2. Define the mutation type alias¶
Create a type alias for feature.Mutation parameterized on your mutator. This gives callers a clean name when defining
feature mutations, mirroring the Mutation alias each built-in primitive exports.
package messagequeue
import "github.com/sourcehawk/operator-component-framework/pkg/feature"
// Mutation defines a feature-gated mutation applied to a MessageQueue resource.
type Mutation = feature.Mutation[*Mutator]
3. Implement the mutator¶
The mutator records mutation intent and applies it in a single controlled pass. It must implement
generic.FeatureMutator:
Apply() executes all recorded mutations against the underlying object. NextFeature() advances to a new feature
scope; the framework calls it between each registered mutation to maintain per-feature ordering boundaries.
Plan and apply¶
Mutator methods record intent rather than modifying the object directly. The framework calls Apply() once, after
all mutations have been recorded. This is the same plan-and-apply model the built-in primitives use; see
The Mutation System for the rationale and the ordering guarantees.
package messagequeue
import (
examplev1 "example.io/api/v1"
)
// featurePlan groups all mutation operations recorded by a single feature.
type featurePlan struct {
replicaOps []func(*examplev1.MessageQueueSpec)
configOps []func(*examplev1.MessageQueueSpec)
}
// Mutator records mutation intent for a MessageQueue and applies changes in one pass.
//
// It maintains feature boundaries: each feature's mutations are planned together
// and applied in the order the features were registered.
type Mutator struct {
current *examplev1.MessageQueue
plans []featurePlan
active *featurePlan
}
// NewMutator creates a new Mutator for the given MessageQueue.
//
// The constructor creates the initial feature scope, so mutations can be
// registered immediately.
func NewMutator(current *examplev1.MessageQueue) *Mutator {
m := &Mutator{current: current}
m.NextFeature()
return m
}
// NextFeature advances to a new feature planning scope. All subsequent mutation
// registrations are grouped into this scope until NextFeature is called again.
//
// The first scope is created automatically by NewMutator. The framework calls
// this method between mutations to maintain per-feature ordering semantics.
func (m *Mutator) NextFeature() {
m.plans = append(m.plans, featurePlan{})
m.active = &m.plans[len(m.plans)-1]
}
// SetMaxConnections records intent to set the maximum connection count.
func (m *Mutator) SetMaxConnections(count int32) {
m.active.configOps = append(m.active.configOps, func(spec *examplev1.MessageQueueSpec) {
spec.MaxConnections = count
})
}
// SetReplicas records intent to set the replica count.
func (m *Mutator) SetReplicas(replicas int32) {
m.active.replicaOps = append(m.active.replicaOps, func(spec *examplev1.MessageQueueSpec) {
spec.Replicas = &replicas
})
}
// Apply executes all recorded mutations against the MessageQueue.
// Features are applied in registration order. Within each feature,
// replica operations are applied before config operations.
func (m *Mutator) Apply() error {
for _, plan := range m.plans {
for _, op := range plan.replicaOps {
op(&m.current.Spec)
}
for _, op := range plan.configOps {
op(&m.current.Spec)
}
}
return nil
}
Mutator design
- Record, don't mutate. Methods like
SetMaxConnectionsappend to the active feature plan. They do not touchcurrentdirectly. - Scope per feature.
NextFeature()opens a new plan scope. The framework calls it between registered mutations so each feature's operations are grouped and applied in registration order.Apply()iterates plans sequentially, so each feature sees the object as modified by all previous features. - Keep it typed. Expose domain-specific methods (
SetMaxConnections,SetReplicas) rather than generic ones. This makes feature mutations self-documenting and keeps callers on the plan-and-apply path. The built-in workload mutators follow the same approach, layering convenience wrappers such asEnsureReplicasover lower-level edits.
4. Implement status handlers¶
Status handlers translate your CRD's runtime state into framework status types. Which handlers you need depends on the category.
Required versus optional handlers¶
The generic builder's Build() fails if the convergence handler is missing. For workload and task resources this is the
converging-status handler registered with WithCustomConvergeStatus; for integration resources it is the
operational-status handler registered with WithCustomOperationalStatus. Every other handler defaults to a safe value
at the generic layer:
- Grace status defaults to
Healthy(workload and integration only). - Suspension status defaults to
Suspended. - The suspension mutation defaults to a no-op.
- The delete-on-suspend decision defaults to
false.
Register custom handlers only where your CRD has domain-specific behavior. The workload handlers below mirror what
pkg/primitives/deployment registers by default.
package messagequeue
import (
"fmt"
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
examplev1 "example.io/api/v1"
)
// DefaultConvergingStatusHandler reports whether the MessageQueue has reached its desired state.
func DefaultConvergingStatusHandler(
op concepts.ConvergingOperation, mq *examplev1.MessageQueue,
) (concepts.AliveStatusWithReason, error) {
desired := int32(1)
if mq.Spec.Replicas != nil {
desired = *mq.Spec.Replicas
}
// Defer to the generation check first, so readiness fields are not read while
// the CRD's own controller is still behind the latest spec.
if status := concepts.StaleGenerationStatus(
op, mq.Status.ObservedGeneration, mq.Generation, "messagequeue",
); status != nil {
return *status, nil
}
if mq.Status.ReadyReplicas == desired {
return concepts.AliveStatusWithReason{
Status: concepts.AliveConvergingStatusHealthy,
Reason: "All replicas are ready",
}, nil
}
var status concepts.AliveConvergingStatus
switch op {
case concepts.ConvergingOperationCreated:
status = concepts.AliveConvergingStatusCreating
case concepts.ConvergingOperationUpdated:
status = concepts.AliveConvergingStatusUpdating
default:
status = concepts.AliveConvergingStatusScaling
}
return concepts.AliveStatusWithReason{
Status: status,
Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", mq.Status.ReadyReplicas, desired),
}, nil
}
// DefaultGraceStatusHandler reports health once the grace period has expired.
func DefaultGraceStatusHandler(mq *examplev1.MessageQueue) (concepts.GraceStatusWithReason, error) {
desired := int32(1)
if mq.Spec.Replicas != nil {
desired = *mq.Spec.Replicas
}
// Use == rather than >= so grace and convergence agree on replica state.
// Both handlers evaluate the same object in the same reconcile loop, so grace
// must not return Healthy for a state convergence considers non-healthy
// (e.g. ReadyReplicas > desired during scale-down).
if mq.Status.ReadyReplicas == desired {
return concepts.GraceStatusWithReason{
Status: concepts.GraceStatusHealthy,
Reason: "All replicas are ready",
}, nil
}
if mq.Status.ReadyReplicas > 0 {
return concepts.GraceStatusWithReason{
Status: concepts.GraceStatusDegraded,
Reason: "MessageQueue partially available",
}, nil
}
return concepts.GraceStatusWithReason{
Status: concepts.GraceStatusDown,
Reason: "No replicas are ready",
}, nil
}
// DefaultSuspensionStatusHandler reports progress towards a suspended state.
func DefaultSuspensionStatusHandler(
mq *examplev1.MessageQueue,
) (concepts.SuspensionStatusWithReason, error) {
if mq.Status.Replicas == 0 {
return concepts.SuspensionStatusWithReason{
Status: concepts.SuspensionStatusSuspended,
Reason: "MessageQueue scaled to zero",
}, nil
}
return concepts.SuspensionStatusWithReason{
Status: concepts.SuspensionStatusSuspending,
Reason: fmt.Sprintf("%d replicas still running", mq.Status.Replicas),
}, nil
}
// DefaultSuspendMutationHandler scales the MessageQueue to zero replicas.
func DefaultSuspendMutationHandler(m *Mutator) error {
m.SetReplicas(0)
return nil
}
// DefaultDeleteOnSuspendHandler returns false: keep the resource, just scale down.
func DefaultDeleteOnSuspendHandler(_ *examplev1.MessageQueue) bool {
return false
}
Keeping convergence and grace consistent¶
The convergence handler and the grace handler evaluate the same object in the same reconcile loop, with no refetch
between them. When convergence returns Healthy the component is satisfied and grace is never called. For every other
state, grace must not contradict convergence by returning Healthy. The table below shows a consistent pair for a
workload with three desired replicas:
| Desired | Ready | Convergence | Grace |
|---|---|---|---|
| 3 | 0 | Creating | Down |
| 3 | 1 | Scaling | Degraded |
| 3 | 3 | Healthy | (not called) |
| 3 | 5 | Scaling | Degraded |
If grace reported Healthy in the last row, it would tell the component everything is fine while convergence still
considers the resource non-healthy (scaling down). The component logs a warning when it detects this. If the
inconsistency is intentional, pass the component.SuppressGraceInconsistencyWarning() resource option to WithResource
(Step 8) to silence the log.
Status constants reference¶
These are the runtime string values each lifecycle status reports. They appear in the component's conditions and in golden snapshots, so use the exact strings. Lifecycle Interfaces gives the authoritative interface-to-value mapping; the table here is the implementer's quick reference.
| Category | Status type | Constant | String value |
|---|---|---|---|
| Workload | concepts.AliveConvergingStatus |
AliveConvergingStatusHealthy |
Healthy |
AliveConvergingStatusCreating |
Creating |
||
AliveConvergingStatusUpdating |
Updating |
||
AliveConvergingStatusScaling |
Scaling |
||
AliveConvergingStatusFailing |
Failing |
||
| Workload, Integration | concepts.GraceStatus |
GraceStatusHealthy |
Healthy |
GraceStatusDegraded |
Degraded |
||
GraceStatusDown |
Down |
||
| Task | concepts.CompletionStatus |
CompletionStatusCompleted |
Completed |
CompletionStatusRunning |
TaskRunning |
||
CompletionStatusPending |
TaskPending |
||
CompletionStatusFailing |
TaskFailing |
||
| Integration | concepts.OperationalStatus |
OperationalStatusOperational |
Operational |
OperationalStatusPending |
OperationPending |
||
OperationalStatusFailing |
OperationFailing |
||
| All | concepts.SuspensionStatus |
SuspensionStatusPending |
PendingSuspension |
SuspensionStatusSuspending |
Suspending |
||
SuspensionStatusSuspended |
Suspended |
||
| All | concepts.GuardStatus |
GuardStatusBlocked |
Blocked |
GuardStatusUnblocked |
Unblocked |
Unblocked is an internal signal
GuardStatusUnblocked is never written to a condition. It is the control value the framework uses to decide whether
to proceed with a resource. Only Blocked surfaces in status.
5. Implement the builder¶
The builder wraps the generic builder, registers default handlers in its constructor, and exposes a fluent configuration
API. It validates and returns the concrete Resource from Build().
The identity function is required and must produce a stable, unique identity for the object. The framework's convention,
used by every built-in primitive, is <groupversion>/<Kind>/<namespace>/<name> (for example
apps/v1/Deployment/<namespace>/<name>, or v1/Service/<namespace>/<name> for core-group kinds). Cluster-scoped kinds
omit the namespace segment. Follow this format so identities stay consistent and collision-free across your operator.
package messagequeue
import (
"fmt"
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/feature"
"github.com/sourcehawk/operator-component-framework/pkg/generic"
examplev1 "example.io/api/v1"
)
// Builder configures and validates a MessageQueue resource.
type Builder struct {
base *generic.WorkloadBuilder[*examplev1.MessageQueue, *Mutator]
}
// NewBuilder creates a Builder with the provided MessageQueue as the desired base state.
//
// The object must have Name and Namespace set.
func NewBuilder(mq *examplev1.MessageQueue) *Builder {
identityFunc := func(mq *examplev1.MessageQueue) string {
return fmt.Sprintf("messagequeues.example.io/v1/MessageQueue/%s/%s", mq.Namespace, mq.Name)
}
base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator](
mq,
identityFunc,
NewMutator,
)
// Register domain-specific defaults.
base.
WithCustomConvergeStatus(DefaultConvergingStatusHandler).
WithCustomGraceStatus(DefaultGraceStatusHandler).
WithCustomSuspendStatus(DefaultSuspensionStatusHandler).
WithCustomSuspendMutation(DefaultSuspendMutationHandler).
WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler)
return &Builder{base: base}
}
// WithMutation registers one or more feature-gated mutations, applied in the order given.
// Pass a slice with the spread operator: b.WithMutation(factory()...)
func (b *Builder) WithMutation(ms ...Mutation) *Builder {
for _, m := range ms {
b.base.WithMutation(feature.Mutation[*Mutator](m))
}
return b
}
// WithGuard registers a guard precondition evaluated before the object is applied.
// If the guard returns Blocked, this resource and all resources after it in the
// component are skipped. Passing nil clears any previously registered guard.
func (b *Builder) WithGuard(
guard func(examplev1.MessageQueue) (concepts.GuardStatusWithReason, error),
) *Builder {
b.base.WithGuard(generic.WrapGuard(guard))
return b
}
// WithDataExtractor registers a data extractor to run after the resource is processed.
func (b *Builder) WithDataExtractor(extractor func(examplev1.MessageQueue) error) *Builder {
b.base.WithDataExtractor(generic.WrapExtractor(extractor))
return b
}
// WithCustomConvergeStatus overrides the default convergence status handler.
func (b *Builder) WithCustomConvergeStatus(
handler func(concepts.ConvergingOperation, *examplev1.MessageQueue) (concepts.AliveStatusWithReason, error),
) *Builder {
b.base.WithCustomConvergeStatus(handler)
return b
}
// WithCustomGraceStatus overrides the default grace status handler.
func (b *Builder) WithCustomGraceStatus(
handler func(*examplev1.MessageQueue) (concepts.GraceStatusWithReason, error),
) *Builder {
b.base.WithCustomGraceStatus(handler)
return b
}
// Build validates the configuration and returns the initialized Resource.
func (b *Builder) Build() (*Resource, error) {
genericRes, err := b.base.Build()
if err != nil {
return nil, err
}
return &Resource{base: genericRes}, nil
}
The builder exposes WithCustomSuspendStatus, WithCustomSuspendMutation, and WithCustomSuspendDeletionDecision the
same way if callers need to override suspension behavior after construction; they are omitted above for brevity.
Builder conventions
generic.WrapGuardandgeneric.WrapExtractorconvert value-receiver callbacks (func(T)) into the pointer-receiver form (func(*T)) the generic layer expects, so your public API can take the kind by value. The built-in builders use both.- Register defaults in the constructor. Set the handlers your CRD has meaningful semantics for, then let callers override them per resource.
- Return
*Builderfrom every method for fluent chaining. - Validate in
Build(). The generic build checks for a non-nil object, a name, a namespace (unless cluster-scoped), an identity function, a mutator factory, the required convergence handler, and that mutation names are unique. Add any custom validation after the generic build returns.
6. Implement the resource¶
The resource is a thin wrapper that delegates every interface method to the generic base. This layer exists so your
package exports a concrete type rather than a generic one. List the interfaces it satisfies in its GoDoc, matching how
the built-in Resource types document themselves.
package messagequeue
import (
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/generic"
examplev1 "example.io/api/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Resource manages a MessageQueue within a component's reconciliation loop.
//
// It implements:
// - component.Resource (Identity, Object, Mutate)
// - concepts.Alive (ConvergingStatus)
// - concepts.Graceful (GraceStatus)
// - concepts.Suspendable (DeleteOnSuspend, Suspend, SuspensionStatus)
// - concepts.Guardable (GuardStatus)
// - concepts.DataExtractable (ExtractData)
// - concepts.ObservationRecorder (RecordObservation)
// - concepts.Previewable (Preview)
// - concepts.MutationInspector (RegisteredMutations, FiringSet)
type Resource struct {
base *generic.WorkloadResource[*examplev1.MessageQueue, *Mutator]
}
func (r *Resource) Identity() string {
return r.base.Identity()
}
func (r *Resource) Object() (client.Object, error) {
return r.base.Object()
}
func (r *Resource) Mutate(current client.Object) error {
return r.base.Mutate(current)
}
func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) {
return r.base.ConvergingStatus(op)
}
func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) {
return r.base.GraceStatus()
}
func (r *Resource) DeleteOnSuspend() bool {
return r.base.DeleteOnSuspend()
}
func (r *Resource) Suspend() error {
return r.base.Suspend()
}
func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) {
return r.base.SuspensionStatus()
}
func (r *Resource) GuardStatus() (concepts.GuardStatusWithReason, error) {
return r.base.GuardStatus()
}
func (r *Resource) ExtractData() error {
return r.base.ExtractData()
}
func (r *Resource) RecordObservation(observed client.Object) error {
return r.base.RecordObservation(observed)
}
// Preview renders the desired state with all feature mutations applied, without
// touching the resource's internal state or contacting the cluster.
func (r *Resource) Preview() (client.Object, error) {
return r.base.Preview()
}
// RegisteredMutations returns the names of every mutation registered on the resource.
func (r *Resource) RegisteredMutations() []string {
return r.base.RegisteredMutations()
}
// FiringSet returns the names of registered mutations whose gate fires at the built version.
func (r *Resource) FiringSet() ([]string, error) {
return r.base.FiringSet()
}
// Compile-time guarantee that the wrapper exposes the inspection surface.
var _ concepts.MutationInspector = (*Resource)(nil)
Do not omit Preview
Preview() satisfies concepts.Previewable. Without it, component.Preview() fails at runtime and golden snapshot
tests cannot render the resource. Every built-in resource delegates Preview() to its base; so must yours.
RegisteredMutations() and FiringSet() satisfy concepts.MutationInspector. Nothing in the reconcile path calls
them, but version-matrix golden generation uses them to introspect which mutations a resource registers
and which fire at a given version. Delegate both to the base, as shown.
Forward RecordObservation whenever the resource may be registered read-only with a data extractor. The framework feeds
the fetched cluster object back to the resource before extraction runs; without it, the extractor would see the inert
base passed to the builder rather than live cluster state.
Which methods to include depends on the category:
| Category | Methods to include |
|---|---|
| Workload | Identity, Object, Mutate, ConvergingStatus, GraceStatus, DeleteOnSuspend, Suspend, SuspensionStatus, GuardStatus, ExtractData, RecordObservation, Preview, RegisteredMutations, FiringSet |
| Static | Identity, Object, Mutate, GuardStatus, ExtractData, RecordObservation, Preview, RegisteredMutations, FiringSet |
| Task | Identity, Object, Mutate, ConvergingStatus, DeleteOnSuspend, Suspend, SuspensionStatus, GuardStatus, ExtractData, RecordObservation, Preview, RegisteredMutations, FiringSet |
| Integration | Identity, Object, Mutate, ConvergingStatus, GraceStatus, DeleteOnSuspend, Suspend, SuspensionStatus, GuardStatus, ExtractData, RecordObservation, Preview, RegisteredMutations, FiringSet |
For task and integration resources, ConvergingStatus returns concepts.CompletionStatusWithReason and
concepts.OperationalStatusWithReason respectively, matching the generic base method signature.
7. Define feature mutations¶
Feature mutations use the Mutation alias from Step 2. Each declares a name, an
optional feature gate, and a function that calls mutator methods to record intent. Name every mutation: the name is what
gating and error reporting refer to, and the builder rejects duplicate names within a resource.
package features
import (
"github.com/sourcehawk/operator-component-framework/pkg/feature"
"example.io/messagequeue"
)
// HighThroughputMode raises the connection ceiling for versions >= 2.0.0.
func HighThroughputMode(version string) messagequeue.Mutation {
return messagequeue.Mutation{
Name: "high-throughput-mode",
Feature: feature.NewVersionGate(version, versionConstraints),
Mutate: func(m *messagequeue.Mutator) error {
m.SetMaxConnections(2000)
return nil
},
}
}
// ConstrainedMode caps connections when the flag is set.
func ConstrainedMode(version string, enabled bool) messagequeue.Mutation {
return messagequeue.Mutation{
Name: "constrained-mode",
Feature: feature.NewVersionGate(version, nil).When(enabled),
Mutate: func(m *messagequeue.Mutator) error {
m.SetMaxConnections(100)
return nil
},
}
}
// DefaultSettings returns baseline mutations applied to every MessageQueue.
// The version parameter is forwarded to any version-aware mutations in the set.
func DefaultSettings(version string) []messagequeue.Mutation {
return []messagequeue.Mutation{
{
Name: "default-replicas",
Feature: nil, // always applied
Mutate: func(m *messagequeue.Mutator) error {
m.SetReplicas(1)
return nil
},
},
{
Name: "default-max-connections",
Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *messagequeue.Mutator) error {
m.SetMaxConnections(500)
return nil
},
},
}
}
Mutations apply in registration order. When a mutation's Feature is nil or its gate reports enabled, its Mutate
function runs; otherwise it is skipped. For the gating model (version gates, boolean When conditions, and how the two
combine) see Version-Gated Mutations and
Boolean-Gated Mutations.
8. Register with a component¶
Use your custom resource with the component builder exactly like a built-in primitive.
func buildQueueComponent(owner *MyOperatorCR) (*component.Component, error) {
mq := &examplev1.MessageQueue{
ObjectMeta: metav1.ObjectMeta{
Name: "main-queue",
Namespace: owner.Namespace,
},
Spec: examplev1.MessageQueueSpec{
Replicas: ptr.To(int32(3)),
MaxConnections: 500,
},
}
res, err := messagequeue.NewBuilder(mq).
WithMutation(features.HighThroughputMode(owner.Spec.Version)).
WithMutation(features.ConstrainedMode(owner.Spec.Version, owner.Spec.Constrained)).
WithMutation(features.DefaultSettings(owner.Spec.Version)...). // spread a []Mutation slice
Build()
if err != nil {
return nil, err
}
return component.NewComponentBuilder().
WithName("message-queue").
WithConditionType("MessageQueueReady").
WithResource(res).
WithGracePeriod(5 * time.Minute).
Suspend(owner.Spec.Suspended).
Build()
}
For the component reconciliation lifecycle, status aggregation, and resource options such as ReadOnly(),
Auxiliary(), and BlockOnAbsence(), see the Component page.
Cluster-Scoped Resources¶
For cluster-scoped CRDs, call MarkClusterScoped() on the generic builder before building. Validation then rejects a
non-empty namespace instead of requiring one, and the identity function should omit the namespace segment.
func NewBuilder(mq *examplev1.MessageQueue) *Builder {
base := generic.NewWorkloadBuilder[*examplev1.MessageQueue, *Mutator](mq, identityFunc, NewMutator)
base.MarkClusterScoped()
// ... register handlers ...
return &Builder{base: base}
}
See Cluster-Scoped Primitives for the ownership and garbage-collection implications.
Category-Specific Notes¶
Static resources¶
Static resources have the simplest implementation. They do not participate in convergence, grace, or suspension
reporting. The builder uses generic.NewStaticBuilder, which supports WithMutation, WithGuard, and
WithDataExtractor. The resource wrapper needs only Identity, Object, Mutate, GuardStatus, ExtractData,
RecordObservation, Preview, RegisteredMutations, and FiringSet. pkg/primitives/configmap is a complete
reference.
Task resources¶
Task resources use generic.NewTaskBuilder and report convergence as concepts.CompletionStatusWithReason instead of
AliveStatusWithReason. The converging handler, registered with WithCustomConvergeStatus, reports Completed,
TaskRunning, TaskPending, or TaskFailing.
Integration resources¶
Integration resources use generic.NewIntegrationBuilder and report convergence as
concepts.OperationalStatusWithReason. The handler is registered with WithCustomOperationalStatus (not
WithCustomConvergeStatus) and reports Operational, OperationPending, or OperationFailing. Integration resources
also implement Graceful, defaulting to Healthy. The resource wrapper includes GraceStatus alongside the other
methods. A minimal integration builder for a DNSRecord CRD whose readiness depends on an external provider assigning a
record ID:
package dnsrecord
import (
"fmt"
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/generic"
examplev1 "example.io/api/v1"
)
// Builder configures and validates a DNSRecord integration resource.
type Builder struct {
base *generic.IntegrationBuilder[*examplev1.DNSRecord, *Mutator]
}
// DefaultOperationalStatusHandler reports the DNSRecord operational once the
// external provider has assigned a record ID.
func DefaultOperationalStatusHandler(
_ concepts.ConvergingOperation, r *examplev1.DNSRecord,
) (concepts.OperationalStatusWithReason, error) {
if r.Status.RecordID != "" {
return concepts.OperationalStatusWithReason{
Status: concepts.OperationalStatusOperational,
Reason: "Record provisioned by provider",
}, nil
}
return concepts.OperationalStatusWithReason{
Status: concepts.OperationalStatusPending,
Reason: "Awaiting record ID from provider",
}, nil
}
// NewBuilder creates a Builder with the provided DNSRecord as the desired base state.
func NewBuilder(record *examplev1.DNSRecord) *Builder {
identityFunc := func(r *examplev1.DNSRecord) string {
return fmt.Sprintf("dnsrecords.example.io/v1/DNSRecord/%s/%s", r.Namespace, r.Name)
}
base := generic.NewIntegrationBuilder[*examplev1.DNSRecord, *Mutator](
record,
identityFunc,
NewMutator,
)
base.WithCustomOperationalStatus(DefaultOperationalStatusHandler)
return &Builder{base: base}
}
// Build validates the configuration and returns the initialized Resource.
func (b *Builder) Build() (*Resource, error) {
genericRes, err := b.base.Build()
if err != nil {
return nil, err
}
return &Resource{base: genericRes}, nil
}
pkg/primitives/service is a complete integration reference, including a grace handler that mirrors the operational
logic.
Reference¶
| Package | Contains |
|---|---|
pkg/generic |
Generic resource types, builders, WrapGuard, WrapExtractor |
pkg/feature |
Mutation, Gate, VersionGate, NewVersionGate |
pkg/component/concepts |
Lifecycle interfaces and status type constants |
pkg/component |
Component builder, resource registration, reconciliation |
pkg/primitives/* |
Built-in implementations to use as references |
For a complete, runnable wrapper of a third-party CRD (using the unstructured static builder rather than a typed
struct), see examples/custom-resource.