` makes the pattern immediately recognizable. When scanning a builder
-chain, `BackwardCompatV1Container` tells you exactly what it does and why it exists without reading the implementation.
-
-`LessThan` here is a user-provided implementation of `feature.VersionConstraint` that wraps a semver comparison. The
-interface requires a single `Enabled(version string) (bool, error)` method, so you can use any semver library to
-implement your constraints.
-
-For version 2.0 and above, the gate is inactive and the baseline is applied as-is. For older versions, the mutation
-adjusts the container name and ports back to the legacy shape. The mutation is explicitly about backward compatibility,
-gated on the versions that need it, and will stop running entirely once those versions are no longer supported.
-
-### Verifying backward compatibility mutations
-
-When you update the baseline, you need confidence that older versions still produce the same object they did before. The
-framework provides a `golden` package for this. `AssertYAML` accepts any resource that implements `Preview`, renders it
-to YAML, and compares the result against a golden file.
-
-```go
-import "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
-
-var update = flag.Bool("update", false, "update golden files")
-
-func TestDeploymentShape(t *testing.T) {
- tests := []struct {
- name string
- version string
- golden string
- }{
- {name: "v1.9", version: "1.9.0", golden: "testdata/deployment-v1.9.0.yaml"},
- {name: "v2.0", version: "2.0.0", golden: "testdata/deployment-v2.0.0.yaml"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- owner := &v1alpha1.MyApp{
- Spec: v1alpha1.MyAppSpec{Version: tt.version},
- }
-
- res, err := buildDeployment(owner)
- require.NoError(t, err)
-
- golden.AssertYAML(t, tt.golden, res, golden.Update(*update))
- })
- }
-}
-```
-
-Each version you care about gets a golden file. When the baseline evolves, run `go test -update` to regenerate the
-golden files, then review the diff. The current version's golden file updates to reflect the new shape, but older
-version golden files should stay unchanged. If a baseline change accidentally breaks a backward compat mutation, the
-snapshot diff shows exactly what shifted.
-
-A reasonable heuristic for the boundary: if a field is always present regardless of feature flags or version, it belongs
-in the baseline. If it is conditional, it belongs in a mutation.
-
-To snapshot an entire component at once, use `AssertComponentYAML`. It calls `comp.Preview()`, serializes every managed
-resource the component would apply into a single multi-document YAML file (documents joined by `---` separators, in
-registration order), and compares the result against the golden file. This is useful when you want to verify that a
-cross-component change does not accidentally alter any resource's shape.
-
-```go
-func TestComponentShape(t *testing.T) {
- owner := &v1alpha1.MyApp{
- Spec: v1alpha1.MyAppSpec{Version: "2.0.0"},
- }
-
- comp, err := buildWebComponent(owner)
- require.NoError(t, err)
-
- golden.AssertComponentYAML(t, "testdata/web-component.yaml", comp, golden.Update(*update))
-}
-```
-
-Read-only and delete resources are excluded from the component preview; only resources the component would actively
-apply appear in the golden file.
+The baseline owns structure; the image mutation owns the version-dependent value. When the version changes, exactly one
+mutation produces the new image and nothing in the baseline contradicts it.
## One Component Per Logical Condition
-Each component reports exactly one condition on the owner CRD's status. If your operator needs to report `DatabaseReady`
-and `WebInterfaceReady` independently, those are two components.
+Each component reports exactly one condition on the owner's status. If users would ask "is the backend ready?" and "is
+the frontend ready?" as separate questions, those are separate components.
```go
-dbComp, err := component.NewComponentBuilder().
- WithName("database").
- WithConditionType("DatabaseReady").
- WithResource(statefulSet).
- WithResource(dbService).
+backendComp, err := component.NewComponentBuilder().
+ WithName("backend").
+ WithConditionType("BackendReady").
+ WithResource(backendService).
+ WithResource(backendStatefulSet).
Build()
-webComp, err := component.NewComponentBuilder().
- WithName("web-interface").
- WithConditionType("WebInterfaceReady").
- WithResource(deployment).
- WithResource(ingress).
+frontendComp, err := component.NewComponentBuilder().
+ WithName("frontend").
+ WithConditionType("FrontendReady").
+ WithResource(frontendService).
+ WithResource(frontendDeployment).
Build()
```
-Separate components give users and monitoring systems granular observability: "the database is down" is a different
-signal from "the web interface is scaling." A problem in one component does not mask the status of another.
-
-When two components depend on each other (e.g., the web interface needs the database to be ready before it can be
-created), use [prerequisites](#use-prerequisites-for-cross-component-dependencies) to express that dependency
-declaratively. Guards and data extraction work within a single component's resource list; prerequisites work between
-components.
-
-### When to split vs. combine
-
-**Split** when:
-
-- Users would ask "is the database ready?" and "is the web interface ready?" as separate questions.
-- Resources can be independently healthy, degraded, or suspended.
-- Failure in one group should not mask the status of another.
-
-**Combine** when:
+Separate components give users and monitoring granular observability: "the backend is down" is a different signal from
+"the frontend is scaling," and a problem in one does not mask the status of another.
-- Resources only make sense as a unit (a deployment and its service, a job and its configmap).
-- Reporting separate conditions would add noise without actionable information.
-- Resources share guards or data extraction chains that would be awkward to split across components.
+**Split** when users would ask about the parts separately, when parts can be independently healthy or degraded, or when
+a failure in one should not mask another. **Combine** when resources only make sense as a unit (a Deployment and the
+Service that fronts it have no useful readiness independent of each other), or when separate conditions would add noise
+without actionable information.
-A deployment and its associated service are a common example of resources worth combining: the service has no useful
-"ready" semantics independent of the deployment it fronts. Reporting them as one condition (`WebInterfaceReady`) is
-clearer than splitting them into `DeploymentReady` and `ServiceReady`.
+Controllers typically reconcile every component and fold the per-component conditions into one top-level aggregate, for
+example a `Ready` condition that names the components that are not ready. The component conditions stay granular for
+debugging; the aggregate gives a single signal to gate on. See [Keep Controllers Thin](#keep-controllers-thin) for the
+aggregation pattern.
## Keep Controllers Thin
-Controllers should fetch the owner, decide which components to build, call `Reconcile()`, and defer a single
-`component.FlushStatus` to persist status. Business logic, resource construction, and feature decisions belong in
-components and their resource builders.
+A controller should fetch the owner, decide which components to build, reconcile each one, and defer a single
+[`component.FlushStatus`](component.md#persisting-status-with-flushstatus) to persist status. Resource construction,
+feature decisions, and mutation logic belong in component-building functions, which then test as pure functions: owner
+in, component out, no cluster required.
+
+When a controller owns several components, reconcile them all, collect the first error but **continue on error** so one
+failing component does not stall the rest, and flush once at the end.
```go
-func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
- owner := &v1alpha1.MyApp{}
- if err := r.Get(ctx, req.NamespacedName, owner); err != nil {
+func (r *WebAppReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_ reconcile.Result, err error) {
+ app := &v1alpha1.WebApp{}
+ if err := r.Get(ctx, req.NamespacedName, app); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err)
}
@@ -338,204 +147,197 @@ func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (_
Scheme: r.Scheme,
Recorder: r.Recorder,
Metrics: r.Metrics,
- Owner: owner,
+ Owner: app,
}
+ // Persist all staged conditions exactly once, even on the error path.
defer func() {
if flushErr := component.FlushStatus(ctx, recCtx); flushErr != nil && err == nil {
err = flushErr
}
}()
- comp, err := buildWebComponent(owner)
- if err != nil {
- return reconcile.Result{}, err
+ comps, buildErr := buildComponents(app)
+ if buildErr != nil {
+ return reconcile.Result{}, buildErr
}
- return reconcile.Result{}, comp.Reconcile(ctx, recCtx)
+ var firstErr error
+ for _, comp := range comps {
+ if rErr := comp.Reconcile(ctx, recCtx); rErr != nil && firstErr == nil {
+ firstErr = rErr
+ }
+ }
+ return reconcile.Result{}, firstErr
}
```
-This keeps controller logic trivial to test (there is almost nothing to test) and makes component construction functions
-independently testable as pure functions: owner in, component out, no cluster required.
+`Component.Reconcile` mutates the owner's conditions **in memory only**. Persisting them is the controller's job, via
+one `FlushStatus` per reconcile, deferred so that conditions set on error paths are still written when `Reconcile`
+returns an error.
+
+!!! warning
+
+ Do not call `FlushStatus` between component reconciles. With several components per controller, the point of the
+ split is to stage every condition in memory and write them once at the end. Flushing between components reintroduces
+ the 409 conflict pattern the split exists to avoid.
-### Flushing status is the controller's job
+If you do not want condition metrics, leave `ReconcileContext.Metrics` as `nil`; `FlushStatus` tolerates a nil recorder
+and skips metric emission.
-`Component.Reconcile` only mutates the owner's conditions in memory. Persisting them is explicitly the controller's
-responsibility, via one `component.FlushStatus` call per reconcile, typically deferred so that conditions set by error
-paths (for example, `fail()` in the framework) are still written when `Reconcile` returns an error.
+Building the component set from a pure resolver `(spec, version) -> []*component.Component` keeps the loop stable:
+enabling an optional feature changes which components the resolver returns without touching the reconcile loop.
-Do not call `FlushStatus` in between component reconciles. With several components per controller the point of the split
-is to stage all their conditions in memory first and write them once at the end. Flushing between components brings back
-the exact 409 conflict pattern the split was introduced to eliminate.
+## Reconciler Error Handling and Requeueing
-If you do not want to emit condition metrics, leave `ReconcileContext.Metrics` as `nil`. `FlushStatus` tolerates a nil
-recorder and simply skips metric emission.
+The framework distinguishes between conditions and errors. A resource that is merely converging (a rolling Deployment, a
+`Blocked` guard) reports its state through its condition and does **not** return an error; the framework re-queues the
+owner through controller-runtime's normal watch and resync mechanics. A returned error is for a genuine fault: an API
+call failed, a mutation could not be applied, a version is below the supported floor.
+
+Return the error from `Reconcile` and let controller-runtime apply exponential backoff. Avoid setting an explicit
+`reconcile.Result{RequeueAfter: ...}` unless you have a concrete reason to poll on a fixed cadence; in most cases the
+combination of resource watches and the manager's resync period already re-queues at the right time. Because
+`FlushStatus` is deferred, the owner's conditions are written before the error propagates, so the failure is visible in
+status even while controller-runtime backs off.
## Resource Registration Order Is Execution Order
-Resources are reconciled in the exact order they are registered with `WithResource()`. This is deliberate: guards and
-data extractors depend on it.
+Resources reconcile in the exact order they are registered with `WithResource`. This is deliberate: guards and data
+extractors depend on it, and reading the calls top to bottom tells you the order with no implicit dependency graph to
+reconstruct.
-If resource B needs data extracted from resource A, register A first:
+Register dependencies before dependents. A common per-component bundle reads as a dependency chain: read-only Secret
+references first (with [`BlockOnAbsence`](component.md#resource-registration-options) so an absent Secret blocks the
+rest rather than erroring), then the ServiceAccount for workloads that need an identity, then the Service, then the
+workload last.
```go
comp, err := component.NewComponentBuilder().
- WithName("cloud-resources").
- WithConditionType("CloudReady").
- WithResource(roleRes). // Applied first, ARN extracted
- WithResource(bucketRes). // Guard checks ARN, applied second
+ WithName("backend").
+ WithConditionType("BackendReady").
+ WithResource(dbCredentialsSecret, component.ReadOnly(), component.BlockOnAbsence()). // must exist first
+ WithResource(backendServiceAccount).
+ WithResource(backendService).
+ WithResource(backendStatefulSet). // applied last; depends on everything above
Build()
```
-Reading the `WithResource()` calls top to bottom tells you the execution order. There is no implicit dependency graph to
-reconstruct. The flip side is that reordering these calls can silently break data flow between guards and extractors.
-Document the dependency when it exists.
-
-## Mutation Ordering and Container Name Dependencies
+The flip side is that reordering these calls can silently break data flow between extractors and guards, so document the
+dependency where one exists.
-Mutations within a resource are also applied in registration order. Each mutation gets its own feature scope, and later
-mutations see the resource as modified by all earlier mutations. This is normally invisible because most mutations are
-independent. It becomes visible when a backward compat mutation renames a container and a feature mutation needs to
-target that container by name.
+## Mutation Ordering and Container-Name Dependencies
-Consider a deployment where the baseline container is named `"app"` (v2+), and a backward compat mutation renames it to
-`"server"` for versions before 2.0. A new mutation that sets `LOG_LEVEL=debug` on the application container faces a
-question: does it target `"app"` or `"server"`?
+Mutations within a resource also apply in registration order, and each one sees the resource as modified by all earlier
+mutations. This is invisible while mutations are independent. It becomes visible when a compat mutation renames a
+container and a later mutation targets that container by name.
-The answer depends on registration order, and there are two rules that eliminate the problem.
+Two rules eliminate the problem:
-### Use broad selectors for version-independent mutations
-
-If a mutation applies to all versions regardless of container name, use `AllContainers()`, `EnsureContainerEnvVar`, or
-`EnsureContainerArg`. These selectors never reference a name, so they work whether or not a backward compat rename has
-fired. No ordering constraint is needed.
+- **Use broad selectors for version-independent mutations.** `selectors.AllContainers()`, or the mutator's
+ `EnsureContainerEnvVar` / `EnsureContainerArg`, never reference a name, so they apply regardless of a rename and need
+ no ordering constraint.
+- **Register name-specific mutations before the compat mutation that renames the container.** Placed before the rename,
+ the mutation sees the baseline name, and its edits carry through because the compat mutation overwrites only specific
+ fields (such as `Name` and `Ports`), not the whole container.
```go
-// TracingSidecar uses EnsureContainerEnvVar (wraps AllContainers) and is order-insensitive.
-func TracingSidecarMutation(enabled bool) deployment.Mutation {
- return deployment.Mutation{
- Name: "Tracing",
- Feature: feature.NewVersionGate("any", nil).When(enabled),
- Mutate: func(m *deployment.Mutator) error {
- m.EnsureContainer(corev1.Container{
- Name: "jaeger-agent",
- Image: "jaegertracing/jaeger-agent:1.28",
- })
- m.EnsureContainerEnvVar(corev1.EnvVar{
- Name: "JAEGER_AGENT_HOST",
- Value: "localhost",
- })
- return nil
- },
- }
-}
+res, err := deployment.NewBuilder(frontendDeployment(app)).
+ WithMutation(debugLogging(app)). // targets ContainerNamed("frontend") by name
+ WithMutation(compatV1Container(app)). // renames "frontend" -> "web" for versions < 2.0
+ WithMutation(tracingSidecar(app)). // AllContainers, order-insensitive
+ Build()
```
-### Register name-specific mutations before backward compat renames
+Do not work around ordering by matching multiple names (`ContainersNamed("frontend", "web")`); that couples the mutation
+to every name the container has ever had. The primitives overview covers the
+[ordering semantics within a feature](primitives.md#ordering-within-a-feature) in full.
-When a mutation must target a specific container by name, register it before the backward compat mutation that renames
-it. Registered in that position, the mutation sees the baseline name because the rename has not fired yet. Its edits
-carry through the rename because the backward compat mutation only overwrites specific fields (`Name`, `Ports`), not the
-entire container.
+## Layer Mutations in a Fixed Order
-```go
-// DebugLogging targets ContainerNamed("app"), so it must come before BackwardCompatV1Container.
-func DebugLoggingMutation(enabled bool) deployment.Mutation {
- return deployment.Mutation{
- Name: "DebugLogging",
- Feature: feature.NewVersionGate("any", nil).When(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
- },
- }
-}
-```
+Order a resource's mutations into fixed layers so the pipeline reads the same way for every workload:
-The registration order makes the constraint explicit:
+1. **defaults**: the operator's desired state for the current version (image, default env, sidecars).
+2. **compat**: version-gated rollbacks that restore older shapes (see below).
+3. **overrides**: values from the user's spec, applied last among the value-producing layers so user input wins.
+4. **checksum**: a final annotation mutation that stamps content hashes onto the pod template (see
+ [Provide a User-Override Escape Hatch](#provide-a-user-override-escape-hatch-as-the-last-mutation) and the rotation
+ pattern below).
-```go
-res, err := deployment.NewBuilder(BaseDeployment(owner)).
- WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // targets "app" by name
- WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // renames "app" → "server" for v1
- WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // uses AllContainers, order-insensitive
- Build()
+```mermaid
+flowchart LR
+ B[Baseline
latest shape] --> D[defaults]
+ D --> C[compat
version rollbacks]
+ C --> O[overrides
user spec wins]
+ O --> H[checksum
pod-template annotations]
```
-For v2+, the backward compat mutation is inactive and `DebugLogging` sets the env var on `"app"`. For v1, `DebugLogging`
-sets the env var on `"app"`, then `BackwardCompatV1Container` renames the container to `"server"` and resets its ports.
-The env var survives because the rename does not touch `Env`.
-
-### Ordering with multiple backward compat mutations
-
-When multiple backward compat mutations exist, the same chained revert ordering from
-[Revert mutations vs. forward mutations](#revert-mutations-vs-forward-mutations) applies: register the newest first
-(closest to the baseline) and the oldest last. The additional constraint here is that feature mutations targeting a
-container by name must come before the backward compat mutations that rename it. Feature mutations using broad selectors
-can go anywhere.
+A field whose shape changed between versions is best handled by a **pair of mutually exclusive version gates** (`>= V`
+and `< V`), so exactly one fires and the two layers never disagree.
```go
-res, err := deployment.NewBuilder(BaseDeployment(owner)). // baseline is v3
- WithMutation(DebugLoggingMutation(owner.Spec.EnableDebugLogging)). // must come before backward compat renames
- WithMutation(BackwardCompatV2Container(owner.Spec.Version)). // reverts v3 → v2 for < 3.0.0
- WithMutation(BackwardCompatV1Container(owner.Spec.Version)). // reverts v2 → v1 for < 2.0.0
- WithMutation(TracingSidecarMutation(owner.Spec.EnableTracing)). // order-insensitive (broad selector)
- Build()
+geV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{atLeast("2.0.0")})
+ltV := feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")})
```
-When you add a new version that changes the resource structure, update the baseline and insert the new backward compat
-mutation before the existing ones.
-
-### What to avoid
+This layering keeps every override decision in one place and makes the compat layer self-contained, so it can shrink as
+old versions drop out.
-Do not work around the ordering problem by matching multiple names:
+## Prefer Reverting Compat Mutations Over Forward Mutations
-```go
-// Anti-pattern: couples this mutation to knowledge of legacy naming.
-m.EditContainers(selectors.ContainersNamed("app", "server"), func(ce *editors.ContainerEditor) error {
- ce.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"})
- return nil
-})
-```
+When a structural version change lands, update the baseline to the new shape and add a **revert** mutation gated on the
+older versions, rather than holding the baseline at the old shape and patching it forward. The revert direction is
+easier to maintain:
-This works today but breaks if a future version renames the container again. The mutation now needs to track every name
-the container has ever had. Instead, target the baseline name and
-[register the mutation before the backward compat rename](#register-name-specific-mutations-before-backward-compat-renames).
+- **Adding a revert mutation does not change existing ones.** Each revert handles one version step (the v2 revert turns
+ v3 back into v2; the v1 revert turns v2 into v1). Dropping support for a version deletes exactly one mutation.
+- **Forward mutations grow fragile ordering dependencies.** A v3 forward patch may assume a v2 patch already ran;
+ deleting the v2 patch later breaks v3 silently.
+- **You read the baseline far more often than you change it.** Baseline-as-latest shows the current shape at a glance;
+ baseline-as-original forces a contributor to replay every forward patch mentally.
-### When a backward compat mutation replaces the entire container
+The cost is one new revert mutation per structural version change. That friction is a forcing function: it makes the
+backward-compatibility decision explicit instead of letting old shapes silently persist as the baseline drifts.
-The carry-through property depends on the backward compat mutation only overwriting specific fields. If a backward
-compat mutation replaces the entire container (sets all fields, not just `Name` and `Ports`), edits from earlier
-mutations are lost. In that case, the mutation is effectively a full override and later mutations should target the
-post-rename name via version gating rather than relying on ordering.
+```go
+func compatV1Container(app *v1alpha1.WebApp) deployment.Mutation {
+ return deployment.Mutation{
+ Name: "CompatV1Container",
+ Feature: feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}),
+ Mutate: func(m *deployment.Mutator) error {
+ m.EditContainers(selectors.ContainerNamed("frontend"), func(e *editors.ContainerEditor) error {
+ e.Raw().Name = "web" // legacy name before 2.0
+ return nil
+ })
+ return nil
+ },
+ }
+}
+```
-See the [mutations-and-gating example](../examples/mutations-and-gating/) for a working demonstration of these patterns.
+A compat mutation should only **roll back**, never introduce a new field. The number of revert mutations is bounded by
+the number of supported versions, and each one deletes cleanly when its version falls out of support.
-## Use Data Extraction and Guards for Resource Dependencies
+## Use Data Extraction and Guards for Intra-Component Dependencies
-When one resource depends on data from another, use a data extractor on the first resource and a guard on the second. Do
-not assume a resource is ready simply because it was registered earlier.
+When one resource depends on data from another resource in the **same** component, register a data extractor on the
+source and a guard on the dependent. Do not assume a resource is ready just because it was registered earlier.
```go
var roleARN string
-roleRes, _ := static.NewBuilder(newCloudRole(owner)).
+roleRes, _ := static.NewBuilder(cloudRole(app)).
WithDataExtractor(func(obj uns.Unstructured) error {
- arn, _, _ := unstructured.NestedString(obj.Object, "status", "arn")
- roleARN = arn
+ roleARN, _, _ = unstructured.NestedString(obj.Object, "status", "arn")
return nil
}).
Build()
-bucketRes, _ := static.NewBuilder(newCloudBucket(owner)).
+bucketRes, _ := static.NewBuilder(cloudBucket(app)).
WithGuard(func(_ uns.Unstructured) (concepts.GuardStatusWithReason, error) {
if roleARN == "" {
return concepts.GuardStatusWithReason{
Status: concepts.GuardStatusBlocked,
- Reason: "waiting for cloud provider role ARN",
+ Reason: "waiting for cloud role ARN",
}, nil
}
return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil
@@ -543,167 +345,205 @@ bucketRes, _ := static.NewBuilder(newCloudBucket(owner)).
Build()
```
-The guard prevents the dependent resource from being applied until its precondition is met, and a blocked guard surfaces
-as a `Blocked` condition reason so users can see why a resource has not been created yet. The shared variable
-(`roleARN`) is scoped to the reconciliation call, which prevents state leakage between reconciles.
-
-### Prefer stable values for guard conditions
-
-A guard re-evaluates on every reconcile. If the extracted value it depends on is unstable (it can disappear, change, or
-transiently become empty), the guard will re-block after the dependent resource has already been created. In most cases
-this is not intentional. The resource is already running, but the guard now reports `Blocked` and skips reconciliation
-for everything after it.
+A blocked guard surfaces as a `Blocked` condition reason, so users can see why a resource has not been created yet. The
+shared variable is scoped to one reconcile, which prevents state leaking between reconciles.
-Good candidates for guard conditions are values that appear once and remain stable: a status field written by a
-controller (an ARN, a provisioned IP, a generated credential reference). Poor candidates are values that fluctuate
-during normal operation, such as replica counts, transient annotations, or fields that get cleared during rolling
-updates.
-
-If you genuinely need to react to a value disappearing after initial creation, that is a valid use case, but it should
-be a deliberate design choice rather than an accidental side effect of choosing an unstable extraction target.
+Prefer **stable** values for guard conditions. A guard re-evaluates every reconcile, so a value that can transiently
+disappear (a replica count, a field cleared during a rolling update) will re-block a resource that is already running.
+Good targets appear once and persist: a status field written by a controller, a provisioned IP, a generated credential
+reference.
## Use Prerequisites for Cross-Component Dependencies
-When one component cannot start until another is ready, use a prerequisite on the dependent component rather than
-orchestrating the ordering in the controller.
+When one component cannot start until **another component** is ready, attach a prerequisite rather than orchestrating
+ordering in the controller.
```go
-dbComp, err := component.NewComponentBuilder().
- WithName("database").
- WithConditionType("DatabaseReady").
- WithResource(statefulSet).
- Build()
-
-webComp, err := component.NewComponentBuilder().
- WithName("web-interface").
- WithConditionType("WebInterfaceReady").
- WithPrerequisite(component.DependsOn("DatabaseReady")).
- WithResource(deployment).
+frontendComp, err := component.NewComponentBuilder().
+ WithName("frontend").
+ WithConditionType("FrontendReady").
+ WithPrerequisite(component.DependsOn("BackendReady")).
+ WithResource(frontendService).
+ WithResource(frontendDeployment).
Build()
```
-The web-interface component will not reconcile any resources until the `DatabaseReady` condition on the owner is `True`.
-Once the component passes through to normal reconciliation for the first time, the prerequisite is permanently passed
-and never re-evaluated.
+The frontend reconciles no resources until `BackendReady` on the owner is `True`. Once the component passes through to
+normal reconciliation for the first time, the prerequisite is permanently satisfied and never re-evaluated.
-This is the right tool when a component needs something to exist before it can be created. It is not the right tool for
-ongoing health dependencies. If the database goes down after the web interface is already running, the web interface
-component continues reconciling its own resources. The database's condition reflects the problem, and the web
-interface's condition reflects its own health independently. Conflating the two would lose the granularity that separate
-components provide.
+Prerequisites are for **startup** ordering, not ongoing health. If the backend goes down after the frontend is already
+running, the frontend keeps reconciling its own resources; the two conditions reflect their own health independently.
+Contrast with [guards](#use-data-extraction-and-guards-for-intra-component-dependencies), which work within a single
+component and re-evaluate every reconcile. See the [prerequisite behavior](component.md#prerequisite-behavior) section
+for the full lifecycle.
-**Guards vs. prerequisites:** Guards are for resource dependencies within a single component (resource B depends on data
-from resource A). Prerequisites are for startup dependencies between components. Guards re-evaluate every reconcile;
-prerequisites evaluate only until the component's first successful reconciliation.
+## Use Feature Gates for Optional Components and Conditional Resources
-## Use Component Feature Gates for Optional Components
+Gate optional pieces with a feature gate rather than branching in the controller. The framework then owns the full
+lifecycle, including deletion when the gate flips off.
-When an entire component should only exist based on a feature flag, use a component-level feature gate rather than
-conditionally building the component in the controller.
+For an entire optional component, use a **component** gate:
```go
-comp, err := component.NewComponentBuilder().
- WithName("monitoring").
- WithConditionType("MonitoringReady").
- WithFeatureGate(feature.NewVersionGate(owner.Spec.Version, nil).When(owner.Spec.MonitoringEnabled)).
- WithResource(exporterDeployment).
- WithResource(exporterService).
+cacheComp, err := component.NewComponentBuilder().
+ WithName("cache").
+ WithConditionType("CacheReady").
+ WithFeatureGate(feature.NewVersionGate(app.Spec.Version, nil).When(app.Spec.Cache.Enabled)).
+ WithResource(cacheService).
+ WithResource(cacheDeployment).
Build()
```
-When the gate is disabled, the framework deletes all of the component's resources and reports `True/Disabled`. When
-re-enabled, the component reconciles normally. This is different from resource-level feature gating, which controls
-individual resources within a component. Use a component gate when the entire component is conditional; use resource
-gates when only some resources within the component are conditional.
+When the gate is disabled the framework deletes the component's resources and reports `True/Disabled`. A disabled gate
+takes precedence over suspension.
-A disabled component gate takes precedence over suspension. If both the gate and suspension are active, the component is
-treated as disabled (resources deleted), not suspended (resources scaled down).
+For a single optional resource the component owns, use [`component.GatedBy`](component.md#feature-gates) on
+`WithResource`:
-## Mutations Describe Intent, Not Observation
+```go
+comp, _ := component.NewComponentBuilder().
+ WithName("frontend").
+ WithConditionType("FrontendReady").
+ WithResource(frontendDeployment).
+ WithResource(tracingConfigMap, component.GatedBy(tracingGate)). // deleted when the gate is off
+ Build()
+```
-Mutations operate on the desired object, not the server's current state. A mutation should be a pure function of the
-owner spec and other static inputs available at build time. It should never try to read the resource's live cluster
-state to decide what to write.
+A disabled `GatedBy` gate deletes the resource on the next reconcile. For an optional resource the component does
+**not** own (a read-only Secret reference behind an optional spec field), use `IncludeWhen`, which omits the resource
+without ever deleting it. The [IncludeWhen vs. GatedBy](component.md#includewhen-vs-gatedby) section covers the
+distinction.
-This is not just a style preference. Within a single resource, the framework runs mutations **before** data extraction.
-A data extractor registered on the same builder as a mutation will not have executed yet when that mutation runs. Any
-closure variable populated by the extractor will still hold its zero value.
+## Provide a User-Override Escape Hatch as the Last Mutation
-Data extractors exist to pass observed state from an **earlier** resource to a **later** resource's guards and
-mutations. They are not a mechanism for feeding a resource's own live state back into its own mutations. If you find
-yourself wanting to do that, reconsider the design: the mutation is likely encoding observation rather than intent.
+Give users a documented way to override operator-emitted values, applied as the last value-producing mutation so their
+input shadows the defaults. A common shape is an optional `spec.ExtraEnv` applied through `EnsureEnvVars` behind a
+`.When` gate.
-A well-written mutation produces the same desired state for the same owner spec, regardless of what currently exists in
-the cluster. This aligns with Server-Side Apply's declarative model and keeps the reconciliation loop predictable.
+```go
+func extraEnv(app *v1alpha1.WebApp) deployment.Mutation {
+ envs := app.Spec.Frontend.ExtraEnv
+ return deployment.Mutation{
+ Name: "ExtraEnv",
+ Feature: feature.NewVersionGate(app.Spec.Version, nil).When(len(envs) > 0),
+ Mutate: func(m *deployment.Mutator) error {
+ m.EditContainers(selectors.ContainerNamed("frontend"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVars(envs)
+ return nil
+ })
+ return nil
+ },
+ }
+}
+```
-## Understand Participation Modes
+Because `EnsureEnvVars` replaces existing entries by name, registering this mutation after the operator's own env
+mutations lets a user value shadow an operator-emitted one without you enumerating every overridable field.
-`ParticipationModeAuxiliary` means "reconciled but not required for health." It does not mean "skipped." A failing
-auxiliary resource still fails the reconciliation. The only difference is that an auxiliary resource's health status
-does not affect whether the component condition becomes Ready.
+A related use of a final mutation is **secret-rotation restart**: each read-only Secret has a data extractor that hashes
+its contents into a shared map, and a final mutation stamps that map onto the pod template as annotations through
+`EditPodTemplateMetadata`. A Secret rotation changes a hash, which changes the pod template, which triggers a rolling
+restart. Keep the map empty during preview so golden snapshots stay stable.
```go
-comp, _ := component.NewComponentBuilder().
- WithName("web-interface").
- WithConditionType("WebInterfaceReady").
- WithResource(deployment). // Required for Ready
- WithResource(metricsExporter, component.Auxiliary()). // Not required for Ready
- Build()
+func checksumAnnotations(hashes map[string]string) deployment.Mutation {
+ return deployment.Mutation{
+ Name: "ChecksumAnnotations",
+ Mutate: func(m *deployment.Mutator) error {
+ m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error {
+ for k, v := range hashes {
+ e.EnsureAnnotation("checksum/"+k, v)
+ }
+ return nil
+ })
+ return nil
+ },
+ }
+}
```
-Use `Auxiliary` for resources that provide supporting functionality (metrics exporters, debug sidecars, optional
-integrations) where their health should not block the component from reporting Ready.
+## Fail Loudly Below the Supported Version Floor
-**Exception**: a blocked guard always contributes to the condition regardless of participation mode. A blocked guard
-halts the reconciliation pipeline, and that must be visible in the condition.
+A version below the supported floor should produce a loud error, not a silently wrong workload. When a compat mutation
+cannot faithfully represent a version, return an error from `Mutate` rather than emitting an approximation.
-## Use Feature Gating for Conditional Resources
+```go
+func compatV1Container(app *v1alpha1.WebApp) deployment.Mutation {
+ return deployment.Mutation{
+ Name: "CompatV1Container",
+ Feature: feature.NewVersionGate(app.Spec.Version, []feature.VersionConstraint{lessThan("2.0.0")}),
+ Mutate: func(m *deployment.Mutator) error {
+ if belowFloor(app.Spec.Version, "1.0.0") {
+ return fmt.Errorf("version %s is below the supported floor 1.0.0", app.Spec.Version)
+ }
+ // ... roll back to the legacy shape
+ return nil
+ },
+ }
+}
+```
-When an entire resource should only exist based on a feature flag or version constraint, pass `component.GatedBy(gate)`
-to `WithResource` rather than conditionally calling `WithResource()` in the controller.
+The error propagates out of `Component.Reconcile`, and because [`FlushStatus`](#keep-controllers-thin) is deferred, the
+failure is recorded on the owner's condition where an operator can see it.
-```go
-tracingGate := feature.NewVersionGate(owner.Spec.Version, nil).When(owner.Spec.TracingEnabled)
+## Name Mutations for Golden Introspection
+
+Give every mutation a `Name`. Names appear in error reporting, and version-matrix golden manifests reference them in
+their `requires` and `forbids` lists, so descriptive names keep those manifests self-documenting. Name compat mutations
+after what they restore (`CompatV1Container`), so a reader scanning a builder chain understands each entry without
+opening its implementation. See [testing.md](testing.md#firing-set-classification) for how named mutations drive
+firing-set classification.
+
+## Understand Participation Modes
+[`component.Auxiliary()`](component.md#resource-registration-options) means "reconciled but not required for health." It
+does not mean "skipped." A failing auxiliary resource still fails the reconciliation; the only difference is that its
+health does not affect whether the component condition becomes Ready.
+
+```go
comp, _ := component.NewComponentBuilder().
- WithName("web-interface").
- WithConditionType("WebInterfaceReady").
- WithResource(deployment).
- WithResource(jaegerSidecar, component.GatedBy(tracingGate)).
+ WithName("frontend").
+ WithConditionType("FrontendReady").
+ WithResource(frontendDeployment). // required for Ready
+ WithResource(metricsExporter, component.Auxiliary()). // not required for Ready
Build()
```
-When the gate evaluates to disabled, the framework deletes the resource if it exists. This handles the full lifecycle:
-creation when enabled, deletion when disabled. Note that deletion is immediate on the next reconcile, so if you need
-graceful decommissioning, handle that before disabling the gate.
+Use `Auxiliary` for supporting resources (metrics exporters, debug sidecars, optional integrations) whose health should
+not block the component from reporting Ready.
+
+!!! note
+
+ A blocked guard always contributes to the condition regardless of participation mode. A blocked guard halts the
+ reconciliation pipeline, and that must be visible in the condition.
## Grace Periods Are Convergence Time
-A component in `Creating` or `Updating` for a few minutes during a rolling update is normal, not a failure. Grace
-periods give the component time to converge before the framework escalates the condition to `Degraded` or `Down`.
+A component in `Creating` or `Updating` for a few minutes during a rolling update is normal, not a failure. The grace
+period gives a component time to converge before the framework escalates the condition to `Degraded` or `Down`.
```go
comp, _ := component.NewComponentBuilder().
- WithName("web-interface").
- WithConditionType("WebInterfaceReady").
- WithResource(deployment).
+ WithName("backend").
+ WithConditionType("BackendReady").
+ WithResource(backendStatefulSet).
WithGracePeriod(5 * time.Minute).
Build()
```
-Set the grace period based on how long the resource legitimately takes to converge. A deployment with a large image pull
-or a slow readiness probe needs a longer grace period than a configmap update. A very long grace period delays detection
-of genuine failures, so choose a value that reflects expected convergence time, not a safety margin.
+Set the grace period to how long the resource legitimately takes to converge. A workload with a large image pull or a
+slow readiness probe needs a longer grace period than a ConfigMap update. A very long grace period delays detection of
+genuine failures, so choose a value that reflects expected convergence time, not a safety margin.
## Handle Cluster-Scoped Resources Explicitly
-When a namespace-scoped owner manages cluster-scoped resources (like `ClusterRole` or `ClusterRoleBinding`), the
-framework cannot set an owner reference because Kubernetes does not allow cross-scope ownership. The framework detects
-this, skips setting the owner reference, and emits an Info log noting the skipped reference and its garbage collection
-implications.
+When a namespace-scoped owner manages cluster-scoped resources (`ClusterRole`, `ClusterRoleBinding`), Kubernetes does
+not allow cross-scope ownership, so the framework cannot set an owner reference. It detects this, skips the reference,
+and logs the skip with its garbage-collection implication.
-This means cluster-scoped resources will not be garbage collected when the owner is deleted. Handle cleanup explicitly
-using `Delete: true` in resource options or a finalizer on the owner CRD:
+The consequence is that those resources are **not** garbage-collected when the owner is deleted. Clean them up
+explicitly with [`component.Delete()`](component.md#resource-registration-options) (or `DeleteWhen`) and a finalizer on
+the owner CRD that keeps the owner alive until its cluster-scoped resources are removed.
```go
comp, _ := component.NewComponentBuilder().
@@ -713,27 +553,54 @@ comp, _ := component.NewComponentBuilder().
Build()
```
+The [cluster-scoped resources](component.md#cluster-scoped-resources) section covers the ownership and deletion behavior
+in full.
+
+## Name Resources to Avoid Multi-Tenant Collisions
+
+A single operator typically reconciles many owner instances in many namespaces. Derive every managed resource's name
+from the owner so two owners never collide. Prefix namespace-scoped resources with the owner name
+(`app.Name + "-backend"`), and for **cluster-scoped** resources, which share one global namespace, include the owner's
+namespace too (`app.Namespace + "-" + app.Name + "-reader"`).
+
+```go
+clusterRoleName := fmt.Sprintf("%s-%s-reader", app.Namespace, app.Name)
+```
+
+A cluster-scoped resource named after the owner alone collides the moment two namespaces hold an owner with the same
+name. Encoding the namespace in the name keeps each instance's resources distinct.
+
## Name Conditions for the Audience Reading Them
-Condition types appear in `kubectl get` output and in monitoring dashboards. Name them for the person or system
-consuming that output, not for the internal implementation.
+Condition types appear in `kubectl get` output and on dashboards. Name them for the person or system consuming that
+output, after the capability, not the Kubernetes resource type backing it.
+
+**Prefer:** `BackendReady`, `FrontendReady`, `MigrationComplete`.
+
+**Avoid:** `StatefulSetHealthy`, `DeploymentReconciled`, `JobFinished`.
-**Prefer**:
+A condition named `DeploymentReconciled` tells a user nothing about which capability is affected. `BackendReady` does.
-- `WebInterfaceReady`
-- `DatabaseReady`
-- `MigrationComplete`
+## Pin Rendered Output Across Supported Versions
-**Avoid**:
+Every supported version's rendered output should be covered by a golden, so that when you change the baseline you can
+prove older versions still render what they did before and that the change touched only the version you intended. This
+is the safety net that lets you keep the baseline at the latest shape (see
+[Represent Desired State in the Baseline Object](#represent-desired-state-in-the-baseline-object)) without silently
+regressing older ones.
-- `DeploymentReconciled`
-- `StatefulSetHealthy`
-- `JobFinished`
+Use `goldengen.Resource` rather than a hand-written loop with one golden per version. It sweeps the versions, collapses
+them into firing regimes (one golden per distinct set of firing mutations, not one per version), asserts which mutations
+fire at each version, and proves through `AssertComplete` that every registered mutation is covered. A new version that
+fires the same mutations as an existing one adds no golden; a version that crosses a gate boundary gets its own. See
+[Testing](testing.md) for the mechanics.
-The audience cares about the feature, not the Kubernetes resource type backing it. A condition named
-`DeploymentReconciled` tells a user nothing about what capability is affected.
+After a deliberate baseline change, regenerate with `go test ./path -update` and review the diff. Only the regimes you
+meant to change should move. If an older regime's golden shifts, a compat mutation broke, and the diff shows exactly
+what.
## Further Reading
For a deeper look at the structural problems these guidelines address, see
[The Missing Layers in Your Kubernetes Operator](https://medium.com/@sourcehawk/the-missing-layers-in-your-kubernetes-operator-306ee8633350).
+
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..23b9cf04
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,94 @@
+# Operator Component Framework
+
+A Go framework for building Kubernetes operators that stay maintainable as they grow.
+
+## Start here
+
+New to the framework? Start with **Getting Started**. Already building and looking for patterns? Read **Guidelines**.
+
+
+
+
+- :material-rocket-launch-outline: **[Getting Started](getting-started.md)**
+
+ Build your first component step by step.
+
+- :material-cube-outline: **[Component](component.md)**
+
+ Lifecycle, status model, and reconciliation phases.
+
+- :material-shape-outline: **[Primitives](primitives.md)**
+
+ Typed wrappers over Kubernetes resources with builders, mutators, and feature gating.
+
+- :material-source-branch: **[Custom Resources](custom-resource.md)**
+
+ Build custom resource wrappers with `pkg/generic`.
+
+- :material-book-open-variant: **[Guidelines](guidelines.md)**
+
+ Patterns for structuring operators well.
+
+- :material-test-tube: **[Testing](testing.md)**
+
+ Golden snapshots and version-matrix golden generation.
+
+
+
+## Why this exists
+
+A Kubernetes operator does far more than create resources. For every resource it manages, a controller has to construct
+the desired object, apply it without overwriting fields it does not own, decide whether the resource is healthy, fold
+that health into a status condition on the owner, and adapt behavior to feature flags and the application versions it
+supports. Written by hand, this logic collects in the reconciler until it is large, repetitive, and hard to test, and
+the part you actually care about, what your operator does, is buried under mechanics that every operator repeats.
+
+This framework gives you two reusable layers, **components** and **resource primitives**, that sit between your
+reconciler and the Kubernetes objects it manages. You declare the desired state of each resource and the behavior that
+varies by flag or version. The framework handles server-side apply, per-resource health, aggregation into a single owner
+condition without update conflicts, lifecycle (grace periods, suspension, prerequisites, guards), and feature gating.
+Controllers stay thin, version-specific behavior lives in small named mutations you can test in isolation, and you keep
+full control where it matters.
+
+## Key features
+
+**Reconciliation and status**
+
+- Resource primitives report health in a way that fits their category, and the component aggregates them into one owner
+ condition with a single status write.
+- Grace periods give resources time to converge before a component reports degraded or down.
+
+**Feature and version management**
+
+- Mutations apply patches only when a flag is set or a version constraint matches, keeping the baseline object clean.
+- Feature gates enable or disable an entire component, or an individual resource within one, based on flags or version
+ ranges.
+
+**Orchestration**
+
+- Guards block a resource and everything after it until a precondition is met.
+- Data extraction harvests values from one resource for guards and mutations on later ones.
+- Prerequisites express startup ordering between components.
+
+## A taste
+
+A component composes resource primitives into one reconcilable unit with a single owner condition. The reconciler builds
+it and hands it to the framework.
+
+```go
+comp, err := component.NewComponentBuilder().
+ WithName("example-app").
+ WithConditionType("AppReady").
+ WithResource(deployResource).
+ WithResource(cmResource, component.DeleteWhen(!owner.Spec.EnableMetrics)).
+ Suspend(owner.Spec.Suspended).
+ Build()
+if err != nil {
+ return err
+}
+
+return comp.Reconcile(ctx, recCtx)
+```
+
+[Getting Started](getting-started.md) walks through building `deployResource` and `cmResource` and wiring the reconcile
+loop end to end.
diff --git a/docs/primitives.md b/docs/primitives.md
index 52fd50ba..6443a554 100644
--- a/docs/primitives.md
+++ b/docs/primitives.md
@@ -1,231 +1,335 @@
-# Resource Primitives
-
-The `primitives` package provides reusable, type-safe wrappers for individual Kubernetes objects. Primitives sit between
-the [Component layer](component.md) and raw Kubernetes resources. They handle the complexities of state synchronization,
-mutation, and lifecycle management so operator authors don't have to.
-
-## Table of Contents
-
-- [What a Primitive Is](#what-a-primitive-is)
-- [Primitive Categories](#primitive-categories)
- - [Static](#static)
- - [Workload](#workload)
- - [Task](#task)
- - [Integration](#integration)
-- [Cluster-Scoped Primitives](#cluster-scoped-primitives)
-- [Lifecycle Interfaces](#lifecycle-interfaces)
-- [Server-Side Apply](#server-side-apply)
-- [Mutation System](#mutation-system)
-- [Mutation Editors](#mutation-editors)
-- [Container Selectors](#container-selectors)
-- [Built-in Primitives](#built-in-primitives)
-- [Usage Examples](#usage-examples)
- - [Creating a primitive](#creating-a-primitive)
- - [Adding a mutation](#adding-a-mutation)
- - [Targeting multiple containers](#targeting-multiple-containers)
- - [Adding a guard](#adding-a-guard)
-- [Unstructured Primitives](#unstructured-primitives)
-- [Implementing a Custom Resource](#implementing-a-custom-resource)
+# Primitives Overview
+
+The `primitives` packages provide reusable, type-safe wrappers for individual Kubernetes objects. A primitive sits
+between the [Component layer](component.md) 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](#built-in-primitives) 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 (e.g., `Deployment`, `ConfigMap`) and encapsulates:
+A primitive wraps a specific Kubernetes kind (for example `Deployment` or `ConfigMap`) and encapsulates:
-- **Desired state baseline**: the ideal configuration of the resource.
-- **Lifecycle integration**: built-in readiness detection, grace handling, and suspension.
-- **Mutation surfaces**: typed APIs for modifying the resource based on active features or version constraints.
-- **Server-Side Apply**: desired state is applied via SSA, preserving server defaults and fields managed by external
+- **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.
-Each primitive implements the `component.Resource` interface, and may additionally implement one or more
+Every primitive implements the `component.Resource` interface, and may additionally implement one or more
[lifecycle interfaces](#lifecycle-interfaces) to participate in component status aggregation.
## Primitive Categories
-The framework categorizes primitives based on their runtime behavior.
+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.
+
+```mermaid
+flowchart TD
+ Start([Choosing a primitive category]) --> Q1{Long-running
process?}
+ Q1 -->|Yes| Workload[Workload
Deployment, StatefulSet, DaemonSet]
+ Q1 -->|No| Q2{Runs to
completion?}
+ Q2 -->|Yes| Task[Task
Job]
+ Q2 -->|No| Q3{Readiness depends
on an external
controller?}
+ Q3 -->|Yes| Integration[Integration
Service, Ingress, CronJob, HPA]
+ Q3 -->|No| Static[Static
ConfigMap, Secret, RBAC, PDB]
+```
### Static
-Examples: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`
+Examples: `ConfigMap`, `Secret`, `ServiceAccount`, RBAC objects, `PodDisruptionBudget`.
-These resources have a mostly static desired state. They are created or updated based on configuration but have no
-complex runtime convergence. They are considered `Ready` as long as they exist. They may optionally implement `Alive` or
-`Operational` for more granular tracking.
+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`
+Examples: `Deployment`, `StatefulSet`, `DaemonSet`.
-These resources represent 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.
+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`
+Examples: `Job`.
-These resources represent short-lived operations that run to completion (database migrations, backups, initialization
-steps). They implement `Completable` and `Suspendable`. When suspended, tasks can be paused (if the underlying resource
-supports it) or deleted and recreated when resumed.
+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`, `Gateway`, `CronJob`
+Examples: `Service`, `Ingress`, `CronJob`, `HPA`.
-These resources define integration points with external or cluster-level systems (networking, load balancers, DNS,
-schedules). Their readiness depends on external controllers and may be delayed or partial. They implement `Operational`,
-`Graceful`, and/or `Suspendable`.
+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`.
-## Cluster-Scoped Primitives
-
-Some Kubernetes resources are cluster-scoped: they have no namespace. Examples include `ClusterRole`,
-`ClusterRoleBinding`, and `PersistentVolume`.
+## Lifecycle Interfaces
-When implementing a primitive for a cluster-scoped kind, the primitive's builder must explicitly call
-`MarkClusterScoped()` on its internal `BaseBuilder` during construction. This changes `ValidateBase()` behavior: instead
-of requiring a non-empty namespace, it rejects a non-empty namespace. The primitive's builder is also responsible for
-providing an identity function that formats the identity string appropriately, typically omitting the namespace segment
-(e.g., `rbac.authorization.k8s.io/v1/ClusterRole/my-role` rather than including a namespace).
+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.
-At reconcile time, the component framework automatically detects scope incompatibilities between the owner CRD and
-managed resources using the cluster's REST mapper. See [Cluster-Scoped Resources](component.md#cluster-scoped-resources)
-in the component documentation for details on owner reference behavior and garbage collection.
+!!! note "This table is the single source of truth"
-## Lifecycle Interfaces
+ Other documentation links here for the interface-to-status mapping. The [component page](component.md) owns how
+ these values are prioritized and aggregated; the [custom resource guide](custom-resource.md) owns the Go constant
+ reference for implementers.
-Primitives implement behavioral interfaces that the component layer uses for status aggregation:
-
-| Interface | Status values reported | Typical use |
+| 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` | `Blocked`, `Unblocked` | Resources with runtime preconditions |
+
+!!! warning "`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.
-## Server-Side Apply
+## Cluster-Scoped Primitives
+
+Some Kubernetes kinds are cluster-scoped and have no namespace, for example `ClusterRole`, `ClusterRoleBinding`, and
+`PersistentVolume`.
-The framework reconciles resources using **Server-Side Apply** (SSA). Each primitive builds the desired state (the
-baseline object with all registered mutations applied) and patches it to the cluster using `client.Apply`. Only 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.
+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.
-Field ownership is tracked automatically by the Kubernetes API server. The field manager name is derived from the owner
-and component: `"{Owner.GetKind()}/{componentName}"`. The framework applies with forced ownership, meaning it will take
-control of any conflicting fields from other managers. Fields that the operator does not include in its desired state
-are left to their current owners.
+```text
+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:
-This approach removes the perpetual-update problem that arises when an operator strips server defaults every reconcile
-cycle, and it allows primitives to coexist naturally in clusters where multiple controllers touch the same resources.
+```text
+cluster-scoped object must not have a namespace
+```
-## Mutation System
+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](component.md#cluster-scoped-resources) for owner-reference and garbage-collection behavior.
-Primitives use a **plan-and-apply pattern**: instead of mutating the Kubernetes object directly, mutations record their
-intent through typed editors, which are applied in a single controlled pass.
+## Server-Side Apply
-This design:
+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.
-- **Prevents uncontrolled mutation**: changes are staged before any object is touched
-- **Enables composability**: independent features contribute edits without knowing about each other
-- **Guarantees ordering**: features apply in registration order; within a feature, categories apply in a fixed sequence
-- **Avoids error-prone slice manipulation**: editors handle presence operations and stable selection internally
+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.
-### Registering multiple mutations
+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.
-`WithMutation` is variadic, so a single call can register several mutations, applied in the order given:
+## 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:
```go
-b.WithMutation(first, second, third)
+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
+}
```
-This composes cleanly with factories that return `[]Mutation`, without breaking the fluent chain:
+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:
```go
-return statefulset.NewBuilder(base).
- WithMutation(defaults.ContainerImage(version, registry)).
- WithMutation(defaults.ClusterEnv(cc)...).
- WithMutation(defaults.ExporterEnv(version)...).
- Build()
+b.WithMutation(first, second, third)
```
-Calling `WithMutation()` with no arguments is a no-op.
+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()`.
+
+```mermaid
+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
+```
-Mutation names must be unique within a resource. `Build` returns an error if two registered mutations share a `Name`,
-because the name is the identifier that gating and error reporting refer to, and a collision would silently mask a
-mis-targeted or dead mutation behind its namesake. The check compares names only and evaluates no feature gates.
+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.
-### Workload-kind-agnostic mutations
+### Ordering within a feature
-`*statefulset.Mutator`, `*deployment.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 framework interface covering exactly that shared surface, so a single mutation can
-target any pod-workload kind.
+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
-Write the emitter once against the interface, then lift it into each kind's `Mutation` with that package's
-`LiftMutation` adapter before registering it:
+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:
```go
-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"
-)
+import "github.com/sourcehawk/operator-component-framework/pkg/feature"
-func emitAuthEnv() 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
- },
- }
-}
+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`:
-zeebeSts.WithMutation(statefulset.LiftMutation(emitAuthEnv()))
-gatewayDeploy.WithMutation(deployment.LiftMutation(emitAuthEnv()))
-nodeAgentDs.WithMutation(daemonset.LiftMutation(emitAuthEnv()))
+```go
+gate := feature.NewVersionGate(currentVersion, []feature.VersionConstraint{
+ semver.MustConstraint(">= 2.0.0"),
+})
```
-Each package's `LiftMutation` returns that package's own `Mutation` type (`statefulset.LiftMutation` returns a
-`statefulset.Mutation`, and so on), which is the concrete type that builder's `WithMutation` accepts. The lift is what
-bridges an interface-typed emitter to the kind's concrete mutation type. The mutation's `Name` and `Feature` gate carry
-through unchanged, so a lifted mutation gates and composes alongside natively-typed mutations on the same builder.
+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:
-The interface deliberately omits operations that are not common to all three kinds: the per-kind spec editors
-(`EditStatefulSetSpec`, `EditDeploymentSpec`, `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.
+```go
+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.
+
+!!! note "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 specific parts of a resource. Every editor exposes a `.Raw()` method
-for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping
-the mutation scoped to that editor's target.
+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](#plan-and-apply). Editors fall into a few
+groups:
+
+- **Container editors** (`ContainerEditor`) for env vars, args, resources, probes, and the like, selected by a
+ [container selector](#container-selectors).
+- **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 documents its available editors in its own [Relevant Editors](#built-in-primitives) section.
+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](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors).
## Container Selectors
-Selectors determine which containers an editor targets. This is important for multi-container pods:
+A container selector decides which containers an editor targets, which matters for multi-container pods. The selectors
+live in `pkg/mutation/selectors`:
```go
selectors.AllContainers() // every container in the pod
selectors.ContainerNamed("app") // a single container by name
-selectors.ContainersNamed("web", "api") // multiple containers 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) // container at a specific index
+selectors.ContainerAtIndex(0) // the container at a given index
```
-Selectors are evaluated against the container list _after_ any presence operations (add/remove) within the same mutation
-have been applied. This means a single mutation can safely add a container and then configure it.
+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:
+
+```go
+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
@@ -255,88 +359,114 @@ have been applied. This means a single mutation can safely add a container and t
## Usage Examples
-### Creating a primitive
-
-```go
-import "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
-
-base := &appsv1.Deployment{
- ObjectMeta: metav1.ObjectMeta{
- Name: "web-server",
- Namespace: owner.Namespace,
- },
- // ... spec
-}
-
-resource, err := deployment.NewBuilder(base).
- Build()
-```
-
-### Adding a mutation
-
-```go
-import (
- corev1 "k8s.io/api/core/v1"
- "github.com/sourcehawk/operator-component-framework/pkg/feature"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
- "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
- "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors"
-)
-
-resource, err := deployment.NewBuilder(base).
- WithMutation(deployment.Mutation{
- Name: "add-proxy-sidecar",
- Feature: feature.NewVersionGate(version, nil),
- 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
+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.
+
+=== "Building and registering a primitive"
+
+ ```go
+ 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,
},
- }).
- Build()
-```
-
-### Targeting multiple containers
-
-```go
-m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error {
- e.EnsureArg("--log-format=json")
- return nil
-})
-```
-
-### Adding a guard
-
-Guards block a resource from being applied until a precondition is met. Combined with data extraction, they enable
-runtime dependencies between resources: an earlier resource extracts data after it is applied, and a later resource's
-guard checks that data before proceeding.
-
-```go
-resource, err := deployment.NewBuilder(base).
- WithGuard(func(_ appsv1.Deployment) (concepts.GuardStatusWithReason, error) {
- if roleARN == "" {
- return concepts.GuardStatusWithReason{
- Status: concepts.GuardStatusBlocked,
- Reason: "waiting for IAM role ARN",
- }, nil
- }
- return concepts.GuardStatusWithReason{Status: concepts.GuardStatusUnblocked}, nil
- }).
- Build()
-```
-
-Guards handle dependencies between resources **within** a single component. For dependencies **between** components
-(e.g., "the web interface cannot start until the database is ready"), use [prerequisites](component.md#prerequisites) on
-the component builder instead.
-
-See [Guards](component.md#guards) in the component documentation for the full behavioral contract and a complete example
-showing data extraction feeding into a guard.
+ 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()
+ ```
+
+=== "Targeting multiple containers"
+
+ ```go
+ m.EditContainers(selectors.ContainersNamed("web", "api"), func(e *editors.ContainerEditor) error {
+ e.EnsureArg("--log-format=json")
+ return nil
+ })
+ ```
+
+!!! note "Guards versus prerequisites"
+
+ A [guard](component.md#guards) 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](component.md#prerequisites) on the component builder instead. See
+ [Guards](component.md#guards) for the full behavioral contract.
## Unstructured Primitives
@@ -348,22 +478,20 @@ showing data extraction feeding into a guard.
| `pkg/primitives/unstructured/task` | Task | [unstructured.md](primitives/unstructured.md) |
The unstructured primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type, for
-example, Crossplane resources, external CRDs, or any object known only at runtime. One variant exists per
-[lifecycle category](#primitive-categories), each implementing the corresponding interfaces.
-
-Because the framework cannot know the semantics of an unstructured object, it does **not infer any semantic or
-domain-specific defaults**. The builders instead configure generic safe defaults: if you omit a grace handler, the
-primitive treats the resource as Healthy; if you omit suspension handlers, the primitive reports Suspended and the
-suspend mutation is a no-op. Only the converge/operational status handler is required at build time.
+example external CRDs or any object known only at runtime. One variant exists per [category](#primitive-categories),
+each implementing the matching lifecycle interfaces.
-The unstructured primitives share a single `Mutator` and use an `UnstructuredContentEditor` for manipulating nested
-fields in the object's content map. See [unstructured.md](primitives/unstructured.md) for full details.
+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](primitives/unstructured.md) for details.
## Implementing a Custom Resource
-When the built-in primitives do not cover your use case, you can implement custom resource wrappers for any Kubernetes
-object, including custom CRDs. The framework provides generic building blocks in `pkg/generic` that handle
-reconciliation mechanics, mutation sequencing, and suspension, so you only need to provide type-specific logic.
+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](custom-resource.md) for a complete walkthrough covering mutator design,
status handlers, builders, and component registration.
diff --git a/docs/primitives/clusterrole.md b/docs/primitives/clusterrole.md
index d2de78e4..19657583 100644
--- a/docs/primitives/clusterrole.md
+++ b/docs/primitives/clusterrole.md
@@ -1,27 +1,28 @@
# ClusterRole Primitive
-The `clusterrole` primitive is the framework's built-in static abstraction for managing Kubernetes `ClusterRole`
-resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.rules`,
-`.aggregationRule`, and object metadata.
+The `clusterrole` primitive wraps a Kubernetes `ClusterRole` and manages RBAC policy rules, aggregation rules, and
+object metadata within the component lifecycle.
-ClusterRole is cluster-scoped: it has no namespace. The builder validates that the Name is set and that Namespace is
-empty. Setting a namespace on a cluster-scoped resource is rejected.
+!!! warning "Ownership limitation for namespaced owners"
-> **Ownership limitation:** During reconciliation, the framework attempts to set a controller reference on managed
-> objects, but only when the owner and dependent scopes are compatible. When a namespaced owner manages a cluster-scoped
-> resource such as a `ClusterRole`, the owner reference is skipped (and this is logged) instead of causing the reconcile
-> to fail. In this case, the `ClusterRole` is **not** owned by the custom resource for Kubernetes garbage-collection or
-> ownership semantics, so it will not be automatically deleted when the owner is removed; you must handle its lifecycle
-> explicitly or use a cluster-scoped owner if automatic cleanup is required.
+ When a namespaced owner manages a cluster-scoped resource such as a `ClusterRole`, the framework cannot set a
+ controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged.
+ The `ClusterRole` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for
+ example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See
+ [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior.
## Capabilities
-| Capability | Detail |
-| --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors (`PolicyRulesEditor`) for `.rules` and object metadata, with aggregation rule support and a raw escape hatch |
-| **Cluster-scoped** | No namespace required. Identity format is `rbac.authorization.k8s.io/v1/ClusterRole/` |
-| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRole after each sync cycle |
+| Capability | Interfaces / detail |
+| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension |
+| **Mutation** | `PolicyRulesEditor` for `.rules`; `SetAggregationRule` for `.aggregationRule`; `ObjectMetaEditor` for labels and annotations |
+| **Cluster-scoped** | `MarkClusterScoped()` called during construction; `Build()` rejects a non-empty namespace |
+| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) |
+| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. For
+cluster-scoped builder behavior, see [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives).
## Building a ClusterRole Primitive
@@ -42,71 +43,30 @@ base := &rbacv1.ClusterRole{
}
resource, err := clusterrole.NewBuilder(base).
- WithMutation(MyFeatureMutation(owner.Spec.Version)).
+ WithMutation(CRDAccessMutation(owner.Spec.Version, owner.Spec.ManageCRDs)).
Build()
```
-## Mutations
-
-Mutations are the primary mechanism for modifying a `ClusterRole` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally:
-
-```go
-func PodReadMutation() clusterrole.Mutation {
- return clusterrole.Mutation{
- Name: "pod-read",
- Mutate: func(m *clusterrole.Mutator) error {
- m.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{""},
- Resources: []string{"pods"},
- Verbs: []string{"get", "list", "watch"},
- })
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder.
+`Build()` returns an error if `Name` is empty or if `Namespace` is non-empty. The constructor calls
+`MarkClusterScoped()` internally, so you do not need to call it manually.
-### Boolean-gated mutations
+Identity format: `rbac.authorization.k8s.io/v1/ClusterRole/`.
-```go
-func SecretAccessMutation(version string, needsSecrets bool) clusterrole.Mutation {
- return clusterrole.Mutation{
- Name: "secret-access",
- Feature: feature.NewVersionGate(version, nil).When(needsSecrets),
- Mutate: func(m *clusterrole.Mutator) error {
- m.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{""},
- Resources: []string{"secrets"},
- Verbs: []string{"get", "list"},
- })
- return nil
- },
- }
-}
-```
+## Mutations
-### Version-gated mutations
+Each mutation is a named `clusterrole.Mutation` that receives a `*Mutator` and records edit intent through typed
+editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model.
```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyRBACMutation(version string) clusterrole.Mutation {
+func CRDAccessMutation(version string, manageCRDs bool) clusterrole.Mutation {
return clusterrole.Mutation{
- Name: "legacy-rbac",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
+ Name: "crd-access",
+ Feature: feature.NewVersionGate(version, nil).When(manageCRDs),
Mutate: func(m *clusterrole.Mutator) error {
m.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{"extensions"},
- Resources: []string{"deployments"},
- Verbs: []string{"get", "list"},
+ APIGroups: []string{"apiextensions.k8s.io"},
+ Resources: []string{"customresourcedefinitions"},
+ Verbs: []string{"get", "list", "watch"},
})
return nil
},
@@ -114,50 +74,40 @@ func LegacyRBACMutation(version string) clusterrole.Mutation {
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+For boolean conditions, chain `.When()` on the gate. See
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-The Mutator maintains feature boundaries: each feature's mutations are planned together and applied in the order the
-features were registered. Within each feature, edits are applied in a fixed category order:
+Within a single mutation, edits are applied in this fixed category order regardless of the call order:
-| Step | Category | What it affects |
-| ---- | ---------------- | ------------------------------------------- |
-| 1 | Metadata edits | Labels and annotations on the `ClusterRole` |
-| 2 | Rules edits | `.rules` entries: EditRules, AddRule |
-| 3 | Aggregation rule | `.aggregationRule`: SetAggregationRule |
+| Step | Category | What it affects |
+| ---- | ---------------- | ----------------------------------------------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the `ClusterRole` |
+| 2 | Rules edits | `.rules`: `EditRules`, `AddRule` |
+| 3 | Aggregation rule | `.aggregationRule`: `SetAggregationRule` (last call wins within each feature) |
-Within each category, edits are applied in their registration order. For aggregation rules, the last
-`SetAggregationRule` call wins within each feature. Later features observe the ClusterRole as modified by all previous
-features.
+Within each category, edits apply in registration order. Later features observe the object as modified by all earlier
+ones.
## Relevant Editors
### PolicyRulesEditor
-The primary API for modifying `.rules` entries. Use `m.EditRules` for full control:
-
-```go
-m.EditRules(func(e *editors.PolicyRulesEditor) error {
- e.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{"apps"},
- Resources: []string{"deployments"},
- Verbs: []string{"get", "list", "watch"},
- })
- return nil
-})
-```
+The primary API for modifying `.rules`. Use `m.EditRules` for full control. See
+[Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
#### AddRule
-`AddRule` appends a PolicyRule to the rules slice:
+`AddRule` appends a `PolicyRule` to the rules slice:
```go
m.EditRules(func(e *editors.PolicyRulesEditor) error {
e.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{""},
- Resources: []string{"configmaps"},
- Verbs: []string{"get", "list"},
+ APIGroups: []string{"apps"},
+ Resources: []string{"deployments"},
+ Verbs: []string{"get", "list", "watch"},
})
return nil
})
@@ -165,7 +115,7 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error {
#### RemoveRuleByIndex
-`RemoveRuleByIndex` removes the rule at the given index. It is a no-op if the index is out of bounds:
+`RemoveRuleByIndex` removes the rule at the given index. No-op if the index is out of bounds:
```go
m.EditRules(func(e *editors.PolicyRulesEditor) error {
@@ -199,9 +149,8 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error {
### ObjectMetaEditor
-Modifies labels and annotations via `m.EditObjectMetadata`.
-
-Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`,
+`EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
@@ -213,7 +162,7 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
## Convenience Methods
-The `Mutator` exposes a convenience wrapper for the most common `.rules` operation:
+The `*Mutator` exposes a direct convenience method for the most common `.rules` operation:
| Method | Equivalent to |
| --------------- | ------------------------------- |
@@ -235,9 +184,26 @@ m.SetAggregationRule(&rbacv1.AggregationRule{
})
```
-Setting the aggregation rule to nil clears it. Within a single feature, the last `SetAggregationRule` call wins.
+Pass `nil` to clear the aggregation rule. Within a single feature, the last `SetAggregationRule` call wins.
+
+!!! note
+
+ The Kubernetes API ignores `.rules` when `.aggregationRule` is set. The two approaches are mutually exclusive.
+
+## Data Extraction
-## Full Example: Feature-Composed RBAC
+`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ClusterRole:
+
+```go
+resource, err := clusterrole.NewBuilder(base).
+ WithDataExtractor(func(cr rbacv1.ClusterRole) error {
+ sharedState.ClusterRoleName = cr.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
```go
func CoreRulesMutation() clusterrole.Mutation {
@@ -280,12 +246,18 @@ written. Neither mutation needs to know about the other.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use
+`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean
conditions.
+**Use `AddRule` for composable permissions.** `AddRule` lets each feature contribute rules without knowing about others.
+Using `SetRules` (via `Raw`) in multiple features means the last write wins; use that only when full replacement is the
+intended semantics.
+
**Use `SetAggregationRule` for composite roles.** When you want the API server to aggregate rules from multiple
-ClusterRoles based on label selectors, use `SetAggregationRule` instead of managing `.rules` directly. The two
-approaches are mutually exclusive in the Kubernetes API: the API server ignores `.rules` when `.aggregationRule` is set.
+ClusterRoles via label selectors, call `SetAggregationRule` instead of managing `.rules` directly. Do not mix both
+approaches on the same role.
-**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first.
+**Cluster-scoped resources are not garbage-collected by namespaced owners.** A namespaced custom resource cannot own a
+cluster-scoped `ClusterRole`. Handle deletion explicitly, for example by adding a finalizer on the owner that deletes
+the `ClusterRole` before the owner is removed.
diff --git a/docs/primitives/clusterrolebinding.md b/docs/primitives/clusterrolebinding.md
index 99836639..bc549d36 100644
--- a/docs/primitives/clusterrolebinding.md
+++ b/docs/primitives/clusterrolebinding.md
@@ -1,17 +1,28 @@
# ClusterRoleBinding Primitive
-The `clusterrolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes
-`ClusterRoleBinding` resources. It integrates with the component lifecycle and provides a structured mutation API for
-managing `.subjects` entries and object metadata.
+The `clusterrolebinding` primitive wraps a Kubernetes `ClusterRoleBinding` and manages the subjects list and object
+metadata within the component lifecycle.
+
+!!! warning "Ownership limitation for namespaced owners"
+
+ When a namespaced owner manages a cluster-scoped resource such as a `ClusterRoleBinding`, the framework cannot set a
+ controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged.
+ The `ClusterRoleBinding` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See
+ [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior.
## Capabilities
-| Capability | Detail |
-| --------------------- | ----------------------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Cluster-scoped** | Cluster-scoped resource. Build() validates Name and requires metadata.namespace to be empty (errors if set) |
-| **Mutation pipeline** | Typed editors for `.subjects` entries and object metadata, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads generated or updated values back from the reconciled ClusterRoleBinding after each sync cycle |
+| Capability | Interfaces / detail |
+| --------------------- | ----------------------------------------------------------------------------------------- |
+| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension |
+| **Mutation** | `BindingSubjectsEditor` for `.subjects`; `ObjectMetaEditor` for labels and annotations |
+| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation |
+| **Cluster-scoped** | `MarkClusterScoped()` called during construction; `Build()` rejects a non-empty namespace |
+| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) |
+| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping. For
+cluster-scoped builder behavior, see [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives).
## Building a ClusterRoleBinding Primitive
@@ -37,44 +48,28 @@ base := &rbacv1.ClusterRoleBinding{
}
resource, err := clusterrolebinding.NewBuilder(base).
- WithMutation(MySubjectMutation(owner.Spec.Version)).
+ WithMutation(ExtraSubjectMutation(owner.Spec.Version, owner.Spec.EnableExtra)).
Build()
```
-## Mutations
-
-Mutations are the primary mechanism for modifying a `ClusterRoleBinding` beyond its baseline. Each mutation is a named
-function that receives a `*Mutator` and records edit intent through typed editors.
+`Build()` returns an error if `Name` is empty or if `Namespace` is non-empty. The constructor calls
+`MarkClusterScoped()` internally, so you do not need to call it manually.
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not
+modifiable via the mutation API. To change a `roleRef`, delete and recreate the ClusterRoleBinding.
-```go
-func MySubjectMutation(version string) clusterrolebinding.Mutation {
- return clusterrolebinding.Mutation{
- Name: "my-subjects",
- Feature: feature.NewVersionGate(version, nil),
- Mutate: func(m *clusterrolebinding.Mutator) error {
- m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureServiceAccount("my-sa", "default")
- return nil
- })
- return nil
- },
- }
-}
-```
+Identity format: `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`.
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
+## Mutations
-### Boolean-gated mutations
+Each mutation is a named `clusterrolebinding.Mutation` that receives a `*Mutator` and records edit intent through typed
+editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model.
```go
-func ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrolebinding.Mutation {
+func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutation {
return clusterrolebinding.Mutation{
- Name: "conditional-subject",
- Feature: feature.NewVersionGate(version, nil).When(addExtraSubject),
+ Name: "extra-subject",
+ Feature: feature.NewVersionGate(version, nil).When(enabled),
Mutate: func(m *clusterrolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
e.EnsureServiceAccount("extra-sa", "monitoring")
@@ -86,26 +81,28 @@ func ConditionalSubjectMutation(version string, addExtraSubject bool) clusterrol
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+For boolean conditions, chain `.When()` on the gate. See
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in this fixed category order regardless of the call order:
-| Step | Category | What it affects |
-| ---- | -------------- | ------------------------------------------------------ |
-| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` |
-| 2 | Subject edits | `.subjects` entries: Add, Remove, EnsureServiceAccount |
+| Step | Category | What it affects |
+| ---- | -------------- | -------------------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the `ClusterRoleBinding` |
+| 2 | Subject edits | `.subjects` entries via `BindingSubjectsEditor` |
-Within each category, edits are applied in their registration order. Later features observe the ClusterRoleBinding as
-modified by all previous features.
+Within each category, edits apply in registration order. Later features observe the object as modified by all earlier
+ones.
## Relevant Editors
### BindingSubjectsEditor
-The primary API for modifying `.subjects` entries. Use `m.EditSubjects` for full control:
+The primary API for modifying `.subjects`. Use `m.EditSubjects` for full control. See
+[Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
@@ -117,42 +114,33 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
#### EnsureSubject
-Upserts a subject in the subjects list. A subject is identified by the combination of Kind, Name, and Namespace. If a
-matching subject already exists it is replaced; otherwise the new subject is appended:
+`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already
+exists it is replaced; otherwise the new subject is appended.
```go
-m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureSubject(rbacv1.Subject{
- Kind: "Group",
- Name: "developers",
- APIGroup: "rbac.authorization.k8s.io",
- })
- return nil
+e.EnsureSubject(rbacv1.Subject{
+ Kind: "Group",
+ Name: "developers",
+ APIGroup: "rbac.authorization.k8s.io",
})
```
#### EnsureServiceAccount
-Convenience method that ensures a `ServiceAccount` subject with the given name and namespace exists:
+Convenience wrapper that ensures a `ServiceAccount` subject with the given name and namespace exists:
```go
-m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureServiceAccount("app-sa", "production")
- return nil
-})
+e.EnsureServiceAccount("app-sa", "production")
```
#### RemoveSubject and RemoveServiceAccount
-`RemoveSubject` removes a subject matching the given kind, name, and namespace. `RemoveServiceAccount` is a convenience
+`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience
wrapper for removing `ServiceAccount` subjects:
```go
-m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.RemoveSubject("User", "old-user", "")
- e.RemoveServiceAccount("deprecated-sa", "default")
- return nil
-})
+e.RemoveSubject("User", "old-user", "")
+e.RemoveServiceAccount("deprecated-sa", "default")
```
#### Raw Escape Hatch
@@ -173,25 +161,83 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
### ObjectMetaEditor
-Modifies labels and annotations via `m.EditObjectMetadata`.
-
-Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`,
+`EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator")
- e.EnsureAnnotation("description", "cluster-wide admin binding")
+ e.EnsureAnnotation("description", "cluster-wide binding")
return nil
})
```
+## Data Extraction
+
+`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled
+ClusterRoleBinding. Use it to surface binding metadata to other resources:
+
+```go
+resource, err := clusterrolebinding.NewBuilder(base).
+ WithDataExtractor(func(crb rbacv1.ClusterRoleBinding) error {
+ sharedState.ClusterRoleBindingName = crb.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
+
+```go
+func BaseSubjectMutation(version, saName, saNamespace string) clusterrolebinding.Mutation {
+ return clusterrolebinding.Mutation{
+ Name: "base-subject",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *clusterrolebinding.Mutator) error {
+ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
+ e.EnsureServiceAccount(saName, saNamespace)
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+func ExtraSubjectMutation(version string, enabled bool) clusterrolebinding.Mutation {
+ return clusterrolebinding.Mutation{
+ Name: "extra-subject",
+ Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Mutate: func(m *clusterrolebinding.Mutator) error {
+ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
+ e.EnsureServiceAccount("extra-sa", "monitoring")
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+resource, err := clusterrolebinding.NewBuilder(base).
+ WithMutation(BaseSubjectMutation(owner.Spec.Version, "app-sa", owner.Namespace)).
+ WithMutation(ExtraSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)).
+ Build()
+```
+
+When `EnableMonitoring` is true, the binding's subjects list contains both the base service account and the monitoring
+service account. When false, only the base subject is present.
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change
+a `roleRef`, delete and recreate the ClusterRoleBinding.
+
+**Use `EnsureServiceAccount` as a shortcut for the most common subject type.** It sets `Kind`, `Name`, and `Namespace`
+in one call and is equivalent to `EnsureSubject` with a `ServiceAccount` kind.
-**Cluster-scoped resources have no namespace.** Unlike namespaced primitives, ClusterRoleBinding does not require or
-validate a namespace. The identity format is `rbac.authorization.k8s.io/v1/ClusterRoleBinding/`.
+**Cluster-scoped resources are not garbage-collected by namespaced owners.** A namespaced custom resource cannot own a
+cluster-scoped `ClusterRoleBinding`. Handle deletion explicitly, for example by adding a finalizer on the owner that
+deletes the `ClusterRoleBinding` before the owner is removed.
-**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first.
+**Cluster-scoped bindings have no namespace.** The identity format is
+`rbac.authorization.k8s.io/v1/ClusterRoleBinding/`. Leave `ObjectMeta.Namespace` empty; `Build()` rejects a
+non-empty namespace.
diff --git a/docs/primitives/configmap.md b/docs/primitives/configmap.md
index 39d4e328..0b38fde1 100644
--- a/docs/primitives/configmap.md
+++ b/docs/primitives/configmap.md
@@ -1,17 +1,19 @@
# ConfigMap Primitive
-The `configmap` primitive is the framework's built-in static abstraction for managing Kubernetes `ConfigMap` resources.
-It integrates with the component lifecycle and provides a structured mutation API for managing `.data` entries and
-object metadata.
+The `configmap` primitive wraps a Kubernetes `ConfigMap` and integrates with the component lifecycle as a Static
+resource, providing a structured mutation API for managing `.data` entries and object metadata.
## Capabilities
-| Capability | Detail |
-| --------------------- | --------------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for `.data` entries and object metadata, with a raw escape hatch for free-form access |
-| **MergeYAML** | Deep-merges YAML patches into individual `.data` entries; composable across independent features |
-| **Data extraction** | Reads generated or updated values back from the reconciled ConfigMap after each sync cycle |
+| Capability | Detail |
+| --------------------- | ------------------------------------------------------------------------------------------------ |
+| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
+| **Mutation pipeline** | Typed editors for `.data` entries and object metadata, with a `Raw()` escape hatch |
+| **MergeYAML** | Deep-merges YAML patches into individual `.data` entries; composable across independent features |
+| **DataExtractable** | Reads values back from the reconciled ConfigMap after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building a ConfigMap Primitive
@@ -35,17 +37,18 @@ resource, err := configmap.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `ConfigMap` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+A kind-specific example using the `SetEntry` convenience method:
```go
func MyFeatureMutation(version string) configmap.Mutation {
return configmap.Mutation{
Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *configmap.Mutator) error {
m.SetEntry("feature-flag", "enabled")
return nil
@@ -54,61 +57,22 @@ func MyFeatureMutation(version string) configmap.Mutation {
}
```
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-```go
-func TLSConfigMutation(version string, tlsEnabled bool) configmap.Mutation {
- return configmap.Mutation{
- Name: "tls-config",
- Feature: feature.NewVersionGate(version, nil).When(tlsEnabled),
- Mutate: func(m *configmap.Mutator) error {
- m.SetEntry("tls_mode", "strict")
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyAuthMutation(version string) configmap.Mutation {
- return configmap.Mutation{
- Name: "legacy-auth",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *configmap.Mutator) error {
- m.SetEntry("auth_mode", "legacy-token")
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | -------------- | -------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `ConfigMap` |
| 2 | Data edits | `.data` entries: Set, Remove, MergeYAML, Raw |
-Within each category, edits are applied in their registration order. Later features observe the ConfigMap as modified by
-all previous features.
+Within each category, edits run in registration order. Later features observe the ConfigMap as modified by all earlier
+ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### ConfigMapDataEditor
The primary API for modifying `.data` and `.binaryData` entries. Use `m.EditData` for full control:
@@ -136,7 +100,7 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error {
#### SetBinary and RemoveBinary
`SetBinary` sets a raw byte slice in `.binaryData`. `RemoveBinary` deletes a `.binaryData` key; it is a no-op if the key
-is absent. No helpers are provided beyond set and remove. Format and encode the value before passing it in.
+is absent. Format and encode the value before passing it in.
```go
m.EditData(func(e *editors.ConfigMapDataEditor) error {
@@ -156,8 +120,8 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error {
- For all other types (scalars, sequences, mixed), the patch value wins.
- If the key does not yet exist, the patch is written as-is.
-This makes it suitable for composing contributions from independent features without each feature needing to know about
-the others:
+This makes it suitable for composing contributions from independent features without each needing to know about the
+others:
```go
// Feature A contributes logging config.
@@ -175,7 +139,7 @@ m.EditData(func(e *editors.ConfigMapDataEditor) error {
#### Raw Escape Hatches
`Raw()` returns the underlying `map[string]string` for `.data`. `RawBinary()` returns the underlying `map[string][]byte`
-for `.binaryData`. Both give direct access for free-form editing when none of the structured methods are sufficient:
+for `.binaryData`. Both give direct access for free-form editing:
```go
m.EditData(func(e *editors.ConfigMapDataEditor) error {
@@ -218,9 +182,8 @@ single edit block.
## Data Hash
-Two utilities are provided for computing a stable SHA-256 hash of a ConfigMap's `.data` and `.binaryData` fields. A
-common use is to annotate a Deployment's pod template with this hash so that a configuration change triggers a rolling
-restart.
+Two utilities compute a stable SHA-256 hash of a ConfigMap's `.data` and `.binaryData` fields. A common use is to
+annotate a Deployment's pod template with this hash so that a configuration change triggers a rolling restart.
### DataHash
@@ -231,7 +194,7 @@ hash, err := configmap.DataHash(cm)
```
The hash is derived from the canonical JSON encoding of `.data` and `.binaryData` with map keys sorted alphabetically,
-so it is deterministic regardless of insertion order. Metadata fields (labels, annotations, etc.) are excluded.
+so it is deterministic regardless of insertion order. Metadata fields are excluded.
### Resource.DesiredHash
@@ -247,12 +210,12 @@ cmResource, err := configmap.NewBuilder(base).
hash, err := cmResource.DesiredHash()
```
-The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash.
+The hash covers only operator-controlled fields.
### Annotating a Deployment pod template (single-pass pattern)
-Build the configmap resource first, compute the hash, then pass it into the deployment resource factory. Both resources
-are registered with the same component, so the configmap is reconciled first and the deployment sees the correct hash on
+Build the ConfigMap resource first, compute the hash, then pass it into the Deployment resource factory. Both resources
+are registered with the same component, so the ConfigMap is reconciled first and the Deployment sees the correct hash on
every cycle.
`DesiredHash` is defined on `*configmap.Resource`, not on the `component.Resource` interface, so keep the concrete type
@@ -278,7 +241,7 @@ if err != nil {
}
comp, err := component.NewComponentBuilder().
- WithResource(cmResource). // reconciled first
+ WithResource(cmResource). // reconciled first
WithResource(deployResource).
Build()
```
@@ -300,10 +263,10 @@ func ChecksumAnnotationMutation(version, configHash string) deployment.Mutation
}
```
-When the configmap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the
+When the ConfigMap mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the
same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart.
-## Full Example: Feature-Composed Configuration
+## Full Example
```go
func BaseConfigMutation(version string) configmap.Mutation {
@@ -346,17 +309,19 @@ resource, err := configmap.NewBuilder(base).
Build()
```
-When `MetricsEnabled` is true, the final `app.yaml` entry will contain the merged result of both patches. When false,
-only the base config is written. Neither mutation needs to know about the other.
+When `MetricsEnabled` is true, the final `app.yaml` entry contains the merged result of both patches. When false, only
+the base config is written. Neither mutation needs to know about the other.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions.
-**Use `MergeYAML` for composable config files.** When multiple features need to contribute to the same YAML entry,
-`MergeYAML` lets each feature contribute its section independently. Using `SetEntry` in multiple features for the same
-key means the last registration wins. Only use that when replacement is the intended semantics.
+**Use `MergeYAML` for composable config files.** When multiple features contribute to the same YAML entry, `MergeYAML`
+lets each contribute its section independently. Using `SetEntry` in multiple features for the same key means the last
+registration wins; only use that when replacement is the intended semantics.
**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first.
+
+**Use `DesiredHash` for rolling restarts.** Build the ConfigMap resource, call `DesiredHash()`, and stamp the result as
+a pod-template annotation on the Deployment in the same reconcile pass. No extra cluster reads are required.
diff --git a/docs/primitives/cronjob.md b/docs/primitives/cronjob.md
index 20eaa329..8dab0ea0 100644
--- a/docs/primitives/cronjob.md
+++ b/docs/primitives/cronjob.md
@@ -1,23 +1,18 @@
# CronJob Primitive
-The `cronjob` primitive is the framework's built-in integration abstraction for managing Kubernetes `CronJob` resources.
-It integrates with the component lifecycle through the Operational, Graceful, and Suspendable concepts, and provides a
-rich mutation API for managing the CronJob schedule, job template, pod spec, and containers.
+The `cronjob` primitive wraps a Kubernetes `CronJob` and provides operational status tracking, grace handling,
+suspension, and a typed mutation API for managing the schedule, job template, pod spec, and containers as part of the
+component lifecycle.
## Capabilities
-| Capability | Detail |
-| ------------------------ | ------------------------------------------------------------------------------------------- |
-| **Operational tracking** | Reports `OperationPending` (never scheduled) or `Operational` (has scheduled at least once) |
-| **Grace status** | Always reports `Healthy`. A CronJob is a passive scheduler and is healthy once it exists |
-| **Suspension** | Sets `spec.suspend = true`; reports `Suspending` (active jobs running) / `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, CronJob spec, Job spec, pod spec, and containers |
-
-## Server-Side Apply
-
-The CronJob primitive reconciles resources using **Server-Side Apply** (SSA). Only fields declared by the operator are
-sent; server-managed defaults, fields set by other controllers, and values written by webhooks are left untouched. Field
-ownership is tracked automatically by the Kubernetes API server.
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ----------------------------------------------------- |
+| `Operational` | `Operational`, `OperationPending`, `OperationFailing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a CronJob Primitive
@@ -35,10 +30,10 @@ base := &batchv1.CronJob{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
+ RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
- {Name: "cleanup", Image: "cleanup:latest"},
+ {Name: "cleanup", Image: "cleanup-tool:latest"},
},
- RestartPolicy: corev1.RestartPolicyOnFailure,
},
},
},
@@ -53,13 +48,12 @@ resource, err := cronjob.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `CronJob` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
+Each mutation is a named `cronjob.Mutation` that receives a `*cronjob.Mutator` and records edits through typed editors.
```go
-func MyScheduleMutation(version string) cronjob.Mutation {
+func ScheduleMutation(version string) cronjob.Mutation {
return cronjob.Mutation{
- Name: "my-schedule",
+ Name: "schedule",
Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *cronjob.Mutator) error {
m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error {
@@ -73,34 +67,19 @@ func MyScheduleMutation(version string) cronjob.Mutation {
}
```
-### Boolean-gated mutations
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func TimeZoneMutation(version string, enabled bool) cronjob.Mutation {
- return cronjob.Mutation{
- Name: "timezone",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *cronjob.Mutator) error {
- m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error {
- e.SetTimeZone("America/New_York")
- return nil
- })
- return nil
- },
- }
-}
-```
+For all primitives, desired state is reconciled via [Server-Side Apply](../primitives.md#server-side-apply).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | --------------------------- | --------------------------------------------------------------------------------------- |
-| 1 | CronJob metadata edits | Labels and annotations on the `CronJob` object |
+| 1 | Object metadata edits | Labels and annotations on the `CronJob` object |
| 2 | CronJobSpec edits | Schedule, concurrency policy, time zone, history limits |
| 3 | JobSpec edits | Completions, parallelism, backoff limit, TTL |
| 4 | Pod template metadata edits | Labels and annotations on the pod template |
@@ -110,11 +89,13 @@ order they are recorded.
| 8 | Init container presence | Adding or removing containers from `spec.jobTemplate.spec.template.spec.initContainers` |
| 9 | Init container edits | Env vars, args, resources (snapshot taken after step 8) |
-Container edits (steps 7 and 9) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 7 and 9) are evaluated against a snapshot taken _after_ presence operations in the same feature.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### CronJobSpecEditor
Controls CronJob-level settings via `m.EditCronJobSpec`.
@@ -131,8 +112,10 @@ m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error {
})
```
-Note: no typed helper is provided for `spec.suspend`; it can be set via `Raw()` if needed, but suspension should
-typically be handled via the framework's suspend mechanism.
+!!! note "No typed helper for `spec.suspend`"
+
+ `spec.suspend` is not exposed by the typed API. Use `Raw()` if you need to set it directly, but prefer the
+ framework's suspend mechanism instead.
### JobSpecEditor
@@ -160,15 +143,14 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens
```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
e.SetServiceAccountName("cleanup-sa")
- e.Raw().RestartPolicy = corev1.RestartPolicyOnFailure
return nil
})
```
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
@@ -183,8 +165,8 @@ m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerE
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `CronJob` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `CronJob` itself or `m.EditPodTemplateMetadata` for
+the pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
@@ -197,8 +179,6 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
## Convenience Methods
-The `Mutator` also exposes convenience wrappers that target all containers at once:
-
| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
@@ -206,57 +186,119 @@ The `Mutator` also exposes convenience wrappers that target all containers at on
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
-## Operational Status
-
-The CronJob primitive reports operational status based on the CronJob's scheduling history:
+## Workload-Kind-Agnostic Mutations
-| Status | Condition |
-| ------------------ | -------------------------------- |
-| `OperationPending` | `Status.LastScheduleTime == nil` |
-| `Operational` | `Status.LastScheduleTime != nil` |
+The `cronjob.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation`
+adapter. The `WorkloadMutator` interface targets Deployment, StatefulSet, and DaemonSet. Write shared mutation logic as
+a plain function accepting `*cronjob.Mutator` and call it directly.
-Failures are reported on the spawned Job resources, not on the CronJob itself.
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern.
-## Grace Status
+## Operational Status
-The default grace status handler always reports `Healthy`. A CronJob is a passive scheduler: once it exists and is not
-suspended, it is functioning correctly regardless of whether it has fired yet. The schedule interval may be longer than
-the grace period (e.g. monthly), so waiting for the first execution would produce false degradation signals.
+`DefaultOperationalStatusHandler` always reports `Operational`. A CronJob is a passive scheduler: once it exists in the
+cluster it is functioning correctly regardless of whether it has fired yet. Schedule intervals may be longer than the
+component's grace period, so treating a never-scheduled CronJob as pending would produce false degradation signals.
+Failures are reported on the spawned Job resources, not on the CronJob itself.
-Override with `WithCustomGraceStatus` if your CronJob has specific health requirements:
+Override with `WithCustomOperationalStatus` if you need visibility into whether the CronJob has executed:
```go
cronjob.NewBuilder(base).
- WithCustomGraceStatus(func(cj *batchv1.CronJob) (concepts.GraceStatusWithReason, error) {
- // Custom logic based on your CronJob's semantics
- return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil
+ WithCustomOperationalStatus(func(_ concepts.ConvergingOperation, cj *batchv1.CronJob) (concepts.OperationalStatusWithReason, error) {
+ if cj.Status.LastScheduleTime == nil {
+ return concepts.OperationalStatusWithReason{
+ Status: concepts.OperationalStatusPending,
+ Reason: "CronJob has not fired yet",
+ }, nil
+ }
+ return concepts.OperationalStatusWithReason{
+ Status: concepts.OperationalStatusOperational,
+ Reason: "CronJob has fired at least once",
+ }, nil
})
```
+## Grace Status
+
+`DefaultGraceStatusHandler` always reports `Healthy`. A CronJob is considered healthy once it exists and is not
+suspended. Override with `WithCustomGraceStatus` if your CronJob has specific health requirements.
+
## Suspension
-When the component is suspended, the CronJob primitive sets `spec.suspend = true`. This prevents the CronJob controller
-from creating new Job objects. Existing active jobs continue to run.
+When the component is suspended, the CronJob sets `spec.suspend = true`, preventing new Jobs from being created.
+Existing active jobs continue running.
| Status | Condition |
| ------------ | ---------------------------------------------------- |
-| `Suspended` | `spec.suspend == true` and no active jobs |
| `Suspending` | `spec.suspend == true` but active jobs still running |
-| `Suspending` | Waiting for suspend flag to be applied |
+| `Suspended` | `spec.suspend == true` and no active jobs |
+| `Suspending` | Waiting for the suspend flag to be applied |
-On unsuspend, the desired state (without `spec.suspend = true`) is applied via SSA, allowing the CronJob to resume
-scheduling.
+The CronJob is never deleted on suspend (`DefaultDeleteOnSuspendHandler` returns `false`). On unsuspend, the desired
+state without `spec.suspend = true` is reapplied via Server-Side Apply, and the CronJob resumes scheduling.
-The CronJob is never deleted on suspend (`DeleteOnSuspend = false`).
+## Full Example
+
+```go
+func CleanupMutation(version string, schedule string) cronjob.Mutation {
+ return cronjob.Mutation{
+ Name: "cleanup-schedule",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *cronjob.Mutator) error {
+ // CronJob spec: schedule and concurrency
+ m.EditCronJobSpec(func(e *editors.CronJobSpecEditor) error {
+ e.SetSchedule(schedule)
+ e.SetConcurrencyPolicy(batchv1.ForbidConcurrent)
+ e.SetFailedJobsHistoryLimit(3)
+ e.SetSuccessfulJobsHistoryLimit(1)
+ return nil
+ })
+
+ // Job spec: backoff and TTL
+ m.EditJobSpec(func(e *editors.JobSpecEditor) error {
+ e.SetBackoffLimit(2)
+ e.SetTTLSecondsAfterFinished(3600)
+ return nil
+ })
+
+ // Pod spec: service account
+ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
+ e.SetServiceAccountName("cleanup-sa")
+ return nil
+ })
+
+ // Container: configuration
+ m.EditContainers(selectors.ContainerNamed("cleanup"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVar(corev1.EnvVar{Name: "DRY_RUN", Value: "false"})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("200m"))
+ e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi"))
+ return nil
+ })
+
+ return nil
+ },
+ }
+}
+```
+
+The four nesting levels mirror the object structure: `CronJobSpec` -> `JobSpec` -> `PodSpec` -> `ContainerEditor`. Each
+editor targets one level of that nesting.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run.
+**CronJobs are passive schedulers.** They do not run actively; the CronJob controller creates Job objects on schedule.
+Model health around the spawned Jobs, not the CronJob resource itself.
-**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
+conditions.
+
+**Set `RestartPolicy` in the baseline.** Kubernetes requires `spec.jobTemplate.spec.template.spec.restartPolicy` to be
+`OnFailure` or `Never`. Set it in the desired object passed to `NewBuilder`.
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly.
+**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
diff --git a/docs/primitives/daemonset.md b/docs/primitives/daemonset.md
index 26eefe47..b2abf38e 100644
--- a/docs/primitives/daemonset.md
+++ b/docs/primitives/daemonset.md
@@ -1,17 +1,18 @@
# DaemonSet Primitive
-The `daemonset` primitive is the framework's built-in workload abstraction for managing Kubernetes `DaemonSet`
-resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers,
-pod specs, and metadata.
+The `daemonset` primitive wraps a Kubernetes `DaemonSet` and provides health tracking, suspension, and a typed mutation
+API for managing pod spec and containers as part of the component lifecycle. A DaemonSet runs one pod per qualifying
+node.
## Capabilities
-| Capability | Detail |
-| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `NumberReady`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` |
-| **Graceful rollouts** | Reports rollout progress via `GraceStatus` for use with component-level grace periods (for example, configured with `WithGracePeriod`) |
-| **Suspension** | Deletes the DaemonSet on suspend; reports `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, DaemonSet spec, pod spec, and containers |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ------------------------------------------------------- |
+| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a DaemonSet Primitive
@@ -28,7 +29,14 @@ base := &appsv1.DaemonSet{
MatchLabels: map[string]string{"app": "log-collector"},
},
Template: corev1.PodTemplateSpec{
- // baseline pod template
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{"app": "log-collector"},
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {Name: "collector"},
+ },
+ },
},
},
}
@@ -40,31 +48,8 @@ resource, err := daemonset.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `DaemonSet` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) daemonset.Mutation {
- return daemonset.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *daemonset.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
+Each mutation is a named `daemonset.Mutation` that receives a `*daemonset.Mutator` and records edits through typed
+editors.
```go
func MonitoringMutation(version string, enabled bool) daemonset.Mutation {
@@ -74,7 +59,7 @@ func MonitoringMutation(version string, enabled bool) daemonset.Mutation {
Mutate: func(m *daemonset.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "metrics-exporter",
- Image: "prom/node-exporter:v1.3.1",
+ Image: "prom/node-exporter:v1.8.0",
})
return nil
},
@@ -82,14 +67,17 @@ func MonitoringMutation(version string, enabled bool) daemonset.Mutation {
}
```
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
+
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded. This ensures structural consistency across mutations.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
-| 1 | DaemonSet metadata edits | Labels and annotations on the `DaemonSet` object |
+| 1 | Object metadata edits | Labels and annotations on the `DaemonSet` object |
| 2 | DaemonSetSpec edits | Update strategy, min ready seconds, revision history limit |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
@@ -98,11 +86,13 @@ order they are recorded. This ensures structural consistency across mutations.
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
-Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### DaemonSetSpecEditor
Controls DaemonSet-level settings via `m.EditDaemonSetSpec`.
@@ -117,7 +107,7 @@ m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error {
})
```
-For fields not covered by the typed API, use `Raw()`:
+Use `Raw()` for fields the typed API does not cover:
```go
m.EditDaemonSetSpec(func(e *editors.DaemonSetSpecEditor) error {
@@ -151,8 +141,8 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
@@ -167,8 +157,8 @@ m.EditContainers(selectors.ContainerNamed("collector"), func(e *editors.Containe
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `DaemonSet` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `DaemonSet` itself or `m.EditPodTemplateMetadata`
+for the pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
@@ -179,15 +169,8 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
-### Raw Escape Hatch
-
-All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is
-insufficient.
-
## Convenience Methods
-The `Mutator` also exposes convenience wrappers that target all containers at once:
-
| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
@@ -195,54 +178,109 @@ The `Mutator` also exposes convenience wrappers that target all containers at on
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
+!!! note "No `EnsureReplicas` on DaemonSet"
+
+ DaemonSets have no replicas field. Use node selectors, tolerations, and affinities in the pod spec to control which
+ nodes run the pods.
+
+## Workload-Kind-Agnostic Mutations
+
+A mutation written against `primitives.WorkloadMutator` can be applied to a DaemonSet builder using
+`daemonset.LiftMutation`. This lets one emitter function target DaemonSets, Deployments, and StatefulSets without
+duplicating code.
+
+```go
+agent.WithMutation(daemonset.LiftMutation(sharedAuthMutation()))
+```
+
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern.
+
## Suspension
DaemonSets have no replicas field, so there is no clean in-place pause mechanism. By default, the DaemonSet is
**deleted** when the component is suspended and recreated when unsuspended.
-- `DefaultDeleteOnSuspendHandler` returns `true`
-- `DefaultSuspendMutationHandler` is a no-op
-- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"DaemonSet deleted on suspend"`
+- `DefaultDeleteOnSuspendHandler` returns `true`.
+- `DefaultSuspendMutationHandler` is a no-op (deletion is handled by the framework).
+- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"DaemonSet deleted on suspend"`.
Override these handlers via `WithCustomSuspendDeletionDecision`, `WithCustomSuspendMutation`, and
-`WithCustomSuspendStatus` if a different suspension strategy is required.
+`WithCustomSuspendStatus` if a different suspension strategy is needed.
## Status Handlers
### ConvergingStatus
`DefaultConvergingStatusHandler` considers a DaemonSet ready when `Status.NumberReady >= Status.DesiredNumberScheduled`
-and `DesiredNumberScheduled > 0`. When `DesiredNumberScheduled` is zero (no matching nodes) and the controller has
-observed the current generation (`ObservedGeneration >= Generation`), the DaemonSet is considered converged with the
-reason "No nodes match the DaemonSet node selector".
+and `DesiredNumberScheduled > 0`. When `DesiredNumberScheduled` is zero and the controller has observed the current
+generation (`ObservedGeneration >= Generation`), the DaemonSet is considered converged with reason "No nodes match the
+DaemonSet node selector".
### GraceStatus
`DefaultGraceStatusHandler` categorizes health as:
-| Status | Condition |
-| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `Healthy` | `DesiredNumberScheduled == 0` and `ObservedGeneration >= Generation` (no nodes match the selector) |
-| `Degraded` | `DesiredNumberScheduled == 0` but controller has not observed latest generation, or `DesiredNumberScheduled > 0 && NumberReady >= 1` but below desired |
-| `Down` | `DesiredNumberScheduled > 0 && NumberReady == 0` |
+| Status | Condition |
+| ---------- | ----------------------------------------------------------------------------------------------------------- |
+| `Healthy` | `DesiredNumberScheduled == 0` and `ObservedGeneration >= Generation` (no matching nodes is a valid state) |
+| `Degraded` | `DesiredNumberScheduled == 0` but controller has not observed the latest generation, or at least one pod is |
+| | ready but below desired count |
+| `Down` | `DesiredNumberScheduled > 0` and `NumberReady == 0` |
-The `Healthy` status for zero desired pods reflects that having no matching nodes is a valid configuration state, not a
+The `Healthy` status for zero desired pods reflects that having no matching nodes is a valid configuration, not a
failure. The generation check ensures the controller has observed the latest spec before declaring health.
+## Full Example
+
+```go
+func NodeAgentMutation(version string, hostLogPath string) daemonset.Mutation {
+ return daemonset.Mutation{
+ Name: "node-agent",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *daemonset.Mutator) error {
+ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
+ e.SetServiceAccountName("node-agent-sa")
+ e.EnsureVolume(corev1.Volume{
+ Name: "host-logs",
+ VolumeSource: corev1.VolumeSource{
+ HostPath: &corev1.HostPathVolumeSource{Path: hostLogPath},
+ },
+ })
+ return nil
+ })
+
+ m.EditContainers(selectors.ContainerNamed("collector"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_PATH", Value: "/host/logs"})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("100m"))
+ e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("128Mi"))
+ e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
+ Name: "host-logs",
+ MountPath: "/host/logs",
+ ReadOnly: true,
+ })
+ return nil
+ })
+
+ return nil
+ },
+ }
+}
+```
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**DaemonSets are node-scoped.** Unlike Deployments, a DaemonSet runs one pod per qualifying node. Use node selectors,
+tolerations, and affinities to control which nodes run the pods.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
-The internal ordering within each mutation handles intra-mutation dependencies automatically.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+**DaemonSets are deleted on suspend.** There is no in-place scale-to-zero. Override `WithCustomSuspendDeletionDecision`
+if you need the resource to remain in the cluster when the component is suspended.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
-
-**DaemonSets are node-scoped.** Unlike Deployments, DaemonSets run one pod per qualifying node. Use node selectors,
-tolerations, and affinities in the pod spec to control which nodes run the DaemonSet pods.
diff --git a/docs/primitives/deployment.md b/docs/primitives/deployment.md
index cbd25c6e..8dbf06b1 100644
--- a/docs/primitives/deployment.md
+++ b/docs/primitives/deployment.md
@@ -1,17 +1,17 @@
# Deployment Primitive
-The `deployment` primitive is the framework's built-in workload abstraction for managing Kubernetes `Deployment`
-resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers,
-pod specs, and metadata.
+The `deployment` primitive wraps a Kubernetes `Deployment` and provides health tracking, suspension, and a typed
+mutation API for managing replicas, pod spec, and containers as part of the component lifecycle.
## Capabilities
-| Capability | Detail |
-| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, `Scaling`, or `Failing` |
-| **Graceful rollouts** | Detects stalled or failing rollouts via configurable grace periods |
-| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, deployment spec, pod spec, and containers |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ------------------------------------------------------- |
+| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a Deployment Primitive
@@ -24,7 +24,13 @@ base := &appsv1.Deployment{
Namespace: owner.Namespace,
},
Spec: appsv1.DeploymentSpec{
- // baseline spec
+ Template: corev1.PodTemplateSpec{
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {Name: "web"},
+ },
+ },
+ },
},
}
@@ -35,97 +41,49 @@ resource, err := deployment.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `Deployment` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) deployment.Mutation {
- return deployment.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *deployment.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func TracingMutation(version string, enabled bool) deployment.Mutation {
- return deployment.Mutation{
- Name: "tracing",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *deployment.Mutator) error {
- m.EnsureContainer(corev1.Container{
- Name: "jaeger-agent",
- Image: "jaegertracing/jaeger-agent:1.28",
- })
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-Pass a `[]feature.VersionConstraint` to gate on a semver range. `VersionConstraint` is an interface. Implement it using
-the `github.com/Masterminds/semver/v3` library or any other mechanism:
+Each mutation is a named `deployment.Mutation` that receives a `*deployment.Mutator` and records edits through typed
+editors.
```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyAuthMutation(version string, enabled bool) deployment.Mutation {
+func ConfigMutation(version string) deployment.Mutation {
return deployment.Mutation{
- Name: "legacy-auth-header",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ).When(enabled),
+ Name: "config",
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *deployment.Mutator) error {
- m.EditContainers(selectors.ContainerNamed("api"), func(e *editors.ContainerEditor) error {
- e.EnsureEnvVar(corev1.EnvVar{Name: "AUTH_HEADER", Value: "X-Legacy-Token"})
- return nil
- })
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
return nil
},
}
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded. This ensures structural consistency across mutations.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
-| 1 | Deployment metadata edits | Labels and annotations on the `Deployment` object |
+| 1 | Object metadata edits | Labels and annotations on the `Deployment` object |
| 2 | DeploymentSpec edits | Replicas, progress deadline, revision history, etc. |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
-| 5 | Regular container presence | Adding or removing containers from `spec.containers` |
+| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` |
| 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) |
-| 7 | Init container presence | Adding or removing containers from `spec.initContainers` |
+| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
-Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature.
+A single mutation can add a container and then configure it without selector resolution issues.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### DeploymentSpecEditor
Controls deployment-level settings via `m.EditDeploymentSpec`.
@@ -141,7 +99,7 @@ m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error {
})
```
-For fields not covered by the typed API (such as update strategy), use `Raw()`:
+Use `Raw()` for fields the typed API does not cover, such as update strategy:
```go
m.EditDeploymentSpec(func(e *editors.DeploymentSpecEditor) error {
@@ -162,7 +120,7 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens
```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
- e.SetServiceAccountName("my-service-account")
+ e.SetServiceAccountName("web-sa")
e.EnsureVolume(corev1.Volume{
Name: "config",
VolumeSource: corev1.VolumeSource{
@@ -177,25 +135,24 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
+m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
- e.EnsureArg("--metrics-port=9090")
e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
return nil
})
```
-For fields not covered by the typed API (such as volume mounts), use `Raw()`:
+For fields the typed API does not cover, such as volume mounts, use `Raw()`:
```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
+m.EditContainers(selectors.ContainerNamed("web"), func(e *editors.ContainerEditor) error {
e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
Name: "config",
MountPath: "/etc/config",
@@ -206,44 +163,24 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Deployment` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `Deployment` itself or `m.EditPodTemplateMetadata`
+for the pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
-// On the Deployment itself
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
-
-// On the pod template
m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureAnnotation("prometheus.io/scrape", "true")
return nil
})
```
-### Raw Escape Hatch
-
-All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is
-insufficient. The mutation remains scoped to the editor's target, so you cannot accidentally modify unrelated parts of
-the spec.
-
-```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
- e.Raw().SecurityContext = &corev1.SecurityContext{
- ReadOnlyRootFilesystem: ptr.To(true),
- }
- return nil
-})
-```
-
## Convenience Methods
-The `Mutator` also exposes convenience wrappers that target all containers at once:
-
| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureReplicas(n)` | `EditDeploymentSpec` → `SetReplicas(n)` |
@@ -252,7 +189,30 @@ The `Mutator` also exposes convenience wrappers that target all containers at on
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
-## Full Example: Adding a Sidecar
+## Workload-Kind-Agnostic Mutations
+
+A mutation written against `primitives.WorkloadMutator` can be applied to a Deployment builder using
+`deployment.LiftMutation`. This lets one emitter function target Deployments, StatefulSets, and DaemonSets without
+duplicating code.
+
+```go
+frontend.WithMutation(deployment.LiftMutation(sharedAuthMutation()))
+```
+
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern.
+
+## Suspension
+
+When the component is suspended, the Deployment is scaled to zero replicas. The resource is not deleted.
+
+- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`.
+- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`.
+- `DefaultDeleteOnSuspendHandler` returns `false`.
+
+Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision`
+on the builder.
+
+## Full Example
```go
func LoggingSidecarMutation(version string) deployment.Mutation {
@@ -260,16 +220,15 @@ func LoggingSidecarMutation(version string) deployment.Mutation {
Name: "logging-sidecar",
Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *deployment.Mutator) error {
- // Step 1: ensure the sidecar exists (presence operation, step 5)
+ // Presence operation runs at step 5
m.EnsureContainer(corev1.Container{
Name: "logger",
Image: "fluent/fluent-bit:3.0",
})
- // Step 2: configure it (evaluated after step 1, step 6)
+ // Container edit runs at step 6 (after presence)
m.EditContainers(selectors.ContainerNamed("logger"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
- // Volume mounts are not in the typed API, so use Raw()
e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
Name: "varlog",
MountPath: "/var/log",
@@ -277,7 +236,7 @@ func LoggingSidecarMutation(version string) deployment.Mutation {
return nil
})
- // Step 3: add the shared volume to the pod spec (step 4, runs before containers)
+ // Pod spec edit runs at step 4 (before container presence)
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
e.EnsureVolume(corev1.Volume{
Name: "varlog",
@@ -292,21 +251,25 @@ func LoggingSidecarMutation(version string) deployment.Mutation {
}
```
-Note: although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container
+Although `EditPodSpec` is called after `EnsureContainer` in the source, it is applied in step 4 (before container
presence in step 5) per the internal ordering. Order your source calls for readability; the framework handles execution
order.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**Use a Deployment for stateless long-running workloads.** Deployments manage rolling updates and replica counts but do
+not guarantee pod identity or stable network addresses. For stateful workloads requiring stable hostnames or persistent
+volumes bound to a specific pod, use a StatefulSet.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
-The internal ordering within each mutation handles intra-mutation dependencies automatically.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the
+same mutation resolve correctly and reconciliation remains idempotent.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md
index 7246fa87..1a26473a 100644
--- a/docs/primitives/hpa.md
+++ b/docs/primitives/hpa.md
@@ -1,18 +1,20 @@
-# HorizontalPodAutoscaler (HPA) Primitive
+# HorizontalPodAutoscaler Primitive
-The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes
-`HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational,
-Graceful, Suspendable resource and provides a structured mutation API for configuring autoscaling behavior.
+The `hpa` primitive wraps `autoscaling/v2 HorizontalPodAutoscaler` and integrates it with the component lifecycle as an
+Operational, Graceful, and Suspendable resource.
## Capabilities
-| Capability | Detail |
-| ----------------------- | ------------------------------------------------------------------------------------------------------------- |
-| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` |
-| **Grace status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Healthy`, `Degraded`, or `Down` |
-| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume |
-| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata |
-| **Data extraction** | Allows custom extraction from the reconciled HPA object via a registered data extractor (`WithDataExtractor`) |
+The interfaces below are from [`pkg/component/concepts`](../primitives.md#lifecycle-interfaces). The values in the table
+are the runtime strings that appear in conditions.
+
+| Interface | Reported status values | Notes |
+| ----------------- | ----------------------------------------------------- | ------------------------------------------- |
+| `Operational` | `Operational`, `OperationPending`, `OperationFailing` | Inspects `ScalingActive` and `AbleToScale` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` | Same HPA conditions, evaluated post-grace |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` | Delete-on-suspend by default |
+| `Guardable` | `Blocked` | Optional runtime precondition |
+| `DataExtractable` | _(side-effecting, no status)_ | Read generated fields after each sync cycle |
## Building an HPA Primitive
@@ -21,14 +23,14 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa"
base := &autoscalingv2.HorizontalPodAutoscaler{
ObjectMeta: metav1.ObjectMeta{
- Name: "web-hpa",
+ Name: "backend-hpa",
Namespace: owner.Namespace,
},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: "apps/v1",
Kind: "Deployment",
- Name: "web",
+ Name: "backend",
},
MinReplicas: ptr.To(int32(2)),
MaxReplicas: 10,
@@ -36,53 +38,34 @@ base := &autoscalingv2.HorizontalPodAutoscaler{
}
resource, err := hpa.NewBuilder(base).
- WithMutation(CPUMetricMutation(owner.Spec.Version)).
+ WithMutation(CPUScalingMutation(owner.Spec.Version)).
Build()
```
## Mutations
-Mutations are the primary mechanism for modifying an HPA beyond its baseline. Each mutation is a named function that
-receives a `*Mutator` and records edit intent through typed editors.
+Mutations are named functions that receive a `*hpa.Mutator` and record edit intent through typed editors. For a full
+explanation of the mutation system, boolean-gated mutations, and version-gated mutations see
+[The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+A concise version-gated example:
```go
-func CPUMetricMutation(version string) hpa.Mutation {
- return hpa.Mutation{
- Name: "cpu-metric",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *hpa.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
+var newScalingConstraint = semver.MustConstraint(">= 2.0.0")
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func CustomMetricsMutation(version string, enabled bool) hpa.Mutation {
+func AggressiveScalingMutation(version string, enabled bool) hpa.Mutation {
return hpa.Mutation{
- Name: "custom-metrics",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Name: "aggressive-scaling",
+ Feature: feature.NewVersionGate(version, []feature.VersionConstraint{newScalingConstraint}).
+ When(enabled),
Mutate: func(m *hpa.Mutator) error {
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
- e.EnsureMetric(autoscalingv2.MetricSpec{
- Type: autoscalingv2.PodsMetricSourceType,
- Pods: &autoscalingv2.PodsMetricSource{
- Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"},
- Target: autoscalingv2.MetricTarget{
- Type: autoscalingv2.AverageValueMetricType,
- AverageValue: ptr.To(resource.MustParse("100")),
- },
+ e.SetMaxReplicas(20)
+ e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{
+ ScaleDown: &autoscalingv2.HPAScalingRules{
+ StabilizationWindowSeconds: ptr.To(int32(60)),
},
})
return nil
@@ -93,48 +76,26 @@ func CustomMetricsMutation(version string, enabled bool) hpa.Mutation {
}
```
-### Version-gated mutations
-
-Pass a `[]feature.VersionConstraint` to gate on a semver range:
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyScalingMutation(version string) hpa.Mutation {
- return hpa.Mutation{
- Name: "legacy-scaling",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *hpa.Mutator) error {
- m.EditHPASpec(func(e *editors.HPASpecEditor) error {
- e.SetMaxReplicas(5) // legacy apps limited to 5 replicas
- return nil
- })
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded:
+Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded:
| Step | Category | What it affects |
| ---- | -------------- | -------------------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object |
| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior |
+Features apply in registration order. Later features observe the HPA as modified by all earlier ones.
+
## Relevant Editors
+For the full method list of any editor see the
+[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The
+generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors).
+
### HPASpecEditor
-Controls HPA-level settings via `m.EditHPASpec`.
+Controls the HPA spec via `m.EditHPASpec`.
Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`,
`SetBehavior`, `Raw`.
@@ -157,30 +118,29 @@ m.EditHPASpec(func(e *editors.HPASpecEditor) error {
})
```
-#### EnsureMetric
-
-`EnsureMetric` upserts a metric based on its full metric identity, not just type and name. Matching rules:
+#### EnsureMetric identity rules
-| Metric type | Match key |
-| ----------------- | --------------------------------------------------------------------------------------------------------- |
-| Resource | `Resource.Name` (e.g. `cpu`, `memory`) |
-| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label selector; `nil` is a distinct identity) |
-| Object | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` |
-| ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` |
-| External | `External.Metric.Name` + `External.Metric.Selector` (label selector; `nil` is a distinct identity) |
+`EnsureMetric` upserts by full metric identity. If a matching entry exists it is replaced; otherwise the metric is
+appended.
-If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or
-described objects result in different metric identities, even if the metric names are the same.
+| Metric type | Match key |
+| ------------------- | --------------------------------------------------------------------------------------------------------- |
+| `Resource` | `Resource.Name` (e.g. `cpu`, `memory`) |
+| `Pods` | `Pods.Metric.Name` + `Pods.Metric.Selector` (`nil` is a distinct identity) |
+| `Object` | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` |
+| `ContainerResource` | `ContainerResource.Name` + `ContainerResource.Container` |
+| `External` | `External.Metric.Name` + `External.Metric.Selector` (`nil` is a distinct identity) |
#### RemoveMetric
-`RemoveMetric(type, name)` removes all metrics matching the given type and name. For ContainerResource metrics, all
-container variants of the named resource are removed.
+`RemoveMetric(type, name)` removes all metrics matching the given type and name. For `ContainerResource` metrics all
+container variants of the named resource are removed. For fine-grained removal of a single identity, use `Raw()` and
+modify the slice directly.
#### SetBehavior
`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom
-behavior and use Kubernetes defaults.
+behavior and revert to Kubernetes defaults.
```go
m.EditHPASpec(func(e *editors.HPASpecEditor) error {
@@ -210,29 +170,23 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
- e.EnsureLabel("app.kubernetes.io/managed-by", "my-operator")
- e.EnsureAnnotation("autoscaling.example.io/policy", "aggressive")
+ e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
```
-### Raw Escape Hatch
-
-All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is
-insufficient.
-
## Operational Status
-The default operational status handler inspects `Status.Conditions`:
+The default handler inspects `Status.Conditions`:
-| Status | Condition |
-| ------------- | ------------------------------------------------------- |
-| `Operational` | `ScalingActive` is `True` |
-| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` |
-| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` |
+| Status | Condition |
+| ------------------ | ------------------------------------------------------- |
+| `Operational` | `ScalingActive` is `True` |
+| `OperationPending` | Conditions absent, or `ScalingActive` is `Unknown` |
+| `OperationFailing` | `ScalingActive` is `False`, or `AbleToScale` is `False` |
-`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not
-operationally healthy regardless of what the scaling-active condition reports.
+`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot scale is not healthy
+regardless of what the scaling-active condition reports.
Override with `WithCustomOperationalStatus`:
@@ -250,7 +204,7 @@ hpa.NewBuilder(base).
## Grace Status
-The default grace status handler inspects `Status.Conditions` to assess health after the grace period expires:
+The default grace handler applies the same condition inspection after the grace period expires:
| Status | Condition |
| ---------- | ------------------------------------------------------- |
@@ -258,9 +212,6 @@ The default grace status handler inspects `Status.Conditions` to assess health a
| `Degraded` | Conditions absent, or `ScalingActive` is `Unknown` |
| `Down` | `ScalingActive` is `False`, or `AbleToScale` is `False` |
-`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not
-healthy regardless of what the scaling-active condition reports.
-
Override with `WithCustomGraceStatus`:
```go
@@ -277,27 +228,46 @@ hpa.NewBuilder(base).
## Suspension
-HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component is
-suspended (`DefaultDeleteOnSuspendHandler` returns `true`). A retained HPA would conflict with the suspension of its
-scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA controller continuously enforces
-`minReplicas` and would scale the target back up. Deleting the HPA prevents this interference. On resume the framework
-recreates the HPA with the desired spec.
+HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component
+suspends and recreated on resume.
-The default suspension status handler reports `Suspended` immediately with the reason
-`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. Override this handler with
-`WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour.
+The reason this is necessary is the sequencing interaction with the HPA's scale target. When a `Deployment` (or other
+workload) is suspended, the framework scales it to zero. A retained HPA would continuously enforce `minReplicas` and
+scale the target back up, fighting the suspension. By deleting the HPA first, the target is free to scale down cleanly.
+On resume the framework recreates the HPA before bringing the workload back.
-Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale
-target is managed externally and will not be present during suspension):
+The default suspension status handler reports `Suspended` immediately because the deletion is handled by the framework
+and no additional convergence is required.
+
+Override the deletion decision with `WithCustomSuspendDeletionDecision`:
```go
hpa.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool {
- return false // keep HPA during suspension
+ return false // keep the HPA during suspension
})
```
-## Full Example: CPU and Memory Autoscaling
+!!! note "When to keep the HPA"
+
+ Retaining the HPA during suspension is only appropriate when the scale target is managed externally and will not
+ be present during the component's suspension period. In the normal case where the HPA and its target are both
+ managed by the same component, use the default delete behavior.
+
+Override the suspension reason with `WithCustomSuspendStatus` if you need a message that reflects a non-default deletion
+decision:
+
+```go
+hpa.NewBuilder(base).
+ WithCustomSuspendStatus(func(_ *autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error) {
+ return concepts.SuspensionStatusWithReason{
+ Status: concepts.SuspensionStatusSuspended,
+ Reason: "HPA retained; scale target managed externally",
+ }, nil
+ })
+```
+
+## Full Example
```go
func AutoscalingMutation(version string) hpa.Mutation {
@@ -309,7 +279,7 @@ func AutoscalingMutation(version string) hpa.Mutation {
e.SetMinReplicas(ptr.To(int32(2)))
e.SetMaxReplicas(10)
- // CPU-based scaling
+ // CPU-based scaling target
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
@@ -321,7 +291,7 @@ func AutoscalingMutation(version string) hpa.Mutation {
},
})
- // Memory-based scaling
+ // Memory-based scaling target
e.EnsureMetric(autoscalingv2.MetricSpec{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
@@ -333,7 +303,7 @@ func AutoscalingMutation(version string) hpa.Mutation {
},
})
- // Conservative scale-down
+ // Conservative scale-down to avoid thrashing
e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{
ScaleDown: &autoscalingv2.HPAScalingRules{
StabilizationWindowSeconds: ptr.To(int32(300)),
@@ -352,24 +322,29 @@ func AutoscalingMutation(version string) hpa.Mutation {
},
}
}
+
+resource, err := hpa.NewBuilder(base).
+ WithMutation(AutoscalingMutation(owner.Spec.Version)).
+ Build()
```
-Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per
-the internal ordering. Order your source calls for readability; the framework handles execution order.
+Although `EditObjectMetadata` is called after `EditHPASpec` in source, metadata edits are applied first per the internal
+ordering. Call order inside `Mutate` is for readability only; the framework enforces the correct execution sequence.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use
+`feature.NewVersionGate(version, constraints)` for version gating and chain `.When(bool)` for boolean conditions.
+
+**Register mutations in dependency order.** If mutation B relies on a metric or field set by mutation A, register A
+first.
-**Register mutations in dependency order.** If mutation B relies on a metric added by mutation A, register A first.
+**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity so repeated calls
+with the same identity update rather than duplicate.
-**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name,
-selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate.
+**Delete on suspend is the correct default.** The HPA is removed during component suspension to prevent it from fighting
+a scale-to-zero workload. Only override the deletion decision when the scale target is managed externally.
-**HPA deletion on suspend is the default.** The primitive's default `DeleteOnSuspend` decision removes the HPA during
-component suspension (matching the "Suspension (delete)" capability). This prevents the Kubernetes HPA controller from
-scaling the target back up while it is suspended. On resume the framework recreates the HPA with the desired spec. If
-you need the HPA to be retained during suspension (for example, when the scale target is managed externally and will not
-be present), override `WithCustomSuspendDeletionDecision` to return `false`.
+**Pair the suspension status handler with the deletion decision.** The default suspension reason is intentionally
+deletion-agnostic. If you override `WithCustomSuspendDeletionDecision` to retain the HPA, also override
+`WithCustomSuspendStatus` so the reason accurately describes what is happening.
diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md
index c5319a5a..6ccd685a 100644
--- a/docs/primitives/ingress.md
+++ b/docs/primitives/ingress.md
@@ -1,18 +1,21 @@
# Ingress Primitive
-The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources.
-It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a structured
-mutation API for managing rules, TLS configuration, and metadata. For an overview of all built-in primitives, see
-[Primitives](../primitives.md).
+The `ingress` primitive wraps a Kubernetes `Ingress` and integrates with the component lifecycle as an Integration,
+Graceful, and Suspendable resource, providing a structured mutation API for managing rules, TLS configuration, and
+metadata.
## Capabilities
-| Capability | Detail |
-| ---------------------- | ---------------------------------------------------------------------------------------------- |
-| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` |
-| **Grace status** | Reports `Degraded` until a load balancer IP or hostname is assigned, then `Healthy` |
-| **Suspension** | No-op by default. Ingress is left in place; backend returns 502/503 |
-| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) |
+| Capability | Detail |
+| --------------------- | ---------------------------------------------------------------------------------------------------- |
+| **Operational** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` |
+| **Graceful** | Reports `Degraded` until a load balancer IP or hostname is assigned, then `Healthy` |
+| **Suspendable** | No-op by default. Ingress is left in place; backend returns 502/503 when the backing service is down |
+| **DataExtractable** | Reads assigned load balancer addresses after each sync cycle via `WithDataExtractor` |
+| **Mutation pipeline** | Typed editors for metadata and Ingress spec (rules, TLS, class name, default backend) |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building an Ingress Primitive
@@ -28,7 +31,7 @@ base := &networkingv1.Ingress{
IngressClassName: ptr.To("nginx"),
Rules: []networkingv1.IngressRule{
{
- Host: "example.com",
+ Host: "app.example.com",
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
@@ -57,31 +60,12 @@ resource, err := ingress.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) ingress.Mutation {
- return ingress.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *ingress.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
+A kind-specific example gating a TLS mutation on a boolean condition:
```go
func TLSMutation(version string, enabled bool) ingress.Mutation {
@@ -91,7 +75,7 @@ func TLSMutation(version string, enabled bool) ingress.Mutation {
Mutate: func(m *ingress.Mutator) error {
m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
e.EnsureTLS(networkingv1.IngressTLS{
- Hosts: []string{"example.com"},
+ Hosts: []string{"app.example.com"},
SecretName: "tls-cert",
})
return nil
@@ -102,23 +86,22 @@ func TLSMutation(version string, enabled bool) ingress.Mutation {
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | ------------------ | ----------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `Ingress` object |
| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor |
-Within each category, edits are applied in their registration order. Later features observe the Ingress as modified by
-all previous features.
+Within each category, edits run in registration order. Later features observe the Ingress as modified by all earlier
+ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### IngressSpecEditor
The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full control:
@@ -126,9 +109,9 @@ The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full
```go
m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
e.SetIngressClassName("nginx")
- e.EnsureRule(networkingv1.IngressRule{Host: "example.com"})
+ e.EnsureRule(networkingv1.IngressRule{Host: "app.example.com"})
e.EnsureTLS(networkingv1.IngressTLS{
- Hosts: []string{"example.com"},
+ Hosts: []string{"app.example.com"},
SecretName: "tls-cert",
})
return nil
@@ -182,7 +165,7 @@ matches any of the provided hosts.
```go
m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
e.EnsureTLS(networkingv1.IngressTLS{
- Hosts: []string{"example.com", "www.example.com"},
+ Hosts: []string{"app.example.com", "www.example.com"},
SecretName: "wildcard-tls",
})
e.RemoveTLS("old.example.com")
@@ -192,7 +175,7 @@ m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
#### Raw Escape Hatch
-`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access when the typed API is insufficient:
+`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access:
```go
m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
@@ -217,30 +200,25 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
## Operational Status
-The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of
-`concepts.Alive`.
+The Ingress primitive implements `concepts.Operational`. The default handler iterates over `Status.LoadBalancer.Ingress`
+entries and requires at least one with a non-empty `IP` or `Hostname`:
-### DefaultOperationalStatusHandler
+| Condition | Status |
+| ----------------------------------------- | ------------------ |
+| Entry with `IP != ""` or `Hostname != ""` | `Operational` |
+| Otherwise | `OperationPending` |
-| Condition | Status | Reason |
-| ----------------------------------------- | ------------------ | ----------------------------------------- |
-| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address |
-| Otherwise | `OperationPending` | Awaiting load balancer address assignment |
-
-The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or
-`Hostname` to report operational.
-
-Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by
-cloud providers).
+Override with `WithCustomOperationalStatus` for more complex health checks, such as verifying specific annotations set
+by cloud providers.
## Grace Status
-The default grace status handler inspects `Status.LoadBalancer.Ingress` to assess health after the grace period expires:
+The default grace status handler inspects `Status.LoadBalancer.Ingress` after the grace period expires:
-| Status | Condition |
-| ---------- | -------------------------------------------------------- |
-| `Healthy` | At least one entry with a non-empty `IP` or `Hostname` |
-| `Degraded` | No entries, or all entries lack both `IP` and `Hostname` |
+| Condition | Status |
+| -------------------------------------------------------- | ---------- |
+| At least one entry with a non-empty `IP` or `Hostname` | `Healthy` |
+| No entries, or all entries lack both `IP` and `Hostname` | `Degraded` |
Override with `WithCustomGraceStatus`:
@@ -258,18 +236,18 @@ ingress.NewBuilder(base).
## Suspension
-### Default Behaviour
+### Default Behavior
-The default suspension strategy is a **no-op**:
+The default suspension strategy is a no-op:
- `DefaultDeleteOnSuspendHandler` returns `false`. The Ingress is not deleted.
- `DefaultSuspendMutationHandler` does nothing. The Ingress spec is not modified.
- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason
`"Ingress suspended (backend unavailable)"`.
-**Rationale**: deleting an Ingress causes the ingress controller (e.g. nginx) to reload its configuration, which affects
-the entire cluster's routing, not just the suspended service. When the backend service is suspended, the Ingress
-returning 502/503 is the correct observable behaviour.
+**Rationale**: deleting an Ingress causes the ingress controller to reload its configuration, which affects the entire
+cluster's routing, not just the suspended service. When the backend service is suspended, the Ingress returning 502/503
+is the correct observable behavior.
### Custom Suspension
@@ -283,13 +261,76 @@ resource, err := ingress.NewBuilder(base).
Build()
```
+## Full Example
+
+```go
+func BaseIngressMutation(version string) ingress.Mutation {
+ return ingress.Mutation{
+ Name: "base-ingress",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *ingress.Mutator) error {
+ m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
+ e.SetIngressClassName("nginx")
+ e.EnsureRule(networkingv1.IngressRule{
+ Host: "app.example.com",
+ IngressRuleValue: networkingv1.IngressRuleValue{
+ HTTP: &networkingv1.HTTPIngressRuleValue{
+ Paths: []networkingv1.HTTPIngressPath{
+ {
+ Path: "/",
+ PathType: ptr.To(networkingv1.PathTypePrefix),
+ Backend: networkingv1.IngressBackend{
+ Service: &networkingv1.IngressServiceBackend{
+ Name: "web-svc",
+ Port: networkingv1.ServiceBackendPort{Number: 80},
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+func TLSMutation(version string, enabled bool) ingress.Mutation {
+ return ingress.Mutation{
+ Name: "tls",
+ Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Mutate: func(m *ingress.Mutator) error {
+ m.EditIngressSpec(func(e *editors.IngressSpecEditor) error {
+ e.EnsureTLS(networkingv1.IngressTLS{
+ Hosts: []string{"app.example.com"},
+ SecretName: "tls-cert",
+ })
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+resource, err := ingress.NewBuilder(base).
+ WithMutation(BaseIngressMutation(owner.Spec.Version)).
+ WithMutation(TLSMutation(owner.Spec.Version, owner.Spec.TLSEnabled)).
+ Build()
+```
+
+When `TLSEnabled` is true, the Ingress includes a TLS block for the host. When false, only the rule is present.
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions.
**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first.
**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override
to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension.
+
+**Use `EnsureRule` for idempotent rule management.** Rules are matched by `Host`; repeated calls with the same host
+replace the existing rule rather than duplicating it.
diff --git a/docs/primitives/job.md b/docs/primitives/job.md
index 578ea304..2e40c4d1 100644
--- a/docs/primitives/job.md
+++ b/docs/primitives/job.md
@@ -1,16 +1,16 @@
# Job Primitive
-The `job` primitive is the framework's built-in task abstraction for managing Kubernetes `Job` resources. It integrates
-fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata,
-following the same pod-template mutation pattern as the Deployment primitive.
+The `job` primitive wraps a Kubernetes `Job` and provides completion tracking, suspension, and a typed mutation API for
+managing job spec, pod spec, and containers as part of the component lifecycle.
## Capabilities
-| Capability | Detail |
-| ----------------------- | ----------------------------------------------------------------------------------------------- |
-| **Completion tracking** | Monitors Job conditions and reports `Completed`, `TaskRunning`, `TaskPending`, or `TaskFailing` |
-| **Suspension** | Sets `spec.suspend=true` or deletes the Job (default); reports `Suspending` / `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, job spec, pod spec, and containers |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | -------------------------------------------------------- |
+| `Completable` | `Completed`, `TaskRunning`, `TaskPending`, `TaskFailing` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a Job Primitive
@@ -27,7 +27,7 @@ base := &batchv1.Job{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
- {Name: "migrate", Image: "my-app-migration:latest"},
+ {Name: "migrate", Image: "migration-tool:latest"},
},
},
},
@@ -41,65 +41,16 @@ resource, err := job.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `Job` beyond its baseline. Each mutation is a named function that
-receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Each mutation is a named `job.Mutation` that receives a `*job.Mutator` and records edits through typed editors.
```go
-func MyFeatureMutation(version string) job.Mutation {
+func MigrationConfigMutation(version string) job.Mutation {
return job.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *job.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func TracingMutation(version string, enabled bool) job.Mutation {
- return job.Mutation{
- Name: "tracing",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *job.Mutator) error {
- m.EnsureContainerEnvVar(corev1.EnvVar{
- Name: "OTEL_EXPORTER_OTLP_ENDPOINT",
- Value: "http://otel-collector:4317",
- })
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-Pass a `[]feature.VersionConstraint` to gate on a semver range:
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyMigrationMutation(version string) job.Mutation {
- return job.Mutation{
- Name: "legacy-migration-format",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
+ Name: "migration-config",
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *job.Mutator) error {
m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error {
- e.EnsureEnvVar(corev1.EnvVar{Name: "MIGRATION_FORMAT", Value: "v1"})
+ e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"})
return nil
})
return nil
@@ -108,16 +59,17 @@ func LegacyMigrationMutation(version string) job.Mutation {
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded. This ensures structural consistency across mutations.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
-| 1 | Job metadata edits | Labels and annotations on the `Job` object |
+| 1 | Object metadata edits | Labels and annotations on the `Job` object |
| 2 | JobSpec edits | Completions, parallelism, backoff limit, deadline, etc. |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
@@ -126,11 +78,13 @@ order they are recorded. This ensures structural consistency across mutations.
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
-Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### JobSpecEditor
Controls job-level settings via `m.EditJobSpec`.
@@ -146,7 +100,7 @@ m.EditJobSpec(func(e *editors.JobSpecEditor) error {
})
```
-For fields not covered by the typed API, use `Raw()`:
+Use `Raw()` for fields the typed API does not cover:
```go
m.EditJobSpec(func(e *editors.JobSpecEditor) error {
@@ -180,15 +134,15 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
```go
m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error {
- e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "postgres:5432"})
+ e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: "db:5432"})
e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
return nil
})
@@ -196,8 +150,8 @@ m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerE
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `Job` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `Job` itself or `m.EditPodTemplateMetadata` for the
+pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
@@ -210,47 +164,87 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
## Convenience Methods
-The `Mutator` exposes convenience wrappers that target all containers at once:
-
| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |
+## Workload-Kind-Agnostic Mutations
+
+The `job.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation` adapter.
+The `WorkloadMutator` interface targets Deployment, StatefulSet, and DaemonSet. Write shared mutation logic as a plain
+function accepting `*job.Mutator` and call it directly.
+
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern.
+
## Suspension
-Jobs use the Task lifecycle for suspension, which differs from Workloads:
+Jobs use the `Completable` lifecycle rather than `Alive`. The suspension behavior differs from Workload primitives:
- **Default behavior**: `DefaultDeleteOnSuspendHandler` returns `true`, meaning the Job is deleted from the cluster
during suspension.
- **Suspend mutation**: `DefaultSuspendMutationHandler` sets `spec.suspend=true`, which prevents the Job controller from
creating new pods while allowing existing pods to complete.
-- **Suspension status**: `DefaultSuspensionStatusHandler` checks if `spec.suspend=true` and `status.active=0`.
+- **Suspension status**: `DefaultSuspensionStatusHandler` reports `Suspending` if `spec.suspend=true` but active pods
+ remain, and `Suspended` once `spec.suspend=true` and `status.active==0`.
-Override any of these via the Builder:
+Override any handler via `WithCustomSuspendDeletionDecision`, `WithCustomSuspendMutation`, or `WithCustomSuspendStatus`
+on the builder:
```go
resource, err := job.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(j *batchv1.Job) bool {
- return false // Keep the Job in the cluster when suspended
+ return false // keep the Job in the cluster when suspended
}).
Build()
```
+## Full Example
+
+```go
+func MigrationMutation(version string, dbHost string) job.Mutation {
+ return job.Mutation{
+ Name: "migration",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *job.Mutator) error {
+ m.EditJobSpec(func(e *editors.JobSpecEditor) error {
+ e.SetBackoffLimit(3)
+ e.SetActiveDeadlineSeconds(300)
+ return nil
+ })
+
+ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
+ e.SetServiceAccountName("migration-sa")
+ return nil
+ })
+
+ m.EditContainers(selectors.ContainerNamed("migrate"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVar(corev1.EnvVar{Name: "DB_HOST", Value: dbHost})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
+ e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi"))
+ return nil
+ })
+
+ return nil
+ },
+ }
+}
+```
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension.
+Override `WithCustomSuspendDeletionDecision` if you need the Job resource to remain in the cluster.
+
+**Set `RestartPolicy` in the baseline.** Kubernetes requires `spec.template.spec.restartPolicy` to be `OnFailure` or
+`Never` for Jobs. Set it in the desired object passed to `NewBuilder`.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
-The internal ordering within each mutation handles intra-mutation dependencies automatically.
-
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if init containers or sidecar containers are present.
-
-**Jobs are deleted on suspend by default.** Unlike Deployments which scale to zero, Jobs are deleted during suspension.
-Override `WithCustomSuspendDeletionDecision` if you need to keep the Job resource in the cluster.
diff --git a/docs/primitives/networkpolicy.md b/docs/primitives/networkpolicy.md
index c7895d70..b49dfa9e 100644
--- a/docs/primitives/networkpolicy.md
+++ b/docs/primitives/networkpolicy.md
@@ -1,17 +1,19 @@
# NetworkPolicy Primitive
-The `networkpolicy` primitive is the framework's built-in static abstraction for managing Kubernetes `NetworkPolicy`
-resources. It integrates with the component lifecycle and provides a structured mutation API for managing pod selectors,
-ingress rules, egress rules, and policy types.
+The `networkpolicy` primitive wraps a Kubernetes `NetworkPolicy` and integrates with the component lifecycle as a Static
+resource, providing a structured mutation API for managing pod selectors, ingress rules, egress rules, and policy types.
## Capabilities
| Capability | Detail |
| --------------------- | ---------------------------------------------------------------------------------------------------------- |
| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for NetworkPolicy spec and object metadata, with a raw escape hatch for free-form access |
-| **Append semantics** | Ingress and egress rules have no unique key. `AppendIngressRule`/`AppendEgressRule` append unconditionally |
-| **Data extraction** | Reads generated or updated values back from the reconciled NetworkPolicy after each sync cycle |
+| **Mutation pipeline** | Typed editors for NetworkPolicy spec and object metadata, with a `Raw()` escape hatch |
+| **Append semantics** | Ingress and egress rules have no unique key; `AppendIngressRule`/`AppendEgressRule` append unconditionally |
+| **DataExtractable** | Reads values back from the reconciled NetworkPolicy after each sync cycle via `WithDataExtractor` |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building a NetworkPolicy Primitive
@@ -41,11 +43,12 @@ resource, err := networkpolicy.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `NetworkPolicy` beyond its baseline. Each mutation is a named
-function that receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. Prefer this
-for mutations that should always run and do not need feature-gate evaluation:
+A kind-specific example appending an ingress rule unconditionally:
```go
func HTTPIngressMutation() networkpolicy.Mutation {
@@ -69,75 +72,22 @@ func HTTPIngressMutation() networkpolicy.Mutation {
}
```
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-```go
-func MetricsIngressMutation(version string, enableMetrics bool) networkpolicy.Mutation {
- return networkpolicy.Mutation{
- Name: "metrics-ingress",
- Feature: feature.NewVersionGate(version, nil).When(enableMetrics),
- Mutate: func(m *networkpolicy.Mutator) error {
- m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error {
- port := intstr.FromInt32(9090)
- tcp := corev1.ProtocolTCP
- e.AppendIngressRule(networkingv1.NetworkPolicyIngressRule{
- Ports: []networkingv1.NetworkPolicyPort{
- {Protocol: &tcp, Port: &port},
- },
- })
- return nil
- })
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyNetworkPolicyMutation(version string) networkpolicy.Mutation {
- return networkpolicy.Mutation{
- Name: "legacy-policy",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *networkpolicy.Mutator) error {
- m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error {
- e.SetPolicyTypes([]networkingv1.PolicyType{
- networkingv1.PolicyTypeIngress,
- })
- return nil
- })
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | -------------- | --------------------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `NetworkPolicy` |
| 2 | Spec edits | Pod selector, ingress rules, egress rules, policy types via Raw |
-Within each category, edits are applied in their registration order. Later features observe the NetworkPolicy as
-modified by all previous features.
+Within each category, edits run in registration order. Later features observe the NetworkPolicy as modified by all
+earlier ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### NetworkPolicySpecEditor
The primary API for modifying the NetworkPolicy spec. Use `m.EditNetworkPolicySpec` for full control:
@@ -190,8 +140,7 @@ Sets the policy types. Valid values are `networkingv1.PolicyTypeIngress` and `ne
#### Raw Escape Hatch
-`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing when none of the structured
-methods are sufficient:
+`Raw()` returns the underlying `*networkingv1.NetworkPolicySpec` for free-form editing:
```go
m.EditNetworkPolicySpec(func(e *editors.NetworkPolicySpecEditor) error {
@@ -218,7 +167,23 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
-## Full Example: Feature-Composed Network Policy
+## Data Extraction
+
+Use `WithDataExtractor` to read values from the reconciled NetworkPolicy after each sync cycle. This is useful when
+downstream resources need to observe the final applied policy (for example, its resource version or assigned labels):
+
+```go
+var policyName string
+
+resource, err := networkpolicy.NewBuilder(base).
+ WithDataExtractor(func(np networkingv1.NetworkPolicy) error {
+ policyName = np.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
```go
func HTTPIngressMutation() networkpolicy.Mutation {
@@ -266,14 +231,13 @@ resource, err := networkpolicy.NewBuilder(base).
Build()
```
-When `EnableMetrics` is true, the final NetworkPolicy will have both HTTP and metrics ingress rules. When false, only
-the HTTP rule is present. Neither mutation needs to know about the other.
+When `EnableMetrics` is true, the final NetworkPolicy has both HTTP and metrics ingress rules. When false, only the HTTP
+rule is present. Neither mutation needs to know about the other.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions.
**Use `RemoveIngressRules`/`RemoveEgressRules` for atomic replacement.** Since rules have no unique key, there is no
upsert-by-key operation. To replace the full set of rules, call `Remove*Rules` first and then add the desired rules.
@@ -282,3 +246,6 @@ Alternatively, use `Raw()` for fine-grained manipulation.
**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. Since
`AppendIngressRule`/`AppendEgressRule` append unconditionally, the order of registration determines the order of rules
in the resulting spec.
+
+**NetworkPolicy is Static.** It has no operational status, grace status, or suspension behavior. If the policy applies,
+the resource is considered ready.
diff --git a/docs/primitives/pdb.md b/docs/primitives/pdb.md
index 46a31d3c..ceab4477 100644
--- a/docs/primitives/pdb.md
+++ b/docs/primitives/pdb.md
@@ -1,16 +1,24 @@
# PodDisruptionBudget Primitive
-The `pdb` primitive is the framework's built-in static abstraction for managing Kubernetes `PodDisruptionBudget`
-resources. It integrates with the component lifecycle and provides a structured mutation API for managing disruption
-policies and object metadata.
+The `pdb` primitive wraps `policy/v1 PodDisruptionBudget` and reconciles it to desired state without health tracking or
+suspension.
+
+!!! note "PDB is Static"
+
+ Despite sitting in the "Scaling & Availability" nav group alongside HPA, `PodDisruptionBudget` is a
+ [Static](../primitives.md#static) primitive. It has no convergence loop, no operational status, and no
+ suspension behavior. The resource is applied and considered ready once it exists. Readers expecting an
+ `OperationPending` condition will not find one.
## Capabilities
-| Capability | Detail |
-| --------------------- | --------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for PDB spec and object metadata, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads generated or updated values back from the reconciled PDB after each sync cycle |
+The interfaces below are from [`pkg/component/concepts`](../primitives.md#lifecycle-interfaces). The values in the table
+are the runtime strings that appear in conditions.
+
+| Interface | Reported status values | Notes |
+| ----------------- | ----------------------------- | ------------------------------------------- |
+| `Guardable` | `Blocked` | Optional runtime precondition |
+| `DataExtractable` | _(side-effecting, no status)_ | Read generated fields after each sync cycle |
## Building a PDB Primitive
@@ -20,53 +28,37 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pdb"
minAvailable := intstr.FromString("50%")
base := &policyv1.PodDisruptionBudget{
ObjectMeta: metav1.ObjectMeta{
- Name: "web-server-pdb",
+ Name: "backend-pdb",
Namespace: owner.Namespace,
},
Spec: policyv1.PodDisruptionBudgetSpec{
MinAvailable: &minAvailable,
Selector: &metav1.LabelSelector{
- MatchLabels: map[string]string{"app": "web-server"},
+ MatchLabels: map[string]string{"app": "backend"},
},
},
}
resource, err := pdb.NewBuilder(base).
- WithMutation(MyFeatureMutation(owner.Spec.Version)).
+ WithMutation(DisruptionPolicyMutation(owner.Spec.Version)).
Build()
```
## Mutations
-Mutations are the primary mechanism for modifying a `PodDisruptionBudget` beyond its baseline. Each mutation is a named
-function that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) pdb.Mutation {
- return pdb.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *pdb.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
+Mutations are named functions that receive a `*pdb.Mutator` and record edit intent through typed editors. For a full
+explanation of the mutation system, boolean-gated mutations, and version-gated mutations see
+[The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-### Boolean-gated mutations
+A concise boolean-gated example that switches from percentage-based `MinAvailable` to absolute `MaxUnavailable`:
```go
-func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
+func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation {
return pdb.Mutation{
Name: "strict-availability",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Feature: feature.NewVersionGate(version, nil).When(strict),
Mutate: func(m *pdb.Mutator) error {
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.ClearMinAvailable()
@@ -79,55 +71,35 @@ func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
}
```
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyPDBMutation(version string) pdb.Mutation {
- return pdb.Mutation{
- Name: "legacy-pdb",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *pdb.Mutator) error {
- m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
- e.SetMinAvailable(intstr.FromInt32(1))
- return nil
- })
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded:
| Step | Category | What it affects |
| ---- | -------------- | ------------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `PodDisruptionBudget` |
| 2 | Spec edits | MinAvailable, MaxUnavailable, selector, eviction policy |
-Within each category, edits are applied in their registration order. Later features observe the PodDisruptionBudget as
-modified by all previous features.
+Features apply in registration order. Later features observe the PDB as modified by all earlier ones.
## Relevant Editors
+For the full method list of any editor see the
+[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The
+generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors).
+
### PodDisruptionBudgetSpecEditor
-The primary API for modifying the PDB spec. Use `m.EditSpec` for full control:
+The primary API for modifying the PDB spec. Access it via `m.EditSpec`.
+
+Available methods: `SetMinAvailable`, `SetMaxUnavailable`, `ClearMinAvailable`, `ClearMaxUnavailable`, `SetSelector`,
+`SetUnhealthyPodEvictionPolicy`, `ClearUnhealthyPodEvictionPolicy`, `Raw`.
```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.SetMinAvailable(intstr.FromString("50%"))
e.SetSelector(&metav1.LabelSelector{
- MatchLabels: map[string]string{"app": "web"},
+ MatchLabels: map[string]string{"app": "backend"},
})
return nil
})
@@ -135,12 +107,8 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
#### SetMinAvailable and SetMaxUnavailable
-`SetMinAvailable` sets the minimum number of pods that must remain available during a disruption. `SetMaxUnavailable`
-sets the maximum number of pods that can be unavailable. Both accept `intstr.IntOrString`, either an integer count or a
-percentage string (e.g. `"50%"`).
-
-These fields are mutually exclusive in the Kubernetes API. Use `ClearMinAvailable` or `ClearMaxUnavailable` to remove
-the opposing constraint when switching between them:
+Both methods accept `intstr.IntOrString`, either an integer count or a percentage string (e.g. `"50%"`). These fields
+are mutually exclusive in the Kubernetes API. When switching between them, clear the opposing field first:
```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
@@ -150,24 +118,10 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
})
```
-#### SetSelector
-
-`SetSelector` replaces the pod selector used by the PDB:
-
-```go
-m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
- e.SetSelector(&metav1.LabelSelector{
- MatchLabels: map[string]string{"app": "web", "tier": "frontend"},
- })
- return nil
-})
-```
-
#### SetUnhealthyPodEvictionPolicy
-`SetUnhealthyPodEvictionPolicy` controls how unhealthy pods are handled during eviction. Valid values are
-`policyv1.IfHealthyBudget` and `policyv1.AlwaysAllow`. Use `ClearUnhealthyPodEvictionPolicy` to revert to the cluster
-default:
+Controls how unhealthy pods are handled during eviction. Valid values are `policyv1.IfHealthyBudget` and
+`policyv1.AlwaysAllow`. Use `ClearUnhealthyPodEvictionPolicy` to revert to the cluster default:
```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
@@ -176,9 +130,7 @@ m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
})
```
-#### Raw Escape Hatch
-
-`Raw()` returns the underlying `*policyv1.PodDisruptionBudgetSpec` for direct access when the typed API is insufficient:
+For fields not covered by the typed API, use `Raw()`:
```go
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
@@ -196,12 +148,25 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
- e.EnsureAnnotation("pdb.example.io/policy", "strict")
return nil
})
```
-## Full Example: Feature-Gated Disruption Policy
+## Data Extraction
+
+Use `WithDataExtractor` to read generated or server-populated fields after each sync cycle. The extractor receives a
+value copy of the reconciled PDB:
+
+```go
+pdb.NewBuilder(base).
+ WithDataExtractor(func(p policyv1.PodDisruptionBudget) error {
+ // p.Status.ExpectedPods is populated by the Kubernetes PDB controller
+ myComponent.ExpectedPods = p.Status.ExpectedPods
+ return nil
+ })
+```
+
+## Full Example
```go
func BasePDBMutation(version string) pdb.Mutation {
@@ -218,10 +183,10 @@ func BasePDBMutation(version string) pdb.Mutation {
}
}
-func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
+func StrictAvailabilityMutation(version string, strict bool) pdb.Mutation {
return pdb.Mutation{
Name: "strict-availability",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Feature: feature.NewVersionGate(version, nil).When(strict),
Mutate: func(m *pdb.Mutator) error {
m.EditSpec(func(e *editors.PodDisruptionBudgetSpecEditor) error {
e.ClearMinAvailable()
@@ -233,23 +198,44 @@ func StrictAvailabilityMutation(version string, enabled bool) pdb.Mutation {
}
}
+minAvailable := intstr.FromString("50%")
+base := &policyv1.PodDisruptionBudget{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "backend-pdb",
+ Namespace: owner.Namespace,
+ },
+ Spec: policyv1.PodDisruptionBudgetSpec{
+ MinAvailable: &minAvailable,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "backend"},
+ },
+ },
+}
+
resource, err := pdb.NewBuilder(base).
WithMutation(BasePDBMutation(owner.Spec.Version)).
WithMutation(StrictAvailabilityMutation(owner.Spec.Version, owner.Spec.StrictMode)).
Build()
```
-When `StrictMode` is true, the PDB switches from percentage-based `MinAvailable` to an absolute `MaxUnavailable` of 1.
-When false, only the base mutation runs and the original `MinAvailable` from the baseline is preserved. Neither mutation
+When `StrictMode` is true the PDB switches from percentage-based `MinAvailable` to an absolute `MaxUnavailable` of 1.
+When false only the base mutation runs and the original `MinAvailable` from the baseline is preserved. Neither mutation
needs to know about the other.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**PDB is Static: there is no operational status.** Registering a PDB in a component contributes no `Operational` or
+`Alive` condition. It simply exists or does not. If you need lifecycle signals, use the HPA or another Integration
+primitive alongside the PDB.
**`MinAvailable` and `MaxUnavailable` are mutually exclusive.** When switching between them, always clear the opposing
field first. The typed API makes this explicit with `ClearMinAvailable` and `ClearMaxUnavailable`.
+**Selector and workload labels must stay in sync.** The PDB selector must match the pod labels of the workload it
+protects. If a mutation renames pods or changes their labels, update the PDB selector in the same release.
+
**Register mutations in dependency order.** If mutation B relies on state set by mutation A, register A first.
+
+**Use data extraction to read `Status` fields.** Fields like `Status.ExpectedPods`, `Status.CurrentHealthy`, and
+`Status.DisruptionsAllowed` are populated by the Kubernetes PDB controller after reconciliation. Access them through
+`WithDataExtractor` rather than inspecting the baseline object.
diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md
index 741000f5..63c855e7 100644
--- a/docs/primitives/pod.md
+++ b/docs/primitives/pod.md
@@ -1,20 +1,21 @@
# Pod Primitive
-The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly.
-It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and
-metadata.
+The `pod` primitive wraps a Kubernetes `Pod` and provides health tracking, suspension, and a typed mutation API for
+managing pod spec and containers as part of the component lifecycle.
-Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage
-pod objects (e.g. debugging utilities, node-local agents).
+Most operators do not manage Pod objects directly; higher-level primitives (Deployment, StatefulSet, DaemonSet) own pod
+lifecycle. This primitive is provided for operators that explicitly manage individual pods, such as debugging utilities
+or node-local agents where a controller-per-pod model applies.
## Capabilities
-| Capability | Detail |
-| --------------------- | -------------------------------------------------------------------------------------------------- |
-| **Health tracking** | Monitors pod phase and container statuses; reports `Healthy`, `Creating`, `Updating`, or `Failing` |
-| **Graceful rollouts** | Detects degraded or down states via grace status handler |
-| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ---------------------------------------------- |
+| `Alive` | `Healthy`, `Creating`, `Updating`, `Failing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a Pod Primitive
@@ -23,15 +24,12 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod"
base := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
- Name: "debug-pod",
+ Name: "agent",
Namespace: owner.Namespace,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
- {
- Name: "debug",
- Image: "busybox:latest",
- },
+ {Name: "agent", Image: "agent:latest"},
},
},
}
@@ -43,49 +41,28 @@ resource, err := pod.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that
-receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Each mutation is a named `pod.Mutation` that receives a `*pod.Mutator` and records edits through typed editors.
```go
-func MyFeatureMutation(version string) pod.Mutation {
+func AgentConfigMutation(version string, debug bool) pod.Mutation {
return pod.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
+ Name: "agent-config",
+ Feature: feature.NewVersionGate(version, nil).When(debug),
Mutate: func(m *pod.Mutator) error {
- // record edits here
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"})
return nil
},
}
}
```
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func DebugMutation(version string, enabled bool) pod.Mutation {
- return pod.Mutation{
- Name: "debug-mode",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *pod.Mutator) error {
- m.EnsureContainerEnvVar(corev1.EnvVar{Name: "DEBUG", Value: "true"})
- return nil
- },
- }
-}
-```
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded. This ensures structural consistency across mutations.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | -------------------------- | ----------------------------------------------------------------------- |
@@ -96,37 +73,37 @@ order they are recorded. This ensures structural consistency across mutations.
| 5 | Init container presence | Adding or removing containers from `spec.initContainers` |
| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) |
-Container edits (steps 4 and 6) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 4 and 6) are evaluated against a snapshot taken _after_ presence operations in the same feature.
-**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall
-structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`,
-`args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the
-corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod,
-not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the
-Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing
-Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize);
-treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes.
+!!! warning "Pod spec is largely immutable after creation"
+
+ Most fields in `Pod.spec` are immutable once the pod exists, including the container list, env vars, args,
+ resources, ports, and probes. Presence operations (`EnsureContainer`, `RemoveContainer`) and most field mutations
+ are only effective when constructing a new pod or when the pod will be deleted and recreated. The very small set of
+ fields that can be updated in-place includes container images and, in some configurations, resource requests. Treat
+ pods as effectively immutable and plan on delete-and-recreate when structural changes are needed.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### PodSpecEditor
Manages pod-level configuration via `m.EditPodSpec`.
Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`,
`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`,
-`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. `RemoveTolerations` accepts a predicate
-function (`match func(corev1.Toleration) bool`) and removes all tolerations for which `match` returns `true`.
+`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`.
```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
- e.SetServiceAccountName("my-service-account")
+ e.SetServiceAccountName("agent-sa")
e.EnsureVolume(corev1.Volume{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
- LocalObjectReference: corev1.LocalObjectReference{Name: "app-config"},
+ LocalObjectReference: corev1.LocalObjectReference{Name: "agent-config"},
},
},
})
@@ -136,28 +113,27 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
+m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
- e.EnsureArg("--metrics-port=9090")
e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
return nil
})
```
-For fields not covered by the typed API (such as volume mounts), use `Raw()`:
+For fields the typed API does not cover, such as volume mounts, use `Raw()`:
```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
+m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error {
e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
Name: "config",
- MountPath: "/etc/config",
+ MountPath: "/etc/agent",
})
return nil
})
@@ -176,25 +152,8 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
-### Raw Escape Hatch
-
-All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is
-insufficient. The mutation remains scoped to the editor's target, so you cannot accidentally modify unrelated parts of
-the spec.
-
-```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
- e.Raw().SecurityContext = &corev1.SecurityContext{
- ReadOnlyRootFilesystem: ptr.To(true),
- }
- return nil
-})
-```
-
## Convenience Methods
-The `Mutator` also exposes convenience wrappers that target all containers at once:
-
| Method | Equivalent to |
| ----------------------------- | ------------------------------------------------------------- |
| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
@@ -206,21 +165,61 @@ The `Mutator` also exposes convenience wrappers that target all containers at on
Pods cannot be paused. The default behavior deletes the pod when the component is suspended.
-- `DefaultDeleteOnSuspendHandler`: returns `true`. The pod is deleted on suspend.
-- `DefaultSuspendMutationHandler`: no-op (deletion is handled by the framework).
-- `DefaultSuspensionStatusHandler`: always returns `{Suspended, "Pod deleted on suspend"}`.
+- `DefaultDeleteOnSuspendHandler` returns `true`. The pod is deleted on suspend.
+- `DefaultSuspendMutationHandler` is a no-op; deletion is handled by the framework.
+- `DefaultSuspensionStatusHandler` always reports `Suspended` with reason `"Pod deleted on suspend"`.
+
+## Full Example
+
+```go
+func AgentMutation(version string, cfgName string) pod.Mutation {
+ return pod.Mutation{
+ Name: "agent-setup",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *pod.Mutator) error {
+ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
+ e.SetServiceAccountName("agent-sa")
+ e.EnsureVolume(corev1.Volume{
+ Name: "config",
+ VolumeSource: corev1.VolumeSource{
+ ConfigMap: &corev1.ConfigMapVolumeSource{
+ LocalObjectReference: corev1.LocalObjectReference{Name: cfgName},
+ },
+ },
+ })
+ return nil
+ })
+
+ m.EditContainers(selectors.ContainerNamed("agent"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVar(corev1.EnvVar{Name: "CONFIG_PATH", Value: "/etc/agent/config.yaml"})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("200m"))
+ e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("128Mi"))
+ e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
+ Name: "config",
+ MountPath: "/etc/agent",
+ ReadOnly: true,
+ })
+ return nil
+ })
+
+ return nil
+ },
+ }
+}
+```
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**Pods are effectively immutable after creation.** Plan the full desired state before the pod is created. Changes to
+most spec fields require deleting and recreating the pod. Use the Deployment, StatefulSet, or DaemonSet primitives for
+workloads that need rolling updates or scaling without manual recreation.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
-The internal ordering within each mutation handles intra-mutation dependencies automatically.
-
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
diff --git a/docs/primitives/pv.md b/docs/primitives/pv.md
index d92e6be2..cbc054ce 100644
--- a/docs/primitives/pv.md
+++ b/docs/primitives/pv.md
@@ -1,18 +1,21 @@
# PersistentVolume Primitive
-The `pv` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolume`
-resources. It integrates with the component lifecycle as an Operational, Graceful resource and provides a structured
-mutation API for managing PV spec fields and object metadata.
+The `pv` primitive wraps a Kubernetes `PersistentVolume` and integrates with the component lifecycle as an Integration
+and Graceful resource, providing a structured mutation API for managing PV spec fields and object metadata.
## Capabilities
-| Capability | Detail |
-| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Integration lifecycle** | Reports `concepts.OperationalStatusOperational`, `concepts.OperationalStatusPending`, or `concepts.OperationalStatusFailing` based on the PV's phase |
-| **Grace status** | Maps PV phase to grace status: Available/Bound are `Healthy`, Pending is `Degraded`, Released/Failed are `Down` |
-| **Cluster-scoped** | No namespace in the identity or builder. PersistentVolumes are cluster-scoped resources |
-| **Mutation pipeline** | Typed editors for PV spec fields and object metadata, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle |
+| Capability | Detail |
+| --------------------- | ------------------------------------------------------------------------------------------------------ |
+| **Operational** | Maps PV phase to `Operational`, `OperationPending`, or `OperationFailing` |
+| **Graceful** | Available/Bound are `Healthy`; Pending is `Degraded`; Released/Failed are `Down` |
+| **Cluster-scoped** | No namespace in the identity or builder. PersistentVolumes are cluster-scoped resources |
+| **DataExtractable** | Reads generated or updated values back from the reconciled PersistentVolume after each sync cycle |
+| **Mutation pipeline** | Typed editors for PV spec fields and object metadata, with a `Raw()` escape hatch for free-form access |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports. For cluster-scoped handling and owner-reference behavior, see
+[Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives).
## Building a PersistentVolume Primitive
@@ -42,34 +45,17 @@ resource, err := pv.NewBuilder(base).
Build()
```
-PersistentVolumes are cluster-scoped. The builder validates that Name is set and that Namespace is empty. Setting a
-namespace on the PV object will cause `Build()` to return an error.
+PersistentVolumes are cluster-scoped. The builder validates that `Name` is set and that `Namespace` is empty. Setting a
+namespace on the PV object causes `Build()` to return an error.
## Mutations
-Mutations are the primary mechanism for modifying a `PersistentVolume` beyond its baseline. Each mutation is a named
-function that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) pv.Mutation {
- return pv.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *pv.Mutator) error {
- m.SetStorageClassName("fast-ssd")
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-### Boolean-gated mutations
+A kind-specific example using the `SetStorageClassName` convenience method:
```go
func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation {
@@ -84,43 +70,22 @@ func RetainPolicyMutation(version string, retainEnabled bool) pv.Mutation {
}
```
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyStorageClassMutation(version string) pv.Mutation {
- return pv.Mutation{
- Name: "legacy-storage-class",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *pv.Mutator) error {
- m.SetStorageClassName("legacy-hdd")
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | -------------- | ------------------------------------------------------------------ |
| 1 | Metadata edits | Labels and annotations on the `PersistentVolume` |
| 2 | Spec edits | PV spec fields: storage class, reclaim policy, mount options, etc. |
-Within each category, edits are applied in their registration order. Later features observe the PersistentVolume as
-modified by all previous features.
+Within each category, edits run in registration order. Later features observe the PersistentVolume as modified by all
+earlier ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### PVSpecEditor
The primary API for modifying PersistentVolume spec fields. Use `m.EditPVSpec` for full control:
@@ -149,8 +114,7 @@ m.EditPVSpec(func(e *editors.PVSpecEditor) error {
#### Raw escape hatch
-`Raw()` returns the underlying `*corev1.PersistentVolumeSpec` for free-form editing when none of the structured methods
-are sufficient:
+`Raw()` returns the underlying `*corev1.PersistentVolumeSpec` for free-form editing:
```go
m.EditPVSpec(func(e *editors.PVSpecEditor) error {
@@ -188,16 +152,15 @@ single edit block.
## Operational Status
-The PV primitive uses the Integration lifecycle. The default operational status handler maps PV phases to framework
-status:
+The PV primitive implements `concepts.Operational`. The default handler maps PV phase to operational status:
-| PV Phase | Operational Status | Meaning |
-| --------- | ---------------------------- | -------------------------------------- |
-| Available | OperationalStatusOperational | PV is ready for binding |
-| Bound | OperationalStatusOperational | PV is bound to a PersistentVolumeClaim |
-| Pending | OperationalStatusPending | PV is waiting to become available |
-| Released | OperationalStatusFailing | PV was released, not yet reclaimed |
-| Failed | OperationalStatusFailing | PV reclamation has failed |
+| PV Phase | Status | Meaning |
+| --------- | ------------------ | -------------------------------------- |
+| Available | `Operational` | PV is ready for binding |
+| Bound | `Operational` | PV is bound to a PersistentVolumeClaim |
+| Pending | `OperationPending` | PV is waiting to become available |
+| Released | `OperationFailing` | PV was released, not yet reclaimed |
+| Failed | `OperationFailing` | PV reclamation has failed |
Override with `WithCustomOperationalStatus` when your PV requires different readiness logic.
@@ -227,7 +190,7 @@ pv.NewBuilder(base).
})
```
-## Full Example: Storage-Tier PersistentVolume
+## Full Example
```go
func StorageClassMutation(version string) pv.Mutation {
@@ -267,20 +230,15 @@ resource, err := pv.NewBuilder(base).
**PersistentVolumes are cluster-scoped.** Do not set a namespace on the PV object. The builder rejects namespaced PVs
with a clear error.
-**Use the Integration lifecycle for status.** PVs report `OperationalStatusOperational`, `OperationalStatusPending`, or
-`OperationalStatusFailing` based on their phase. Override with `WithCustomOperationalStatus` only when phase-based
-readiness is insufficient.
-
-**Controller references and garbage collection.** The component reconciliation pipeline attempts to set a controller
+**Understand the garbage collection constraint.** The component reconciliation pipeline attempts to set a controller
reference on created/updated resources. Because `PersistentVolume` is cluster-scoped, its controller owner must also be
-cluster-scoped. When the owner is namespace-scoped and the PV is cluster-scoped, the framework detects this mismatch and
-**skips setting `ownerReferences`** (logging an informational message) instead of letting the API server reject the
-request. As a result, such PVs will **not** be garbage collected automatically when the owning component is deleted. If
-you need garbage collection for PVs, either:
-
-- Model the PV as owned by a dedicated **cluster-scoped** controller/component so a valid controller reference can be
- set, or
-- Accept that PVs managed from a **namespace-scoped** component will not have `ownerReferences` and handle their
- lifecycle explicitly (for example, by deleting them in custom logic when appropriate).
+cluster-scoped. When the owner is namespace-scoped, the framework detects the mismatch and skips setting
+`ownerReferences` instead of letting the API server reject the request. Such PVs will not be garbage collected
+automatically when the owning component is deleted. Either model the PV under a dedicated cluster-scoped component to
+allow a valid controller reference, or accept that PVs managed from a namespace-scoped component require explicit
+lifecycle handling.
+
+**Use string status values in conditions.** The operational status values that appear in conditions are the runtime
+strings `"Operational"`, `"OperationPending"`, and `"OperationFailing"`, not the Go constant identifiers.
**Register mutations in dependency order.** If mutation B relies on a field set by mutation A, register A first.
diff --git a/docs/primitives/pvc.md b/docs/primitives/pvc.md
index fcc0f489..777ce034 100644
--- a/docs/primitives/pvc.md
+++ b/docs/primitives/pvc.md
@@ -1,18 +1,21 @@
# PersistentVolumeClaim Primitive
-The `pvc` primitive is the framework's built-in integration abstraction for managing Kubernetes `PersistentVolumeClaim`
-resources. It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a
-structured mutation API for managing storage requests and object metadata.
+The `pvc` primitive wraps a Kubernetes `PersistentVolumeClaim` and integrates with the component lifecycle as an
+Integration, Graceful, and Suspendable resource, providing a structured mutation API for managing storage requests and
+object metadata.
## Capabilities
-| Capability | Detail |
-| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
-| **Operational tracking** | Monitors PVC phase. Reports `OperationalStatusOperational` (Bound), `OperationalStatusPending`, or `OperationalStatusFailing` (Lost) |
-| **Grace status** | Bound is `Healthy`, Lost is `Down`, any other phase is `Degraded` |
-| **Suspension** | PVCs are immediately suspended (no runtime state to wind down); data is preserved by default |
-| **Mutation pipeline** | Typed editors for PVC spec and object metadata, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads bound volume name, capacity, or other status fields after each sync cycle |
+| Capability | Detail |
+| --------------------- | ----------------------------------------------------------------------------------------- |
+| **Operational** | Maps PVC phase to `Operational` (Bound), `OperationPending`, or `OperationFailing` (Lost) |
+| **Graceful** | Bound is `Healthy`; Lost is `Down`; any other phase is `Degraded` |
+| **Suspendable** | Immediately suspended (no runtime state to wind down); data is preserved by default |
+| **DataExtractable** | Reads bound volume name, capacity, or other status fields after each sync cycle |
+| **Mutation pipeline** | Typed editors for PVC spec and object metadata, with a `Raw()` escape hatch |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building a PVC Primitive
@@ -41,17 +44,18 @@ resource, err := pvc.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `PersistentVolumeClaim` beyond its baseline. Each mutation is a
-named function that receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+A kind-specific example using the `SetStorageRequest` convenience method:
```go
func MyStorageMutation(version string) pvc.Mutation {
return pvc.Mutation{
Name: "storage-expansion",
- Feature: feature.NewVersionGate(version, nil), // always enabled
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *pvc.Mutator) error {
m.SetStorageRequest(resource.MustParse("20Gi"))
return nil
@@ -60,62 +64,21 @@ func MyStorageMutation(version string) pvc.Mutation {
}
```
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-```go
-func LargeStorageMutation(version string, needsLargeStorage bool) pvc.Mutation {
- return pvc.Mutation{
- Name: "large-storage",
- Feature: feature.NewVersionGate(version, nil).When(needsLargeStorage),
- Mutate: func(m *pvc.Mutator) error {
- m.SetStorageRequest(resource.MustParse("100Gi"))
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-```go
-var v2Constraint = mustSemverConstraint(">= 2.0.0")
-
-func V2StorageMutation(version string) pvc.Mutation {
- return pvc.Mutation{
- Name: "v2-storage",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{v2Constraint},
- ),
- Mutate: func(m *pvc.Mutator) error {
- m.SetStorageRequest(resource.MustParse("50Gi"))
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | -------------- | ----------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `PersistentVolumeClaim` |
| 2 | Spec edits | PVC spec: storage requests, access modes, etc. |
-Within each category, edits are applied in their registration order. The PVC primitive groups mutations by feature
-boundary: for each applicable feature (after evaluating version constraints and any `When()` conditions), all of its
-planned edits are applied in order, and later features and mutations observe the fully-applied state from earlier ones.
+Within each category, edits run in registration order. Later features observe the PVC as modified by all earlier ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### PVCSpecEditor
The primary API for modifying PVC spec fields. Use `m.EditPVCSpec` for full control:
@@ -140,8 +103,7 @@ Available methods:
#### Raw Escape Hatch
-`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing when none of the structured
-methods are sufficient:
+`Raw()` returns the underlying `*corev1.PersistentVolumeClaimSpec` for free-form editing:
```go
m.EditPVCSpec(func(e *editors.PVCSpecEditor) error {
@@ -178,31 +140,17 @@ The `Mutator` exposes a convenience wrapper for the most common PVC operation:
Use this for simple, single-operation mutations. Use `EditPVCSpec` when you need multiple operations or raw access in a
single edit block.
-## Status Handlers
-
-### Operational Status
+## Operational Status
-The default handler (`DefaultOperationalStatusHandler`) maps PVC phase to operational status:
+The PVC primitive implements `concepts.Operational`. The default handler maps PVC phase to operational status:
-| PVC Phase | Status | Reason |
-| --------- | ------------------------------ | ------------------------------- |
-| `Bound` | `OperationalStatusOperational` | PVC is bound to volume \ |
-| `Pending` | `OperationalStatusPending` | Waiting for PVC to be bound |
-| `Lost` | `OperationalStatusFailing` | PVC has lost its bound volume |
+| PVC Phase | Status | Reason |
+| --------- | ------------------ | ------------------------------- |
+| `Bound` | `Operational` | PVC is bound to volume `` |
+| `Pending` | `OperationPending` | Waiting for PVC to be bound |
+| `Lost` | `OperationFailing` | PVC has lost its bound volume |
-Override with `WithCustomOperationalStatus` for additional checks (e.g. verifying specific annotations or volume
-attributes).
-
-### Suspension
-
-PVCs have no runtime state to wind down, so:
-
-- `DefaultSuspendMutationHandler` is a no-op.
-- `DefaultSuspensionStatusHandler` always reports `Suspended`.
-- `DefaultDeleteOnSuspendHandler` returns `false` to preserve data.
-
-Override these handlers if you need custom suspension behavior, such as adding annotations when suspended or deleting
-PVCs that use ephemeral storage.
+Override with `WithCustomOperationalStatus` for additional checks.
## Grace Status
@@ -228,11 +176,81 @@ pvc.NewBuilder(base).
})
```
+## Suspension
+
+PVCs have no runtime state to wind down:
+
+- `DefaultSuspendMutationHandler` is a no-op.
+- `DefaultSuspensionStatusHandler` always reports `Suspended`.
+- `DefaultDeleteOnSuspendHandler` returns `false` to preserve data.
+
+Override these handlers if you need custom suspension behavior, such as adding annotations when suspended or deleting
+PVCs that use ephemeral storage:
+
+```go
+resource, err := pvc.NewBuilder(base).
+ WithCustomSuspendDeletionDecision(func(_ *corev1.PersistentVolumeClaim) bool {
+ return true // delete on suspend
+ }).
+ Build()
+```
+
+## Full Example
+
+```go
+func StorageRequestMutation(version string) pvc.Mutation {
+ return pvc.Mutation{
+ Name: "storage-request",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *pvc.Mutator) error {
+ m.SetStorageRequest(resource.MustParse("10Gi"))
+ return nil
+ },
+ }
+}
+
+var v2Constraint = mustSemverConstraint(">= 2.0.0")
+
+func ExpandedStorageMutation(version string) pvc.Mutation {
+ return pvc.Mutation{
+ Name: "expanded-storage",
+ Feature: feature.NewVersionGate(
+ version,
+ []feature.VersionConstraint{v2Constraint},
+ ),
+ Mutate: func(m *pvc.Mutator) error {
+ m.SetStorageRequest(resource.MustParse("50Gi"))
+ return nil
+ },
+ }
+}
+
+var boundVolumeName string
+
+resource, err := pvc.NewBuilder(base).
+ WithMutation(StorageRequestMutation(owner.Spec.Version)).
+ WithMutation(ExpandedStorageMutation(owner.Spec.Version)).
+ WithDataExtractor(func(p corev1.PersistentVolumeClaim) error {
+ boundVolumeName = p.Spec.VolumeName
+ return nil
+ }).
+ Build()
+```
+
+On versions 2.0.0 and above, `ExpandedStorageMutation` fires and sets the storage request to 50Gi. On earlier versions,
+only the base 10Gi request is applied. After each reconcile cycle, the data extractor captures the bound volume name.
+
## Guidance
-**Register mutations for storage expansion carefully.** Kubernetes only allows expanding PVC storage (not shrinking).
-Ensure your mutations respect this constraint. The `SetStorageRequest` method does not enforce this; the API server will
-reject invalid requests.
+**Register storage expansion mutations carefully.** Kubernetes allows expanding PVC storage but not shrinking it. Ensure
+your mutations respect this constraint. The `SetStorageRequest` method does not enforce this; the API server rejects
+invalid requests.
**Prefer `WithCustomSuspendDeletionDecision` over deleting PVCs manually.** If you need PVCs to be cleaned up during
suspension, register a deletion decision handler rather than deleting them in a mutation.
+
+**Use `WithDataExtractor` to read bound volume information.** The bound volume name and actual allocated capacity are
+server-assigned. Read them with a data extractor after reconciliation rather than caching them in mutation logic.
+
+**Use string status values in conditions.** The operational status values that appear in conditions are the runtime
+strings `"Operational"`, `"OperationPending"`, and `"OperationFailing"`, not the Go constant identifiers.
diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md
index bb9c7a9e..2d7cb9b2 100644
--- a/docs/primitives/replicaset.md
+++ b/docs/primitives/replicaset.md
@@ -1,19 +1,21 @@
# ReplicaSet Primitive
-The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It
-integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and
-metadata.
+The `replicaset` primitive wraps a Kubernetes `ReplicaSet` and provides health tracking, suspension, and a typed
+mutation API for managing replicas, pod spec, and containers as part of the component lifecycle.
-ReplicaSets are rarely managed directly; operators typically use Deployments. This primitive is provided for operators
-that own ReplicaSets explicitly (e.g. custom rollout controllers).
+ReplicaSets are rarely managed directly by operators. Deployments own and manage ReplicaSets automatically. This
+primitive is intended for operators that explicitly own ReplicaSet objects, such as custom rollout controllers that
+manage sets of pods without Deployment's rollout semantics.
## Capabilities
-| Capability | Detail |
-| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` |
-| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ------------------------------------------------------- |
+| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a ReplicaSet Primitive
@@ -29,7 +31,16 @@ base := &appsv1.ReplicaSet{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "worker"},
},
- // baseline spec
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{"app": "worker"},
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {Name: "worker"},
+ },
+ },
+ },
},
}
@@ -40,51 +51,29 @@ resource, err := replicaset.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Each mutation is a named `replicaset.Mutation` that receives a `*replicaset.Mutator` and records edits through typed
+editors.
```go
-func MyFeatureMutation(version string) replicaset.Mutation {
+func WorkerConfigMutation(version string) replicaset.Mutation {
return replicaset.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
+ Name: "worker-config",
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *replicaset.Mutator) error {
- // record edits here
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "WORKER_THREADS", Value: "4"})
return nil
},
}
}
```
-Mutations are applied in the order they are registered with the builder.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func TracingMutation(version string, enabled bool) replicaset.Mutation {
- return replicaset.Mutation{
- Name: "tracing",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *replicaset.Mutator) error {
- m.EnsureContainer(corev1.Container{
- Name: "jaeger-agent",
- Image: "jaegertracing/jaeger-agent:1.28",
- })
- return nil
- },
- }
-}
-```
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded:
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | --------------------------- | ----------------------------------------------------------------------- |
@@ -97,10 +86,13 @@ order they are recorded:
| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` |
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
-Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
+Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### ReplicaSetSpecEditor
Controls replicaset-level settings via `m.EditReplicaSetSpec`.
@@ -115,8 +107,10 @@ m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error {
})
```
-Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object
-passed to `NewBuilder`.
+!!! note "`spec.selector` is immutable"
+
+ `spec.selector` cannot be changed after the ReplicaSet is created. Set it in the desired object passed to
+ `NewBuilder`; it is not exposed by `ReplicaSetSpecEditor`.
### PodSpecEditor
@@ -128,30 +122,31 @@ Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `Ens
```go
m.EditPodSpec(func(e *editors.PodSpecEditor) error {
- e.SetServiceAccountName("my-service-account")
+ e.SetServiceAccountName("worker-sa")
return nil
})
```
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
```go
-m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error {
+m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error {
e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
return nil
})
```
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `ReplicaSet` itself or `m.EditPodTemplateMetadata`
+for the pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
@@ -165,14 +160,69 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno
| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
+## Workload-Kind-Agnostic Mutations
+
+The `replicaset.Mutator` does not implement `primitives.WorkloadMutator` and therefore does not have a `LiftMutation`
+adapter. Workload-kind-agnostic mutations target the Deployment, StatefulSet, and DaemonSet mutators. If you need to
+share container or env-var mutations across those kinds and a ReplicaSet, write the shared logic as a plain function
+that accepts `*replicaset.Mutator` and call it directly from a `replicaset.Mutation`.
+
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the cross-kind pattern.
+
+## Suspension
+
+When the component is suspended, the ReplicaSet is scaled to zero replicas. The resource is not deleted.
+
+- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`.
+- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`.
+- `DefaultDeleteOnSuspendHandler` returns `false`.
+
+Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision`
+on the builder.
+
+## Full Example
+
+```go
+func WorkerMutation(version string, replicas int32) replicaset.Mutation {
+ return replicaset.Mutation{
+ Name: "worker-sizing",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *replicaset.Mutator) error {
+ m.EnsureReplicas(replicas)
+
+ m.EditContainers(selectors.ContainerNamed("worker"), func(e *editors.ContainerEditor) error {
+ e.EnsureEnvVar(corev1.EnvVar{Name: "WORKER_THREADS", Value: "4"})
+ e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m"))
+ e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("256Mi"))
+ return nil
+ })
+
+ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
+ e.SetServiceAccountName("worker-sa")
+ return nil
+ })
+
+ return nil
+ },
+ }
+}
+```
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run.
+**Prefer Deployments over direct ReplicaSet management.** Deployments add rolling-update semantics and revision history.
+Use this primitive only when you are building a custom rollout controller or you have a specific reason to own
+ReplicaSet objects directly.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
+conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the
+same mutation resolve correctly and reconciliation remains idempotent.
**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
cause unexpected behavior if sidecar containers are present.
diff --git a/docs/primitives/role.md b/docs/primitives/role.md
index eaf85fc1..29664c65 100644
--- a/docs/primitives/role.md
+++ b/docs/primitives/role.md
@@ -1,16 +1,18 @@
# Role Primitive
-The `role` primitive is the framework's built-in static abstraction for managing Kubernetes `Role` resources. It
-integrates with the component lifecycle and provides a structured mutation API for managing RBAC policy rules and object
-metadata.
+The `role` primitive wraps a Kubernetes `Role` and manages RBAC policy rules and object metadata within the component
+lifecycle.
## Capabilities
-| Capability | Detail |
-| --------------------- | --------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for `.rules` and object metadata, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads generated or updated values back from the reconciled Role after each sync cycle |
+| Capability | Interfaces / detail |
+| -------------------- | -------------------------------------------------------------------------------------- |
+| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension |
+| **Mutation** | `PolicyRulesEditor` for `.rules`; `ObjectMetaEditor` for labels and annotations |
+| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) |
+| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping.
## Building a Role Primitive
@@ -32,42 +34,18 @@ base := &rbacv1.Role{
}
resource, err := role.NewBuilder(base).
- WithMutation(MyFeatureMutation(owner.Spec.Version)).
+ WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)).
Build()
```
-## Mutations
-
-Mutations are the primary mechanism for modifying a `Role` beyond its baseline. Each mutation is a named function that
-receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+`Build()` returns an error if `Name` or `Namespace` is empty.
-```go
-func MyFeatureMutation(version string) role.Mutation {
- return role.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *role.Mutator) error {
- m.EditRules(func(e *editors.PolicyRulesEditor) error {
- e.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{""},
- Resources: []string{"configmaps"},
- Verbs: []string{"get"},
- })
- return nil
- })
- return nil
- },
- }
-}
-```
+Identity format: `rbac.authorization.k8s.io/v1/Role//`.
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
+## Mutations
-### Boolean-gated mutations
+Each mutation is a named `role.Mutation` that receives a `*Mutator` and records edit intent through typed editors. See
+[The Mutation System](../primitives.md#the-mutation-system) for the full model.
```go
func SecretAccessMutation(version string, enabled bool) role.Mutation {
@@ -89,71 +67,39 @@ func SecretAccessMutation(version string, enabled bool) role.Mutation {
}
```
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyRoleMutation(version string) role.Mutation {
- return role.Mutation{
- Name: "legacy-role",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *role.Mutator) error {
- m.EditRules(func(e *editors.PolicyRulesEditor) error {
- e.AddRule(rbacv1.PolicyRule{
- APIGroups: []string{"extensions"},
- Resources: []string{"ingresses"},
- Verbs: []string{"get", "list"},
- })
- return nil
- })
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+For boolean conditions, chain `.When()` on the gate. See
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in this fixed category order regardless of the call order:
-| Step | Category | What it affects |
-| ---- | -------------- | ---------------------------------- |
-| 1 | Metadata edits | Labels and annotations on the Role |
-| 2 | Rules edits | `.rules`: SetRules, AddRule, Raw |
+| Step | Category | What it affects |
+| ---- | -------------- | -------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the Role |
+| 2 | Rules edits | `.rules`: `SetRules`, `AddRule`, `Raw` |
-Within each category, edits are applied in their registration order. Later features observe the Role as modified by all
-previous features.
+Within each category, edits apply in registration order. Later features observe the object as modified by all earlier
+ones.
## Relevant Editors
### PolicyRulesEditor
-The primary API for modifying `.rules`. Use `m.EditRules` for full control:
-
-```go
-m.EditRules(func(e *editors.PolicyRulesEditor) error {
- e.SetRules([]rbacv1.PolicyRule{
- {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}},
- })
- return nil
-})
-```
+The primary API for modifying `.rules`. Use `m.EditRules` for full control. See
+[Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
#### SetRules
-`SetRules` replaces the entire rules slice atomically. Use this when the mutation should define the complete set of
-rules, discarding any previously accumulated entries.
+`SetRules` replaces the entire rules slice atomically. Use this when a mutation should define the complete set of rules,
+discarding any previously accumulated entries.
```go
m.EditRules(func(e *editors.PolicyRulesEditor) error {
- e.SetRules(desiredRules)
+ e.SetRules([]rbacv1.PolicyRule{
+ {APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list", "watch"}},
+ })
return nil
})
```
@@ -182,7 +128,6 @@ methods are sufficient:
```go
m.EditRules(func(e *editors.PolicyRulesEditor) error {
raw := e.Raw()
- // Filter out rules that grant write access
filtered := (*raw)[:0]
for _, r := range *raw {
if !containsVerb(r.Verbs, "create") {
@@ -196,9 +141,8 @@ m.EditRules(func(e *editors.PolicyRulesEditor) error {
### ObjectMetaEditor
-Modifies labels and annotations via `m.EditObjectMetadata`.
-
-Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`,
+`EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
@@ -208,7 +152,21 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
-## Full Example: Feature-Composed Permissions
+## Data Extraction
+
+`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled Role. Use it to
+surface the applied rules or metadata to other resources:
+
+```go
+resource, err := role.NewBuilder(base).
+ WithDataExtractor(func(r rbacv1.Role) error {
+ sharedState.RoleName = r.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
```go
func BaseRuleMutation(version string) role.Mutation {
@@ -247,24 +205,24 @@ func SecretAccessMutation(version string, enabled bool) role.Mutation {
resource, err := role.NewBuilder(base).
WithMutation(BaseRuleMutation(owner.Spec.Version)).
- WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableTracing)).
+ WithMutation(SecretAccessMutation(owner.Spec.Version, owner.Spec.EnableSecretAccess)).
Build()
```
-When `EnableTracing` is true, the final Role will contain both the base pod rules and the secrets rule. When false, only
-the base rules are applied. Neither mutation needs to know about the other.
+When `EnableSecretAccess` is true, the final Role contains both the base pod rules and the secrets rule. When false,
+only the base rules are applied. Neither mutation needs to know about the other.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use
+`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean
conditions.
-**Use `AddRule` for composable permissions.** When multiple features need to contribute rules to the same Role,
-`AddRule` lets each feature add its permissions independently. Using `SetRules` in multiple features means the last
-registration wins. Only use that when full replacement is the intended semantics.
+**Use `AddRule` for composable permissions.** When multiple features contribute rules to the same Role, `AddRule` lets
+each feature add its permissions independently. `SetRules` in multiple features means the last registration wins; only
+use that when full replacement is the intended semantics.
-**Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first.
+**PolicyRule has no unique key.** There is no upsert or remove-by-key operation on rules. Use `SetRules` to replace
+atomically, `AddRule` to accumulate, or `Raw()` for arbitrary manipulation including filtering.
-**PolicyRule has no unique key.** There is no upsert or remove-by-key operation. Use `SetRules` to replace atomically,
-`AddRule` to accumulate, or `Raw()` for arbitrary manipulation including filtering.
+**Register mutations in dependency order.** If mutation B relies on rules set by mutation A, register A first.
diff --git a/docs/primitives/rolebinding.md b/docs/primitives/rolebinding.md
index 4ccac00c..0017bfa3 100644
--- a/docs/primitives/rolebinding.md
+++ b/docs/primitives/rolebinding.md
@@ -1,17 +1,19 @@
# RoleBinding Primitive
-The `rolebinding` primitive is the framework's built-in static abstraction for managing Kubernetes `RoleBinding`
-resources. It integrates with the component lifecycle and provides a structured mutation API for managing subjects and
-object metadata.
+The `rolebinding` primitive wraps a Kubernetes `RoleBinding` and manages the subjects list and object metadata within
+the component lifecycle.
## Capabilities
-| Capability | Detail |
-| --------------------- | -------------------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for subjects and object metadata, with a raw escape hatch for free-form access |
-| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation (requires delete/recreate) |
-| **Data extraction** | Reads generated or updated values back from the reconciled RoleBinding after each sync cycle |
+| Capability | Interfaces / detail |
+| --------------------- | -------------------------------------------------------------------------------------- |
+| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension |
+| **Mutation** | `BindingSubjectsEditor` for `.subjects`; `ObjectMetaEditor` for labels and annotations |
+| **Immutable roleRef** | `roleRef` must be set on the base object and cannot be changed after creation |
+| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) |
+| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping.
## Building a RoleBinding Primitive
@@ -34,42 +36,22 @@ base := &rbacv1.RoleBinding{
}
resource, err := rolebinding.NewBuilder(base).
- WithMutation(MySubjectMutation(owner.Spec.Version)).
+ WithMutation(MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)).
Build()
```
-`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not
-modifiable via the mutation API.
+`Build()` returns an error if `Name` or `Namespace` is empty, or if `roleRef.APIGroup`, `roleRef.Kind`, or
+`roleRef.Name` is empty.
-## Mutations
-
-Mutations are the primary mechanism for modifying a `RoleBinding` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
+`roleRef` must be set on the base object passed to `NewBuilder`. It is immutable after creation in Kubernetes and is not
+modifiable via the mutation API. To change a `roleRef`, delete and recreate the RoleBinding.
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Identity format: `rbac.authorization.k8s.io/v1/RoleBinding//`.
-```go
-func AddServiceAccountMutation(version, saName, saNamespace string) rolebinding.Mutation {
- return rolebinding.Mutation{
- Name: "add-service-account",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *rolebinding.Mutator) error {
- m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureSubject(rbacv1.Subject{
- Kind: "ServiceAccount",
- Name: saName,
- Namespace: saNamespace,
- })
- return nil
- })
- return nil
- },
- }
-}
-```
+## Mutations
-### Boolean-gated mutations
+Each mutation is a named `rolebinding.Mutation` that receives a `*Mutator` and records edit intent through typed
+editors. See [The Mutation System](../primitives.md#the-mutation-system) for the full model.
```go
func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation {
@@ -78,37 +60,7 @@ func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutatio
Feature: feature.NewVersionGate(version, nil).When(enabled),
Mutate: func(m *rolebinding.Mutator) error {
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureSubject(rbacv1.Subject{
- Kind: "ServiceAccount",
- Name: "monitoring-agent",
- Namespace: "monitoring",
- })
- return nil
- })
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacySubjectMutation(version string) rolebinding.Mutation {
- return rolebinding.Mutation{
- Name: "legacy-subject",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *rolebinding.Mutator) error {
- m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
- e.EnsureSubject(rbacv1.Subject{
- Kind: "User",
- Name: "legacy-admin",
- })
+ e.EnsureServiceAccount("monitoring-agent", "monitoring")
return nil
})
return nil
@@ -117,26 +69,28 @@ func LegacySubjectMutation(version string) rolebinding.Mutation {
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+For boolean conditions, chain `.When()` on the gate. See
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in this fixed category order regardless of the call order:
-| Step | Category | What it affects |
-| ---- | -------------- | --------------------------------------------- |
-| 1 | Metadata edits | Labels and annotations on the RoleBinding |
-| 2 | Subject edits | `.subjects` entries via BindingSubjectsEditor |
+| Step | Category | What it affects |
+| ---- | -------------- | ----------------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the RoleBinding |
+| 2 | Subject edits | `.subjects` entries via `BindingSubjectsEditor` |
-Within each category, edits are applied in their registration order. Later features observe the RoleBinding as modified
-by all previous features.
+Within each category, edits apply in registration order. Later features observe the object as modified by all earlier
+ones.
## Relevant Editors
### BindingSubjectsEditor
-The primary API for modifying the subjects list. Use `m.EditSubjects` for full control:
+The primary API for modifying the subjects list. Use `m.EditSubjects` for full control. See
+[Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
@@ -153,16 +107,29 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
#### EnsureSubject
`EnsureSubject` upserts a subject by the combination of `Kind`, `Name`, and `Namespace`. If a matching subject already
-exists, it is replaced; otherwise the new subject is appended.
+exists it is replaced; otherwise the new subject is appended.
+
+#### EnsureServiceAccount
+
+Convenience wrapper that ensures a `ServiceAccount` subject with the given name and namespace exists.
+
+```go
+e.EnsureServiceAccount("app-sa", "production")
+```
-#### RemoveSubject
+#### RemoveSubject and RemoveServiceAccount
-`RemoveSubject` removes a subject identified by kind, name, and namespace. It is a no-op if no matching subject exists.
+`RemoveSubject` removes a subject identified by kind, name, and namespace. `RemoveServiceAccount` is a convenience
+wrapper for removing `ServiceAccount` subjects:
-#### Raw
+```go
+e.RemoveSubject("User", "old-user", "")
+e.RemoveServiceAccount("deprecated-sa", "default")
+```
-`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` slice for free-form access when the structured methods
-are insufficient:
+#### Raw Escape Hatch
+
+`Raw()` returns a pointer to the underlying `[]rbacv1.Subject` for free-form editing:
```go
m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
@@ -177,9 +144,8 @@ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
### ObjectMetaEditor
-Modifies labels and annotations via `m.EditObjectMetadata`.
-
-Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`,
+`EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
@@ -189,16 +155,69 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
+## Data Extraction
+
+`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled RoleBinding. Use
+it to surface binding metadata to other resources:
+
+```go
+resource, err := rolebinding.NewBuilder(base).
+ WithDataExtractor(func(rb rbacv1.RoleBinding) error {
+ sharedState.RoleBindingName = rb.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
+
+```go
+func BaseSubjectMutation(version string, saName, saNamespace string) rolebinding.Mutation {
+ return rolebinding.Mutation{
+ Name: "base-subject",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *rolebinding.Mutator) error {
+ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
+ e.EnsureServiceAccount(saName, saNamespace)
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+func MonitoringSubjectMutation(version string, enabled bool) rolebinding.Mutation {
+ return rolebinding.Mutation{
+ Name: "monitoring-subject",
+ Feature: feature.NewVersionGate(version, nil).When(enabled),
+ Mutate: func(m *rolebinding.Mutator) error {
+ m.EditSubjects(func(e *editors.BindingSubjectsEditor) error {
+ e.EnsureServiceAccount("monitoring-agent", "monitoring")
+ return nil
+ })
+ return nil
+ },
+ }
+}
+
+resource, err := rolebinding.NewBuilder(base).
+ WithMutation(BaseSubjectMutation(owner.Spec.Version, "app-sa", owner.Namespace)).
+ WithMutation(MonitoringSubjectMutation(owner.Spec.Version, owner.Spec.EnableMonitoring)).
+ Build()
+```
+
+When `EnableMonitoring` is true, the binding's subjects list contains both the base service account and the monitoring
+agent. When false, only the base subject is present. Neither mutation needs to know about the other.
+
## Guidance
**Set `roleRef` on the base object, not via mutations.** Kubernetes makes `roleRef` immutable after creation. To change
a `roleRef`, delete and recreate the RoleBinding.
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
-
**Use `EnsureSubject` for idempotent subject management.** `EnsureSubject` upserts by Kind+Name+Namespace, making it
safe to call on every reconciliation without creating duplicates.
+**Use `EnsureServiceAccount` as a shortcut for the most common subject type.** It sets `Kind`, `Name`, and `Namespace`
+in one call and is equivalent to `EnsureSubject` with a `ServiceAccount` kind.
+
**Register mutations in dependency order.** If mutation B relies on a subject added by mutation A, register A first.
diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md
index 8cf46dca..7c7aee84 100644
--- a/docs/primitives/secret.md
+++ b/docs/primitives/secret.md
@@ -1,16 +1,18 @@
# Secret Primitive
-The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It
-integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData`
-entries and object metadata.
+The `secret` primitive wraps a Kubernetes `Secret` and integrates with the component lifecycle as a Static resource,
+providing a structured mutation API for managing `.data` and `.stringData` entries and object metadata.
## Capabilities
-| Capability | Detail |
-| --------------------- | ------------------------------------------------------------------------------------------------ |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch |
-| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle |
+| Capability | Detail |
+| --------------------- | ---------------------------------------------------------------------------------------------------- |
+| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
+| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a `Raw()` escape hatch |
+| **DataExtractable** | Reads values back from the reconciled Secret after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building a Secret Primitive
@@ -34,17 +36,18 @@ resource, err := secret.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that
-receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+A kind-specific example using the `SetData` convenience method:
```go
func MyFeatureMutation(version string) secret.Mutation {
return secret.Mutation{
Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *secret.Mutator) error {
m.SetData("feature-flag", []byte("enabled"))
return nil
@@ -53,62 +56,22 @@ func MyFeatureMutation(version string) secret.Mutation {
}
```
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-```go
-func TLSSecretMutation(version string, tlsEnabled bool) secret.Mutation {
- return secret.Mutation{
- Name: "tls-secret",
- Feature: feature.NewVersionGate(version, nil).When(tlsEnabled),
- Mutate: func(m *secret.Mutator) error {
- m.SetData("tls.crt", certBytes)
- m.SetData("tls.key", keyBytes)
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyTokenMutation(version string) secret.Mutation {
- return secret.Mutation{
- Name: "legacy-token",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *secret.Mutator) error {
- m.SetStringData("auth-mode", "legacy-token")
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
| Step | Category | What it affects |
| ---- | -------------- | --------------------------------------------------- |
| 1 | Metadata edits | Labels and annotations on the `Secret` |
| 2 | Data edits | `.data` and `.stringData` entries: Set, Remove, Raw |
-Within each category, edits are applied in their registration order. Later edits in the same mutation observe the Secret
-as modified by all earlier edits.
+Within each category, edits run in registration order. Later features observe the Secret as modified by all earlier
+ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### SecretDataEditor
The primary API for modifying `.data` and `.stringData` entries. Use `m.EditData` for full control:
@@ -151,8 +114,7 @@ m.EditData(func(e *editors.SecretDataEditor) error {
#### Raw Escape Hatches
`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying
-`map[string]string` for `.stringData`. Both give direct access for free-form editing when none of the structured methods
-are sufficient:
+`map[string]string` for `.stringData`. Both give direct access for free-form editing:
```go
m.EditData(func(e *editors.SecretDataEditor) error {
@@ -196,9 +158,9 @@ single edit block.
## Data Hash
-Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus
-`.stringData` merged using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template
-with this hash so that a secret change triggers a rolling restart.
+Two utilities compute a stable SHA-256 hash of a Secret's effective data content (`.data` plus `.stringData` merged
+using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template with this hash so that a
+secret change triggers a rolling restart.
### DataHash
@@ -208,11 +170,10 @@ with this hash so that a secret change triggers a rolling restart.
hash, err := secret.DataHash(s)
```
-The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is
-deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.stringData` entries are
-merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, matching Kubernetes API-server
-write semantics. This ensures the hash is consistent whether called on a desired object (which may use `.stringData`) or
-a cluster-read object (where `.stringData` has already been merged into `.data`).
+The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically.
+`.stringData` entries are merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing,
+matching Kubernetes API-server write semantics. This ensures the hash is consistent whether called on a desired object
+or a cluster-read object.
### Resource.DesiredHash
@@ -228,12 +189,12 @@ secretResource, err := secret.NewBuilder(base).
hash, err := secretResource.DesiredHash()
```
-The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash.
+The hash covers only operator-controlled fields.
### Annotating a Deployment pod template (single-pass pattern)
-Build the secret resource first, compute the hash, then pass it into the deployment resource factory. Both resources are
-registered with the same component, so the secret is reconciled first and the deployment sees the correct hash on every
+Build the Secret resource first, compute the hash, then pass it into the Deployment resource factory. Both resources are
+registered with the same component, so the Secret is reconciled first and the Deployment sees the correct hash on every
cycle.
`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type
@@ -259,7 +220,7 @@ if err != nil {
}
comp, err := component.NewComponentBuilder().
- WithResource(secretResource). // reconciled first
+ WithResource(secretResource). // reconciled first
WithResource(deployResource).
Build()
```
@@ -281,16 +242,70 @@ func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation
}
```
-When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same
+When the Secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same
reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart.
+## Full Example
+
+```go
+func BaseSecretMutation(version string) secret.Mutation {
+ return secret.Mutation{
+ Name: "base-secret",
+ Feature: feature.NewVersionGate(version, nil),
+ Mutate: func(m *secret.Mutator) error {
+ m.SetStringData("auth-mode", "token")
+ return nil
+ },
+ }
+}
+
+var legacyConstraint = mustSemverConstraint("< 2.0.0")
+
+func LegacyTokenMutation(version string) secret.Mutation {
+ return secret.Mutation{
+ Name: "legacy-token",
+ Feature: feature.NewVersionGate(
+ version,
+ []feature.VersionConstraint{legacyConstraint},
+ ),
+ Mutate: func(m *secret.Mutator) error {
+ m.SetStringData("auth-mode", "legacy-token")
+ return nil
+ },
+ }
+}
+
+func TLSSecretMutation(version string, tlsEnabled bool) secret.Mutation {
+ return secret.Mutation{
+ Name: "tls-secret",
+ Feature: feature.NewVersionGate(version, nil).When(tlsEnabled),
+ Mutate: func(m *secret.Mutator) error {
+ m.SetData("tls.crt", certBytes)
+ m.SetData("tls.key", keyBytes)
+ return nil
+ },
+ }
+}
+
+resource, err := secret.NewBuilder(base).
+ WithMutation(BaseSecretMutation(owner.Spec.Version)).
+ WithMutation(LegacyTokenMutation(owner.Spec.Version)).
+ WithMutation(TLSSecretMutation(owner.Spec.Version, owner.Spec.EnableTLS)).
+ Build()
+```
+
+On versions below 2.0.0 the `auth-mode` key is overwritten to `legacy-token` by the version-gated mutation. On 2.0.0 and
+above only the base value is written. When TLS is enabled, the certificate bytes are added regardless of version.
+
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for boolean conditions.
**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first.
**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids
manual encoding in mutation code.
+
+**Use `DesiredHash` for rolling restarts triggered by secret rotation.** Build the Secret resource, call
+`DesiredHash()`, and stamp the result as a pod-template annotation on the Deployment in the same reconcile pass.
diff --git a/docs/primitives/service.md b/docs/primitives/service.md
index 2a201159..609efd26 100644
--- a/docs/primitives/service.md
+++ b/docs/primitives/service.md
@@ -1,18 +1,20 @@
# Service Primitive
-The `service` primitive is the framework's built-in integration abstraction for managing Kubernetes `Service` resources.
-It integrates with the component lifecycle as an Operational, Graceful, Suspendable resource and provides a structured
-mutation API for managing ports, selectors, and service configuration.
+The `service` primitive wraps a Kubernetes `Service` and integrates with the component lifecycle as an Integration,
+Graceful, and Suspendable resource.
## Capabilities
-| Capability | Detail |
-| ------------------------ | --------------------------------------------------------------------------------------------- |
-| **Operational tracking** | Monitors LoadBalancer ingress assignment; reports `Operational` or `Pending` |
-| **Suspension** | Unaffected by suspension by default; customizable via handlers to delete or mutate on suspend |
-| **Grace status** | LoadBalancer with no ingress reports `Degraded`; non-LoadBalancer or has ingress is `Healthy` |
-| **Mutation pipeline** | Typed editors for metadata and service spec, with a raw escape hatch for free-form access |
-| **Data extraction** | Reads generated or updated values (ClusterIP, LoadBalancer ingress) after each sync cycle |
+| Capability | Detail |
+| --------------------- | -------------------------------------------------------------------------------------------------- |
+| **Operational** | Monitors LoadBalancer ingress assignment; reports `Operational` or `OperationPending` |
+| **Graceful** | LoadBalancer with no ingress reports `Degraded`; non-LoadBalancer or assigned ingress is `Healthy` |
+| **Suspendable** | No-op by default; Service is left in place. Customizable via handlers |
+| **DataExtractable** | Reads assigned ClusterIP or LoadBalancer ingress after each sync cycle |
+| **Mutation pipeline** | Typed editors for metadata and Service spec, with a `Raw()` escape hatch for free-form access |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full set of status values each interface
+reports.
## Building a Service Primitive
@@ -21,7 +23,7 @@ import "github.com/sourcehawk/operator-component-framework/pkg/primitives/servic
base := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
- Name: "app-service",
+ Name: "app-svc",
Namespace: owner.Namespace,
},
Spec: corev1.ServiceSpec{
@@ -33,37 +35,18 @@ base := &corev1.Service{
}
resource, err := service.NewBuilder(base).
- WithMutation(MyFeatureMutation(owner.Spec.Version)).
+ WithMutation(BaseServiceMutation(owner.Spec.Version)).
Build()
```
## Mutations
-Mutations are the primary mechanism for modifying a `Service` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
+Register mutations with `WithMutation`. The mutation system, boolean-gated mutations, and version-gated mutations are
+explained in [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
-
-```go
-func MyFeatureMutation(version string) service.Mutation {
- return service.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *service.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
+A kind-specific example, gating a NodePort mutation on a boolean condition:
```go
func NodePortMutation(version string, enabled bool) service.Mutation {
@@ -81,51 +64,25 @@ func NodePortMutation(version string, enabled bool) service.Mutation {
}
```
-### Version-gated mutations
-
-Pass a `[]feature.VersionConstraint` to gate on a semver range:
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyPortMutation(version string) service.Mutation {
- return service.Mutation{
- Name: "legacy-port",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
- Mutate: func(m *service.Mutator) error {
- m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
- e.EnsurePort(corev1.ServicePort{Name: "legacy", Port: 9090})
- return nil
- })
- return nil
- },
- }
-}
-```
-
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
-
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded:
+Within a single mutation, edits are applied in a fixed category order regardless of recording order:
-| Step | Category | What it affects |
-| ---- | ----------------- | ---------------------------------------- |
-| 1 | Metadata edits | Labels and annotations on the `Service` |
-| 2 | ServiceSpec edits | Ports, selectors, type, traffic policies |
+| Step | Category | What it affects |
+| ---- | -------------- | ---------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the `Service` |
+| 2 | ServiceSpec | Ports, selectors, type, traffic policies |
-Within each category, edits are applied in their registration order. Later features observe the Service as modified by
-all previous features.
+Within each category, edits run in registration order. Later features observe the Service as modified by all earlier
+ones.
## Relevant Editors
+See [Mutation Editors](../primitives.md#mutation-editors) for the general editor model.
+
### ServiceSpecEditor
-Controls service-level settings via `m.EditServiceSpec`.
+Controls Service-level settings via `m.EditServiceSpec`.
Available methods: `SetType`, `EnsurePort`, `RemovePort`, `SetSelector`, `EnsureSelector`, `RemoveSelector`,
`SetSessionAffinity`, `SetSessionAffinityConfig`, `SetPublishNotReadyAddresses`, `SetExternalTrafficPolicy`,
@@ -146,10 +103,10 @@ m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
#### Port Management
-`EnsurePort` upserts a port: if a port with the same `Name` exists, it is replaced; otherwise, when `Name` is empty, the
-match is performed on the combination of `Port` and the effective `Protocol` (treating an empty protocol value as TCP).
-This means TCP and UDP ports with the same port number are considered distinct unless you explicitly set matching
-protocols. If no existing port matches, the new port is appended. `RemovePort` removes a port by name.
+`EnsurePort` upserts a port: if a port with the same `Name` exists it is replaced; when `Name` is empty the match uses
+the combination of `Port` and the effective `Protocol` (treating an empty protocol as TCP). TCP and UDP ports with the
+same port number are distinct unless protocols match explicitly. If no existing port matches, the new port is appended.
+`RemovePort` removes a port by name.
```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
@@ -166,13 +123,13 @@ m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
- e.EnsureSelector("app", "myapp")
- e.EnsureSelector("env", "production")
+ e.EnsureSelector("app", "web")
+ e.EnsureSelector("tier", "frontend")
return nil
})
```
-For fields not covered by the typed API, use `Raw()`:
+Use `Raw()` for fields not covered by the typed API:
```go
m.EditServiceSpec(func(e *editors.ServiceSpecEditor) error {
@@ -195,26 +152,40 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
})
```
-## Operational Status
+## Data Extraction
+
+Use `WithDataExtractor` to read values from the reconciled Service after each sync cycle, such as the assigned ClusterIP
+or LoadBalancer ingress:
-The Service primitive implements the `Operational` concept to track whether the Service is ready to accept traffic.
+```go
+var assignedIP string
-### DefaultOperationalStatusHandler
+resource, err := service.NewBuilder(base).
+ WithDataExtractor(func(svc corev1.Service) error {
+ assignedIP = svc.Spec.ClusterIP
+ return nil
+ }).
+ Build()
+```
-| Service Type | Behaviour |
-| -------------- | ------------------------------------------------------------------------------------------------------------ |
-| `LoadBalancer` | Reports `Pending` until `Status.LoadBalancer.Ingress` has entries with an IP or hostname; then `Operational` |
-| `ClusterIP` | Immediately `Operational` |
-| `NodePort` | Immediately `Operational` |
-| `ExternalName` | Immediately `Operational` |
-| Headless | Immediately `Operational` |
+## Operational Status
+
+The Service primitive implements `concepts.Operational`. The default handler reports:
-Override with `WithCustomOperationalStatus` to add custom checks:
+| Service Type | Condition | Status |
+| -------------- | --------------------------------------------------------------------------- | ------------------ |
+| `LoadBalancer` | `Status.LoadBalancer.Ingress` has no entry with an IP or hostname | `OperationPending` |
+| `LoadBalancer` | `Status.LoadBalancer.Ingress` has at least one entry with an IP or hostname | `Operational` |
+| `ClusterIP` | Always | `Operational` |
+| `NodePort` | Always | `Operational` |
+| `ExternalName` | Always | `Operational` |
+| Headless | Always | `Operational` |
+
+Override with `WithCustomOperationalStatus`:
```go
resource, err := service.NewBuilder(base).
WithCustomOperationalStatus(func(op concepts.ConvergingOperation, svc *corev1.Service) (concepts.OperationalStatusWithReason, error) {
- // Custom logic, e.g. check for specific annotations
return service.DefaultOperationalStatusHandler(op, svc)
}).
Build()
@@ -222,8 +193,7 @@ resource, err := service.NewBuilder(base).
## Grace Status
-The default grace status handler inspects the Service type and load balancer status to assess health after the grace
-period expires:
+The default grace status handler assesses health after the grace period expires:
| Service Type | Condition | Status |
| -------------- | ----------------------------------------- | ---------- |
@@ -250,42 +220,26 @@ service.NewBuilder(base).
## Suspension
-By default, Services are **unaffected** by suspension. They remain in the cluster when the parent component is
-suspended. The default suspend mutation handler is a no-op, `DefaultDeleteOnSuspendHandler` returns `false`, and the
-default suspension status handler reports `Suspended` immediately (no work required).
+By default, Services are unaffected by suspension. They remain in the cluster when the parent component is suspended.
+`DefaultDeleteOnSuspendHandler` returns `false`, `DefaultSuspendMutationHandler` is a no-op, and
+`DefaultSuspensionStatusHandler` reports `Suspended` immediately.
This is appropriate for most use cases because Services are stateless routing objects that are safe to leave in place.
-Override with `WithCustomSuspendDeletionDecision` if you want to delete the Service on suspend:
+Override with `WithCustomSuspendDeletionDecision` to delete the Service on suspend:
```go
resource, err := service.NewBuilder(base).
WithCustomSuspendDeletionDecision(func(_ *corev1.Service) bool {
- return true // delete the Service during suspension
+ return true
}).
Build()
```
-You can also combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behaviour,
-such as modifying the Service before it is deleted or tracking external readiness before reporting suspended.
-
-## Data Extraction
-
-Use `WithDataExtractor` to read values from the reconciled Service, such as the assigned ClusterIP or LoadBalancer
-ingress:
-
-```go
-var assignedIP string
-
-resource, err := service.NewBuilder(base).
- WithDataExtractor(func(svc corev1.Service) error {
- assignedIP = svc.Spec.ClusterIP
- return nil
- }).
- Build()
-```
+Combine `WithCustomSuspendMutation` and `WithCustomSuspendStatus` for more advanced suspension behavior, such as
+modifying the Service before deletion or tracking external readiness before reporting suspended.
-## Full Example: Feature-Composed Service
+## Full Example
```go
func BaseServiceMutation(version string) service.Mutation {
@@ -324,22 +278,32 @@ func MetricsPortMutation(version string, enabled bool) service.Mutation {
}
}
+var assignedIP string
+
resource, err := service.NewBuilder(base).
WithMutation(BaseServiceMutation(owner.Spec.Version)).
WithMutation(MetricsPortMutation(owner.Spec.Version, owner.Spec.EnableMetrics)).
+ WithDataExtractor(func(svc corev1.Service) error {
+ assignedIP = svc.Spec.ClusterIP
+ return nil
+ }).
Build()
```
-When `EnableMetrics` is true, the Service will expose both the HTTP port and the metrics port. When false, only the HTTP
-port is configured. Neither mutation needs to know about the other.
+When `EnableMetrics` is true, the Service exposes both the HTTP and metrics ports. When false, only HTTP is configured.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
-conditions.
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Chain `.When(bool)` for
+boolean conditions and pass version constraints to `NewVersionGate` for version-gated behavior.
-**Register mutations in dependency order.** If mutation B relies on a port added by mutation A, register A first.
+**Register mutations in dependency order.** If mutation B depends on a port added by mutation A, register A first.
-**Use `EnsurePort` for idempotent port management.** The mutator tracks ports by name (or port number when unnamed), so
+**Use `EnsurePort` for idempotent port management.** Ports are tracked by name (or port number when unnamed), so
repeated calls with the same name produce the same result.
+
+**Leave Services in place during suspension.** The no-op default is correct for most Services. Only override
+`WithCustomSuspendDeletionDecision` when your use case requires explicitly removing the Service during suspension.
+
+**Use `WithDataExtractor` for assigned addresses.** ClusterIP and LoadBalancer ingress are server-assigned. Read them
+with a data extractor after reconciliation rather than caching them in mutation logic.
diff --git a/docs/primitives/serviceaccount.md b/docs/primitives/serviceaccount.md
index e8834f59..7d936ee3 100644
--- a/docs/primitives/serviceaccount.md
+++ b/docs/primitives/serviceaccount.md
@@ -1,16 +1,18 @@
# ServiceAccount Primitive
-The `serviceaccount` primitive is the framework's built-in static abstraction for managing Kubernetes `ServiceAccount`
-resources. It integrates with the component lifecycle and provides a structured mutation API for managing image pull
-secrets, the automount token flag, and object metadata.
+The `serviceaccount` primitive wraps a Kubernetes `ServiceAccount` and manages image pull secrets, the automount token
+flag, and object metadata within the component lifecycle.
## Capabilities
-| Capability | Detail |
-| --------------------- | --------------------------------------------------------------------------------------------------------- |
-| **Static lifecycle** | No health tracking, grace periods, or suspension. The resource is reconciled to desired state |
-| **Mutation pipeline** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`, plus metadata editors |
-| **Data extraction** | Reads generated or updated values back from the reconciled ServiceAccount after each sync cycle |
+| Capability | Interfaces / detail |
+| -------------------- | --------------------------------------------------------------------------------------------------- |
+| **Static lifecycle** | `component.Resource`. No health tracking, grace periods, or suspension |
+| **Mutation** | Direct mutator methods for `.imagePullSecrets` and `.automountServiceAccountToken`; metadata editor |
+| **Guard** | `concepts.Guardable`: blocks reconciliation when a precondition is not met (`Blocked`) |
+| **Data extraction** | `concepts.DataExtractable`: reads values back after each sync cycle |
+
+See [Lifecycle Interfaces](../primitives.md#lifecycle-interfaces) for the full interface-to-status mapping.
## Building a ServiceAccount Primitive
@@ -25,93 +27,56 @@ base := &corev1.ServiceAccount{
}
resource, err := serviceaccount.NewBuilder(base).
- WithMutation(MyFeatureMutation(owner.Spec.Version)).
+ WithMutation(BaseTokenMutation(owner.Spec.Version)).
Build()
```
-## Mutations
-
-Mutations are the primary mechanism for modifying a `ServiceAccount` beyond its baseline. Each mutation is a named
-function that receives a `*Mutator` and records edit intent through direct methods.
+`Build()` returns an error if `Name` or `Namespace` is empty.
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Identity format: `v1/ServiceAccount//`.
-```go
-func MyFeatureMutation(version string) serviceaccount.Mutation {
- return serviceaccount.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *serviceaccount.Mutator) error {
- m.EnsureImagePullSecret("my-registry")
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-```go
-func PrivateRegistryMutation(version string, usePrivateRegistry bool) serviceaccount.Mutation {
- return serviceaccount.Mutation{
- Name: "private-registry",
- Feature: feature.NewVersionGate(version, nil).When(usePrivateRegistry),
- Mutate: func(m *serviceaccount.Mutator) error {
- m.EnsureImagePullSecret("private-registry-creds")
- return nil
- },
- }
-}
-```
+## Mutations
-### Version-gated mutations
+Each mutation is a named `serviceaccount.Mutation` that receives a `*Mutator` and records edit intent through direct
+methods. See [The Mutation System](../primitives.md#the-mutation-system) for the full model.
```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyTokenMutation(version string) serviceaccount.Mutation {
+func BaseTokenMutation(version string) serviceaccount.Mutation {
return serviceaccount.Mutation{
- Name: "legacy-token",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
+ Name: "base-token",
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *serviceaccount.Mutator) error {
- v := true
- m.SetAutomountServiceAccountToken(&v)
+ m.EnsureImagePullSecret("default-registry")
return nil
},
}
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+For boolean conditions, chain `.When()` on the gate. See
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations). For version constraints, see
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are
-recorded:
+Within a single mutation, edits are applied in this fixed category order regardless of the call order:
-| Step | Category | What it affects |
-| ---- | ----------------------- | ----------------------------------------------------------------- |
-| 1 | Metadata edits | Labels and annotations on the `ServiceAccount` |
-| 2 | Image pull secret edits | `.imagePullSecrets`: EnsureImagePullSecret, RemoveImagePullSecret |
-| 3 | Automount edits | `.automountServiceAccountToken`: SetAutomountServiceAccountToken |
+| Step | Category | What it affects |
+| ---- | ----------------------- | --------------------------------------------------------------------- |
+| 1 | Metadata edits | Labels and annotations on the `ServiceAccount` |
+| 2 | Image pull secret edits | `.imagePullSecrets`: `EnsureImagePullSecret`, `RemoveImagePullSecret` |
+| 3 | Automount edits | `.automountServiceAccountToken`: `SetAutomountServiceAccountToken` |
-Within each category, edits are applied in their registration order. Later features observe the ServiceAccount as
-modified by all previous features.
+Within each category, edits apply in registration order. Later features observe the object as modified by all earlier
+ones.
## Relevant Editors
### ObjectMetaEditor
-Modifies labels and annotations via `m.EditObjectMetadata`.
-
-Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+Modifies labels and annotations via `m.EditObjectMetadata`. Available methods: `EnsureLabel`, `RemoveLabel`,
+`EnsureAnnotation`, `RemoveAnnotation`, `Raw`. See [Mutation Editors](../primitives.md#mutation-editors) for the general
+editor model.
```go
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
@@ -123,18 +88,21 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
## Mutator Methods
+The `*serviceaccount.Mutator` exposes direct methods that bypass a nested editor for the two ServiceAccount-specific
+fields.
+
### EnsureImagePullSecret
Adds a named image pull secret to `.imagePullSecrets` if not already present. Idempotent: calling it with an
already-present name is a no-op.
```go
-m.EnsureImagePullSecret("my-registry-creds")
+m.EnsureImagePullSecret("registry-creds")
```
### RemoveImagePullSecret
-Removes a named image pull secret from `.imagePullSecrets`. It is a no-op if the secret is not present.
+Removes a named image pull secret from `.imagePullSecrets`. No-op if the name is not present.
```go
m.RemoveImagePullSecret("old-registry-creds")
@@ -142,19 +110,35 @@ m.RemoveImagePullSecret("old-registry-creds")
### SetAutomountServiceAccountToken
-Sets `.automountServiceAccountToken` to the provided value. Pass `nil` to unset the field.
+Sets `.automountServiceAccountToken`. Pass `nil` to unset the field.
```go
v := false
m.SetAutomountServiceAccountToken(&v)
```
-## Full Example: Feature-Composed ServiceAccount
+The pointed-to value is snapshotted at registration time, so later caller-side changes do not affect `Apply()`.
+
+## Data Extraction
+
+`WithDataExtractor` runs a callback after successful reconciliation with a value copy of the reconciled ServiceAccount.
+Use it to surface generated fields to other resources:
```go
-func BaseImagePullSecretMutation(version string) serviceaccount.Mutation {
+resource, err := serviceaccount.NewBuilder(base).
+ WithDataExtractor(func(sa corev1.ServiceAccount) error {
+ sharedState.ServiceAccountName = sa.Name
+ return nil
+ }).
+ Build()
+```
+
+## Full Example
+
+```go
+func PullSecretMutation(version string) serviceaccount.Mutation {
return serviceaccount.Mutation{
- Name: "base-pull-secret",
+ Name: "pull-secret",
Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *serviceaccount.Mutator) error {
m.EnsureImagePullSecret("default-registry")
@@ -163,10 +147,10 @@ func BaseImagePullSecretMutation(version string) serviceaccount.Mutation {
}
}
-func DisableAutomountMutation(version string, disableAutomount bool) serviceaccount.Mutation {
+func DisableAutomountMutation(version string, disable bool) serviceaccount.Mutation {
return serviceaccount.Mutation{
Name: "disable-automount",
- Feature: feature.NewVersionGate(version, nil).When(disableAutomount),
+ Feature: feature.NewVersionGate(version, nil).When(disable),
Mutate: func(m *serviceaccount.Mutator) error {
v := false
m.SetAutomountServiceAccountToken(&v)
@@ -176,21 +160,26 @@ func DisableAutomountMutation(version string, disableAutomount bool) serviceacco
}
resource, err := serviceaccount.NewBuilder(base).
- WithMutation(BaseImagePullSecretMutation(owner.Spec.Version)).
+ WithMutation(PullSecretMutation(owner.Spec.Version)).
WithMutation(DisableAutomountMutation(owner.Spec.Version, owner.Spec.DisableAutomount)).
Build()
```
When `DisableAutomount` is true, `.automountServiceAccountToken` is set to `false`. When the condition is not met, the
-field is left at its baseline value. Neither mutation needs to know about the other.
+field stays at its baseline value.
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that always run. Use
+`feature.NewVersionGate(version, constraints)` when version gating is needed, and chain `.When(bool)` for boolean
conditions.
**Use `EnsureImagePullSecret` for idempotent secret registration.** Multiple features can independently ensure their
-required pull secrets without conflicting with each other.
+required pull secrets without conflicting.
+
+**Register mutations in dependency order.** If one mutation depends on a field set by another, register the dependency
+first.
-**Register mutations in dependency order.** If mutation B relies on a secret added by mutation A, register A first.
+**ServiceAccount is genuinely simple.** The `*Mutator` exposes direct methods rather than a nested editor because the
+only mutable fields are `.imagePullSecrets` and `.automountServiceAccountToken`. For anything beyond those fields, use
+`EditObjectMetadata`.
diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md
index fa6fa7b9..bfa3ae0b 100644
--- a/docs/primitives/statefulset.md
+++ b/docs/primitives/statefulset.md
@@ -1,17 +1,18 @@
# StatefulSet Primitive
-The `statefulset` primitive is the framework's built-in workload abstraction for managing Kubernetes `StatefulSet`
-resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers,
-pod specs, metadata, and volume claim templates.
+The `statefulset` primitive wraps a Kubernetes `StatefulSet` and provides health tracking, suspension, volume claim
+template management, and a typed mutation API for managing replicas, pod spec, and containers as part of the component
+lifecycle.
## Capabilities
-| Capability | Detail |
-| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling`; grace handler can mark Down/Degraded |
-| **Rollout health** | Surfaces stalled or failing rollouts by transitioning the resource to `Degraded` or `Down` (no grace-period timing) |
-| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` |
-| **Mutation pipeline** | Typed editors for metadata, statefulset spec, pod spec, containers, and volume claim templates |
+| [Lifecycle interface](../primitives.md#lifecycle-interfaces) | Reported status values |
+| ------------------------------------------------------------ | ------------------------------------------------------- |
+| `Alive` | `Healthy`, `Creating`, `Updating`, `Scaling`, `Failing` |
+| `Graceful` | `Healthy`, `Degraded`, `Down` |
+| `Suspendable` | `PendingSuspension`, `Suspending`, `Suspended` |
+| `Guardable` | `Blocked` |
+| `DataExtractable` | _(side-effecting, no status)_ |
## Building a StatefulSet Primitive
@@ -48,65 +49,17 @@ resource, err := statefulset.NewBuilder(base).
## Mutations
-Mutations are the primary mechanism for modifying a `StatefulSet` beyond its baseline. Each mutation is a named function
-that receives a `*Mutator` and records edit intent through typed editors.
-
-The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature
-with no version constraints and no `When()` conditions is also always enabled:
+Each mutation is a named `statefulset.Mutation` that receives a `*statefulset.Mutator` and records edits through typed
+editors.
```go
-func MyFeatureMutation(version string) statefulset.Mutation {
+func StorageMutation(version string) statefulset.Mutation {
return statefulset.Mutation{
- Name: "my-feature",
- Feature: feature.NewVersionGate(version, nil), // always enabled
- Mutate: func(m *statefulset.Mutator) error {
- // record edits here
- return nil
- },
- }
-}
-```
-
-Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by
-another, register the dependency first.
-
-### Boolean-gated mutations
-
-Use `When(bool)` to gate a mutation on a runtime condition:
-
-```go
-func TracingMutation(version string, enabled bool) statefulset.Mutation {
- return statefulset.Mutation{
- Name: "tracing",
- Feature: feature.NewVersionGate(version, nil).When(enabled),
- Mutate: func(m *statefulset.Mutator) error {
- m.EnsureInitContainer(corev1.Container{
- Name: "init-config",
- Image: "config-init:latest",
- })
- return nil
- },
- }
-}
-```
-
-### Version-gated mutations
-
-Pass a `[]feature.VersionConstraint` to gate on a semver range:
-
-```go
-var legacyConstraint = mustSemverConstraint("< 2.0.0")
-
-func LegacyStorageMutation(version string) statefulset.Mutation {
- return statefulset.Mutation{
- Name: "legacy-storage",
- Feature: feature.NewVersionGate(
- version,
- []feature.VersionConstraint{legacyConstraint},
- ),
+ Name: "storage-backend",
+ Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *statefulset.Mutator) error {
m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error {
- e.EnsureEnvVar(corev1.EnvVar{Name: "STORAGE_BACKEND", Value: "legacy"})
+ e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"})
return nil
})
return nil
@@ -115,16 +68,17 @@ func LegacyStorageMutation(version string) statefulset.Mutation {
}
```
-All version constraints and `When()` conditions must be satisfied for a mutation to apply.
+See [the mutation system](../primitives.md#the-mutation-system),
+[boolean gating](../primitives.md#boolean-gated-mutations), and
+[version gating](../primitives.md#version-gated-mutations).
## Internal Mutation Ordering
-Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the
-order they are recorded. This ensures structural consistency across mutations.
+Within each feature, edits run in this fixed category order:
| Step | Category | What it affects |
| ---- | -------------------------------- | ----------------------------------------------------------------------- |
-| 1 | StatefulSet metadata edits | Labels and annotations on the `StatefulSet` object |
+| 1 | Object metadata edits | Labels and annotations on the `StatefulSet` object |
| 2 | StatefulSetSpec edits | Replicas, service name, update strategy, etc. |
| 3 | Pod template metadata edits | Labels and annotations on the pod template |
| 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context |
@@ -134,11 +88,13 @@ order they are recorded. This ensures structural consistency across mutations.
| 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) |
| 9 | Volume claim template operations | Adding or removing entries from `spec.volumeClaimTemplates` |
-Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation.
-This means a single mutation can add a container and then configure it without selector resolution issues.
+Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same feature.
## Relevant Editors
+For the generic editor and selector concepts, see [mutation editors](../primitives.md#mutation-editors) and
+[container selectors](../primitives.md#container-selectors).
+
### StatefulSetSpecEditor
Controls statefulset-level settings via `m.EditStatefulSetSpec`.
@@ -155,7 +111,7 @@ m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
})
```
-For fields not covered by the typed API, use `Raw()`:
+Use `Raw()` for fields the typed API does not cover:
```go
m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
@@ -191,8 +147,8 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error {
### ContainerEditor
-Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a
-[selector](../primitives.md#container-selectors).
+Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`, combined with a
+[container selector](../primitives.md#container-selectors).
Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`,
`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`.
@@ -207,28 +163,47 @@ m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor
### ObjectMetaEditor
-Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `StatefulSet` object itself, or
-`m.EditPodTemplateMetadata` to target the pod template.
+Modifies labels and annotations. Use `m.EditObjectMetadata` for the `StatefulSet` itself or `m.EditPodTemplateMetadata`
+for the pod template.
Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
```go
-// On the StatefulSet itself
m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureLabel("app.kubernetes.io/version", version)
return nil
})
-
-// On the pod template
m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error {
e.EnsureAnnotation("prometheus.io/scrape", "true")
return nil
})
```
+## Convenience Methods
+
+| Method | Equivalent to |
+| ----------------------------- | ------------------------------------------------------------- |
+| `EnsureReplicas(n)` | `EditStatefulSetSpec` → `SetReplicas(n)` |
+| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
+| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |
+| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
+| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
+
+## Workload-Kind-Agnostic Mutations
+
+A mutation written against `primitives.WorkloadMutator` can be applied to a StatefulSet builder using
+`statefulset.LiftMutation`. This lets one emitter function target StatefulSets, Deployments, and DaemonSets without
+duplicating code.
+
+```go
+backend.WithMutation(statefulset.LiftMutation(sharedAuthMutation()))
+```
+
+See [workload-kind-agnostic mutations](../primitives.md#workload-kind-agnostic-mutations) for the full pattern.
+
## Volume Claim Templates
-The mutator provides `EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` for managing persistent storage:
+`EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` manage persistent storage templates:
```go
m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{
@@ -244,22 +219,24 @@ m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{
})
```
-**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. These mutation methods are
-primarily useful for constructing the initial desired state or when recreating a StatefulSet.
+!!! warning "VolumeClaimTemplates are immutable after creation"
-## Convenience Methods
+ `spec.volumeClaimTemplates` cannot be changed once the StatefulSet exists in the cluster; the API server rejects
+ such updates. The mutator silently skips these operations on existing StatefulSets (identified by a non-empty
+ `ResourceVersion`). Plan your storage layout before the first creation.
-The `Mutator` also exposes convenience wrappers:
+## Suspension
-| Method | Equivalent to |
-| ----------------------------- | ------------------------------------------------------------- |
-| `EnsureReplicas(n)` | `EditStatefulSetSpec` → `SetReplicas(n)` |
-| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` |
-| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` |
-| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` |
-| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` |
+When the component is suspended, the StatefulSet is scaled to zero replicas. The resource is not deleted.
+
+- `DefaultSuspendMutationHandler` calls `EnsureReplicas(0)`.
+- `DefaultSuspensionStatusHandler` reports `Suspending` while `Status.Replicas > 0`, then `Suspended`.
+- `DefaultDeleteOnSuspendHandler` returns `false`.
-## Full Example: Database StatefulSet with Storage
+Override any handler via `WithCustomSuspendMutation`, `WithCustomSuspendStatus`, or `WithCustomSuspendDeletionDecision`
+on the builder.
+
+## Full Example
```go
func DatabaseMutation(version string) statefulset.Mutation {
@@ -267,14 +244,12 @@ func DatabaseMutation(version string) statefulset.Mutation {
Name: "database-storage",
Feature: feature.NewVersionGate(version, nil),
Mutate: func(m *statefulset.Mutator) error {
- // Configure the StatefulSet spec
m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error {
e.SetReplicas(3)
e.SetPodManagementPolicy(appsv1.OrderedReadyPodManagement)
return nil
})
- // Add a volume claim template for persistent data
m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "data"},
Spec: corev1.PersistentVolumeClaimSpec{
@@ -287,7 +262,6 @@ func DatabaseMutation(version string) statefulset.Mutation {
},
})
- // Mount the volume in the database container
m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error {
e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{
Name: "data",
@@ -304,17 +278,19 @@ func DatabaseMutation(version string) statefulset.Mutation {
## Guidance
-**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use
-`feature.NewVersionGate(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean
+**Use a StatefulSet for stateful workloads requiring pod identity.** StatefulSets provide stable network identities
+(`pod-0`, `pod-1`, ...) and support VolumeClaimTemplates. For stateless workloads where pod identity does not matter, a
+Deployment is simpler.
+
+**`Feature: nil` applies unconditionally.** Omit `Feature` for mutations that should always run. Use
+`feature.NewVersionGate(version, constraints)` for version-based gating and chain `.When(bool)` for runtime boolean
conditions.
**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first.
-The internal ordering within each mutation handles intra-mutation dependencies automatically.
-
-**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in
-the same mutation resolve correctly and reconciliation remains idempotent.
+Internal ordering within each mutation handles intra-mutation dependencies automatically.
-**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation.
+**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so selectors in the
+same mutation resolve correctly and reconciliation remains idempotent.
-**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can
-cause unexpected behavior if sidecar containers are present.
+**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. Changing the templates
+requires recreating the StatefulSet.
diff --git a/docs/primitives/unstructured.md b/docs/primitives/unstructured.md
index 0725139a..93368655 100644
--- a/docs/primitives/unstructured.md
+++ b/docs/primitives/unstructured.md
@@ -1,39 +1,48 @@
# Unstructured Primitives
-The `unstructured` primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type
-definition available at compile time: Crossplane resources, external CRDs, or any object known only at runtime.
+The unstructured primitives are an escape hatch for managing arbitrary Kubernetes objects that have no Go type
+definition at compile time: external CRDs, Crossplane resources, or any object known only at runtime.
-Four variants are provided, one per [lifecycle category](../primitives.md#primitive-categories):
+## When to Use Unstructured
-| Package | Category | Lifecycle Interfaces |
-| ----------------------------------------- | ----------- | ------------------------------------------------------------------------ |
-| `pkg/primitives/unstructured/static` | Static | `Guardable`, `DataExtractable` |
-| `pkg/primitives/unstructured/workload` | Workload | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` |
-| `pkg/primitives/unstructured/integration` | Integration | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` |
-| `pkg/primitives/unstructured/task` | Task | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` |
+Choose between the three approaches in this order:
-## No Semantic Defaults
+1. **Typed primitive** (`pkg/primitives/`): use this whenever a built-in primitive covers your kind. It has the
+ most safety, the richest editor API, and the best domain defaults.
+2. **Unstructured primitive** (this page): use this when the object's kind has no corresponding Go type or when you want
+ to manage an external CRD without generating Go client code. You supply all lifecycle semantics through required
+ handlers.
+3. **Custom resource wrapper** (`pkg/generic`): use this when you own the Go type (your own CRD) or want a fully typed
+ mutation surface with a custom builder API. See the [Custom Resource Implementation Guide](../custom-resource.md).
+
+See also [Unstructured Primitives](../primitives.md#unstructured-primitives) in the Primitives Overview for a summary
+table and [Implementing a Custom Resource](../primitives.md#implementing-a-custom-resource) for the full walkthrough.
+
+## Variants
+
+One variant exists per [lifecycle category](../primitives.md#primitive-categories), each implementing the corresponding
+interfaces. Status values below are the runtime strings that appear in conditions (see
+[Lifecycle Interfaces](../primitives.md#lifecycle-interfaces)).
+
+| Package | Category | Lifecycle interfaces | Required at `Build()` |
+| ----------------------------------------- | ----------- | ------------------------------------------------------------------------ | ----------------------------- |
+| `pkg/primitives/unstructured/static` | Static | `Guardable`, `DataExtractable` | _(none)_ |
+| `pkg/primitives/unstructured/workload` | Workload | `Alive`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomConvergeStatus` |
+| `pkg/primitives/unstructured/integration` | Integration | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomOperationalStatus` |
+| `pkg/primitives/unstructured/task` | Task | `Completable`, `Suspendable`, `Guardable`, `DataExtractable` | `WithCustomConvergeStatus` |
-Because the framework cannot know the semantics of an unstructured object, **no domain-specific status or suspension
-behavior is inferred**. The unstructured builders only configure generic safe defaults: grace status defaults to
-`Healthy`, suspension status to `Suspended`, and suspension mutations are no-ops. Only the converging or operational
-status handler is required at build time; all other handlers are optional and fall back to these safe defaults when
-omitted. Calling `Build()` without the required handler returns an error.
+## No Semantic Defaults
-### Required Handlers per Variant
+Because the framework has no type information for unstructured objects, it infers no domain-specific status or
+suspension behavior. Safe fallbacks are configured instead:
-| Variant | Required at `Build()` | Optional |
-| --------------- | --------------------- | -------------------------------------------------------- |
-| **Static** | _(none)_ | All optional |
-| **Workload** | `ConvergingStatus` | `GraceStatus` (defaults to Healthy), suspension handlers |
-| **Integration** | `OperationalStatus` | `GraceStatus` (defaults to Healthy), suspension handlers |
-| **Task** | `ConvergingStatus` | Suspension handlers |
+- Grace status defaults to `Healthy` when no handler is provided.
+- Suspension status defaults to `Suspended` immediately (no-op suspend mutation, `DeleteOnSuspend` returns `false`).
-Suspension handlers default to safe no-ops when omitted: `DeleteOnSuspend()` returns false, `Suspend()` is a no-op, and
-`SuspensionStatus()` reports `Suspended` immediately. Override these via `WithCustomSuspendDeletionDecision`,
-`WithCustomSuspendMutation`, and `WithCustomSuspendStatus` if the resource needs custom suspension behavior.
+Only the converge or operational status handler is required. All other handlers are optional. Calling `Build()` without
+the required handler returns an error.
-## Building an Unstructured Primitive
+## Building Unstructured Primitives
### Static (simplest)
@@ -46,13 +55,13 @@ import (
obj := &uns.Unstructured{}
obj.SetGroupVersionKind(schema.GroupVersionKind{
- Group: "example.crossplane.io", Version: "v1alpha1", Kind: "Database",
+ Group: "example.io", Version: "v1alpha1", Kind: "Widget",
})
-obj.SetName("my-database")
+obj.SetName("my-widget")
obj.SetNamespace(owner.Namespace)
resource, err := static.NewBuilder(obj).
- WithMutation(myMutation(owner.Spec.Version)).
+ WithMutation(RegionMutation(owner.Spec.Version, owner.Spec.Region)).
Build()
```
@@ -68,7 +77,6 @@ import (
resource, err := workload.NewBuilder(obj).
WithCustomConvergeStatus(func(op concepts.ConvergingOperation, o *uns.Unstructured) (concepts.AliveStatusWithReason, error) {
- // Inspect o.Object to determine health
ready, _, _ := uns.NestedBool(o.Object, "status", "ready")
if ready {
return concepts.AliveStatusWithReason{
@@ -81,25 +89,38 @@ resource, err := workload.NewBuilder(obj).
Reason: "waiting for readiness",
}, nil
}).
- WithCustomGraceStatus(func(o *uns.Unstructured) (concepts.GraceStatusWithReason, error) {
- return concepts.GraceStatusWithReason{Status: concepts.GraceStatusDegraded}, nil
- }).
- WithCustomSuspendStatus(func(o *uns.Unstructured) (concepts.SuspensionStatusWithReason, error) {
- return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil
- }).
- WithCustomSuspendMutation(func(m *unstruct.Mutator) error {
- return nil
- }).
- WithCustomSuspendDeletionDecision(func(o *uns.Unstructured) bool {
- return true // delete on suspend
+ Build()
+```
+
+### Integration
+
+```go
+import (
+ "github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
+ "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/integration"
+ uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+resource, err := integration.NewBuilder(obj).
+ WithCustomOperationalStatus(func(op concepts.ConvergingOperation, o *uns.Unstructured) (concepts.OperationalStatusWithReason, error) {
+ phase, _, _ := uns.NestedString(o.Object, "status", "phase")
+ switch phase {
+ case "Ready":
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil
+ case "Pending":
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending}, nil
+ default:
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing, Reason: phase}, nil
+ }
}).
Build()
```
### Cluster-Scoped Resources
-Call `MarkClusterScoped()` for resources without a namespace. The builder validates that the object's namespace is empty
-and formats the identity string without a namespace segment.
+Call `MarkClusterScoped()` for resources without a namespace. The builder rejects a non-empty namespace and formats the
+identity string without a namespace segment. See [Cluster-Scoped Primitives](../primitives.md#cluster-scoped-primitives)
+for details.
```go
resource, err := static.NewBuilder(obj).
@@ -109,8 +130,11 @@ resource, err := static.NewBuilder(obj).
## Mutations
-Mutations follow the same pattern as typed primitives. The `Mutation` type is defined in the shared
-`primitives/unstructured` package and used by all four variants:
+All four variants share `unstruct.Mutation` and `*unstruct.Mutator` from the parent `pkg/primitives/unstructured`
+package. Mutations follow the same pattern as typed primitives. For a full explanation of the mutation system,
+boolean-gated mutations, and version-gated mutations see [The Mutation System](../primitives.md#the-mutation-system),
+[Boolean-Gated Mutations](../primitives.md#boolean-gated-mutations), and
+[Version-Gated Mutations](../primitives.md#version-gated-mutations).
```go
import (
@@ -139,36 +163,40 @@ func RegionMutation(version, region string) unstruct.Mutation {
## Internal Mutation Ordering
-Within each feature, mutations execute in the following order:
+Within a single mutation, edits execute in a fixed category order regardless of the order they are recorded:
+
+| Step | Category | What it affects |
+| ---- | -------------- | --------------------------------------------- |
+| 1 | Metadata edits | Labels and annotations via `ObjectMetaEditor` |
+| 2 | Content edits | Nested fields via `UnstructuredContentEditor` |
+
+Features apply in registration order. Later features observe the object as modified by all earlier ones.
-1. **Metadata edits**: labels and annotations via `ObjectMetaEditor`
-2. **Content edits**: nested fields via `UnstructuredContentEditor`
+## Relevant Editors
-Features are applied in registration order. Later features observe the object as modified by all previous features.
+For the full method list of any editor see the
+[Go API reference](https://pkg.go.dev/github.com/sourcehawk/operator-component-framework/pkg/mutation/editors). The
+generic concept is explained in [Mutation Editors](../primitives.md#mutation-editors).
-## UnstructuredContentEditor
+### UnstructuredContentEditor
The `UnstructuredContentEditor` wraps the object's `map[string]interface{}` content and provides structured operations
-for setting and removing values at nested paths.
-
-### Methods
-
-| Method | Signature | Purpose |
-| ---------------------------- | -------------------------------------------------------- | ------------------------------------------- |
-| `SetNestedField` | `(value interface{}, fields ...string) error` | Set any value at a nested path |
-| `RemoveNestedField` | `(fields ...string)` | Remove a field at a nested path |
-| `SetNestedString` | `(value string, fields ...string) error` | Convenience for string fields |
-| `SetNestedBool` | `(value bool, fields ...string) error` | Convenience for boolean fields |
-| `SetNestedInt64` | `(value int64, fields ...string) error` | Convenience for integer fields |
-| `SetNestedFloat64` | `(value float64, fields ...string) error` | Convenience for float fields |
-| `SetNestedStringMap` | `(value map[string]string, fields ...string) error` | Set a string map (labels, selectors) |
-| `EnsureNestedStringMapEntry` | `(key, value string, fields ...string) error` | Add/update one entry in a nested string map |
-| `RemoveNestedStringMapEntry` | `(key string, fields ...string) error` | Remove one entry from a nested string map |
-| `SetNestedSlice` | `(value []interface{}, fields ...string) error` | Set an entire slice |
-| `SetNestedMap` | `(value map[string]interface{}, fields ...string) error` | Set an entire sub-object |
-| `Raw` | `() map[string]interface{}` | Escape hatch for free-form access |
-
-### Raw Escape Hatch
+for setting and removing values at nested paths. Access it via `m.EditContent`.
+
+| Method | Signature | Purpose |
+| ---------------------------- | -------------------------------------------------------- | ---------------------------------------------- |
+| `SetNestedField` | `(value interface{}, fields ...string) error` | Set any value at a nested path |
+| `RemoveNestedField` | `(fields ...string)` | Remove a field at a nested path |
+| `SetNestedString` | `(value string, fields ...string) error` | Convenience for string fields |
+| `SetNestedBool` | `(value bool, fields ...string) error` | Convenience for boolean fields |
+| `SetNestedInt64` | `(value int64, fields ...string) error` | Convenience for integer fields |
+| `SetNestedFloat64` | `(value float64, fields ...string) error` | Convenience for float fields |
+| `SetNestedStringMap` | `(value map[string]string, fields ...string) error` | Set a string map (labels, selectors) |
+| `EnsureNestedStringMapEntry` | `(key, value string, fields ...string) error` | Add or update one entry in a nested string map |
+| `RemoveNestedStringMapEntry` | `(key string, fields ...string) error` | Remove one entry from a nested string map |
+| `SetNestedSlice` | `(value []interface{}, fields ...string) error` | Set an entire slice |
+| `SetNestedMap` | `(value map[string]interface{}, fields ...string) error` | Set an entire sub-object |
+| `Raw` | `() map[string]interface{}` | Escape hatch for free-form access |
When the structured methods are insufficient, `Raw()` returns the underlying content map for direct manipulation:
@@ -185,37 +213,129 @@ m.EditContent(func(e *editors.UnstructuredContentEditor) error {
})
```
+### ObjectMetaEditor
+
+Modifies labels and annotations via `m.EditObjectMetadata`.
+
+Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`.
+
+!!! note "Metadata bridging"
+
+ `*uns.Unstructured` does not embed `metav1.ObjectMeta`. During `Apply()`, the mutator populates a temporary
+ `ObjectMeta` from the object's labels and annotations, runs the editor, and writes the results back via
+ `SetLabels`/`SetAnnotations`. The behavior is identical to typed primitives from the caller's perspective.
+
## Identity
-The identity string is derived from the object's GVK, namespace, and name:
+The identity string is derived from the object's GVK, namespace, and name at build time:
-- **Namespaced**: `{group}/{version}/{kind}/{namespace}/{name}`
-- **Cluster-scoped**: `{group}/{version}/{kind}/{name}`
+- Namespaced: `{group}/{version}/{kind}/{namespace}/{name}`
+- Cluster-scoped: `{group}/{version}/{kind}/{name}`
-Namespaced resources must have a non-empty namespace set on the object; `Build()` rejects empty namespaces.
+Namespaced resources must have a non-empty namespace; `Build()` rejects empty namespaces unless `MarkClusterScoped()`
+was called.
## Data Extraction
-All four variants support data extraction. Extractors receive a value copy of the reconciled object:
+All four variants support data extraction. The extractor receives a value copy of the reconciled object after each sync
+cycle:
```go
builder.WithDataExtractor(func(obj uns.Unstructured) error {
ip, found, _ := uns.NestedString(obj.Object, "status", "atProvider", "ipAddress")
if found {
- myComponent.DatabaseIP = ip
+ myComponent.ResourceIP = ip
}
return nil
})
```
+## Suspension Handlers
+
+The non-static variants support custom suspension behavior. All three handlers default to safe no-ops when omitted.
+
+| Builder method | Default behavior |
+| ----------------------------------- | ----------------------------------- |
+| `WithCustomSuspendDeletionDecision` | Returns `false` (keep the resource) |
+| `WithCustomSuspendMutation` | No-op (no spec changes on suspend) |
+| `WithCustomSuspendStatus` | Reports `Suspended` immediately |
+
+Override them when the resource has native suspend semantics or must be deleted on suspend:
+
+```go
+workload.NewBuilder(obj).
+ WithCustomSuspendDeletionDecision(func(o *uns.Unstructured) bool {
+ return true // delete on suspend
+ }).
+ WithCustomSuspendMutation(func(m *unstruct.Mutator) error {
+ return nil // no-op; deletion handles everything
+ }).
+ WithCustomSuspendStatus(func(o *uns.Unstructured) (concepts.SuspensionStatusWithReason, error) {
+ return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil
+ }).
+ Build()
+```
+
+## Full Example
+
+```go
+// Manage an external CRD that provisions a database connection.
+obj := &uns.Unstructured{}
+obj.SetGroupVersionKind(schema.GroupVersionKind{
+ Group: "db.example.io", Version: "v1", Kind: "Connection",
+})
+obj.SetName("app-db")
+obj.SetNamespace(owner.Namespace)
+
+resource, err := integration.NewBuilder(obj).
+ WithMutation(unstruct.Mutation{
+ Name: "connection-config",
+ Feature: feature.NewVersionGate(owner.Spec.Version, nil),
+ Mutate: func(m *unstruct.Mutator) error {
+ m.EditContent(func(e *editors.UnstructuredContentEditor) error {
+ if err := e.SetNestedString(owner.Spec.Region, "spec", "region"); err != nil {
+ return err
+ }
+ return e.SetNestedInt64(int64(owner.Spec.PoolSize), "spec", "poolSize")
+ })
+ return nil
+ },
+ }).
+ WithCustomOperationalStatus(func(_ concepts.ConvergingOperation, o *uns.Unstructured) (concepts.OperationalStatusWithReason, error) {
+ phase, _, _ := uns.NestedString(o.Object, "status", "phase")
+ switch phase {
+ case "Ready":
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil
+ case "Provisioning":
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusPending, Reason: "provisioning"}, nil
+ default:
+ return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing, Reason: phase}, nil
+ }
+ }).
+ WithDataExtractor(func(o uns.Unstructured) error {
+ endpoint, _, _ := uns.NestedString(o.Object, "status", "endpoint")
+ myComponent.DBEndpoint = endpoint
+ return nil
+ }).
+ Build()
+```
+
## Guidance
-- **Choose the right variant.** Pick the variant matching the object's runtime behavior. If the object runs continuously
- and has observable health, use `workload`. If it depends on external assignments, use `integration`. If it runs to
- completion, use `task`. If it is configuration-like, use `static`.
-- **Handlers encode your domain knowledge.** Since the framework has no type information for unstructured objects, the
- handlers you provide are the only source of lifecycle semantics. Inspect `obj.Object` fields to determine status.
-- **Use typed primitives when possible.** Unstructured primitives trade compile-time safety for runtime flexibility.
- Prefer typed primitives for standard Kubernetes resources.
-- **Test your handlers.** Without domain-specific defaults as a safety net, handler correctness is entirely on the
- operator author. Write table-driven tests covering all status transitions.
+**Choose the right variant.** Pick the variant that matches the object's runtime behavior. Use `workload` for
+long-running objects with observable health, `integration` for objects whose readiness depends on an external
+controller, `task` for objects that run to completion, and `static` for configuration-like objects.
+
+**Handlers encode all lifecycle semantics.** The framework has no type information for unstructured objects. The
+handlers you provide are the sole source of lifecycle semantics. Inspect `obj.Object` fields directly to determine
+status.
+
+**Prefer typed primitives when possible.** Unstructured primitives trade compile-time safety for runtime flexibility. If
+a built-in typed primitive covers the kind, use it.
+
+**Test handlers thoroughly.** Without domain-specific defaults as a safety net, handler correctness is entirely on the
+operator author. Write table-driven tests covering all status transitions before deploying.
+
+**Use typed primitives or custom resource wrappers for your own CRDs.** Unstructured primitives are intended for
+third-party or generated resources where a Go type is unavailable. For your own CRDs, generate the Go type and use a
+typed wrapper; see the [Custom Resource Implementation Guide](../custom-resource.md).
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
new file mode 100644
index 00000000..e400104e
--- /dev/null
+++ b/docs/stylesheets/extra.css
@@ -0,0 +1,16 @@
+/* Brand and spacing overrides for Material for MkDocs. */
+:root {
+ --md-primary-fg-color: #3f51b5;
+ --md-accent-fg-color: #3f51b5;
+}
+
+/* Center Mermaid diagrams. A diagram narrower than the page is centered;
+ a full-width one is unaffected. */
+.mermaid {
+ text-align: center;
+}
+
+.mermaid svg {
+ margin: 0 auto;
+}
+
diff --git a/docs/testing.md b/docs/testing.md
index 321df9c6..fd2cd25b 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -1,17 +1,48 @@
# Testing
-The framework ships two test-only packages for asserting the desired state your resources and components produce:
+The framework ships two test-only packages: `pkg/testing/golden` for single-build snapshot tests and
+`pkg/testing/goldengen` for declarative coverage across versions and specs. Both are opt-in and import nothing into the
+reconcile path, so a consumer that does not test against them pays nothing. This page organizes them around three
+testing layers.
-- `pkg/testing/golden` snapshots a single build to a YAML golden file and compares against it on every run.
-- `pkg/testing/goldengen` sweeps a version universe over one or more fixtures, classifies the swept versions into gating
- regimes, generates the minimal set of goldens covering them, asserts which mutations fire at which version, and proves
- every registered mutation is accounted for.
+## The three layers
-Reach for `golden` when you want to pin the output of one build. Reach for `goldengen` when a resource carries
-version-gated mutations and you want one golden per behavior rather than one per version, with the gating asserted
-explicitly.
+Test a component from the inside out. Each layer asserts something the layer below cannot:
-Both packages are opt-in and import nothing into the reconcile path. A consumer that does not import them pays nothing.
+| Layer | What you assert | Tool |
+| ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------- |
+| **Mutation** | one mutation makes the field changes you intend, on a baseline | testify, against `Preview()` |
+| **Resource** | the right mutations fire for a spec, and the rendered output is pinned | `golden` for a snapshot, `goldengen.Resource` for coverage |
+| **Component** | the whole component renders the resources you expect, applied together | `golden.AssertComponentYAML`, or `goldengen.Component` |
+
+The
+[`mutations-and-gating` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating)
+demonstrates all three, and the
+[`version-matrix` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/version-matrix)
+is a focused walkthrough of `goldengen`.
+
+## Mutation tests
+
+Unit-test a mutation in isolation: build a minimal baseline primitive with only that mutation, preview it, and assert
+the fields it changed. There is no golden file at this layer; the assertion states intent directly.
+
+```go
+func TestDebugLoggingMutation(t *testing.T) {
+ res, err := deployment.NewBuilder(baseDeployment()).
+ WithMutation(features.DebugLoggingMutation(true)).
+ Build()
+ require.NoError(t, err)
+
+ dep, err := res.Preview()
+ require.NoError(t, err)
+
+ container := dep.(*appsv1.Deployment).Spec.Template.Spec.Containers[0]
+ assert.Contains(t, container.Env, corev1.EnvVar{Name: "LOG_LEVEL", Value: "debug"})
+}
+```
+
+Share the minimal `baseDeployment()` / `baseConfigMap()` baselines across a package's mutation tests in a
+`helpers_test.go` so each test declares only what it exercises.
## Golden snapshots
@@ -19,67 +50,179 @@ Both packages are opt-in and import nothing into the reconcile path. A consumer
serialization resolves `TypeMeta` (from the object or a supplied scheme) and strips zero-value noise fields, so the
golden reflects only the meaningful desired state.
+### golden.WithScheme is effectively mandatory
+
+Typed Kubernetes objects (all built-in primitives and standard `k8s.io/api` types) do not populate `TypeMeta` by
+default. Attempting to serialize such an object without a scheme produces an error:
+
+```
+object *v1.Deployment has incomplete TypeMeta (kind="", apiVersion="") and no scheme was provided
+```
+
+Pass `golden.WithScheme(scheme)` to every `AssertYAML` and `AssertComponentYAML` call. The scheme only needs to register
+the types you are serializing; the same scheme you use in your controller's manager is normally sufficient.
+
+```go
+var scheme = runtime.NewScheme()
+
+func init() {
+ _ = appsv1.AddToScheme(scheme)
+ _ = corev1.AddToScheme(scheme)
+}
+```
+
+### The Previewer and ComponentPreviewer contracts
+
+`AssertYAML` accepts a `golden.Previewer`:
+
+```go
+type Previewer interface {
+ Preview() (client.Object, error)
+}
+```
+
+`AssertComponentYAML` accepts a `golden.ComponentPreviewer`:
+
+```go
+type ComponentPreviewer interface {
+ Preview() ([]client.Object, error)
+}
+```
+
+All built-in primitives satisfy `Previewer` through `generic.BaseResource`. A built `*component.Component` satisfies
+`ComponentPreviewer` through its `Preview` method. If you are implementing a custom resource wrapper, your built
+resource must also satisfy `Previewer` for golden tests to work. See [Custom Resources](custom-resource.md) for how to
+implement `Preview` on a custom resource.
+
### Assert a single resource
-`AssertYAML` previews a built primitive, serializes it, and fails the test on any difference from the golden file. Wire
-a `-update` flag to regenerate the golden when the desired state legitimately changes.
+`AssertYAML` previews a built primitive, serializes it, and fails the test on any difference from the golden file. The
+test helpers live in `github.com/sourcehawk/operator-component-framework/pkg/testing/golden`; `app` and `resources` are
+your own packages, and `scheme` is the package-level scheme from the section above.
```go
+import (
+ "flag"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/require"
+
+ "your.module/app"
+ "your.module/resources"
+)
+
var update = flag.Bool("update", false, "update golden files")
func TestDeploymentGolden(t *testing.T) {
- res, err := deployment.NewBuilder(baseDeployment()).
- WithMutation(features.DebugLoggingMutation(true)).
- Build()
- require.NoError(t, err)
+ owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}}
+ owner.Name = "my-app"
+ owner.Namespace = "default"
- golden.AssertYAML(t, "testdata/deployment.yaml", res,
- golden.WithScheme(scheme), golden.Update(*update))
+ res, err := resources.NewDeploymentResource(owner)
+ require.NoError(t, err)
+
+ previewer, ok := res.(golden.Previewer)
+ require.True(t, ok)
+ golden.AssertYAML(t, "testdata/deployment.yaml", previewer,
+ golden.WithScheme(scheme), golden.Update(*update))
}
```
-`golden.WithScheme(scheme)` resolves `apiVersion` and `kind` for objects that do not populate `TypeMeta`. Without it,
-serialization of such an object fails. `golden.Update(*update)` overwrites the golden file (creating intermediate
-directories) instead of comparing, so `go test ./... -update` refreshes every golden in one pass.
+`resources.NewDeploymentResource` returns a `component.Resource`, the lean interface the reconciler uses. Rendering is a
+separate capability, so the test asserts to `golden.Previewer` (the contract shown above); for any built-in primitive
+the assertion always succeeds, since `generic.BaseResource` implements `Preview`.
+
+`golden.Update(*update)` overwrites the golden file (creating intermediate directories) instead of comparing. Generate
+the golden once, inspect it, then commit it:
+
+```bash
+go test ./path/to/pkg -run TestDeploymentGolden -update
+go test ./path/to/pkg -run TestDeploymentGolden
+```
+
+!!! note
+
+ The `-update` flag goes **after** the package path, not before it. `go test -update ./...` passes `-update` to
+ `go test` itself, which rejects it. The correct form is `go test ./path/to/pkg -update`.
+
+Golden files live in a `testdata/` directory next to the test file. Go excludes `testdata/` from the build by
+convention, so the files are invisible to the compiler.
### Assert a component
`AssertComponentYAML` previews every resource a component would apply and serializes them into one multi-document YAML
-stream (`---` separated, in apply order).
+stream (`---` separated, in apply order). `buildComponent` here is your own helper that assembles the component with
+`component.NewComponentBuilder` (see [Getting Started](getting-started.md#step-5-wire-the-reconciler) for building one);
+extract it from your reconciler so the test and the controller build the component the same way. A built
+`*component.Component` satisfies `golden.ComponentPreviewer` directly, so no type assertion is needed.
```go
func TestComponentGolden(t *testing.T) {
- c, err := buildComponent(owner)
- require.NoError(t, err)
+ owner := &app.ExampleApp{Spec: app.ExampleAppSpec{Version: "2.0.0", EnableDebugLogging: true}}
+ owner.Name = "my-app"
+ owner.Namespace = "default"
+
+ comp, err := buildComponent(owner) // your component-building helper
+ require.NoError(t, err)
- golden.AssertComponentYAML(t, "testdata/component.yaml", c,
- golden.WithScheme(scheme), golden.Update(*update))
+ golden.AssertComponentYAML(t, "testdata/component.yaml", comp,
+ golden.WithScheme(scheme), golden.Update(*update))
}
```
-Both helpers have non-`testing.T` variants, `CompareYAML` and `CompareComponentYAML`, that return a `*MismatchError`
-(carrying a unified diff) instead of failing a test, for use outside a test body.
+Generate and verify with the same `-update` pattern:
-### Serialize out of band
+```bash
+go test ./path/to/pkg -run TestComponentGolden -update
+go test ./path/to/pkg -run TestComponentGolden
+```
+
+### Non-testing variants and out-of-band serialization
+
+Both helpers have non-`testing.T` variants that return a `*MismatchError` (carrying a unified diff) instead of failing a
+test, for use outside a test body:
+
+- `CompareYAML(path string, p Previewer, opts ...Option) error`
+- `CompareComponentYAML(path string, c ComponentPreviewer, opts ...Option) error`
-When you need the canonical YAML bytes directly, for example to feed a custom comparison or to generate goldens from a
-tool, call the serializers the assertions use:
+When you need the canonical YAML bytes directly (to feed a custom comparison or generate goldens from a tool), call the
+serializers directly:
```go
-data, err := golden.Serialize(obj, scheme) // one object
-stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream
+data, err := golden.Serialize(obj, scheme) // one object
+stream, err := golden.SerializeComponent(objs, scheme) // multi-document stream
```
`goldengen` is built on exactly these two functions.
-## Version matrix generation
+## Coverage with goldengen
+
+`goldengen` is the declarative way to do the resource and component layers when you want coverage rather than a single
+snapshot. It sweeps a set of versions and specs, asserts which mutations fire at each, writes one golden per distinct
+firing group, and proves through `AssertComplete` that no registered mutation went untested.
+
+It works at either granularity through one `Unit` abstraction: wrap a built resource with
+`goldengen.Resource(res, scheme)` for resource-level coverage, or a built component with
+`goldengen.Component(comp, scheme)` for component-level coverage. Everything below (fixtures, gating assertions, the
+manifest, completeness) applies the same to both.
+
+!!! note
+
+ `goldengen` classifies firing and checks completeness by reading each unit's `RegisteredMutations()` and
+ `FiringSet()`, the `concepts.MutationInspector` interface every built resource and component implements. You rarely
+ call it directly; `goldengen` is the supported way to assert which mutations fire.
-A resource with version-gated mutations behaves differently across versions, but not at every version: it changes only
-where a gate flips. Asserting one golden per version is wasteful and obscures where behavior actually changes.
-`goldengen` sweeps the versions you supply, groups them by which mutations fire, and writes one golden per distinct
-group.
+A resource with version-gated mutations behaves differently across versions, but not at every version: behavior changes
+only where a gate flips. Asserting one golden per version is wasteful and obscures where behavior actually changes.
+`goldengen` groups the swept versions by which mutations fire and writes one golden per distinct group.
-The worked example lives at [`examples/version-matrix`](../examples/version-matrix). The walkthrough below follows it.
+The worked example lives at
+[`examples/version-matrix`](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/version-matrix)
+(a single resource); the
+[`mutations-and-gating` example](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/mutations-and-gating)
+applies the same harness at both the resource and component layers. The walkthrough below follows the version-matrix
+example.
### Declare the matrix
@@ -88,30 +231,30 @@ function accepts).
```go
var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{
- Dir: "testdata/version_matrix",
- Versions: []string{"8.7.0", "8.8.2", "8.9.0"},
- Fixtures: []goldengen.Fixture[*app.ExampleApp]{{
- Name: "default",
- Spec: defaultCluster(),
- Requires: []goldengen.Expect{
- {Name: "ContainerImage"},
- {Name: "ClusterEnv/Pre89", For: "8.8.2"},
- {Name: "ClusterEnv/Unified89", For: "8.9.0"},
- },
- Forbids: []goldengen.Expect{
- {Name: "ClusterEnv/Unified89", For: "8.8.2"},
- {Name: "ClusterEnv/Pre89", For: "8.9.0"},
- },
- }},
- Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
- c := spec.DeepCopyObject().(*app.ExampleApp)
- c.Spec.Version = version
- res, err := resources.NewStatefulSetResource(c)
- if err != nil {
- return nil, err
- }
- return goldengen.Resource(res, scheme), nil
- },
+ Dir: "testdata/version_matrix",
+ Versions: []string{"1.0.0", "1.5.0", "2.0.0"},
+ Fixtures: []goldengen.Fixture[*app.ExampleApp]{{
+ Name: "default",
+ Spec: defaultCluster(),
+ Requires: []goldengen.Expect{
+ {Name: "ContainerImage"},
+ {Name: "PeerDiscovery/PreV2", For: "1.5.0"},
+ {Name: "PeerDiscovery/V2", For: "2.0.0"},
+ },
+ Forbids: []goldengen.Expect{
+ {Name: "PeerDiscovery/V2", For: "1.5.0"},
+ {Name: "PeerDiscovery/PreV2", For: "2.0.0"},
+ },
+ }},
+ Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ c := spec.DeepCopyObject().(*app.ExampleApp)
+ c.Spec.Version = version
+ res, err := resources.NewStatefulSetResource(c)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res, scheme), nil
+ },
})
```
@@ -127,7 +270,31 @@ The fields:
`Build` returns a `Unit`, the introspectable-and-renderable handle the generator works with. Adapt a built primitive
with `goldengen.Resource(res, scheme)` or a built component with `goldengen.Component(comp, scheme)`. Both delegate
-rendering to `golden.Serialize` / `golden.SerializeComponent`.
+rendering to `golden.Serialize` / `golden.SerializeComponent`. For component-level coverage, build the whole component
+in `Build` and wrap it instead; everything else (fixtures, gating assertions, the manifest, `AssertComplete`) is
+identical:
+
+```go
+Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ c := spec.DeepCopyObject().(*app.ExampleApp)
+ c.Spec.Version = version
+ comp, err := buildComponent(c) // returns *component.Component, as your reconciler builds it
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Component(comp, scheme), nil
+},
+```
+
+A component's registered and firing sets are the union of its resources' mutations, deduplicated. So at the component
+layer, `Requires`/`Forbids` and `AssertComplete` range over every mutation any resource in the component registers, not
+a separate component-level set.
+
+`goldengen.Resource` requires that the primitive satisfies both `concepts.MutationInspector` (for `RegisteredMutations`
+and `FiringSet`) and `concepts.Previewable` (for `Preview`); `goldengen.Component` requires the equivalent on a
+`*component.Component`. All built-in primitives satisfy both through `generic.BaseResource`, and a built component
+satisfies them by aggregating its resources. For custom resources, see [Custom Resources](custom-resource.md) for how to
+implement `MutationInspector`.
### Run the sweep
@@ -137,8 +304,8 @@ Wire a `-update` flag through `WithUpdate` and call `Run` from a normal test:
var update = flag.Bool("update", false, "update golden files")
func TestVersionMatrix(t *testing.T) {
- gen.WithUpdate(*update)
- gen.Run(t)
+ gen.WithUpdate(*update)
+ gen.Run(t)
}
```
@@ -146,8 +313,8 @@ func TestVersionMatrix(t *testing.T) {
compares one golden per regime plus the manifest. Generate the goldens once, inspect them, then commit:
```bash
-go test ./examples/version-matrix/ -run TestVersionMatrix -update # generate
-go test ./examples/version-matrix/ # verify
+go test ./examples/version-matrix/ -run TestVersionMatrix -update
+go test ./examples/version-matrix/
```
### Firing-set classification
@@ -156,26 +323,26 @@ The firing set at a version is the set of registered mutations whose gate is ena
fires unconditionally). A **regime** is a maximal group of swept versions sharing an identical firing set. `goldengen`
writes one golden per regime, named after the regime's representative, instead of one golden per version.
-In the example, the universe `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes:
+In the example, the universe `1.0.0`, `1.5.0`, `2.0.0` collapses to two regimes:
```mermaid
flowchart LR
- v1["8.7.0"] --> r1
- v2["8.8.2"] --> r1
- v3["8.9.0"] --> r2
- r1["regime: ContainerImage + ClusterEnv/Pre89
golden: default/8.7.0.yaml"]
- r2["regime: ContainerImage + ClusterEnv/Unified89
golden: default/8.9.0.yaml"]
+ v1["1.0.0"] --> r1
+ v2["1.5.0"] --> r1
+ v3["2.0.0"] --> r2
+ r1["regime: ContainerImage + PeerDiscovery/PreV2
golden: default/1.0.0.yaml"]
+ r2["regime: ContainerImage + PeerDiscovery/V2
golden: default/2.0.0.yaml"]
```
-`8.7.0` and `8.8.2` fire the same set, so they share one golden; `8.9.0` crosses the `ClusterEnv` boundary into its own
-regime. Two goldens cover three versions, and adding more versions inside an existing regime adds no goldens.
+`1.0.0` and `1.5.0` fire the same set, so they share one golden; `2.0.0` crosses the `PeerDiscovery` boundary into its
+own regime. Two goldens cover three versions, and adding more versions inside an existing regime adds no goldens.
### Version ordering
The representative of a regime is the first version in supplied order that belongs to it. Listing `Versions` ascending
therefore puts each representative on the **lower inclusive boundary** of its gating range, so the golden's filename
-marks exactly where the regime begins. In the example, `default/8.9.0.yaml` is named for the first version at which the
-unified-discovery regime takes effect. List versions ascending unless you have a specific reason not to.
+marks exactly where the regime begins. In the example, `default/2.0.0.yaml` is named for the first version at which the
+newer peer-discovery regime takes effect. List versions ascending unless you have a specific reason not to.
### The four assertions
@@ -189,8 +356,8 @@ set it must be a version drawn from `Versions`.
| `Forbids{Name}` | no | the mutation fires at **no** swept version |
| `Forbids{Name, For}` | yes | the mutation **does not** fire at that version |
-Pin both sides of a boundary to assert it precisely: in the example `ClusterEnv/Unified89` is required at `8.9.0` and
-forbidden at `8.8.2`, which locks the gate to exactly the `8.9.0` boundary rather than merely "fires somewhere".
+Pin both sides of a boundary to assert it precisely: in the example `PeerDiscovery/V2` is required at `2.0.0` and
+forbidden at `1.5.0`, which locks the gate to exactly the `2.0.0` boundary rather than merely "fires somewhere".
### Completeness accounting
@@ -199,7 +366,19 @@ forbidden at `8.8.2`, which locks the gate to exactly the `8.9.0` boundary rathe
```go
func TestMain(m *testing.M) {
- os.Exit(gen.AssertComplete(m.Run()))
+ os.Exit(gen.AssertComplete(m.Run()))
+}
+```
+
+With more than one generator in a package (say a resource matrix and a component matrix), there is still one `TestMain`;
+chain the accounting so a violation in either fails the package:
+
+```go
+func TestMain(m *testing.M) {
+ code := m.Run()
+ code = resourceGen.AssertComplete(code)
+ code = componentGen.AssertComplete(code)
+ os.Exit(code)
}
```
@@ -215,6 +394,21 @@ violations are:
The effect: registering a new version-gated mutation fails the suite until you either assert it with a `Requires` or
deliberately set it aside with `Exclude`.
+`AssertComplete` checks coverage, not firing. It confirms every registered mutation is named in a `Requires` or
+`Exclude`; it never evaluates whether a mutation fired. Firing is verified separately, when `Run` checks each `Requires`
+during the sweep. The two compose: `AssertComplete` forces every mutation to be asserted, and the `Requires` it forces
+you to write then proves the mutation actually fires.
+
+| Check | Runs | Fails when |
+| ---------------- | ---------------- | ------------------------------------------------------------ |
+| `Requires{Name}` | during the sweep | the named mutation does **not** fire |
+| `Forbids{Name}` | during the sweep | the named mutation **does** fire |
+| `AssertComplete` | from `TestMain` | a registered mutation is in neither `Requires` nor `Exclude` |
+
+`Requires` and `Forbids` assert behavior (firing); `AssertComplete` asserts coverage, on registration. Nothing fails
+merely because a mutation fired without a matching `Requires`. The coverage net is registration-based: every registered
+mutation must be required or excluded.
+
### The manifest
Alongside the goldens, `Run` writes `/manifest.yaml`, a reviewable coverage map: per fixture, each regime with its
@@ -224,19 +418,19 @@ representative version, the versions it covers, and the shared firing set.
fixtures:
- name: default
regimes:
- - representative: 8.7.0
+ - representative: 1.0.0
versions:
- - 8.7.0
- - 8.8.2
+ - 1.0.0
+ - 1.5.0
firing:
- - ClusterEnv/Pre89
- ContainerImage
- - representative: 8.9.0
+ - PeerDiscovery/PreV2
+ - representative: 2.0.0
versions:
- - 8.9.0
+ - 2.0.0
firing:
- - ClusterEnv/Unified89
- ContainerImage
+ - PeerDiscovery/V2
```
Reviewing the manifest diff in a pull request shows at a glance how the gating coverage changed: a new regime, a moved
@@ -249,15 +443,17 @@ function stays in code. `LoadMatrix` reads the file and returns a ready-to-run `
```go
func LoadMatrix[T any](
- path string,
- newSpec func() T,
- build func(version string, spec T) (Unit, error),
+ path string,
+ newSpec func() T,
+ build func(version string, spec T) (Unit, error),
) (Config[T], error)
```
-`newSpec` returns a fresh, empty spec to unmarshal each fixture into; `build` is the same callback you would set on a Go
-`Config`, and it supplies the scheme by passing it to `goldengen.Resource` or `goldengen.Component`. The returned config
-is validated before it is returned.
+`newSpec` returns a fresh, empty spec to unmarshal a fixture into, called once per fixture at load time, not per build.
+`build` is the same callback you would set on a Go `Config`, including the deep copy: it receives the loaded fixture
+spec, which `goldengen` reuses across every version in the sweep, so it must copy the spec before setting the version,
+exactly as the [Go `Config.Build`](#declare-the-matrix) does. It supplies the scheme by passing the built unit through
+`goldengen.Resource` or `goldengen.Component`. The returned config is validated before it is returned.
A matrix file mirrors `Config` minus the Go-only `build`. Each fixture supplies its spec either inline under `spec:` or
from an external file under `specFile:` (resolved relative to the matrix file), exactly one of the two:
@@ -265,9 +461,9 @@ from an external file under `specFile:` (resolved relative to the matrix file),
```yaml
dir: testdata/version_matrix
versions:
- - "8.7.0"
- - "8.8.2"
- - "8.9.0"
+ - "1.0.0"
+ - "1.5.0"
+ - "2.0.0"
exclude: []
fixtures:
- name: default
@@ -278,13 +474,13 @@ fixtures:
name: demo
namespace: default
spec:
- version: 8.7.0
+ version: 1.0.0
requires:
- { name: ContainerImage }
- - { name: ClusterEnv/Pre89, for: "8.8.2" }
- - { name: ClusterEnv/Unified89, for: "8.9.0" }
+ - { name: PeerDiscovery/PreV2, for: "1.5.0" }
+ - { name: PeerDiscovery/V2, for: "2.0.0" }
forbids:
- - { name: ClusterEnv/Unified89, for: "8.8.2" }
+ - { name: PeerDiscovery/V2, for: "1.5.0" }
- name: tls
specFile: fixtures/tls.yaml # external custom resource
requires:
@@ -292,14 +488,35 @@ fixtures:
```
```go
-cfg, err := goldengen.LoadMatrix("testdata/matrix.yaml",
- func() *app.ExampleApp { return &app.ExampleApp{} },
- buildUnit)
+// buildUnit is the same function you would set as Config.Build: it copies the
+// loaded spec (shared across the sweep), applies the version, builds the
+// resource, and wraps it as a Unit.
+func buildUnit(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ c := spec.DeepCopyObject().(*app.ExampleApp)
+ c.Spec.Version = version
+ res, err := resources.NewStatefulSetResource(c)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res, scheme), nil
+}
+
+cfg, err := goldengen.LoadMatrix(
+ "testdata/matrix.yaml",
+ func() *app.ExampleApp { return &app.ExampleApp{} },
+ buildUnit,
+)
require.NoError(t, err)
gen := goldengen.New(cfg).WithUpdate(*update)
gen.Run(t)
```
+`LoadMatrix` does not call `buildUnit` itself. It loads the fixtures and versions from the file and stores `buildUnit`
+as the config's `Build` field, then the config runs exactly like one declared in Go: `goldengen.New(cfg)` wraps it, and
+`gen.Run` calls `buildUnit(version, spec)` for each version and fixture during the sweep, passing the spec it
+unmarshaled from the file. The YAML supplies the data (specs, versions, expectations); `buildUnit` supplies the build
+logic.
+
`LoadMatrix` errors if a fixture sets both `spec` and `specFile` or neither, if a `for` value is not in `versions`, or
if any spec fails to unmarshal into `T`.
diff --git a/examples/component-prerequisites/app/component_test.go b/examples/component-prerequisites/app/component_test.go
new file mode 100644
index 00000000..7ffe2f63
--- /dev/null
+++ b/examples/component-prerequisites/app/component_test.go
@@ -0,0 +1,71 @@
+package app_test
+
+import (
+ "flag"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/app"
+ "github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+)
+
+// update is this package's own -update flag. The resources package declares its
+// own; the two live in separate test binaries, so there is no conflict.
+var update = flag.Bool("update", false, "update golden files")
+
+// scheme resolves TypeMeta for the rendered resources in the component stream.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the core and apps Kubernetes types registered.
+func newScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(s); err != nil {
+ panic(err)
+ }
+ return s
+}
+
+func testOwner() *sharedapp.ExampleApp {
+ owner := &sharedapp.ExampleApp{
+ Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"},
+ }
+ owner.Name = "my-app"
+ owner.Namespace = "default"
+ return owner
+}
+
+func testController() *app.Controller {
+ return &app.Controller{
+ NewConfigMapResource: resources.NewConfigMapResource,
+ NewDeploymentResource: resources.NewDeploymentResource,
+ }
+}
+
+// TestBuildInfraComponent goldens the infra component the controller reconciles
+// first: a single ConfigMap with no prerequisites. The controller and this test
+// build the component the same way, so the reconciled component and the snapshot
+// stay in lockstep.
+func TestBuildInfraComponent(t *testing.T) {
+ comp, err := testController().BuildInfraComponent(testOwner())
+ require.NoError(t, err)
+
+ golden.AssertComponentYAML(t, "testdata/infra-component.yaml", comp,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
+
+// TestBuildAppComponent goldens the app component the controller reconciles
+// second: a Deployment gated behind the InfraReady prerequisite. The DependsOn
+// ordering is the point of this example. The controller and this test build the
+// component the same way, so the reconciled component and the snapshot stay in
+// lockstep.
+func TestBuildAppComponent(t *testing.T) {
+ comp, err := testController().BuildAppComponent(testOwner())
+ require.NoError(t, err)
+
+ golden.AssertComponentYAML(t, "testdata/app-component.yaml", comp,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
diff --git a/examples/component-prerequisites/app/controller.go b/examples/component-prerequisites/app/controller.go
index f760a41e..96486101 100644
--- a/examples/component-prerequisites/app/controller.go
+++ b/examples/component-prerequisites/app/controller.go
@@ -48,40 +48,55 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
}()
// --- Infra component: no prerequisites ---
- cmResource, err := r.NewConfigMapResource(owner)
+ infra, err := r.BuildInfraComponent(owner)
if err != nil {
return err
}
- infra, err := component.NewComponentBuilder().
- WithName("infra").
- WithConditionType("InfraReady").
- WithResource(cmResource).
- Build()
- if err != nil {
+ if err := infra.Reconcile(ctx, recCtx); err != nil {
return err
}
- if err := infra.Reconcile(ctx, recCtx); err != nil {
+ // --- App component: depends on InfraReady ---
+ app, err := r.BuildAppComponent(owner)
+ if err != nil {
return err
}
- // --- App component: depends on InfraReady ---
+ return app.Reconcile(ctx, recCtx)
+}
+
+// BuildInfraComponent assembles the infra component: a single ConfigMap reporting
+// the InfraReady condition, with no prerequisites. The controller and tests share
+// this so the reconciled component and the golden snapshot stay in lockstep.
+func (r *Controller) BuildInfraComponent(owner *ExampleApp) (*component.Component, error) {
+ cmResource, err := r.NewConfigMapResource(owner)
+ if err != nil {
+ return nil, err
+ }
+
+ return component.NewComponentBuilder().
+ WithName("infra").
+ WithConditionType("InfraReady").
+ WithResource(cmResource).
+ Build()
+}
+
+// BuildAppComponent assembles the app component: a Deployment reporting the
+// AppReady condition, gated behind the InfraReady prerequisite. The DependsOn
+// prerequisite is the point of this example, so the controller and tests build
+// the component the same way.
+func (r *Controller) BuildAppComponent(owner *ExampleApp) (*component.Component, error) {
deployResource, err := r.NewDeploymentResource(owner)
if err != nil {
- return err
+ return nil, err
}
- app, err := component.NewComponentBuilder().
+ return component.NewComponentBuilder().
WithName("app").
WithConditionType("AppReady").
WithResource(deployResource).
WithPrerequisite(component.DependsOn("InfraReady")).
Suspend(owner.Spec.Suspended).
Build()
- if err != nil {
- return err
- }
-
- return app.Reconcile(ctx, recCtx)
}
diff --git a/examples/component-prerequisites/app/testdata/app-component.yaml b/examples/component-prerequisites/app/testdata/app-component.yaml
new file mode 100644
index 00000000..fa6cf1c8
--- /dev/null
+++ b/examples/component-prerequisites/app/testdata/app-component.yaml
@@ -0,0 +1,21 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ labels:
+ app: my-app
+ name: my-app-app
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: my-app
+ strategy: {}
+ template:
+ metadata:
+ labels:
+ app: my-app
+ spec:
+ containers:
+ - image: my-app:1.0.0
+ name: app
+ resources: {}
diff --git a/examples/component-prerequisites/app/testdata/infra-component.yaml b/examples/component-prerequisites/app/testdata/infra-component.yaml
new file mode 100644
index 00000000..3c257dea
--- /dev/null
+++ b/examples/component-prerequisites/app/testdata/infra-component.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+data:
+ cluster-dns: 10.96.0.10
+kind: ConfigMap
+metadata:
+ labels:
+ app: my-app
+ name: my-app-infra-config
+ namespace: default
diff --git a/examples/component-prerequisites/resources/configmap_test.go b/examples/component-prerequisites/resources/configmap_test.go
index 8c77efa0..84c99744 100644
--- a/examples/component-prerequisites/resources/configmap_test.go
+++ b/examples/component-prerequisites/resources/configmap_test.go
@@ -6,7 +6,6 @@ import (
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
@@ -24,16 +23,17 @@ func testOwner(version string) *sharedapp.ExampleApp {
return owner
}
-// TestConfigMapShape pins the infra component's ConfigMap baseline. Changes
-// to the base object surface as a diff against the golden file.
+// TestConfigMapShape pins the infra component's ConfigMap as built by its
+// factory. The factory registers no mutations, so the golden file captures the
+// full desired state. Changes to the base object surface as a diff.
func TestConfigMapShape(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
owner := testOwner("1.0.0")
- res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build()
+ res, err := resources.NewConfigMapResource(owner)
require.NoError(t, err)
- golden.AssertYAML(t, "testdata/configmap.yaml", res,
+ golden.AssertYAML(t, "testdata/configmap.yaml", res.(golden.Previewer),
golden.WithScheme(scheme), golden.Update(*update))
}
diff --git a/examples/component-prerequisites/resources/deployment_test.go b/examples/component-prerequisites/resources/deployment_test.go
index 35822ba4..396ddea5 100644
--- a/examples/component-prerequisites/resources/deployment_test.go
+++ b/examples/component-prerequisites/resources/deployment_test.go
@@ -1,24 +1,39 @@
package resources_test
import (
- "fmt"
"testing"
"github.com/sourcehawk/operator-component-framework/examples/component-prerequisites/resources"
- "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"
+ "github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/runtime"
)
-// TestDeploymentShape verifies the app component's Deployment for each version.
-// The baseline carries the latest container layout; the version mutation sets
-// the image tag. Golden files for each version catch regressions when the
-// baseline or mutation logic changes.
+// TestDeploymentMutations verifies the factory registers the Version mutation
+// and that it fires at every version. The version gate has no constraint, so
+// the mutation always applies and rewrites the image tag.
+func TestDeploymentMutations(t *testing.T) {
+ owner := testOwner("1.0.0")
+ res, err := resources.NewDeploymentResource(owner)
+ require.NoError(t, err)
+
+ inspector, ok := res.(concepts.MutationInspector)
+ require.True(t, ok)
+
+ assert.ElementsMatch(t, []string{"Version"}, inspector.RegisteredMutations())
+
+ firing, err := inspector.FiringSet()
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"Version"}, firing)
+}
+
+// TestDeploymentShape verifies the app component's Deployment as built by its
+// factory for each version. The baseline carries the latest container layout;
+// the Version mutation sets the image tag. Golden files for each version catch
+// regressions when the baseline or mutation logic changes.
func TestDeploymentShape(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, appsv1.AddToScheme(scheme))
@@ -44,22 +59,11 @@ func TestDeploymentShape(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
owner := testOwner(tt.version)
- res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).
- WithMutation(deployment.Mutation{
- Name: "Version",
- Feature: feature.NewVersionGate(tt.version, nil),
- Mutate: func(m *deployment.Mutator) error {
- m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error {
- ce.Raw().Image = fmt.Sprintf("my-app:%s", tt.version)
- return nil
- })
- return nil
- },
- }).
- Build()
+ res, err := resources.NewDeploymentResource(owner)
require.NoError(t, err)
- golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update))
+ golden.AssertYAML(t, tt.golden, res.(golden.Previewer),
+ golden.WithScheme(scheme), golden.Update(*update))
})
}
}
diff --git a/examples/custom-resource/resources/certificate_test.go b/examples/custom-resource/resources/certificate_test.go
index c58e06b4..fed35b59 100644
--- a/examples/custom-resource/resources/certificate_test.go
+++ b/examples/custom-resource/resources/certificate_test.go
@@ -6,10 +6,10 @@ import (
"github.com/sourcehawk/operator-component-framework/examples/custom-resource/resources"
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
- "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors"
- unstruct "github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured"
+ "github.com/sourcehawk/operator-component-framework/pkg/component/concepts"
"github.com/sourcehawk/operator-component-framework/pkg/primitives/unstructured/static"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
@@ -25,41 +25,31 @@ func testOwner() *sharedapp.ExampleApp {
return owner
}
-// TestCertificateShape verifies the CertificateRequest's shape after mutations
-// set spec fields (issuerRef, dnsNames) and metadata labels. The golden file
-// catches regressions when the mutation logic or base object changes.
-func TestCertificateShape(t *testing.T) {
- owner := testOwner()
+// TestCertificateMutations verifies the factory registers the certificate-spec
+// mutation and that it fires. The mutation has no gate, so it always applies.
+func TestCertificateMutations(t *testing.T) {
+ res, err := resources.NewCertificateResource(testOwner())
+ require.NoError(t, err)
+
+ inspector, ok := res.(concepts.MutationInspector)
+ require.True(t, ok)
+
+ assert.ElementsMatch(t, []string{"certificate-spec"}, inspector.RegisteredMutations())
- res, err := static.NewBuilder(resources.BaseCertificateRequest(owner)).
- WithMutation(unstruct.Mutation{
- Name: "certificate-spec",
- Mutate: func(m *unstruct.Mutator) error {
- m.EditContent(func(e *editors.UnstructuredContentEditor) error {
- if err := e.SetNestedString("letsencrypt-prod", "spec", "issuerRef", "name"); err != nil {
- return err
- }
- if err := e.SetNestedString("ClusterIssuer", "spec", "issuerRef", "kind"); err != nil {
- return err
- }
- return e.SetNestedSlice(
- []interface{}{owner.Name + ".example.com"},
- "spec", "dnsNames",
- )
- })
-
- m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error {
- meta.EnsureLabel("app", owner.Name)
- return nil
- })
-
- return nil
- },
- }).
- Build()
+ firing, err := inspector.FiringSet()
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"certificate-spec"}, firing)
+}
+
+// TestCertificateShape verifies the CertificateRequest as built by its factory,
+// after the certificate-spec mutation sets spec fields (issuerRef, dnsNames)
+// and metadata labels. The golden file catches regressions when the mutation
+// logic or base object changes.
+func TestCertificateShape(t *testing.T) {
+ res, err := resources.NewCertificateResource(testOwner())
require.NoError(t, err)
- golden.AssertYAML(t, "testdata/certificate.yaml", res, golden.Update(*update))
+ golden.AssertYAML(t, "testdata/certificate.yaml", res.(golden.Previewer), golden.Update(*update))
}
// TestCertificateBaseShape pins the bare base object before any mutations.
diff --git a/examples/extraction-and-guards/app/component_test.go b/examples/extraction-and-guards/app/component_test.go
new file mode 100644
index 00000000..78126da2
--- /dev/null
+++ b/examples/extraction-and-guards/app/component_test.go
@@ -0,0 +1,59 @@
+package app_test
+
+import (
+ "flag"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/app"
+ "github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources"
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+)
+
+// update is this package's own -update flag. The resources package declares its
+// own; the two live in separate test binaries, so there is no conflict.
+var update = flag.Bool("update", false, "update golden files")
+
+// scheme resolves TypeMeta for the rendered resources in the component stream.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the core and apps Kubernetes types registered.
+func newScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(s); err != nil {
+ panic(err)
+ }
+ return s
+}
+
+func testOwner() *sharedapp.ExampleApp {
+ owner := &sharedapp.ExampleApp{
+ Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"},
+ }
+ owner.Name = "my-app"
+ owner.Namespace = "default"
+ return owner
+}
+
+// TestBuildComponent goldens the whole component the controller reconciles. The
+// point of this example is data extraction feeding a guard: the ConfigMap is
+// registered before the Secret, and BuildComponent owns the shared dbHost pointer
+// that wires the extractor to the guard. The multi-document golden pins the
+// rendered desired state of both resources, in the order the component applies
+// them. The controller and this test build the component the same way, so the
+// reconciled component and the snapshot stay in lockstep.
+func TestBuildComponent(t *testing.T) {
+ controller := &app.Controller{
+ NewConfigMapResource: resources.NewConfigMapResource,
+ NewSecretResource: resources.NewSecretResource,
+ }
+
+ comp, err := controller.BuildComponent(testOwner())
+ require.NoError(t, err)
+
+ golden.AssertComponentYAML(t, "testdata/component.yaml", comp,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
diff --git a/examples/extraction-and-guards/app/controller.go b/examples/extraction-and-guards/app/controller.go
index 31aae777..13f16c9a 100644
--- a/examples/extraction-and-guards/app/controller.go
+++ b/examples/extraction-and-guards/app/controller.go
@@ -44,28 +44,37 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
}
}()
+ comp, err := r.BuildComponent(owner)
+ if err != nil {
+ return err
+ }
+
+ return comp.Reconcile(ctx, recCtx)
+}
+
+// BuildComponent assembles the database component: a ConfigMap registered before
+// a Secret, both wired to a shared dbHost pointer. The ConfigMap extractor writes
+// the pointer and the Secret guard reads it, so registration order matters. The
+// controller and tests share this assembly so the reconciled component and the
+// golden snapshot stay in lockstep.
+func (r *Controller) BuildComponent(owner *ExampleApp) (*component.Component, error) {
// Shared state: the ConfigMap extractor writes here, the Secret guard reads it.
var dbHost string
cmResource, err := r.NewConfigMapResource(owner, &dbHost)
if err != nil {
- return err
+ return nil, err
}
secretResource, err := r.NewSecretResource(owner, &dbHost)
if err != nil {
- return err
+ return nil, err
}
- comp, err := component.NewComponentBuilder().
+ return component.NewComponentBuilder().
WithName("database").
WithConditionType("DatabaseReady").
WithResource(cmResource).
WithResource(secretResource).
Build()
- if err != nil {
- return err
- }
-
- return comp.Reconcile(ctx, recCtx)
}
diff --git a/examples/extraction-and-guards/app/testdata/component.yaml b/examples/extraction-and-guards/app/testdata/component.yaml
new file mode 100644
index 00000000..4a694ea9
--- /dev/null
+++ b/examples/extraction-and-guards/app/testdata/component.yaml
@@ -0,0 +1,21 @@
+apiVersion: v1
+data:
+ db-host: postgres.default.svc
+ db-port: "5432"
+kind: ConfigMap
+metadata:
+ labels:
+ app: my-app
+ name: my-app-db-config
+ namespace: default
+---
+apiVersion: v1
+data:
+ password: Y2hhbmdlbWU=
+ username: YXBwLXVzZXI=
+kind: Secret
+metadata:
+ labels:
+ app: my-app
+ name: my-app-db-credentials
+ namespace: default
diff --git a/examples/extraction-and-guards/resources/configmap_test.go b/examples/extraction-and-guards/resources/configmap_test.go
index 4d293e5f..fa59f3e4 100644
--- a/examples/extraction-and-guards/resources/configmap_test.go
+++ b/examples/extraction-and-guards/resources/configmap_test.go
@@ -6,7 +6,6 @@ import (
"github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources"
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
@@ -24,17 +23,19 @@ func testOwner() *sharedapp.ExampleApp {
return owner
}
-// TestConfigMapShape pins the database config ConfigMap's baseline shape.
-// If the base object changes (e.g. new keys added or defaults changed), the
-// golden file catches it so the change is reviewed explicitly.
+// TestConfigMapShape pins the database config ConfigMap as built by its factory.
+// The factory registers a data extractor but no mutations, so the golden file
+// captures the full desired state. If the base object changes (e.g. new keys
+// added or defaults changed), the golden file catches it.
func TestConfigMapShape(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
owner := testOwner()
- res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).Build()
+ var dbHost string
+ res, err := resources.NewConfigMapResource(owner, &dbHost)
require.NoError(t, err)
- golden.AssertYAML(t, "testdata/configmap.yaml", res,
+ golden.AssertYAML(t, "testdata/configmap.yaml", res.(golden.Previewer),
golden.WithScheme(scheme), golden.Update(*update))
}
diff --git a/examples/extraction-and-guards/resources/secret_test.go b/examples/extraction-and-guards/resources/secret_test.go
index 74017c16..6fc151a8 100644
--- a/examples/extraction-and-guards/resources/secret_test.go
+++ b/examples/extraction-and-guards/resources/secret_test.go
@@ -4,24 +4,25 @@ import (
"testing"
"github.com/sourcehawk/operator-component-framework/examples/extraction-and-guards/resources"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
)
-// TestSecretShape pins the database credentials Secret's baseline shape.
-// The guard is not exercised here; this test only verifies the resource's
-// desired state before reconciliation.
+// TestSecretShape pins the database credentials Secret as built by its factory.
+// The factory registers a guard but no mutations, so the golden file captures
+// the full desired state. The guard is not exercised here; this test only
+// verifies the resource's desired state before reconciliation.
func TestSecretShape(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
owner := testOwner()
- res, err := secret.NewBuilder(resources.BaseSecret(owner)).Build()
+ var dbHost string
+ res, err := resources.NewSecretResource(owner, &dbHost)
require.NoError(t, err)
- golden.AssertYAML(t, "testdata/secret.yaml", res,
+ golden.AssertYAML(t, "testdata/secret.yaml", res.(golden.Previewer),
golden.WithScheme(scheme), golden.Update(*update))
}
diff --git a/examples/grace-inconsistency/app/component_test.go b/examples/grace-inconsistency/app/component_test.go
new file mode 100644
index 00000000..589df65d
--- /dev/null
+++ b/examples/grace-inconsistency/app/component_test.go
@@ -0,0 +1,58 @@
+package app_test
+
+import (
+ "flag"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/app"
+ "github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources"
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+)
+
+// update is this package's own -update flag. The resources package declares its
+// own; the two live in separate test binaries, so there is no conflict.
+var update = flag.Bool("update", false, "update golden files")
+
+// scheme resolves TypeMeta for the rendered resources in the component stream.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the core and apps Kubernetes types registered.
+func newScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(s); err != nil {
+ panic(err)
+ }
+ return s
+}
+
+func testOwner() *sharedapp.ExampleApp {
+ owner := &sharedapp.ExampleApp{
+ Spec: sharedapp.ExampleAppSpec{Version: "1.0.0"},
+ }
+ owner.Name = "my-app"
+ owner.Namespace = "default"
+ return owner
+}
+
+// TestBuildComponent goldens the whole component the controller reconciles. The
+// point of this example is grace-inconsistency suppression: the component carries
+// a grace period and registers its Deployment with the
+// SuppressGraceInconsistencyWarning resource option. The multi-document golden
+// pins the rendered desired state of the resource the component applies. The
+// controller and this test build the component the same way, so the reconciled
+// component and the snapshot stay in lockstep.
+func TestBuildComponent(t *testing.T) {
+ controller := &app.Controller{
+ NewDeploymentResource: resources.NewDeploymentResource,
+ }
+
+ comp, err := controller.BuildComponent(testOwner())
+ require.NoError(t, err)
+
+ golden.AssertComponentYAML(t, "testdata/component.yaml", comp,
+ golden.WithScheme(scheme), golden.Update(*update))
+}
diff --git a/examples/grace-inconsistency/app/controller.go b/examples/grace-inconsistency/app/controller.go
index da8d6d8f..7d76f973 100644
--- a/examples/grace-inconsistency/app/controller.go
+++ b/examples/grace-inconsistency/app/controller.go
@@ -40,12 +40,27 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
}
}()
- deployResource, err := r.NewDeploymentResource(owner)
+ comp, err := r.BuildComponent(owner)
if err != nil {
return err
}
- comp, err := component.NewComponentBuilder().
+ return comp.Reconcile(ctx, recCtx)
+}
+
+// BuildComponent assembles the monitoring component: a Deployment whose custom
+// grace handler reports Healthy while the convergence handler may report
+// non-healthy. The grace period and the SuppressGraceInconsistencyWarning option
+// are the point of this example, so the controller and tests build the component
+// the same way to keep the reconciled component and the golden snapshot in
+// lockstep.
+func (r *Controller) BuildComponent(owner *ExampleApp) (*component.Component, error) {
+ deployResource, err := r.NewDeploymentResource(owner)
+ if err != nil {
+ return nil, err
+ }
+
+ return component.NewComponentBuilder().
WithName("monitoring").
WithConditionType("MonitoringReady").
// SuppressGraceInconsistencyWarning tells the framework not to log a
@@ -55,9 +70,4 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
WithResource(deployResource, component.SuppressGraceInconsistencyWarning()).
WithGracePeriod(5 * time.Second).
Build()
- if err != nil {
- return err
- }
-
- return comp.Reconcile(ctx, recCtx)
}
diff --git a/examples/grace-inconsistency/app/testdata/component.yaml b/examples/grace-inconsistency/app/testdata/component.yaml
new file mode 100644
index 00000000..7be53598
--- /dev/null
+++ b/examples/grace-inconsistency/app/testdata/component.yaml
@@ -0,0 +1,23 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ labels:
+ app: my-app
+ name: my-app-monitoring
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: my-app
+ role: monitoring
+ strategy: {}
+ template:
+ metadata:
+ labels:
+ app: my-app
+ role: monitoring
+ spec:
+ containers:
+ - image: prom/node-exporter:v1.3.1
+ name: prometheus-exporter
+ resources: {}
diff --git a/examples/grace-inconsistency/resources/deployment_test.go b/examples/grace-inconsistency/resources/deployment_test.go
index 691f48a3..9e279a08 100644
--- a/examples/grace-inconsistency/resources/deployment_test.go
+++ b/examples/grace-inconsistency/resources/deployment_test.go
@@ -6,7 +6,6 @@ import (
"github.com/sourcehawk/operator-component-framework/examples/grace-inconsistency/resources"
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
"github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
@@ -30,13 +29,15 @@ func testScheme() *runtime.Scheme {
return s
}
-// TestDeploymentShape pins the monitoring Deployment's baseline. This resource
-// has no mutations; changes to the base object surface as a golden file diff.
+// TestDeploymentShape pins the monitoring Deployment as built by its factory.
+// The factory registers a custom grace handler but no mutations, so the golden
+// file captures the full desired state. Changes to the base object surface as a
+// golden file diff.
func TestDeploymentShape(t *testing.T) {
owner := testOwner()
- res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).Build()
+ res, err := resources.NewDeploymentResource(owner)
require.NoError(t, err)
- golden.AssertYAML(t, "testdata/deployment.yaml", res,
+ golden.AssertYAML(t, "testdata/deployment.yaml", res.(golden.Previewer),
golden.WithScheme(testScheme()), golden.Update(*update))
}
diff --git a/examples/mutations-and-gating/README.md b/examples/mutations-and-gating/README.md
index b7cb76d2..485150e7 100644
--- a/examples/mutations-and-gating/README.md
+++ b/examples/mutations-and-gating/README.md
@@ -10,8 +10,8 @@ manages two resources: a Deployment and a ConfigMap.
- **Version-gated backward compat mutation**: `BackwardCompatV1Container` activates for versions `< 2.0.0` and rolls the
baseline back to the v1 layout (container named "server", HTTP port only). Uses a `semver.Constraint` as a
`feature.VersionConstraint`. The `BackwardCompat` prefix makes the pattern immediately recognizable.
-- **Boolean-gated mutation**: `TracingSidecarMutation` injects a Jaeger sidecar. It is gated via `.When(enabled)`, so
- the sidecar is added only when tracing is on and removed when it is off.
+- **Boolean-gated mutation**: `TracingSidecarMutation` injects a Jaeger sidecar. It is gated with
+ `feature.NewBooleanGate(enabled)`, so the sidecar is added only when tracing is on and removed when it is off.
- **Mutation ordering for container name stability**: `DebugLoggingMutation` targets `ContainerNamed("app")` and is
registered before `BackwardCompatV1Container`. This ensures it always sees the baseline name, even though the backward
compat mutation renames the container for older versions. The env var edit carries through the rename because the
diff --git a/examples/mutations-and-gating/app/component_test.go b/examples/mutations-and-gating/app/component_test.go
new file mode 100644
index 00000000..289db6af
--- /dev/null
+++ b/examples/mutations-and-gating/app/component_test.go
@@ -0,0 +1,108 @@
+package app_test
+
+import (
+ "flag"
+ "os"
+ "testing"
+
+ "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/app"
+ "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources"
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+)
+
+// update is this package's own -update flag. The resources package declares its
+// own; the two live in separate test binaries, so there is no conflict.
+var update = flag.Bool("update", false, "update golden files")
+
+// scheme resolves TypeMeta for the rendered resources in the component stream.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the core and apps Kubernetes types registered.
+func newScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(s); err != nil {
+ panic(err)
+ }
+ return s
+}
+
+// owner returns a fixture owner. The Build function overwrites Spec.Version per
+// version, so the Version set on a fixture spec is just a placeholder.
+func owner(spec sharedapp.ExampleAppSpec) *app.ExampleApp {
+ o := &app.ExampleApp{Spec: spec}
+ o.Name = "my-app"
+ o.Namespace = "default"
+ return o
+}
+
+// gen declares the component matrix. It builds the whole component the controller
+// reconciles (via the shared app.BuildComponent) and goldens the multi-document
+// YAML of every resource it would apply.
+//
+// The component aggregates the registered mutations of both resources, so its four
+// registered mutations are BackwardCompatV1Container, DebugLogging, and Tracing from
+// the Deployment plus metrics-config from the ConfigMap. The "all" fixture turns
+// every flag on at a pre-2.0.0 version so all four fire and are accounted for; the
+// "minimal" fixture turns them off at 2.0.0 and forbids them, pinning the boundary
+// from the other side.
+var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{
+ Dir: "testdata/component",
+ Versions: []string{"1.9.0", "2.0.0"},
+ Fixtures: []goldengen.Fixture[*app.ExampleApp]{
+ {
+ Name: "all",
+ Spec: owner(sharedapp.ExampleAppSpec{
+ EnableDebugLogging: true,
+ EnableTracing: true,
+ EnableMetrics: true,
+ }),
+ Requires: []goldengen.Expect{
+ {Name: "DebugLogging"},
+ {Name: "Tracing"},
+ {Name: "metrics-config"},
+ {Name: "BackwardCompatV1Container", For: "1.9.0"}, // legacy container before 2.0.0
+ },
+ Forbids: []goldengen.Expect{
+ {Name: "BackwardCompatV1Container", For: "2.0.0"}, // not from 2.0.0 onward
+ },
+ },
+ {
+ Name: "minimal",
+ Spec: owner(sharedapp.ExampleAppSpec{}),
+ Forbids: []goldengen.Expect{
+ {Name: "DebugLogging"},
+ {Name: "Tracing"},
+ {Name: "metrics-config"},
+ {Name: "BackwardCompatV1Container", For: "2.0.0"},
+ },
+ },
+ },
+ Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
+ o := spec.DeepCopyObject().(*app.ExampleApp)
+ o.Spec.Version = version
+ comp, err := app.BuildComponent(o, resources.NewDeploymentResource, resources.NewConfigMapResource)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Component(comp, scheme), nil
+ },
+})
+
+// TestBuildComponentVersionMatrix runs the component sweep: it asserts the gating
+// expectations across the composed desired state and writes or compares one golden
+// per regime plus the coverage manifest. Unlike the resource-level matrices, which
+// pin one resource at a time, this goldens the Deployment and ConfigMap rendered
+// together, in the order the component applies them.
+func TestBuildComponentVersionMatrix(t *testing.T) {
+ gen.WithUpdate(*update)
+ gen.Run(t)
+}
+
+// TestMain runs the package tests, then proves every registered mutation across the
+// composed component is required or excluded before reporting the exit code.
+func TestMain(m *testing.M) {
+ os.Exit(gen.AssertComplete(m.Run()))
+}
diff --git a/examples/mutations-and-gating/app/controller.go b/examples/mutations-and-gating/app/controller.go
index 09d09d44..7bbd6ed3 100644
--- a/examples/mutations-and-gating/app/controller.go
+++ b/examples/mutations-and-gating/app/controller.go
@@ -38,28 +38,47 @@ func (r *Controller) Reconcile(ctx context.Context, owner *ExampleApp) (err erro
}
}()
- deployResource, err := r.NewDeploymentResource(owner)
+ comp, err := BuildComponent(owner, r.NewDeploymentResource, r.NewConfigMapResource)
if err != nil {
return err
}
- cmResource, err := r.NewConfigMapResource(owner)
+ return comp.Reconcile(ctx, recCtx)
+}
+
+// BuildComponent assembles the reconciled component for the given owner from the
+// supplied resource factories. It is the single source of truth for how the
+// Deployment and ConfigMap are composed into one component, shared by the
+// controller's reconcile path and by component-level golden tests so both
+// exercise the exact same assembly.
+//
+// The factories are passed in rather than imported so this package stays free of
+// a dependency on the resources package (which already imports this one). The
+// controller injects its production factories; tests pass the same ones via
+// resources.NewDeploymentResource / resources.NewConfigMapResource.
+//
+// The ConfigMap is gated at the resource level: when metrics are disabled the
+// framework deletes it.
+func BuildComponent(
+ owner *ExampleApp,
+ newDeployment func(*ExampleApp) (component.Resource, error),
+ newConfigMap func(*ExampleApp) (component.Resource, error),
+) (*component.Component, error) {
+ deployResource, err := newDeployment(owner)
if err != nil {
- return err
+ return nil, err
+ }
+
+ cmResource, err := newConfigMap(owner)
+ if err != nil {
+ return nil, err
}
- comp, err := component.NewComponentBuilder().
+ return component.NewComponentBuilder().
WithName("example-app").
WithConditionType("AppReady").
WithResource(deployResource).
- // Gate the ConfigMap at the resource level: when metrics are disabled the
- // framework deletes the ConfigMap.
WithResource(cmResource, component.DeleteWhen(!owner.Spec.EnableMetrics)).
Suspend(owner.Spec.Suspended).
Build()
- if err != nil {
- return err
- }
-
- return comp.Reconcile(ctx, recCtx)
}
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml b/examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml
similarity index 73%
rename from examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml
rename to examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml
index 557acf1d..20a90330 100644
--- a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing-debug.yaml
+++ b/examples/mutations-and-gating/app/testdata/component/all/1.9.0.yaml
@@ -33,3 +33,20 @@ spec:
image: jaegertracing/jaeger-agent:1.28
name: jaeger-agent
resources: {}
+---
+apiVersion: v1
+data:
+ app.yaml: |
+ metrics:
+ enabled: true
+ path: /metrics
+ port: 9090
+ server:
+ port: 8080
+ timeout: 30s
+kind: ConfigMap
+metadata:
+ labels:
+ app: my-app
+ name: my-app-config
+ namespace: default
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml b/examples/mutations-and-gating/app/testdata/component/all/2.0.0.yaml
similarity index 74%
rename from examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml
rename to examples/mutations-and-gating/app/testdata/component/all/2.0.0.yaml
index 31da05b2..aac67ecd 100644
--- a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing-debug.yaml
+++ b/examples/mutations-and-gating/app/testdata/component/all/2.0.0.yaml
@@ -35,3 +35,20 @@ spec:
image: jaegertracing/jaeger-agent:1.28
name: jaeger-agent
resources: {}
+---
+apiVersion: v1
+data:
+ app.yaml: |
+ metrics:
+ enabled: true
+ path: /metrics
+ port: 9090
+ server:
+ port: 8080
+ timeout: 30s
+kind: ConfigMap
+metadata:
+ labels:
+ app: my-app
+ name: my-app-config
+ namespace: default
diff --git a/examples/mutations-and-gating/app/testdata/component/manifest.yaml b/examples/mutations-and-gating/app/testdata/component/manifest.yaml
new file mode 100644
index 00000000..24074e6d
--- /dev/null
+++ b/examples/mutations-and-gating/app/testdata/component/manifest.yaml
@@ -0,0 +1,29 @@
+fixtures:
+- name: all
+ regimes:
+ - firing:
+ - BackwardCompatV1Container
+ - DebugLogging
+ - Tracing
+ - metrics-config
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - firing:
+ - DebugLogging
+ - Tracing
+ - metrics-config
+ representative: 2.0.0
+ versions:
+ - 2.0.0
+- name: minimal
+ regimes:
+ - firing:
+ - BackwardCompatV1Container
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - firing: null
+ representative: 2.0.0
+ versions:
+ - 2.0.0
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1.yaml b/examples/mutations-and-gating/app/testdata/component/minimal/1.9.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v1.yaml
rename to examples/mutations-and-gating/app/testdata/component/minimal/1.9.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2.yaml b/examples/mutations-and-gating/app/testdata/component/minimal/2.0.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v2.yaml
rename to examples/mutations-and-gating/app/testdata/component/minimal/2.0.0.yaml
diff --git a/examples/mutations-and-gating/features/debug_logging.go b/examples/mutations-and-gating/features/debug_logging.go
index d58c3b24..02af9743 100644
--- a/examples/mutations-and-gating/features/debug_logging.go
+++ b/examples/mutations-and-gating/features/debug_logging.go
@@ -16,7 +16,7 @@ import (
func DebugLoggingMutation(enabled bool) deployment.Mutation {
return deployment.Mutation{
Name: "DebugLogging",
- Feature: feature.NewVersionGate("any", nil).When(enabled),
+ 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"})
diff --git a/examples/mutations-and-gating/features/debug_logging_test.go b/examples/mutations-and-gating/features/debug_logging_test.go
index 90f7877d..b7edb7d6 100644
--- a/examples/mutations-and-gating/features/debug_logging_test.go
+++ b/examples/mutations-and-gating/features/debug_logging_test.go
@@ -8,27 +8,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestDebugLoggingMutation verifies that the mutation sets LOG_LEVEL=debug
// on the application container.
func TestDebugLoggingMutation(t *testing.T) {
- base := &appsv1.Deployment{
- ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
- Spec: appsv1.DeploymentSpec{
- Template: corev1.PodTemplateSpec{
- Spec: corev1.PodSpec{
- Containers: []corev1.Container{
- {Name: "app"},
- },
- },
- },
- },
- }
-
- res, err := deployment.NewBuilder(base).
+ res, err := deployment.NewBuilder(baseDeployment()).
WithMutation(features.DebugLoggingMutation(true)).
Build()
require.NoError(t, err)
diff --git a/examples/mutations-and-gating/features/helpers_test.go b/examples/mutations-and-gating/features/helpers_test.go
new file mode 100644
index 00000000..60365de3
--- /dev/null
+++ b/examples/mutations-and-gating/features/helpers_test.go
@@ -0,0 +1,53 @@
+package features_test
+
+import (
+ "flag"
+
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// The mutation-level tests own no golden files, so -update is a no-op here. It is
+// declared only so that running the whole example with `go test ./... -update`
+// (to regenerate the resource and component goldens) does not fail flag parsing
+// in this package's test binary.
+var _ = flag.Bool("update", false, "no-op: this package has no golden files")
+
+// baseDeployment returns the minimal baseline Deployment the mutation-level tests
+// apply a single mutation to. It carries one container named "app" with the v2
+// port layout (http + health), which is the smallest object the deployment
+// mutations in this package operate on. Each mutation test starts from this base,
+// applies exactly one mutation, previews, and asserts the specific field change.
+func baseDeployment() *appsv1.Deployment {
+ return &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
+ Spec: appsv1.DeploymentSpec{
+ Template: corev1.PodTemplateSpec{
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: "app",
+ Ports: []corev1.ContainerPort{
+ {Name: "http", ContainerPort: 8080},
+ {Name: "health", ContainerPort: 8081},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+// baseConfigMap returns the minimal baseline ConfigMap the ConfigMap
+// mutation-level tests apply a single mutation to. It carries the core server
+// config in app.yaml, which the metrics mutation merges into.
+func baseConfigMap() *corev1.ConfigMap {
+ return &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
+ Data: map[string]string{
+ "app.yaml": "server:\n port: 8080\n",
+ },
+ }
+}
diff --git a/examples/mutations-and-gating/features/legacy_container_test.go b/examples/mutations-and-gating/features/legacy_container_test.go
index 288af348..03e52c59 100644
--- a/examples/mutations-and-gating/features/legacy_container_test.go
+++ b/examples/mutations-and-gating/features/legacy_container_test.go
@@ -8,33 +8,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestBackwardCompatV1Container verifies that the mutation renames the
// container to "server" and drops the health port for pre-2.0 versions.
func TestBackwardCompatV1Container(t *testing.T) {
- base := &appsv1.Deployment{
- ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
- Spec: appsv1.DeploymentSpec{
- Template: corev1.PodTemplateSpec{
- Spec: corev1.PodSpec{
- Containers: []corev1.Container{
- {
- Name: "app",
- Ports: []corev1.ContainerPort{
- {Name: "http", ContainerPort: 8080},
- {Name: "health", ContainerPort: 8081},
- },
- },
- },
- },
- },
- },
- }
-
- res, err := deployment.NewBuilder(base).
+ res, err := deployment.NewBuilder(baseDeployment()).
WithMutation(features.BackwardCompatV1Container("1.9.0")).
Build()
require.NoError(t, err)
diff --git a/examples/mutations-and-gating/features/metrics_config.go b/examples/mutations-and-gating/features/metrics_config.go
index 492c3095..02d3a587 100644
--- a/examples/mutations-and-gating/features/metrics_config.go
+++ b/examples/mutations-and-gating/features/metrics_config.go
@@ -7,10 +7,10 @@ import (
// MetricsConfigMutation adds a Prometheus metrics section to app.yaml.
// It is boolean-gated on the enableMetrics flag.
-func MetricsConfigMutation(version string, enableMetrics bool) configmap.Mutation {
+func MetricsConfigMutation(enableMetrics bool) configmap.Mutation {
return configmap.Mutation{
Name: "metrics-config",
- Feature: feature.NewVersionGate(version, nil).When(enableMetrics),
+ Feature: feature.NewBooleanGate(enableMetrics),
Mutate: func(m *configmap.Mutator) error {
m.MergeYAML("app.yaml", `
metrics:
diff --git a/examples/mutations-and-gating/features/metrics_config_test.go b/examples/mutations-and-gating/features/metrics_config_test.go
index 8960a68e..8c8db07d 100644
--- a/examples/mutations-and-gating/features/metrics_config_test.go
+++ b/examples/mutations-and-gating/features/metrics_config_test.go
@@ -8,21 +8,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestMetricsConfigMutation verifies that the mutation merges a Prometheus
// metrics section into app.yaml.
func TestMetricsConfigMutation(t *testing.T) {
- base := &corev1.ConfigMap{
- ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
- Data: map[string]string{
- "app.yaml": "server:\n port: 8080\n",
- },
- }
-
- res, err := configmap.NewBuilder(base).
- WithMutation(features.MetricsConfigMutation("1.0.0", true)).
+ res, err := configmap.NewBuilder(baseConfigMap()).
+ WithMutation(features.MetricsConfigMutation(true)).
Build()
require.NoError(t, err)
diff --git a/examples/mutations-and-gating/features/tracing_sidecar.go b/examples/mutations-and-gating/features/tracing_sidecar.go
index 6b77ccd1..b47b6940 100644
--- a/examples/mutations-and-gating/features/tracing_sidecar.go
+++ b/examples/mutations-and-gating/features/tracing_sidecar.go
@@ -11,7 +11,7 @@ import (
func TracingSidecarMutation(enabled bool) deployment.Mutation {
return deployment.Mutation{
Name: "Tracing",
- Feature: feature.NewVersionGate("any", nil).When(enabled),
+ Feature: feature.NewBooleanGate(enabled),
Mutate: func(m *deployment.Mutator) error {
m.EnsureContainer(corev1.Container{
Name: "jaeger-agent",
diff --git a/examples/mutations-and-gating/features/tracing_sidecar_test.go b/examples/mutations-and-gating/features/tracing_sidecar_test.go
index b4f46574..b34b37d3 100644
--- a/examples/mutations-and-gating/features/tracing_sidecar_test.go
+++ b/examples/mutations-and-gating/features/tracing_sidecar_test.go
@@ -8,27 +8,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
- corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TestTracingSidecarMutation verifies that the mutation injects a Jaeger
// sidecar and sets JAEGER_AGENT_HOST on all containers.
func TestTracingSidecarMutation(t *testing.T) {
- base := &appsv1.Deployment{
- ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
- Spec: appsv1.DeploymentSpec{
- Template: corev1.PodTemplateSpec{
- Spec: corev1.PodSpec{
- Containers: []corev1.Container{
- {Name: "web"},
- },
- },
- },
- },
- }
-
- res, err := deployment.NewBuilder(base).
+ res, err := deployment.NewBuilder(baseDeployment()).
WithMutation(features.TracingSidecarMutation(true)).
Build()
require.NoError(t, err)
diff --git a/examples/mutations-and-gating/resources/configmap.go b/examples/mutations-and-gating/resources/configmap.go
index 270d55e9..51f333ff 100644
--- a/examples/mutations-and-gating/resources/configmap.go
+++ b/examples/mutations-and-gating/resources/configmap.go
@@ -31,7 +31,7 @@ func BaseConfigMap(owner *app.ExampleApp) *corev1.ConfigMap {
// ConfigMap can be gated at the resource level by the controller.
func NewConfigMapResource(owner *app.ExampleApp) (component.Resource, error) {
builder := configmap.NewBuilder(BaseConfigMap(owner))
- builder.WithMutation(features.MetricsConfigMutation(owner.Spec.Version, owner.Spec.EnableMetrics))
+ builder.WithMutation(features.MetricsConfigMutation(owner.Spec.EnableMetrics))
return builder.Build()
}
diff --git a/examples/mutations-and-gating/resources/configmap_test.go b/examples/mutations-and-gating/resources/configmap_test.go
index d6c175b8..705fb8d5 100644
--- a/examples/mutations-and-gating/resources/configmap_test.go
+++ b/examples/mutations-and-gating/resources/configmap_test.go
@@ -3,55 +3,53 @@ package resources_test
import (
"testing"
- "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features"
"github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/configmap"
- "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
- "github.com/stretchr/testify/require"
- corev1 "k8s.io/api/core/v1"
- "k8s.io/apimachinery/pkg/runtime"
+ sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
)
-// TestConfigMapShape verifies the ConfigMap's rendered YAML against golden
-// files for each feature combination.
+// configMapGen declares the ConfigMap resource matrix. The ConfigMap registers one
+// mutation, "metrics-config", boolean-gated on EnableMetrics. The default fixture
+// pins that it does not fire with metrics off; the metrics fixture pins that it
+// fires with metrics on, which is what accounts for the mutation in AssertComplete.
//
-// The baseline ConfigMap carries the core server config in its Data field.
-// Boolean-gated mutations (MetricsConfigMutation) layer additional sections
-// on top. Golden files pin the output so that changes to the baseline or
-// mutation logic surface as test failures.
-func TestConfigMapShape(t *testing.T) {
- scheme := runtime.NewScheme()
- require.NoError(t, corev1.AddToScheme(scheme))
-
- tests := []struct {
- name string
- version string
- metrics bool
- golden string
- }{
+// The version sweep is the same universe as the Deployment, but no ConfigMap
+// mutation is version-gated, so both versions collapse to a single regime per
+// fixture and only one golden is written per fixture.
+var configMapGen = goldengen.New(goldengen.Config[*sharedapp.ExampleApp]{
+ Dir: "testdata/configmap",
+ Versions: []string{"1.9.0", "2.0.0"},
+ Fixtures: []goldengen.Fixture[*sharedapp.ExampleApp]{
{
- name: "baseline",
- version: "1.0.0",
- golden: "testdata/configmap-baseline.yaml",
+ Name: "default",
+ Spec: owner(sharedapp.ExampleAppSpec{}),
+ Forbids: []goldengen.Expect{
+ {Name: "metrics-config"}, // metrics off, so it never fires
+ },
},
{
- name: "with metrics",
- version: "1.0.0",
- metrics: true,
- golden: "testdata/configmap-metrics.yaml",
+ Name: "metrics",
+ Spec: owner(sharedapp.ExampleAppSpec{EnableMetrics: true}),
+ Requires: []goldengen.Expect{
+ {Name: "metrics-config"}, // boolean-gated on EnableMetrics
+ },
},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- owner := testOwner(tt.version)
-
- res, err := configmap.NewBuilder(resources.BaseConfigMap(owner)).
- WithMutation(features.MetricsConfigMutation(tt.version, tt.metrics)).
- Build()
- require.NoError(t, err)
+ },
+ Build: func(version string, spec *sharedapp.ExampleApp) (goldengen.Unit, error) {
+ o := spec.DeepCopyObject().(*sharedapp.ExampleApp)
+ o.Spec.Version = version
+ res, err := resources.NewConfigMapResource(o)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res.(goldengen.ResourcePreviewer), scheme), nil
+ },
+})
- golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update))
- })
- }
+// TestConfigMapVersionMatrix runs the ConfigMap sweep: it asserts the gating
+// expectations and writes or compares one golden per regime plus the coverage
+// manifest.
+func TestConfigMapVersionMatrix(t *testing.T) {
+ configMapGen.WithUpdate(*update)
+ configMapGen.Run(t)
}
diff --git a/examples/mutations-and-gating/resources/deployment_test.go b/examples/mutations-and-gating/resources/deployment_test.go
index 957eff99..278f6ee9 100644
--- a/examples/mutations-and-gating/resources/deployment_test.go
+++ b/examples/mutations-and-gating/resources/deployment_test.go
@@ -2,124 +2,112 @@ package resources_test
import (
"flag"
+ "os"
"testing"
- "github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/features"
"github.com/sourcehawk/operator-component-framework/examples/mutations-and-gating/resources"
sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app"
- "github.com/sourcehawk/operator-component-framework/pkg/primitives/deployment"
- "github.com/sourcehawk/operator-component-framework/pkg/testing/golden"
- "github.com/stretchr/testify/require"
- appsv1 "k8s.io/api/apps/v1"
+ "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen"
"k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
)
+// update is wired to the -update flag so both resource generators overwrite their
+// goldens and manifests when set:
+//
+// go test ./examples/mutations-and-gating/resources/ -update
+//
+// It is declared once for the whole resources test package; the Deployment and
+// ConfigMap generators share it.
var update = flag.Bool("update", false, "update golden files")
-func testOwner(version string) *sharedapp.ExampleApp {
- owner := &sharedapp.ExampleApp{
- Spec: sharedapp.ExampleAppSpec{Version: version},
+// scheme resolves TypeMeta for the rendered resources.
+var scheme = newScheme()
+
+// newScheme returns a scheme with the core and apps Kubernetes types registered.
+func newScheme() *runtime.Scheme {
+ s := runtime.NewScheme()
+ if err := clientgoscheme.AddToScheme(s); err != nil {
+ panic(err)
}
- owner.Name = "my-app"
- owner.Namespace = "default"
- return owner
+ return s
}
-// TestDeploymentShape verifies the Deployment's rendered YAML against golden
-// files for each supported version and feature combination.
-//
-// The baseline object (BaseDeployment) always reflects the latest version's
-// desired state (v2: container "app", HTTP + health ports). Legacy mutations
-// roll it back for older versions (v1: container "server", HTTP port only).
+// owner returns a fixture owner. The Build functions overwrite Spec.Version per
+// version, so the Version set on a fixture spec is just a placeholder.
+func owner(spec sharedapp.ExampleAppSpec) *sharedapp.ExampleApp {
+ o := &sharedapp.ExampleApp{Spec: spec}
+ o.Name = "my-app"
+ o.Namespace = "default"
+ return o
+}
+
+// deploymentGen declares the Deployment resource matrix. The Deployment registers
+// three mutations:
//
-// Mutation registration order mirrors NewDeploymentResource: DebugLogging
-// targets ContainerNamed("app") and must come before LegacyContainer which
-// renames the container. TracingSidecar uses AllContainers and is
-// order-insensitive.
+// - BackwardCompatV1Container is version-gated to fire for versions < 2.0.0, so the
+// version sweep splits the default fixture into a pre-2.0.0 regime (the mutation
+// fires) and a 2.0.0 regime (it does not).
+// - DebugLogging is boolean-gated on EnableDebugLogging.
+// - Tracing is boolean-gated on EnableTracing.
//
-// These snapshots catch unintended regressions: if someone updates the
-// baseline to accommodate a new v3 layout, the v1 and v2 golden files will
-// fail unless the corresponding backward compat mutations still produce the
-// correct shape. This ensures that changes to the latest version do not silently
-// break the resource shape served to older versions.
-func TestDeploymentShape(t *testing.T) {
- scheme := runtime.NewScheme()
- require.NoError(t, appsv1.AddToScheme(scheme))
-
- tests := []struct {
- name string
- version string
- debug bool
- tracing bool
- golden string
- }{
- // v1 cases: BackwardCompatV1Container fires and rolls back the v2
- // baseline to the v1 container layout. If the baseline changes,
- // these golden files catch any v1 regression.
- {
- name: "v1 legacy container",
- version: "1.9.0",
- golden: "testdata/deployment-v1.yaml",
- },
- {
- name: "v1 with tracing",
- version: "1.9.0",
- tracing: true,
- golden: "testdata/deployment-v1-tracing.yaml",
- },
+// One fixture per boolean flag exercises the gate, and the version sweep covers the
+// version gate. Together the fixtures' Requires account for all three mutations.
+var deploymentGen = goldengen.New(goldengen.Config[*sharedapp.ExampleApp]{
+ Dir: "testdata/deployment",
+ Versions: []string{"1.9.0", "2.0.0"},
+ Fixtures: []goldengen.Fixture[*sharedapp.ExampleApp]{
{
- name: "v1 with debug",
- version: "1.9.0",
- debug: true,
- golden: "testdata/deployment-v1-debug.yaml",
+ Name: "default",
+ Spec: owner(sharedapp.ExampleAppSpec{}),
+ Requires: []goldengen.Expect{
+ {Name: "BackwardCompatV1Container", For: "1.9.0"}, // legacy container before 2.0.0
+ },
+ Forbids: []goldengen.Expect{
+ {Name: "BackwardCompatV1Container", For: "2.0.0"}, // not from 2.0.0 onward
+ },
},
{
- name: "v1 with tracing and debug",
- version: "1.9.0",
- debug: true,
- tracing: true,
- golden: "testdata/deployment-v1-tracing-debug.yaml",
+ Name: "debug",
+ Spec: owner(sharedapp.ExampleAppSpec{EnableDebugLogging: true}),
+ Requires: []goldengen.Expect{
+ {Name: "DebugLogging"}, // boolean-gated on EnableDebugLogging
+ },
},
- // v2 cases: no legacy mutation fires, so the baseline is rendered
- // as-is. These golden files pin the current latest shape.
{
- name: "v2 baseline",
- version: "2.0.0",
- golden: "testdata/deployment-v2.yaml",
+ Name: "tracing",
+ Spec: owner(sharedapp.ExampleAppSpec{EnableTracing: true}),
+ Requires: []goldengen.Expect{
+ {Name: "Tracing"}, // boolean-gated on EnableTracing
+ },
},
- {
- name: "v2 with tracing",
- version: "2.0.0",
- tracing: true,
- golden: "testdata/deployment-v2-tracing.yaml",
- },
- {
- name: "v2 with debug",
- version: "2.0.0",
- debug: true,
- golden: "testdata/deployment-v2-debug.yaml",
- },
- {
- name: "v2 with tracing and debug",
- version: "2.0.0",
- debug: true,
- tracing: true,
- golden: "testdata/deployment-v2-tracing-debug.yaml",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- owner := testOwner(tt.version)
+ },
+ Build: func(version string, spec *sharedapp.ExampleApp) (goldengen.Unit, error) {
+ o := spec.DeepCopyObject().(*sharedapp.ExampleApp)
+ o.Spec.Version = version
+ res, err := resources.NewDeploymentResource(o)
+ if err != nil {
+ return nil, err
+ }
+ return goldengen.Resource(res.(goldengen.ResourcePreviewer), scheme), nil
+ },
+})
- res, err := deployment.NewBuilder(resources.BaseDeployment(owner)).
- WithMutation(features.DebugLoggingMutation(tt.debug)).
- WithMutation(features.BackwardCompatV1Container(tt.version)).
- WithMutation(features.TracingSidecarMutation(tt.tracing)).
- Build()
- require.NoError(t, err)
+// TestDeploymentVersionMatrix runs the Deployment sweep: it asserts the gating
+// expectations and writes or compares one golden per regime plus the coverage
+// manifest.
+func TestDeploymentVersionMatrix(t *testing.T) {
+ deploymentGen.WithUpdate(*update)
+ deploymentGen.Run(t)
+}
- golden.AssertYAML(t, tt.golden, res, golden.WithScheme(scheme), golden.Update(*update))
- })
- }
+// TestMain runs the package tests, then proves every registered mutation across
+// both resource generators is required or excluded before reporting the exit code.
+// Chaining the AssertComplete calls means the package fails if either resource
+// leaves a mutation unaccounted.
+func TestMain(m *testing.M) {
+ code := m.Run()
+ code = deploymentGen.AssertComplete(code)
+ code = configMapGen.AssertComplete(code)
+ os.Exit(code)
}
diff --git a/examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml b/examples/mutations-and-gating/resources/testdata/configmap/default/1.9.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/configmap-baseline.yaml
rename to examples/mutations-and-gating/resources/testdata/configmap/default/1.9.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml b/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml
new file mode 100644
index 00000000..d4f10458
--- /dev/null
+++ b/examples/mutations-and-gating/resources/testdata/configmap/manifest.yaml
@@ -0,0 +1,16 @@
+fixtures:
+- name: default
+ regimes:
+ - firing: null
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - 2.0.0
+- name: metrics
+ regimes:
+ - firing:
+ - metrics-config
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - 2.0.0
diff --git a/examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml b/examples/mutations-and-gating/resources/testdata/configmap/metrics/1.9.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/configmap-metrics.yaml
rename to examples/mutations-and-gating/resources/testdata/configmap/metrics/1.9.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment/debug/1.9.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v1-debug.yaml
rename to examples/mutations-and-gating/resources/testdata/deployment/debug/1.9.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml b/examples/mutations-and-gating/resources/testdata/deployment/debug/2.0.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v2-debug.yaml
rename to examples/mutations-and-gating/resources/testdata/deployment/debug/2.0.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/deployment/default/1.9.0.yaml b/examples/mutations-and-gating/resources/testdata/deployment/default/1.9.0.yaml
new file mode 100644
index 00000000..0223991d
--- /dev/null
+++ b/examples/mutations-and-gating/resources/testdata/deployment/default/1.9.0.yaml
@@ -0,0 +1,24 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ labels:
+ app: my-app
+ name: my-app-app
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: my-app
+ strategy: {}
+ template:
+ metadata:
+ labels:
+ app: my-app
+ spec:
+ containers:
+ - image: my-app:1.9.0
+ name: server
+ ports:
+ - containerPort: 8080
+ name: http
+ resources: {}
diff --git a/examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml b/examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml
new file mode 100644
index 00000000..712145c4
--- /dev/null
+++ b/examples/mutations-and-gating/resources/testdata/deployment/default/2.0.0.yaml
@@ -0,0 +1,26 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ labels:
+ app: my-app
+ name: my-app-app
+ namespace: default
+spec:
+ selector:
+ matchLabels:
+ app: my-app
+ strategy: {}
+ template:
+ metadata:
+ labels:
+ app: my-app
+ spec:
+ containers:
+ - image: my-app:2.0.0
+ name: app
+ ports:
+ - containerPort: 8080
+ name: http
+ - containerPort: 8081
+ name: health
+ resources: {}
diff --git a/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml b/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml
new file mode 100644
index 00000000..90a71490
--- /dev/null
+++ b/examples/mutations-and-gating/resources/testdata/deployment/manifest.yaml
@@ -0,0 +1,38 @@
+fixtures:
+- name: default
+ regimes:
+ - firing:
+ - BackwardCompatV1Container
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - firing: null
+ representative: 2.0.0
+ versions:
+ - 2.0.0
+- name: debug
+ regimes:
+ - firing:
+ - BackwardCompatV1Container
+ - DebugLogging
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - firing:
+ - DebugLogging
+ representative: 2.0.0
+ versions:
+ - 2.0.0
+- name: tracing
+ regimes:
+ - firing:
+ - BackwardCompatV1Container
+ - Tracing
+ representative: 1.9.0
+ versions:
+ - 1.9.0
+ - firing:
+ - Tracing
+ representative: 2.0.0
+ versions:
+ - 2.0.0
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment/tracing/1.9.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v1-tracing.yaml
rename to examples/mutations-and-gating/resources/testdata/deployment/tracing/1.9.0.yaml
diff --git a/examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml b/examples/mutations-and-gating/resources/testdata/deployment/tracing/2.0.0.yaml
similarity index 100%
rename from examples/mutations-and-gating/resources/testdata/deployment-v2-tracing.yaml
rename to examples/mutations-and-gating/resources/testdata/deployment/tracing/2.0.0.yaml
diff --git a/examples/version-matrix/README.md b/examples/version-matrix/README.md
index 4dafb5f2..8103d1e3 100644
--- a/examples/version-matrix/README.md
+++ b/examples/version-matrix/README.md
@@ -11,15 +11,15 @@ the gating, and proving every registered mutation is accounted for.
version universe produces a distinct golden per gating regime instead of one golden per version.
- **Version-gated mutations**:
- `ContainerImage` has no gate, so it fires at every version and anchors the always-on part of the firing set.
- - `ClusterEnv/Pre89` fires for versions `< 8.9.0` (legacy gossip discovery).
- - `ClusterEnv/Unified89` fires for versions `>= 8.9.0` (unified raft discovery).
-- **Firing-set classification**: The version universe `8.7.0`, `8.8.2`, `8.9.0` collapses to two regimes:
- `{ContainerImage, ClusterEnv/Pre89}` covering `8.7.0` and `8.8.2`, and `{ContainerImage, ClusterEnv/Unified89}`
- covering `8.9.0`. Only two goldens are written, one per regime, named by the regime's representative version.
+ - `PeerDiscovery/PreV2` fires for versions `< 2.0.0` (legacy peer-discovery format).
+ - `PeerDiscovery/V2` fires for versions `>= 2.0.0` (peer-discovery format introduced in 2.0.0).
+- **Firing-set classification**: The version universe `1.0.0`, `1.5.0`, `2.0.0` collapses to two regimes:
+ `{ContainerImage, PeerDiscovery/PreV2}` covering `1.0.0` and `1.5.0`, and `{ContainerImage, PeerDiscovery/V2}`
+ covering `2.0.0`. Only two goldens are written, one per regime, named by the regime's representative version.
- **Ascending version order**: Listing `Versions` ascending puts each regime's representative on the lower inclusive
boundary of its gating range, so the golden's filename marks exactly where the regime begins.
- **Gating assertions**: `Requires`/`Forbids` pin which mutation fires (or does not) at which version. The boundary is
- asserted from both sides: `ClusterEnv/Unified89` is required at `8.9.0` and forbidden at `8.8.2`.
+ asserted from both sides: `PeerDiscovery/V2` is required at `2.0.0` and forbidden at `1.5.0`.
- **Completeness accounting**: `TestMain` calls `gen.AssertComplete(m.Run())`, which fails the package if any registered
mutation is neither required by a fixture nor listed in `Exclude`. Adding a fourth mutation without asserting it would
break this test.
@@ -29,8 +29,8 @@ the gating, and proving every registered mutation is accounted for.
```
testdata/version_matrix/
manifest.yaml # per-fixture regimes: representative, versions, firing-set
- default/8.7.0.yaml # regime representative for { ContainerImage, ClusterEnv/Pre89 }
- default/8.9.0.yaml # regime representative for { ContainerImage, ClusterEnv/Unified89 }
+ default/1.0.0.yaml # regime representative for { ContainerImage, PeerDiscovery/PreV2 }
+ default/2.0.0.yaml # regime representative for { ContainerImage, PeerDiscovery/V2 }
```
## Running
diff --git a/examples/version-matrix/resources/statefulset.go b/examples/version-matrix/resources/statefulset.go
index 6aefdecf..fb416529 100644
--- a/examples/version-matrix/resources/statefulset.go
+++ b/examples/version-matrix/resources/statefulset.go
@@ -78,28 +78,28 @@ func ContainerImageMutation(owner *app.ExampleApp) statefulset.Mutation {
}
}
-// ClusterEnvPre89Mutation sets the pre-8.9 cluster-coordination environment
-// variable. It fires only for versions below 8.9.0, where the unified protocol is
-// not yet available.
-func ClusterEnvPre89Mutation(version string) statefulset.Mutation {
+// PeerDiscoveryPreV2Mutation sets the legacy peer-discovery environment variable.
+// It fires only for versions below 2.0.0, where the newer discovery format is not
+// yet available.
+func PeerDiscoveryPreV2Mutation(version string) statefulset.Mutation {
return statefulset.Mutation{
- Name: "ClusterEnv/Pre89",
- Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 8.9.0")}),
+ Name: "PeerDiscovery/PreV2",
+ Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint("< 2.0.0")}),
Mutate: func(m *statefulset.Mutator) error {
- m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "legacy-gossip"})
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "PEER_DISCOVERY", Value: "legacy"})
return nil
},
}
}
-// ClusterEnvUnified89Mutation sets the unified cluster-coordination environment
-// variable introduced in 8.9.0. It fires only for versions at or above 8.9.0.
-func ClusterEnvUnified89Mutation(version string) statefulset.Mutation {
+// PeerDiscoveryV2Mutation sets the peer-discovery environment variable introduced
+// in 2.0.0. It fires only for versions at or above 2.0.0.
+func PeerDiscoveryV2Mutation(version string) statefulset.Mutation {
return statefulset.Mutation{
- Name: "ClusterEnv/Unified89",
- Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 8.9.0")}),
+ Name: "PeerDiscovery/V2",
+ Feature: feature.NewVersionGate(version, []feature.VersionConstraint{mustConstraint(">= 2.0.0")}),
Mutate: func(m *statefulset.Mutator) error {
- m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CLUSTER_DISCOVERY", Value: "unified-raft"})
+ m.EnsureContainerEnvVar(corev1.EnvVar{Name: "PEER_DISCOVERY", Value: "v2"})
return nil
},
}
@@ -111,7 +111,7 @@ func ClusterEnvUnified89Mutation(version string) statefulset.Mutation {
func NewStatefulSetResource(owner *app.ExampleApp) (*statefulset.Resource, error) {
return statefulset.NewBuilder(BaseStatefulSet(owner)).
WithMutation(ContainerImageMutation(owner)).
- WithMutation(ClusterEnvPre89Mutation(owner.Spec.Version)).
- WithMutation(ClusterEnvUnified89Mutation(owner.Spec.Version)).
+ WithMutation(PeerDiscoveryPreV2Mutation(owner.Spec.Version)).
+ WithMutation(PeerDiscoveryV2Mutation(owner.Spec.Version)).
Build()
}
diff --git a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml b/examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml
similarity index 80%
rename from examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml
rename to examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml
index 9c0aa59f..2dfb0638 100644
--- a/examples/version-matrix/testdata/version_matrix/default/8.9.0.yaml
+++ b/examples/version-matrix/testdata/version_matrix/default/1.0.0.yaml
@@ -17,9 +17,9 @@ spec:
spec:
containers:
- env:
- - name: CLUSTER_DISCOVERY
- value: unified-raft
- image: example/db:8.9.0
+ - name: PEER_DISCOVERY
+ value: legacy
+ image: example/db:1.0.0
name: db
resources: {}
updateStrategy: {}
diff --git a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml b/examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml
similarity index 79%
rename from examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml
rename to examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml
index fee8dce3..95b3c866 100644
--- a/examples/version-matrix/testdata/version_matrix/default/8.7.0.yaml
+++ b/examples/version-matrix/testdata/version_matrix/default/2.0.0.yaml
@@ -17,9 +17,9 @@ spec:
spec:
containers:
- env:
- - name: CLUSTER_DISCOVERY
- value: legacy-gossip
- image: example/db:8.7.0
+ - name: PEER_DISCOVERY
+ value: v2
+ image: example/db:2.0.0
name: db
resources: {}
updateStrategy: {}
diff --git a/examples/version-matrix/testdata/version_matrix/manifest.yaml b/examples/version-matrix/testdata/version_matrix/manifest.yaml
index 24c3413f..1724b039 100644
--- a/examples/version-matrix/testdata/version_matrix/manifest.yaml
+++ b/examples/version-matrix/testdata/version_matrix/manifest.yaml
@@ -2,15 +2,15 @@ fixtures:
- name: default
regimes:
- firing:
- - ClusterEnv/Pre89
- ContainerImage
- representative: 8.7.0
+ - PeerDiscovery/PreV2
+ representative: 1.0.0
versions:
- - 8.7.0
- - 8.8.2
+ - 1.0.0
+ - 1.5.0
- firing:
- - ClusterEnv/Unified89
- ContainerImage
- representative: 8.9.0
+ - PeerDiscovery/V2
+ representative: 2.0.0
versions:
- - 8.9.0
+ - 2.0.0
diff --git a/examples/version-matrix/version_matrix_test.go b/examples/version-matrix/version_matrix_test.go
index 1e8d00d3..1bb5fc3e 100644
--- a/examples/version-matrix/version_matrix_test.go
+++ b/examples/version-matrix/version_matrix_test.go
@@ -50,18 +50,18 @@ func defaultCluster() *app.ExampleApp {
// regime's representative lands on the lower inclusive boundary of its gating range.
var gen = goldengen.New(goldengen.Config[*app.ExampleApp]{
Dir: "testdata/version_matrix",
- Versions: []string{"8.7.0", "8.8.2", "8.9.0"},
+ Versions: []string{"1.0.0", "1.5.0", "2.0.0"},
Fixtures: []goldengen.Fixture[*app.ExampleApp]{{
Name: "default",
Spec: defaultCluster(),
Requires: []goldengen.Expect{
- {Name: "ContainerImage"}, // fires at every version
- {Name: "ClusterEnv/Pre89", For: "8.8.2"}, // legacy discovery before 8.9
- {Name: "ClusterEnv/Unified89", For: "8.9.0"}, // unified discovery from 8.9
+ {Name: "ContainerImage"}, // fires at every version
+ {Name: "PeerDiscovery/PreV2", For: "1.5.0"}, // legacy format before 2.0.0
+ {Name: "PeerDiscovery/V2", For: "2.0.0"}, // new format from 2.0.0
},
Forbids: []goldengen.Expect{
- {Name: "ClusterEnv/Unified89", For: "8.8.2"}, // not before the boundary
- {Name: "ClusterEnv/Pre89", For: "8.9.0"}, // not after the boundary
+ {Name: "PeerDiscovery/V2", For: "1.5.0"}, // not before the boundary
+ {Name: "PeerDiscovery/PreV2", For: "2.0.0"}, // not after the boundary
},
}},
Build: func(version string, spec *app.ExampleApp) (goldengen.Unit, error) {
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 00000000..e8fee6e8
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,116 @@
+site_name: Operator Component Framework
+site_description: A Go framework for building maintainable Kubernetes operators
+site_url: https://sourcehawk.github.io/operator-component-framework/
+repo_url: https://github.com/sourcehawk/operator-component-framework
+repo_name: sourcehawk/operator-component-framework
+edit_uri: edit/main/docs/
+
+# docs/superpowers/ is gitignored and never published.
+exclude_docs: |
+ superpowers/
+
+theme:
+ name: material
+ icon:
+ repo: fontawesome/brands/github
+ features:
+ - navigation.indexes
+ - navigation.top
+ - navigation.tracking
+ - navigation.footer
+ - toc.follow
+ - search.suggest
+ - search.highlight
+ - content.code.copy
+ - content.code.annotate
+ - content.tabs.link
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/weather-night
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: indigo
+ accent: indigo
+ toggle:
+ icon: material/weather-sunny
+ name: Switch to light mode
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - md_in_html
+ - tables
+ - pymdownx.details
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ - toc:
+ permalink: true
+
+plugins:
+ - search
+
+extra_css:
+ - stylesheets/extra.css
+
+nav:
+ - Home: index.md
+ - Getting Started: getting-started.md
+ - Concepts:
+ - Component: component.md
+ - Primitives Overview: primitives.md
+ - Custom Resources: custom-resource.md
+ - Primitives:
+ - Workloads:
+ - Pod: primitives/pod.md
+ - Deployment: primitives/deployment.md
+ - StatefulSet: primitives/statefulset.md
+ - DaemonSet: primitives/daemonset.md
+ - ReplicaSet: primitives/replicaset.md
+ - Job: primitives/job.md
+ - CronJob: primitives/cronjob.md
+ - Networking:
+ - Service: primitives/service.md
+ - Ingress: primitives/ingress.md
+ - NetworkPolicy: primitives/networkpolicy.md
+ - Config & Secrets:
+ - ConfigMap: primitives/configmap.md
+ - Secret: primitives/secret.md
+ - Storage:
+ - PersistentVolume: primitives/pv.md
+ - PersistentVolumeClaim: primitives/pvc.md
+ - RBAC:
+ - ServiceAccount: primitives/serviceaccount.md
+ - Role: primitives/role.md
+ - RoleBinding: primitives/rolebinding.md
+ - ClusterRole: primitives/clusterrole.md
+ - ClusterRoleBinding: primitives/clusterrolebinding.md
+ - Scaling & Availability:
+ - HorizontalPodAutoscaler: primitives/hpa.md
+ - PodDisruptionBudget: primitives/pdb.md
+ - Escape Hatch:
+ - Unstructured: primitives/unstructured.md
+ - Guides:
+ - Guidelines: guidelines.md
+ - Testing: testing.md
+ - Reference:
+ - Compatibility: compatibility.md
+ - Go API ↗: https://pkg.go.dev/github.com/sourcehawk/operator-component-framework
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 00000000..83a41987
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1 @@
+mkdocs-material==9.7.6