Primitives Overview¶
The primitives packages provide reusable, type-safe wrappers for individual Kubernetes objects. A primitive sits
between the Component layer and a raw Kubernetes resource, handling state synchronization, mutation, and
lifecycle so operator authors do not have to.
This page is the canonical reference for the concepts shared across every primitive: the lifecycle interfaces and the status values they report, the mutation system, editors and selectors, Server-Side Apply, and cluster-scoped handling. Individual primitive pages link here rather than repeating these explanations, and document only their kind-specific surface.
What a Primitive Is¶
A primitive wraps a specific Kubernetes kind (for example Deployment or ConfigMap) and encapsulates:
- A desired-state baseline. The object you hand the builder, representing the resource's intended shape.
- A mutation surface. Typed editors that record changes to the baseline, gated by features or version constraints.
- Lifecycle integration. Readiness detection, grace handling, and suspension, depending on the kind.
- Server-Side Apply. Desired state is applied via SSA, preserving server defaults and fields owned by other controllers.
Every primitive implements the component.Resource interface, and may additionally implement one or more
lifecycle interfaces to participate in component status aggregation.
Primitive Categories¶
The framework groups primitives by runtime behavior. The category determines which lifecycle interfaces a primitive implements and therefore how it contributes to a component's aggregate status.
flowchart TD
Start([Choosing a primitive category]) --> Q1{Long-running<br/>process?}
Q1 -->|Yes| Workload[Workload<br/>Deployment, StatefulSet, DaemonSet]
Q1 -->|No| Q2{Runs to<br/>completion?}
Q2 -->|Yes| Task[Task<br/>Job]
Q2 -->|No| Q3{Readiness depends<br/>on an external<br/>controller?}
Q3 -->|Yes| Integration[Integration<br/>Service, Ingress, CronJob, HPA]
Q3 -->|No| Static[Static<br/>ConfigMap, Secret, RBAC, PDB]
Static¶
Examples: ConfigMap, Secret, ServiceAccount, RBAC objects, PodDisruptionBudget.
The desired state is mostly fixed. These resources are created or updated from configuration but have no complex runtime
convergence, so they are considered Ready as soon as they exist. They may optionally expose data through
DataExtractable.
Workload¶
Examples: Deployment, StatefulSet, DaemonSet.
Long-running processes that require runtime convergence (pods being scheduled and becoming ready). They implement
Alive, Graceful, and Suspendable, supporting health tracking, grace periods, and scaling to zero.
Task¶
Examples: Job.
Short-lived operations that run to completion (migrations, backups, initialization steps). They implement Completable
and Suspendable. When suspended, a task is paused if its kind supports it, or deleted and recreated when resumed.
Integration¶
Examples: Service, Ingress, CronJob, HPA.
Integration points with external or cluster-level systems (networking, load balancers, schedules, autoscaling). Their
readiness depends on controllers the operator does not own, so it may be delayed or partial. They implement
Operational, and may also implement Graceful or Suspendable.
Lifecycle Interfaces¶
A primitive participates in status aggregation by implementing one or more lifecycle interfaces from
pkg/component/concepts. Each interface reports a small, fixed set of status values. The values below are the runtime
strings that appear in conditions, not the Go constant identifiers.
This table is the single source of truth
Other documentation links here for the interface-to-status mapping. The component page owns how these values are prioritized and aggregated; the custom resource guide owns the Go constant reference for implementers.
| Interface | Reported status values | Typical kinds |
|---|---|---|
Alive |
Healthy, Creating, Updating, Scaling, Failing |
Deployments, StatefulSets, DaemonSets |
Graceful |
Healthy, Degraded, Down |
Workloads and integrations with slow convergence |
Suspendable |
PendingSuspension, Suspending, Suspended |
Any resource with a deactivation behavior |
Completable |
Completed, TaskRunning, TaskPending, TaskFailing |
Jobs and task primitives |
Operational |
Operational, OperationPending, OperationFailing |
Services, Ingresses, CronJobs |
Guardable |
Blocked |
Resources with runtime preconditions |
DataExtractable |
(no status, side-effecting) | Resources that expose post-sync data |
Guardable reports only Blocked
A guard's other result, Unblocked, is an internal control signal that lets the framework proceed. It is never
written to a condition. Only Blocked surfaces, with the reason explaining what the resource is waiting for.
Custom resource wrappers can implement any subset of these interfaces to opt into the corresponding component behaviors.
Cluster-Scoped Primitives¶
Some Kubernetes kinds are cluster-scoped and have no namespace, for example ClusterRole, ClusterRoleBinding, and
PersistentVolume.
A primitive for a cluster-scoped kind must call MarkClusterScoped() on its BaseBuilder during construction. This
inverts the namespace check in ValidateBase(): instead of requiring a non-empty namespace, the builder rejects one.
If you build a cluster-scoped primitive without marking it, Build() fails with the error above, because the validator
still expects a namespace. With MarkClusterScoped() set, supplying a namespace fails the other way:
A cluster-scoped builder also provides an identity function that omits the namespace segment (for example
rbac.authorization.k8s.io/v1/ClusterRole/my-role). At reconcile time the framework detects scope mismatches between
the owner CRD and managed resources using the cluster's REST mapper. See
Cluster-Scoped Resources for owner-reference and garbage-collection behavior.
Server-Side Apply¶
The framework reconciles resources with Server-Side Apply (SSA). Each primitive builds its desired state (the
baseline with all active mutations applied) and patches it with client.Apply. Only the fields the operator declares
are sent; server-managed defaults, fields set by other controllers (HPAs, sidecar injectors, annotation-based tooling),
and values written by webhooks are left untouched.
The API server tracks field ownership automatically. The field manager name is derived from the owner and component as
"{Owner.GetKind()}/{componentName}". The framework applies with forced ownership, so it takes control of conflicting
fields from other managers, while fields it does not include stay with their current owners.
This removes the perpetual-update problem that arises when an operator strips server defaults every cycle, and it lets primitives coexist with other controllers that touch the same resources.
The Mutation System¶
Mutations let independent features contribute changes to a primitive's baseline without knowing about each other. A
mutation is a feature.Mutation[T], where T is the primitive's mutator type:
type Mutation[T any] struct {
Name string // unique within the resource; used in gating and error reporting
Feature Gate // optional; nil means apply unconditionally
Mutate func(T) error
}
Each primitive package defines its own concrete alias (deployment.Mutation, statefulset.Mutation, and so on) over
this generic type. Register mutations with the builder's variadic WithMutation, which preserves the order given:
Calling WithMutation() with no arguments is a no-op, which composes cleanly with factories that return []Mutation.
Mutation names must be unique within a resource: Build() returns an error if two registered mutations share a Name,
because the name is what gating and error reporting refer to, and a collision would mask a mis-targeted mutation. The
check compares names only and evaluates no feature gates.
Plan and apply¶
Mutations do not touch the Kubernetes object directly. Each Mutate function records its intent through typed editors,
and the framework replays every recorded edit in a single controlled pass when it calls the mutator's Apply().
sequenceDiagram
participant Author
participant Builder
participant Mutator
participant Object as Kubernetes object
Author->>Builder: WithMutation(name, feature, mutate)
Note over Builder: stores the mutation, nothing applied yet
Builder->>Mutator: Apply()
loop each enabled feature, in registration order
Mutator->>Mutator: replay recorded edits in fixed category order
Mutator->>Object: write fields
end
This staging buys three things: changes are recorded before any object is touched, independent features compose without coupling, and the editors handle presence operations and stable container selection internally instead of leaving slice surgery to the author.
Ordering within a feature¶
Features apply in registration order. Within a single feature's apply pass, edits run in a fixed category order so the
result is deterministic regardless of the order methods were called inside Mutate. For the pod-workload mutators the
order is:
- Object metadata edits
- Spec edits (for example
EditDeploymentSpec) - Pod-template metadata edits
- Pod-spec edits
- Container presence operations (add / remove)
- Container edits
- Init-container presence operations
- Init-container edits
Within each category, edits run in the order they were recorded. Later features observe the object as modified by all earlier ones.
Boolean-Gated Mutations¶
A mutation can be enabled by a runtime condition rather than a version. Use NewBooleanGate for a gate whose result is
driven purely by a boolean:
import "github.com/sourcehawk/operator-component-framework/pkg/feature"
gate := feature.NewBooleanGate(len(spec.ExtraEnv) > 0)
NewBooleanGate(b) is shorthand for NewVersionGate("", nil).When(b): a gate with no version constraints whose result
depends only on the boolean. It returns a *VersionGate, so further conditions can be added with When, and every
value passed must be true for the gate to enable. This is the idiomatic way to make a mutation conditional on the
owner's spec, for example applying a user-override mutation only when the user supplied values.
Version-Gated Mutations¶
To enable a mutation only for certain versions, pass the current version and a slice of feature.VersionConstraint to
NewVersionGate:
gate := feature.NewVersionGate(currentVersion, []feature.VersionConstraint{
semver.MustConstraint(">= 2.0.0"),
})
A VersionGate is enabled only when every constraint matches currentVersion and every When condition is true.
nil constraints are ignored, so version and boolean gating combine freely:
A common pattern pairs mutually exclusive gates (>= V and < V) for a field whose shape changed between versions, so
exactly one fires for any given version.
VersionConstraint is an interface
feature.VersionConstraint is an interface (Enabled(version string) (bool, error)). The framework does not ship a
semver implementation; supply one from your version package. The semver.MustConstraint call above is illustrative.
Mutation Editors¶
Editors provide scoped, typed APIs for modifying one part of a resource. A mutator hands an editor to your callback; you record changes; the framework applies them during the plan-and-apply pass. Editors fall into a few groups:
- Container editors (
ContainerEditor) for env vars, args, resources, probes, and the like, selected by a container selector. - Pod-shaping editors (
PodSpecEditor,ObjectMetaEditor) shared by all pod-workload kinds. - Kind-specific spec editors (
DeploymentSpecEditor,ServiceSpecEditor,IngressSpecEditor, and so on), one per kind. - Data editors (
ConfigMapDataEditor,SecretDataEditor) and RBAC editors (PolicyRulesEditor,BindingSubjectsEditor).
Every editor exposes a .Raw() method returning a pointer to the underlying Kubernetes struct, for the cases the typed
API does not cover. Using .Raw() is safe because the mutation stays scoped to that editor's target and still runs
inside the controlled apply pass.
Each primitive page documents the editors relevant to its kind. For the full method list of any editor, see the Go API reference on pkg.go.dev.
Container Selectors¶
A container selector decides which containers an editor targets, which matters for multi-container pods. The selectors
live in pkg/mutation/selectors:
selectors.AllContainers() // every container in the pod
selectors.ContainerNamed("app") // a single container by name
selectors.ContainersNamed("web", "api") // several containers by name
selectors.ContainerNotNamed("sidecar") // all containers except one
selectors.ContainersNotNamed("agent", "log") // all containers except several
selectors.ContainerAtIndex(0) // the container at a given index
Within a feature's apply pass, a selector is evaluated against a snapshot of the containers taken at the start of the container phase, after that same feature's presence operations have run. Matching against the snapshot keeps selection stable even if an earlier edit renames a container, and it lets a single mutation add a container and then configure it in the same pass.
Workload-Kind-Agnostic Mutations¶
*deployment.Mutator, *statefulset.Mutator, and *daemonset.Mutator share the same container, init-container,
pod-spec, pod-template-metadata, object-metadata, environment-variable, and argument editing methods.
primitives.WorkloadMutator is the interface covering exactly that shared surface, so one mutation can target any
pod-workload kind.
Write the emitter once against the interface, then lift it onto each kind's builder with that package's LiftMutation
adapter:
import (
corev1 "k8s.io/api/core/v1"
"github.com/sourcehawk/operator-component-framework/pkg/feature"
"github.com/sourcehawk/operator-component-framework/pkg/primitives"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/daemonset"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset"
)
// One emitter, written against the shared interface.
func authEnv() feature.Mutation[primitives.WorkloadMutator] {
return feature.Mutation[primitives.WorkloadMutator]{
Name: "auth-env",
Mutate: func(m primitives.WorkloadMutator) error {
m.EnsureContainerEnvVar(corev1.EnvVar{Name: "AUTH_MODE", Value: "oidc"})
return nil
},
}
}
// Lifted onto each typed builder.
backend.WithMutation(statefulset.LiftMutation(authEnv()))
frontend.WithMutation(deployment.LiftMutation(authEnv()))
agent.WithMutation(daemonset.LiftMutation(authEnv()))
Each LiftMutation returns that package's own Mutation type, which is what the builder's WithMutation accepts. The
lift bridges the interface-typed emitter to the kind's concrete mutation type, carrying the Name and Feature gate
through unchanged, so a lifted mutation gates and composes alongside natively typed mutations on the same builder.
The interface deliberately omits operations that are not common to all three kinds: the per-kind spec editors
(EditDeploymentSpec, EditStatefulSetSpec, EditDaemonSetSpec), EnsureReplicas (the DaemonSet mutator has no
replica field), and the StatefulSet-only VolumeClaimTemplate methods. Reach for the concrete mutator type when you need
those.
Built-in Primitives¶
| Primitive | Category | Documentation |
|---|---|---|
pkg/primitives/deployment |
Workload | deployment.md |
pkg/primitives/statefulset |
Workload | statefulset.md |
pkg/primitives/replicaset |
Workload | replicaset.md |
pkg/primitives/daemonset |
Workload | daemonset.md |
pkg/primitives/pod |
Workload | pod.md |
pkg/primitives/job |
Task | job.md |
pkg/primitives/cronjob |
Integration | cronjob.md |
pkg/primitives/configmap |
Static | configmap.md |
pkg/primitives/secret |
Static | secret.md |
pkg/primitives/role |
Static | role.md |
pkg/primitives/rolebinding |
Static | rolebinding.md |
pkg/primitives/pdb |
Static | pdb.md |
pkg/primitives/clusterrole |
Static | clusterrole.md |
pkg/primitives/clusterrolebinding |
Static | clusterrolebinding.md |
pkg/primitives/serviceaccount |
Static | serviceaccount.md |
pkg/primitives/service |
Integration | service.md |
pkg/primitives/pv |
Integration | pv.md |
pkg/primitives/pvc |
Integration | pvc.md |
pkg/primitives/hpa |
Integration | hpa.md |
pkg/primitives/ingress |
Integration | ingress.md |
pkg/primitives/networkpolicy |
Static | networkpolicy.md |
Usage Examples¶
The example below builds a frontend Deployment for a hypothetical WebApp operator, adds a version-gated sidecar
mutation, targets multiple containers, guards on a value extracted from an earlier resource, and registers the result
with a component.
import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/sourcehawk/operator-component-framework/pkg/component"
"github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"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"
)
// 1. Baseline: the resource's intended shape. Version-dependent fields
// (such as the image) are left empty and owned by a mutation.
base := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "frontend",
Namespace: owner.Namespace,
},
Spec: appsv1.DeploymentSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{Name: "web"},
{Name: "api"},
},
},
},
},
}
res, err := deployment.NewBuilder(base).
// 2. A mutation: add a sidecar, gated on a version constraint, and
// configure it. The sidecar is added then edited in one pass.
WithMutation(deployment.Mutation{
Name: "add-proxy-sidecar",
Feature: feature.NewVersionGate(version, proxyConstraints),
Mutate: func(m *deployment.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "proxy",
Image: "envoyproxy/envoy:v1.29",
})
m.EditContainers(selectors.ContainerNamed("proxy"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "PROXY_ADMIN_PORT", Value: "9901"})
return nil
})
return nil
},
}).
// 3. Target multiple containers in a single edit.
WithMutation(deployment.Mutation{
Name: "json-logging",
Mutate: func(m *deployment.Mutator) error {
m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error {
e.EnsureArg("--log-format=json")
return nil
})
return nil
},
}).
// 4. A guard: do not apply until a precondition (here, a value
// extracted from an earlier resource) is satisfied.
WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) {
if apiEndpoint == "" {
return concepts.GuardStatusWithReason{
Status: concepts.GuardStatusBlocked,
Reason: "waiting for backend endpoint",
}, nil
}
return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil
}).
Build()
if err != nil {
return nil, err
}
// 5. Register the primitive with a component.
comp, err := component.NewComponentBuilder().
WithName("frontend").
WithConditionType("FrontendReady").
WithResource(res).
Build()
Guards versus prerequisites
A guard handles a dependency within one component: an earlier resource extracts data after it is applied, and a later resource's guard checks that data before proceeding. For a dependency between components (the frontend cannot start until the backend is ready), use prerequisites on the component builder instead. See Guards for the full behavioral contract.
Unstructured Primitives¶
| Primitive | Category | Documentation |
|---|---|---|
pkg/primitives/unstructured/static |
Static | unstructured.md |
pkg/primitives/unstructured/workload |
Workload | unstructured.md |
pkg/primitives/unstructured/integration |
Integration | unstructured.md |
pkg/primitives/unstructured/task |
Task | unstructured.md |
The unstructured primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type, for example external CRDs or any object known only at runtime. One variant exists per category, each implementing the matching lifecycle interfaces.
Because the framework cannot know the semantics of an unstructured object, it infers no domain-specific defaults. The
builders configure generic safe defaults instead: omit a grace handler and the resource is treated as Healthy; omit
suspension handlers and it reports Suspended with a no-op suspend mutation. Only the converge or operational status
handler is required at build time. All variants share a single Mutator and use an UnstructuredContentEditor for
nested-field edits. See unstructured.md for details.
Implementing a Custom Resource¶
When the built-in primitives do not cover your kind, implement a custom resource wrapper for any Kubernetes object,
including your own CRDs. The framework provides generic building blocks in pkg/generic that handle reconciliation
mechanics, mutation sequencing, and suspension, so you supply only the type-specific logic.
See the Custom Resource Implementation Guide for a complete walkthrough covering mutator design, status handlers, builders, and component registration.