From 175450ce2b66090b579d69f5b0188b0cb67d0b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:40:19 +0000 Subject: [PATCH 01/18] Add ReplicaSet primitive with editor, builder, mutator, handlers, and flavors Implements the ReplicaSet workload primitive following the same pattern as the Deployment primitive. Includes ReplicaSetSpecEditor, DefaultFieldApplicator that preserves the immutable spec.selector, and all standard workload lifecycle handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/replicasetspec.go | 36 ++ pkg/mutation/editors/replicasetspec_test.go | 30 + pkg/primitives/replicaset/builder.go | 208 +++++++ pkg/primitives/replicaset/builder_test.go | 272 +++++++++ pkg/primitives/replicaset/flavors.go | 44 ++ pkg/primitives/replicaset/flavors_test.go | 146 +++++ pkg/primitives/replicaset/handlers.go | 111 ++++ pkg/primitives/replicaset/handlers_test.go | 197 +++++++ pkg/primitives/replicaset/mutator.go | 465 +++++++++++++++ pkg/primitives/replicaset/mutator_test.go | 612 ++++++++++++++++++++ pkg/primitives/replicaset/resource.go | 134 +++++ 11 files changed, 2255 insertions(+) create mode 100644 pkg/mutation/editors/replicasetspec.go create mode 100644 pkg/mutation/editors/replicasetspec_test.go create mode 100644 pkg/primitives/replicaset/builder.go create mode 100644 pkg/primitives/replicaset/builder_test.go create mode 100644 pkg/primitives/replicaset/flavors.go create mode 100644 pkg/primitives/replicaset/flavors_test.go create mode 100644 pkg/primitives/replicaset/handlers.go create mode 100644 pkg/primitives/replicaset/handlers_test.go create mode 100644 pkg/primitives/replicaset/mutator.go create mode 100644 pkg/primitives/replicaset/mutator_test.go create mode 100644 pkg/primitives/replicaset/resource.go diff --git a/pkg/mutation/editors/replicasetspec.go b/pkg/mutation/editors/replicasetspec.go new file mode 100644 index 00000000..d2e2ed6f --- /dev/null +++ b/pkg/mutation/editors/replicasetspec.go @@ -0,0 +1,36 @@ +package editors + +import ( + appsv1 "k8s.io/api/apps/v1" +) + +// ReplicaSetSpecEditor provides a typed API for mutating a Kubernetes ReplicaSetSpec. +// +// Note: spec.selector is immutable after creation and is not exposed here. Set it via +// the desired object passed to the ReplicaSet builder. +type ReplicaSetSpecEditor struct { + spec *appsv1.ReplicaSetSpec +} + +// NewReplicaSetSpecEditor creates a new ReplicaSetSpecEditor for the given ReplicaSetSpec. +func NewReplicaSetSpecEditor(spec *appsv1.ReplicaSetSpec) *ReplicaSetSpecEditor { + return &ReplicaSetSpecEditor{spec: spec} +} + +// Raw returns the underlying *appsv1.ReplicaSetSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *ReplicaSetSpecEditor) Raw() *appsv1.ReplicaSetSpec { + return e.spec +} + +// SetReplicas sets the number of desired replicas for the ReplicaSet. +func (e *ReplicaSetSpecEditor) SetReplicas(replicas int32) { + e.spec.Replicas = &replicas +} + +// SetMinReadySeconds sets the minimum number of seconds for which a newly created pod should be ready +// without any of its container crashing, for it to be considered available. +func (e *ReplicaSetSpecEditor) SetMinReadySeconds(seconds int32) { + e.spec.MinReadySeconds = seconds +} diff --git a/pkg/mutation/editors/replicasetspec_test.go b/pkg/mutation/editors/replicasetspec_test.go new file mode 100644 index 00000000..30b16adb --- /dev/null +++ b/pkg/mutation/editors/replicasetspec_test.go @@ -0,0 +1,30 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" +) + +func TestReplicaSetSpecEditor(t *testing.T) { + t.Run("SetReplicas", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + editor.SetReplicas(3) + assert.Equal(t, int32(3), *spec.Replicas) + }) + + t.Run("SetMinReadySeconds", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + editor.SetMinReadySeconds(10) + assert.Equal(t, int32(10), spec.MinReadySeconds) + }) + + t.Run("Raw", func(t *testing.T) { + spec := &appsv1.ReplicaSetSpec{} + editor := NewReplicaSetSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) +} diff --git a/pkg/primitives/replicaset/builder.go b/pkg/primitives/replicaset/builder.go new file mode 100644 index 00000000..ac97880c --- /dev/null +++ b/pkg/primitives/replicaset/builder.go @@ -0,0 +1,208 @@ +// Package replicaset provides a builder and resource for managing Kubernetes ReplicaSets. +package replicaset + +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 ReplicaSet 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.ReplicaSet, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided ReplicaSet object. +// +// The ReplicaSet 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 replicaset must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(replicaset *appsv1.ReplicaSet) *Builder { + identityFunc := func(rs *appsv1.ReplicaSet) string { + return fmt.Sprintf("apps/v1/ReplicaSet/%s/%s", rs.Namespace, rs.Name) + } + + base := generic.NewWorkloadBuilder[*appsv1.ReplicaSet, *Mutator]( + replicaset, + 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 ReplicaSet. +// +// 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 ReplicaSet's containers. +// +// Since mutations are often version-gated, the provided feature.Mutation +// should contain the logic to determine if and how the mutation is applied +// based on the component's current version or configuration. +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 ReplicaSet 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 +// the immutable spec.selector from the live object. +// +// The applicator function receives both the 'current' object from the API +// server and the 'desired' object from the Resource. It is responsible for +// merging the desired changes into the current object. +// +// If a custom applicator is set, it overrides the default baseline application +// logic. Post-application flavors and mutations are still applied afterward. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current *appsv1.ReplicaSet, desired *appsv1.ReplicaSet) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a reusable post-application "flavor" for +// the ReplicaSet. +// +// 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.ReplicaSet](flavor)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// ReplicaSet has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which considers a +// ReplicaSet ready when its ReadyReplicas count matches the desired replica count. +// Use this method if your ReplicaSet requires more complex health checks. +// +// If you want to augment the default behavior, you can call DefaultConvergingStatusHandler +// within your custom handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the ReplicaSet reports its health while +// it is still converging. +// +// The default behavior uses DefaultGraceStatusHandler. +// +// If you want to augment the default behavior, you can call DefaultGraceStatusHandler +// within your custom handler. +func (b *Builder) WithCustomGraceStatus( + handler func(*appsv1.ReplicaSet) (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. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the ReplicaSet should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which scales the +// ReplicaSet to zero replicas. +// +// If you want to augment the default behavior, you can call DefaultSuspendMutationHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the ReplicaSet when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which does not +// delete ReplicaSets during suspension (only scaled down). Return true from +// this handler if you want the ReplicaSet to be completely removed from the +// cluster when suspended. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*appsv1.ReplicaSet) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// ReplicaSet after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like names or assigned +// IPs) and making them available to other components or resources via the +// framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(appsv1.ReplicaSet) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(rs *appsv1.ReplicaSet) error { + return extractor(*rs) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base ReplicaSet object was provided. +// - The ReplicaSet 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/replicaset/builder_test.go b/pkg/primitives/replicaset/builder_test.go new file mode 100644 index 00000000..ca98d99e --- /dev/null +++ b/pkg/primitives/replicaset/builder_test.go @@ -0,0 +1,272 @@ +package replicaset + +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 + replicaset *appsv1.ReplicaSet + expectedErr string + }{ + { + name: "nil replicaset", + replicaset: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid replicaset", + replicaset: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.replicaset).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/ReplicaSet/test-ns/test-rs", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_ *appsv1.ReplicaSet, _ *appsv1.ReplicaSet) error { + applied = true + return nil + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + handler := func(_ *appsv1.ReplicaSet) bool { + return true + } + res, err := NewBuilder(rs). + 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() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ appsv1.ReplicaSet) error { + called = true + return nil + } + res, err := NewBuilder(rs). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&appsv1.ReplicaSet{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(rs). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/replicaset/flavors.go b/pkg/primitives/replicaset/flavors.go new file mode 100644 index 00000000..1790b275 --- /dev/null +++ b/pkg/primitives/replicaset/flavors.go @@ -0,0 +1,44 @@ +package replicaset + +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.ReplicaSet] + +// PreserveCurrentLabels ensures that any labels present on the current live +// ReplicaSet 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.ReplicaSet) error { + return flavors.PreserveCurrentLabels[*appsv1.ReplicaSet]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live ReplicaSet 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.ReplicaSet) error { + return flavors.PreserveCurrentAnnotations[*appsv1.ReplicaSet]()(applied, current, desired) +} + +// PreserveCurrentPodTemplateLabels ensures that any labels present on the +// current live ReplicaSet'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.ReplicaSet) 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 ReplicaSet'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.ReplicaSet) error { + applied.Spec.Template.Annotations = utils.PreserveMap(applied.Spec.Template.Annotations, current.Spec.Template.Annotations) + return nil +} diff --git a/pkg/primitives/replicaset/flavors_test.go b/pkg/primitives/replicaset/flavors_test.go new file mode 100644 index 00000000..6e615d40 --- /dev/null +++ b/pkg/primitives/replicaset/flavors_test.go @@ -0,0 +1,146 @@ +package replicaset + +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.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptrInt32(3), + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + 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.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *appsv1.ReplicaSet) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *appsv1.ReplicaSet) 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.ReplicaSet{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *appsv1.ReplicaSet) 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.ReplicaSet{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &appsv1.ReplicaSet{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.ReplicaSet{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &appsv1.ReplicaSet{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.ReplicaSet{Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}} + current := &appsv1.ReplicaSet{Spec: appsv1.ReplicaSetSpec{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.ReplicaSet{Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}} + current := &appsv1.ReplicaSet{Spec: appsv1.ReplicaSetSpec{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.ReplicaSet{} + current := &appsv1.ReplicaSet{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/replicaset/handlers.go b/pkg/primitives/replicaset/handlers.go new file mode 100644 index 00000000..a0f38dd2 --- /dev/null +++ b/pkg/primitives/replicaset/handlers.go @@ -0,0 +1,111 @@ +package replicaset + +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 ReplicaSet has reached its desired state. +// +// It considers a ReplicaSet 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, rs *appsv1.ReplicaSet, +) (concepts.AliveStatusWithReason, error) { + desiredReplicas := int32(1) + if rs.Spec.Replicas != nil { + desiredReplicas = *rs.Spec.Replicas + } + + if rs.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", rs.Status.ReadyReplicas, desiredReplicas), + }, nil +} + +// DefaultGraceStatusHandler provides a default health assessment of the ReplicaSet 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(rs *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + if rs.Status.ReadyReplicas > 0 { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "ReplicaSet 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 ReplicaSet +// when the parent component is suspended. +// +// It always returns false, meaning the ReplicaSet 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.ReplicaSet) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a ReplicaSet when the component is suspended. +// +// It scales the ReplicaSet 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 ReplicaSet 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(rs *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + if rs.Status.Replicas == 0 { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "ReplicaSet scaled to zero", + }, nil + } + + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspending, + Reason: fmt.Sprintf("Waiting for replicas to scale down, %d replicas still running.", rs.Status.Replicas), + }, nil +} diff --git a/pkg/primitives/replicaset/handlers_test.go b/pkg/primitives/replicaset/handlers_test.go new file mode 100644 index 00000000..58ef1cd9 --- /dev/null +++ b/pkg/primitives/replicaset/handlers_test.go @@ -0,0 +1,197 @@ +package replicaset + +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 + rs *appsv1.ReplicaSet + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "ready with 1 replica (default)", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{}, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "ready with custom replicas", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 3, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + { + name: "creating", + op: concepts.ConvergingOperationCreated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "updating", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "scaling", + op: concepts.ConvergingOperation("Scaling"), + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusScaling, + wantReason: "Waiting for replicas: 1/3 ready", + }, + { + name: "zero replicas desired and ready", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(0)), + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 0, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "All replicas are ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.rs) + 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) { + rs := &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 1, + }, + } + got, err := DefaultGraceStatusHandler(rs) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "ReplicaSet partially available", got.Reason) + }) + + t.Run("down (none ready)", func(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: 0, + }, + } + got, err := DefaultGraceStatusHandler(rs) + 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) { + rs := &appsv1.ReplicaSet{} + assert.False(t, DefaultDeleteOnSuspendHandler(rs)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + mutator := NewMutator(rs) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + err = mutator.Apply() + require.NoError(t, err) + assert.Equal(t, int32(0), *rs.Spec.Replicas) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + tests := []struct { + name string + rs *appsv1.ReplicaSet + wantStatus concepts.SuspensionStatus + wantReason string + }{ + { + name: "suspended", + rs: &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + Replicas: 0, + }, + }, + wantStatus: concepts.SuspensionStatusSuspended, + wantReason: "ReplicaSet scaled to zero", + }, + { + name: "suspending", + rs: &appsv1.ReplicaSet{ + Status: appsv1.ReplicaSetStatus{ + 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.rs) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} diff --git a/pkg/primitives/replicaset/mutator.go b/pkg/primitives/replicaset/mutator.go new file mode 100644 index 00000000..f239572c --- /dev/null +++ b/pkg/primitives/replicaset/mutator.go @@ -0,0 +1,465 @@ +package replicaset + +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 replicaset 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 featurePlan struct { + replicaSetMetadataEdits []func(*editors.ObjectMetaEditor) error + replicaSetSpecEdits []func(*editors.ReplicaSetSpecEditor) error + podTemplateMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit +} + +// Mutator is a high-level helper for modifying a Kubernetes ReplicaSet. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the ReplicaSet in a single controlled pass when Apply() is called. +// +// This approach ensures that mutations are applied consistently and minimizes +// repeated scans of the underlying Kubernetes structures. +// +// 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.ReplicaSet + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given ReplicaSet. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the ReplicaSet. +func NewMutator(current *appsv1.ReplicaSet) *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 EndFeature +// or another beginFeature is called. +// +// This is used to ensure that mutations from different features are applied +// in registration order while maintaining internal category ordering within +// each feature. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditReplicaSetSpec records a mutation for the ReplicaSet's top-level spec. +// +// Planning: +// All replicaset spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, replicaset spec edits are executed AFTER replicaset-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) EditReplicaSetSpec(edit func(*editors.ReplicaSetSpecEditor) error) { + if edit == nil { + return + } + m.active.replicaSetSpecEdits = append(m.active.replicaSetSpecEdits, edit) +} + +// EditPodSpec records a mutation for the ReplicaSet'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 replica/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 ReplicaSet'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 replica/replicaset-metadata 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 ReplicaSet'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.replicaSetMetadataEdits = append(m.active.replicaSetMetadataEdits, edit) +} + +// 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. +// - 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 + } + 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 ReplicaSet. +// 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 ReplicaSet. +// 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) + } +} + +// EnsureReplicas records the desired number of replicas for the ReplicaSet. +func (m *Mutator) EnsureReplicas(replicas int32) { + m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetReplicas(replicas) + return nil + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the ReplicaSet. +// +// 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 ReplicaSet. +// +// 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 ReplicaSet. +// +// 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 ReplicaSet. +// +// 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 ReplicaSet. +// +// 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 ReplicaSet. +// +// 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 ReplicaSet. +// +// 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. ReplicaSetSpec 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 +// +// Within each category of a single feature, edits are applied in their registration order. +// +// Selection & Identity: +// - Container selectors target containers in the state they are in at the start of that feature's +// container phase (after presence operations of the SAME feature have been applied). +// - Selector matching within a phase is evaluated against a snapshot of containers at the start +// of that phase, not the progressively mutated live containers. +// - Later features observe the ReplicaSet as modified by all previous features. +// +// Timing: +// No changes are made to the ReplicaSet until Apply() is called. +// Selectors and edit functions are executed during this pass. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.replicaSetMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.replicaSetMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. ReplicaSetSpec + if len(plan.replicaSetSpecEdits) > 0 { + editor := editors.NewReplicaSetSpecEditor(&m.current.Spec) + for _, edit := range plan.replicaSetSpecEdits { + 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 { + // Take snapshot of containers AFTER presence ops but BEFORE applying any edits for stable selector matching + 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 { + // Take snapshot of init containers AFTER presence ops but BEFORE applying any edits + 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 + } + } + } + } + } + } + + 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) + } +} diff --git a/pkg/primitives/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go new file mode 100644 index 00000000..80493722 --- /dev/null +++ b/pkg/primitives/replicaset/mutator_test.go @@ -0,0 +1,612 @@ +package replicaset + +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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestMutator_EnvVars(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + 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(rs) + 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 := rs.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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := rs.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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + m := NewMutator(rs) + m.EnsureReplicas(5) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, int32(5), *rs.Spec.Replicas) +} + +func TestNewMutator(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + assert.NotNil(t, m) + assert.Equal(t, rs, m.current) +} + +func TestMutator_EditContainers(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + 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", rs.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "", rs.Spec.Template.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", rs.Spec.Template.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", rs.Spec.Template.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + 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", rs.Spec.Template.Spec.ServiceAccountName) +} + +func TestMutator_EditReplicaSetSpec(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetMinReadySeconds(10) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, int32(10), rs.Spec.MinReadySeconds) +} + +func TestMutator_EditMetadata(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"rs": "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", rs.Labels["rs"]) + assert.Equal(t, "ann", rs.Spec.Template.Annotations["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + 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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + 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(rs) + 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.EditReplicaSetSpec(func(_ *editors.ReplicaSetSpecEditor) error { + order = append(order, "rsspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "rsmeta") + return nil + }) + m.EnsureReplicas(3) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"rsmeta", "rsspec", "podmeta", "podspec", "container"} + assert.Equal(t, expected, order) + assert.Equal(t, int32(3), *rs.Spec.Replicas) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = newImage + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if rs.Spec.Template.Spec.InitContainers[0].Image != newImage { + t.Errorf("expected image %s, got %s", newImage, rs.Spec.Template.Spec.InitContainers[0].Image) + } +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + m.RemoveContainer("sidecar") + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if len(rs.Spec.Template.Spec.Containers) != 2 { + t.Fatalf("expected 2 containers, got %d", len(rs.Spec.Template.Spec.Containers)) + } + + if rs.Spec.Template.Spec.Containers[0].Name != "app" || rs.Spec.Template.Spec.Containers[0].Image != "app-new-image" { + t.Errorf("unexpected container at index 0: %+v", rs.Spec.Template.Spec.Containers[0]) + } + + if rs.Spec.Template.Spec.Containers[1].Name != "new-container" || rs.Spec.Template.Spec.Containers[1].Image != newImage { + t.Errorf("unexpected container at index 1: %+v", rs.Spec.Template.Spec.Containers[1]) + } +} + +func TestMutator_InitContainerPresence(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if len(rs.Spec.Template.Spec.InitContainers) != 1 { + t.Fatalf("expected 1 init container, got %d", len(rs.Spec.Template.Spec.InitContainers)) + } + + if rs.Spec.Template.Spec.InitContainers[0].Name != "init-2" { + t.Errorf("expected init-2, got %s", rs.Spec.Template.Spec.InitContainers[0].Name) + } +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + }, + }, + } + + m := NewMutator(rs) + + 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 + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if rs.Spec.Template.Spec.Containers[0].Name != appV2 { + t.Errorf("expected name %s, got %s", appV2, rs.Spec.Template.Spec.Containers[0].Name) + } + + if rs.Spec.Template.Spec.Containers[0].Image != "app-image-updated" { + t.Errorf("expected image app-image-updated, got %s", rs.Spec.Template.Spec.Containers[0].Image) + } +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(rs) + + 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"}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if len(rs.Spec.Template.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(rs.Spec.Template.Spec.Containers)) + } + + if rs.Spec.Template.Spec.Containers[0].Image != "edited-image" { + t.Errorf("expected edited-image, got %s", rs.Spec.Template.Spec.Containers[0].Image) + } +} + +func TestMutator_NilSafety(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + }, + }, + } + m := NewMutator(rs) + + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditPodTemplateMetadata(nil) + m.EditObjectMetadata(nil) + m.EditReplicaSetSpec(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + }, + }, + } + + m := NewMutator(rs) + + // Feature A + m.beginFeature() + m.EnsureReplicas(2) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B + m.beginFeature() + m.EnsureReplicas(3) + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + assert.Equal(t, int32(3), *rs.Spec.Replicas) + assert.Equal(t, "v3", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(rs) + + 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.EditReplicaSetSpec(func(_ *editors.ReplicaSetSpecEditor) error { + executionOrder = append(executionOrder, "replicasetspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "replicasetmeta") + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + expectedOrder := []string{ + "replicasetmeta", + "replicasetspec", + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + + m := NewMutator(rs) + + // Feature A renames container + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Feature B selects by the new name + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + assert.Equal(t, "app-v2", rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", rs.Spec.Template.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + }, + }, + } + + m := NewMutator(rs) + + 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 + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + require.Len(t, rs.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", rs.Spec.Template.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", rs.Spec.Template.Spec.InitContainers[0].Image) +} diff --git a/pkg/primitives/replicaset/resource.go b/pkg/primitives/replicaset/resource.go new file mode 100644 index 00000000..bc50eb46 --- /dev/null +++ b/pkg/primitives/replicaset/resource.go @@ -0,0 +1,134 @@ +package replicaset + +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, preserving +// the immutable spec.selector from the live object. +// +// In Kubernetes, a ReplicaSet's spec.selector is immutable after creation. This +// applicator saves the current selector before overwriting and restores it if the +// object already exists in the cluster (indicated by a non-empty ResourceVersion). +func DefaultFieldApplicator(current, desired *appsv1.ReplicaSet) error { + selector := current.Spec.Selector + *current = *desired.DeepCopy() + if current.ResourceVersion != "" { + current.Spec.Selector = selector + } + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes ReplicaSet 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 ReplicaSet, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*appsv1.ReplicaSet, *Mutator] +} + +// Identity returns a unique identifier for the ReplicaSet in the format +// "apps/v1/ReplicaSet//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific ReplicaSet 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 ReplicaSet 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 ReplicaSet 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 ReplicaSet. +// 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 ReplicaSet 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 ReplicaSet 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 ReplicaSet should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false, meaning +// the ReplicaSet 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 ReplicaSet. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler to scale the ReplicaSet +// to zero replicas. +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 +// ReplicaSet 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 ReplicaSet. +// +// Data extractors are provided with a deep copy of the current ReplicaSet to +// prevent accidental mutations during the extraction process. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From 9b83617ebfefca1add51e38240ec07bb9017f169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:41:09 +0000 Subject: [PATCH 02/18] Add ReplicaSet primitive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/replicaset.md | 173 ++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/primitives/replicaset.md diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md new file mode 100644 index 00000000..d4e4b093 --- /dev/null +++ b/docs/primitives/replicaset.md @@ -0,0 +1,173 @@ +# ReplicaSet Primitive + +The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata. + +ReplicaSets are rarely managed directly — operators typically use Deployments. This primitive is provided for operators that own ReplicaSets explicitly (e.g. custom rollout controllers). + +## 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, replicaset spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | + +## Building a ReplicaSet Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" + +base := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker", + Namespace: owner.Namespace, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "worker"}, + }, + // baseline spec + }, +} + +resource, err := replicaset.NewBuilder(base). + WithFieldApplicationFlavor(replicaset.PreserveCurrentLabels). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Immutable Selector + +A ReplicaSet's `spec.selector` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the selector from the live object when updating an existing ReplicaSet. Set the selector via the desired object passed to `NewBuilder` — it is applied on creation and preserved on subsequent updates. + +If you supply a custom field applicator via `WithCustomFieldApplicator`, you are responsible for preserving the selector yourself. + +## Mutations + +Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally: + +```go +func MyFeatureMutation(version string) replicaset.Mutation { + return replicaset.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *replicaset.Mutator) error { + // record edits here + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TracingMutation(version string, enabled bool) replicaset.Mutation { + return replicaset.Mutation{ + Name: "tracing", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *replicaset.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + return nil + }, + } +} +``` + +## 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: + +| Step | Category | What it affects | +|---|---|---| +| 1 | Object metadata edits | Labels and annotations on the `ReplicaSet` object | +| 2 | ReplicaSetSpec edits | Replicas, min ready seconds | +| 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) | + +Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* presence operations in the same mutation. + +## Editors + +### ReplicaSetSpecEditor + +Controls replicaset-level settings via `m.EditReplicaSetSpec`. + +Available methods: `SetReplicas`, `SetMinReadySeconds`, `Raw`. + +```go +m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { + e.SetReplicas(3) + e.SetMinReadySeconds(10) + return nil +}) +``` + +Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object passed to `NewBuilder`. + +### 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("my-service-account") + 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("app"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or `m.EditPodTemplateMetadata` to target the pod template. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +## Convenience Methods + +| Method | Equivalent to | +|-------------------------------|-------------------------------------------------------------------| +| `EnsureReplicas(n)` | `EditReplicaSetSpec` → `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)` | + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. From 7fdff1ca30dec057172f9345b3eac131b995fc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:46 +0000 Subject: [PATCH 03/18] Add ReplicaSet primitive example Demonstrates building a ReplicaSet resource with feature mutations, flavors, data extraction, and suspension using the replicaset primitive. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/replicaset-primitive/README.md | 32 +++++ .../replicaset-primitive/app/controller.go | 54 ++++++++ examples/replicaset-primitive/app/owner.go | 20 +++ .../replicaset-primitive/features/flavors.go | 16 +++ .../features/mutations.go | 75 +++++++++++ examples/replicaset-primitive/main.go | 122 ++++++++++++++++++ .../resources/replicaset.go | 80 ++++++++++++ 7 files changed, 399 insertions(+) create mode 100644 examples/replicaset-primitive/README.md create mode 100644 examples/replicaset-primitive/app/controller.go create mode 100644 examples/replicaset-primitive/app/owner.go create mode 100644 examples/replicaset-primitive/features/flavors.go create mode 100644 examples/replicaset-primitive/features/mutations.go create mode 100644 examples/replicaset-primitive/main.go create mode 100644 examples/replicaset-primitive/resources/replicaset.go diff --git a/examples/replicaset-primitive/README.md b/examples/replicaset-primitive/README.md new file mode 100644 index 00000000..56e89622 --- /dev/null +++ b/examples/replicaset-primitive/README.md @@ -0,0 +1,32 @@ +# ReplicaSet Primitive Example + +This example demonstrates the usage of the `replicaset` primitive within the operator component framework. +It shows how to manage a Kubernetes ReplicaSet as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a ReplicaSet with basic metadata and spec. +- **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). +- **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. +- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the `replicaset.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/replicaset-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/replicaset-primitive/app/controller.go b/examples/replicaset-primitive/app/controller.go new file mode 100644 index 00000000..1b2e569a --- /dev/null +++ b/examples/replicaset-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the replicaset 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 + + // NewReplicaSetResource is a factory function to create the replicaset resource. + // This allows us to inject the resource construction logic. + NewReplicaSetResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the replicaset resource for this owner. + rsResource, err := r.NewReplicaSetResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the replicaset. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(rsResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + 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/replicaset-primitive/app/owner.go b/examples/replicaset-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/replicaset-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/replicaset-primitive/features/flavors.go b/examples/replicaset-primitive/features/flavors.go new file mode 100644 index 00000000..bda6995c --- /dev/null +++ b/examples/replicaset-primitive/features/flavors.go @@ -0,0 +1,16 @@ +// Package features provides sample features for the replicaset primitive. +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" +) + +// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. +func PreserveLabelsFlavor() replicaset.FieldApplicationFlavor { + return replicaset.PreserveCurrentLabels +} + +// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. +func PreserveAnnotationsFlavor() replicaset.FieldApplicationFlavor { + return replicaset.PreserveCurrentAnnotations +} diff --git a/examples/replicaset-primitive/features/mutations.go b/examples/replicaset-primitive/features/mutations.go new file mode 100644 index 00000000..4237913c --- /dev/null +++ b/examples/replicaset-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/replicaset" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds a Jaeger sidecar to the replicaset. +func TracingFeature(enabled bool) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *replicaset.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) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Metrics", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *replicaset.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) replicaset.Mutation { + return replicaset.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *replicaset.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/replicaset-primitive/main.go b/examples/replicaset-primitive/main.go new file mode 100644 index 00000000..24efa3c1 --- /dev/null +++ b/examples/replicaset-primitive/main.go @@ -0,0 +1,122 @@ +// Package main is the entry point for the replicaset 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/replicaset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/replicaset-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, + }, + + // Pass the replicaset resource factory. + NewReplicaSetResource: resources.NewReplicaSetResource, + } + + // 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/replicaset-primitive/resources/replicaset.go b/examples/replicaset-primitive/resources/replicaset.go new file mode 100644 index 00000000..bfdc4b84 --- /dev/null +++ b/examples/replicaset-primitive/resources/replicaset.go @@ -0,0 +1,80 @@ +// Package resources provides resource implementations for the replicaset primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/replicaset-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewReplicaSetResource constructs a replicaset primitive resource with all the features. +func NewReplicaSetResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base replicaset object. + base := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-replicaset", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: appsv1.ReplicaSetSpec{ + 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 + }, + }, + }, + }, + }, + } + + // 2. Initialize the replicaset builder. + builder := replicaset.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 (e.g., preserve labels/annotations if they were modified externally). + builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) + builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) + + // 5. Data extraction (optional). + builder.WithDataExtractor(func(rs appsv1.ReplicaSet) error { + fmt.Printf("Reconciling replicaset: %s, ready replicas: %d\n", rs.Name, rs.Status.ReadyReplicas) + + // Print the complete replicaset resource object as yaml + y, err := yaml.Marshal(rs) + if err != nil { + return fmt.Errorf("failed to marshal replicaset to yaml: %w", err) + } + fmt.Printf("Complete ReplicaSet Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From 3870cc279df6e21c205d448a0bbef74011740f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:31:14 +0000 Subject: [PATCH 04/18] Fix DefaultFieldApplicator bug and docs from Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address GitHub Copilot PR review comments: - Fix DefaultFieldApplicator: capture ResourceVersion before DeepCopy overwrites it, so immutable spec.selector is correctly preserved on updates (was silently broken because ResourceVersion was always empty after the copy). - Fix comment typo: "custom customFieldApplicator" → "custom field applicator". - Fix docs capabilities table: remove "Failing" status and "Graceful rollouts" row that described unimplemented functionality. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/replicaset.md | 3 +-- pkg/primitives/replicaset/resource.go | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md index d4e4b093..5c720e74 100644 --- a/docs/primitives/replicaset.md +++ b/docs/primitives/replicaset.md @@ -8,8 +8,7 @@ ReplicaSets are rarely managed directly — operators typically use Deployments. | 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 | +| **Health tracking** | Monitors `ReadyReplicas` and reports `Healthy`, `Creating`, `Updating`, or `Scaling` | | **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | | **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | | **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | diff --git a/pkg/primitives/replicaset/resource.go b/pkg/primitives/replicaset/resource.go index bc50eb46..015a96b4 100644 --- a/pkg/primitives/replicaset/resource.go +++ b/pkg/primitives/replicaset/resource.go @@ -14,10 +14,14 @@ import ( // applicator saves the current selector before overwriting and restores it if the // object already exists in the cluster (indicated by a non-empty ResourceVersion). func DefaultFieldApplicator(current, desired *appsv1.ReplicaSet) error { + originalResourceVersion := current.ResourceVersion selector := current.Spec.Selector + *current = *desired.DeepCopy() - if current.ResourceVersion != "" { + + if originalResourceVersion != "" { current.Spec.Selector = selector + current.ResourceVersion = originalResourceVersion } return nil } @@ -63,7 +67,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 ReplicaSet. // 3. Suspension: If the resource is in a suspending state, the suspension From d8bc81078d188b2d5d4449f98369f8c81fb7cc28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:40:56 +0000 Subject: [PATCH 05/18] Add resource-level tests for ReplicaSet primitive Add comprehensive test coverage for the ReplicaSet Resource, matching the patterns established by the deployment primitive: - Identity format verification - Object deep copy semantics - Mutate with feature mutations and env var injection - Feature ordering across multiple mutations - DefaultFieldApplicator: creation applies all fields, update preserves immutable selector and ResourceVersion, desired object not mutated - Status handler delegation (ConvergingStatus, GraceStatus) with both custom mock handlers and default handler verification - DeleteOnSuspend with custom and default handlers - Suspend + Mutate integration (default scales to zero, custom handler) - SuspensionStatus with custom and default handlers - ExtractData captures container image from reconciled object - CustomFieldApplicator selective field application and error propagation Addresses Copilot review comment requesting resource-level test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/resource_test.go | 531 +++++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 pkg/primitives/replicaset/resource_test.go diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go new file mode 100644 index 00000000..80bd8f03 --- /dev/null +++ b/pkg/primitives/replicaset/resource_test.go @@ -0,0 +1,531 @@ +package replicaset + +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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestResource_Identity(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, _ := NewBuilder(rs).Build() + + assert.Equal(t, "apps/v1/ReplicaSet/test-ns/test-rs", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-rs", + Namespace: "test-ns", + }, + } + res, _ := NewBuilder(rs).Build() + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*appsv1.ReplicaSet) + require.True(t, ok) + assert.Equal(t, rs.Name, got.Name) + assert.Equal(t, rs.Namespace, got.Namespace) + + // Ensure it's a deep copy + got.Name = "changed" + assert.Equal(t, "test-rs", rs.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + 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.ReplicaSet{} + 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.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + 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 { + // This should see image "v2" if beginFeature() is working correctly between mutations + 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.ReplicaSet{} + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "v3", current.Spec.Template.Spec.Containers[0].Image) +} + +func TestDefaultFieldApplicator(t *testing.T) { + t.Run("creation applies all fields from desired", func(t *testing.T) { + current := &appsv1.ReplicaSet{} + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, "test", current.Name) + assert.Equal(t, int32(3), *current.Spec.Replicas) + assert.Equal(t, "test", current.Spec.Selector.MatchLabels["app"]) + }) + + t.Run("update preserves immutable selector from current", func(t *testing.T) { + currentSelector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "original"}, + } + current := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "12345", + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + Selector: currentSelector, + }, + } + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(5)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "changed"}, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, int32(5), *current.Spec.Replicas, "mutable fields should be updated") + assert.Equal(t, "original", current.Spec.Selector.MatchLabels["app"], + "immutable selector should be preserved from current on update") + }) + + t.Run("update preserves ResourceVersion", func(t *testing.T) { + current := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "99999", + }, + } + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(2)), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, "99999", current.ResourceVersion, + "ResourceVersion should be preserved from current on update") + }) + + t.Run("desired is not mutated", func(t *testing.T) { + current := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ResourceVersion: "1"}, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "current"}, + }, + }, + } + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "desired"}, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + assert.Equal(t, "desired", desired.Spec.Selector.MatchLabels["app"], + "desired should not be mutated by the applicator") + }) +} + +type mockHandlers struct { + mock.Mock +} + +func (m *mockHandlers) ConvergingStatus(op concepts.ConvergingOperation, rs *appsv1.ReplicaSet) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, rs) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) GraceStatus(rs *appsv1.ReplicaSet) (concepts.GraceStatusWithReason, error) { + args := m.Called(rs) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *mockHandlers) SuspensionStatus(rs *appsv1.ReplicaSet) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(rs) + 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(rs *appsv1.ReplicaSet) bool { + args := m.Called(rs) + return args.Bool(0) +} + +func TestResource_Status(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.ReplicaSetStatus{ + 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, rs).Return(statusReady, nil) + + res, _ := NewBuilder(rs). + 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(rs).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", rs).Return(statusReady, nil) + + res, _ := NewBuilder(rs). + 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(rs).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) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + m.On("DeleteOnSuspend", rs).Return(true) + + res, err := NewBuilder(rs). + 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(rs).Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + t.Run("Suspend registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(rs).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := rs.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(rs). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := rs.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) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: appsv1.ReplicaSetStatus{ + Replicas: 0, + }, + } + + t.Run("calls handler", func(t *testing.T) { + m := &mockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", rs).Return(statusSuspended, nil) + + res, err := NewBuilder(rs). + 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(rs).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) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "web", Image: "nginx:latest"}}, + }, + }, + }, + } + + extractedImage := "" + res, err := NewBuilder(rs). + WithDataExtractor(func(r appsv1.ReplicaSet) error { + extractedImage = r.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.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + }, + } + + applicatorCalled := false + res, _ := NewBuilder(desired). + WithCustomFieldApplicator(func(current *appsv1.ReplicaSet, desired *appsv1.ReplicaSet) 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.ReplicaSet{ + 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.ReplicaSet, _ *appsv1.ReplicaSet) error { + return errors.New("applicator error") + }). + Build() + + err := res.Mutate(&appsv1.ReplicaSet{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") + }) +} From 6ab1aa7b8436c4bec630f26670e0421889e8bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:51:44 +0000 Subject: [PATCH 06/18] Preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/resource.go | 20 ++++---- pkg/primitives/replicaset/resource_test.go | 55 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/pkg/primitives/replicaset/resource.go b/pkg/primitives/replicaset/resource.go index 015a96b4..5eddee3a 100644 --- a/pkg/primitives/replicaset/resource.go +++ b/pkg/primitives/replicaset/resource.go @@ -7,21 +7,21 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired, preserving -// the immutable spec.selector from the live 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. // // In Kubernetes, a ReplicaSet's spec.selector is immutable after creation. This -// applicator saves the current selector before overwriting and restores it if the -// object already exists in the cluster (indicated by a non-empty ResourceVersion). +// applicator also preserves the current selector when updating an existing object +// (indicated by a non-empty ResourceVersion). func DefaultFieldApplicator(current, desired *appsv1.ReplicaSet) error { - originalResourceVersion := current.ResourceVersion - selector := current.Spec.Selector - + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) - if originalResourceVersion != "" { - current.Spec.Selector = selector - current.ResourceVersion = originalResourceVersion + if original.ResourceVersion != "" { + current.Spec.Selector = original.Spec.Selector } return nil } diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go index 80bd8f03..f447b889 100644 --- a/pkg/primitives/replicaset/resource_test.go +++ b/pkg/primitives/replicaset/resource_test.go @@ -263,6 +263,61 @@ func TestDefaultFieldApplicator(t *testing.T) { }) } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &appsv1.ReplicaSet{ + 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"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "original"}, + }, + }, + } + desired := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(3)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "changed"}, + }, + }, + } + + 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) + + // Immutable selector is preserved + assert.Equal(t, "original", current.Spec.Selector.MatchLabels["app"], + "immutable selector should be preserved from current on update") +} + type mockHandlers struct { mock.Mock } From ed059bf2248a034af40fdb6ab0abdf2b23990acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni?= Date: Sun, 22 Mar 2026 23:00:34 +0000 Subject: [PATCH 07/18] Update pkg/primitives/replicaset/resource.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/primitives/replicaset/resource.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/replicaset/resource.go b/pkg/primitives/replicaset/resource.go index 5eddee3a..fe1ddd77 100644 --- a/pkg/primitives/replicaset/resource.go +++ b/pkg/primitives/replicaset/resource.go @@ -31,9 +31,9 @@ func DefaultFieldApplicator(current, desired *appsv1.ReplicaSet) error { // // 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. +// - concepts.Alive: for health and readiness tracking. +// - concepts.Suspendable: for graceful scale-down or temporary deactivation. +// - concepts.DataExtractable: for exporting information after successful reconciliation. // // This resource handles the lifecycle of a ReplicaSet, including initial creation, // updates via feature mutations, and status monitoring. From 52c666144a7836a80537af86669d0cb7968f84a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 02:55:50 +0000 Subject: [PATCH 08/18] Fix range variable pointer bug in findEnv test helper Iterate by index instead of copying the range variable to avoid returning a pointer to a reused loop-variable copy. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/mutator_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go index 80493722..863fee9e 100644 --- a/pkg/primitives/replicaset/mutator_test.go +++ b/pkg/primitives/replicaset/mutator_test.go @@ -46,9 +46,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 9b6f00c05d4281aa2582429f1144acdc06641d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:58:32 +0000 Subject: [PATCH 09/18] Replace t.Fatalf/t.Errorf with testify require/assert in replicaset mutator tests Standardize test assertion style to use testify require.NoError, require.Len, and assert.Equal consistently across all replicaset mutator tests, matching the conventions used elsewhere in the package. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/mutator_test.go | 91 ++++++++--------------- 1 file changed, 30 insertions(+), 61 deletions(-) diff --git a/pkg/primitives/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go index 863fee9e..19125f06 100644 --- a/pkg/primitives/replicaset/mutator_test.go +++ b/pkg/primitives/replicaset/mutator_test.go @@ -272,13 +272,10 @@ func TestMutator_InitContainers(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) - if rs.Spec.Template.Spec.InitContainers[0].Image != newImage { - t.Errorf("expected image %s, got %s", newImage, rs.Spec.Template.Spec.InitContainers[0].Image) - } + assert.Equal(t, newImage, rs.Spec.Template.Spec.InitContainers[0].Image) } func TestMutator_ContainerPresence(t *testing.T) { @@ -301,21 +298,15 @@ func TestMutator_ContainerPresence(t *testing.T) { m.RemoveContainer("sidecar") m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if len(rs.Spec.Template.Spec.Containers) != 2 { - t.Fatalf("expected 2 containers, got %d", len(rs.Spec.Template.Spec.Containers)) - } + err := m.Apply() + require.NoError(t, err) - if rs.Spec.Template.Spec.Containers[0].Name != "app" || rs.Spec.Template.Spec.Containers[0].Image != "app-new-image" { - t.Errorf("unexpected container at index 0: %+v", rs.Spec.Template.Spec.Containers[0]) - } + require.Len(t, rs.Spec.Template.Spec.Containers, 2) - if rs.Spec.Template.Spec.Containers[1].Name != "new-container" || rs.Spec.Template.Spec.Containers[1].Image != newImage { - t.Errorf("unexpected container at index 1: %+v", rs.Spec.Template.Spec.Containers[1]) - } + assert.Equal(t, "app", rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", rs.Spec.Template.Spec.Containers[0].Image) + assert.Equal(t, "new-container", rs.Spec.Template.Spec.Containers[1].Name) + assert.Equal(t, newImage, rs.Spec.Template.Spec.Containers[1].Image) } func TestMutator_InitContainerPresence(t *testing.T) { @@ -335,17 +326,11 @@ func TestMutator_InitContainerPresence(t *testing.T) { m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if len(rs.Spec.Template.Spec.InitContainers) != 1 { - t.Fatalf("expected 1 init container, got %d", len(rs.Spec.Template.Spec.InitContainers)) - } + err := m.Apply() + require.NoError(t, err) - if rs.Spec.Template.Spec.InitContainers[0].Name != "init-2" { - t.Errorf("expected init-2, got %s", rs.Spec.Template.Spec.InitContainers[0].Name) - } + require.Len(t, rs.Spec.Template.Spec.InitContainers, 1) + assert.Equal(t, "init-2", rs.Spec.Template.Spec.InitContainers[0].Name) } func TestMutator_SelectorSnapshotSemantics(t *testing.T) { @@ -379,17 +364,11 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if rs.Spec.Template.Spec.Containers[0].Name != appV2 { - t.Errorf("expected name %s, got %s", appV2, rs.Spec.Template.Spec.Containers[0].Name) - } + err := m.Apply() + require.NoError(t, err) - if rs.Spec.Template.Spec.Containers[0].Image != "app-image-updated" { - t.Errorf("expected image app-image-updated, got %s", rs.Spec.Template.Spec.Containers[0].Image) - } + assert.Equal(t, appV2, rs.Spec.Template.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", rs.Spec.Template.Spec.Containers[0].Image) } func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { @@ -412,17 +391,11 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if len(rs.Spec.Template.Spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(rs.Spec.Template.Spec.Containers)) - } + err := m.Apply() + require.NoError(t, err) - if rs.Spec.Template.Spec.Containers[0].Image != "edited-image" { - t.Errorf("expected edited-image, got %s", rs.Spec.Template.Spec.Containers[0].Image) - } + require.Len(t, rs.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "edited-image", rs.Spec.Template.Spec.Containers[0].Image) } func TestMutator_NilSafety(t *testing.T) { @@ -478,9 +451,8 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) assert.Equal(t, int32(3), *rs.Spec.Replicas) assert.Equal(t, "v3", rs.Spec.Template.Spec.Containers[0].Image) @@ -523,9 +495,8 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) expectedOrder := []string{ "replicasetmeta", @@ -564,9 +535,8 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) assert.Equal(t, "app-v2", rs.Spec.Template.Spec.Containers[0].Name) assert.Equal(t, "v2-image", rs.Spec.Template.Spec.Containers[0].Image) @@ -602,9 +572,8 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) require.Len(t, rs.Spec.Template.Spec.InitContainers, 1) assert.Equal(t, "init-1-renamed", rs.Spec.Template.Spec.InitContainers[0].Name) From ade78f21ccdcd54db32acd2fb0c4f5df18b7719b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:35:56 +0000 Subject: [PATCH 10/18] Export BeginFeature() in replicaset mutator to satisfy FeatureMutator interface Aligns with upstream change in main that exported BeginFeature() across all primitives. Also updates NewMutator to use inline plan initialization matching the configmap and deployment patterns. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/mutator.go | 9 +++++---- pkg/primitives/replicaset/mutator_test.go | 8 ++++---- pkg/primitives/replicaset/resource_test.go | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pkg/primitives/replicaset/mutator.go b/pkg/primitives/replicaset/mutator.go index f239572c..708c176f 100644 --- a/pkg/primitives/replicaset/mutator.go +++ b/pkg/primitives/replicaset/mutator.go @@ -57,19 +57,20 @@ type Mutator struct { func NewMutator(current *appsv1.ReplicaSet) *Mutator { m := &Mutator{ current: current, + plans: []featurePlan{{}}, } - m.beginFeature() + m.active = &m.plans[0] 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 EndFeature -// or another beginFeature is called. +// or another BeginFeature is called. // // This is used to ensure that mutations from different features are applied // in registration order while maintaining internal category ordering within // each feature. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go index 19125f06..a83eda3b 100644 --- a/pkg/primitives/replicaset/mutator_test.go +++ b/pkg/primitives/replicaset/mutator_test.go @@ -436,7 +436,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(rs) // Feature A - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" @@ -444,7 +444,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { }) // Feature B - m.beginFeature() + m.BeginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" @@ -522,14 +522,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(rs) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go index f447b889..76ddc96d 100644 --- a/pkg/primitives/replicaset/resource_test.go +++ b/pkg/primitives/replicaset/resource_test.go @@ -129,7 +129,7 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Name: "feature-b", Feature: feature.NewResourceFeature("v1", nil).When(true), Mutate: func(m *Mutator) error { - // This should see image "v2" if beginFeature() is working correctly between mutations + // This should see image "v2" if BeginFeature() is working correctly between mutations m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { if e.Raw().Image == "v2" { e.Raw().Image = "v3" From d7c80a7d33bc09af9d564b05f7ad0a852cd29859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:25:18 +0000 Subject: [PATCH 11/18] Add ObservedGeneration guard to ReplicaSet DefaultConvergingStatusHandler Verify that the replicaset controller has observed the latest spec (Status.ObservedGeneration >= ObjectMeta.Generation) before evaluating readiness fields. This prevents falsely reporting Healthy based on stale status from a previous generation. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/replicaset.md | 90 +++++++++++++--------- examples/replicaset-primitive/README.md | 18 +++-- pkg/primitives/replicaset/handlers.go | 12 ++- pkg/primitives/replicaset/handlers_test.go | 49 ++++++++++++ 4 files changed, 124 insertions(+), 45 deletions(-) diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md index 5c720e74..87c19e56 100644 --- a/docs/primitives/replicaset.md +++ b/docs/primitives/replicaset.md @@ -1,17 +1,20 @@ # ReplicaSet Primitive -The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and metadata. +The `replicaset` primitive is the framework's workload abstraction for managing Kubernetes `ReplicaSet` resources. It +integrates fully with the component lifecycle and provides a rich mutation API for managing containers, pod specs, and +metadata. -ReplicaSets are rarely managed directly — operators typically use Deployments. This primitive is provided for operators that own ReplicaSets explicitly (e.g. custom rollout controllers). +ReplicaSets are rarely managed directly — operators typically use Deployments. This primitive is provided for operators +that own ReplicaSets explicitly (e.g. custom rollout controllers). ## Capabilities -| Capability | Detail | -|-----------------------|-------------------------------------------------------------------------------------------------| -| **Health tracking** | Monitors `ReadyReplicas` and reports `Healthy`, `Creating`, `Updating`, or `Scaling` | -| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | -| **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` | +| **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Building a ReplicaSet Primitive @@ -39,13 +42,17 @@ resource, err := replicaset.NewBuilder(base). ## Immutable Selector -A ReplicaSet's `spec.selector` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the selector from the live object when updating an existing ReplicaSet. Set the selector via the desired object passed to `NewBuilder` — it is applied on creation and preserved on subsequent updates. +A ReplicaSet's `spec.selector` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the +selector from the live object when updating an existing ReplicaSet. Set the selector via the desired object passed to +`NewBuilder` — it is applied on creation and preserved on subsequent updates. -If you supply a custom field applicator via `WithCustomFieldApplicator`, you are responsible for preserving the selector yourself. +If you supply a custom field applicator via `WithCustomFieldApplicator`, you are responsible for preserving the selector +yourself. ## Mutations -Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally: @@ -86,20 +93,21 @@ func TracingMutation(version string, enabled bool) replicaset.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: +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded: -| Step | Category | What it affects | -|---|---|---| -| 1 | Object metadata edits | Labels and annotations on the `ReplicaSet` object | -| 2 | ReplicaSetSpec edits | Replicas, min ready seconds | -| 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) | +| Step | Category | What it affects | +| ---- | --------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `ReplicaSet` object | +| 2 | ReplicaSetSpec edits | Replicas, min ready seconds | +| 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) | -Container edits (steps 6 and 8) are evaluated against a snapshot taken *after* presence operations in the same mutation. +Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation. ## Editors @@ -117,13 +125,16 @@ m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { }) ``` -Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object passed to `NewBuilder`. +Note: `spec.selector` is immutable after creation and is not exposed by this editor. Set it via the desired object +passed to `NewBuilder`. ### 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`. +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 { @@ -134,9 +145,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("app"), func(e *editors.ContainerEditor) error { @@ -147,19 +160,20 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito ### ObjectMetaEditor -Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or `m.EditPodTemplateMetadata` to target the pod template. +Modifies labels and annotations. Use `m.EditObjectMetadata` to target the `ReplicaSet` object itself, or +`m.EditPodTemplateMetadata` to target the pod template. Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. ## Convenience Methods -| Method | Equivalent to | -|-------------------------------|-------------------------------------------------------------------| -| `EnsureReplicas(n)` | `EditReplicaSetSpec` → `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)` | `EditReplicaSetSpec` → `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)` | ## Guidance @@ -167,6 +181,8 @@ Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnno **Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. -**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. -**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/replicaset-primitive/README.md b/examples/replicaset-primitive/README.md index 56e89622..f93ce23b 100644 --- a/examples/replicaset-primitive/README.md +++ b/examples/replicaset-primitive/README.md @@ -1,20 +1,23 @@ # ReplicaSet Primitive Example -This example demonstrates the usage of the `replicaset` primitive within the operator component framework. -It shows how to manage a Kubernetes ReplicaSet as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `replicaset` primitive within the operator component framework. It shows how +to manage a Kubernetes ReplicaSet as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a ReplicaSet with basic metadata and spec. -- **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). +- **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). - **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. -- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the `replicaset.Builder`. + - `mutations.go`: sidecar injection, env vars, and version-based image updates. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve fields. +- `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the + `replicaset.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. ## Running the Example @@ -26,6 +29,7 @@ go run examples/replicaset-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/replicaset/handlers.go b/pkg/primitives/replicaset/handlers.go index a0f38dd2..1ba5a37d 100644 --- a/pkg/primitives/replicaset/handlers.go +++ b/pkg/primitives/replicaset/handlers.go @@ -9,13 +9,23 @@ import ( // DefaultConvergingStatusHandler is the default logic for determining if a ReplicaSet has reached its desired state. // -// It considers a ReplicaSet ready when its Status.ReadyReplicas matches the Spec.Replicas (defaulting to 1 if nil). +// It considers a ReplicaSet ready when the replicaset 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, rs *appsv1.ReplicaSet, ) (concepts.AliveStatusWithReason, error) { + if status := concepts.StaleGenerationStatus( + op, rs.Status.ObservedGeneration, rs.Generation, "replicaset", + ); status != nil { + return *status, nil + } + desiredReplicas := int32(1) if rs.Spec.Replicas != nil { desiredReplicas = *rs.Spec.Replicas diff --git a/pkg/primitives/replicaset/handlers_test.go b/pkg/primitives/replicaset/handlers_test.go index 58ef1cd9..b3a1865e 100644 --- a/pkg/primitives/replicaset/handlers_test.go +++ b/pkg/primitives/replicaset/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" ) @@ -100,6 +101,54 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { wantStatus: concepts.AliveConvergingStatusHealthy, wantReason: "All replicas are ready", }, + { + name: "stale observed generation after create", + op: concepts.ConvergingOperationCreated, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, + { + name: "stale observed generation after update", + op: concepts.ConvergingOperationUpdated, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 3}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 2, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, + { + name: "stale observed generation with no operation", + op: concepts.ConvergingOperationNone, + rs: &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Generation: 2}, + Spec: appsv1.ReplicaSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.ReplicaSetStatus{ + ObservedGeneration: 1, + ReadyReplicas: 1, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Waiting for replicaset controller to observe latest spec", + }, } for _, tt := range tests { From d706f93d2ecb523399356e204dc5cdcc5bb76fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:24:49 +0000 Subject: [PATCH 12/18] Fix BeginFeature docstring to remove reference to nonexistent EndFeature method Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/mutator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/replicaset/mutator.go b/pkg/primitives/replicaset/mutator.go index 708c176f..0d91bcf8 100644 --- a/pkg/primitives/replicaset/mutator.go +++ b/pkg/primitives/replicaset/mutator.go @@ -64,8 +64,8 @@ func NewMutator(current *appsv1.ReplicaSet) *Mutator { } // BeginFeature starts a new feature planning scope. All subsequent mutation -// registrations will be grouped into this feature's plan until EndFeature -// or another BeginFeature is called. +// registrations will be grouped into this feature's plan until another +// BeginFeature is called. // // This is used to ensure that mutations from different features are applied // in registration order while maintaining internal category ordering within From de71e8d5b64df829ff62b36e430a8e28930c4821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:58:49 +0000 Subject: [PATCH 13/18] Fix ReplicaSet Mutator to not create initial feature plan on construction Align ReplicaSet NewMutator with Deployment and ConfigMap patterns: - NewMutator no longer creates an initial empty feature plan - BeginFeature must be called before registering mutations - Updated all tests to call BeginFeature() before mutations - Added constructor and plan invariant tests matching other primitives Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/handlers_test.go | 1 + pkg/primitives/replicaset/mutator.go | 8 +- pkg/primitives/replicaset/mutator_test.go | 97 +++++++++++++++++++++- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/pkg/primitives/replicaset/handlers_test.go b/pkg/primitives/replicaset/handlers_test.go index b3a1865e..1fc84d18 100644 --- a/pkg/primitives/replicaset/handlers_test.go +++ b/pkg/primitives/replicaset/handlers_test.go @@ -199,6 +199,7 @@ func TestDefaultSuspendMutationHandler(t *testing.T) { }, } mutator := NewMutator(rs) + mutator.BeginFeature() err := DefaultSuspendMutationHandler(mutator) require.NoError(t, err) err = mutator.Apply() diff --git a/pkg/primitives/replicaset/mutator.go b/pkg/primitives/replicaset/mutator.go index 0d91bcf8..6245a2f5 100644 --- a/pkg/primitives/replicaset/mutator.go +++ b/pkg/primitives/replicaset/mutator.go @@ -53,14 +53,12 @@ type Mutator struct { // NewMutator creates a new Mutator for the given ReplicaSet. // // It is typically used within a Feature's Mutation logic to express desired -// changes to the ReplicaSet. +// changes to the ReplicaSet. BeginFeature must be called before registering +// any mutations. func NewMutator(current *appsv1.ReplicaSet) *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/replicaset/mutator_test.go b/pkg/primitives/replicaset/mutator_test.go index a83eda3b..ffc14849 100644 --- a/pkg/primitives/replicaset/mutator_test.go +++ b/pkg/primitives/replicaset/mutator_test.go @@ -35,6 +35,7 @@ func TestMutator_EnvVars(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) @@ -78,6 +79,7 @@ func TestMutator_Args(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EnsureContainerArg("--change=new") m.EnsureContainerArg("--add") m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) @@ -101,6 +103,7 @@ func TestMutator_Replicas(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EnsureReplicas(5) err := m.Apply() @@ -114,6 +117,63 @@ func TestNewMutator(t *testing.T) { m := NewMutator(rs) assert.NotNil(t, m) assert.Equal(t, rs, 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) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + + 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) { + rs := &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + }, + }, + } + m := NewMutator(rs) + + // 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 different mutations + m.BeginFeature() + m.EnsureReplicas(5) + + // First plan should have its edits, second plan should have its own + assert.Len(t, m.plans[0].replicaSetSpecEdits, 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].replicaSetSpecEdits, 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) { + rs := &appsv1.ReplicaSet{} + m := NewMutator(rs) + m.BeginFeature() + m.EnsureReplicas(3) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, int32(3), *rs.Spec.Replicas) } func TestMutator_EditContainers(t *testing.T) { @@ -131,6 +191,7 @@ func TestMutator_EditContainers(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "c1-image" return nil @@ -152,6 +213,7 @@ func TestMutator_EditContainers(t *testing.T) { func TestMutator_EditPodSpec(t *testing.T) { rs := &appsv1.ReplicaSet{} m := NewMutator(rs) + m.BeginFeature() m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.Raw().ServiceAccountName = "my-sa" return nil @@ -165,6 +227,7 @@ func TestMutator_EditPodSpec(t *testing.T) { func TestMutator_EditReplicaSetSpec(t *testing.T) { rs := &appsv1.ReplicaSet{} m := NewMutator(rs) + m.BeginFeature() m.EditReplicaSetSpec(func(e *editors.ReplicaSetSpecEditor) error { e.SetMinReadySeconds(10) return nil @@ -178,6 +241,7 @@ func TestMutator_EditReplicaSetSpec(t *testing.T) { func TestMutator_EditMetadata(t *testing.T) { rs := &appsv1.ReplicaSet{} m := NewMutator(rs) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"rs": "label"} return nil @@ -196,6 +260,7 @@ func TestMutator_EditMetadata(t *testing.T) { func TestMutator_Errors(t *testing.T) { rs := &appsv1.ReplicaSet{} m := NewMutator(rs) + m.BeginFeature() m.EditPodSpec(func(_ *editors.PodSpecEditor) error { return errors.New("boom") }) @@ -222,6 +287,7 @@ func TestMutator_Order(t *testing.T) { var order []string m := NewMutator(rs) + m.BeginFeature() m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { order = append(order, "container") return nil @@ -267,6 +333,7 @@ func TestMutator_InitContainers(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = newImage return nil @@ -294,15 +361,18 @@ func TestMutator_ContainerPresence(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() + // Replace m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + // Remove m.RemoveContainer("sidecar") + // Append m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) err := m.Apply() require.NoError(t, err) require.Len(t, rs.Spec.Template.Spec.Containers, 2) - assert.Equal(t, "app", rs.Spec.Template.Spec.Containers[0].Name) assert.Equal(t, "app-new-image", rs.Spec.Template.Spec.Containers[0].Image) assert.Equal(t, "new-container", rs.Spec.Template.Spec.Containers[1].Name) @@ -323,6 +393,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) @@ -348,17 +419,21 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() + // First edit renames the container m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = appV2 return nil }) + // Second edit should still match using "app" selector because of snapshot m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "app-image-updated" return nil }) + // Third edit targeting "app-v2" should NOT match in this apply pass m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { e.Raw().Image = "should-not-be-set" return nil @@ -383,17 +458,21 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() + // Register edit first m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "edited-image" return nil }) + // Register presence later m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) err := m.Apply() require.NoError(t, err) + // It should work because presence happens before edits in Apply() require.Len(t, rs.Spec.Template.Spec.Containers, 1) assert.Equal(t, "edited-image", rs.Spec.Template.Spec.Containers[0].Image) } @@ -409,7 +488,9 @@ func TestMutator_NilSafety(t *testing.T) { }, } m := NewMutator(rs) + m.BeginFeature() + // These should all be no-ops and not panic m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) m.EditContainers(selectors.AllContainers(), nil) m.EditPodSpec(nil) @@ -435,7 +516,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(rs) - // Feature A + // Feature A: sets replicas to 2, image to v2 m.BeginFeature() m.EnsureReplicas(2) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -443,7 +524,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { return nil }) - // Feature B + // Feature B: sets replicas to 3, image to v3 m.BeginFeature() m.EnsureReplicas(3) m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -454,6 +535,7 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { err := m.Apply() require.NoError(t, err) + // Feature B should win assert.Equal(t, int32(3), *rs.Spec.Replicas) assert.Equal(t, "v3", rs.Spec.Template.Spec.Containers[0].Image) } @@ -471,9 +553,11 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() var executionOrder []string + // We register them in reverse order of expected execution m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { executionOrder = append(executionOrder, "container") return nil @@ -528,7 +612,7 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { return nil }) - // Feature B selects by the new name + // Feature B selects by the new name - this should work! m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" @@ -554,19 +638,24 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { } m := NewMutator(rs) + m.BeginFeature() + // 1. Add init-1 m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + // 2. Edit init-1 (it's present in the same feature's phase) m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v1-edited" return nil }) + // 3. Rename it inside the edit phase m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Name = "init-1-renamed" return nil }) + // 4. Selector targeting "init-1" should still match because of snapshot in same phase m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v1-final" return nil From 9774c7ece71d00ed25edb9bfaaeb088b5e8c7df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:10:09 +0000 Subject: [PATCH 14/18] Remove field applicators and flavors from ReplicaSet primitive for SSA migration The framework now uses Server-Side Apply instead of ctrl.CreateOrUpdate, eliminating the need for field applicators and flavors. This aligns the ReplicaSet primitive with the refactored generic builder API. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/replicaset.md | 11 - examples/replicaset-primitive/README.md | 3 - .../replicaset-primitive/features/flavors.go | 16 -- .../resources/replicaset.go | 8 +- pkg/primitives/replicaset/builder.go | 35 --- pkg/primitives/replicaset/builder_test.go | 38 --- pkg/primitives/replicaset/flavors.go | 44 --- pkg/primitives/replicaset/flavors_test.go | 146 ---------- pkg/primitives/replicaset/resource.go | 25 +- pkg/primitives/replicaset/resource_test.go | 252 ++---------------- 10 files changed, 22 insertions(+), 556 deletions(-) delete mode 100644 examples/replicaset-primitive/features/flavors.go delete mode 100644 pkg/primitives/replicaset/flavors.go delete mode 100644 pkg/primitives/replicaset/flavors_test.go diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md index 87c19e56..d990844b 100644 --- a/docs/primitives/replicaset.md +++ b/docs/primitives/replicaset.md @@ -14,7 +14,6 @@ that own ReplicaSets explicitly (e.g. custom rollout controllers). | **Health tracking** | Verifies `ObservedGeneration` matches `Generation` before evaluating `ReadyReplicas`; reports `Healthy`, `Creating`, `Updating`, or `Scaling` | | **Suspension** | Scales to zero replicas; reports `Suspending` / `Suspended` | | **Mutation pipeline** | Typed editors for metadata, replicaset spec, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations, pod template metadata) | ## Building a ReplicaSet Primitive @@ -35,20 +34,10 @@ base := &appsv1.ReplicaSet{ } resource, err := replicaset.NewBuilder(base). - WithFieldApplicationFlavor(replicaset.PreserveCurrentLabels). WithMutation(MyFeatureMutation(owner.Spec.Version)). Build() ``` -## Immutable Selector - -A ReplicaSet's `spec.selector` is immutable after creation in Kubernetes. The `DefaultFieldApplicator` preserves the -selector from the live object when updating an existing ReplicaSet. Set the selector via the desired object passed to -`NewBuilder` — it is applied on creation and preserved on subsequent updates. - -If you supply a custom field applicator via `WithCustomFieldApplicator`, you are responsible for preserving the selector -yourself. - ## Mutations Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function diff --git a/examples/replicaset-primitive/README.md b/examples/replicaset-primitive/README.md index f93ce23b..3ccbbce1 100644 --- a/examples/replicaset-primitive/README.md +++ b/examples/replicaset-primitive/README.md @@ -6,8 +6,6 @@ to manage a Kubernetes ReplicaSet as a component of a larger application, utiliz - **Base Construction**: Initializing a ReplicaSet with basic metadata and spec. - **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). - **Data Extraction**: Harvesting information from the reconciled resource. ## Directory Structure @@ -15,7 +13,6 @@ to manage a Kubernetes ReplicaSet as a component of a larger application, utiliz - `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. - `resources/`: Contains the central `NewReplicaSetResource` factory that assembles all features using the `replicaset.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. diff --git a/examples/replicaset-primitive/features/flavors.go b/examples/replicaset-primitive/features/flavors.go deleted file mode 100644 index bda6995c..00000000 --- a/examples/replicaset-primitive/features/flavors.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package features provides sample features for the replicaset primitive. -package features - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/primitives/replicaset" -) - -// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. -func PreserveLabelsFlavor() replicaset.FieldApplicationFlavor { - return replicaset.PreserveCurrentLabels -} - -// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. -func PreserveAnnotationsFlavor() replicaset.FieldApplicationFlavor { - return replicaset.PreserveCurrentAnnotations -} diff --git a/examples/replicaset-primitive/resources/replicaset.go b/examples/replicaset-primitive/resources/replicaset.go index bfdc4b84..29b9febe 100644 --- a/examples/replicaset-primitive/resources/replicaset.go +++ b/examples/replicaset-primitive/resources/replicaset.go @@ -57,11 +57,7 @@ func NewReplicaSetResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) builder.WithMutation(features.MetricsFeature(owner.Spec.EnableMetrics, 9090)) - // 4. Configure flavors (e.g., preserve labels/annotations if they were modified externally). - builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) - builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) - - // 5. Data extraction (optional). + // 4. Data extraction (optional). builder.WithDataExtractor(func(rs appsv1.ReplicaSet) error { fmt.Printf("Reconciling replicaset: %s, ready replicas: %d\n", rs.Name, rs.Status.ReadyReplicas) @@ -75,6 +71,6 @@ func NewReplicaSetResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } diff --git a/pkg/primitives/replicaset/builder.go b/pkg/primitives/replicaset/builder.go index ac97880c..49311bf1 100644 --- a/pkg/primitives/replicaset/builder.go +++ b/pkg/primitives/replicaset/builder.go @@ -35,7 +35,6 @@ func NewBuilder(replicaset *appsv1.ReplicaSet) *Builder { base := generic.NewWorkloadBuilder[*appsv1.ReplicaSet, *Mutator]( replicaset, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -65,40 +64,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing ReplicaSet 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 -// the immutable spec.selector from the live object. -// -// The applicator function receives both the 'current' object from the API -// server and the 'desired' object from the Resource. It is responsible for -// merging the desired changes into the current object. -// -// If a custom applicator is set, it overrides the default baseline application -// logic. Post-application flavors and mutations are still applied afterward. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current *appsv1.ReplicaSet, desired *appsv1.ReplicaSet) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a reusable post-application "flavor" for -// the ReplicaSet. -// -// 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.ReplicaSet](flavor)) - return b -} - // WithCustomConvergeStatus overrides the default logic for determining if the // ReplicaSet has reached its desired state. // diff --git a/pkg/primitives/replicaset/builder_test.go b/pkg/primitives/replicaset/builder_test.go index ca98d99e..8c4c4c3e 100644 --- a/pkg/primitives/replicaset/builder_test.go +++ b/pkg/primitives/replicaset/builder_test.go @@ -92,44 +92,6 @@ func TestBuilder(t *testing.T) { assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) }) - t.Run("WithCustomFieldApplicator", func(t *testing.T) { - t.Parallel() - rs := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-rs", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_ *appsv1.ReplicaSet, _ *appsv1.ReplicaSet) error { - applied = true - return nil - } - res, err := NewBuilder(rs). - 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() - rs := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-rs", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(rs). - 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() rs := &appsv1.ReplicaSet{ diff --git a/pkg/primitives/replicaset/flavors.go b/pkg/primitives/replicaset/flavors.go deleted file mode 100644 index 1790b275..00000000 --- a/pkg/primitives/replicaset/flavors.go +++ /dev/null @@ -1,44 +0,0 @@ -package replicaset - -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.ReplicaSet] - -// PreserveCurrentLabels ensures that any labels present on the current live -// ReplicaSet 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.ReplicaSet) error { - return flavors.PreserveCurrentLabels[*appsv1.ReplicaSet]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live ReplicaSet 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.ReplicaSet) error { - return flavors.PreserveCurrentAnnotations[*appsv1.ReplicaSet]()(applied, current, desired) -} - -// PreserveCurrentPodTemplateLabels ensures that any labels present on the -// current live ReplicaSet'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.ReplicaSet) 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 ReplicaSet'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.ReplicaSet) error { - applied.Spec.Template.Annotations = utils.PreserveMap(applied.Spec.Template.Annotations, current.Spec.Template.Annotations) - return nil -} diff --git a/pkg/primitives/replicaset/flavors_test.go b/pkg/primitives/replicaset/flavors_test.go deleted file mode 100644 index 6e615d40..00000000 --- a/pkg/primitives/replicaset/flavors_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package replicaset - -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.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-rs", - Namespace: "test-ns", - Labels: map[string]string{"app": "desired"}, - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptrInt32(3), - }, - } - - t.Run("flavors run after baseline applicator", func(t *testing.T) { - current := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-rs", - 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.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-rs", - Namespace: "test-ns", - }, - } - - var order []string - flavor1 := func(_, _, _ *appsv1.ReplicaSet) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *appsv1.ReplicaSet) 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.ReplicaSet{} - flavorErr := errors.New("boom") - flavor := func(_, _, _ *appsv1.ReplicaSet) 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.ReplicaSet{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} - current := &appsv1.ReplicaSet{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.ReplicaSet{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &appsv1.ReplicaSet{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.ReplicaSet{Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}}}} - current := &appsv1.ReplicaSet{Spec: appsv1.ReplicaSetSpec{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.ReplicaSet{Spec: appsv1.ReplicaSetSpec{Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}}}} - current := &appsv1.ReplicaSet{Spec: appsv1.ReplicaSetSpec{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.ReplicaSet{} - current := &appsv1.ReplicaSet{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/replicaset/resource.go b/pkg/primitives/replicaset/resource.go index fe1ddd77..10f85d33 100644 --- a/pkg/primitives/replicaset/resource.go +++ b/pkg/primitives/replicaset/resource.go @@ -7,25 +7,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// 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. -// -// In Kubernetes, a ReplicaSet's spec.selector is immutable after creation. This -// applicator also preserves the current selector when updating an existing object -// (indicated by a non-empty ResourceVersion). -func DefaultFieldApplicator(current, desired *appsv1.ReplicaSet) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - - if original.ResourceVersion != "" { - current.Spec.Selector = original.Spec.Selector - } - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes ReplicaSet within a controller's // reconciliation loop. // @@ -66,11 +47,9 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes ReplicaSet 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 field applicator if one is configured. -// 2. Feature Mutations: All registered feature-based mutations are applied, +// 1. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the ReplicaSet. -// 3. Suspension: If the resource is in a suspending state, the suspension +// 2. 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 diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go index 76ddc96d..f67d1dbe 100644 --- a/pkg/primitives/replicaset/resource_test.go +++ b/pkg/primitives/replicaset/resource_test.go @@ -1,7 +1,6 @@ package replicaset import ( - "errors" "testing" "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" @@ -87,13 +86,14 @@ func TestResource_Mutate(t *testing.T) { }). Build() - current := &appsv1.ReplicaSet{} - err := res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*appsv1.ReplicaSet) - 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) + assert.Equal(t, int32(3), *got.Spec.Replicas) + assert.Equal(t, "test", got.Labels["app"]) + assert.Equal(t, "BAR", got.Spec.Template.Spec.Containers[0].Env[0].Value) } func TestResource_Mutate_FeatureOrdering(t *testing.T) { @@ -141,181 +141,12 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { }). Build() - current := &appsv1.ReplicaSet{} - err := res.Mutate(current) - require.NoError(t, err) - - assert.Equal(t, "v3", current.Spec.Template.Spec.Containers[0].Image) -} - -func TestDefaultFieldApplicator(t *testing.T) { - t.Run("creation applies all fields from desired", func(t *testing.T) { - current := &appsv1.ReplicaSet{} - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Labels: map[string]string{"app": "test"}, - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(3)), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "test"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - assert.Equal(t, "test", current.Name) - assert.Equal(t, int32(3), *current.Spec.Replicas) - assert.Equal(t, "test", current.Spec.Selector.MatchLabels["app"]) - }) - - t.Run("update preserves immutable selector from current", func(t *testing.T) { - currentSelector := &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "original"}, - } - current := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - ResourceVersion: "12345", - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(1)), - Selector: currentSelector, - }, - } - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(5)), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "changed"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - assert.Equal(t, int32(5), *current.Spec.Replicas, "mutable fields should be updated") - assert.Equal(t, "original", current.Spec.Selector.MatchLabels["app"], - "immutable selector should be preserved from current on update") - }) - - t.Run("update preserves ResourceVersion", func(t *testing.T) { - current := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - ResourceVersion: "99999", - }, - } - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(2)), - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - assert.Equal(t, "99999", current.ResourceVersion, - "ResourceVersion should be preserved from current on update") - }) - - t.Run("desired is not mutated", func(t *testing.T) { - current := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ResourceVersion: "1"}, - Spec: appsv1.ReplicaSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "current"}, - }, - }, - } - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: appsv1.ReplicaSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "desired"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - assert.Equal(t, "desired", desired.Spec.Selector.MatchLabels["app"], - "desired should not be mutated by the applicator") - }) -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &appsv1.ReplicaSet{ - 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"}, - }, - Spec: appsv1.ReplicaSetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "original"}, - }, - }, - } - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Labels: map[string]string{"app": "test"}, - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(3)), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"app": "changed"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*appsv1.ReplicaSet) - // 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) - - // Immutable selector is preserved - assert.Equal(t, "original", current.Spec.Selector.MatchLabels["app"], - "immutable selector should be preserved from current on update") + assert.Equal(t, "v3", got.Spec.Template.Spec.Containers[0].Image) } type mockHandlers struct { @@ -447,11 +278,12 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := rs.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*appsv1.ReplicaSet) - assert.Equal(t, int32(0), *current.Spec.Replicas) + assert.Equal(t, int32(0), *got.Spec.Replicas) }) t.Run("Suspend uses custom mutation handler", func(t *testing.T) { @@ -468,12 +300,13 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := rs.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) + got := obj.(*appsv1.ReplicaSet) m.AssertExpectations(t) - assert.Equal(t, int32(1), *current.Spec.Replicas) + assert.Equal(t, int32(1), *got.Spec.Replicas) }) } @@ -535,52 +368,3 @@ func TestResource_ExtractData(t *testing.T) { assert.Equal(t, "nginx:latest", extractedImage) } -func TestResource_CustomFieldApplicator(t *testing.T) { - desired := &appsv1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Labels: map[string]string{"app": "test"}, - }, - Spec: appsv1.ReplicaSetSpec{ - Replicas: ptr.To(int32(3)), - }, - } - - applicatorCalled := false - res, _ := NewBuilder(desired). - WithCustomFieldApplicator(func(current *appsv1.ReplicaSet, desired *appsv1.ReplicaSet) 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.ReplicaSet{ - 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.ReplicaSet, _ *appsv1.ReplicaSet) error { - return errors.New("applicator error") - }). - Build() - - err := res.Mutate(&appsv1.ReplicaSet{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") - }) -} From 84003e57d61d8e720158e0f8d21fc8bccd41bddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:46:33 +0000 Subject: [PATCH 15/18] Fix lint errors: gofmt formatting and missing package comment Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/replicaset-primitive/features/mutations.go | 1 + pkg/primitives/replicaset/resource_test.go | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/replicaset-primitive/features/mutations.go b/examples/replicaset-primitive/features/mutations.go index 4237913c..784394c3 100644 --- a/examples/replicaset-primitive/features/mutations.go +++ b/examples/replicaset-primitive/features/mutations.go @@ -1,3 +1,4 @@ +// Package features provides feature plan mutations for the replicaset-primitive example. package features import ( diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go index f67d1dbe..c63d5c0b 100644 --- a/pkg/primitives/replicaset/resource_test.go +++ b/pkg/primitives/replicaset/resource_test.go @@ -367,4 +367,3 @@ func TestResource_ExtractData(t *testing.T) { require.NoError(t, err) assert.Equal(t, "nginx:latest", extractedImage) } - From f52bb3eff22d72d45611d0aec5766c85798bdd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:03:12 +0000 Subject: [PATCH 16/18] Address Copilot review: docs discoverability, accuracy, and Makefile coverage - Add replicaset entry to primitives index (docs/primitives.md) - Add ReplicaSetSpecEditor to shared editors table - Fix container paths in ordering table to use spec.template.spec prefix - Clarify Feature field docs to match deployment primitive wording - Add replicaset-primitive to Makefile run-examples target Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 1 + docs/primitives.md | 10 ++++++---- docs/primitives/replicaset.md | 9 +++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 67dde702..328fa73b 100644 --- a/Makefile +++ b/Makefile @@ -122,6 +122,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/replicaset-primitive/. go run ./examples/custom-resource-implementation/. ##@ E2E Testing diff --git a/docs/primitives.md b/docs/primitives.md index 985288f1..c2336ea4 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -118,8 +118,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 | +| `ReplicaSetSpecEditor` | Replicas, min ready seconds | +| `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 @@ -143,8 +144,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/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | ## Usage Examples diff --git a/docs/primitives/replicaset.md b/docs/primitives/replicaset.md index d990844b..61362cdb 100644 --- a/docs/primitives/replicaset.md +++ b/docs/primitives/replicaset.md @@ -43,13 +43,14 @@ resource, err := replicaset.NewBuilder(base). Mutations are the primary mechanism for modifying a `ReplicaSet` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally: +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) replicaset.Mutation { return replicaset.Mutation{ Name: "my-feature", - Feature: feature.NewResourceFeature(version, nil), + Feature: feature.NewResourceFeature(version, nil), // always enabled Mutate: func(m *replicaset.Mutator) error { // record edits here return nil @@ -91,9 +92,9 @@ order they are recorded: | 2 | ReplicaSetSpec edits | Replicas, min ready seconds | | 3 | Pod template metadata edits | Labels and annotations on the pod template | | 4 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | -| 5 | Regular container presence | Adding or removing containers from `spec.containers` | +| 5 | Regular container presence | Adding or removing containers from `spec.template.spec.containers` | | 6 | Regular container edits | Env vars, args, resources (snapshot taken after step 5) | -| 7 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 7 | Init container presence | Adding or removing containers from `spec.template.spec.initContainers` | | 8 | Init container edits | Env vars, args, resources (snapshot taken after step 7) | Container edits (steps 6 and 8) are evaluated against a snapshot taken _after_ presence operations in the same mutation. From 915b110f6440e9158f7c5e684a77590185bbb6d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:11:40 +0000 Subject: [PATCH 17/18] fix readme --- docs/primitives.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index c2336ea4..ae08f9c0 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -118,9 +118,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 | -| `ReplicaSetSpecEditor` | Replicas, min ready seconds | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | +| `ReplicaSetSpecEditor` | Replicas, min ready seconds | +| `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 @@ -144,9 +144,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/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/replicaset` | Workload | [replicaset.md](primitives/replicaset.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | ## Usage Examples From e7bfc2de9cf92ef1989e3ab0e202799259d2e1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:15:37 +0000 Subject: [PATCH 18/18] Check Build() errors in resource tests to prevent nil-resource panics Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/replicaset/resource_test.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/pkg/primitives/replicaset/resource_test.go b/pkg/primitives/replicaset/resource_test.go index c63d5c0b..b6750dee 100644 --- a/pkg/primitives/replicaset/resource_test.go +++ b/pkg/primitives/replicaset/resource_test.go @@ -23,7 +23,8 @@ func TestResource_Identity(t *testing.T) { Namespace: "test-ns", }, } - res, _ := NewBuilder(rs).Build() + res, err := NewBuilder(rs).Build() + require.NoError(t, err) assert.Equal(t, "apps/v1/ReplicaSet/test-ns/test-rs", res.Identity()) } @@ -35,7 +36,8 @@ func TestResource_Object(t *testing.T) { Namespace: "test-ns", }, } - res, _ := NewBuilder(rs).Build() + res, err := NewBuilder(rs).Build() + require.NoError(t, err) obj, err := res.Object() require.NoError(t, err) @@ -75,7 +77,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), @@ -85,6 +87,7 @@ func TestResource_Mutate(t *testing.T) { }, }). Build() + require.NoError(t, err) obj, err := res.Object() require.NoError(t, err) @@ -113,7 +116,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), @@ -140,6 +143,7 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { }, }). Build() + require.NoError(t, err) obj, err := res.Object() require.NoError(t, err) @@ -198,9 +202,10 @@ func TestResource_Status(t *testing.T) { statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, rs).Return(statusReady, nil) - res, _ := NewBuilder(rs). + res, err := NewBuilder(rs). WithCustomConvergeStatus(m.ConvergingStatus). Build() + require.NoError(t, err) status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) require.NoError(t, err) @@ -221,9 +226,10 @@ func TestResource_Status(t *testing.T) { statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} m.On("GraceStatus", rs).Return(statusReady, nil) - res, _ := NewBuilder(rs). + res, err := NewBuilder(rs). WithCustomGraceStatus(m.GraceStatus). Build() + require.NoError(t, err) status, err := res.GraceStatus() require.NoError(t, err)