Getting Started¶
This tutorial builds your first component from scratch: one component that manages a ConfigMap and a Deployment, reports a single condition on the owner object, and gates one piece of behavior behind a boolean flag. By the end you have a working reconcile loop and a golden test pinning the rendered output.
Every snippet here is taken from the mutations-and-gating example in the repository. If you want the finished code
side by side, open examples/mutations-and-gating.
Requirements¶
- A controller-runtime project, such as one scaffolded with Kubebuilder. This framework is not a replacement for controller-runtime; it is a library you use inside a reconciler to manage the layers between the reconciler and the Kubernetes objects it manages.
- An owner CRD type whose status implements
GetStatusConditions() *[]metav1.Condition. The framework stages and flushes conditions through this accessor. - The module installed:
See Compatibility for the supported Go and controller-runtime versions.
What you will build¶
A single component named example-app with the condition type AppReady. It manages:
- A ConfigMap holding the application configuration.
- A Deployment running the application container, with one boolean-gated mutation that sets
LOG_LEVEL=debugwhen a spec flag is enabled.
The reconciler builds the component and hands it to the framework, which applies the resources, aggregates their health, and writes one condition back to the owner.
Step 1: Define the owner CRD¶
The framework only requires one thing of your CRD: the status type exposes its conditions through GetStatusConditions.
Here is a minimal owner with a version, one feature flag, and a conditions slice.
package app
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ExampleAppSpec defines the desired state of ExampleApp.
type ExampleAppSpec struct {
// Version of the application to deploy.
Version string `json:"version"`
// EnableDebugLogging sets LOG_LEVEL=debug on the application container.
EnableDebugLogging bool `json:"enableDebugLogging"`
// Suspended determines whether the application is active.
Suspended bool `json:"suspended"`
}
// ExampleAppStatus defines the observed state of ExampleApp.
type ExampleAppStatus struct {
// Conditions store the status of the application's components.
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// ExampleApp is the owner CRD managed by the operator.
type ExampleApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ExampleAppSpec `json:"spec,omitempty"`
Status ExampleAppStatus `json:"status,omitempty"`
}
// GetStatusConditions returns the status conditions for the ExampleApp.
// The framework reads and writes the owner's conditions through this accessor.
func (in *ExampleApp) GetStatusConditions() *[]metav1.Condition {
return &in.Status.Conditions
}
A real CRD also implements runtime.Object (the DeepCopyObject method and a list type) and registers with a scheme.
Kubebuilder generates that boilerplate for you. The full shared type lives in examples/shared/app/owner.go.
Step 2: Build the ConfigMap primitive¶
A primitive wraps one Kubernetes object. Start by writing a function that returns the desired-state object, then build a
component.Resource from it with the primitive builder.
package resources
import (
"github.com/sourcehawk/operator-component-framework/pkg/component"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"your.module/app"
)
// BaseConfigMap returns the desired-state ConfigMap for the given owner.
func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: owner.Name + "-config",
Namespace: owner.Namespace,
Labels: map[string]string{"app": owner.Name},
},
Data: map[string]string{
"app.yaml": "server:\n port: 8080\n timeout: 30s\n",
},
}
}
// NewConfigMapResource builds the ConfigMap resource.
func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) {
return configmap.NewBuilder(BaseConfigMap(owner)).Build()
}
NewBuilder takes the object, Build returns a component.Resource ready to register on a component.
Step 3: Build the Deployment primitive¶
The Deployment is built the same way, with one addition: a boolean-gated mutation. A mutation is a named edit that the framework applies only when its feature gate is active. Defining the edit separately from the baseline keeps the baseline readable and the conditional behavior testable on its own.
First, the baseline. Define it as the latest version's desired shape and leave version-dependent fields out of it. This is the baseline-as-latest convention; the Guidelines explain why it pays off.
package resources
import (
"fmt"
"github.com/sourcehawk/operator-component-framework/pkg/component"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"your.module/app"
)
// BaseDeployment returns the desired-state Deployment for the given owner.
func BaseDeployment(owner *app.ExampleApp) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: owner.Name + "-app",
Namespace: owner.Namespace,
Labels: map[string]string{"app": owner.Name},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": owner.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": owner.Name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "app",
Image: fmt.Sprintf("my-app:%s", owner.Spec.Version),
},
},
},
},
},
}
}
Now the mutation. It targets the container named app and sets an environment variable. The gate is built with
feature.NewBooleanGate(enabled): a gate with no version constraints whose result is driven purely by the boolean. When
enabled is false, the framework skips the edit.
package features
import (
"github.com/sourcehawk/operator-component-framework/pkg/feature"
"github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
"github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
corev1 "k8s.io/api/core/v1"
)
// DebugLoggingMutation sets LOG_LEVEL=debug on the application container when enabled.
func DebugLoggingMutation(enabled bool) deployment.Mutation {
return deployment.Mutation{
Name: "DebugLogging",
Feature: feature.NewBooleanGate(enabled),
Mutate: func(m *deployment.Mutator) error {
m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error {
ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"})
return nil
})
return nil
},
}
}
Register the mutation when building the resource. Mutations apply in registration order.
// NewDeploymentResource builds the Deployment resource with its mutations.
func NewDeploymentResource(owner *app.ExampleApp) (component.Resource, error) {
return deployment.NewBuilder(BaseDeployment(owner)).
WithMutation(features.DebugLoggingMutation(owner.Spec.EnableDebugLogging)).
Build()
}
Version-gated mutations
The same gate does version gating. Pass a non-empty version and a constraint slice instead of a boolean, and the mutation fires only when the version satisfies the constraint. This is how backward-compatibility patches are expressed without touching the baseline:
Feature: feature.NewVersionGate(owner.Spec.Version, []feature.VersionConstraint{
mustConstraint("< 2.0.0"), // a VersionConstraint you supply, e.g. backed by a semver library
}),
The framework does not ship a VersionConstraint implementation, so you provide one (a few lines wrapping a semver
library). The
mutations-and-gating example
wires a real one in its backward-compatibility mutation. See
Version-Gated Mutations for the full pattern and
Guidelines for the baseline-as-latest approach.
Step 4: Compose the component¶
A component groups resources under one name and one condition type. Register resources in dependency order; they reconcile in the order you add them.
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(deployResource).
WithResource(cmResource).
Suspend(owner.Spec.Suspended).
Build()
if err != nil {
return err
}
Suspend(true) deactivates the component's suspendable resources (the Deployment scales to zero) without deleting the
component's record of them.
Step 5: Wire the reconciler¶
The controller stays thin. It builds the resources, builds the component, and calls Reconcile. A ReconcileContext
carries the client, scheme, recorders, and owner. A single deferred component.FlushStatus persists every staged
condition with one status update at the end of the loop, which keeps controllers with multiple components free of
self-induced update conflicts.
ReconcileContext has five fields:
| Field | Type | Notes |
|---|---|---|
Client |
client.Client |
The controller-runtime client. |
Scheme |
*runtime.Scheme |
The operator scheme. |
Recorder |
record.EventRecorder |
For Kubernetes events. Your Kubebuilder manager provides one. |
Metrics |
component.Recorder |
Optional. Pass nil to skip status-condition metrics. |
Owner |
component.OperatorCRD |
The owner object you fetched. Your CRD satisfies this via GetStatusConditions. |
Note
FlushStatus performs the status write itself: it calls Client.Status().Update on the owner (retrying on
conflict). Do not also call Status().Update for the conditions the framework manages, or you will double-write.
Because the call is deferred, the conditions are flushed even if Reconcile returns an error.
package app
import (
"context"
"github.com/sourcehawk/operator-component-framework/pkg/component"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
)
type Controller struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
Metrics component.Recorder
NewDeploymentResource func(*ExampleApp) (component.Resource, error)
NewConfigMapResource func(*ExampleApp) (component.Resource, error)
}
func (r *Controller) reconcile(ctx context.Context, owner *ExampleApp) (err error) {
recCtx := component.ReconcileContext{
Client: r.Client,
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
Owner: owner,
}
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()
deployResource, err := r.NewDeploymentResource(owner)
if err != nil {
return err
}
cmResource, err := r.NewConfigMapResource(owner)
if err != nil {
return err
}
comp, err := component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(deployResource).
WithResource(cmResource).
Suspend(owner.Spec.Suspended).
Build()
if err != nil {
return err
}
return comp.Reconcile(ctx, recCtx)
}
The reconcile method above takes the owner directly so the framework logic is easy to read and test. In a Kubebuilder
project the generated entry point has the signature Reconcile(ctx, req). Fetch the owner there, handle the not-found
case, and delegate to it:
func (r *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
owner := &ExampleApp{}
if err := r.Get(ctx, req.NamespacedName, owner); err != nil {
// Ignore not-found: the owner was deleted and its resources are
// garbage-collected through their owner references.
return ctrl.Result{}, client.IgnoreNotFound(err)
}
return ctrl.Result{}, r.reconcile(ctx, owner)
}
This entry point uses ctrl "sigs.k8s.io/controller-runtime" for ctrl.Request and ctrl.Result. After a reconcile,
the owner's Status.Conditions carries one AppReady condition reflecting the aggregated health of the Deployment and
ConfigMap.
Step 6: Test the resource¶
Pin the resource's rendered output against a checked-in snapshot. Build it through the same factory the reconciler uses,
then golden it: golden.AssertYAML does the comparison, golden.WithScheme makes the output carry apiVersion and
kind for typed objects, and the -update flag regenerates the snapshot.
package resources_test
import (
"flag"
"testing"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/runtime"
"your.module/app"
"your.module/resources"
)
var update = flag.Bool("update", false, "update golden files")
func TestDeploymentResource(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, appsv1.AddToScheme(scheme))
owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}}
owner.Name = "my-app"
owner.Namespace = "default"
// Build the resource exactly as the reconciler does.
res, err := resources.NewDeploymentResource(owner)
require.NoError(t, err)
// The built resource implements golden.Previewer.
previewer, ok := res.(golden.Previewer)
require.True(t, ok)
golden.AssertYAML(t, "testdata/deployment.yaml", previewer, golden.WithScheme(scheme), golden.Update(*update))
}
Generate the snapshot, then run the test normally to verify it stays stable:
go test ./resources -run TestDeploymentResource -update
go test ./resources -run TestDeploymentResource
Commit the generated testdata/deployment.yaml alongside the test. This resource test is one layer of a fuller
strategy: test each mutation against a baseline, assert which mutations fire at the resource level, and golden the whole
component with golden.AssertComponentYAML. See Testing for all three layers and for version-matrix
generation across supported versions.
Next steps¶
- Component: the full lifecycle, status model, grace periods, suspension, guards, and prerequisites.
- Guidelines: the baseline-as-latest convention, one component per condition, thin controllers, and version-gated mutations in practice.
- Testing: golden snapshots in depth and version-matrix golden generation across supported versions.