From 877105f18189a2da5fb7aa08c9bae681675763eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:44 +0000 Subject: [PATCH 01/26] Add StatefulSetSpecEditor tests Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/statefulsetspec_test.go | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 pkg/mutation/editors/statefulsetspec_test.go diff --git a/pkg/mutation/editors/statefulsetspec_test.go b/pkg/mutation/editors/statefulsetspec_test.go new file mode 100644 index 00000000..f2e27e99 --- /dev/null +++ b/pkg/mutation/editors/statefulsetspec_test.go @@ -0,0 +1,72 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" +) + +func TestStatefulSetSpecEditor(t *testing.T) { + t.Run("SetReplicas", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetReplicas(3) + assert.Equal(t, int32(3), *spec.Replicas) + }) + + t.Run("SetServiceName", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetServiceName("my-service") + assert.Equal(t, "my-service", spec.ServiceName) + }) + + t.Run("SetPodManagementPolicy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetPodManagementPolicy(appsv1.ParallelPodManagement) + assert.Equal(t, appsv1.ParallelPodManagement, spec.PodManagementPolicy) + }) + + t.Run("SetUpdateStrategy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + strategy := appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + } + editor.SetUpdateStrategy(strategy) + assert.Equal(t, appsv1.OnDeleteStatefulSetStrategyType, spec.UpdateStrategy.Type) + }) + + t.Run("SetRevisionHistoryLimit", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetRevisionHistoryLimit(5) + assert.Equal(t, int32(5), *spec.RevisionHistoryLimit) + }) + + t.Run("SetMinReadySeconds", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + editor.SetMinReadySeconds(10) + assert.Equal(t, int32(10), spec.MinReadySeconds) + }) + + t.Run("SetPersistentVolumeClaimRetentionPolicy", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + policy := &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.RetainPersistentVolumeClaimRetentionPolicyType, + } + editor.SetPersistentVolumeClaimRetentionPolicy(policy) + assert.Equal(t, policy, spec.PersistentVolumeClaimRetentionPolicy) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &appsv1.StatefulSetSpec{} + editor := NewStatefulSetSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} From a20aaf4c14c80e51ad83cd9f409a6277cbc35774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:51 +0000 Subject: [PATCH 02/26] Implement StatefulSet primitive package Adds builder, resource, mutator, flavors, and handlers for the StatefulSet workload primitive, following the same patterns as the Deployment primitive. Key differences from Deployment: - DefaultFieldApplicator preserves VolumeClaimTemplates (immutable after creation) - Mutator supports EnsureVolumeClaimTemplate/RemoveVolumeClaimTemplate operations - EditStatefulSetSpec provides typed access to StatefulSet-specific fields - VCT operations run after all container edits in the apply order Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/builder.go | 178 +++++ pkg/primitives/statefulset/builder_test.go | 272 ++++++++ pkg/primitives/statefulset/flavors.go | 44 ++ pkg/primitives/statefulset/flavors_test.go | 146 ++++ pkg/primitives/statefulset/handlers.go | 111 ++++ pkg/primitives/statefulset/handlers_test.go | 183 +++++ pkg/primitives/statefulset/mutator.go | 506 ++++++++++++++ pkg/primitives/statefulset/mutator_test.go | 698 ++++++++++++++++++++ pkg/primitives/statefulset/resource.go | 136 ++++ 9 files changed, 2274 insertions(+) create mode 100644 pkg/primitives/statefulset/builder.go create mode 100644 pkg/primitives/statefulset/builder_test.go create mode 100644 pkg/primitives/statefulset/flavors.go create mode 100644 pkg/primitives/statefulset/flavors_test.go create mode 100644 pkg/primitives/statefulset/handlers.go create mode 100644 pkg/primitives/statefulset/handlers_test.go create mode 100644 pkg/primitives/statefulset/mutator.go create mode 100644 pkg/primitives/statefulset/mutator_test.go create mode 100644 pkg/primitives/statefulset/resource.go diff --git a/pkg/primitives/statefulset/builder.go b/pkg/primitives/statefulset/builder.go new file mode 100644 index 00000000..d94d4756 --- /dev/null +++ b/pkg/primitives/statefulset/builder.go @@ -0,0 +1,178 @@ +// Package statefulset provides a builder and resource for managing Kubernetes StatefulSets. +package statefulset + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + appsv1 "k8s.io/api/apps/v1" +) + +// Builder is a configuration helper for creating and customizing a StatefulSet Resource. +// +// It provides a fluent API for registering mutations, status handlers, and +// data extractors. This builder ensures that the resulting Resource is +// properly initialized and validated before use in a reconciliation loop. +type Builder struct { + base *generic.WorkloadBuilder[*appsv1.StatefulSet, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided StatefulSet object. +// +// The StatefulSet object passed here serves as the "desired base state". During +// reconciliation, the Resource will attempt to make the cluster's state match +// this base state, modified by any registered mutations. +// +// The provided StatefulSet must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(statefulset *appsv1.StatefulSet) *Builder { + identityFunc := func(s *appsv1.StatefulSet) string { + return fmt.Sprintf("apps/v1/StatefulSet/%s/%s", s.Namespace, s.Name) + } + + base := generic.NewWorkloadBuilder[*appsv1.StatefulSet, *Mutator]( + statefulset, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the StatefulSet. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// They are typically used by Features to inject environment variables, +// arguments, or other configuration into the StatefulSet's containers. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomFieldApplicator sets a custom strategy for applying the desired +// state to the existing StatefulSet in the cluster. +// +// There is a default field applicator (DefaultFieldApplicator) that overwrites +// the entire spec of the current object with the desired state, while preserving +// VolumeClaimTemplates from the live object (since they are immutable after creation). +func (b *Builder) WithCustomFieldApplicator( + applicator func(current *appsv1.StatefulSet, desired *appsv1.StatefulSet) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a reusable post-application "flavor" for +// the StatefulSet. +// +// Flavors are applied in the order they are registered, after the baseline field +// applicator (default or custom) has already run. They are typically used to +// preserve selected live fields from the current object that should not be +// overwritten by the desired state. +// +// If the provided flavor is nil, it is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*appsv1.StatefulSet](flavor)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// StatefulSet has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which considers a +// StatefulSet ready when its ReadyReplicas count matches the desired replica count. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the StatefulSet reports its health while +// it is still converging. +// +// The default behavior uses DefaultGraceStatusHandler. +func (b *Builder) WithCustomGraceStatus( + handler func(*appsv1.StatefulSet) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which reports the +// progress of scaling down to zero replicas. +func (b *Builder) WithCustomSuspendStatus( + handler func(*appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the StatefulSet should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which scales the +// StatefulSet to zero replicas. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the StatefulSet when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which does not +// delete StatefulSets during suspension (only scaled down). +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*appsv1.StatefulSet) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// StatefulSet after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields and making them available +// to other components or resources via the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(appsv1.StatefulSet) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(s *appsv1.StatefulSet) error { + return extractor(*s) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base StatefulSet object was provided. +// - The StatefulSet has both a name and a namespace set. +// +// If validation fails, an error is returned and the Resource should not be used. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/statefulset/builder_test.go b/pkg/primitives/statefulset/builder_test.go new file mode 100644 index 00000000..c7140dc9 --- /dev/null +++ b/pkg/primitives/statefulset/builder_test.go @@ -0,0 +1,272 @@ +package statefulset + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + + t.Run("Build validation", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statefulset *appsv1.StatefulSet + expectedErr string + }{ + { + name: "nil statefulset", + statefulset: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid statefulset", + statefulset: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.statefulset).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "apps/v1/StatefulSet/test-ns/test-sts", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(sts). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomFieldApplicator", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_ *appsv1.StatefulSet, _ *appsv1.StatefulSet) error { + applied = true + return nil + } + res, err := NewBuilder(sts). + WithCustomFieldApplicator(applicator). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.CustomFieldApplicator) + _ = res.base.CustomFieldApplicator(nil, nil) + assert.True(t, applied) + }) + + t.Run("WithFieldApplicationFlavor", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(sts). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomConvergeStatus", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(sts). + WithCustomConvergeStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.ConvergingStatusHandler) + status, err := res.base.ConvergingStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusUpdating, status.Status) + }) + + t.Run("WithCustomGraceStatus", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(sts). + WithCustomGraceStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.GraceStatusHandler) + status, err := res.base.GraceStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(sts). + WithCustomSuspendStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendStatusHandler) + status, err := res.base.SuspendStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("WithCustomSuspendMutation", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(sts). + WithCustomSuspendMutation(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendMutationHandler) + err = res.base.SuspendMutationHandler(nil) + assert.EqualError(t, err, "suspend error") + }) + + t.Run("WithCustomSuspendDeletionDecision", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.StatefulSet) bool { + return true + } + res, err := NewBuilder(sts). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ appsv1.StatefulSet) error { + called = true + return nil + } + res, err := NewBuilder(sts). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&appsv1.StatefulSet{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(sts). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/statefulset/flavors.go b/pkg/primitives/statefulset/flavors.go new file mode 100644 index 00000000..09752f5e --- /dev/null +++ b/pkg/primitives/statefulset/flavors.go @@ -0,0 +1,44 @@ +package statefulset + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + "github.com/sourcehawk/operator-component-framework/pkg/flavors/utils" + appsv1 "k8s.io/api/apps/v1" +) + +// FieldApplicationFlavor defines a function signature for applying "flavors" to a resource. +// A flavor typically preserves certain fields from the current (live) object after the +// baseline field application has occurred. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*appsv1.StatefulSet] + +// PreserveCurrentLabels ensures that any labels present on the current live +// StatefulSet but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *appsv1.StatefulSet) error { + return flavors.PreserveCurrentLabels[*appsv1.StatefulSet]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live StatefulSet but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *appsv1.StatefulSet) error { + return flavors.PreserveCurrentAnnotations[*appsv1.StatefulSet]()(applied, current, desired) +} + +// PreserveCurrentPodTemplateLabels ensures that any labels present on the +// current live StatefulSet's pod template but missing from the applied +// (desired) object's pod template are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentPodTemplateLabels(applied, current, _ *appsv1.StatefulSet) error { + applied.Spec.Template.Labels = utils.PreserveMap(applied.Spec.Template.Labels, current.Spec.Template.Labels) + return nil +} + +// PreserveCurrentPodTemplateAnnotations ensures that any annotations present +// on the current live StatefulSet's pod template but missing from the applied +// (desired) object's pod template are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentPodTemplateAnnotations(applied, current, _ *appsv1.StatefulSet) error { + applied.Spec.Template.Annotations = utils.PreserveMap(applied.Spec.Template.Annotations, current.Spec.Template.Annotations) + return nil +} diff --git a/pkg/primitives/statefulset/flavors_test.go b/pkg/primitives/statefulset/flavors_test.go new file mode 100644 index 00000000..560de032 --- /dev/null +++ b/pkg/primitives/statefulset/flavors_test.go @@ -0,0 +1,146 @@ +package statefulset + +import ( + "errors" + "testing" + + "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" +) + +func TestMutate_OrderingAndFlavors(t *testing.T) { + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptrInt32(3), + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + Labels: map[string]string{"extra": "preserved"}, + }, + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(PreserveCurrentLabels). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "desired", current.Labels["app"]) + assert.Equal(t, "preserved", current.Labels["extra"]) + }) + + t.Run("flavors run in registration order", func(t *testing.T) { + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *appsv1.StatefulSet) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *appsv1.StatefulSet) error { + order = append(order, "flavor2") + return nil + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor1). + WithFieldApplicationFlavor(flavor2). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + assert.Equal(t, []string{"flavor1", "flavor2"}, order) + }) + + t.Run("flavor error is returned with context", func(t *testing.T) { + current := &appsv1.StatefulSet{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *appsv1.StatefulSet) error { + return flavorErr + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor). + Build() + + err := res.Mutate(current) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply field application flavor") + assert.True(t, errors.Is(err, flavorErr)) + }) +} + +func TestDefaultFlavors(t *testing.T) { + t.Run("PreserveCurrentLabels", func(t *testing.T) { + applied := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current", "overlap": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Labels["keep"]) + assert.Equal(t, "applied", applied.Labels["overlap"]) + assert.Equal(t, "current", applied.Labels["extra"]) + }) + + t.Run("PreserveCurrentAnnotations", func(t *testing.T) { + applied := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} + + err := PreserveCurrentAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Annotations["keep"]) + assert.Equal(t, "current", applied.Annotations["extra"]) + }) + + t.Run("PreserveCurrentPodTemplateLabels", func(t *testing.T) { + applied := &appsv1.StatefulSet{Spec: appsv1.StatefulSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}} + current := &appsv1.StatefulSet{Spec: appsv1.StatefulSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}}}} + + err := PreserveCurrentPodTemplateLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Spec.Template.Labels["keep"]) + assert.Equal(t, "current", applied.Spec.Template.Labels["extra"]) + }) + + t.Run("PreserveCurrentPodTemplateAnnotations", func(t *testing.T) { + applied := &appsv1.StatefulSet{Spec: appsv1.StatefulSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}} + current := &appsv1.StatefulSet{Spec: appsv1.StatefulSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}}}} + + err := PreserveCurrentPodTemplateAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Spec.Template.Annotations["keep"]) + assert.Equal(t, "current", applied.Spec.Template.Annotations["extra"]) + }) + + t.Run("handles nil maps safely", func(t *testing.T) { + applied := &appsv1.StatefulSet{} + current := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "current", applied.Labels["extra"]) + }) +} + +func ptrInt32(i int32) *int32 { + return &i +} diff --git a/pkg/primitives/statefulset/handlers.go b/pkg/primitives/statefulset/handlers.go new file mode 100644 index 00000000..8627118c --- /dev/null +++ b/pkg/primitives/statefulset/handlers.go @@ -0,0 +1,111 @@ +package statefulset + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + appsv1 "k8s.io/api/apps/v1" +) + +// DefaultConvergingStatusHandler is the default logic for determining if a StatefulSet has reached its desired state. +// +// It considers a StatefulSet ready when its Status.ReadyReplicas matches the Spec.Replicas (defaulting to 1 if nil). +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomConvergeStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultConvergingStatusHandler( + op concepts.ConvergingOperation, sts *appsv1.StatefulSet, +) (concepts.AliveStatusWithReason, error) { + desiredReplicas := int32(1) + if sts.Spec.Replicas != nil { + desiredReplicas = *sts.Spec.Replicas + } + + if sts.Status.ReadyReplicas == desiredReplicas { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "All replicas are ready", + }, nil + } + + var status concepts.AliveConvergingStatus + switch op { + case concepts.ConvergingOperationCreated: + status = concepts.AliveConvergingStatusCreating + case concepts.ConvergingOperationUpdated: + status = concepts.AliveConvergingStatusUpdating + default: + status = concepts.AliveConvergingStatusScaling + } + + return concepts.AliveStatusWithReason{ + Status: status, + Reason: fmt.Sprintf("Waiting for replicas: %d/%d ready", sts.Status.ReadyReplicas, desiredReplicas), + }, nil +} + +// DefaultGraceStatusHandler provides a default health assessment of the StatefulSet when it has not yet +// reached full readiness. +// +// It categorizes the current state into: +// - GraceStatusDegraded: At least one replica is ready, but the desired count is not met. +// - GraceStatusDown: No replicas are ready. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomGraceStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultGraceStatusHandler(sts *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + if sts.Status.ReadyReplicas > 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "StatefulSet partially available", + }, nil + } + + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "No replicas are ready", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the StatefulSet +// when the parent component is suspended. +// +// It always returns false, meaning the StatefulSet is kept in the cluster but scaled to zero replicas. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendDeletionDecision. It can be reused within custom handlers. +func DefaultDeleteOnSuspendHandler(_ *appsv1.StatefulSet) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a StatefulSet when the component is suspended. +// +// It scales the StatefulSet to zero replicas by setting Spec.Replicas to 0. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendMutation. It can be reused within custom handlers. +func DefaultSuspendMutationHandler(mutator *Mutator) error { + mutator.EnsureReplicas(0) + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It reports whether the StatefulSet has successfully scaled down to zero replicas +// by checking if Status.Replicas is 0. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendStatus. It can be reused within custom handlers. +func DefaultSuspensionStatusHandler(sts *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + if sts.Status.Replicas == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "StatefulSet scaled to zero", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("Waiting for replicas to scale down, %d replicas still running.", sts.Status.Replicas), + }, nil +} diff --git a/pkg/primitives/statefulset/handlers_test.go b/pkg/primitives/statefulset/handlers_test.go new file mode 100644 index 00000000..38dc368b --- /dev/null +++ b/pkg/primitives/statefulset/handlers_test.go @@ -0,0 +1,183 @@ +package statefulset + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/utils/ptr" +) + +func TestDefaultConvergingStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + sts *appsv1.StatefulSet + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "ready with 1 replica (default)", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{}, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "ready with custom replicas", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 3, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "creating", + op: concepts.ConvergingOperationCreated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "updating", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "scaling", + op: concepts.ConvergingOperation("Scaling"), + sts: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusScaling, + wantReason: "Waiting for replicas: 1/3 ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.sts) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultGraceStatusHandler(t *testing.T) { + t.Run("degraded (some ready)", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + }, + } + got, err := DefaultGraceStatusHandler(sts) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "StatefulSet partially available", got.Reason) + }) + + t.Run("down (none ready)", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 0, + }, + } + got, err := DefaultGraceStatusHandler(sts) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + assert.Equal(t, "No replicas are ready", got.Reason) + }) +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + sts := &appsv1.StatefulSet{} + assert.False(t, DefaultDeleteOnSuspendHandler(sts)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + mutator := NewMutator(sts) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + assert.Equal(t, int32(0), *sts.Spec.Replicas) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + sts *appsv1.StatefulSet + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended", + sts: &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + Replicas: 0, + }, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "StatefulSet scaled to zero", + }, + { + name: "suspending", + sts: &appsv1.StatefulSet{ + Status: appsv1.StatefulSetStatus{ + Replicas: 2, + }, + }, + wantStatus: concepts.SuspensionStatusSuspending, + wantReason: "Waiting for replicas to scale down, 2 replicas still running.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultSuspensionStatusHandler(tt.sts) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go new file mode 100644 index 00000000..9e6cc52a --- /dev/null +++ b/pkg/primitives/statefulset/mutator.go @@ -0,0 +1,506 @@ +package statefulset + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a statefulset Mutator +// only if its associated feature.ResourceFeature is enabled. +type Mutation feature.Mutation[*Mutator] + +type containerEdit struct { + selector selectors.ContainerSelector + edit func(*editors.ContainerEditor) error +} + +type containerPresenceOp struct { + name string + container *corev1.Container // nil for remove +} + +type volumeClaimTemplateOp struct { + name string + pvc *corev1.PersistentVolumeClaim // nil for remove +} + +type featurePlan struct { + statefulsetMetadataEdits []func(*editors.ObjectMetaEditor) error + statefulsetSpecEdits []func(*editors.StatefulSetSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit + volumeClaimTemplateOps []volumeClaimTemplateOp +} + +// Mutator is a high-level helper for modifying a Kubernetes StatefulSet. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the StatefulSet in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *appsv1.StatefulSet + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given StatefulSet. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the StatefulSet. +func NewMutator(current *appsv1.StatefulSet) *Mutator { + m := &Mutator{ + current: current, + } + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until another +// beginFeature is called. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditContainers records a mutation for containers matching the given selector. +// +// Planning: +// All container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, container edits are executed AFTER container presence operations within the same feature. +// +// Selection: +// - The selector determines which containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selector matching is evaluated against a snapshot taken after the current feature's container presence operations are applied. +func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EditInitContainers records a mutation for init containers matching the given selector. +// +// Planning: +// All init container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, init container edits apply only to spec.template.spec.initContainers. +// - They run in their own category during Apply(), after init container presence operations within the same feature. +// +// Selection: +// - The selector determines which init containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selector matching is evaluated against a snapshot taken after the current feature's init container presence operations are applied. +func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainer records that a regular container must be present in the StatefulSet. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveContainer records that a regular container should be removed by name. +func (m *Mutator) RemoveContainer(name string) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// EnsureInitContainer records that an init container must be present in the StatefulSet. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveInitContainer records that an init container should be removed by name. +func (m *Mutator) RemoveInitContainer(name string) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// EditStatefulSetSpec records a mutation for the StatefulSet's top-level spec. +// +// Planning: +// All statefulset spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, statefulset spec edits are executed AFTER statefulset-metadata edits but BEFORE pod template/spec/container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditStatefulSetSpec(edit func(*editors.StatefulSetSpecEditor) error) { + if edit == nil { + return + } + m.active.statefulsetSpecEdits = append(m.active.statefulsetSpecEdits, edit) +} + +// EditPodSpec records a mutation for the StatefulSet's pod spec. +// +// Planning: +// All pod spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, pod spec edits are executed AFTER metadata edits but BEFORE container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { + if edit == nil { + return + } + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EditPodTemplateMetadata records a mutation for the StatefulSet's pod template metadata. +// +// Planning: +// All pod template metadata edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, pod template metadata edits are executed AFTER statefulset spec edits but BEFORE pod spec/container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodTemplateMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.podTemplateMetadataEdits = append(m.active.podTemplateMetadataEdits, edit) +} + +// EditObjectMetadata records a mutation for the StatefulSet's own metadata. +// +// Planning: +// All object metadata edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, object metadata edits are executed BEFORE all other categories within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.statefulsetMetadataEdits = append(m.active.statefulsetMetadataEdits, edit) +} + +// EnsureReplicas records the desired number of replicas for the StatefulSet. +func (m *Mutator) EnsureReplicas(replicas int32) { + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(replicas) + return nil + }) +} + +// EnsureVolumeClaimTemplate records that a PersistentVolumeClaim template must be +// present in the StatefulSet. If a VolumeClaimTemplate with the same name exists, +// it is replaced; otherwise, it is appended. +// +// Note: VolumeClaimTemplates are immutable once the StatefulSet exists in the cluster. +// Changes to VolumeClaimTemplates on an existing StatefulSet will be rejected by +// the Kubernetes API server. This method is primarily useful for initial creation +// or when recreating a StatefulSet. +func (m *Mutator) EnsureVolumeClaimTemplate(pvc corev1.PersistentVolumeClaim) { + m.active.volumeClaimTemplateOps = append(m.active.volumeClaimTemplateOps, volumeClaimTemplateOp{ + name: pvc.Name, + pvc: &pvc, + }) +} + +// RemoveVolumeClaimTemplate records that a VolumeClaimTemplate should be removed by name. +// +// Note: VolumeClaimTemplates are immutable once the StatefulSet exists in the cluster. +// This method is primarily useful when constructing the initial desired state. +func (m *Mutator) RemoveVolumeClaimTemplate(name string) { + m.active.volumeClaimTemplateOps = append(m.active.volumeClaimTemplateOps, volumeClaimTemplateOp{ + name: name, + pvc: nil, + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) EnsureContainerEnvVar(ev corev1.EnvVar) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(ev) + return nil + }) +} + +// RemoveContainerEnvVar records that an environment variable should be +// removed from all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// RemoveContainerEnvVars records that multiple environment variables should be +// removed from all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVars(names []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVars(names) + return nil + }) +} + +// EnsureContainerArg records that a command-line argument must be present +// in all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) EnsureContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureArg(arg) + return nil + }) +} + +// RemoveContainerArg records that a command-line argument should be +// removed from all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArg(arg) + return nil + }) +} + +// RemoveContainerArgs records that multiple command-line arguments should be +// removed from all containers of the StatefulSet. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerArgs(args []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArgs(args) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying StatefulSet. +// +// Execution Order: +// Features are applied in the order they were registered. +// Within each feature, mutations are applied in this fixed category order: +// 1. Object metadata edits +// 2. StatefulSetSpec edits +// 3. Pod template metadata edits +// 4. Pod spec edits +// 5. Regular container presence operations +// 6. Regular container edits +// 7. Init container presence operations +// 8. Init container edits +// 9. Volume claim template operations +// +// Within each category of a single feature, edits are applied in their registration order. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.statefulsetMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.statefulsetMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. StatefulSetSpec + if len(plan.statefulsetSpecEdits) > 0 { + editor := editors.NewStatefulSetSpecEditor(&m.current.Spec) + for _, edit := range plan.statefulsetSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Pod template metadata + if len(plan.podTemplateMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.Spec.Template.ObjectMeta) + for _, edit := range plan.podTemplateMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 4. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec.Template.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 5. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.Containers, op) + } + + // 6. Regular container edits + if len(plan.containerEdits) > 0 { + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.Containers)) + for i := range m.current.Spec.Template.Spec.Containers { + m.current.Spec.Template.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.Containers { + container := &m.current.Spec.Template.Spec.Containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.containerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + + // 7. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.Template.Spec.InitContainers, op) + } + + // 8. Init container edits + if len(plan.initContainerEdits) > 0 { + snapshots := make([]corev1.Container, len(m.current.Spec.Template.Spec.InitContainers)) + for i := range m.current.Spec.Template.Spec.InitContainers { + m.current.Spec.Template.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Template.Spec.InitContainers { + container := &m.current.Spec.Template.Spec.InitContainers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.initContainerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + + // 9. Volume claim template operations + for _, op := range plan.volumeClaimTemplateOps { + applyVolumeClaimTemplateOp(&m.current.Spec.VolumeClaimTemplates, op) + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.container == nil { + // Remove + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = *op.container + } else { + *containers = append(*containers, *op.container) + } +} + +func applyVolumeClaimTemplateOp(vcts *[]corev1.PersistentVolumeClaim, op volumeClaimTemplateOp) { + found := -1 + for i, v := range *vcts { + if v.Name == op.name { + found = i + break + } + } + + if op.pvc == nil { + // Remove + if found != -1 { + *vcts = append((*vcts)[:found], (*vcts)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*vcts)[found] = *op.pvc + } else { + *vcts = append(*vcts, *op.pvc) + } +} diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go new file mode 100644 index 00000000..33746ef1 --- /dev/null +++ b/pkg/primitives/statefulset/mutator_test.go @@ -0,0 +1,698 @@ +package statefulset + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestMutator_EnvVars(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + {Name: "KEEP", Value: "stay"}, + {Name: "CHANGE", Value: "old"}, + {Name: "REMOVE", Value: "gone"}, + }, + }, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) + m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) + + err := m.Apply() + require.NoError(t, err) + + env := sts.Spec.Template.Spec.Containers[0].Env + assert.Len(t, env, 3) + + findEnv := func(name string) *corev1.EnvVar { + for _, e := range env { + if e.Name == name { + return &e + } + } + return nil + } + + assert.NotNil(t, findEnv("KEEP")) + assert.Equal(t, "stay", findEnv("KEEP").Value) + assert.Equal(t, "new", findEnv("CHANGE").Value) + assert.Equal(t, "added", findEnv("ADD").Value) + assert.Nil(t, findEnv("REMOVE")) +} + +func TestMutator_Args(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := sts.Spec.Template.Spec.Containers[0].Args + assert.Contains(t, args, "--keep") + assert.Contains(t, args, "--change=old") + assert.Contains(t, args, "--change=new") + assert.Contains(t, args, "--add") + assert.NotContains(t, args, "--remove") +} + +func TestMutator_Replicas(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + m := NewMutator(sts) + m.EnsureReplicas(5) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, int32(5), *sts.Spec.Replicas) +} + +func TestNewMutator(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + assert.NotNil(t, m) + assert.Equal(t, sts, m.current) +} + +func TestMutator_EditContainers(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "c1-image" + return nil + }) + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "GLOBAL", Value: "true"}) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "c1-image", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", sts.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", sts.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", sts.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.Raw().ServiceAccountName = "my-sa" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-sa", sts.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditStatefulSetSpec(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetServiceName("my-service") + e.SetMinReadySeconds(10) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-service", sts.Spec.ServiceName) + assert.Equal(t, int32(10), sts.Spec.MinReadySeconds) +} + +func TestMutator_EditMetadata(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"sts": "label"} + return nil + }) + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Annotations = map[string]string{"pod": "ann"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", sts.Labels["sts"]) + assert.Equal(t, "ann", sts.Spec.Template.Annotations["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + return errors.New("boom") + }) + + err := m.Apply() + assert.Error(t, err) + assert.Equal(t, "boom", err.Error()) +} + +func TestMutator_Order(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"orig": "label"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + + var order []string + + m := NewMutator(sts) + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + order = append(order, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "podmeta") + return nil + }) + m.EditStatefulSetSpec(func(_ *editors.StatefulSetSpecEditor) error { + order = append(order, "stsspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "stsmeta") + return nil + }) + m.EnsureReplicas(3) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"stsmeta", "stsspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) + assert.Equal(t, int32(3), *sts.Spec.Replicas) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = newImage + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, newImage, sts.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + m.RemoveContainer("sidecar") + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.Containers, 2) + assert.Equal(t, "app", sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", sts.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", sts.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, newImage, sts.Spec.Template.Spec.Containers[1].Image) +} + +func TestMutator_InitContainerPresence(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", sts.Spec.Template.Spec.InitContainers[0].Name) +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + } + + m := NewMutator(sts) + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = appV2 + return nil + }) + + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, appV2, sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_NilSafety(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + m := NewMutator(sts) + + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditInitContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditInitContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditStatefulSetSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.beginFeature() + m.EnsureReplicas(2) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + m.beginFeature() + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, int32(3), *sts.Spec.Replicas) + assert.Equal(t, "v3", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(sts) + + var executionOrder []string + + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditPodTemplateMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + m.EditStatefulSetSpec(func(_ *editors.StatefulSetSpecEditor) error { + executionOrder = append(executionOrder, "statefulsetspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "statefulsetmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expectedOrder := []string{ + "statefulsetmeta", + "statefulsetspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "app-v2", sts.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", sts.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(sts) + + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-final" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + require.Len(t, sts.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", sts.Spec.Template.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", sts.Spec.Template.Spec.InitContainers[0].Image) +} + +func TestMutator_VolumeClaimTemplates(t *testing.T) { + t.Run("ensure adds new VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }) + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "data", sts.Spec.VolumeClaimTemplates[0].Name) + }) + + t.Run("ensure replaces existing VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("20Gi"), + }, + }, + }, + }) + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + qty := sts.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] + assert.Equal(t, "20Gi", qty.String()) + }) + + t.Run("remove VCT", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "logs"}}, + }, + }, + } + m := NewMutator(sts) + + m.RemoveVolumeClaimTemplate("data") + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "logs", sts.Spec.VolumeClaimTemplates[0].Name) + }) + + t.Run("remove nonexistent VCT is no-op", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + + m.RemoveVolumeClaimTemplate("nonexistent") + + err := m.Apply() + require.NoError(t, err) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + }) + + t.Run("VCT ops run after container edits", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + var order []string + m := NewMutator(sts) + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + }) + + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + // Container edits run first (step 6), then VCT ops (step 9) + assert.Equal(t, []string{"container"}, order) + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + }) +} diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go new file mode 100644 index 00000000..df17b8db --- /dev/null +++ b/pkg/primitives/statefulset/resource.go @@ -0,0 +1,136 @@ +package statefulset + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator replaces current with a deep copy of desired, but preserves +// the existing VolumeClaimTemplates from the current object. +// +// spec.volumeClaimTemplates is immutable after creation in Kubernetes. Attempting to +// update it will be rejected by the API server. This applicator preserves the live +// VolumeClaimTemplates to avoid such rejections while still replacing all other fields. +func DefaultFieldApplicator(current, desired *appsv1.StatefulSet) error { + vcts := current.Spec.VolumeClaimTemplates + *current = *desired.DeepCopy() + if current.ResourceVersion != "" { + current.Spec.VolumeClaimTemplates = vcts + } + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes StatefulSet within a controller's +// reconciliation loop. +// +// It implements several component interfaces to integrate with the operator-component-framework: +// - component.Resource: for basic identity and mutation behavior. +// - component.Alive: for health and readiness tracking. +// - component.Suspendable: for graceful scale-down or temporary deactivation. +// - component.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a StatefulSet, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*appsv1.StatefulSet, *Mutator] +} + +// Identity returns a unique identifier for the StatefulSet in the format +// "apps/v1/StatefulSet//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific StatefulSet from other resources +// managed by the same component. +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes StatefulSet object. +// +// The returned object implements the client.Object interface, making it +// fully compatible with controller-runtime's Client for operations like +// Get, Create, Update, and Patch. +// +// This method is called by the framework to obtain the current state +// of the resource before applying mutations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes StatefulSet into the desired state. +// +// The mutation process follows a specific order: +// 1. Core State: The current object is reset to the desired base state, or +// modified via a custom customFieldApplicator if one is configured. +// 2. Feature Mutations: All registered feature-based mutations are applied, +// allowing for granular, version-gated changes to the StatefulSet. +// 3. Suspension: If the resource is in a suspending state, the suspension +// logic (e.g., scaling to zero) is applied. +// +// This method is invoked by the framework during the "Update" phase of +// reconciliation. It ensures that the in-memory object reflects all +// configuration and feature requirements before it is sent to the API server. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates if the StatefulSet has successfully reached its desired state. +// +// By default, it uses DefaultConvergingStatusHandler, which checks if the number of ReadyReplicas +// matches the desired replica count. +// +// The return value includes a descriptive status (Ready, Creating, Updating, or Scaling) +// and a human-readable reason, which are used to update the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// GraceStatus provides a health assessment of the StatefulSet when it has not yet +// reached full readiness. +// +// By default, it uses DefaultGraceStatusHandler, which categorizes the current state into: +// - GraceStatusDegraded: At least one replica is ready, but the desired count is not met. +// - GraceStatusDown: No replicas are ready. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the StatefulSet should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false, meaning +// the StatefulSet is kept in the cluster but scaled to zero replicas. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the StatefulSet. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler to scale the StatefulSet +// to zero replicas, which effectively stops the application while keeping the +// Kubernetes resource intact. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which reports whether the +// StatefulSet has successfully scaled down to zero replicas or is still in the +// process of doing so. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled StatefulSet. +// +// This is called by the framework after a successful reconciliation of the +// resource. It allows the component to export details that might be needed by +// other resources or higher-level controllers. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From dff72992bc18b6c832df93e221bb09f62965e959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:44:06 +0000 Subject: [PATCH 03/26] Add StatefulSet primitive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/statefulset.md | 352 +++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/primitives/statefulset.md diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md new file mode 100644 index 00000000..9a058b6d --- /dev/null +++ b/docs/primitives/statefulset.md @@ -0,0 +1,352 @@ +# 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. + +## Capabilities + +| Capability | Detail | +|-----------------------|-------------------------------------------------------------------------------------------------| +| **Health tracking** | Monitors `ReadyReplicas` and 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, statefulset spec, pod spec, containers, and volume claim templates | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | + +## Building a StatefulSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + +base := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db", + Namespace: owner.Namespace, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: "db-headless", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "db"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "db"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "db", Image: "postgres:15"}, + }, + }, + }, + }, +} + +resource, err := statefulset.NewBuilder(base). + WithFieldApplicationFlavor(statefulset.PreserveCurrentLabels). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Default Field Application + +`DefaultFieldApplicator` replaces the current StatefulSet with a deep copy of the desired object, but preserves `spec.volumeClaimTemplates` from the live object when the resource already exists (i.e., has a non-empty `ResourceVersion`). + +This is necessary because `spec.volumeClaimTemplates` is immutable after creation in Kubernetes — the API server rejects any update that changes it. + +Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: + +```go +resource, err := statefulset.NewBuilder(base). + WithCustomFieldApplicator(func(current, desired *appsv1.StatefulSet) error { + // Custom merge logic + current.Spec.Template = desired.Spec.Template.DeepCopy() + return nil + }). + Build() +``` + +## 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: + +```go +func MyFeatureMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(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.NewResourceFeature(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.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + 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"}) + 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. This ensures structural consistency across mutations. + +| Step | Category | What it affects | +|---|---|---| +| 1 | StatefulSet 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 | +| 5 | Regular container presence | Adding or removing containers from `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` | +| 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. + +## Editors + +### StatefulSetSpecEditor + +Controls statefulset-level settings via `m.EditStatefulSetSpec`. + +Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`, `SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`. + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.SetReplicas(3) + e.SetServiceName("db-headless") + e.SetPodManagementPolicy(appsv1.ParallelPodManagement) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { + e.Raw().UpdateStrategy = appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.OnDeleteStatefulSetStrategyType, + } + return nil +}) +``` + +### 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`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("db-sa") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "db-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "PGDATA", Value: "/var/lib/postgresql/data"}) + e.SetResourceLimit(corev1.ResourceMemory, resource.MustParse("2Gi")) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `StatefulSet` object itself, or `m.EditPodTemplateMetadata` to target 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 +}) +``` + +## Volume Claim Templates + +The mutator provides `EnsureVolumeClaimTemplate` and `RemoveVolumeClaimTemplate` for managing persistent storage: + +```go +m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, +}) +``` + +**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the live VolumeClaimTemplates to avoid API server rejections. These mutation methods are primarily useful for constructing the initial desired state or when recreating a StatefulSet. + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers: + +| 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)` | + +## Flavors + +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. + +### PreserveCurrentLabels + +Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +### PreserveCurrentPodTemplateLabels + +Preserves labels present on the live object's pod template but absent from the applied desired state's pod template. Applied labels win on overlap. + +### PreserveCurrentPodTemplateAnnotations + +Preserves annotations present on the live object's pod template but absent from the applied desired state's pod template. Applied annotations win on overlap. + +```go +resource, err := statefulset.NewBuilder(base). + WithFieldApplicationFlavor(statefulset.PreserveCurrentLabels). + WithFieldApplicationFlavor(statefulset.PreserveCurrentAnnotations). + WithFieldApplicationFlavor(statefulset.PreserveCurrentPodTemplateLabels). + WithFieldApplicationFlavor(statefulset.PreserveCurrentPodTemplateAnnotations). + Build() +``` + +Multiple flavors can be registered and run in registration order. + +## Full Example: Database StatefulSet with Storage + +```go +func DatabaseMutation(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "database-storage", + Feature: feature.NewResourceFeature(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{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("50Gi"), + }, + }, + }, + }) + + // 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", + MountPath: "/var/lib/postgresql/data", + }) + return nil + }) + + return nil + }, + } +} +``` + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for 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. + +**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. The `DefaultFieldApplicator` preserves live VolumeClaimTemplates to prevent API server rejections on updates. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. From ccbe198fc311eaaed8aeb43d60732601de3d49f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:46:25 +0000 Subject: [PATCH 04/26] Add StatefulSet primitive example Demonstrates building a StatefulSet with feature mutations, flavors, custom status handlers, suspension logic, and data extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/statefulset-primitive/README.md | 34 +++++ .../statefulset-primitive/app/controller.go | 50 ++++++++ examples/statefulset-primitive/app/owner.go | 20 +++ .../statefulset-primitive/features/flavors.go | 73 +++++++++++ .../features/mutations.go | 75 +++++++++++ examples/statefulset-primitive/main.go | 121 ++++++++++++++++++ .../resources/statefulset.go | 101 +++++++++++++++ 7 files changed, 474 insertions(+) create mode 100644 examples/statefulset-primitive/README.md create mode 100644 examples/statefulset-primitive/app/controller.go create mode 100644 examples/statefulset-primitive/app/owner.go create mode 100644 examples/statefulset-primitive/features/flavors.go create mode 100644 examples/statefulset-primitive/features/mutations.go create mode 100644 examples/statefulset-primitive/main.go create mode 100644 examples/statefulset-primitive/resources/statefulset.go diff --git a/examples/statefulset-primitive/README.md b/examples/statefulset-primitive/README.md new file mode 100644 index 00000000..3dfbae57 --- /dev/null +++ b/examples/statefulset-primitive/README.md @@ -0,0 +1,34 @@ +# StatefulSet Primitive Example + +This example demonstrates the usage of the `statefulset` primitive within the operator component framework. +It shows how to manage a Kubernetes StatefulSet as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a StatefulSet with basic metadata, spec, and volume claim templates. +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). +- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health assessment during rollouts (`GraceStatus`). +- **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: sidecar injection, env vars, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields, plus custom status and suspension handlers. +- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the `statefulset.Builder`. +- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/statefulset-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components. +4. Print the resulting status conditions. diff --git a/examples/statefulset-primitive/app/controller.go b/examples/statefulset-primitive/app/controller.go new file mode 100644 index 00000000..29a7aca9 --- /dev/null +++ b/examples/statefulset-primitive/app/controller.go @@ -0,0 +1,50 @@ +// Package app provides a sample controller using the statefulset primitive. +package app + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewStatefulSetResource is a factory function to create the statefulset resource. + NewStatefulSetResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + stsResource, err := r.NewStatefulSetResource(owner) + if err != nil { + return err + } + + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(stsResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/statefulset-primitive/app/owner.go b/examples/statefulset-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/statefulset-primitive/app/owner.go @@ -0,0 +1,20 @@ +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/statefulset-primitive/features/flavors.go b/examples/statefulset-primitive/features/flavors.go new file mode 100644 index 00000000..d8169b6d --- /dev/null +++ b/examples/statefulset-primitive/features/flavors.go @@ -0,0 +1,73 @@ +// Package features provides sample features for the statefulset primitive. +package features + +import ( + "fmt" + "time" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + appsv1 "k8s.io/api/apps/v1" +) + +// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. +func PreserveLabelsFlavor() statefulset.FieldApplicationFlavor { + return statefulset.PreserveCurrentLabels +} + +// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. +func PreserveAnnotationsFlavor() statefulset.FieldApplicationFlavor { + return statefulset.PreserveCurrentAnnotations +} + +// CustomConvergeStatus demonstrates a custom handler for statefulset readiness. +func CustomConvergeStatus() func(concepts.ConvergingOperation, *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + return func(op concepts.ConvergingOperation, s *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + status, err := statefulset.DefaultConvergingStatusHandler(op, s) + if err != nil { + return status, err + } + + if status.Status == concepts.AliveConvergingStatusHealthy { + status.Reason = "Application is fully operational and healthy" + } else { + status.Reason = fmt.Sprintf("Application is warming up: %s", status.Reason) + } + + return status, nil + } +} + +// CustomGraceStatus demonstrates a custom handler for health when not ready. +func CustomGraceStatus() func(*appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + return func(s *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + if s.Status.ReadyReplicas < 2 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "At least 2 replicas are required for minimal service", + }, nil + } + + return statefulset.DefaultGraceStatusHandler(s) + } +} + +// CustomSuspendMutation demonstrates a custom mutation when suspended. +func CustomSuspendMutation() func(*statefulset.Mutator) error { + return func(m *statefulset.Mutator) error { + if err := statefulset.DefaultSuspendMutationHandler(m); err != nil { + return err + } + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + raw := meta.Raw() + if _, exists := raw.Annotations["example.io/suspended-at"]; !exists { + meta.EnsureAnnotation("example.io/suspended-at", time.Now().UTC().Format(time.RFC3339)) + } + return nil + }) + + return nil + } +} diff --git a/examples/statefulset-primitive/features/mutations.go b/examples/statefulset-primitive/features/mutations.go new file mode 100644 index 00000000..d324f826 --- /dev/null +++ b/examples/statefulset-primitive/features/mutations.go @@ -0,0 +1,75 @@ +package features + +import ( + "fmt" + + "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/statefulset" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds a Jaeger sidecar to the statefulset. +func TracingFeature(enabled bool) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *statefulset.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 + }, + } +} + +// MetricsFeature adds an exporter sidecar and some annotations. +func MetricsFeature(enabled bool, port int) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *statefulset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "prometheus-exporter", + Image: "prom/node-exporter:v1.3.1", + }) + + m.EditPodTemplateMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("prometheus.io/scrape", "true") + meta.EnsureAnnotation("prometheus.io/port", fmt.Sprintf("%d", port)) + return nil + }) + + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) statefulset.Mutation { + return statefulset.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *statefulset.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app:%s", version) + return nil + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} diff --git a/examples/statefulset-primitive/main.go b/examples/statefulset-primitive/main.go new file mode 100644 index 00000000..1f1dce92 --- /dev/null +++ b/examples/statefulset-primitive/main.go @@ -0,0 +1,121 @@ +// Package main is the entry point for the statefulset primitive example. +package main + +import ( + "context" + "fmt" + "os" + + ocm "github.com/sourcehawk/go-crd-condition-metrics/pkg/crd-condition-metrics" + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/resources" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client for the example. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := appsv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add apps/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + NewStatefulSetResource: resources.NewStatefulSetResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: true, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Tracing=%v, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/statefulset-primitive/resources/statefulset.go b/examples/statefulset-primitive/resources/statefulset.go new file mode 100644 index 00000000..3b94b871 --- /dev/null +++ b/examples/statefulset-primitive/resources/statefulset.go @@ -0,0 +1,101 @@ +// Package resources provides resource implementations for the statefulset primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/statefulset-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewStatefulSetResource constructs a statefulset primitive resource with all the features. +func NewStatefulSetResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base statefulset object. + base := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-statefulset", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: owner.Name + "-headless", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": owner.Name, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "my-app:latest", // Will be overwritten by VersionFeature + }, + }, + }, + }, + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }, + }, + }, + } + + // 2. Initialize the statefulset builder. + builder := statefulset.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionFeature(owner.Spec.Version)) + builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) + + // 4. Configure flavors. + builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) + builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) + + // 5. Configure custom status handlers. + builder.WithCustomConvergeStatus(features.CustomConvergeStatus()) + builder.WithCustomGraceStatus(features.CustomGraceStatus()) + + // 6. Configure custom suspension logic. + builder.WithCustomSuspendMutation(features.CustomSuspendMutation()) + + // 7. Data extraction (optional). + builder.WithDataExtractor(func(s appsv1.StatefulSet) error { + fmt.Printf("Reconciling statefulset: %s, ready replicas: %d\n", s.Name, s.Status.ReadyReplicas) + + y, err := yaml.Marshal(s) + if err != nil { + return fmt.Errorf("failed to marshal statefulset to yaml: %w", err) + } + fmt.Printf("Complete StatefulSet Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 8. Build the final resource. + return builder.Build() +} From 805449134c104e018fb4bf53c125f98cfa422e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:48:54 +0000 Subject: [PATCH 05/26] Fix formatting in statefulset mutator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/mutator.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index 9e6cc52a..a7e5669f 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -28,15 +28,15 @@ type volumeClaimTemplateOp struct { } type featurePlan struct { - statefulsetMetadataEdits []func(*editors.ObjectMetaEditor) error - statefulsetSpecEdits []func(*editors.StatefulSetSpecEditor) error - podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error - podSpecEdits []func(*editors.PodSpecEditor) error - containerPresence []containerPresenceOp - containerEdits []containerEdit - initContainerPresence []containerPresenceOp - initContainerEdits []containerEdit - volumeClaimTemplateOps []volumeClaimTemplateOp + statefulsetMetadataEdits []func(*editors.ObjectMetaEditor) error + statefulsetSpecEdits []func(*editors.StatefulSetSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit + volumeClaimTemplateOps []volumeClaimTemplateOp } // Mutator is a high-level helper for modifying a Kubernetes StatefulSet. From f458085c242a37e37dc1862357c0e5ce21f083bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 03:31:20 +0000 Subject: [PATCH 06/26] commit statefulsetspec file --- pkg/mutation/editors/statefulsetspec.go | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg/mutation/editors/statefulsetspec.go diff --git a/pkg/mutation/editors/statefulsetspec.go b/pkg/mutation/editors/statefulsetspec.go new file mode 100644 index 00000000..be53a4b8 --- /dev/null +++ b/pkg/mutation/editors/statefulsetspec.go @@ -0,0 +1,61 @@ +package editors + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// StatefulSetSpecEditor provides a typed API for mutating a Kubernetes StatefulSetSpec. +type StatefulSetSpecEditor struct { + spec *appsv1.StatefulSetSpec +} + +// NewStatefulSetSpecEditor creates a new StatefulSetSpecEditor for the given StatefulSetSpec. +func NewStatefulSetSpecEditor(spec *appsv1.StatefulSetSpec) *StatefulSetSpecEditor { + return &StatefulSetSpecEditor{spec: spec} +} + +// Raw returns the underlying *appsv1.StatefulSetSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *StatefulSetSpecEditor) Raw() *appsv1.StatefulSetSpec { + return e.spec +} + +// SetReplicas sets the number of desired replicas for the StatefulSet. +func (e *StatefulSetSpecEditor) SetReplicas(replicas int32) { + e.spec.Replicas = &replicas +} + +// SetServiceName sets the name of the governing Service for the StatefulSet. +func (e *StatefulSetSpecEditor) SetServiceName(name string) { + e.spec.ServiceName = name +} + +// SetPodManagementPolicy sets the policy for creating pods under the StatefulSet. +func (e *StatefulSetSpecEditor) SetPodManagementPolicy(policy appsv1.PodManagementPolicyType) { + e.spec.PodManagementPolicy = policy +} + +// SetUpdateStrategy sets the update strategy for the StatefulSet. +func (e *StatefulSetSpecEditor) SetUpdateStrategy(strategy appsv1.StatefulSetUpdateStrategy) { + e.spec.UpdateStrategy = strategy +} + +// SetRevisionHistoryLimit sets the number of revisions to retain in the StatefulSet's revision history. +func (e *StatefulSetSpecEditor) SetRevisionHistoryLimit(limit int32) { + e.spec.RevisionHistoryLimit = &limit +} + +// SetMinReadySeconds sets the minimum number of seconds for which a newly created pod should be ready +// before it is considered available. +func (e *StatefulSetSpecEditor) SetMinReadySeconds(seconds int32) { + e.spec.MinReadySeconds = seconds +} + +// SetPersistentVolumeClaimRetentionPolicy sets the policy for retaining PVCs +// when pods are scaled down or the StatefulSet is deleted. +func (e *StatefulSetSpecEditor) SetPersistentVolumeClaimRetentionPolicy( + policy *appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy, +) { + e.spec.PersistentVolumeClaimRetentionPolicy = policy +} From 77f2693a4d3b3689908d008e996ca27913faad3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:31:24 +0000 Subject: [PATCH 07/26] Fix Copilot review comments on StatefulSet primitive - Fix DefaultFieldApplicator VolumeClaimTemplates preservation: capture ResourceVersion before DeepCopy overwrites it, so VCTs are correctly preserved on updates - Add unit tests for DefaultFieldApplicator create and update paths - Fix docs: dereference DeepCopy pointer in custom applicator example - Fix docs: use correct spec.template.spec.* field paths in mutation ordering table Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/statefulset.md | 6 +- pkg/primitives/statefulset/resource.go | 7 +- pkg/primitives/statefulset/resource_test.go | 90 +++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 pkg/primitives/statefulset/resource_test.go diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md index 9a058b6d..805147bd 100644 --- a/docs/primitives/statefulset.md +++ b/docs/primitives/statefulset.md @@ -58,7 +58,7 @@ Use `WithCustomFieldApplicator` when other controllers manage fields that should resource, err := statefulset.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *appsv1.StatefulSet) error { // Custom merge logic - current.Spec.Template = desired.Spec.Template.DeepCopy() + current.Spec.Template = *desired.Spec.Template.DeepCopy() return nil }). Build() @@ -142,9 +142,9 @@ Within a single mutation, edit operations are grouped into categories and applie | 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 | -| 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) | | 9 | Volume claim template operations | Adding or removing entries from `spec.volumeClaimTemplates` | diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go index df17b8db..f0347993 100644 --- a/pkg/primitives/statefulset/resource.go +++ b/pkg/primitives/statefulset/resource.go @@ -14,9 +14,14 @@ import ( // update it will be rejected by the API server. This applicator preserves the live // VolumeClaimTemplates to avoid such rejections while still replacing all other fields. func DefaultFieldApplicator(current, desired *appsv1.StatefulSet) error { + // Capture whether the live object already exists before overwriting. + // DeepCopy clears ResourceVersion, so we must check before the copy. + exists := current.ResourceVersion != "" + vcts := current.Spec.VolumeClaimTemplates *current = *desired.DeepCopy() - if current.ResourceVersion != "" { + + if exists { current.Spec.VolumeClaimTemplates = vcts } return nil diff --git a/pkg/primitives/statefulset/resource_test.go b/pkg/primitives/statefulset/resource_test.go new file mode 100644 index 00000000..39cfbc2c --- /dev/null +++ b/pkg/primitives/statefulset/resource_test.go @@ -0,0 +1,90 @@ +package statefulset + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDefaultFieldApplicator_Create(t *testing.T) { + current := &appsv1.StatefulSet{} + desired := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + ServiceName: "my-svc", + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{Name: "data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, "my-svc", current.Spec.ServiceName) + require.Len(t, current.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "data", current.Spec.VolumeClaimTemplates[0].Name) +} + +func TestDefaultFieldApplicator_Update_PreservesVCTs(t *testing.T) { + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "12345", + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: "old-svc", + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{Name: "live-data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + }, + }, + }, + } + desired := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + ServiceName: "new-svc", + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + { + ObjectMeta: metav1.ObjectMeta{Name: "desired-data"}, + Spec: corev1.PersistentVolumeClaimSpec{ + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("50Gi"), + }, + }, + }, + }, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, "new-svc", current.Spec.ServiceName) + // VCTs should be preserved from the live object, not replaced by desired + require.Len(t, current.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "live-data", current.Spec.VolumeClaimTemplates[0].Name) + qty := current.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] + assert.Equal(t, "10Gi", qty.String()) +} From 490c612a8f9b1d0fb79a23ec8bb83ae0e7497c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:27:47 +0000 Subject: [PATCH 08/26] Fix comment typos in StatefulSet resource docs - Remove duplicated word in Mutate comment ("custom customFieldApplicator" -> "custom field applicator") - Fix incorrect status name in ConvergingStatus comment ("Ready" -> "Healthy" to match AliveConvergingStatusHealthy) Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/resource.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go index f0347993..d7e57d9d 100644 --- a/pkg/primitives/statefulset/resource.go +++ b/pkg/primitives/statefulset/resource.go @@ -68,7 +68,7 @@ func (r *Resource) Object() (client.Object, error) { // // The mutation process follows a specific order: // 1. Core State: The current object is reset to the desired base state, or -// modified via a custom customFieldApplicator if one is configured. +// modified via a custom field applicator if one is configured. // 2. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the StatefulSet. // 3. Suspension: If the resource is in a suspending state, the suspension @@ -86,7 +86,7 @@ func (r *Resource) Mutate(current client.Object) error { // By default, it uses DefaultConvergingStatusHandler, which checks if the number of ReadyReplicas // matches the desired replica count. // -// The return value includes a descriptive status (Ready, Creating, Updating, or Scaling) +// The return value includes a descriptive status (Healthy, Creating, Updating, or Scaling) // and a human-readable reason, which are used to update the component's conditions. func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { return r.base.ConvergingStatus(op) From 90b402f6dce085249e55d7984780b4b0fcdbeb7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:48:23 +0000 Subject: [PATCH 09/26] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/resource.go | 20 +++++----- pkg/primitives/statefulset/resource_test.go | 44 +++++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go index d7e57d9d..f7a5a21a 100644 --- a/pkg/primitives/statefulset/resource.go +++ b/pkg/primitives/statefulset/resource.go @@ -7,21 +7,23 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired, but preserves -// the existing VolumeClaimTemplates from the current object. +// DefaultFieldApplicator replaces current with a deep copy of desired while +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) +// and shared-controller fields (OwnerReferences, Finalizers) from the original +// current object. // -// spec.volumeClaimTemplates is immutable after creation in Kubernetes. Attempting to -// update it will be rejected by the API server. This applicator preserves the live -// VolumeClaimTemplates to avoid such rejections while still replacing all other fields. +// It also preserves the existing VolumeClaimTemplates from the current object +// when it already exists on the server. spec.volumeClaimTemplates is immutable +// after creation in Kubernetes; attempting to update it will be rejected by the +// API server. func DefaultFieldApplicator(current, desired *appsv1.StatefulSet) error { - // Capture whether the live object already exists before overwriting. - // DeepCopy clears ResourceVersion, so we must check before the copy. - exists := current.ResourceVersion != "" + original := current.DeepCopy() vcts := current.Spec.VolumeClaimTemplates *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) - if exists { + if original.ResourceVersion != "" { current.Spec.VolumeClaimTemplates = vcts } return nil diff --git a/pkg/primitives/statefulset/resource_test.go b/pkg/primitives/statefulset/resource_test.go index 39cfbc2c..fba3893e 100644 --- a/pkg/primitives/statefulset/resource_test.go +++ b/pkg/primitives/statefulset/resource_test.go @@ -9,6 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" ) func TestDefaultFieldApplicator_Create(t *testing.T) { @@ -88,3 +89,46 @@ func TestDefaultFieldApplicator_Update_PreservesVCTs(t *testing.T) { qty := current.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] assert.Equal(t, "10Gi", qty.String()) } + +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + } + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, int32(3), *current.Spec.Replicas) + assert.Equal(t, "test", current.Labels["app"]) + + // Server-managed fields are preserved + assert.Equal(t, "12345", current.ResourceVersion) + assert.Equal(t, "abc-def", string(current.UID)) + assert.Equal(t, int64(3), current.Generation) + + // Shared-controller fields are preserved + assert.Len(t, current.OwnerReferences, 1) + assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) + assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) +} From d6a38e3e6b32c90844733664fc9c7276798c559c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 22:28:18 +0000 Subject: [PATCH 10/26] Skip VolumeClaimTemplate mutations on existing StatefulSets VolumeClaimTemplates are immutable after creation in Kubernetes. The DefaultFieldApplicator already preserves them on updates, but feature mutations in Apply() step 9 could overwrite that preservation, causing API server rejections. Guard VCT ops with a ResourceVersion check so they only run during initial creation. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/mutator.go | 8 +++++-- pkg/primitives/statefulset/mutator_test.go | 26 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index a7e5669f..90ba6a12 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -447,8 +447,12 @@ func (m *Mutator) Apply() error { } // 9. Volume claim template operations - for _, op := range plan.volumeClaimTemplateOps { - applyVolumeClaimTemplateOp(&m.current.Spec.VolumeClaimTemplates, op) + // VolumeClaimTemplates are immutable after creation. Only apply these + // operations when the StatefulSet does not yet exist on the server. + if m.current.ResourceVersion == "" { + for _, op := range plan.volumeClaimTemplateOps { + applyVolumeClaimTemplateOp(&m.current.Spec.VolumeClaimTemplates, op) + } } } diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go index 33746ef1..8a07a91e 100644 --- a/pkg/primitives/statefulset/mutator_test.go +++ b/pkg/primitives/statefulset/mutator_test.go @@ -665,6 +665,32 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { require.Len(t, sts.Spec.VolumeClaimTemplates, 1) }) + t.Run("VCT ops are skipped on existing StatefulSet", func(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "12345", + }, + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{ + {ObjectMeta: metav1.ObjectMeta{Name: "data"}}, + }, + }, + } + m := NewMutator(sts) + + m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "new-volume"}, + }) + m.RemoveVolumeClaimTemplate("data") + + err := m.Apply() + require.NoError(t, err) + + // VCT ops should be skipped since the StatefulSet already exists + require.Len(t, sts.Spec.VolumeClaimTemplates, 1) + assert.Equal(t, "data", sts.Spec.VolumeClaimTemplates[0].Name) + }) + t.Run("VCT ops run after container edits", func(t *testing.T) { sts := &appsv1.StatefulSet{ Spec: appsv1.StatefulSetSpec{ From 17185125cdb7652948dc839f0fbde2f18174b443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:23:55 +0000 Subject: [PATCH 11/26] Fix range variable pointer bug and align docs with implementation - Fix findEnv helper in mutator_test.go to use index-based iteration instead of taking address of range variable (which is reused per iteration) - Update capabilities table in statefulset.md to reflect actual statuses (no Failing status, no grace-period timing logic) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/statefulset.md | 14 +++++++------- pkg/primitives/statefulset/mutator_test.go | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md index 805147bd..eb26c20a 100644 --- a/docs/primitives/statefulset.md +++ b/docs/primitives/statefulset.md @@ -4,13 +4,13 @@ The `statefulset` primitive is the framework's built-in workload abstraction for ## Capabilities -| Capability | Detail | -|-----------------------|-------------------------------------------------------------------------------------------------| -| **Health tracking** | Monitors `ReadyReplicas` and 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, statefulset spec, pod spec, containers, and volume claim templates | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +| Capability | Detail | +|-----------------------|-------------------------------------------------------------------------------------------------------------------| +| **Health tracking** | Monitors `ReadyReplicas` and 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 | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Building a StatefulSet Primitive diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go index 8a07a91e..5f8c51d8 100644 --- a/pkg/primitives/statefulset/mutator_test.go +++ b/pkg/primitives/statefulset/mutator_test.go @@ -47,9 +47,9 @@ func TestMutator_EnvVars(t *testing.T) { assert.Len(t, env, 3) findEnv := func(name string) *corev1.EnvVar { - for _, e := range env { - if e.Name == name { - return &e + for i := range env { + if env[i].Name == name { + return &env[i] } } return nil From 25c31c6254e8e601700ee43cdcb99ad227d7e97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:23 +0000 Subject: [PATCH 12/26] Fix statefulset mutator constructor to not call beginFeature Aligns with the fix applied to deployment and configmap mutators on main. The constructor now directly initializes plans with a pre-allocated featurePlan, avoiding the empty feature created when mutator_helper.go subsequently calls beginFeature. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/mutator.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index 90ba6a12..103da467 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -60,8 +60,9 @@ type Mutator struct { func NewMutator(current *appsv1.StatefulSet) *Mutator { m := &Mutator{ current: current, + plans: []featurePlan{{}}, } - m.beginFeature() + m.active = &m.plans[0] return m } From cde2525e2ffbfb0cf2ab0fbe62e905b02e6054f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:59:31 +0000 Subject: [PATCH 13/26] Add resource-level tests for StatefulSet primitive Adds comprehensive integration tests for the StatefulSet Resource type, covering Identity, Object deep-copy, Mutate with feature mutations, feature ordering, status handlers (converging, grace, suspension), DeleteOnSuspend, Suspend, ExtractData, and custom field applicator. This aligns test coverage with the Deployment primitive. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/resource_test.go | 442 ++++++++++++++++++++ 1 file changed, 442 insertions(+) diff --git a/pkg/primitives/statefulset/resource_test.go b/pkg/primitives/statefulset/resource_test.go index fba3893e..380cc1db 100644 --- a/pkg/primitives/statefulset/resource_test.go +++ b/pkg/primitives/statefulset/resource_test.go @@ -1,9 +1,15 @@ package statefulset import ( + "errors" "testing" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -132,3 +138,439 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } + +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 3, + Replicas: 3, + CurrentReplicas: 3, + UpdatedReplicas: 3, + }, + } + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(5)), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec is applied + assert.Equal(t, int32(5), *current.Spec.Replicas) + + // Status from the live object is preserved + assert.Equal(t, int32(3), current.Status.ReadyReplicas) + assert.Equal(t, int32(3), current.Status.Replicas) + assert.Equal(t, int32(3), current.Status.CurrentReplicas) + assert.Equal(t, int32(3), current.Status.UpdatedReplicas) +} + +// --- Resource-level tests --- + +func TestResource_Identity(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, _ := NewBuilder(sts).Build() + + assert.Equal(t, "apps/v1/StatefulSet/test-ns/test-sts", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sts", + Namespace: "test-ns", + }, + } + res, _ := NewBuilder(sts).Build() + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*appsv1.StatefulSet) + require.True(t, ok) + assert.Equal(t, sts.Name, got.Name) + assert.Equal(t, sts.Namespace, got.Namespace) + + // Ensure it's a deep copy + got.Name = "changed" + assert.Equal(t, "test-sts", sts.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + ServiceName: "test-svc", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "web", Image: "nginx"}, + }, + }, + }, + }, + } + + res, _ := NewBuilder(desired). + WithMutation(Mutation{ + Name: "test-mutation", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "FOO", Value: "BAR"}) + return nil + }, + }). + Build() + + current := &appsv1.StatefulSet{} + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, int32(3), *current.Spec.Replicas) + assert.Equal(t, "test", current.Labels["app"]) + assert.Equal(t, "BAR", current.Spec.Template.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "v1"}, + }, + }, + }, + }, + } + + res, _ := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + if e.Raw().Image == "v2" { + e.Raw().Image = "v3" + } + return nil + }) + return nil + }, + }). + Build() + + current := &appsv1.StatefulSet{} + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "v3", current.Spec.Template.Spec.Containers[0].Image) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, s *appsv1.StatefulSet) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, s) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) GraceStatus(s *appsv1.StatefulSet) (concepts.GraceStatusWithReason, error) { + args := m.Called(s) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(s *appsv1.StatefulSet) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(s) + return args.Get(0).(concepts.SuspensionStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) Suspend(mut *Mutator) error { + args := m.Called(mut) + return args.Error(0) +} + +func (m *mockHandlers) DeleteOnSuspend(s *appsv1.StatefulSet) bool { + args := m.Called(s) + return args.Bool(0) +} + +func TestResource_Status(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 2, + Replicas: 3, + }, + } + + t.Run("ConvergingStatus calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, sts).Return(statusReady, nil) + + res, _ := NewBuilder(sts). + WithCustomConvergeStatus(m.ConvergingStatus). + Build() + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.AliveConvergingStatusHealthy, status.Status) + }) + + t.Run("ConvergingStatus uses default", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusUpdating, status.Status) + }) + + t.Run("GraceStatus calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} + m.On("GraceStatus", sts).Return(statusReady, nil) + + res, _ := NewBuilder(sts). + WithCustomGraceStatus(m.GraceStatus). + Build() + + status, err := res.GraceStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("GraceStatus uses default", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + status, err := res.GraceStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", sts).Return(true) + + res, err := NewBuilder(sts). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + t.Run("Suspend registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := sts.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, int32(0), *current.Spec.Replicas) + }) + + t.Run("Suspend uses custom mutation handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EnsureReplicas(1) + }) + + res, err := NewBuilder(sts). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := sts.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + m.AssertExpectations(t) + assert.Equal(t, int32(1), *current.Spec.Replicas) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: appsv1.StatefulSetStatus{ + Replicas: 0, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", sts).Return(statusSuspended, nil) + + res, err := NewBuilder(sts). + WithCustomSuspendStatus(m.SuspensionStatus). + Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(sts).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) +} + +func TestResource_ExtractData(t *testing.T) { + sts := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "web", Image: "nginx:latest"}}, + }, + }, + }, + } + + extractedImage := "" + res, err := NewBuilder(sts). + WithDataExtractor(func(s appsv1.StatefulSet) error { + extractedImage = s.Spec.Template.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "nginx:latest", extractedImage) +} + +func TestResource_CustomFieldApplicator(t *testing.T) { + desired := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + applicatorCalled := false + res, _ := NewBuilder(desired). + WithCustomFieldApplicator(func(current *appsv1.StatefulSet, desired *appsv1.StatefulSet) error { + applicatorCalled = true + current.Name = desired.Name + current.Namespace = desired.Namespace + // Only apply replicas, ignore labels + current.Spec.Replicas = desired.Spec.Replicas + return nil + }). + Build() + + current := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"external": "label"}, + }, + } + err := res.Mutate(current) + require.NoError(t, err) + + assert.True(t, applicatorCalled) + assert.Equal(t, int32(3), *current.Spec.Replicas) + assert.Equal(t, "label", current.Labels["external"], "External label should be preserved") + assert.NotContains(t, current.Labels, "app", "Desired label should NOT be applied by custom applicator") + + t.Run("returns error", func(t *testing.T) { + res, _ := NewBuilder(desired). + WithCustomFieldApplicator(func(_ *appsv1.StatefulSet, _ *appsv1.StatefulSet) error { + return errors.New("applicator error") + }). + Build() + + err := res.Mutate(&appsv1.StatefulSet{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") + }) +} From 227f800d3b0dff668450445fd64760089f807eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 17:02:51 +0000 Subject: [PATCH 14/26] Add PreserveStatus call to DefaultFieldApplicator The DefaultFieldApplicator was overwriting the entire object via deep copy but not restoring the Status subresource from the original live object. This could clear status fields used by status handlers for converging/suspension decisions. Adds generic.PreserveStatus call after PreserveServerManagedFields, aligning with the Deployment primitive. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/statefulset.md | 4 ++-- pkg/primitives/statefulset/resource.go | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md index eb26c20a..2a3103ed 100644 --- a/docs/primitives/statefulset.md +++ b/docs/primitives/statefulset.md @@ -48,9 +48,9 @@ resource, err := statefulset.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current StatefulSet with a deep copy of the desired object, but preserves `spec.volumeClaimTemplates` from the live object when the resource already exists (i.e., has a non-empty `ResourceVersion`). +`DefaultFieldApplicator` replaces the current StatefulSet with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. -This is necessary because `spec.volumeClaimTemplates` is immutable after creation in Kubernetes — the API server rejects any update that changes it. +It also preserves `spec.volumeClaimTemplates` from the live object when the resource already exists (i.e., has a non-empty `ResourceVersion`). This is necessary because `spec.volumeClaimTemplates` is immutable after creation in Kubernetes — the API server rejects any update that changes it. Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go index f7a5a21a..4d08f132 100644 --- a/pkg/primitives/statefulset/resource.go +++ b/pkg/primitives/statefulset/resource.go @@ -8,9 +8,9 @@ import ( ) // DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), +// shared-controller fields (OwnerReferences, Finalizers), and the Status +// subresource from the original current object. // // It also preserves the existing VolumeClaimTemplates from the current object // when it already exists on the server. spec.volumeClaimTemplates is immutable @@ -22,6 +22,7 @@ func DefaultFieldApplicator(current, desired *appsv1.StatefulSet) error { vcts := current.Spec.VolumeClaimTemplates *current = *desired.DeepCopy() generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) if original.ResourceVersion != "" { current.Spec.VolumeClaimTemplates = vcts From 5657a88fe67a5d57edb9bfe7d7d14e48850cf66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:38:37 +0000 Subject: [PATCH 15/26] Export BeginFeature() to satisfy FeatureMutator interface from main Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/mutator.go | 6 +++--- pkg/primitives/statefulset/mutator_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index 103da467..ccd41cfb 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -66,10 +66,10 @@ func NewMutator(current *appsv1.StatefulSet) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan until another -// beginFeature is called. -func (m *Mutator) beginFeature() { +// BeginFeature is called. +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go index 5f8c51d8..5b49d052 100644 --- a/pkg/primitives/statefulset/mutator_test.go +++ b/pkg/primitives/statefulset/mutator_test.go @@ -438,14 +438,14 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(sts) - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" return nil }) - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" @@ -522,13 +522,13 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(sts) - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil From 787cdf5de4e4fc5274d104da79e4c5f617b39a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:25:39 +0000 Subject: [PATCH 16/26] Add ObservedGeneration guard to StatefulSet DefaultConvergingStatusHandler Verify that the StatefulSet controller has observed the latest spec (Status.ObservedGeneration >= Generation) before evaluating readiness fields, preventing false Healthy reports based on stale status. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/statefulset.md | 126 ++++++++++++-------- examples/statefulset-primitive/README.md | 21 ++-- pkg/primitives/statefulset/handlers.go | 12 +- pkg/primitives/statefulset/handlers_test.go | 49 ++++++++ 4 files changed, 151 insertions(+), 57 deletions(-) diff --git a/docs/primitives/statefulset.md b/docs/primitives/statefulset.md index 2a3103ed..d83b3f00 100644 --- a/docs/primitives/statefulset.md +++ b/docs/primitives/statefulset.md @@ -1,16 +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 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. ## Capabilities -| Capability | Detail | -|-----------------------|-------------------------------------------------------------------------------------------------------------------| -| **Health tracking** | Monitors `ReadyReplicas` and 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 | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | +| 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 | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Building a StatefulSet Primitive @@ -48,9 +50,14 @@ resource, err := statefulset.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current StatefulSet with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. +`DefaultFieldApplicator` replaces the current StatefulSet with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the +Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data +written by the API server or other controllers. -It also preserves `spec.volumeClaimTemplates` from the live object when the resource already exists (i.e., has a non-empty `ResourceVersion`). This is necessary because `spec.volumeClaimTemplates` is immutable after creation in Kubernetes — the API server rejects any update that changes it. +It also preserves `spec.volumeClaimTemplates` from the live object when the resource already exists (i.e., has a +non-empty `ResourceVersion`). This is necessary because `spec.volumeClaimTemplates` is immutable after creation in +Kubernetes — the API server rejects any update that changes it. Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: @@ -66,9 +73,11 @@ 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. +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: +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) statefulset.Mutation { @@ -83,7 +92,8 @@ func MyFeatureMutation(version string) statefulset.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. +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 @@ -134,21 +144,23 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## 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 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. -| Step | Category | What it affects | -|---|---|---| -| 1 | StatefulSet 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 | -| 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.template.spec.initContainers` | -| 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` | +| Step | Category | What it affects | +| ---- | -------------------------------- | ----------------------------------------------------------------------- | +| 1 | StatefulSet 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 | +| 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.template.spec.initContainers` | +| 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 mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. ## Editors @@ -156,7 +168,8 @@ Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* p Controls statefulset-level settings via `m.EditStatefulSetSpec`. -Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`, `SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`. +Available methods: `SetReplicas`, `SetServiceName`, `SetPodManagementPolicy`, `SetUpdateStrategy`, +`SetRevisionHistoryLimit`, `SetMinReadySeconds`, `SetPersistentVolumeClaimRetentionPolicy`, `Raw`. ```go m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { @@ -182,7 +195,9 @@ m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { Manages pod-level configuration via `m.EditPodSpec`. -Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { @@ -201,9 +216,11 @@ 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`. Always used in combination with a +[selector](../primitives.md#container-selectors). -Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. ```go m.EditContainers(selectors.ContainerNamed("db"), func(e *editors.ContainerEditor) error { @@ -215,7 +232,8 @@ 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` to target the `StatefulSet` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. @@ -251,23 +269,26 @@ m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ }) ``` -**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the live VolumeClaimTemplates to avoid API server rejections. These mutation methods are primarily useful for constructing the initial desired state or when recreating a StatefulSet. +**Important:** `spec.volumeClaimTemplates` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` +preserves the live VolumeClaimTemplates to avoid API server rejections. These mutation methods are primarily useful for +constructing the initial desired state or when recreating a StatefulSet. ## Convenience Methods The `Mutator` also exposes convenience wrappers: -| 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)` | +| 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)` | ## Flavors -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external +controllers or other tools. ### PreserveCurrentLabels @@ -275,15 +296,18 @@ Preserves labels present on the live object but absent from the applied desired ### PreserveCurrentAnnotations -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on +overlap. ### PreserveCurrentPodTemplateLabels -Preserves labels present on the live object's pod template but absent from the applied desired state's pod template. Applied labels win on overlap. +Preserves labels present on the live object's pod template but absent from the applied desired state's pod template. +Applied labels win on overlap. ### PreserveCurrentPodTemplateAnnotations -Preserves annotations present on the live object's pod template but absent from the applied desired state's pod template. Applied annotations win on overlap. +Preserves annotations present on the live object's pod template but absent from the applied desired state's pod +template. Applied annotations win on overlap. ```go resource, err := statefulset.NewBuilder(base). @@ -341,12 +365,18 @@ func DatabaseMutation(version string) statefulset.Mutation { ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +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. +**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. +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly and reconciliation remains idempotent. -**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. The `DefaultFieldApplicator` preserves live VolumeClaimTemplates to prevent API server rejections on updates. +**VolumeClaimTemplates are immutable.** Plan your storage layout before the first creation. The `DefaultFieldApplicator` +preserves live VolumeClaimTemplates to prevent API server rejections on updates. -**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. +**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/examples/statefulset-primitive/README.md b/examples/statefulset-primitive/README.md index 3dfbae57..ab132b46 100644 --- a/examples/statefulset-primitive/README.md +++ b/examples/statefulset-primitive/README.md @@ -1,12 +1,15 @@ # StatefulSet Primitive Example -This example demonstrates the usage of the `statefulset` primitive within the operator component framework. -It shows how to manage a Kubernetes StatefulSet as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `statefulset` primitive within the operator component framework. It shows how +to manage a Kubernetes StatefulSet as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a StatefulSet with basic metadata, spec, and volume claim templates. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). -- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health assessment during rollouts (`GraceStatus`). +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars, annotations) using the + `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual + edits). +- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health + assessment during rollouts (`GraceStatus`). - **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. - **Data Extraction**: Harvesting information from the reconciled resource. @@ -14,9 +17,10 @@ It shows how to manage a Kubernetes StatefulSet as a component of a larger appli - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields, plus custom status and suspension handlers. -- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the `statefulset.Builder`. + - `mutations.go`: sidecar injection, env vars, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields, plus custom status and suspension handlers. +- `resources/`: Contains the central `NewStatefulSetResource` factory that assembles all features using the + `statefulset.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. ## Running the Example @@ -28,6 +32,7 @@ go run examples/statefulset-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile the `ExampleApp` components. diff --git a/pkg/primitives/statefulset/handlers.go b/pkg/primitives/statefulset/handlers.go index 8627118c..0e59a754 100644 --- a/pkg/primitives/statefulset/handlers.go +++ b/pkg/primitives/statefulset/handlers.go @@ -9,13 +9,23 @@ import ( // DefaultConvergingStatusHandler is the default logic for determining if a StatefulSet has reached its desired state. // -// It considers a StatefulSet ready when its Status.ReadyReplicas matches the Spec.Replicas (defaulting to 1 if nil). +// It considers a StatefulSet ready when the statefulset controller has observed the current generation +// (Status.ObservedGeneration >= ObjectMeta.Generation) and Status.ReadyReplicas matches the +// Spec.Replicas (defaulting to 1 if nil). If the controller has not yet observed the latest spec, +// the handler reports Creating (when the resource was just created) or Updating (otherwise) to avoid +// falsely reporting health based on stale status fields. // // This function is used as the default handler by the Resource if no custom handler is registered via // Builder.WithCustomConvergeStatus. It can be reused within custom handlers to augment the default behavior. func DefaultConvergingStatusHandler( op concepts.ConvergingOperation, sts *appsv1.StatefulSet, ) (concepts.AliveStatusWithReason, error) { + if status := concepts.StaleGenerationStatus( + op, sts.Status.ObservedGeneration, sts.Generation, "statefulset", + ); status != nil { + return *status, nil + } + desiredReplicas := int32(1) if sts.Spec.Replicas != nil { desiredReplicas = *sts.Spec.Replicas diff --git a/pkg/primitives/statefulset/handlers_test.go b/pkg/primitives/statefulset/handlers_test.go index 38dc368b..e4f1ae1e 100644 --- a/pkg/primitives/statefulset/handlers_test.go +++ b/pkg/primitives/statefulset/handlers_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -86,6 +87,54 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { wantStatus: concepts.AliveConvergingStatusScaling, wantReason: "Waiting for replicas: 1/3 ready", }, + { + name: "stale observed generation after create", + op: concepts.ConvergingOperationCreated, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, + { + name: "stale observed generation after update", + op: concepts.ConvergingOperationUpdated, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 2, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, + { + name: "stale observed generation with no operation", + op: concepts.ConvergingOperationNone, + sts: &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for statefulset controller to observe latest spec", + }, } for _, tt := range tests { From e851efd314eb3e1bab38f0e1518b788a712f92a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:33:48 +0000 Subject: [PATCH 17/26] Use deep-copied VolumeClaimTemplates in DefaultFieldApplicator Use original.Spec.VolumeClaimTemplates (from the DeepCopy snapshot) instead of capturing current.Spec.VolumeClaimTemplates before the overwrite, avoiding retained references to slices from the pre-overwrite object. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/resource.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/primitives/statefulset/resource.go b/pkg/primitives/statefulset/resource.go index 4d08f132..b7100b64 100644 --- a/pkg/primitives/statefulset/resource.go +++ b/pkg/primitives/statefulset/resource.go @@ -19,13 +19,12 @@ import ( func DefaultFieldApplicator(current, desired *appsv1.StatefulSet) error { original := current.DeepCopy() - vcts := current.Spec.VolumeClaimTemplates *current = *desired.DeepCopy() generic.PreserveServerManagedFields(current, original) generic.PreserveStatus(current, original) if original.ResourceVersion != "" { - current.Spec.VolumeClaimTemplates = vcts + current.Spec.VolumeClaimTemplates = original.Spec.VolumeClaimTemplates } return nil } From e9b9d122982870a76a03a3e2fa1b6532adcfc8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:13:34 +0000 Subject: [PATCH 18/26] Add StatefulSet primitive to shared documentation tables Add StatefulSet entry to the Built-in Primitives table and StatefulSetSpecEditor to the Mutation Editors table in docs/primitives.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index dc2d4c2a..8b15233d 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -128,8 +128,9 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | ---------------------- | ----------------------------------------------------------------------- | | `ContainerEditor` | Environment variables, arguments, resource limits, ports | | `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the @@ -153,8 +154,9 @@ have been applied. This means a single mutation can safely add a container and t | Primitive | Category | Documentation | | --------------------------- | -------- | ----------------------------------------- | -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | ## Usage Examples From 27f6a635f646b7eab92def32a3ff3951a2691762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:24:00 +0000 Subject: [PATCH 19/26] Fix markdown table alignment in primitives.md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index 8b15233d..a3a53123 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -124,14 +124,14 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ---------------------- | ----------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| Editor | Scope | +| ----------------------- | ----------------------------------------------------------------------- | +| `ContainerEditor` | Environment variables, arguments, resource limits, ports | +| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `StatefulSetSpecEditor` | Replicas, service name, pod management policy, update strategy | +| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | 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. @@ -152,8 +152,8 @@ have been applied. This means a single mutation can safely add a container and t ## Built-in Primitives -| Primitive | Category | Documentation | -| --------------------------- | -------- | ----------------------------------------- | +| Primitive | Category | Documentation | +| ---------------------------- | -------- | ------------------------------------------- | | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/statefulset` | Workload | [statefulset.md](primitives/statefulset.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | From d7f0248d847ca554b20dc9642a8b6ff05ad759a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:27:21 +0000 Subject: [PATCH 20/26] Address Copilot review: assert Build() errors and fix docstring - Replace all `res, _ := ...Build()` with `res, err := ...Build()` followed by require.NoError in resource_test.go so test failures from Build() are not silently swallowed. - Update WithCustomFieldApplicator docstring to accurately describe that DefaultFieldApplicator deep-copies the entire object (metadata + spec), not just the spec. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/builder.go | 9 ++++-- pkg/primitives/statefulset/resource_test.go | 32 +++++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/pkg/primitives/statefulset/builder.go b/pkg/primitives/statefulset/builder.go index d94d4756..78e9dc59 100644 --- a/pkg/primitives/statefulset/builder.go +++ b/pkg/primitives/statefulset/builder.go @@ -64,9 +64,12 @@ func (b *Builder) WithMutation(m Mutation) *Builder { // WithCustomFieldApplicator sets a custom strategy for applying the desired // state to the existing StatefulSet in the cluster. // -// There is a default field applicator (DefaultFieldApplicator) that overwrites -// the entire spec of the current object with the desired state, while preserving -// VolumeClaimTemplates from the live object (since they are immutable after creation). +// By default, the field applicator (DefaultFieldApplicator) deep-copies the +// entire desired object (metadata and spec) over the current object, and then +// restores server-managed fields, status, and VolumeClaimTemplates from the +// live object (since VCTs are immutable after creation). This means metadata +// such as labels and annotations is not merged or automatically preserved +// unless your custom applicator or a flavor explicitly handles it. func (b *Builder) WithCustomFieldApplicator( applicator func(current *appsv1.StatefulSet, desired *appsv1.StatefulSet) error, ) *Builder { diff --git a/pkg/primitives/statefulset/resource_test.go b/pkg/primitives/statefulset/resource_test.go index 380cc1db..294c0053 100644 --- a/pkg/primitives/statefulset/resource_test.go +++ b/pkg/primitives/statefulset/resource_test.go @@ -184,7 +184,8 @@ func TestResource_Identity(t *testing.T) { Namespace: "test-ns", }, } - res, _ := NewBuilder(sts).Build() + res, err := NewBuilder(sts).Build() + require.NoError(t, err) assert.Equal(t, "apps/v1/StatefulSet/test-ns/test-sts", res.Identity()) } @@ -196,7 +197,8 @@ func TestResource_Object(t *testing.T) { Namespace: "test-ns", }, } - res, _ := NewBuilder(sts).Build() + res, err := NewBuilder(sts).Build() + require.NoError(t, err) obj, err := res.Object() require.NoError(t, err) @@ -237,7 +239,7 @@ func TestResource_Mutate(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithMutation(Mutation{ Name: "test-mutation", Feature: feature.NewResourceFeature("v1", nil).When(true), @@ -247,9 +249,10 @@ func TestResource_Mutate(t *testing.T) { }, }). Build() + require.NoError(t, err) current := &appsv1.StatefulSet{} - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, int32(3), *current.Spec.Replicas) @@ -274,7 +277,7 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithMutation(Mutation{ Name: "feature-a", Feature: feature.NewResourceFeature("v1", nil).When(true), @@ -300,9 +303,10 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { }, }). Build() + require.NoError(t, err) current := &appsv1.StatefulSet{} - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, "v3", current.Spec.Template.Spec.Containers[0].Image) @@ -357,9 +361,10 @@ func TestResource_Status(t *testing.T) { statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, sts).Return(statusReady, nil) - res, _ := NewBuilder(sts). + res, err := NewBuilder(sts). WithCustomConvergeStatus(m.ConvergingStatus). Build() + require.NoError(t, err) status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) require.NoError(t, err) @@ -380,9 +385,10 @@ func TestResource_Status(t *testing.T) { statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} m.On("GraceStatus", sts).Return(statusReady, nil) - res, _ := NewBuilder(sts). + res, err := NewBuilder(sts). WithCustomGraceStatus(m.GraceStatus). Build() + require.NoError(t, err) status, err := res.GraceStatus() require.NoError(t, err) @@ -538,7 +544,7 @@ func TestResource_CustomFieldApplicator(t *testing.T) { } applicatorCalled := false - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithCustomFieldApplicator(func(current *appsv1.StatefulSet, desired *appsv1.StatefulSet) error { applicatorCalled = true current.Name = desired.Name @@ -548,13 +554,14 @@ func TestResource_CustomFieldApplicator(t *testing.T) { return nil }). Build() + require.NoError(t, err) current := &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{"external": "label"}, }, } - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.True(t, applicatorCalled) @@ -563,13 +570,14 @@ func TestResource_CustomFieldApplicator(t *testing.T) { assert.NotContains(t, current.Labels, "app", "Desired label should NOT be applied by custom applicator") t.Run("returns error", func(t *testing.T) { - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithCustomFieldApplicator(func(_ *appsv1.StatefulSet, _ *appsv1.StatefulSet) error { return errors.New("applicator error") }). Build() + require.NoError(t, err) - err := res.Mutate(&appsv1.StatefulSet{}) + err = res.Mutate(&appsv1.StatefulSet{}) require.Error(t, err) assert.Contains(t, err.Error(), "applicator error") }) From b0bc7710d695cef538e662a6e574ab7581e07d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:30:06 +0000 Subject: [PATCH 21/26] Use no-op Mutate function in WithMutation builder test Replace nil Mutate function with a no-op to ensure test data represents a valid mutation, preventing issues if Build starts validating mutations. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/builder_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/statefulset/builder_test.go b/pkg/primitives/statefulset/builder_test.go index c7140dc9..f7929326 100644 --- a/pkg/primitives/statefulset/builder_test.go +++ b/pkg/primitives/statefulset/builder_test.go @@ -82,7 +82,8 @@ func TestBuilder(t *testing.T) { }, } m := Mutation{ - Name: "test-mutation", + Name: "test-mutation", + Mutate: func(_ *Mutator) error { return nil }, } res, err := NewBuilder(sts). WithMutation(m). From 6578643ff008ea38126b1ef7e6cf870dcfdb4864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:18:19 +0000 Subject: [PATCH 22/26] Add snapshot-based selector matching docs to EditContainers Align StatefulSet mutator's EditContainers docstring with the deployment mutator by documenting that selectors target baseline/presence-added containers and should not rely on earlier edits changing match results. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/mutator.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index ccd41cfb..70020498 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -86,7 +86,9 @@ func (m *Mutator) BeginFeature() { // Selection: // - The selector determines which containers the edit function will be called for. // - If either selector or edit function is nil, the registration is ignored. +// - Selectors are intended to target containers defined by the baseline resource structure or added by earlier presence operations. // - Selector matching is evaluated against a snapshot taken after the current feature's container presence operations are applied. +// - Mutations should not rely on earlier edits in the SAME feature phase changing which selectors match. func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { if selector == nil || edit == nil { return From ec8729d0b7387b560e13eb2f93148a6d45bf68b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:26:18 +0000 Subject: [PATCH 23/26] Assert Build() errors in flavors_test.go subtests Capture and assert the error from Build() with require.NoError in all three TestMutate_OrderingAndFlavors subtests so that a builder failure surfaces as a clear test error instead of a nil-pointer panic. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/flavors_test.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pkg/primitives/statefulset/flavors_test.go b/pkg/primitives/statefulset/flavors_test.go index 560de032..2b52b3ce 100644 --- a/pkg/primitives/statefulset/flavors_test.go +++ b/pkg/primitives/statefulset/flavors_test.go @@ -32,11 +32,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(PreserveCurrentLabels). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, "desired", current.Labels["app"]) @@ -61,12 +62,13 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return nil } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor1). WithFieldApplicationFlavor(flavor2). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, []string{"flavor1", "flavor2"}, order) }) @@ -78,11 +80,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return flavorErr } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply field application flavor") assert.True(t, errors.Is(err, flavorErr)) From 1026007ab34bdd48d5f31399d0f2044076747f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:28:58 +0000 Subject: [PATCH 24/26] Fix incorrect type name in statefulset example README Update `ConvergeStatus` to `ConvergingStatus` and mention the `WithCustomConvergeStatus` builder hook so readers can find the correct method/type names in the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/statefulset-primitive/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/statefulset-primitive/README.md b/examples/statefulset-primitive/README.md index ab132b46..3e7739ea 100644 --- a/examples/statefulset-primitive/README.md +++ b/examples/statefulset-primitive/README.md @@ -8,8 +8,8 @@ to manage a Kubernetes StatefulSet as a component of a larger application, utili `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). -- **Custom Status Handlers**: Overriding the default logic for determining readiness (`ConvergeStatus`) and health - assessment during rollouts (`GraceStatus`). +- **Custom Status Handlers**: Overriding the default logic for determining readiness (via `ConvergingStatus` and the + `WithCustomConvergeStatus` builder hook) and health assessment during rollouts (`GraceStatus`). - **Custom Suspension**: Extending the default suspension logic (scaling to 0) with additional mutations. - **Data Extraction**: Harvesting information from the reconciled resource. From d1876e7badf814a98327b9e7a8327984ca2dd72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:31:23 +0000 Subject: [PATCH 25/26] Add statefulset-primitive to run-examples Makefile target The new statefulset-primitive example was not included in the run-examples target, so it was not exercised by `make run-examples`. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 29424227..a15f2d56 100644 --- a/Makefile +++ b/Makefile @@ -115,6 +115,7 @@ build-examples: ## Build all example binaries. run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. + go run ./examples/statefulset-primitive/. go run ./examples/custom-resource-implementation/. From 17f93a44708fb59c3684e1afd680ee85045a2bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:01:21 +0000 Subject: [PATCH 26/26] Do not initialize an empty plan on statefulset mutator construction Align StatefulSet mutator with deployment/configmap: NewMutator no longer creates an initial feature plan. BeginFeature must be called before registering any mutations. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/statefulset/handlers_test.go | 1 + pkg/primitives/statefulset/mutator.go | 8 +-- pkg/primitives/statefulset/mutator_test.go | 79 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/statefulset/handlers_test.go b/pkg/primitives/statefulset/handlers_test.go index e4f1ae1e..f61029b7 100644 --- a/pkg/primitives/statefulset/handlers_test.go +++ b/pkg/primitives/statefulset/handlers_test.go @@ -185,6 +185,7 @@ func TestDefaultSuspendMutationHandler(t *testing.T) { }, } mutator := NewMutator(sts) + mutator.BeginFeature() err := DefaultSuspendMutationHandler(mutator) require.NoError(t, err) err = mutator.Apply() diff --git a/pkg/primitives/statefulset/mutator.go b/pkg/primitives/statefulset/mutator.go index 70020498..d950a074 100644 --- a/pkg/primitives/statefulset/mutator.go +++ b/pkg/primitives/statefulset/mutator.go @@ -56,14 +56,12 @@ type Mutator struct { // NewMutator creates a new Mutator for the given StatefulSet. // // It is typically used within a Feature's Mutation logic to express desired -// changes to the StatefulSet. +// changes to the StatefulSet. BeginFeature must be called before registering +// any mutations. func NewMutator(current *appsv1.StatefulSet) *Mutator { - m := &Mutator{ + return &Mutator{ current: current, - plans: []featurePlan{{}}, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/statefulset/mutator_test.go b/pkg/primitives/statefulset/mutator_test.go index 5b49d052..764347b2 100644 --- a/pkg/primitives/statefulset/mutator_test.go +++ b/pkg/primitives/statefulset/mutator_test.go @@ -36,6 +36,7 @@ func TestMutator_EnvVars(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) @@ -79,6 +80,7 @@ func TestMutator_Args(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureContainerArg("--change=new") m.EnsureContainerArg("--add") m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) @@ -102,6 +104,7 @@ func TestMutator_Replicas(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureReplicas(5) err := m.Apply() @@ -115,6 +118,62 @@ func TestNewMutator(t *testing.T) { m := NewMutator(sts) assert.NotNil(t, m) assert.Equal(t, sts, m.current) + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + sts := &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(sts) + + // Record mutations in the first feature plan + m.BeginFeature() + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1" + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EnsureReplicas(5) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].statefulsetSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[0].containerEdits, 1, "first plan should have one container edit") + assert.Len(t, m.plans[1].statefulsetSpecEdits, 1, "second plan should have one spec edit") + assert.Empty(t, m.plans[1].containerEdits, "second plan should have no container edits") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + sts := &appsv1.StatefulSet{} + m := NewMutator(sts) + m.BeginFeature() + m.EnsureReplicas(3) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1) } func TestMutator_EditContainers(t *testing.T) { @@ -132,6 +191,7 @@ func TestMutator_EditContainers(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "c1-image" return nil @@ -153,6 +213,7 @@ func TestMutator_EditContainers(t *testing.T) { func TestMutator_EditPodSpec(t *testing.T) { sts := &appsv1.StatefulSet{} m := NewMutator(sts) + m.BeginFeature() m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.Raw().ServiceAccountName = "my-sa" return nil @@ -166,6 +227,7 @@ func TestMutator_EditPodSpec(t *testing.T) { func TestMutator_EditStatefulSetSpec(t *testing.T) { sts := &appsv1.StatefulSet{} m := NewMutator(sts) + m.BeginFeature() m.EditStatefulSetSpec(func(e *editors.StatefulSetSpecEditor) error { e.SetServiceName("my-service") e.SetMinReadySeconds(10) @@ -181,6 +243,7 @@ func TestMutator_EditStatefulSetSpec(t *testing.T) { func TestMutator_EditMetadata(t *testing.T) { sts := &appsv1.StatefulSet{} m := NewMutator(sts) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"sts": "label"} return nil @@ -199,6 +262,7 @@ func TestMutator_EditMetadata(t *testing.T) { func TestMutator_Errors(t *testing.T) { sts := &appsv1.StatefulSet{} m := NewMutator(sts) + m.BeginFeature() m.EditPodSpec(func(_ *editors.PodSpecEditor) error { return errors.New("boom") }) @@ -225,6 +289,7 @@ func TestMutator_Order(t *testing.T) { var order []string m := NewMutator(sts) + m.BeginFeature() m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { order = append(order, "container") return nil @@ -270,6 +335,7 @@ func TestMutator_InitContainers(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = newImage return nil @@ -296,6 +362,7 @@ func TestMutator_ContainerPresence(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) m.RemoveContainer("sidecar") m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) @@ -324,6 +391,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) @@ -349,6 +417,7 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = appV2 @@ -384,6 +453,7 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "edited-image" @@ -410,6 +480,7 @@ func TestMutator_NilSafety(t *testing.T) { }, } m := NewMutator(sts) + m.BeginFeature() m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) m.EditContainers(selectors.AllContainers(), nil) @@ -472,6 +543,7 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() var executionOrder []string @@ -553,6 +625,7 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { } m := NewMutator(sts) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) @@ -583,6 +656,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { t.Run("ensure adds new VCT", func(t *testing.T) { sts := &appsv1.StatefulSet{} m := NewMutator(sts) + m.BeginFeature() m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: "data"}, @@ -610,6 +684,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { }, } m := NewMutator(sts) + m.BeginFeature() m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: "data"}, @@ -639,6 +714,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { }, } m := NewMutator(sts) + m.BeginFeature() m.RemoveVolumeClaimTemplate("data") @@ -657,6 +733,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { }, } m := NewMutator(sts) + m.BeginFeature() m.RemoveVolumeClaimTemplate("nonexistent") @@ -677,6 +754,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { }, } m := NewMutator(sts) + m.BeginFeature() m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: "new-volume"}, @@ -704,6 +782,7 @@ func TestMutator_VolumeClaimTemplates(t *testing.T) { var order []string m := NewMutator(sts) + m.BeginFeature() m.EnsureVolumeClaimTemplate(corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{Name: "data"},