Skip to content

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.

object namespace cannot be empty

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:

cluster-scoped object must not have a namespace

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:

b.WithMutation(first, second, third)

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:

  1. Object metadata edits
  2. Spec edits (for example EditDeploymentSpec)
  3. Pod-template metadata edits
  4. Pod-spec edits
  5. Container presence operations (add / remove)
  6. Container edits
  7. Init-container presence operations
  8. 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:

gate := feature.NewVersionGate(currentVersion, constraints).When(spec.FeatureFlag)

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()
m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error {
    e.EnsureArg("--log-format=json")
    return nil
})

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.