From a9e2eb49f7890e5c31332ed138d881d6ff867962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:36:54 +0000 Subject: [PATCH 01/20] Add SecretDataEditor for typed Secret .data/.stringData mutations Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/secretdata.go | 88 ++++++++++++++++ pkg/mutation/editors/secretdata_test.go | 127 ++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 pkg/mutation/editors/secretdata.go create mode 100644 pkg/mutation/editors/secretdata_test.go diff --git a/pkg/mutation/editors/secretdata.go b/pkg/mutation/editors/secretdata.go new file mode 100644 index 00000000..6577a16c --- /dev/null +++ b/pkg/mutation/editors/secretdata.go @@ -0,0 +1,88 @@ +package editors + +// SecretDataEditor provides a typed API for mutating the .data and .stringData +// fields of a Kubernetes Secret. +// +// It exposes structured operations (Set, Remove, SetString, RemoveString) as +// well as Raw() and RawStringData() for free-form access when none of the +// structured methods are sufficient. +// +// Note on Kubernetes semantics: the API server merges .stringData into .data +// on write and returns only .data on read. During reconciliation it is safe to +// use either field; the two coexist on the desired object until it is applied. +type SecretDataEditor struct { + data *map[string][]byte + stringData *map[string]string +} + +// NewSecretDataEditor creates a new SecretDataEditor wrapping the given .data +// and .stringData map pointers. +// +// Either pointer may be nil, in which case the editor allocates a local +// zero-value map for that field. Operations on that field will succeed but +// writes will not propagate back to any external map. Pass non-nil pointers +// (e.g. &secret.Data, &secret.StringData) when the changes must be reflected +// on the object. The maps the pointers refer to may themselves be nil; methods +// that write to a map initialise it automatically. +func NewSecretDataEditor(data *map[string][]byte, stringData *map[string]string) *SecretDataEditor { + if data == nil { + var d map[string][]byte + data = &d + } + if stringData == nil { + var sd map[string]string + stringData = &sd + } + return &SecretDataEditor{data: data, stringData: stringData} +} + +// Raw returns the underlying .data map directly, initialising it if necessary. +// +// This is an escape hatch for free-form editing when none of the structured +// methods are sufficient. +func (e *SecretDataEditor) Raw() map[string][]byte { + if *e.data == nil { + *e.data = make(map[string][]byte) + } + return *e.data +} + +// RawStringData returns the underlying .stringData map directly, initialising +// it if necessary. +// +// This is an escape hatch for free-form editing. +func (e *SecretDataEditor) RawStringData() map[string]string { + if *e.stringData == nil { + *e.stringData = make(map[string]string) + } + return *e.stringData +} + +// Set sets key to value in .data, initialising the map if necessary. +func (e *SecretDataEditor) Set(key string, value []byte) { + if *e.data == nil { + *e.data = make(map[string][]byte) + } + (*e.data)[key] = value +} + +// Remove deletes key from .data. It is a no-op if the key does not exist. +func (e *SecretDataEditor) Remove(key string) { + delete(*e.data, key) +} + +// SetString sets key to value in .stringData, initialising the map if necessary. +// +// The API server will merge .stringData into .data on write; use this when +// working with plaintext values that should not be pre-encoded. +func (e *SecretDataEditor) SetString(key, value string) { + if *e.stringData == nil { + *e.stringData = make(map[string]string) + } + (*e.stringData)[key] = value +} + +// RemoveString deletes key from .stringData. It is a no-op if the key does not exist. +func (e *SecretDataEditor) RemoveString(key string) { + delete(*e.stringData, key) +} diff --git a/pkg/mutation/editors/secretdata_test.go b/pkg/mutation/editors/secretdata_test.go new file mode 100644 index 00000000..d37f3595 --- /dev/null +++ b/pkg/mutation/editors/secretdata_test.go @@ -0,0 +1,127 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- NewSecretDataEditor --- + +func TestNewSecretDataEditor_NilPointers(t *testing.T) { + // Nil pointers must not panic; operations succeed but writes are local only. + e := NewSecretDataEditor(nil, nil) + require.NotNil(t, e) + e.Set("k", []byte("v")) + e.SetString("k", "v") +} + +// --- Set and Remove (.data) --- + +func TestSecretDataEditor_Set(t *testing.T) { + data := map[string][]byte{} + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("value")) + assert.Equal(t, []byte("value"), data["key"]) +} + +func TestSecretDataEditor_Set_InitialisesNilMap(t *testing.T) { + var data map[string][]byte + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("value")) + require.NotNil(t, data) + assert.Equal(t, []byte("value"), data["key"]) +} + +func TestSecretDataEditor_Set_Overwrites(t *testing.T) { + data := map[string][]byte{"key": []byte("old")} + e := NewSecretDataEditor(&data, nil) + e.Set("key", []byte("new")) + assert.Equal(t, []byte("new"), data["key"]) +} + +func TestSecretDataEditor_Remove(t *testing.T) { + data := map[string][]byte{"key": []byte("v"), "other": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + e.Remove("key") + assert.NotContains(t, data, "key") + assert.Equal(t, []byte("keep"), data["other"]) +} + +func TestSecretDataEditor_Remove_MissingKey(t *testing.T) { + data := map[string][]byte{"other": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + e.Remove("missing") + assert.Equal(t, []byte("keep"), data["other"]) +} + +// --- SetString and RemoveString (.stringData) --- + +func TestSecretDataEditor_SetString(t *testing.T) { + sd := map[string]string{} + e := NewSecretDataEditor(nil, &sd) + e.SetString("key", "value") + assert.Equal(t, "value", sd["key"]) +} + +func TestSecretDataEditor_SetString_InitialisesNilMap(t *testing.T) { + var sd map[string]string + e := NewSecretDataEditor(nil, &sd) + e.SetString("key", "value") + require.NotNil(t, sd) + assert.Equal(t, "value", sd["key"]) +} + +func TestSecretDataEditor_RemoveString(t *testing.T) { + sd := map[string]string{"key": "v", "other": "keep"} + e := NewSecretDataEditor(nil, &sd) + e.RemoveString("key") + assert.NotContains(t, sd, "key") + assert.Equal(t, "keep", sd["other"]) +} + +func TestSecretDataEditor_RemoveString_MissingKey(t *testing.T) { + sd := map[string]string{"other": "keep"} + e := NewSecretDataEditor(nil, &sd) + e.RemoveString("missing") + assert.Equal(t, "keep", sd["other"]) +} + +// --- Raw and RawStringData --- + +func TestSecretDataEditor_Raw(t *testing.T) { + data := map[string][]byte{"existing": []byte("keep")} + e := NewSecretDataEditor(&data, nil) + raw := e.Raw() + raw["new"] = []byte("added") + assert.Equal(t, []byte("keep"), data["existing"]) + assert.Equal(t, []byte("added"), data["new"]) +} + +func TestSecretDataEditor_Raw_InitialisesNilMap(t *testing.T) { + var data map[string][]byte + e := NewSecretDataEditor(&data, nil) + raw := e.Raw() + require.NotNil(t, raw) + raw["k"] = []byte("v") + assert.Equal(t, []byte("v"), data["k"]) +} + +func TestSecretDataEditor_RawStringData(t *testing.T) { + sd := map[string]string{"existing": "keep"} + e := NewSecretDataEditor(nil, &sd) + raw := e.RawStringData() + raw["new"] = "added" + assert.Equal(t, "keep", sd["existing"]) + assert.Equal(t, "added", sd["new"]) +} + +func TestSecretDataEditor_RawStringData_InitialisesNilMap(t *testing.T) { + var sd map[string]string + e := NewSecretDataEditor(nil, &sd) + raw := e.RawStringData() + require.NotNil(t, raw) + raw["k"] = "v" + assert.Equal(t, "v", sd["k"]) +} From 2fd4a0ccf67b57846722ed02a8d7b1cd93da3abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:36:59 +0000 Subject: [PATCH 02/20] Add Secret primitive package with builder, resource, mutator, flavors, and hash Implements the Secret primitive following the same pattern as the ConfigMap primitive. Includes full test coverage for builder validation, mutator operations, field application flavors, and data hashing. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/builder.go | 110 +++++++++++++ pkg/primitives/secret/builder_test.go | 154 ++++++++++++++++++ pkg/primitives/secret/flavors.go | 44 ++++++ pkg/primitives/secret/flavors_test.go | 154 ++++++++++++++++++ pkg/primitives/secret/hash.go | 76 +++++++++ pkg/primitives/secret/hash_test.go | 211 +++++++++++++++++++++++++ pkg/primitives/secret/mutator.go | 153 ++++++++++++++++++ pkg/primitives/secret/mutator_test.go | 158 ++++++++++++++++++ pkg/primitives/secret/resource.go | 66 ++++++++ pkg/primitives/secret/resource_test.go | 176 +++++++++++++++++++++ 10 files changed, 1302 insertions(+) create mode 100644 pkg/primitives/secret/builder.go create mode 100644 pkg/primitives/secret/builder_test.go create mode 100644 pkg/primitives/secret/flavors.go create mode 100644 pkg/primitives/secret/flavors_test.go create mode 100644 pkg/primitives/secret/hash.go create mode 100644 pkg/primitives/secret/hash_test.go create mode 100644 pkg/primitives/secret/mutator.go create mode 100644 pkg/primitives/secret/mutator_test.go create mode 100644 pkg/primitives/secret/resource.go create mode 100644 pkg/primitives/secret/resource_test.go diff --git a/pkg/primitives/secret/builder.go b/pkg/primitives/secret/builder.go new file mode 100644 index 00000000..80b9e51d --- /dev/null +++ b/pkg/primitives/secret/builder.go @@ -0,0 +1,110 @@ +package secret + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing a Secret Resource. +// +// It provides a fluent API for registering mutations, field application flavors, +// and data extractors. Build() validates the configuration and returns an +// initialized Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.StaticBuilder[*corev1.Secret, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Secret object. +// +// The Secret object serves as the desired base state. During reconciliation +// the Resource will make the cluster's state match this base, modified by any +// registered mutations and flavors. +// +// The provided Secret must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(s *corev1.Secret) *Builder { + identityFunc := func(secret *corev1.Secret) string { + return fmt.Sprintf("v1/Secret/%s/%s", secret.Namespace, secret.Name) + } + + return &Builder{ + base: generic.NewStaticBuilder[*corev1.Secret, *Mutator]( + s, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ), + } +} + +// WithMutation registers a mutation for the Secret. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation, +// after the baseline field applicator and any registered flavors have run. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +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 Secret in the cluster. +// +// The default applicator (DefaultFieldApplicator) replaces the current object +// with a deep copy of the desired object. Use a custom applicator when other +// controllers manage fields you need to preserve. +// +// The applicator receives the current object from the API server and the desired +// object from the Resource, and is responsible for merging the desired changes +// into the current object. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current, desired *corev1.Secret) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a post-baseline field application flavor. +// +// Flavors run after the baseline applicator (default or custom) in registration +// order. They are typically used to preserve fields from the live cluster object +// that should not be overwritten by the desired state. +// +// A nil flavor is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*corev1.Secret](flavor)) + return b +} + +// WithDataExtractor registers a function to read values from the Secret after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled Secret. This is useful +// for surfacing generated or updated entries to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(corev1.Secret) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(s *corev1.Secret) error { + return extractor(*s) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Secret object was provided. +// - The Secret is missing a Name or Namespace. +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/secret/builder_test.go b/pkg/primitives/secret/builder_test.go new file mode 100644 index 00000000..6f58a1d6 --- /dev/null +++ b/pkg/primitives/secret/builder_test.go @@ -0,0 +1,154 @@ +package secret + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder_Build_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + secret *corev1.Secret + expectedErr string + }{ + { + name: "nil secret", + secret: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret"}, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid secret", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.secret).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, "v1/Secret/test-ns/test-secret", res.Identity()) + } + }) + } +} + +func TestBuilder_WithMutation(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithMutation(Mutation{Name: "test-mutation"}). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) +} + +func TestBuilder_WithCustomFieldApplicator(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + called := false + applicator := func(_, _ *corev1.Secret) error { + called = true + return nil + } + res, err := NewBuilder(s). + WithCustomFieldApplicator(applicator). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.CustomFieldApplicator) + _ = res.base.CustomFieldApplicator(nil, nil) + assert.True(t, called) +} + +func TestBuilder_WithFieldApplicationFlavor(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithFieldApplicationFlavor(PreserveExternalEntries). + WithFieldApplicationFlavor(nil). // nil must be ignored + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) +} + +func TestBuilder_WithDataExtractor(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + called := false + extractor := func(_ corev1.Secret) error { + called = true + return nil + } + res, err := NewBuilder(s). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + require.NoError(t, res.base.DataExtractors[0](&corev1.Secret{})) + assert.True(t, called) +} + +func TestBuilder_WithDataExtractor_Nil(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) +} + +func TestBuilder_WithDataExtractor_ErrorPropagated(t *testing.T) { + t.Parallel() + s := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, + } + res, err := NewBuilder(s). + WithDataExtractor(func(_ corev1.Secret) error { + return errors.New("extractor error") + }). + Build() + require.NoError(t, err) + err = res.base.DataExtractors[0](&corev1.Secret{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "extractor error") +} diff --git a/pkg/primitives/secret/flavors.go b/pkg/primitives/secret/flavors.go new file mode 100644 index 00000000..97cc91fa --- /dev/null +++ b/pkg/primitives/secret/flavors.go @@ -0,0 +1,44 @@ +package secret + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + corev1 "k8s.io/api/core/v1" +) + +// FieldApplicationFlavor defines a function signature for applying flavors to a +// Secret resource. A flavor is called after the baseline field applicator has +// run and can be used to preserve or merge fields from the live cluster object. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*corev1.Secret] + +// PreserveCurrentLabels ensures that any labels present on the current live +// Secret but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *corev1.Secret) error { + return flavors.PreserveCurrentLabels[*corev1.Secret]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live Secret but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *corev1.Secret) error { + return flavors.PreserveCurrentAnnotations[*corev1.Secret]()(applied, current, desired) +} + +// PreserveExternalEntries ensures that any .data keys present on the current live +// Secret but absent from the applied (desired) object are preserved on the +// applied object. +// +// This is useful when other controllers or admission webhooks inject entries into +// the Secret that your operator does not own. Keys present in both are left +// as-is on the applied object (the desired value wins). +func PreserveExternalEntries(applied, current, _ *corev1.Secret) error { + for k, v := range current.Data { + if _, exists := applied.Data[k]; !exists { + if applied.Data == nil { + applied.Data = make(map[string][]byte) + } + applied.Data[k] = v + } + } + return nil +} diff --git a/pkg/primitives/secret/flavors_test.go b/pkg/primitives/secret/flavors_test.go new file mode 100644 index 00000000..3aa89ae7 --- /dev/null +++ b/pkg/primitives/secret/flavors_test.go @@ -0,0 +1,154 @@ +package secret + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPreserveCurrentLabels(t *testing.T) { + t.Run("adds missing labels from current", func(t *testing.T) { + applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}} + current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "applied", applied.Labels["keep"]) + assert.Equal(t, "current", applied.Labels["extra"]) + }) + + t.Run("applied value wins on overlap", func(t *testing.T) { + applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "applied"}}} + current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "applied", applied.Labels["key"]) + }) + + t.Run("handles nil applied labels", func(t *testing.T) { + applied := &corev1.Secret{} + current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentLabels(applied, current, nil)) + assert.Equal(t, "current", applied.Labels["extra"]) + }) +} + +func TestPreserveCurrentAnnotations(t *testing.T) { + t.Run("adds missing annotations from current", func(t *testing.T) { + applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} + + require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) + assert.Equal(t, "applied", applied.Annotations["keep"]) + assert.Equal(t, "current", applied.Annotations["extra"]) + }) + + t.Run("applied value wins on overlap", func(t *testing.T) { + applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "applied"}}} + current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "current"}}} + + require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) + assert.Equal(t, "applied", applied.Annotations["key"]) + }) +} + +func TestPreserveExternalEntries(t *testing.T) { + t.Run("adds missing data keys from current", func(t *testing.T) { + applied := &corev1.Secret{Data: map[string][]byte{"owned": []byte("applied")}} + current := &corev1.Secret{Data: map[string][]byte{"external": []byte("current")}} + + require.NoError(t, PreserveExternalEntries(applied, current, nil)) + assert.Equal(t, []byte("applied"), applied.Data["owned"]) + assert.Equal(t, []byte("current"), applied.Data["external"]) + }) + + t.Run("applied value wins on overlap", func(t *testing.T) { + applied := &corev1.Secret{Data: map[string][]byte{"key": []byte("applied")}} + current := &corev1.Secret{Data: map[string][]byte{"key": []byte("current")}} + + require.NoError(t, PreserveExternalEntries(applied, current, nil)) + assert.Equal(t, []byte("applied"), applied.Data["key"]) + }) + + t.Run("handles nil applied data", func(t *testing.T) { + applied := &corev1.Secret{} + current := &corev1.Secret{Data: map[string][]byte{"external": []byte("current")}} + + require.NoError(t, PreserveExternalEntries(applied, current, nil)) + assert.Equal(t, []byte("current"), applied.Data["external"]) + }) + + t.Run("no-op when current has no data", func(t *testing.T) { + applied := &corev1.Secret{Data: map[string][]byte{"owned": []byte("applied")}} + current := &corev1.Secret{} + + require.NoError(t, PreserveExternalEntries(applied, current, nil)) + assert.Len(t, applied.Data, 1) + assert.Equal(t, []byte("applied"), applied.Data["owned"]) + }) +} + +func TestFlavors_Integration(t *testing.T) { + desired := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + Labels: map[string]string{"app": "desired"}, + }, + Data: map[string][]byte{"owned": []byte("yes")}, + } + + t.Run("PreserveExternalEntries via Mutate", func(t *testing.T) { + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(PreserveExternalEntries). + Build() + require.NoError(t, err) + + current := &corev1.Secret{ + Data: map[string][]byte{"external": []byte("keep"), "owned": []byte("old")}, + } + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, []byte("yes"), current.Data["owned"]) + assert.Equal(t, []byte("keep"), current.Data["external"]) + }) + + t.Run("flavors run in registration order", func(t *testing.T) { + var order []string + flavor1 := func(_, _, _ *corev1.Secret) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *corev1.Secret) error { + order = append(order, "flavor2") + return nil + } + + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(flavor1). + WithFieldApplicationFlavor(flavor2). + Build() + require.NoError(t, err) + + require.NoError(t, res.Mutate(&corev1.Secret{})) + assert.Equal(t, []string{"flavor1", "flavor2"}, order) + }) + + t.Run("flavor error is returned", func(t *testing.T) { + flavorErr := errors.New("flavor boom") + res, err := NewBuilder(desired). + WithFieldApplicationFlavor(func(_, _, _ *corev1.Secret) error { + return flavorErr + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&corev1.Secret{}) + require.Error(t, err) + assert.True(t, errors.Is(err, flavorErr)) + }) +} diff --git a/pkg/primitives/secret/hash.go b/pkg/primitives/secret/hash.go new file mode 100644 index 00000000..a187063e --- /dev/null +++ b/pkg/primitives/secret/hash.go @@ -0,0 +1,76 @@ +package secret + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +// DataHash computes a stable SHA-256 hash of the .data field of the given Secret. +// +// The hash is derived from the canonical JSON encoding of .data with map keys +// sorted alphabetically, so it is deterministic regardless of insertion order. +// The returned string is the lowercase hex encoding of the 256-bit digest. +// +// Only .data is hashed. The .stringData field is write-only in the Kubernetes API +// and is absent from objects returned by the API server; it is intentionally +// excluded so that DataHash is consistent whether called on a desired object or +// a cluster-read object. +// +// A common use case is to annotate a Deployment's pod template with this hash +// so that a change in Secret content triggers a rolling restart: +// +// hash, err := secret.DataHash(s) +// if err != nil { +// return err +// } +// m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { +// e.EnsureAnnotation("checksum/secret", hash) +// return nil +// }) +func DataHash(s corev1.Secret) (string, error) { + // Normalize nil to empty so that a Secret with no .data hashes identically + // to one with an empty map — both represent "no entries". + data := s.Data + if data == nil { + data = map[string][]byte{} + } + + encoded, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("secret %s/%s: failed to marshal data for hashing: %w", + s.Namespace, s.Name, err) + } + + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + +// DesiredHash computes the SHA-256 hash of the Secret as it will be written to +// the cluster, based on the base object and all registered mutations. +// +// The hash covers only operator-controlled fields (.data after applying the +// baseline and mutations). Fields preserved by flavors from the live cluster +// state (e.g. PreserveExternalEntries) are intentionally excluded — only +// changes to operator-owned content will change the hash. +// +// This enables a single-pass checksum pattern: compute the hash before +// reconciliation and pass it directly to the deployment resource factory, +// avoiding the need for a second reconcile cycle. +// +// secretResource, err := secret.NewBuilder(base).WithMutation(...).Build() +// hash, err := secretResource.DesiredHash() +// deployResource, err := deployment.NewBuilder(base). +// WithMutation(ChecksumMutation(version, hash)). +// Build() +func (r *Resource) DesiredHash() (string, error) { + obj, err := r.base.PreviewObject() + if err != nil { + return "", fmt.Errorf("secret %s: failed to compute desired hash: %w", r.Identity(), err) + } + + return DataHash(*obj) +} diff --git a/pkg/primitives/secret/hash_test.go b/pkg/primitives/secret/hash_test.go new file mode 100644 index 00000000..49f9dedf --- /dev/null +++ b/pkg/primitives/secret/hash_test.go @@ -0,0 +1,211 @@ +package secret + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newHashTestSecret(data map[string][]byte) corev1.Secret { + return corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: data, + } +} + +// --- Determinism --- + +func TestDataHash_Deterministic(t *testing.T) { + s := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + h1, err := DataHash(s) + require.NoError(t, err) + h2, err := DataHash(s) + require.NoError(t, err) + assert.Equal(t, h1, h2) +} + +func TestDataHash_InsertionOrderIndependent(t *testing.T) { + // Maps in Go have non-deterministic iteration order; JSON encoding sorts keys. + s1 := newHashTestSecret(map[string][]byte{"a": []byte("1"), "b": []byte("2")}) + s2 := newHashTestSecret(map[string][]byte{"b": []byte("2"), "a": []byte("1")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "hash must be order-independent") +} + +// --- Sensitivity --- + +func TestDataHash_ChangesOnDataChange(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key": []byte("old")}) + s2 := newHashTestSecret(map[string][]byte{"key": []byte("new")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.NotEqual(t, h1, h2) +} + +func TestDataHash_ChangesOnKeyChange(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key-a": []byte("value")}) + s2 := newHashTestSecret(map[string][]byte{"key-b": []byte("value")}) + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.NotEqual(t, h1, h2) +} + +// --- Edge cases --- + +func TestDataHash_NilAndEmptyMapHashIdentically(t *testing.T) { + sNil := newHashTestSecret(nil) + sEmpty := newHashTestSecret(map[string][]byte{}) + + hNil, err := DataHash(sNil) + require.NoError(t, err) + hEmpty, err := DataHash(sEmpty) + require.NoError(t, err) + + assert.Equal(t, hNil, hEmpty, "nil and empty data must produce the same hash") +} + +func TestDataHash_EmptySecret(t *testing.T) { + s := newHashTestSecret(nil) + h, err := DataHash(s) + require.NoError(t, err) + assert.NotEmpty(t, h) +} + +func TestDataHash_MetadataIgnored(t *testing.T) { + s1 := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + s2 := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + s2.Labels = map[string]string{"extra": "label"} + s2.Annotations = map[string]string{"extra": "annotation"} + + h1, err := DataHash(s1) + require.NoError(t, err) + h2, err := DataHash(s2) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "metadata must not influence the hash") +} + +func TestDataHash_ReturnsHexString(t *testing.T) { + s := newHashTestSecret(map[string][]byte{"k": []byte("v")}) + h, err := DataHash(s) + require.NoError(t, err) + // SHA-256 in hex is always 64 characters. + assert.Len(t, h, 64) + for _, c := range h { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), + "expected lowercase hex character, got %c", c) + } +} + +// --- DesiredHash --- + +func newHashTestResource(t *testing.T, data map[string][]byte, mutations ...Mutation) *Resource { + t.Helper() + base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: data, + } + b := NewBuilder(base) + for _, m := range mutations { + b.WithMutation(m) + } + r, err := b.Build() + require.NoError(t, err) + return r +} + +func TestDesiredHash_Deterministic(t *testing.T) { + r := newHashTestResource(t, map[string][]byte{"key": []byte("value")}) + h1, err := r.DesiredHash() + require.NoError(t, err) + h2, err := r.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2) +} + +func TestDesiredHash_NoSideEffects(t *testing.T) { + // Calling DesiredHash must not modify the resource's internal state. + r := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { + m.SetData("key", []byte("value")) + return nil + }, + }) + + h1, err := r.DesiredHash() + require.NoError(t, err) + h2, err := r.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2, "repeated DesiredHash calls must return the same value") + + // Verify the resource still reconciles correctly after hash computation. + s := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}} + require.NoError(t, r.Mutate(s)) + require.NoError(t, r.Mutate(s)) + assert.Equal(t, []byte("value"), s.Data["key"], "mutations must not be applied more than once per reconcile") +} + +func TestDesiredHash_ChangesWhenMutationChangesContent(t *testing.T) { + r1 := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("v1")); return nil }, + }) + r2 := newHashTestResource(t, nil, Mutation{ + Name: "set-key", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("v2")); return nil }, + }) + + h1, err := r1.DesiredHash() + require.NoError(t, err) + h2, err := r2.DesiredHash() + require.NoError(t, err) + assert.NotEqual(t, h1, h2) +} + +func TestDesiredHash_DisabledMutationDoesNotAffectHash(t *testing.T) { + base := newHashTestResource(t, nil, Mutation{ + Name: "always", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("value")); return nil }, + }) + + withDisabled := newHashTestResource(t, nil, + Mutation{ + Name: "always", + Feature: feature.NewResourceFeature("1.0.0", nil), + Mutate: func(m *Mutator) error { m.SetData("key", []byte("value")); return nil }, + }, + Mutation{ + Name: "disabled", + Feature: feature.NewResourceFeature("1.0.0", nil).When(false), + Mutate: func(m *Mutator) error { m.SetData("extra", []byte("skipped")); return nil }, + }, + ) + + h1, err := base.DesiredHash() + require.NoError(t, err) + h2, err := withDisabled.DesiredHash() + require.NoError(t, err) + assert.Equal(t, h1, h2, "disabled mutation must not influence the hash") +} diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go new file mode 100644 index 00000000..f360859d --- /dev/null +++ b/pkg/primitives/secret/mutator.go @@ -0,0 +1,153 @@ +// Package secret provides a builder and resource for managing Kubernetes Secrets. +package secret + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a secret Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + dataEdits []func(*editors.SecretDataEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes Secret. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Secret in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + secret *corev1.Secret + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Secret. +func NewMutator(s *corev1.Secret) *Mutator { + m := &Mutator{secret: s} + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Secret's own metadata. +// +// Metadata edits are applied before data edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditData records a mutation for the Secret's .data and .stringData fields +// via a SecretDataEditor. +// +// The editor provides structured operations (Set, Remove, SetString, +// RemoveString) as well as Raw() and RawStringData() for free-form access. +// Data edits are applied after metadata edits within the same feature, in +// registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditData(edit func(*editors.SecretDataEditor) error) { + if edit == nil { + return + } + m.active.dataEdits = append(m.active.dataEdits, edit) +} + +// SetData records that key in .data should be set to value. +// +// Convenience wrapper over EditData. +func (m *Mutator) SetData(key string, value []byte) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.Set(key, value) + return nil + }) +} + +// RemoveData records that key should be deleted from .data. +// +// Convenience wrapper over EditData. +func (m *Mutator) RemoveData(key string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.Remove(key) + return nil + }) +} + +// SetStringData records that key in .stringData should be set to value. +// +// The API server merges .stringData into .data on write; use this when +// working with plaintext values that should not be pre-encoded. +// +// Convenience wrapper over EditData. +func (m *Mutator) SetStringData(key, value string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.SetString(key, value) + return nil + }) +} + +// RemoveStringData records that key should be deleted from .stringData. +// +// Convenience wrapper over EditData. +func (m *Mutator) RemoveStringData(key string) { + m.EditData(func(e *editors.SecretDataEditor) error { + e.RemoveString(key) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying Secret. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Data edits — EditData, SetData, RemoveData, SetStringData, RemoveStringData +// (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Secret as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.secret.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Data edits + if len(plan.dataEdits) > 0 { + editor := editors.NewSecretDataEditor(&m.secret.Data, &m.secret.StringData) + for _, edit := range plan.dataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/secret/mutator_test.go b/pkg/primitives/secret/mutator_test.go new file mode 100644 index 00000000..c79e2451 --- /dev/null +++ b/pkg/primitives/secret/mutator_test.go @@ -0,0 +1,158 @@ +package secret + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestSecret(data map[string][]byte) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: data, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", s.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +// --- EditData --- + +func TestMutator_EditData_RawAccess(t *testing.T) { + s := newTestSecret(map[string][]byte{"existing": []byte("keep")}) + m := NewMutator(s) + m.EditData(func(e *editors.SecretDataEditor) error { + raw := e.Raw() + raw["new"] = []byte("added") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("keep"), s.Data["existing"]) + assert.Equal(t, []byte("added"), s.Data["new"]) +} + +func TestMutator_EditData_Nil(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.EditData(nil) + assert.NoError(t, m.Apply()) +} + +// --- SetData --- + +func TestMutator_SetData(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.SetData("key", []byte("value")) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("value"), s.Data["key"]) +} + +func TestMutator_SetData_Overwrites(t *testing.T) { + s := newTestSecret(map[string][]byte{"key": []byte("old")}) + m := NewMutator(s) + m.SetData("key", []byte("new")) + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("new"), s.Data["key"]) +} + +// --- RemoveData --- + +func TestMutator_RemoveData(t *testing.T) { + s := newTestSecret(map[string][]byte{"key": []byte("value"), "other": []byte("keep")}) + m := NewMutator(s) + m.RemoveData("key") + require.NoError(t, m.Apply()) + assert.NotContains(t, s.Data, "key") + assert.Equal(t, []byte("keep"), s.Data["other"]) +} + +func TestMutator_RemoveData_NotPresent(t *testing.T) { + s := newTestSecret(map[string][]byte{"other": []byte("keep")}) + m := NewMutator(s) + m.RemoveData("missing") + require.NoError(t, m.Apply()) + assert.Equal(t, []byte("keep"), s.Data["other"]) +} + +// --- SetStringData --- + +func TestMutator_SetStringData(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.SetStringData("key", "value") + require.NoError(t, m.Apply()) + assert.Equal(t, "value", s.StringData["key"]) +} + +// --- RemoveStringData --- + +func TestMutator_RemoveStringData(t *testing.T) { + s := newTestSecret(nil) + s.StringData = map[string]string{"key": "value", "other": "keep"} + m := NewMutator(s) + m.RemoveStringData("key") + require.NoError(t, m.Apply()) + assert.NotContains(t, s.StringData, "key") + assert.Equal(t, "keep", s.StringData["other"]) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before data edits. + s := newTestSecret(nil) + m := NewMutator(s) + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.SetData("direct", []byte("yes")) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + require.NoError(t, m.Apply()) + + assert.Equal(t, "tested", s.Labels["order"]) + assert.Equal(t, []byte("yes"), s.Data["direct"]) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.SetData("feature1", []byte("on")) + m.beginFeature() + m.SetData("feature2", []byte("on")) + require.NoError(t, m.Apply()) + + assert.Equal(t, []byte("on"), s.Data["feature1"]) + assert.Equal(t, []byte("on"), s.Data["feature2"]) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/secret/resource.go b/pkg/primitives/secret/resource.go new file mode 100644 index 00000000..a1c52bb0 --- /dev/null +++ b/pkg/primitives/secret/resource.go @@ -0,0 +1,66 @@ +package secret + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator replaces current with a deep copy of desired. +// +// This is the default baseline field application strategy for Secret resources. +// Use a custom field applicator via Builder.WithCustomFieldApplicator if you need +// to preserve fields that other controllers manage. +func DefaultFieldApplicator(current, desired *corev1.Secret) error { + *current = *desired.DeepCopy() + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes Secret within +// a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// Secret resources are static: they do not model convergence health, grace periods, +// or suspension. Use a workload or task primitive for resources that require those concepts. +type Resource struct { + base *generic.StaticResource[*corev1.Secret, *Mutator] +} + +// Identity returns a unique identifier for the Secret in the format +// "v1/Secret//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Secret object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes Secret into the desired state. +// +// The mutation process follows this order: +// 1. Field application: the current object is updated to reflect the desired base state, +// using either DefaultFieldApplicator or a custom applicator if one is configured. +// 2. Field application flavors: any registered flavors are applied in registration order. +// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled Secret. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the Secret. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} diff --git a/pkg/primitives/secret/resource_test.go b/pkg/primitives/secret/resource_test.go new file mode 100644 index 00000000..7cbd633e --- /dev/null +++ b/pkg/primitives/secret/resource_test.go @@ -0,0 +1,176 @@ +package secret + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{"key": []byte("value")}, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidSecret()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/Secret/test-ns/test-secret", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + s := newValidSecret() + res, err := NewBuilder(s).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.Secret) + require.True(t, ok) + assert.Equal(t, s.Name, got.Name) + assert.Equal(t, s.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-secret", s.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidSecret() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + current := &corev1.Secret{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, []byte("value"), current.Data["key"]) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidSecret() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-entry", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("from-mutation", []byte("yes")) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &corev1.Secret{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, []byte("value"), current.Data["key"]) + assert.Equal(t, []byte("yes"), current.Data["from-mutation"]) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + // A second mutation should observe changes made by the first. + desired := newValidSecret() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("order", []byte("a")) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.SetData("order", []byte("b")) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &corev1.Secret{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, []byte("b"), current.Data["order"]) +} + +func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { + desired := newValidSecret() + + applicatorCalled := false + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(current, d *corev1.Secret) error { + applicatorCalled = true + // Only copy "key", ignore everything else. + if current.Data == nil { + current.Data = make(map[string][]byte) + } + current.Data["key"] = d.Data["key"] + return nil + }). + Build() + require.NoError(t, err) + + current := &corev1.Secret{ + Data: map[string][]byte{"external": []byte("preserved")}, + } + require.NoError(t, res.Mutate(current)) + + assert.True(t, applicatorCalled) + assert.Equal(t, []byte("value"), current.Data["key"]) + assert.Equal(t, []byte("preserved"), current.Data["external"]) +} + +func TestResource_Mutate_CustomFieldApplicator_Error(t *testing.T) { + res, err := NewBuilder(newValidSecret()). + WithCustomFieldApplicator(func(_, _ *corev1.Secret) error { + return errors.New("applicator error") + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&corev1.Secret{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") +} + +func TestResource_ExtractData(t *testing.T) { + s := newValidSecret() + + var extracted []byte + res, err := NewBuilder(s). + WithDataExtractor(func(c corev1.Secret) error { + extracted = c.Data["key"] + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, []byte("value"), extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidSecret()). + WithDataExtractor(func(_ corev1.Secret) error { + return errors.New("extract error") + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.Error(t, err) + assert.Contains(t, err.Error(), "extract error") +} From 560b71f72fc5d1490293e4119f3447e030080e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:39:45 +0000 Subject: [PATCH 03/20] Add Secret primitive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/secret.md | 328 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/primitives/secret.md diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md new file mode 100644 index 00000000..e03075d2 --- /dev/null +++ b/docs/primitives/secret.md @@ -0,0 +1,328 @@ +# Secret Primitive + +The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData` entries and object metadata. + +## Capabilities + +| Capability | Detail | +|-----------------------|---------------------------------------------------------------------------------------------------------| +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | +| **Flavors** | Preserves externally-managed fields — labels, annotations, and `.data` entries not owned by the operator | +| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | + +## Building a Secret Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + +base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-credentials", + Namespace: owner.Namespace, + }, + Data: map[string][]byte{ + "password": []byte("default-password"), + }, +} + +resource, err := secret.NewBuilder(base). + WithFieldApplicationFlavor(secret.PreserveExternalEntries). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Default Field Application + +`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object. This ensures every reconciliation cycle produces a clean, predictable state and avoids any drift from the desired baseline. + +Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: + +```go +resource, err := secret.NewBuilder(base). + WithCustomFieldApplicator(func(current, desired *corev1.Secret) error { + // Only synchronise owned keys; leave other fields untouched. + current.Data["owned-key"] = desired.Data["owned-key"] + return nil + }). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +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) secret.Mutation { + return secret.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *secret.Mutator) error { + m.SetData("feature-flag", []byte("enabled")) + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. + +### Boolean-gated mutations + +```go +func TLSSecretMutation(version string, tlsEnabled bool) secret.Mutation { + return secret.Mutation{ + Name: "tls-secret", + Feature: feature.NewResourceFeature(version, nil).When(tlsEnabled), + Mutate: func(m *secret.Mutator) error { + m.SetData("tls.crt", certBytes) + m.SetData("tls.key", keyBytes) + return nil + }, + } +} +``` + +### Version-gated mutations + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyTokenMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "legacy-token", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("auth-mode", "legacy-token") + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +|------|-------------------|--------------------------------------------------| +| 1 | Metadata edits | Labels and annotations on the `Secret` | +| 2 | Data edits | `.data` and `.stringData` entries — Set, Remove, Raw | + +Within each category, edits are applied in their registration order. Later features observe the Secret as modified by all previous features. + +## Editors + +### SecretDataEditor + +The primary API for modifying `.data` and `.stringData` entries. Use `m.EditData` for full control: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("password", []byte("new-password")) + e.Remove("stale-key") + e.SetString("config-value", "plaintext") + return nil +}) +``` + +#### Set and Remove (.data) + +`Set` adds or overwrites a `.data` key with a byte slice value. `Remove` deletes a `.data` key; it is a no-op if the key is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.Set("api-key", []byte("secret-value")) + e.Remove("deprecated-key") + return nil +}) +``` + +#### SetString and RemoveString (.stringData) + +`SetString` adds or overwrites a `.stringData` key with a plaintext value. The API server merges `.stringData` into `.data` on write. `RemoveString` deletes a `.stringData` key; it is a no-op if the key is absent. + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + e.SetString("username", "admin") + e.RemoveString("old-username") + return nil +}) +``` + +#### Raw Escape Hatches + +`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying `map[string]string` for `.stringData`. Both give direct access for free-form editing when none of the structured methods are sufficient: + +```go +m.EditData(func(e *editors.SecretDataEditor) error { + raw := e.Raw() + for k, v := range externalDefaults { + if _, exists := raw[k]; !exists { + raw[k] = v + } + } + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + e.EnsureAnnotation("checksum/secret", secretHash) + return nil +}) +``` + +## Convenience Methods + +The `Mutator` exposes convenience wrappers for the most common `.data` and `.stringData` operations: + +| Method | Equivalent to | +|--------------------------------|----------------------------------------------------| +| `SetData(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveData(key)` | `EditData` → `e.Remove(key)` | +| `SetStringData(key, value)` | `EditData` → `e.SetString(key, value)` | +| `RemoveStringData(key)` | `EditData` → `e.RemoveString(key)` | + +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a single edit block. + +## Flavors + +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. + +### PreserveCurrentLabels + +Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. + +```go +resource, err := secret.NewBuilder(base). + WithFieldApplicationFlavor(secret.PreserveCurrentLabels). + Build() +``` + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +```go +resource, err := secret.NewBuilder(base). + WithFieldApplicationFlavor(secret.PreserveCurrentAnnotations). + Build() +``` + +### PreserveExternalEntries + +Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on overlap. + +Use this when other controllers or admission webhooks inject entries into the Secret that your operator does not own: + +```go +resource, err := secret.NewBuilder(base). + WithFieldApplicationFlavor(secret.PreserveExternalEntries). + Build() +``` + +Multiple flavors can be registered and run in registration order. + +## Data Hash + +Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.data` field. A common use is to annotate a Deployment's pod template with this hash so that a secret change triggers a rolling restart. + +### DataHash + +`DataHash` hashes a Secret value you already have — for example, one read from the cluster: + +```go +hash, err := secret.DataHash(s) +``` + +The hash is derived from the canonical JSON encoding of `.data` with map keys sorted alphabetically, so it is deterministic regardless of insertion order. Metadata fields (labels, annotations, etc.) and `.stringData` are excluded. Only `.data` is hashed because `.stringData` is write-only in the Kubernetes API and is absent from objects returned by the API server. + +### Resource.DesiredHash + +`DesiredHash` computes the hash of what the operator *will write* — that is, the base object with all registered mutations applied — without performing a cluster read and without a second reconcile cycle: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(BaseSecretMutation(owner.Spec.Version)). + WithMutation(TLSMutation(owner.Spec.EnableTLS)). + Build() + +hash, err := secretResource.DesiredHash() +``` + +The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. `PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. + +### Annotating a Deployment pod template (single-pass pattern) + +Build the secret resource first, compute the hash, then pass it into the deployment resource factory. Both resources are registered with the same component, so the secret is reconciled first and the deployment sees the correct hash on every cycle. + +`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type when you need to call it: + +```go +secretResource, err := secret.NewBuilder(base). + WithMutation(features.BaseSecretMutation(owner.Spec.Version)). + WithMutation(features.TLSMutation(owner.Spec.Version, owner.Spec.EnableTLS)). + Build() +if err != nil { + return err +} + +hash, err := secretResource.DesiredHash() +if err != nil { + return err +} + +deployResource, err := resources.NewDeploymentResource(owner, hash) +if err != nil { + return err +} + +comp, err := component.NewComponentBuilder(). + WithResource(secretResource, component.ResourceOptions{}). // reconciled first + WithResource(deployResource, component.ResourceOptions{}). + Build() +``` + +```go +// In NewDeploymentResource, use the hash in a mutation: +func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation { + return deployment.Mutation{ + Name: "secret-checksum", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *deployment.Mutator) error { + m.EditPodTemplateMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureAnnotation("checksum/secret", secretHash) + return nil + }) + return nil + }, + } +} +``` + +When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. + +## Guidance + +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. + +**Use `PreserveExternalEntries` when sharing a Secret.** If admission webhooks, external controllers, or manual operations add entries to a Secret your operator manages, this flavor prevents your operator from silently deleting those entries each reconcile cycle. + +**Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. + +**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids manual encoding in mutation code. From fa87f9959d9e322428bda78e0d00f0e40e3c6f29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:39:53 +0000 Subject: [PATCH 04/20] Add Secret primitive example with feature-gated mutations Demonstrates building a Secret resource with base credentials, version labels, and feature-gated tracing/metrics tokens using the secret primitive. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/secret-primitive/README.md | 31 +++++ examples/secret-primitive/app/controller.go | 54 ++++++++ examples/secret-primitive/features/flavors.go | 12 ++ .../secret-primitive/features/mutations.go | 64 ++++++++++ examples/secret-primitive/main.go | 115 ++++++++++++++++++ examples/secret-primitive/resources/secret.go | 59 +++++++++ 6 files changed, 335 insertions(+) create mode 100644 examples/secret-primitive/README.md create mode 100644 examples/secret-primitive/app/controller.go create mode 100644 examples/secret-primitive/features/flavors.go create mode 100644 examples/secret-primitive/features/mutations.go create mode 100644 examples/secret-primitive/main.go create mode 100644 examples/secret-primitive/resources/secret.go diff --git a/examples/secret-primitive/README.md b/examples/secret-primitive/README.md new file mode 100644 index 00000000..b6becf62 --- /dev/null +++ b/examples/secret-primitive/README.md @@ -0,0 +1,31 @@ +# Secret Primitive Example + +This example demonstrates the usage of the `secret` primitive within the operator component framework. +It shows how to manage a Kubernetes Secret as a component of a larger application, utilising features like: + +- **Base Construction**: Initializing a Secret with basic metadata and type. +- **Feature Mutations**: Composing secret entries from independent, feature-gated mutations using `SetStringData`. +- **Metadata Mutations**: Setting version labels on the Secret via `EditObjectMetadata`. +- **Field Flavors**: Preserving `.data` entries managed by external controllers using `PreserveExternalEntries`. +- **Data Extraction**: Harvesting Secret entries after each reconcile cycle. + +## Directory Structure + +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from `examples/shared/app`. +- `features/`: Contains modular feature definitions: + - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. +- `resources/`: Contains the central `NewSecretResource` factory that assembles all features using `secret.Builder`. +- `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. + +## Running the Example + +```bash +go run examples/secret-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile through four spec variations, printing the secret entries after each cycle. +4. Print the resulting status conditions. diff --git a/examples/secret-primitive/app/controller.go b/examples/secret-primitive/app/controller.go new file mode 100644 index 00000000..339befb3 --- /dev/null +++ b/examples/secret-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the secret primitive. +package app + +import ( + "context" + + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "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 + + // NewSecretResource is a factory function to create the secret resource. + // This allows us to inject the resource construction logic. + NewSecretResource func(*sharedapp.ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *sharedapp.ExampleApp) error { + // 1. Build the secret resource for this owner. + secretResource, err := r.NewSecretResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the secret. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(secretResource, component.ResourceOptions{}). + 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/secret-primitive/features/flavors.go b/examples/secret-primitive/features/flavors.go new file mode 100644 index 00000000..2bcf3940 --- /dev/null +++ b/examples/secret-primitive/features/flavors.go @@ -0,0 +1,12 @@ +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" +) + +// PreserveExternalEntriesFlavor demonstrates using a flavor to keep Secret +// entries that were added by other controllers or tooling (e.g., admission +// webhooks, manual operations) without the operator overwriting them. +func PreserveExternalEntriesFlavor() secret.FieldApplicationFlavor { + return secret.PreserveExternalEntries +} diff --git a/examples/secret-primitive/features/mutations.go b/examples/secret-primitive/features/mutations.go new file mode 100644 index 00000000..73b88391 --- /dev/null +++ b/examples/secret-primitive/features/mutations.go @@ -0,0 +1,64 @@ +// Package features provides sample mutations for the secret primitive example. +package features + +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/primitives/secret" +) + +// BaseCredentialsMutation writes the application's core credentials into the Secret. +// It is always enabled. +func BaseCredentialsMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "base-credentials", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("username", "app-user") + m.SetStringData("password", "default-password") + return nil + }, + } +} + +// VersionLabelMutation sets the app.kubernetes.io/version label on the Secret. +// It is always enabled. +func VersionLabelMutation(version string) secret.Mutation { + return secret.Mutation{ + Name: "version-label", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *secret.Mutator) error { + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// TracingTokenMutation adds an OpenTelemetry tracing auth token to the Secret. +// It is enabled when enableTracing is true. +func TracingTokenMutation(version string, enableTracing bool) secret.Mutation { + return secret.Mutation{ + Name: "tracing-token", + Feature: feature.NewResourceFeature(version, nil).When(enableTracing), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("otel-auth-token", "trace-secret-token-abc123") + return nil + }, + } +} + +// MetricsTokenMutation adds a Prometheus remote-write auth token to the Secret. +// It is enabled when enableMetrics is true. +func MetricsTokenMutation(version string, enableMetrics bool) secret.Mutation { + return secret.Mutation{ + Name: "metrics-token", + Feature: feature.NewResourceFeature(version, nil).When(enableMetrics), + Mutate: func(m *secret.Mutator) error { + m.SetStringData("metrics-auth-token", "metrics-secret-token-xyz789") + return nil + }, + } +} diff --git a/examples/secret-primitive/main.go b/examples/secret-primitive/main.go new file mode 100644 index 00000000..0902ef74 --- /dev/null +++ b/examples/secret-primitive/main.go @@ -0,0 +1,115 @@ +// Package main is the entry point for the secret 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/secret-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/resources" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + corev1 "k8s.io/api/core/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. + scheme := runtime.NewScheme() + if err := sharedapp.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := corev1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&sharedapp.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &sharedapp.ExampleApp{ + Spec: sharedapp.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + } + 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 the controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + NewSecretResource: resources.NewSecretResource, + } + + // 4. Run reconciliation with multiple spec versions to demonstrate how + // feature-gated mutations compose secret entries. + specs := []sharedapp.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + EnableMetrics: true, + }, + { + Version: "1.2.4", + EnableTracing: false, + EnableMetrics: false, // Disable metrics too + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Version=%s, Tracing=%v, Metrics=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.EnableMetrics) + + 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) + } + + 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/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go new file mode 100644 index 00000000..3821b01a --- /dev/null +++ b/examples/secret-primitive/resources/secret.go @@ -0,0 +1,59 @@ +// Package resources provides resource implementations for the secret primitive example. +package resources + +import ( + "encoding/base64" + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/features" + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NewSecretResource constructs a secret primitive resource with all the features. +func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) { + // 1. Create the base Secret object. + base := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-credentials", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Type: corev1.SecretTypeOpaque, + } + + // 2. Initialize the secret builder. + builder := secret.NewBuilder(base) + + // 3. Register mutations in dependency order. + // + // BaseCredentialsMutation and VersionLabelMutation always run first to establish + // the baseline. Tracing and metrics tokens are then added on top. + builder.WithMutation(features.BaseCredentialsMutation(owner.Spec.Version)) + builder.WithMutation(features.VersionLabelMutation(owner.Spec.Version)) + builder.WithMutation(features.TracingTokenMutation(owner.Spec.Version, owner.Spec.EnableTracing)) + builder.WithMutation(features.MetricsTokenMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) + + // 4. Preserve entries added by external controllers or admission webhooks. + builder.WithFieldApplicationFlavor(features.PreserveExternalEntriesFlavor()) + + // 5. Extract data from the reconciled Secret. + builder.WithDataExtractor(func(s corev1.Secret) error { + fmt.Printf("Reconciled Secret: %s\n", s.Name) + for key, value := range s.Data { + fmt.Printf(" [%s]: %s\n", key, base64.StdEncoding.EncodeToString(value)) + } + for key, value := range s.StringData { + fmt.Printf(" [%s] (stringData): %s\n", key, value) + } + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From 469c4642f729f513e9358a2ca273d730185692cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:32:20 +0000 Subject: [PATCH 05/20] Address Copilot review: StringData handling in hash, flavors, docs, and example - hash.go: Merge .stringData into a copy of .data before hashing to match Kubernetes API-server write semantics. Ensures DesiredHash reflects content set via SetStringData. - flavors.go: PreserveExternalEntries now treats keys present in applied.StringData as owned, preventing incorrect preservation of cluster values that the operator intends to overwrite via .stringData. - secret.md: Add nil-check for current.Data in the custom field applicator example to prevent panic. Update DataHash documentation to describe the merged hash semantics. - example secret.go: Remove misleading StringData iteration from the data extractor since .stringData is write-only and never returned by the API server on read. - Add tests for StringData-aware hash and flavor behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/secret.md | 5 ++- examples/secret-primitive/resources/secret.go | 5 +-- pkg/primitives/secret/flavors.go | 17 ++++++---- pkg/primitives/secret/flavors_test.go | 11 +++++++ pkg/primitives/secret/hash.go | 33 +++++++++++-------- pkg/primitives/secret/hash_test.go | 33 +++++++++++++++++++ 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index e03075d2..e9e7c3ef 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -42,6 +42,9 @@ Use `WithCustomFieldApplicator` when other controllers manage fields that should resource, err := secret.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *corev1.Secret) error { // Only synchronise owned keys; leave other fields untouched. + if current.Data == nil { + current.Data = make(map[string][]byte) + } current.Data["owned-key"] = desired.Data["owned-key"] return nil }). @@ -250,7 +253,7 @@ Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.d hash, err := secret.DataHash(s) ``` -The hash is derived from the canonical JSON encoding of `.data` with map keys sorted alphabetically, so it is deterministic regardless of insertion order. Metadata fields (labels, annotations, etc.) and `.stringData` are excluded. Only `.data` is hashed because `.stringData` is write-only in the Kubernetes API and is absent from objects returned by the API server. +The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.stringData` entries are merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, matching Kubernetes API-server write semantics. This ensures the hash is consistent whether called on a desired object (which may use `.stringData`) or a cluster-read object (where `.stringData` has already been merged into `.data`). ### Resource.DesiredHash diff --git a/examples/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go index 3821b01a..f66d5041 100644 --- a/examples/secret-primitive/resources/secret.go +++ b/examples/secret-primitive/resources/secret.go @@ -42,15 +42,12 @@ func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) // 4. Preserve entries added by external controllers or admission webhooks. builder.WithFieldApplicationFlavor(features.PreserveExternalEntriesFlavor()) - // 5. Extract data from the reconciled Secret. + // 5. Extract data from the reconciled Secret (only the persisted Data field is observable). builder.WithDataExtractor(func(s corev1.Secret) error { fmt.Printf("Reconciled Secret: %s\n", s.Name) for key, value := range s.Data { fmt.Printf(" [%s]: %s\n", key, base64.StdEncoding.EncodeToString(value)) } - for key, value := range s.StringData { - fmt.Printf(" [%s] (stringData): %s\n", key, value) - } return nil }) diff --git a/pkg/primitives/secret/flavors.go b/pkg/primitives/secret/flavors.go index 97cc91fa..5bfe3b9d 100644 --- a/pkg/primitives/secret/flavors.go +++ b/pkg/primitives/secret/flavors.go @@ -30,15 +30,20 @@ func PreserveCurrentAnnotations(applied, current, desired *corev1.Secret) error // // This is useful when other controllers or admission webhooks inject entries into // the Secret that your operator does not own. Keys present in both are left -// as-is on the applied object (the desired value wins). +// as-is on the applied object (the desired value wins). A key is treated as +// owned if it exists in either applied.Data or applied.StringData. func PreserveExternalEntries(applied, current, _ *corev1.Secret) error { for k, v := range current.Data { - if _, exists := applied.Data[k]; !exists { - if applied.Data == nil { - applied.Data = make(map[string][]byte) - } - applied.Data[k] = v + if _, exists := applied.Data[k]; exists { + continue } + if _, exists := applied.StringData[k]; exists { + continue + } + if applied.Data == nil { + applied.Data = make(map[string][]byte) + } + applied.Data[k] = v } return nil } diff --git a/pkg/primitives/secret/flavors_test.go b/pkg/primitives/secret/flavors_test.go index 3aa89ae7..05f5d87e 100644 --- a/pkg/primitives/secret/flavors_test.go +++ b/pkg/primitives/secret/flavors_test.go @@ -90,6 +90,17 @@ func TestPreserveExternalEntries(t *testing.T) { assert.Len(t, applied.Data, 1) assert.Equal(t, []byte("applied"), applied.Data["owned"]) }) + + t.Run("stringData key treated as owned", func(t *testing.T) { + applied := &corev1.Secret{ + StringData: map[string]string{"key": "from-stringdata"}, + } + current := &corev1.Secret{Data: map[string][]byte{"key": []byte("from-cluster")}} + + require.NoError(t, PreserveExternalEntries(applied, current, nil)) + // key is owned via stringData, so it should NOT be copied into applied.Data + assert.Nil(t, applied.Data, "key owned via stringData must not be preserved into Data") + }) } func TestFlavors_Integration(t *testing.T) { diff --git a/pkg/primitives/secret/hash.go b/pkg/primitives/secret/hash.go index a187063e..f1840b2a 100644 --- a/pkg/primitives/secret/hash.go +++ b/pkg/primitives/secret/hash.go @@ -9,16 +9,20 @@ import ( corev1 "k8s.io/api/core/v1" ) -// DataHash computes a stable SHA-256 hash of the .data field of the given Secret. +// DataHash computes a stable SHA-256 hash of the effective data content of the +// given Secret. // -// The hash is derived from the canonical JSON encoding of .data with map keys -// sorted alphabetically, so it is deterministic regardless of insertion order. -// The returned string is the lowercase hex encoding of the 256-bit digest. +// The hash is derived from the canonical JSON encoding of the merged data map +// with keys sorted alphabetically, so it is deterministic regardless of +// insertion order. The returned string is the lowercase hex encoding of the +// 256-bit digest. // -// Only .data is hashed. The .stringData field is write-only in the Kubernetes API -// and is absent from objects returned by the API server; it is intentionally -// excluded so that DataHash is consistent whether called on a desired object or -// a cluster-read object. +// Both .data and .stringData are included. To match Kubernetes API-server write +// semantics, .stringData entries are merged into a copy of .data (with +// .stringData keys taking precedence) before hashing. This ensures the hash is +// consistent whether called on a desired object (which may use .stringData) or +// a cluster-read object (where the API server has already merged .stringData +// into .data). // // A common use case is to annotate a Deployment's pod template with this hash // so that a change in Secret content triggers a rolling restart: @@ -32,11 +36,14 @@ import ( // return nil // }) func DataHash(s corev1.Secret) (string, error) { - // Normalize nil to empty so that a Secret with no .data hashes identically - // to one with an empty map — both represent "no entries". - data := s.Data - if data == nil { - data = map[string][]byte{} + // Build the effective data map by merging .stringData into a copy of .data, + // mirroring the Kubernetes API server's write semantics. + data := make(map[string][]byte, len(s.Data)+len(s.StringData)) + for k, v := range s.Data { + data[k] = v + } + for k, v := range s.StringData { + data[k] = []byte(v) } encoded, err := json.Marshal(data) diff --git a/pkg/primitives/secret/hash_test.go b/pkg/primitives/secret/hash_test.go index 49f9dedf..b6ea4971 100644 --- a/pkg/primitives/secret/hash_test.go +++ b/pkg/primitives/secret/hash_test.go @@ -114,6 +114,39 @@ func TestDataHash_ReturnsHexString(t *testing.T) { } } +// --- StringData merging --- + +func TestDataHash_StringDataIncluded(t *testing.T) { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + StringData: map[string]string{"key": "value"}, + } + hSD, err := DataHash(s) + require.NoError(t, err) + + sData := newHashTestSecret(map[string][]byte{"key": []byte("value")}) + hData, err := DataHash(sData) + require.NoError(t, err) + + assert.Equal(t, hSD, hData, "stringData and equivalent data must produce the same hash") +} + +func TestDataHash_StringDataOverridesData(t *testing.T) { + s := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Data: map[string][]byte{"key": []byte("from-data")}, + StringData: map[string]string{"key": "from-stringdata"}, + } + h1, err := DataHash(s) + require.NoError(t, err) + + sExpected := newHashTestSecret(map[string][]byte{"key": []byte("from-stringdata")}) + h2, err := DataHash(sExpected) + require.NoError(t, err) + + assert.Equal(t, h1, h2, "stringData must override data for the same key") +} + // --- DesiredHash --- func newHashTestResource(t *testing.T, data map[string][]byte, mutations ...Mutation) *Resource { From 74bbea32cf54c2d42bab11c60dfc4c87285beed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:24:57 +0000 Subject: [PATCH 06/20] Avoid logging secret values in example data extractor Print only key names and value lengths instead of base64-encoded values to prevent credential leakage if the example is copied into production. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/secret-primitive/resources/secret.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go index f66d5041..aa267143 100644 --- a/examples/secret-primitive/resources/secret.go +++ b/examples/secret-primitive/resources/secret.go @@ -2,7 +2,6 @@ package resources import ( - "encoding/base64" "fmt" "github.com/sourcehawk/operator-component-framework/examples/secret-primitive/features" @@ -43,10 +42,13 @@ func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) builder.WithFieldApplicationFlavor(features.PreserveExternalEntriesFlavor()) // 5. Extract data from the reconciled Secret (only the persisted Data field is observable). + // + // NOTE: Never log secret values in production controllers. This extractor + // prints only key names and value lengths to avoid leaking credentials. builder.WithDataExtractor(func(s corev1.Secret) error { fmt.Printf("Reconciled Secret: %s\n", s.Name) for key, value := range s.Data { - fmt.Printf(" [%s]: %s\n", key, base64.StdEncoding.EncodeToString(value)) + fmt.Printf(" [%s]: %d bytes\n", key, len(value)) } return nil }) From c468b78a50690c776c2c6a9e0a706c34fe9092f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:48:37 +0000 Subject: [PATCH 07/20] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/resource.go | 11 +++---- pkg/primitives/secret/resource_test.go | 41 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/secret/resource.go b/pkg/primitives/secret/resource.go index a1c52bb0..545a074d 100644 --- a/pkg/primitives/secret/resource.go +++ b/pkg/primitives/secret/resource.go @@ -6,13 +6,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired. -// -// This is the default baseline field application strategy for Secret resources. -// Use a custom field applicator via Builder.WithCustomFieldApplicator if you need -// to preserve fields that other controllers manage. +// 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. func DefaultFieldApplicator(current, desired *corev1.Secret) error { + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/secret/resource_test.go b/pkg/primitives/secret/resource_test.go index 7cbd633e..1579e9e2 100644 --- a/pkg/primitives/secret/resource_test.go +++ b/pkg/primitives/secret/resource_test.go @@ -146,6 +146,47 @@ func TestResource_Mutate_CustomFieldApplicator_Error(t *testing.T) { assert.Contains(t, err.Error(), "applicator error") } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + } + desired := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Data: map[string][]byte{"key": []byte("value")}, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, []byte("value"), current.Data["key"]) + 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) +} + func TestResource_ExtractData(t *testing.T) { s := newValidSecret() From 7d932b90878f73604360a224cc5638f5f283a5ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 22:04:55 +0000 Subject: [PATCH 08/20] Add secret primitive to primitives index documentation Add Secret to the Built-in Primitives table and SecretDataEditor to the Mutation Editors table in docs/primitives.md so the new primitive is discoverable from the main index page. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/primitives.md b/docs/primitives.md index 13fde6a6..913ba9f9 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -107,6 +107,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `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 | +| `SecretDataEditor` | `.data` and `.stringData` entries — set, remove, raw access | | `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. @@ -130,6 +131,7 @@ Selectors are evaluated against the container list *after* any presence operatio |--------------------------------------|------------|-----------------------------------------| | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | ## Usage Examples From 5f0acc1f2b400f477b8629734031e2b6a5a12a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:08:45 +0000 Subject: [PATCH 09/20] align secret docs with preserve status conventions Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/secret.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index e9e7c3ef..41090086 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -34,7 +34,7 @@ resource, err := secret.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object. This ensures every reconciliation cycle produces a clean, predictable state and avoids any drift from the desired baseline. +`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing server-managed data. Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: From 20dc53f468da40701d582ea2f6a2d7dc4b399dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:45 +0000 Subject: [PATCH 10/20] fix secret mutator constructor to not call beginFeature Aligns with the fix applied to deployment and configmap mutators in #42. The constructor now initializes the plans slice directly instead of calling beginFeature(), preventing an empty feature plan when mutate_helper.go calls fm.beginFeature() before each mutation. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/mutator.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go index f360859d..bf951d2b 100644 --- a/pkg/primitives/secret/mutator.go +++ b/pkg/primitives/secret/mutator.go @@ -34,8 +34,11 @@ type Mutator struct { // NewMutator creates a new Mutator for the given Secret. func NewMutator(s *corev1.Secret) *Mutator { - m := &Mutator{secret: s} - m.beginFeature() + m := &Mutator{ + secret: s, + plans: []featurePlan{{}}, + } + m.active = &m.plans[0] return m } From 6fffea1965db358b3e3e89ced0c91dacff5410ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:53:29 +0000 Subject: [PATCH 11/20] narrow secret docs and comments to not overstate cross-feature ordering guarantees The FeatureMutator interface in internal/generic uses an unexported beginFeature() method (sealed interface pattern), so primitive mutators outside that package cannot satisfy it today. Update the secret docs and Apply() doc comment to accurately describe per-mutation ordering rather than claiming cross-feature boundaries that the framework does not currently enforce for primitives. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/secret.md | 2 +- pkg/primitives/secret/mutator.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index 41090086..4a78f32e 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -119,7 +119,7 @@ Within a single mutation, edit operations are applied in a fixed category order | 1 | Metadata edits | Labels and annotations on the `Secret` | | 2 | Data edits | `.data` and `.stringData` entries — Set, Remove, Raw | -Within each category, edits are applied in their registration order. Later features observe the Secret as modified by all previous features. +Within each category, edits are applied in their registration order. Later edits in the same mutation observe the Secret as modified by all earlier edits. ## Editors diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go index bf951d2b..a100db97 100644 --- a/pkg/primitives/secret/mutator.go +++ b/pkg/primitives/secret/mutator.go @@ -121,14 +121,14 @@ func (m *Mutator) RemoveStringData(key string) { // Apply executes all recorded mutation intents on the underlying Secret. // -// Execution order across all registered features: +// Execution order within each plan: // -// 1. Metadata edits (in registration order within each feature) +// 1. Metadata edits (in registration order) // 2. Data edits — EditData, SetData, RemoveData, SetStringData, RemoveStringData -// (in registration order within each feature) +// (in registration order) // -// Features are applied in the order they were registered. Later features observe -// the Secret as modified by all previous features. +// Plans are applied sequentially. Later edits observe the Secret as modified +// by all earlier edits. func (m *Mutator) Apply() error { for _, plan := range m.plans { // 1. Metadata edits From c856ea6f575a18ff27c9318d3012476f44af3c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:38:42 +0000 Subject: [PATCH 12/20] export BeginFeature to satisfy FeatureMutator interface from main Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/mutator.go | 4 ++-- pkg/primitives/secret/mutator_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go index a100db97..a3198d02 100644 --- a/pkg/primitives/secret/mutator.go +++ b/pkg/primitives/secret/mutator.go @@ -42,9 +42,9 @@ func NewMutator(s *corev1.Secret) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -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/secret/mutator_test.go b/pkg/primitives/secret/mutator_test.go index c79e2451..8e7ecbba 100644 --- a/pkg/primitives/secret/mutator_test.go +++ b/pkg/primitives/secret/mutator_test.go @@ -143,7 +143,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) m.SetData("feature1", []byte("on")) - m.beginFeature() + m.BeginFeature() m.SetData("feature2", []byte("on")) require.NoError(t, m.Apply()) From 3262475aa76df6ffb82d3f9a21054c1264694196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:33 +0000 Subject: [PATCH 13/20] format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 2 +- docs/primitives/secret.md | 112 ++++++++++++++++++---------- examples/secret-primitive/README.md | 12 +-- 3 files changed, 81 insertions(+), 45 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index 0df6cfe4..b845041a 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -156,7 +156,7 @@ have been applied. This means a single mutation can safely add a container and t | --------------------------- | -------- | ----------------------------------------- | | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | -| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | +| `pkg/primitives/secret` | Static | [secret.md](primitives/secret.md) | ## Usage Examples diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index 4a78f32e..5f2cdf56 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -1,15 +1,17 @@ # Secret Primitive -The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData` entries and object metadata. +The `secret` primitive is the framework's built-in static abstraction for managing Kubernetes `Secret` resources. It +integrates with the component lifecycle and provides a structured mutation API for managing `.data` and `.stringData` +entries and object metadata. ## Capabilities -| Capability | Detail | -|-----------------------|---------------------------------------------------------------------------------------------------------| -| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | | **Flavors** | Preserves externally-managed fields — labels, annotations, and `.data` entries not owned by the operator | -| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | +| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | ## Building a Secret Primitive @@ -34,7 +36,10 @@ resource, err := secret.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing server-managed data. +`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the +original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing +server-managed data. Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: @@ -53,9 +58,11 @@ resource, err := secret.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that +receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature +with no version constraints and no `When()` conditions is also always enabled: ```go func MyFeatureMutation(version string) secret.Mutation { @@ -70,7 +77,8 @@ func MyFeatureMutation(version string) secret.Mutation { } ``` -Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by +another, register the dependency first. ### Boolean-gated mutations @@ -112,14 +120,16 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## Internal Mutation Ordering -Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are +recorded: -| Step | Category | What it affects | -|------|-------------------|--------------------------------------------------| -| 1 | Metadata edits | Labels and annotations on the `Secret` | -| 2 | Data edits | `.data` and `.stringData` entries — Set, Remove, Raw | +| Step | Category | What it affects | +| ---- | -------------- | ---------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Secret` | +| 2 | Data edits | `.data` and `.stringData` entries — Set, Remove, Raw | -Within each category, edits are applied in their registration order. Later edits in the same mutation observe the Secret as modified by all earlier edits. +Within each category, edits are applied in their registration order. Later edits in the same mutation observe the Secret +as modified by all earlier edits. ## Editors @@ -138,7 +148,8 @@ m.EditData(func(e *editors.SecretDataEditor) error { #### Set and Remove (.data) -`Set` adds or overwrites a `.data` key with a byte slice value. `Remove` deletes a `.data` key; it is a no-op if the key is absent. +`Set` adds or overwrites a `.data` key with a byte slice value. `Remove` deletes a `.data` key; it is a no-op if the key +is absent. ```go m.EditData(func(e *editors.SecretDataEditor) error { @@ -150,7 +161,8 @@ m.EditData(func(e *editors.SecretDataEditor) error { #### SetString and RemoveString (.stringData) -`SetString` adds or overwrites a `.stringData` key with a plaintext value. The API server merges `.stringData` into `.data` on write. `RemoveString` deletes a `.stringData` key; it is a no-op if the key is absent. +`SetString` adds or overwrites a `.stringData` key with a plaintext value. The API server merges `.stringData` into +`.data` on write. `RemoveString` deletes a `.stringData` key; it is a no-op if the key is absent. ```go m.EditData(func(e *editors.SecretDataEditor) error { @@ -162,7 +174,9 @@ m.EditData(func(e *editors.SecretDataEditor) error { #### Raw Escape Hatches -`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying `map[string]string` for `.stringData`. Both give direct access for free-form editing when none of the structured methods are sufficient: +`Raw()` returns the underlying `map[string][]byte` for `.data`. `RawStringData()` returns the underlying +`map[string]string` for `.stringData`. Both give direct access for free-form editing when none of the structured methods +are sufficient: ```go m.EditData(func(e *editors.SecretDataEditor) error { @@ -194,18 +208,20 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { The `Mutator` exposes convenience wrappers for the most common `.data` and `.stringData` operations: -| Method | Equivalent to | -|--------------------------------|----------------------------------------------------| -| `SetData(key, value)` | `EditData` → `e.Set(key, value)` | -| `RemoveData(key)` | `EditData` → `e.Remove(key)` | -| `SetStringData(key, value)` | `EditData` → `e.SetString(key, value)` | -| `RemoveStringData(key)` | `EditData` → `e.RemoveString(key)` | +| Method | Equivalent to | +| --------------------------- | -------------------------------------- | +| `SetData(key, value)` | `EditData` → `e.Set(key, value)` | +| `RemoveData(key)` | `EditData` → `e.Remove(key)` | +| `SetStringData(key, value)` | `EditData` → `e.SetString(key, value)` | +| `RemoveStringData(key)` | `EditData` → `e.RemoveString(key)` | -Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a single edit block. +Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a +single edit block. ## Flavors -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external controllers or other tools. +Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external +controllers or other tools. ### PreserveCurrentLabels @@ -219,7 +235,8 @@ resource, err := secret.NewBuilder(base). ### PreserveCurrentAnnotations -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on +overlap. ```go resource, err := secret.NewBuilder(base). @@ -229,7 +246,8 @@ resource, err := secret.NewBuilder(base). ### PreserveExternalEntries -Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on overlap. +Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on +overlap. Use this when other controllers or admission webhooks inject entries into the Secret that your operator does not own: @@ -243,7 +261,8 @@ Multiple flavors can be registered and run in registration order. ## Data Hash -Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.data` field. A common use is to annotate a Deployment's pod template with this hash so that a secret change triggers a rolling restart. +Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.data` field. A common use is to annotate +a Deployment's pod template with this hash so that a secret change triggers a rolling restart. ### DataHash @@ -253,11 +272,16 @@ Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.d hash, err := secret.DataHash(s) ``` -The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.stringData` entries are merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, matching Kubernetes API-server write semantics. This ensures the hash is consistent whether called on a desired object (which may use `.stringData`) or a cluster-read object (where `.stringData` has already been merged into `.data`). +The hash is derived from the canonical JSON encoding of the effective data map with keys sorted alphabetically, so it is +deterministic regardless of insertion order. Both `.data` and `.stringData` are included: `.stringData` entries are +merged into a copy of `.data` (with `.stringData` keys taking precedence) before hashing, matching Kubernetes API-server +write semantics. This ensures the hash is consistent whether called on a desired object (which may use `.stringData`) or +a cluster-read object (where `.stringData` has already been merged into `.data`). ### Resource.DesiredHash -`DesiredHash` computes the hash of what the operator *will write* — that is, the base object with all registered mutations applied — without performing a cluster read and without a second reconcile cycle: +`DesiredHash` computes the hash of what the operator _will write_ — that is, the base object with all registered +mutations applied — without performing a cluster read and without a second reconcile cycle: ```go secretResource, err := secret.NewBuilder(base). @@ -268,13 +292,17 @@ secretResource, err := secret.NewBuilder(base). hash, err := secretResource.DesiredHash() ``` -The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. `PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. +The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. +`PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. ### Annotating a Deployment pod template (single-pass pattern) -Build the secret resource first, compute the hash, then pass it into the deployment resource factory. Both resources are registered with the same component, so the secret is reconciled first and the deployment sees the correct hash on every cycle. +Build the secret resource first, compute the hash, then pass it into the deployment resource factory. Both resources are +registered with the same component, so the secret is reconciled first and the deployment sees the correct hash on every +cycle. -`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type when you need to call it: +`DesiredHash` is defined on `*secret.Resource`, not on the `component.Resource` interface, so keep the concrete type +when you need to call it: ```go secretResource, err := secret.NewBuilder(base). @@ -318,14 +346,20 @@ func ChecksumAnnotationMutation(version, secretHash string) deployment.Mutation } ``` -When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. +When the secret mutations change (version upgrade, feature toggle), `DesiredHash` returns a different value on the same +reconcile cycle, the pod template annotation changes, and Kubernetes triggers a rolling restart. ## Guidance -**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. +**`Feature: nil` applies unconditionally.** Omit `Feature` (leave it nil) for mutations that should always run. Use +`feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for +boolean conditions. -**Use `PreserveExternalEntries` when sharing a Secret.** If admission webhooks, external controllers, or manual operations add entries to a Secret your operator manages, this flavor prevents your operator from silently deleting those entries each reconcile cycle. +**Use `PreserveExternalEntries` when sharing a Secret.** If admission webhooks, external controllers, or manual +operations add entries to a Secret your operator manages, this flavor prevents your operator from silently deleting +those entries each reconcile cycle. **Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. -**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids manual encoding in mutation code. +**Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids +manual encoding in mutation code. diff --git a/examples/secret-primitive/README.md b/examples/secret-primitive/README.md index b6becf62..4416f9cc 100644 --- a/examples/secret-primitive/README.md +++ b/examples/secret-primitive/README.md @@ -1,7 +1,7 @@ # Secret Primitive Example -This example demonstrates the usage of the `secret` primitive within the operator component framework. -It shows how to manage a Kubernetes Secret as a component of a larger application, utilising features like: +This example demonstrates the usage of the `secret` primitive within the operator component framework. It shows how to +manage a Kubernetes Secret as a component of a larger application, utilising features like: - **Base Construction**: Initializing a Secret with basic metadata and type. - **Feature Mutations**: Composing secret entries from independent, feature-gated mutations using `SetStringData`. @@ -11,10 +11,11 @@ It shows how to manage a Kubernetes Secret as a component of a larger applicatio ## Directory Structure -- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from `examples/shared/app`. +- `app/`: Defines the controller that uses the component framework. The `ExampleApp` CRD is shared from + `examples/shared/app`. - `features/`: Contains modular feature definitions: - - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. + - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. + - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. - `resources/`: Contains the central `NewSecretResource` factory that assembles all features using `secret.Builder`. - `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. @@ -25,6 +26,7 @@ go run examples/secret-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile through four spec variations, printing the secret entries after each cycle. From 9ef877fc9615d03fbc1abc89b18b2fee8424c3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:33:24 +0000 Subject: [PATCH 14/20] fix doc/comment inaccuracies about stringData handling in hash functions Update DesiredHash godoc, SecretDataEditor index entry, and hash section intro to accurately reflect that hashing includes .stringData merged into .data, and that the editor exposes SetString/RemoveString/RawStringData. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 2 +- docs/primitives/secret.md | 3 +-- pkg/primitives/secret/hash.go | 10 ++++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index b845041a..67da493b 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -130,7 +130,7 @@ Editors provide scoped, typed APIs for modifying specific parts of a resource: | `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 | -| `SecretDataEditor` | `.data` and `.stringData` entries — set, remove, raw access | +| `SecretDataEditor` | `.data` and `.stringData` — set/remove bytes, `SetString`/`RemoveString`, `Raw()`/`RawStringData()` | | `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 diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index 5f2cdf56..d547dddf 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -261,8 +261,7 @@ Multiple flavors can be registered and run in registration order. ## Data Hash -Two utilities are provided for computing a stable SHA-256 hash of a Secret's `.data` field. A common use is to annotate -a Deployment's pod template with this hash so that a secret change triggers a rolling restart. +Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus `.stringData` merged using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template with this hash so that a secret change triggers a rolling restart. ### DataHash diff --git a/pkg/primitives/secret/hash.go b/pkg/primitives/secret/hash.go index f1840b2a..ced1c9a7 100644 --- a/pkg/primitives/secret/hash.go +++ b/pkg/primitives/secret/hash.go @@ -59,10 +59,12 @@ func DataHash(s corev1.Secret) (string, error) { // DesiredHash computes the SHA-256 hash of the Secret as it will be written to // the cluster, based on the base object and all registered mutations. // -// The hash covers only operator-controlled fields (.data after applying the -// baseline and mutations). Fields preserved by flavors from the live cluster -// state (e.g. PreserveExternalEntries) are intentionally excluded — only -// changes to operator-owned content will change the hash. +// The hash covers only operator-controlled data content: the effective Secret +// data after applying the baseline and mutations, with .stringData merged into +// .data (and .stringData keys taking precedence), matching DataHash semantics. +// Fields preserved by flavors from the live cluster state (e.g. +// PreserveExternalEntries) are intentionally excluded — only changes to +// operator-owned content will change the hash. // // This enables a single-pass checksum pattern: compute the hash before // reconciliation and pass it directly to the deployment resource factory, From aac72df9ac60b302874e6fdd08d27edd00fa1c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:49:37 +0000 Subject: [PATCH 15/20] format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 14 +++++++------- docs/primitives/secret.md | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/primitives.md b/docs/primitives.md index 67da493b..a26e1288 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -124,14 +124,14 @@ This design: Editors provide scoped, typed APIs for modifying specific parts of a resource: -| Editor | Scope | -| ---------------------- | ----------------------------------------------------------------------- | -| `ContainerEditor` | Environment variables, arguments, resource limits, ports | -| `PodSpecEditor` | Volumes, tolerations, node selectors, service account, security context | -| `DeploymentSpecEditor` | Replicas, update strategy, label selectors | -| `ConfigMapDataEditor` | `.data` entries — set, remove, deep-merge YAML patches, raw access | +| Editor | Scope | +| ---------------------- | --------------------------------------------------------------------------------------------------- | +| `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 | | `SecretDataEditor` | `.data` and `.stringData` — set/remove bytes, `SetString`/`RemoveString`, `Raw()`/`RawStringData()` | -| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | +| `ObjectMetaEditor` | Labels and annotations on any Kubernetes object | Every editor exposes a `.Raw()` method for cases where the typed API is insufficient, giving direct access to the underlying Kubernetes struct while keeping the mutation scoped to that editor's target. diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index d547dddf..1f7ced5b 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -261,7 +261,9 @@ Multiple flavors can be registered and run in registration order. ## Data Hash -Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus `.stringData` merged using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template with this hash so that a secret change triggers a rolling restart. +Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus +`.stringData` merged using Kubernetes API-server semantics). A common use is to annotate a Deployment's pod template +with this hash so that a secret change triggers a rolling restart. ### DataHash From f9c1acaa59ebcc5253e0dafb231cfb4933fd0fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:13:50 +0000 Subject: [PATCH 16/20] normalize stringData into data after Apply to prevent spurious updates The Kubernetes API server merges .stringData into .data on write and never returns .stringData on reads. When controllerutil.CreateOrUpdate diffs the post-mutate object against the server-populated object, leftover .stringData entries cause an Update on every reconcile. After applying all mutations, merge .stringData entries into .data (stringData keys take precedence) and clear .stringData so the mutated object matches the server-persisted form. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/mutator.go | 15 +++++++++++++++ pkg/primitives/secret/mutator_test.go | 10 +++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go index a3198d02..3137fa7d 100644 --- a/pkg/primitives/secret/mutator.go +++ b/pkg/primitives/secret/mutator.go @@ -152,5 +152,20 @@ func (m *Mutator) Apply() error { } } + // Normalize: merge .stringData into .data and clear .stringData. + // The API server performs this same merge on write and never returns + // .stringData on reads. Doing it here ensures the mutated object matches + // the server-persisted form, preventing spurious Updates from + // controllerutil.CreateOrUpdate on every reconcile. + if len(m.secret.StringData) > 0 { + if m.secret.Data == nil { + m.secret.Data = make(map[string][]byte, len(m.secret.StringData)) + } + for k, v := range m.secret.StringData { + m.secret.Data[k] = []byte(v) + } + m.secret.StringData = nil + } + return nil } diff --git a/pkg/primitives/secret/mutator_test.go b/pkg/primitives/secret/mutator_test.go index 8e7ecbba..8ab4f902 100644 --- a/pkg/primitives/secret/mutator_test.go +++ b/pkg/primitives/secret/mutator_test.go @@ -106,7 +106,9 @@ func TestMutator_SetStringData(t *testing.T) { m := NewMutator(s) m.SetStringData("key", "value") require.NoError(t, m.Apply()) - assert.Equal(t, "value", s.StringData["key"]) + // After Apply, stringData is normalized into data and cleared. + assert.Equal(t, []byte("value"), s.Data["key"]) + assert.Nil(t, s.StringData) } // --- RemoveStringData --- @@ -117,8 +119,10 @@ func TestMutator_RemoveStringData(t *testing.T) { m := NewMutator(s) m.RemoveStringData("key") require.NoError(t, m.Apply()) - assert.NotContains(t, s.StringData, "key") - assert.Equal(t, "keep", s.StringData["other"]) + // After Apply, remaining stringData is normalized into data and cleared. + assert.NotContains(t, s.Data, "key") + assert.Equal(t, []byte("keep"), s.Data["other"]) + assert.Nil(t, s.StringData) } // --- Execution order --- From 21b8a43fdca5d13eb1a71f7bb2b6fa72b624322a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:01:01 +0000 Subject: [PATCH 17/20] align secret Mutator construction with configmap/deployment pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Do not initialize an empty feature plan in NewMutator — require BeginFeature before registering mutations, matching the convention established in configmap and deployment primitives. Add constructor and feature-plan invariant tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/secret/mutator.go | 6 +-- pkg/primitives/secret/mutator_test.go | 64 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/pkg/primitives/secret/mutator.go b/pkg/primitives/secret/mutator.go index 3137fa7d..dbc2d37d 100644 --- a/pkg/primitives/secret/mutator.go +++ b/pkg/primitives/secret/mutator.go @@ -33,13 +33,11 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given Secret. +// BeginFeature must be called before registering any mutations. func NewMutator(s *corev1.Secret) *Mutator { - m := &Mutator{ + return &Mutator{ secret: s, - plans: []featurePlan{{}}, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/secret/mutator_test.go b/pkg/primitives/secret/mutator_test.go index 8ab4f902..a099b129 100644 --- a/pkg/primitives/secret/mutator_test.go +++ b/pkg/primitives/secret/mutator_test.go @@ -25,6 +25,7 @@ func newTestSecret(data map[string][]byte) *corev1.Secret { func TestMutator_EditObjectMetadata(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app", "myapp") return nil @@ -36,6 +37,7 @@ func TestMutator_EditObjectMetadata(t *testing.T) { func TestMutator_EditObjectMetadata_Nil(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.EditObjectMetadata(nil) assert.NoError(t, m.Apply()) } @@ -45,6 +47,7 @@ func TestMutator_EditObjectMetadata_Nil(t *testing.T) { func TestMutator_EditData_RawAccess(t *testing.T) { s := newTestSecret(map[string][]byte{"existing": []byte("keep")}) m := NewMutator(s) + m.BeginFeature() m.EditData(func(e *editors.SecretDataEditor) error { raw := e.Raw() raw["new"] = []byte("added") @@ -58,6 +61,7 @@ func TestMutator_EditData_RawAccess(t *testing.T) { func TestMutator_EditData_Nil(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.EditData(nil) assert.NoError(t, m.Apply()) } @@ -67,6 +71,7 @@ func TestMutator_EditData_Nil(t *testing.T) { func TestMutator_SetData(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.SetData("key", []byte("value")) require.NoError(t, m.Apply()) assert.Equal(t, []byte("value"), s.Data["key"]) @@ -75,6 +80,7 @@ func TestMutator_SetData(t *testing.T) { func TestMutator_SetData_Overwrites(t *testing.T) { s := newTestSecret(map[string][]byte{"key": []byte("old")}) m := NewMutator(s) + m.BeginFeature() m.SetData("key", []byte("new")) require.NoError(t, m.Apply()) assert.Equal(t, []byte("new"), s.Data["key"]) @@ -85,6 +91,7 @@ func TestMutator_SetData_Overwrites(t *testing.T) { func TestMutator_RemoveData(t *testing.T) { s := newTestSecret(map[string][]byte{"key": []byte("value"), "other": []byte("keep")}) m := NewMutator(s) + m.BeginFeature() m.RemoveData("key") require.NoError(t, m.Apply()) assert.NotContains(t, s.Data, "key") @@ -94,6 +101,7 @@ func TestMutator_RemoveData(t *testing.T) { func TestMutator_RemoveData_NotPresent(t *testing.T) { s := newTestSecret(map[string][]byte{"other": []byte("keep")}) m := NewMutator(s) + m.BeginFeature() m.RemoveData("missing") require.NoError(t, m.Apply()) assert.Equal(t, []byte("keep"), s.Data["other"]) @@ -104,6 +112,7 @@ func TestMutator_RemoveData_NotPresent(t *testing.T) { func TestMutator_SetStringData(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.SetStringData("key", "value") require.NoError(t, m.Apply()) // After Apply, stringData is normalized into data and cleared. @@ -117,6 +126,7 @@ func TestMutator_RemoveStringData(t *testing.T) { s := newTestSecret(nil) s.StringData = map[string]string{"key": "value", "other": "keep"} m := NewMutator(s) + m.BeginFeature() m.RemoveStringData("key") require.NoError(t, m.Apply()) // After Apply, remaining stringData is normalized into data and cleared. @@ -131,6 +141,7 @@ func TestMutator_OperationOrder(t *testing.T) { // Within a feature: metadata edits run before data edits. s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() // Register in reverse logical order to confirm Apply() enforces category ordering. m.SetData("direct", []byte("yes")) m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { @@ -146,6 +157,7 @@ func TestMutator_OperationOrder(t *testing.T) { func TestMutator_MultipleFeatures(t *testing.T) { s := newTestSecret(nil) m := NewMutator(s) + m.BeginFeature() m.SetData("feature1", []byte("on")) m.BeginFeature() m.SetData("feature2", []byte("on")) @@ -155,6 +167,58 @@ func TestMutator_MultipleFeatures(t *testing.T) { assert.Equal(t, []byte("on"), s.Data["feature2"]) } +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + + 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) { + s := newTestSecret(nil) + m := NewMutator(s) + + 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) { + s := newTestSecret(nil) + m := NewMutator(s) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.SetData("f0", []byte("val0")) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.SetData("f1", []byte("val1")) + + // The initial plan should have exactly one data edit + assert.Len(t, m.plans[0].dataEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one data edit + assert.Len(t, m.plans[1].dataEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + s := newTestSecret(nil) + m := NewMutator(s) + m.BeginFeature() + m.SetData("key", []byte("value")) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, []byte("value"), s.Data["key"]) +} + // --- ObjectMutator interface --- func TestMutator_ImplementsObjectMutator(_ *testing.T) { From ba65357f606b14f1fdcd08033cc077282a4077a2 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:08:09 +0000 Subject: [PATCH 18/20] remove field applicators and flavors from secret primitive Align the secret primitive with the framework's SSA refactor: - Remove DefaultFieldApplicator and PreserveServerManagedFields usage - Remove WithCustomFieldApplicator and WithFieldApplicationFlavor builder methods - Delete flavors.go and flavors_test.go - Update tests to use Object() output instead of empty structs in Mutate calls - Strip field applicator and flavor sections from primitive docs Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/secret.md | 82 +----------- pkg/primitives/secret/builder.go | 41 +----- pkg/primitives/secret/builder_test.go | 32 ----- pkg/primitives/secret/flavors.go | 49 -------- pkg/primitives/secret/flavors_test.go | 165 ------------------------- pkg/primitives/secret/resource.go | 17 +-- pkg/primitives/secret/resource_test.go | 105 +++------------- 7 files changed, 28 insertions(+), 463 deletions(-) delete mode 100644 pkg/primitives/secret/flavors.go delete mode 100644 pkg/primitives/secret/flavors_test.go diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index 1f7ced5b..b4f3f3ff 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -6,12 +6,11 @@ entries and object metadata. ## Capabilities -| Capability | Detail | -| --------------------- | -------------------------------------------------------------------------------------------------------- | -| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | -| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | -| **Flavors** | Preserves externally-managed fields — labels, annotations, and `.data` entries not owned by the operator | -| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | +| Capability | Detail | +| --------------------- | ----------------------------------------------------------------------------------------------- | +| **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | +| **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | +| **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle | ## Building a Secret Primitive @@ -29,33 +28,10 @@ base := &corev1.Secret{ } resource, err := secret.NewBuilder(base). - WithFieldApplicationFlavor(secret.PreserveExternalEntries). WithMutation(MyFeatureMutation(owner.Spec.Version)). Build() ``` -## Default Field Application - -`DefaultFieldApplicator` replaces the current Secret with a deep copy of the desired object, then restores -server-managed metadata (ResourceVersion, UID, etc.) and shared-controller fields (OwnerReferences, Finalizers) from the -original live object. This ensures every reconciliation cycle produces a clean, predictable state without losing -server-managed data. - -Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: - -```go -resource, err := secret.NewBuilder(base). - WithCustomFieldApplicator(func(current, desired *corev1.Secret) error { - // Only synchronise owned keys; leave other fields untouched. - if current.Data == nil { - current.Data = make(map[string][]byte) - } - current.Data["owned-key"] = desired.Data["owned-key"] - return nil - }). - Build() -``` - ## Mutations Mutations are the primary mechanism for modifying a `Secret` beyond its baseline. Each mutation is a named function that @@ -218,47 +194,6 @@ The `Mutator` exposes convenience wrappers for the most common `.data` and `.str Use these for simple, single-operation mutations. Use `EditData` when you need multiple operations or raw access in a single edit block. -## Flavors - -Flavors run after the baseline applicator and before mutations. They are used to preserve fields managed by external -controllers or other tools. - -### PreserveCurrentLabels - -Preserves labels present on the live object but absent from the applied desired state. Applied labels win on overlap. - -```go -resource, err := secret.NewBuilder(base). - WithFieldApplicationFlavor(secret.PreserveCurrentLabels). - Build() -``` - -### PreserveCurrentAnnotations - -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on -overlap. - -```go -resource, err := secret.NewBuilder(base). - WithFieldApplicationFlavor(secret.PreserveCurrentAnnotations). - Build() -``` - -### PreserveExternalEntries - -Preserves `.data` keys present on the live object but absent from the applied desired state. Applied values win on -overlap. - -Use this when other controllers or admission webhooks inject entries into the Secret that your operator does not own: - -```go -resource, err := secret.NewBuilder(base). - WithFieldApplicationFlavor(secret.PreserveExternalEntries). - Build() -``` - -Multiple flavors can be registered and run in registration order. - ## Data Hash Two utilities are provided for computing a stable SHA-256 hash of a Secret's effective data content (`.data` plus @@ -293,8 +228,7 @@ secretResource, err := secret.NewBuilder(base). hash, err := secretResource.DesiredHash() ``` -The hash covers only operator-controlled fields. Entries preserved by flavors from the live cluster (e.g. -`PreserveExternalEntries`) are excluded — only changes to operator-owned content will change the hash. +The hash covers only operator-controlled fields. Only changes to operator-owned content will change the hash. ### Annotating a Deployment pod template (single-pass pattern) @@ -356,10 +290,6 @@ reconcile cycle, the pod template annotation changes, and Kubernetes triggers a `feature.NewResourceFeature(version, constraints)` when version-based gating is needed, and chain `.When(bool)` for boolean conditions. -**Use `PreserveExternalEntries` when sharing a Secret.** If admission webhooks, external controllers, or manual -operations add entries to a Secret your operator manages, this flavor prevents your operator from silently deleting -those entries each reconcile cycle. - **Register mutations in dependency order.** If mutation B relies on an entry set by mutation A, register A first. **Prefer `.stringData` for human-readable values.** The API server handles base64 encoding; using `SetStringData` avoids diff --git a/pkg/primitives/secret/builder.go b/pkg/primitives/secret/builder.go index 80b9e51d..f2e32ce1 100644 --- a/pkg/primitives/secret/builder.go +++ b/pkg/primitives/secret/builder.go @@ -10,9 +10,9 @@ import ( // Builder is a configuration helper for creating and customizing a Secret Resource. // -// It provides a fluent API for registering mutations, field application flavors, -// and data extractors. Build() validates the configuration and returns an -// initialized Resource ready for use in a reconciliation loop. +// It provides a fluent API for registering mutations and data extractors. +// Build() validates the configuration and returns an initialized Resource +// ready for use in a reconciliation loop. type Builder struct { base *generic.StaticBuilder[*corev1.Secret, *Mutator] } @@ -21,7 +21,7 @@ type Builder struct { // // The Secret object serves as the desired base state. During reconciliation // the Resource will make the cluster's state match this base, modified by any -// registered mutations and flavors. +// registered mutations. // // The provided Secret must have both Name and Namespace set, which is validated // during the Build() call. @@ -34,7 +34,6 @@ func NewBuilder(s *corev1.Secret) *Builder { base: generic.NewStaticBuilder[*corev1.Secret, *Mutator]( s, identityFunc, - DefaultFieldApplicator, NewMutator, ), } @@ -42,8 +41,7 @@ func NewBuilder(s *corev1.Secret) *Builder { // WithMutation registers a mutation for the Secret. // -// Mutations are applied sequentially during the Mutate() phase of reconciliation, -// after the baseline field applicator and any registered flavors have run. +// Mutations are applied sequentially during the Mutate() phase of reconciliation. // A mutation with a nil Feature is applied unconditionally; one with a non-nil // Feature is applied only when that feature is enabled. func (b *Builder) WithMutation(m Mutation) *Builder { @@ -51,35 +49,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing Secret in the cluster. -// -// The default applicator (DefaultFieldApplicator) replaces the current object -// with a deep copy of the desired object. Use a custom applicator when other -// controllers manage fields you need to preserve. -// -// The applicator receives the current object from the API server and the desired -// object from the Resource, and is responsible for merging the desired changes -// into the current object. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current, desired *corev1.Secret) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a post-baseline field application flavor. -// -// Flavors run after the baseline applicator (default or custom) in registration -// order. They are typically used to preserve fields from the live cluster object -// that should not be overwritten by the desired state. -// -// A nil flavor is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*corev1.Secret](flavor)) - return b -} - // WithDataExtractor registers a function to read values from the Secret after // it has been successfully reconciled. // diff --git a/pkg/primitives/secret/builder_test.go b/pkg/primitives/secret/builder_test.go index 6f58a1d6..3d8b7bc9 100644 --- a/pkg/primitives/secret/builder_test.go +++ b/pkg/primitives/secret/builder_test.go @@ -74,38 +74,6 @@ func TestBuilder_WithMutation(t *testing.T) { assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) } -func TestBuilder_WithCustomFieldApplicator(t *testing.T) { - t.Parallel() - s := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, - } - called := false - applicator := func(_, _ *corev1.Secret) error { - called = true - return nil - } - res, err := NewBuilder(s). - WithCustomFieldApplicator(applicator). - Build() - require.NoError(t, err) - require.NotNil(t, res.base.CustomFieldApplicator) - _ = res.base.CustomFieldApplicator(nil, nil) - assert.True(t, called) -} - -func TestBuilder_WithFieldApplicationFlavor(t *testing.T) { - t.Parallel() - s := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "test-ns"}, - } - res, err := NewBuilder(s). - WithFieldApplicationFlavor(PreserveExternalEntries). - WithFieldApplicationFlavor(nil). // nil must be ignored - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) -} - func TestBuilder_WithDataExtractor(t *testing.T) { t.Parallel() s := &corev1.Secret{ diff --git a/pkg/primitives/secret/flavors.go b/pkg/primitives/secret/flavors.go deleted file mode 100644 index 5bfe3b9d..00000000 --- a/pkg/primitives/secret/flavors.go +++ /dev/null @@ -1,49 +0,0 @@ -package secret - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - corev1 "k8s.io/api/core/v1" -) - -// FieldApplicationFlavor defines a function signature for applying flavors to a -// Secret resource. A flavor is called after the baseline field applicator has -// run and can be used to preserve or merge fields from the live cluster object. -type FieldApplicationFlavor flavors.FieldApplicationFlavor[*corev1.Secret] - -// PreserveCurrentLabels ensures that any labels present on the current live -// Secret but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *corev1.Secret) error { - return flavors.PreserveCurrentLabels[*corev1.Secret]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live Secret but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *corev1.Secret) error { - return flavors.PreserveCurrentAnnotations[*corev1.Secret]()(applied, current, desired) -} - -// PreserveExternalEntries ensures that any .data keys present on the current live -// Secret but absent from the applied (desired) object are preserved on the -// applied object. -// -// This is useful when other controllers or admission webhooks inject entries into -// the Secret that your operator does not own. Keys present in both are left -// as-is on the applied object (the desired value wins). A key is treated as -// owned if it exists in either applied.Data or applied.StringData. -func PreserveExternalEntries(applied, current, _ *corev1.Secret) error { - for k, v := range current.Data { - if _, exists := applied.Data[k]; exists { - continue - } - if _, exists := applied.StringData[k]; exists { - continue - } - if applied.Data == nil { - applied.Data = make(map[string][]byte) - } - applied.Data[k] = v - } - return nil -} diff --git a/pkg/primitives/secret/flavors_test.go b/pkg/primitives/secret/flavors_test.go deleted file mode 100644 index 05f5d87e..00000000 --- a/pkg/primitives/secret/flavors_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package secret - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestPreserveCurrentLabels(t *testing.T) { - t.Run("adds missing labels from current", func(t *testing.T) { - applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied"}}} - current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "applied", applied.Labels["keep"]) - assert.Equal(t, "current", applied.Labels["extra"]) - }) - - t.Run("applied value wins on overlap", func(t *testing.T) { - applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "applied"}}} - current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"key": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "applied", applied.Labels["key"]) - }) - - t.Run("handles nil applied labels", func(t *testing.T) { - applied := &corev1.Secret{} - current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentLabels(applied, current, nil)) - assert.Equal(t, "current", applied.Labels["extra"]) - }) -} - -func TestPreserveCurrentAnnotations(t *testing.T) { - t.Run("adds missing annotations from current", func(t *testing.T) { - applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} - - require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) - assert.Equal(t, "applied", applied.Annotations["keep"]) - assert.Equal(t, "current", applied.Annotations["extra"]) - }) - - t.Run("applied value wins on overlap", func(t *testing.T) { - applied := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "applied"}}} - current := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"key": "current"}}} - - require.NoError(t, PreserveCurrentAnnotations(applied, current, nil)) - assert.Equal(t, "applied", applied.Annotations["key"]) - }) -} - -func TestPreserveExternalEntries(t *testing.T) { - t.Run("adds missing data keys from current", func(t *testing.T) { - applied := &corev1.Secret{Data: map[string][]byte{"owned": []byte("applied")}} - current := &corev1.Secret{Data: map[string][]byte{"external": []byte("current")}} - - require.NoError(t, PreserveExternalEntries(applied, current, nil)) - assert.Equal(t, []byte("applied"), applied.Data["owned"]) - assert.Equal(t, []byte("current"), applied.Data["external"]) - }) - - t.Run("applied value wins on overlap", func(t *testing.T) { - applied := &corev1.Secret{Data: map[string][]byte{"key": []byte("applied")}} - current := &corev1.Secret{Data: map[string][]byte{"key": []byte("current")}} - - require.NoError(t, PreserveExternalEntries(applied, current, nil)) - assert.Equal(t, []byte("applied"), applied.Data["key"]) - }) - - t.Run("handles nil applied data", func(t *testing.T) { - applied := &corev1.Secret{} - current := &corev1.Secret{Data: map[string][]byte{"external": []byte("current")}} - - require.NoError(t, PreserveExternalEntries(applied, current, nil)) - assert.Equal(t, []byte("current"), applied.Data["external"]) - }) - - t.Run("no-op when current has no data", func(t *testing.T) { - applied := &corev1.Secret{Data: map[string][]byte{"owned": []byte("applied")}} - current := &corev1.Secret{} - - require.NoError(t, PreserveExternalEntries(applied, current, nil)) - assert.Len(t, applied.Data, 1) - assert.Equal(t, []byte("applied"), applied.Data["owned"]) - }) - - t.Run("stringData key treated as owned", func(t *testing.T) { - applied := &corev1.Secret{ - StringData: map[string]string{"key": "from-stringdata"}, - } - current := &corev1.Secret{Data: map[string][]byte{"key": []byte("from-cluster")}} - - require.NoError(t, PreserveExternalEntries(applied, current, nil)) - // key is owned via stringData, so it should NOT be copied into applied.Data - assert.Nil(t, applied.Data, "key owned via stringData must not be preserved into Data") - }) -} - -func TestFlavors_Integration(t *testing.T) { - desired := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-secret", - Namespace: "default", - Labels: map[string]string{"app": "desired"}, - }, - Data: map[string][]byte{"owned": []byte("yes")}, - } - - t.Run("PreserveExternalEntries via Mutate", func(t *testing.T) { - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveExternalEntries). - Build() - require.NoError(t, err) - - current := &corev1.Secret{ - Data: map[string][]byte{"external": []byte("keep"), "owned": []byte("old")}, - } - require.NoError(t, res.Mutate(current)) - - assert.Equal(t, []byte("yes"), current.Data["owned"]) - assert.Equal(t, []byte("keep"), current.Data["external"]) - }) - - t.Run("flavors run in registration order", func(t *testing.T) { - var order []string - flavor1 := func(_, _, _ *corev1.Secret) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *corev1.Secret) error { - order = append(order, "flavor2") - return nil - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, err) - - require.NoError(t, res.Mutate(&corev1.Secret{})) - assert.Equal(t, []string{"flavor1", "flavor2"}, order) - }) - - t.Run("flavor error is returned", func(t *testing.T) { - flavorErr := errors.New("flavor boom") - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(func(_, _, _ *corev1.Secret) error { - return flavorErr - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&corev1.Secret{}) - require.Error(t, err) - assert.True(t, errors.Is(err, flavorErr)) - }) -} diff --git a/pkg/primitives/secret/resource.go b/pkg/primitives/secret/resource.go index 545a074d..41614cc8 100644 --- a/pkg/primitives/secret/resource.go +++ b/pkg/primitives/secret/resource.go @@ -6,17 +6,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. -func DefaultFieldApplicator(current, desired *corev1.Secret) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes Secret within // a controller's reconciliation loop. // @@ -47,10 +36,8 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes Secret into the desired state. // // The mutation process follows this order: -// 1. Field application: the current object is updated to reflect the desired base state, -// using either DefaultFieldApplicator or a custom applicator if one is configured. -// 2. Field application flavors: any registered flavors are applied in registration order. -// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// 1. The desired base state is applied to the current object. +// 2. Feature mutations: all registered feature-gated mutations are applied in order. // // This method is invoked by the framework during the Update phase of reconciliation. func (r *Resource) Mutate(current client.Object) error { diff --git a/pkg/primitives/secret/resource_test.go b/pkg/primitives/secret/resource_test.go index 1579e9e2..fc3e4a8b 100644 --- a/pkg/primitives/secret/resource_test.go +++ b/pkg/primitives/secret/resource_test.go @@ -50,10 +50,12 @@ func TestResource_Mutate(t *testing.T) { res, err := NewBuilder(desired).Build() require.NoError(t, err) - current := &corev1.Secret{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, []byte("value"), current.Data["key"]) + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("value"), got.Data["key"]) } func TestResource_Mutate_WithMutation(t *testing.T) { @@ -70,11 +72,13 @@ func TestResource_Mutate_WithMutation(t *testing.T) { Build() require.NoError(t, err) - current := &corev1.Secret{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, []byte("value"), current.Data["key"]) - assert.Equal(t, []byte("yes"), current.Data["from-mutation"]) + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("value"), got.Data["key"]) + assert.Equal(t, []byte("yes"), got.Data["from-mutation"]) } func TestResource_Mutate_FeatureOrdering(t *testing.T) { @@ -100,91 +104,12 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Build() require.NoError(t, err) - current := &corev1.Secret{} - require.NoError(t, res.Mutate(current)) - - assert.Equal(t, []byte("b"), current.Data["order"]) -} - -func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { - desired := newValidSecret() - - applicatorCalled := false - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(current, d *corev1.Secret) error { - applicatorCalled = true - // Only copy "key", ignore everything else. - if current.Data == nil { - current.Data = make(map[string][]byte) - } - current.Data["key"] = d.Data["key"] - return nil - }). - Build() - require.NoError(t, err) - - current := &corev1.Secret{ - Data: map[string][]byte{"external": []byte("preserved")}, - } - require.NoError(t, res.Mutate(current)) - - assert.True(t, applicatorCalled) - assert.Equal(t, []byte("value"), current.Data["key"]) - assert.Equal(t, []byte("preserved"), current.Data["external"]) -} - -func TestResource_Mutate_CustomFieldApplicator_Error(t *testing.T) { - res, err := NewBuilder(newValidSecret()). - WithCustomFieldApplicator(func(_, _ *corev1.Secret) error { - return errors.New("applicator error") - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&corev1.Secret{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, - }, - } - desired := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Labels: map[string]string{"app": "test"}, - }, - Data: map[string][]byte{"key": []byte("value")}, - } - - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // Desired spec and labels are applied - assert.Equal(t, []byte("value"), current.Data["key"]) - 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) + got := obj.(*corev1.Secret) + assert.Equal(t, []byte("b"), got.Data["order"]) } func TestResource_ExtractData(t *testing.T) { From 2acdc7d682b20fd5670618421a5cd2c4b5dfdd62 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:52:48 +0000 Subject: [PATCH 19/20] remove flavors.go and remaining references to field application flavors The previous commit removed FieldApplicationFlavor and PreserveExternalEntries from the secret package but left the example files referencing them, breaking CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/secret-primitive/README.md | 2 -- examples/secret-primitive/features/flavors.go | 12 ------------ examples/secret-primitive/resources/secret.go | 7 ++----- 3 files changed, 2 insertions(+), 19 deletions(-) delete mode 100644 examples/secret-primitive/features/flavors.go diff --git a/examples/secret-primitive/README.md b/examples/secret-primitive/README.md index 4416f9cc..c87f2144 100644 --- a/examples/secret-primitive/README.md +++ b/examples/secret-primitive/README.md @@ -6,7 +6,6 @@ manage a Kubernetes Secret as a component of a larger application, utilising fea - **Base Construction**: Initializing a Secret with basic metadata and type. - **Feature Mutations**: Composing secret entries from independent, feature-gated mutations using `SetStringData`. - **Metadata Mutations**: Setting version labels on the Secret via `EditObjectMetadata`. -- **Field Flavors**: Preserving `.data` entries managed by external controllers using `PreserveExternalEntries`. - **Data Extraction**: Harvesting Secret entries after each reconcile cycle. ## Directory Structure @@ -15,7 +14,6 @@ manage a Kubernetes Secret as a component of a larger application, utilising fea `examples/shared/app`. - `features/`: Contains modular feature definitions: - `mutations.go`: base credentials, version labelling, and feature-gated tracing and metrics tokens. - - `flavors.go`: usage of `FieldApplicationFlavor` to preserve externally-managed entries. - `resources/`: Contains the central `NewSecretResource` factory that assembles all features using `secret.Builder`. - `main.go`: A standalone entry point that demonstrates multiple reconciliation cycles with a fake client. diff --git a/examples/secret-primitive/features/flavors.go b/examples/secret-primitive/features/flavors.go deleted file mode 100644 index 2bcf3940..00000000 --- a/examples/secret-primitive/features/flavors.go +++ /dev/null @@ -1,12 +0,0 @@ -package features - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/primitives/secret" -) - -// PreserveExternalEntriesFlavor demonstrates using a flavor to keep Secret -// entries that were added by other controllers or tooling (e.g., admission -// webhooks, manual operations) without the operator overwriting them. -func PreserveExternalEntriesFlavor() secret.FieldApplicationFlavor { - return secret.PreserveExternalEntries -} diff --git a/examples/secret-primitive/resources/secret.go b/examples/secret-primitive/resources/secret.go index aa267143..eb2d1e06 100644 --- a/examples/secret-primitive/resources/secret.go +++ b/examples/secret-primitive/resources/secret.go @@ -38,10 +38,7 @@ func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) builder.WithMutation(features.TracingTokenMutation(owner.Spec.Version, owner.Spec.EnableTracing)) builder.WithMutation(features.MetricsTokenMutation(owner.Spec.Version, owner.Spec.EnableMetrics)) - // 4. Preserve entries added by external controllers or admission webhooks. - builder.WithFieldApplicationFlavor(features.PreserveExternalEntriesFlavor()) - - // 5. Extract data from the reconciled Secret (only the persisted Data field is observable). + // 4. Extract data from the reconciled Secret (only the persisted Data field is observable). // // NOTE: Never log secret values in production controllers. This extractor // prints only key names and value lengths to avoid leaking credentials. @@ -53,6 +50,6 @@ func NewSecretResource(owner *sharedapp.ExampleApp) (component.Resource, error) return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } From a7b3a5ce0270cc30877a341501be2a7d2e4557f5 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:57:02 +0000 Subject: [PATCH 20/20] fix formatting --- docs/primitives/secret.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/primitives/secret.md b/docs/primitives/secret.md index b4f3f3ff..42d4a694 100644 --- a/docs/primitives/secret.md +++ b/docs/primitives/secret.md @@ -6,8 +6,8 @@ entries and object metadata. ## Capabilities -| Capability | Detail | -| --------------------- | ----------------------------------------------------------------------------------------------- | +| Capability | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------ | | **Static lifecycle** | No health tracking, grace periods, or suspension — the resource is reconciled to desired state | | **Mutation pipeline** | Typed editors for `.data` and `.stringData` entries and object metadata, with a raw escape hatch | | **Data extraction** | Reads generated or updated values back from the reconciled Secret after each sync cycle |