From e97fa346d1f373846870c45344dec278e13e07db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:10 +0000 Subject: [PATCH 01/22] Add IngressSpecEditor for typed Ingress spec mutations Provides SetIngressClassName, SetDefaultBackend, EnsureRule (upsert by host), RemoveRule, EnsureTLS (upsert by first host), RemoveTLS, and Raw escape hatch. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/ingressspec.go | 91 +++++++++++ pkg/mutation/editors/ingressspec_test.go | 189 +++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 pkg/mutation/editors/ingressspec.go create mode 100644 pkg/mutation/editors/ingressspec_test.go diff --git a/pkg/mutation/editors/ingressspec.go b/pkg/mutation/editors/ingressspec.go new file mode 100644 index 00000000..3ad98a05 --- /dev/null +++ b/pkg/mutation/editors/ingressspec.go @@ -0,0 +1,91 @@ +package editors + +import ( + networkingv1 "k8s.io/api/networking/v1" +) + +// IngressSpecEditor provides a typed API for mutating a Kubernetes IngressSpec. +type IngressSpecEditor struct { + spec *networkingv1.IngressSpec +} + +// NewIngressSpecEditor creates a new IngressSpecEditor for the given IngressSpec. +func NewIngressSpecEditor(spec *networkingv1.IngressSpec) *IngressSpecEditor { + return &IngressSpecEditor{spec: spec} +} + +// Raw returns the underlying *networkingv1.IngressSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *IngressSpecEditor) Raw() *networkingv1.IngressSpec { + return e.spec +} + +// SetIngressClassName sets the IngressClassName field of the IngressSpec. +func (e *IngressSpecEditor) SetIngressClassName(name string) { + e.spec.IngressClassName = &name +} + +// SetDefaultBackend sets the default backend for the Ingress. +func (e *IngressSpecEditor) SetDefaultBackend(backend *networkingv1.IngressBackend) { + e.spec.DefaultBackend = backend +} + +// EnsureRule upserts a rule by Host. If a rule with the same Host already exists, +// it is replaced. Otherwise the rule is appended. +func (e *IngressSpecEditor) EnsureRule(rule networkingv1.IngressRule) { + for i, existing := range e.spec.Rules { + if existing.Host == rule.Host { + e.spec.Rules[i] = rule + return + } + } + e.spec.Rules = append(e.spec.Rules, rule) +} + +// RemoveRule removes the rule with the given host. It is a no-op if no rule +// with that host exists. +func (e *IngressSpecEditor) RemoveRule(host string) { + for i, existing := range e.spec.Rules { + if existing.Host == host { + e.spec.Rules = append(e.spec.Rules[:i], e.spec.Rules[i+1:]...) + return + } + } +} + +// EnsureTLS upserts a TLS entry by the first host in the Hosts slice. If a TLS +// entry with the same first host already exists, it is replaced. Otherwise the +// entry is appended. +func (e *IngressSpecEditor) EnsureTLS(tls networkingv1.IngressTLS) { + if len(tls.Hosts) == 0 { + return + } + key := tls.Hosts[0] + for i, existing := range e.spec.TLS { + if len(existing.Hosts) > 0 && existing.Hosts[0] == key { + e.spec.TLS[i] = tls + return + } + } + e.spec.TLS = append(e.spec.TLS, tls) +} + +// RemoveTLS removes TLS entries whose first host matches any of the provided hosts. +// It is a no-op for hosts that do not match any existing TLS entry. +func (e *IngressSpecEditor) RemoveTLS(hosts ...string) { + hostSet := make(map[string]struct{}, len(hosts)) + for _, h := range hosts { + hostSet[h] = struct{}{} + } + filtered := e.spec.TLS[:0] + for _, tls := range e.spec.TLS { + if len(tls.Hosts) > 0 { + if _, match := hostSet[tls.Hosts[0]]; match { + continue + } + } + filtered = append(filtered, tls) + } + e.spec.TLS = filtered +} diff --git a/pkg/mutation/editors/ingressspec_test.go b/pkg/mutation/editors/ingressspec_test.go new file mode 100644 index 00000000..62e0789c --- /dev/null +++ b/pkg/mutation/editors/ingressspec_test.go @@ -0,0 +1,189 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" +) + +func TestIngressSpecEditor(t *testing.T) { + t.Run("Raw", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + assert.Equal(t, spec, editor.Raw()) + }) + + t.Run("SetIngressClassName", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + editor.SetIngressClassName("nginx") + require.NotNil(t, spec.IngressClassName) + assert.Equal(t, "nginx", *spec.IngressClassName) + }) + + t.Run("SetDefaultBackend", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + backend := &networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "my-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + } + editor.SetDefaultBackend(backend) + assert.Equal(t, backend, spec.DefaultBackend) + }) + + t.Run("SetDefaultBackend nil", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + DefaultBackend: &networkingv1.IngressBackend{}, + } + editor := NewIngressSpecEditor(spec) + editor.SetDefaultBackend(nil) + assert.Nil(t, spec.DefaultBackend) + }) +} + +func TestIngressSpecEditor_EnsureRule(t *testing.T) { + t.Run("appends new rule", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + rule := networkingv1.IngressRule{Host: "example.com"} + editor.EnsureRule(rule) + require.Len(t, spec.Rules, 1) + assert.Equal(t, "example.com", spec.Rules[0].Host) + }) + + t.Run("upserts existing rule by host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + {Host: "other.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + updated := networkingv1.IngressRule{ + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + {Path: "/new"}, + }, + }, + }, + } + editor.EnsureRule(updated) + require.Len(t, spec.Rules, 2) + require.NotNil(t, spec.Rules[0].HTTP) + assert.Equal(t, "/new", spec.Rules[0].HTTP.Paths[0].Path) + assert.Equal(t, "other.com", spec.Rules[1].Host) + }) +} + +func TestIngressSpecEditor_RemoveRule(t *testing.T) { + t.Run("removes existing rule", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + {Host: "other.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveRule("example.com") + require.Len(t, spec.Rules, 1) + assert.Equal(t, "other.com", spec.Rules[0].Host) + }) + + t.Run("no-op for missing host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveRule("nonexistent.com") + require.Len(t, spec.Rules, 1) + }) +} + +func TestIngressSpecEditor_EnsureTLS(t *testing.T) { + t.Run("appends new TLS entry", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-secret", + } + editor.EnsureTLS(tls) + require.Len(t, spec.TLS, 1) + assert.Equal(t, "tls-secret", spec.TLS[0].SecretName) + }) + + t.Run("upserts existing TLS by first host", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "old-secret"}, + {Hosts: []string{"other.com"}, SecretName: "other-secret"}, + }, + } + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "new-secret", + } + editor.EnsureTLS(tls) + require.Len(t, spec.TLS, 2) + assert.Equal(t, "new-secret", spec.TLS[0].SecretName) + assert.Equal(t, "other-secret", spec.TLS[1].SecretName) + }) + + t.Run("ignores TLS with empty hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{} + editor := NewIngressSpecEditor(spec) + tls := networkingv1.IngressTLS{SecretName: "orphan"} + editor.EnsureTLS(tls) + assert.Empty(t, spec.TLS) + }) +} + +func TestIngressSpecEditor_RemoveTLS(t *testing.T) { + t.Run("removes matching TLS entries", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + {Hosts: []string{"other.com"}, SecretName: "s2"}, + {Hosts: []string{"third.com"}, SecretName: "s3"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("example.com", "third.com") + require.Len(t, spec.TLS, 1) + assert.Equal(t, "s2", spec.TLS[0].SecretName) + }) + + t.Run("no-op for missing hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("nonexistent.com") + require.Len(t, spec.TLS, 1) + }) + + t.Run("handles TLS entries with empty hosts", func(t *testing.T) { + spec := &networkingv1.IngressSpec{ + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"example.com"}, SecretName: "s1"}, + {SecretName: "no-hosts"}, + }, + } + editor := NewIngressSpecEditor(spec) + editor.RemoveTLS("example.com") + require.Len(t, spec.TLS, 1) + assert.Equal(t, "no-hosts", spec.TLS[0].SecretName) + }) +} From e8b1260b3a09a0c5970f90d77adf24855446dbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:17 +0000 Subject: [PATCH 02/22] Add Ingress primitive (Integration lifecycle) Implements builder, resource, mutator, handlers, and flavors for the networking.k8s.io/v1 Ingress kind. Key design decisions: - DefaultDeleteOnSuspend = false (avoids ingress controller churn) - DefaultSuspendMutation = no-op (backend 502/503 is correct behaviour) - Operational status: Pending until load balancer address assigned - Suspension status: immediately Suspended with reason message Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/builder.go | 172 ++++++++++++++++ pkg/primitives/ingress/builder_test.go | 251 ++++++++++++++++++++++++ pkg/primitives/ingress/flavors.go | 25 +++ pkg/primitives/ingress/flavors_test.go | 118 +++++++++++ pkg/primitives/ingress/handlers.go | 64 ++++++ pkg/primitives/ingress/handlers_test.go | 196 ++++++++++++++++++ pkg/primitives/ingress/mutator.go | 111 +++++++++++ pkg/primitives/ingress/mutator_test.go | 188 ++++++++++++++++++ pkg/primitives/ingress/resource.go | 108 ++++++++++ 9 files changed, 1233 insertions(+) create mode 100644 pkg/primitives/ingress/builder.go create mode 100644 pkg/primitives/ingress/builder_test.go create mode 100644 pkg/primitives/ingress/flavors.go create mode 100644 pkg/primitives/ingress/flavors_test.go create mode 100644 pkg/primitives/ingress/handlers.go create mode 100644 pkg/primitives/ingress/handlers_test.go create mode 100644 pkg/primitives/ingress/mutator.go create mode 100644 pkg/primitives/ingress/mutator_test.go create mode 100644 pkg/primitives/ingress/resource.go diff --git a/pkg/primitives/ingress/builder.go b/pkg/primitives/ingress/builder.go new file mode 100644 index 00000000..ec7449cf --- /dev/null +++ b/pkg/primitives/ingress/builder.go @@ -0,0 +1,172 @@ +package ingress + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + networkingv1 "k8s.io/api/networking/v1" +) + +// Builder is a configuration helper for creating and customizing an Ingress Resource. +// +// It provides a fluent API for registering mutations, field application flavors, +// status handlers, and data extractors. Build() validates the configuration and +// returns an initialized Resource ready for use in a reconciliation loop. +type Builder struct { + base *generic.IntegrationBuilder[*networkingv1.Ingress, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Ingress object. +// +// The Ingress 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 Ingress must have both Name and Namespace set, which is validated +// during the Build() call. +func NewBuilder(ing *networkingv1.Ingress) *Builder { + identityFunc := func(i *networkingv1.Ingress) string { + return fmt.Sprintf("networking.k8s.io/v1/Ingress/%s/%s", i.Namespace, i.Name) + } + + base := generic.NewIntegrationBuilder[*networkingv1.Ingress, *Mutator]( + ing, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a mutation for the Ingress. +// +// 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 Ingress 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 *networkingv1.Ingress) 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[*networkingv1.Ingress](flavor)) + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining if the +// Ingress has reached its operational state. +// +// The default behavior uses DefaultOperationalStatusHandler, which considers an +// Ingress operational when at least one IP or hostname is assigned in +// Status.LoadBalancer.Ingress. Use this method if your Ingress requires more +// complex health checks. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *networkingv1.Ingress) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which immediately +// reports Suspended since the default suspension is a no-op. +func (b *Builder) WithCustomSuspendStatus( + handler func(*networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Ingress should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op. +// Deleting an Ingress causes ingress controller churn; the recommended approach +// is to let the backend service return 502/503. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the Ingress when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns false. +// Deleting an Ingress causes the ingress controller to reload its configuration, +// affecting the entire cluster's routing. Return true from this handler only if +// explicit deletion is required for your use case. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*networkingv1.Ingress) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to read values from the Ingress after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled Ingress. This is useful +// for surfacing generated or updated entries (such as assigned load balancer +// addresses) to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor(extractor func(networkingv1.Ingress) error) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(ing *networkingv1.Ingress) error { + return extractor(*ing) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No Ingress object was provided. +// - The Ingress 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/ingress/builder_test.go b/pkg/primitives/ingress/builder_test.go new file mode 100644 index 00000000..d7927a73 --- /dev/null +++ b/pkg/primitives/ingress/builder_test.go @@ -0,0 +1,251 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestBuilder(t *testing.T) { + t.Parallel() + + t.Run("Build validation", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ingress *networkingv1.Ingress + expectedErr string + }{ + { + name: "nil ingress", + ingress: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid ingress", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.ingress).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, "networking.k8s.io/v1/Ingress/test-ns/test-ing", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(ing). + WithMutation(m). + Build() + require.NoError(t, err) + assert.Len(t, res.base.Mutations, 1) + assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) + }) + + t.Run("WithCustomFieldApplicator", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_, _ *networkingv1.Ingress) error { + applied = true + return nil + } + res, err := NewBuilder(ing). + WithCustomFieldApplicator(applicator). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.CustomFieldApplicator) + _ = res.base.CustomFieldApplicator(nil, nil) + assert.True(t, applied) + }) + + t.Run("WithFieldApplicationFlavor", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(ing). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *networkingv1.Ingress) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusFailing}, nil + } + res, err := NewBuilder(ing). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationNone, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusFailing, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(ing). + WithCustomSuspendStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendStatusHandler) + status, err := res.base.SuspendStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("WithCustomSuspendMutation", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(ing). + WithCustomSuspendMutation(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.SuspendMutationHandler) + err = res.base.SuspendMutationHandler(nil) + assert.EqualError(t, err, "suspend error") + }) + + t.Run("WithCustomSuspendDeletionDecision", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + handler := func(_ *networkingv1.Ingress) bool { + return true + } + res, err := NewBuilder(ing). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.True(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ networkingv1.Ingress) error { + called = true + return nil + } + res, err := NewBuilder(ing). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&networkingv1.Ingress{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + ing := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(ing). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/ingress/flavors.go b/pkg/primitives/ingress/flavors.go new file mode 100644 index 00000000..276ce370 --- /dev/null +++ b/pkg/primitives/ingress/flavors.go @@ -0,0 +1,25 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + networkingv1 "k8s.io/api/networking/v1" +) + +// FieldApplicationFlavor defines a function signature for applying flavors to an +// Ingress 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[*networkingv1.Ingress] + +// PreserveCurrentLabels ensures that any labels present on the current live +// Ingress but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *networkingv1.Ingress) error { + return flavors.PreserveCurrentLabels[*networkingv1.Ingress]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live Ingress but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *networkingv1.Ingress) error { + return flavors.PreserveCurrentAnnotations[*networkingv1.Ingress]()(applied, current, desired) +} diff --git a/pkg/primitives/ingress/flavors_test.go b/pkg/primitives/ingress/flavors_test.go new file mode 100644 index 00000000..127ee173 --- /dev/null +++ b/pkg/primitives/ingress/flavors_test.go @@ -0,0 +1,118 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutate_OrderingAndFlavors(t *testing.T) { + desired := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + Labels: map[string]string{"extra": "preserved"}, + }, + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(PreserveCurrentLabels). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "desired", current.Labels["app"]) + assert.Equal(t, "preserved", current.Labels["extra"]) + }) + + t.Run("flavors run in registration order", func(t *testing.T) { + current := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *networkingv1.Ingress) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *networkingv1.Ingress) error { + order = append(order, "flavor2") + return nil + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor1). + WithFieldApplicationFlavor(flavor2). + Build() + + err := res.Mutate(current) + require.NoError(t, err) + assert.Equal(t, []string{"flavor1", "flavor2"}, order) + }) + + t.Run("flavor error is returned with context", func(t *testing.T) { + current := &networkingv1.Ingress{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *networkingv1.Ingress) error { + return flavorErr + } + + res, _ := NewBuilder(desired). + WithFieldApplicationFlavor(flavor). + Build() + + err := res.Mutate(current) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply field application flavor") + assert.True(t, errors.Is(err, flavorErr)) + }) +} + +func TestDefaultFlavors(t *testing.T) { + t.Run("PreserveCurrentLabels", func(t *testing.T) { + applied := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current", "overlap": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Labels["keep"]) + assert.Equal(t, "applied", applied.Labels["overlap"]) + assert.Equal(t, "current", applied.Labels["extra"]) + }) + + t.Run("PreserveCurrentAnnotations", func(t *testing.T) { + applied := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} + + err := PreserveCurrentAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "applied", applied.Annotations["keep"]) + assert.Equal(t, "current", applied.Annotations["extra"]) + }) + + t.Run("handles nil maps safely", func(t *testing.T) { + applied := &networkingv1.Ingress{} + current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "current", applied.Labels["extra"]) + }) +} diff --git a/pkg/primitives/ingress/handlers.go b/pkg/primitives/ingress/handlers.go new file mode 100644 index 00000000..86e71d9e --- /dev/null +++ b/pkg/primitives/ingress/handlers.go @@ -0,0 +1,64 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + networkingv1 "k8s.io/api/networking/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining if an Ingress +// has reached its operational state. +// +// It considers an Ingress operational when at least one IP or hostname is assigned +// in Status.LoadBalancer.Ingress. Until then it reports Pending. +// +// This function is used as the default handler by the Resource if no custom handler +// is registered via Builder.WithCustomOperationalStatus. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, ing *networkingv1.Ingress, +) (concepts.OperationalStatusWithReason, error) { + if len(ing.Status.LoadBalancer.Ingress) > 0 { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Ingress has been assigned an address", + }, nil + } + + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Awaiting load balancer address assignment", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete +// the Ingress when the parent component is suspended. +// +// It always returns false. Deleting an Ingress causes the ingress controller (e.g. +// nginx) to reload its configuration, which affects the entire cluster's routing — +// not just the suspended service. When a backend service is suspended, the Ingress +// returning 502/503 is the correct observable behaviour. +// +// Operators that want explicit deletion can set DeleteOnSuspend: true via +// Builder.WithCustomSuspendDeletionDecision. +func DefaultDeleteOnSuspendHandler(_ *networkingv1.Ingress) bool { + return false +} + +// DefaultSuspendMutationHandler provides the default mutation applied to an Ingress +// when the component is suspended. +// +// It is a no-op. The Ingress is left in place and the backend service returning +// 502/503 is the expected observable behaviour during suspension. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// Since the default suspension is a no-op (the Ingress is not deleted or modified), +// it immediately reports Suspended with a reason indicating the backend is unavailable. +func DefaultSuspensionStatusHandler(_ *networkingv1.Ingress) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Ingress suspended (backend unavailable)", + }, nil +} diff --git a/pkg/primitives/ingress/handlers_test.go b/pkg/primitives/ingress/handlers_test.go new file mode 100644 index 00000000..3a5881bd --- /dev/null +++ b/pkg/primitives/ingress/handlers_test.go @@ -0,0 +1,196 @@ +package ingress + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + ingress *networkingv1.Ingress + wantStatus concepts.OperationalStatus + wantReason string + }{ + { + name: "pending with no load balancer ingress", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, + { + name: "pending with empty load balancer ingress slice", + op: concepts.ConvergingOperationUpdated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{}, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, + { + name: "operational with IP assigned", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + { + name: "operational with hostname assigned", + op: concepts.ConvergingOperationNone, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {Hostname: "lb.example.com"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + { + name: "operational with multiple entries", + op: concepts.ConvergingOperationUpdated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + {Hostname: "lb.example.com"}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + wantReason: "Ingress has been assigned an address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(tt.op, tt.ingress) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + ing := &networkingv1.Ingress{} + assert.False(t, DefaultDeleteOnSuspendHandler(ing)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + ing := &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "example.com"}, + }, + }, + } + mutator := NewMutator(ing) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + // No-op: rules should remain unchanged. + assert.Len(t, ing.Spec.Rules, 1) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + t.Run("always reports suspended", func(t *testing.T) { + ing := &networkingv1.Ingress{} + got, err := DefaultSuspensionStatusHandler(ing) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "Ingress suspended (backend unavailable)", got.Reason) + }) + + t.Run("suspended even with load balancer assigned", func(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + } + got, err := DefaultSuspensionStatusHandler(ing) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + }) +} + +// Ensure the handler ignores the ConvergingOperation parameter (tested with all values). +func TestDefaultOperationalStatusHandler_IgnoresOperation(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + }, + } + + ops := []concepts.ConvergingOperation{ + concepts.ConvergingOperationCreated, + concepts.ConvergingOperationUpdated, + concepts.ConvergingOperationNone, + } + + for _, op := range ops { + got, err := DefaultOperationalStatusHandler(op, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, got.Status) + } +} + +// Verify the handler works with the core/v1 LoadBalancerIngress type embedded +// in the networking/v1 IngressLoadBalancerIngress. The Ports field is a +// networking/v1 extension. +func TestDefaultOperationalStatusHandler_WithPorts(t *testing.T) { + ing := &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + { + IP: "10.0.0.1", + Ports: []networkingv1.IngressPortStatus{ + { + Port: 80, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + }, + }, + }, + } + + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationCreated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, got.Status) +} diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go new file mode 100644 index 00000000..f0bb4487 --- /dev/null +++ b/pkg/primitives/ingress/mutator.go @@ -0,0 +1,111 @@ +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + networkingv1 "k8s.io/api/networking/v1" +) + +// Mutation defines a mutation that is applied to an ingress Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + ingressSpecEdits []func(*editors.IngressSpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes Ingress. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the Ingress 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. +// +// Apply order within each feature: +// 1. Object metadata edits +// 2. Ingress spec edits +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + ing *networkingv1.Ingress + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Ingress. +func NewMutator(ing *networkingv1.Ingress) *Mutator { + m := &Mutator{ing: ing} + 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 Ingress's own metadata. +// +// Metadata edits are applied before ingress spec 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) +} + +// EditIngressSpec records a mutation for the Ingress's spec via an IngressSpecEditor. +// +// The editor provides structured operations (SetIngressClassName, SetDefaultBackend, +// EnsureRule, RemoveRule, EnsureTLS, RemoveTLS) as well as Raw() for free-form access. +// Ingress spec edits are applied after metadata edits within the same feature, in +// registration order. +// +// A nil edit function is ignored. +func (m *Mutator) EditIngressSpec(edit func(*editors.IngressSpecEditor) error) { + if edit == nil { + return + } + m.active.ingressSpecEdits = append(m.active.ingressSpecEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying Ingress. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. Ingress spec edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the Ingress 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.ing.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Ingress spec edits + if len(plan.ingressSpecEdits) > 0 { + editor := editors.NewIngressSpecEditor(&m.ing.Spec) + for _, edit := range plan.ingressSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/ingress/mutator_test.go b/pkg/primitives/ingress/mutator_test.go new file mode 100644 index 00000000..2be2b93d --- /dev/null +++ b/pkg/primitives/ingress/mutator_test.go @@ -0,0 +1,188 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestIngress() *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ing", + Namespace: "default", + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", ing.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditObjectMetadata_Error(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return errors.New("metadata error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "metadata error") +} + +// --- EditIngressSpec --- + +func TestMutator_EditIngressSpec(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + require.NoError(t, m.Apply()) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + +func TestMutator_EditIngressSpec_Nil(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditIngressSpec_Error(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(func(_ *editors.IngressSpecEditor) error { + return errors.New("spec error") + }) + err := m.Apply() + require.Error(t, err) + assert.Contains(t, err.Error(), "spec error") +} + +func TestMutator_EditIngressSpec_EnsureRule(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, ing.Spec.Rules, 1) + assert.Equal(t, "example.com", ing.Spec.Rules[0].Host) +} + +func TestMutator_EditIngressSpec_EnsureTLS(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-secret", + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, ing.Spec.TLS, 1) + assert.Equal(t, "tls-secret", ing.Spec.TLS[0].SecretName) +} + +// --- Execution order --- + +func TestMutator_OperationOrder(t *testing.T) { + // Within a feature: metadata edits run before spec edits. + ing := newTestIngress() + m := NewMutator(ing) + + // Register in reverse logical order to confirm Apply() enforces category ordering. + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("order", "tested") + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "tested", ing.Labels["order"]) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + +func TestMutator_MultipleFeatures(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "feature1.com"}) + return nil + }) + m.beginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "feature2.com"}) + return nil + }) + require.NoError(t, m.Apply()) + + require.Len(t, ing.Spec.Rules, 2) + assert.Equal(t, "feature1.com", ing.Spec.Rules[0].Host) + assert.Equal(t, "feature2.com", ing.Spec.Rules[1].Host) +} + +func TestMutator_CombinedMutations(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "web") + e.EnsureAnnotation("description", "main ingress") + return nil + }) + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + + require.NoError(t, m.Apply()) + + assert.Equal(t, "web", ing.Labels["app"]) + assert.Equal(t, "main ingress", ing.Annotations["description"]) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) + require.Len(t, ing.Spec.Rules, 1) + require.Len(t, ing.Spec.TLS, 1) +} + +// --- ObjectMutator interface --- + +func TestMutator_ImplementsObjectMutator(_ *testing.T) { + var _ editors.ObjectMutator = (*Mutator)(nil) +} diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go new file mode 100644 index 00000000..988110cf --- /dev/null +++ b/pkg/primitives/ingress/resource.go @@ -0,0 +1,108 @@ +// Package ingress provides a builder and resource for managing Kubernetes Ingresses. +package ingress + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + networkingv1 "k8s.io/api/networking/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 Ingress resources. +// Ingress status (status.loadBalancer) is managed via the status subresource by +// the ingress controller; regular Update calls do not overwrite it on the server side. +func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { + *current = *desired.DeepCopy() + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes Ingress within +// a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.Operational: for tracking whether the ingress has been assigned an address. +// - component.Suspendable: for controlled suspension when the parent component is suspended. +// - component.DataExtractable: for exporting values after successful reconciliation. +// +// Ingress resources are integration primitives: they depend on an external ingress +// controller to assign load balancer addresses. The default operational status handler +// reports Pending until at least one IP or hostname is assigned, then Operational. +type Resource struct { + base *generic.IntegrationResource[*networkingv1.Ingress, *Mutator] +} + +// Identity returns a unique identifier for the Ingress in the format +// "networking.k8s.io/v1/Ingress//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes Ingress 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 Ingress 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. +// 4. Suspension mutation: if the component is suspended, the suspension mutation is applied. +// +// 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) +} + +// ConvergingStatus evaluates whether the Ingress has reached its operational state. +// +// By default, it uses DefaultOperationalStatusHandler, which reports Pending until +// the ingress controller has assigned at least one IP or hostname, then Operational. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the Ingress should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. Deleting +// an Ingress causes the ingress controller to reload its configuration, affecting +// the entire cluster's routing — not just the suspended service. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the suspension of the Ingress. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior is a no-op — the Ingress is left in place and the +// backend service returning 502/503 is the expected observable behaviour. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which immediately reports +// Suspended with a reason indicating the backend is unavailable. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled Ingress. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values (such as assigned load balancer +// addresses) from the Ingress. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From 6e2be93673a09fff587dc64eeabee701b56b3b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:44:15 +0000 Subject: [PATCH 03/22] Add documentation for the Ingress primitive Covers operational status, suspension strategy, mutation pipeline, editors, flavors, and guidance for common use cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/ingress.md | 284 +++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/primitives/ingress.md diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md new file mode 100644 index 00000000..3a64557e --- /dev/null +++ b/docs/primitives/ingress.md @@ -0,0 +1,284 @@ +# Ingress Primitive + +The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, and metadata. + +## Capabilities + +| Capability | Detail | +|-------------------------|------------------------------------------------------------------------------------------------| +| **Operational status** | Reports `Pending` until the ingress controller assigns an address, then `Operational` | +| **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | +| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | +| **Flavors** | Preserves externally-managed fields (labels, annotations) | + +## Building an Ingress Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" + +base := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-ingress", + Namespace: owner.Namespace, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, +} + +resource, err := ingress.NewBuilder(base). + WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: + +```go +func MyFeatureMutation(version string) ingress.Mutation { + return ingress.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *ingress.Mutator) error { + // record edits here + return nil + }, + } +} +``` + +Mutations are applied in the order they are registered with the builder. If one mutation depends on a change made by another, register the dependency first. + +### Boolean-gated mutations + +Use `When(bool)` to gate a mutation on a runtime condition: + +```go +func TLSMutation(version string, enabled bool) ingress.Mutation { + return ingress.Mutation{ + Name: "tls", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are applied in a fixed category order regardless of the order they are recorded: + +| Step | Category | What it affects | +|------|--------------------|---------------------------------------------------------| +| 1 | Metadata edits | Labels and annotations on the `Ingress` object | +| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor | + +Within each category, edits are applied in their registration order. Later features observe the Ingress as modified by all previous features. + +## Editors + +### IngressSpecEditor + +The primary API for modifying the Ingress spec. Use `m.EditIngressSpec` for full control: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil +}) +``` + +#### SetIngressClassName + +Sets the `spec.ingressClassName` field. + +#### SetDefaultBackend + +Sets the default backend for traffic that does not match any rule. + +#### EnsureRule and RemoveRule + +`EnsureRule` upserts a rule by `Host` — if a rule with the same host already exists, it is replaced. `RemoveRule` deletes the rule with the given host; it is a no-op if no matching rule exists. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{ + Host: "api.example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/v1", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "api-svc", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }, + }, + }, + }, + }) + e.RemoveRule("deprecated.example.com") + return nil +}) +``` + +#### EnsureTLS and RemoveTLS + +`EnsureTLS` upserts a TLS entry by the first host in the `Hosts` slice. `RemoveTLS` removes TLS entries whose first host matches any of the provided hosts. + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com", "www.example.com"}, + SecretName: "wildcard-tls", + }) + e.RemoveTLS("old.example.com") + return nil +}) +``` + +#### Raw Escape Hatch + +`Raw()` returns the underlying `*networkingv1.IngressSpec` for direct access when the typed API is insufficient: + +```go +m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + spec := e.Raw() + // direct manipulation + 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.EnsureAnnotation("nginx.ingress.kubernetes.io/rewrite-target", "/") + return nil +}) +``` + +## Operational Status + +The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of `concepts.Alive`. + +### DefaultOperationalStatusHandler + +| Condition | Status | Reason | +|--------------------------------------------|---------------|-------------------------------------------| +| `len(Status.LoadBalancer.Ingress) > 0` | `Operational` | Ingress has been assigned an address | +| Otherwise | `Pending` | Awaiting load balancer address assignment | + +The handler does not distinguish between IP and hostname assignments — any non-empty `Status.LoadBalancer.Ingress` slice is considered operational. + +Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by cloud providers). + +## Suspension + +### Default Behaviour + +The default suspension strategy is a **no-op**: + +- `DefaultDeleteOnSuspendHandler` returns `false` — the Ingress is not deleted. +- `DefaultSuspendMutationHandler` does nothing — the Ingress spec is not modified. +- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason `"Ingress suspended (backend unavailable)"`. + +**Rationale**: deleting an Ingress causes the ingress controller (e.g. nginx) to reload its configuration, which affects the entire cluster's routing — not just the suspended service. When the backend service is suspended, the Ingress returning 502/503 is the correct observable behaviour. + +### Custom Suspension + +Override any of the suspension handlers via the builder: + +```go +resource, err := ingress.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *networkingv1.Ingress) bool { + return true // delete on suspend + }). + Build() +``` + +## 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 := ingress.NewBuilder(base). + WithFieldApplicationFlavor(ingress.PreserveCurrentLabels). + Build() +``` + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +This is particularly useful for Ingress resources, where ingress controllers and cert-manager often manage annotations: + +```go +resource, err := ingress.NewBuilder(base). + WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations). + Build() +``` + +Multiple flavors can be registered and run in registration order. + +## 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 `PreserveCurrentAnnotations` when sharing an Ingress.** Ingress controllers, cert-manager, and external-dns frequently manage annotations. This flavor prevents your operator from silently deleting those annotations each reconcile cycle. + +**Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. + +**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension. From e357cafefca24be7b55bf36166ff02ff95376520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:50:48 +0000 Subject: [PATCH 04/22] Add Ingress primitive example Demonstrates base construction, feature mutations (version annotation, TLS toggle), field flavors, and data extraction using the ingress builder and mutator. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/ingress-primitive/README.md | 31 +++++ examples/ingress-primitive/app/controller.go | 54 ++++++++ examples/ingress-primitive/app/owner.go | 20 +++ .../ingress-primitive/features/mutations.go | 45 +++++++ examples/ingress-primitive/main.go | 118 ++++++++++++++++++ .../ingress-primitive/resources/ingress.go | 80 ++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 examples/ingress-primitive/README.md create mode 100644 examples/ingress-primitive/app/controller.go create mode 100644 examples/ingress-primitive/app/owner.go create mode 100644 examples/ingress-primitive/features/mutations.go create mode 100644 examples/ingress-primitive/main.go create mode 100644 examples/ingress-primitive/resources/ingress.go diff --git a/examples/ingress-primitive/README.md b/examples/ingress-primitive/README.md new file mode 100644 index 00000000..659656d1 --- /dev/null +++ b/examples/ingress-primitive/README.md @@ -0,0 +1,31 @@ +# Ingress Primitive Example + +This example demonstrates the usage of the `ingress` primitive within the operator component framework. +It shows how to manage a Kubernetes Ingress as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing an Ingress with rules, TLS, and ingress class. +- **Feature Mutations**: Applying conditional changes (TLS configuration, version annotations) using the `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., cert-manager, ingress controllers). +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: version annotation and TLS configuration mutations. +- `resources/`: Contains the central `NewIngressResource` factory that assembles all features using the `ingress.Builder`. +- `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/ingress-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through several spec changes (version upgrade, TLS toggle, suspension). +4. Print the resulting status conditions and Ingress resource state. diff --git a/examples/ingress-primitive/app/controller.go b/examples/ingress-primitive/app/controller.go new file mode 100644 index 00000000..c3c53c96 --- /dev/null +++ b/examples/ingress-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the ingress primitive. +package app + +import ( + "context" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ExampleController reconciles an ExampleApp object using the component framework. +type ExampleController struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Metrics component.Recorder + + // NewIngressResource is a factory function to create the ingress resource. + // This allows us to inject the resource construction logic. + NewIngressResource func(*ExampleApp) (component.Resource, error) +} + +// Reconcile performs the reconciliation for a single ExampleApp. +func (r *ExampleController) Reconcile(ctx context.Context, owner *ExampleApp) error { + // 1. Build the ingress resource for this owner. + ingressResource, err := r.NewIngressResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the ingress. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(ingressResource, component.ResourceOptions{}). + Suspend(owner.Spec.Suspended). + Build() + if err != nil { + return err + } + + // 3. Execute the component reconciliation. + resCtx := component.ReconcileContext{ + Client: r.Client, + Scheme: r.Scheme, + Recorder: r.Recorder, + Metrics: r.Metrics, + Owner: owner, + } + + return comp.Reconcile(ctx, resCtx) +} diff --git a/examples/ingress-primitive/app/owner.go b/examples/ingress-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/ingress-primitive/app/owner.go @@ -0,0 +1,20 @@ +package app + +import ( + sharedapp "github.com/sourcehawk/operator-component-framework/examples/shared/app" +) + +// ExampleApp re-exports the shared CRD type so callers in this package need no import alias. +type ExampleApp = sharedapp.ExampleApp + +// ExampleAppSpec re-exports the shared spec type. +type ExampleAppSpec = sharedapp.ExampleAppSpec + +// ExampleAppStatus re-exports the shared status type. +type ExampleAppStatus = sharedapp.ExampleAppStatus + +// ExampleAppList re-exports the shared list type. +type ExampleAppList = sharedapp.ExampleAppList + +// AddToScheme registers the ExampleApp types with the given scheme. +var AddToScheme = sharedapp.AddToScheme diff --git a/examples/ingress-primitive/features/mutations.go b/examples/ingress-primitive/features/mutations.go new file mode 100644 index 00000000..a4a84642 --- /dev/null +++ b/examples/ingress-primitive/features/mutations.go @@ -0,0 +1,45 @@ +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/ingress" + networkingv1 "k8s.io/api/networking/v1" +) + +// VersionAnnotation sets a version annotation on the Ingress metadata. +func VersionAnnotation(version string) ingress.Mutation { + return ingress.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *ingress.Mutator) error { + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("app.kubernetes.io/version", version) + return nil + }) + return nil + }, + } +} + +// TLSFeature adds a TLS entry when enabled. +func TLSFeature(enabled bool, appName string) ingress.Mutation { + return ingress.Mutation{ + Name: "TLS", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *ingress.Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{appName + ".example.com"}, + SecretName: appName + "-tls", + }) + return nil + }) + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureAnnotation("cert-manager.io/cluster-issuer", "letsencrypt-prod") + return nil + }) + return nil + }, + } +} diff --git a/examples/ingress-primitive/main.go b/examples/ingress-primitive/main.go new file mode 100644 index 00000000..bb35a72c --- /dev/null +++ b/examples/ingress-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the ingress 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/ingress-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/resources" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client for the example. + scheme := runtime.NewScheme() + if err := app.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add to scheme: %v\n", err) + os.Exit(1) + } + if err := networkingv1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add networking/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, // Used as TLS toggle in this example + EnableMetrics: false, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the ingress resource factory. + NewIngressResource: resources.NewIngressResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, // TLS enabled + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable TLS + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, + Suspended: true, // Suspend the app + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, TLS=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableTracing, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/ingress-primitive/resources/ingress.go b/examples/ingress-primitive/resources/ingress.go new file mode 100644 index 00000000..0d3d44f9 --- /dev/null +++ b/examples/ingress-primitive/resources/ingress.go @@ -0,0 +1,80 @@ +// Package resources provides resource implementations for the ingress primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/ingress-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/ingress" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" +) + +// NewIngressResource constructs an ingress primitive resource with all the features. +func NewIngressResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base ingress object. + base := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-ingress", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: owner.Name + ".example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: owner.Name + "-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + // 2. Initialize the ingress builder. + builder := ingress.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionAnnotation(owner.Spec.Version)) + builder.WithMutation(features.TLSFeature(owner.Spec.EnableTracing, owner.Name)) + + // 4. Configure flavors. + builder.WithFieldApplicationFlavor(ingress.PreserveCurrentLabels) + builder.WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations) + + // 5. Data extraction. + builder.WithDataExtractor(func(ing networkingv1.Ingress) error { + fmt.Printf("Reconciling ingress: %s\n", ing.Name) + + y, err := yaml.Marshal(ing) + if err != nil { + return fmt.Errorf("failed to marshal ingress to yaml: %w", err) + } + fmt.Printf("Complete Ingress Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From 9f796d7349f2de4e6c9accca5dfd2d9b949e97b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:53:15 +0000 Subject: [PATCH 05/22] Add package comment to features package for lint compliance Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/ingress-primitive/features/mutations.go | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/ingress-primitive/features/mutations.go b/examples/ingress-primitive/features/mutations.go index a4a84642..84c86326 100644 --- a/examples/ingress-primitive/features/mutations.go +++ b/examples/ingress-primitive/features/mutations.go @@ -1,3 +1,4 @@ +// Package features provides modular feature mutations for the ingress primitive example. package features import ( From b71f887f55ae99d1ff77a178333df1b1c79880ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:31:19 +0000 Subject: [PATCH 06/22] Fix operational status handler to validate LB entry fields Address Copilot review: DefaultOperationalStatusHandler now checks that at least one LoadBalancer.Ingress entry has a non-empty IP or Hostname, rather than only checking slice length. This prevents marking an Ingress as Operational when entries exist but have no assigned address. Update docs and add test for the empty-entry edge case. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/ingress.md | 4 ++-- pkg/primitives/ingress/handlers.go | 12 +++++++----- pkg/primitives/ingress/handlers_test.go | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md index 3a64557e..b5f28b2d 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -214,10 +214,10 @@ The Ingress primitive uses the **Integration** lifecycle, which implements `conc | Condition | Status | Reason | |--------------------------------------------|---------------|-------------------------------------------| -| `len(Status.LoadBalancer.Ingress) > 0` | `Operational` | Ingress has been assigned an address | +| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | | Otherwise | `Pending` | Awaiting load balancer address assignment | -The handler does not distinguish between IP and hostname assignments — any non-empty `Status.LoadBalancer.Ingress` slice is considered operational. +The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or `Hostname` to report operational. Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by cloud providers). diff --git a/pkg/primitives/ingress/handlers.go b/pkg/primitives/ingress/handlers.go index 86e71d9e..433df000 100644 --- a/pkg/primitives/ingress/handlers.go +++ b/pkg/primitives/ingress/handlers.go @@ -16,11 +16,13 @@ import ( func DefaultOperationalStatusHandler( _ concepts.ConvergingOperation, ing *networkingv1.Ingress, ) (concepts.OperationalStatusWithReason, error) { - if len(ing.Status.LoadBalancer.Ingress) > 0 { - return concepts.OperationalStatusWithReason{ - Status: concepts.OperationalStatusOperational, - Reason: "Ingress has been assigned an address", - }, nil + for _, lb := range ing.Status.LoadBalancer.Ingress { + if lb.IP != "" || lb.Hostname != "" { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: "Ingress has been assigned an address", + }, nil + } } return concepts.OperationalStatusWithReason{ diff --git a/pkg/primitives/ingress/handlers_test.go b/pkg/primitives/ingress/handlers_test.go index 3a5881bd..b8a9fe5b 100644 --- a/pkg/primitives/ingress/handlers_test.go +++ b/pkg/primitives/ingress/handlers_test.go @@ -40,6 +40,21 @@ func TestDefaultOperationalStatusHandler(t *testing.T) { wantStatus: concepts.OperationalStatusPending, wantReason: "Awaiting load balancer address assignment", }, + { + name: "pending with non-empty slice but empty entries", + op: concepts.ConvergingOperationCreated, + ingress: &networkingv1.Ingress{ + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {}, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + wantReason: "Awaiting load balancer address assignment", + }, { name: "operational with IP assigned", op: concepts.ConvergingOperationCreated, From 6cfb139eb973e75087348040a563562c926f3499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:31:14 +0000 Subject: [PATCH 07/22] Address review feedback for Ingress primitive - Fix doc comments to reference concepts.Operational/Suspendable/DataExtractable instead of component.* for lifecycle interfaces - Use correct OperationPending status name in docs and code comments - Add ingress entry to built-in primitives table in docs/primitives.md - Add IngressSpecEditor to mutation editors table in docs/primitives.md - Add cross-reference link to primitives overview in ingress doc - Add resource_test.go with tests for Identity, Object deep-copy, Mutate, mutations, feature ordering, custom applicator, converging status, suspension, and data extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 6 +- docs/primitives/ingress.md | 6 +- pkg/primitives/ingress/resource.go | 12 +- pkg/primitives/ingress/resource_test.go | 249 ++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 11 deletions(-) create mode 100644 pkg/primitives/ingress/resource_test.go diff --git a/docs/primitives.md b/docs/primitives.md index 13fde6a6..11ecd1c7 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 | +| `IngressSpecEditor` | Ingress class, default backend, rules, TLS configuration | | `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. @@ -128,8 +129,9 @@ Selectors are evaluated against the container list *after* any presence operatio | Primitive | Category | Documentation | |--------------------------------------|------------|-----------------------------------------| -| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | -| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | +| `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/ingress` | Integration | [ingress.md](primitives/ingress.md) | ## Usage Examples diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md index b5f28b2d..d6ee5e04 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -1,12 +1,12 @@ # Ingress Primitive -The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, and metadata. +The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, and metadata. For an overview of all built-in primitives, see [Primitives](../primitives.md). ## Capabilities | Capability | Detail | |-------------------------|------------------------------------------------------------------------------------------------| -| **Operational status** | Reports `Pending` until the ingress controller assigns an address, then `Operational` | +| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | | **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | | **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | | **Flavors** | Preserves externally-managed fields (labels, annotations) | @@ -215,7 +215,7 @@ The Ingress primitive uses the **Integration** lifecycle, which implements `conc | Condition | Status | Reason | |--------------------------------------------|---------------|-------------------------------------------| | Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | -| Otherwise | `Pending` | Awaiting load balancer address assignment | +| Otherwise | `OperationPending` | Awaiting load balancer address assignment | The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or `Hostname` to report operational. diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index 988110cf..885e3b34 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -21,11 +21,11 @@ func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { // Resource is a high-level abstraction for managing a Kubernetes Ingress within // a controller's reconciliation loop. // -// It implements the following component interfaces: +// It implements the following component lifecycle interfaces: // - component.Resource: for basic identity and mutation behaviour. -// - component.Operational: for tracking whether the ingress has been assigned an address. -// - component.Suspendable: for controlled suspension when the parent component is suspended. -// - component.DataExtractable: for exporting values after successful reconciliation. +// - concepts.Operational: for tracking whether the ingress has been assigned an address. +// - concepts.Suspendable: for controlled suspension when the parent component is suspended. +// - concepts.DataExtractable: for exporting values after successful reconciliation. // // Ingress resources are integration primitives: they depend on an external ingress // controller to assign load balancer addresses. The default operational status handler @@ -64,8 +64,8 @@ func (r *Resource) Mutate(current client.Object) error { // ConvergingStatus evaluates whether the Ingress has reached its operational state. // -// By default, it uses DefaultOperationalStatusHandler, which reports Pending until -// the ingress controller has assigned at least one IP or hostname, then Operational. +// By default, it uses DefaultOperationalStatusHandler, which reports OperationPending +// until the ingress controller has assigned at least one IP or hostname, then Operational. func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { return r.base.ConvergingStatus(op) } diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go new file mode 100644 index 00000000..851d3044 --- /dev/null +++ b/pkg/primitives/ingress/resource_test.go @@ -0,0 +1,249 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newValidIngress() *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ingress", + Namespace: "test-ns", + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + Rules: []networkingv1.IngressRule{ + { + Host: "example.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "web-svc", + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + assert.Equal(t, "networking.k8s.io/v1/Ingress/test-ns/test-ingress", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + ing := newValidIngress() + res, err := NewBuilder(ing).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*networkingv1.Ingress) + require.True(t, ok) + assert.Equal(t, ing.Name, got.Name) + assert.Equal(t, ing.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-ingress", ing.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidIngress() + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + current := &networkingv1.Ingress{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) + require.Len(t, current.Spec.Rules, 1) + assert.Equal(t, "example.com", current.Spec.Rules[0].Host) +} + +func TestResource_Mutate_WithMutation(t *testing.T) { + desired := newValidIngress() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-tls", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureTLS(networkingv1.IngressTLS{ + Hosts: []string{"example.com"}, + SecretName: "tls-cert", + }) + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &networkingv1.Ingress{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) + require.Len(t, current.Spec.TLS, 1) + assert.Equal(t, "tls-cert", current.Spec.TLS[0].SecretName) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidIngress() + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("traefik") + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("haproxy") + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &networkingv1.Ingress{} + require.NoError(t, res.Mutate(current)) + + // Last mutation wins. + assert.Equal(t, ptr.To("haproxy"), current.Spec.IngressClassName) +} + +func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { + desired := newValidIngress() + + applicatorCalled := false + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(current, d *networkingv1.Ingress) error { + applicatorCalled = true + current.Spec.IngressClassName = d.Spec.IngressClassName + return nil + }). + Build() + require.NoError(t, err) + + current := &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{Host: "preserved.com"}}, + }, + } + require.NoError(t, res.Mutate(current)) + + assert.True(t, applicatorCalled) + assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) + // Custom applicator only copied className, so rules should be preserved. + require.Len(t, current.Spec.Rules, 1) + assert.Equal(t, "preserved.com", current.Spec.Rules[0].Host) +} + +func TestResource_Mutate_CustomFieldApplicator_Error(t *testing.T) { + res, err := NewBuilder(newValidIngress()). + WithCustomFieldApplicator(func(_, _ *networkingv1.Ingress) error { + return errors.New("applicator error") + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&networkingv1.Ingress{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") +} + +func TestResource_ConvergingStatus_Pending(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationCreated) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusPending, status.Status) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + assert.False(t, res.DeleteOnSuspend()) +} + +func TestResource_Suspend(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + require.NoError(t, res.Suspend()) +} + +func TestResource_SuspensionStatus(t *testing.T) { + res, err := NewBuilder(newValidIngress()).Build() + require.NoError(t, err) + + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) +} + +func TestResource_ExtractData(t *testing.T) { + ing := newValidIngress() + + var extracted string + res, err := NewBuilder(ing). + WithDataExtractor(func(i networkingv1.Ingress) error { + extracted = *i.Spec.IngressClassName + return nil + }). + Build() + require.NoError(t, err) + + require.NoError(t, res.ExtractData()) + assert.Equal(t, "nginx", extracted) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidIngress()). + WithDataExtractor(func(_ networkingv1.Ingress) 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 38997d88a995bf04b622d10dfea57e934d2d4a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:49:56 +0000 Subject: [PATCH 08/22] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/resource.go | 11 ++++--- pkg/primitives/ingress/resource_test.go | 43 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index 885e3b34..d58a880f 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -8,13 +8,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 Ingress resources. -// Ingress status (status.loadBalancer) is managed via the status subresource by -// the ingress controller; regular Update calls do not overwrite it on the server side. +// 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 *networkingv1.Ingress) error { + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go index 851d3044..d427427a 100644 --- a/pkg/primitives/ingress/resource_test.go +++ b/pkg/primitives/ingress/resource_test.go @@ -147,6 +147,49 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { assert.Equal(t, ptr.To("haproxy"), current.Spec.IngressClassName) } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &networkingv1.Ingress{ + 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 := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) + 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_Mutate_CustomFieldApplicator(t *testing.T) { desired := newValidIngress() From 788752aef0ab8e005bffe6e02854d2a37f49865e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:31:14 +0000 Subject: [PATCH 09/22] Address review feedback for Ingress primitive - Fix doc comments to reference concepts.Operational/Suspendable/DataExtractable instead of component.* for lifecycle interfaces - Use correct OperationPending status name in docs and code comments - Add ingress entry to built-in primitives table in docs/primitives.md - Add IngressSpecEditor to mutation editors table in docs/primitives.md - Add cross-reference link to primitives overview in ingress doc - Add resource_test.go with tests for Identity, Object deep-copy, Mutate, mutations, feature ordering, custom applicator, converging status, suspension, and data extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/resource_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go index d427427a..7cde44c7 100644 --- a/pkg/primitives/ingress/resource_test.go +++ b/pkg/primitives/ingress/resource_test.go @@ -190,6 +190,7 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } + func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { desired := newValidIngress() From 5b2badb1def4d5643beb8c5788316e84cedf071c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:49:56 +0000 Subject: [PATCH 10/22] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/resource_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go index 7cde44c7..d427427a 100644 --- a/pkg/primitives/ingress/resource_test.go +++ b/pkg/primitives/ingress/resource_test.go @@ -190,7 +190,6 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } - func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { desired := newValidIngress() From edb8e4f1a6c9ff6f95c5b8db2e4b6d54b7c01ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:24:34 +0000 Subject: [PATCH 11/22] Preserve live Status in DefaultFieldApplicator and add test coverage The DefaultFieldApplicator was replacing the entire object with the desired state, which wiped ingress-controller-owned fields like Status.LoadBalancer.Ingress. This caused DefaultOperationalStatusHandler to never see assigned addresses, keeping the resource permanently pending. Save and restore the live Status before overwriting with the desired spec so that readiness detection works correctly. Also adds test coverage for: - Status.LoadBalancer preservation through DefaultFieldApplicator - DefaultOperationalStatusHandler returning Operational with IP - DefaultOperationalStatusHandler returning Operational with Hostname Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/resource.go | 5 ++ pkg/primitives/ingress/resource_test.go | 68 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index d58a880f..8e2fe2d4 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -12,9 +12,14 @@ import ( // preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) // and shared-controller fields (OwnerReferences, Finalizers) from the original // current object. +// +// The live Status field is preserved so that ingress-controller-owned fields +// such as Status.LoadBalancer.Ingress are not cleared during reconciliation. func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { original := current.DeepCopy() + savedStatus := *current.Status.DeepCopy() *current = *desired.DeepCopy() + current.Status = savedStatus generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go index d427427a..a71dcd27 100644 --- a/pkg/primitives/ingress/resource_test.go +++ b/pkg/primitives/ingress/resource_test.go @@ -190,6 +190,74 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + current := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "12345", + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + {Hostname: "lb.example.com"}, + }, + }, + }, + } + desired := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: ptr.To("nginx"), + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec is applied + assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) + + // Status is preserved from the live object + require.Len(t, current.Status.LoadBalancer.Ingress, 2) + assert.Equal(t, "10.0.0.1", current.Status.LoadBalancer.Ingress[0].IP) + assert.Equal(t, "lb.example.com", current.Status.LoadBalancer.Ingress[1].Hostname) +} + +func TestDefaultOperationalStatusHandler_Operational(t *testing.T) { + ing := newValidIngress() + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "10.0.0.1"}, + }, + }, + } + + status, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationUpdated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) +} + +func TestDefaultOperationalStatusHandler_OperationalWithHostname(t *testing.T) { + ing := newValidIngress() + ing.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {Hostname: "lb.example.com"}, + }, + }, + } + + status, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationUpdated, ing) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) +} + func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { desired := newValidIngress() From d82938405a88bd111bb63b805d3d159a000cac43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:08:13 +0000 Subject: [PATCH 12/22] Use generic PreserveStatus in ingress DefaultFieldApplicator Replace manual status save/restore with generic.PreserveStatus to match the deployment primitive pattern. Add Default Field Application section to ingress primitive docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/ingress.md | 6 ++++++ pkg/primitives/ingress/resource.go | 12 ++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md index d6ee5e04..88d18509 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -53,6 +53,12 @@ resource, err := ingress.NewBuilder(base). Build() ``` +## Default Field Application + +`DefaultFieldApplicator` replaces the current Ingress with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the ingress controller (e.g. `Status.LoadBalancer.Ingress` addresses). + +Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. + ## Mutations Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index 8e2fe2d4..0f9f25d7 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -9,18 +9,14 @@ import ( ) // DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. -// -// The live Status field is preserved so that ingress-controller-owned fields -// such as Status.LoadBalancer.Ingress are not cleared during reconciliation. +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), +// shared-controller fields (OwnerReferences, Finalizers), and the Status +// subresource from the original current object. func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { original := current.DeepCopy() - savedStatus := *current.Status.DeepCopy() *current = *desired.DeepCopy() - current.Status = savedStatus generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) return nil } From f68ec34f5c2e09209f473ef3bb68b2227248e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:05 +0000 Subject: [PATCH 13/22] Remove beginFeature() call from ingress Mutator constructor Initialize plans slice and active pointer directly in the constructor, matching the fix already applied to deployment and configmap mutators. This prevents an empty feature from being created when the generic helper in mutator_helper.go calls beginFeature(). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/mutator.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go index f0bb4487..6de1f73f 100644 --- a/pkg/primitives/ingress/mutator.go +++ b/pkg/primitives/ingress/mutator.go @@ -37,8 +37,11 @@ type Mutator struct { // NewMutator creates a new Mutator for the given Ingress. func NewMutator(ing *networkingv1.Ingress) *Mutator { - m := &Mutator{ing: ing} - m.beginFeature() + m := &Mutator{ + ing: ing, + plans: []featurePlan{{}}, + } + m.active = &m.plans[0] return m } From 3964dcfff1c324fe39db407d69ba0650d74e0a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:33:46 +0000 Subject: [PATCH 14/22] Export BeginFeature() to satisfy updated FeatureMutator interface Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/mutator.go | 4 ++-- pkg/primitives/ingress/mutator_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go index 6de1f73f..71abd494 100644 --- a/pkg/primitives/ingress/mutator.go +++ b/pkg/primitives/ingress/mutator.go @@ -45,9 +45,9 @@ func NewMutator(ing *networkingv1.Ingress) *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/ingress/mutator_test.go b/pkg/primitives/ingress/mutator_test.go index 2be2b93d..6a214fda 100644 --- a/pkg/primitives/ingress/mutator_test.go +++ b/pkg/primitives/ingress/mutator_test.go @@ -140,7 +140,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { e.EnsureRule(networkingv1.IngressRule{Host: "feature1.com"}) return nil }) - m.beginFeature() + m.BeginFeature() m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureRule(networkingv1.IngressRule{Host: "feature2.com"}) return nil From 373c591547e7bd5735aeeb925f8d56c6bbfb7440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:27 +0000 Subject: [PATCH 15/22] Format markdown files with prettier Run make fmt-md to apply consistent markdown formatting to ingress documentation and example README. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/ingress.md | 91 ++++++++++++++++++---------- examples/ingress-primitive/README.md | 13 ++-- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md index 88d18509..b6a5e5ac 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -1,15 +1,17 @@ # Ingress Primitive -The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, and metadata. For an overview of all built-in primitives, see [Primitives](../primitives.md). +The `ingress` primitive is the framework's built-in integration abstraction for managing Kubernetes `Ingress` resources. +It integrates with the component lifecycle and provides a structured mutation API for managing rules, TLS configuration, +and metadata. For an overview of all built-in primitives, see [Primitives](../primitives.md). ## Capabilities -| Capability | Detail | -|-------------------------|------------------------------------------------------------------------------------------------| -| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | -| **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | -| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | -| **Flavors** | Preserves externally-managed fields (labels, annotations) | +| Capability | Detail | +| ---------------------- | ---------------------------------------------------------------------------------------------- | +| **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | +| **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | +| **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | +| **Flavors** | Preserves externally-managed fields (labels, annotations) | ## Building an Ingress Primitive @@ -55,15 +57,20 @@ resource, err := ingress.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current Ingress with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the ingress controller (e.g. `Status.LoadBalancer.Ingress` addresses). +`DefaultFieldApplicator` replaces the current Ingress with a deep copy of the desired object, then restores +server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the +Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data +written by the ingress controller (e.g. `Status.LoadBalancer.Ingress` addresses). Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. ## Mutations -Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function +that receives a `*Mutator` and records edit intent through typed editors. -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) ingress.Mutation { @@ -78,7 +85,8 @@ func MyFeatureMutation(version string) ingress.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 @@ -107,14 +115,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 `Ingress` object | -| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor | +| Step | Category | What it affects | +| ---- | ------------------ | ----------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `Ingress` object | +| 2 | Ingress spec edits | Ingress class, default backend, rules, TLS via editor | -Within each category, edits are applied in their registration order. Later features observe the Ingress as modified by all previous features. +Within each category, edits are applied in their registration order. Later features observe the Ingress as modified by +all previous features. ## Editors @@ -144,7 +154,8 @@ Sets the default backend for traffic that does not match any rule. #### EnsureRule and RemoveRule -`EnsureRule` upserts a rule by `Host` — if a rule with the same host already exists, it is replaced. `RemoveRule` deletes the rule with the given host; it is a no-op if no matching rule exists. +`EnsureRule` upserts a rule by `Host` — if a rule with the same host already exists, it is replaced. `RemoveRule` +deletes the rule with the given host; it is a no-op if no matching rule exists. ```go m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { @@ -174,7 +185,8 @@ m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { #### EnsureTLS and RemoveTLS -`EnsureTLS` upserts a TLS entry by the first host in the `Hosts` slice. `RemoveTLS` removes TLS entries whose first host matches any of the provided hosts. +`EnsureTLS` upserts a TLS entry by the first host in the `Hosts` slice. `RemoveTLS` removes TLS entries whose first host +matches any of the provided hosts. ```go m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { @@ -214,18 +226,21 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ## Operational Status -The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of `concepts.Alive`. +The Ingress primitive uses the **Integration** lifecycle, which implements `concepts.Operational` instead of +`concepts.Alive`. ### DefaultOperationalStatusHandler -| Condition | Status | Reason | -|--------------------------------------------|---------------|-------------------------------------------| -| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | -| Otherwise | `OperationPending` | Awaiting load balancer address assignment | +| Condition | Status | Reason | +| ----------------------------------------- | ------------------ | ----------------------------------------- | +| Entry with `IP != ""` or `Hostname != ""` | `Operational` | Ingress has been assigned an address | +| Otherwise | `OperationPending` | Awaiting load balancer address assignment | -The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or `Hostname` to report operational. +The handler iterates over `Status.LoadBalancer.Ingress` entries and requires at least one with a non-empty `IP` or +`Hostname` to report operational. -Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by cloud providers). +Override with `WithCustomOperationalStatus` for more complex health checks (e.g. verifying specific annotations set by +cloud providers). ## Suspension @@ -235,9 +250,12 @@ The default suspension strategy is a **no-op**: - `DefaultDeleteOnSuspendHandler` returns `false` — the Ingress is not deleted. - `DefaultSuspendMutationHandler` does nothing — the Ingress spec is not modified. -- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason `"Ingress suspended (backend unavailable)"`. +- `DefaultSuspensionStatusHandler` immediately reports `Suspended` with reason + `"Ingress suspended (backend unavailable)"`. -**Rationale**: deleting an Ingress causes the ingress controller (e.g. nginx) to reload its configuration, which affects the entire cluster's routing — not just the suspended service. When the backend service is suspended, the Ingress returning 502/503 is the correct observable behaviour. +**Rationale**: deleting an Ingress causes the ingress controller (e.g. nginx) to reload its configuration, which affects +the entire cluster's routing — not just the suspended service. When the backend service is suspended, the Ingress +returning 502/503 is the correct observable behaviour. ### Custom Suspension @@ -253,7 +271,8 @@ resource, err := ingress.NewBuilder(base). ## 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 @@ -267,7 +286,8 @@ resource, err := ingress.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. This is particularly useful for Ingress resources, where ingress controllers and cert-manager often manage annotations: @@ -281,10 +301,15 @@ Multiple flavors can be registered and run in registration order. ## 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 `PreserveCurrentAnnotations` when sharing an Ingress.** Ingress controllers, cert-manager, and external-dns frequently manage annotations. This flavor prevents your operator from silently deleting those annotations each reconcile cycle. +**Use `PreserveCurrentAnnotations` when sharing an Ingress.** Ingress controllers, cert-manager, and external-dns +frequently manage annotations. This flavor prevents your operator from silently deleting those annotations each +reconcile cycle. **Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. -**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension. +**Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override +to delete-on-suspend if your use case specifically requires removing the Ingress from the cluster during suspension. diff --git a/examples/ingress-primitive/README.md b/examples/ingress-primitive/README.md index 659656d1..e5d87535 100644 --- a/examples/ingress-primitive/README.md +++ b/examples/ingress-primitive/README.md @@ -1,19 +1,21 @@ # Ingress Primitive Example -This example demonstrates the usage of the `ingress` primitive within the operator component framework. -It shows how to manage a Kubernetes Ingress as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `ingress` primitive within the operator component framework. It shows how to +manage a Kubernetes Ingress as a component of a larger application, utilizing features like: - **Base Construction**: Initializing an Ingress with rules, TLS, and ingress class. - **Feature Mutations**: Applying conditional changes (TLS configuration, version annotations) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., cert-manager, ingress controllers). +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., cert-manager, + ingress controllers). - **Data Extraction**: Harvesting information from the reconciled resource. ## Directory Structure - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: version annotation and TLS configuration mutations. -- `resources/`: Contains the central `NewIngressResource` factory that assembles all features using the `ingress.Builder`. + - `mutations.go`: version annotation and TLS configuration mutations. +- `resources/`: Contains the central `NewIngressResource` factory that assembles all features using the + `ingress.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. ## Running the Example @@ -25,6 +27,7 @@ go run examples/ingress-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile the `ExampleApp` components through several spec changes (version upgrade, TLS toggle, suspension). From 5e6242c425707672c2d5ac21249405f08c3bec2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:33:07 +0000 Subject: [PATCH 16/22] Assert Build() errors in flavors_test.go to prevent silent failures Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/flavors_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/ingress/flavors_test.go b/pkg/primitives/ingress/flavors_test.go index 127ee173..e753c5cc 100644 --- a/pkg/primitives/ingress/flavors_test.go +++ b/pkg/primitives/ingress/flavors_test.go @@ -28,9 +28,10 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, buildErr := NewBuilder(desired). WithFieldApplicationFlavor(PreserveCurrentLabels). Build() + require.NoError(t, buildErr) err := res.Mutate(current) require.NoError(t, err) @@ -57,10 +58,11 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return nil } - res, _ := NewBuilder(desired). + res, buildErr := NewBuilder(desired). WithFieldApplicationFlavor(flavor1). WithFieldApplicationFlavor(flavor2). Build() + require.NoError(t, buildErr) err := res.Mutate(current) require.NoError(t, err) @@ -74,9 +76,10 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return flavorErr } - res, _ := NewBuilder(desired). + res, buildErr := NewBuilder(desired). WithFieldApplicationFlavor(flavor). Build() + require.NoError(t, buildErr) err := res.Mutate(current) require.Error(t, err) From fe55cdf72cab07ff520fd7f28d0b8bb87023bb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:08:03 +0000 Subject: [PATCH 17/22] Update operational status terminology in Resource doc comment Use framework-specific OperationPending (concepts.OperationalStatusPending) terminology instead of generic "Pending" to avoid confusion with other lifecycle states. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/resource.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index 0f9f25d7..903c7233 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -31,7 +31,8 @@ func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { // // Ingress resources are integration primitives: they depend on an external ingress // controller to assign load balancer addresses. The default operational status handler -// reports Pending until at least one IP or hostname is assigned, then Operational. +// reports OperationPending (concepts.OperationalStatusPending) until at least one IP or +// hostname is assigned, then Operational. type Resource struct { base *generic.IntegrationResource[*networkingv1.Ingress, *Mutator] } From 32e1fce904e8fe57bf97e8b69d391754c65012d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:56:15 +0000 Subject: [PATCH 18/22] Do not initialize an empty plan on ingress mutator construction Align the ingress Mutator with the configmap and deployment primitives: NewMutator no longer creates an initial feature plan. BeginFeature must be called before registering any mutations. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/handlers_test.go | 1 + pkg/primitives/ingress/mutator.go | 8 ++- pkg/primitives/ingress/mutator_test.go | 69 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/ingress/handlers_test.go b/pkg/primitives/ingress/handlers_test.go index b8a9fe5b..3430d524 100644 --- a/pkg/primitives/ingress/handlers_test.go +++ b/pkg/primitives/ingress/handlers_test.go @@ -127,6 +127,7 @@ func TestDefaultSuspendMutationHandler(t *testing.T) { }, } mutator := NewMutator(ing) + mutator.BeginFeature() err := DefaultSuspendMutationHandler(mutator) require.NoError(t, err) // No-op: rules should remain unchanged. diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go index 71abd494..0b08f8aa 100644 --- a/pkg/primitives/ingress/mutator.go +++ b/pkg/primitives/ingress/mutator.go @@ -36,13 +36,11 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given Ingress. +// BeginFeature must be called before registering any mutations. func NewMutator(ing *networkingv1.Ingress) *Mutator { - m := &Mutator{ - ing: ing, - plans: []featurePlan{{}}, + return &Mutator{ + ing: ing, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/ingress/mutator_test.go b/pkg/primitives/ingress/mutator_test.go index 6a214fda..97accce4 100644 --- a/pkg/primitives/ingress/mutator_test.go +++ b/pkg/primitives/ingress/mutator_test.go @@ -25,6 +25,7 @@ func newTestIngress() *networkingv1.Ingress { func TestMutator_EditObjectMetadata(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + 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) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditObjectMetadata(nil) assert.NoError(t, m.Apply()) } @@ -43,6 +45,7 @@ func TestMutator_EditObjectMetadata_Nil(t *testing.T) { func TestMutator_EditObjectMetadata_Error(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { return errors.New("metadata error") }) @@ -56,6 +59,7 @@ func TestMutator_EditObjectMetadata_Error(t *testing.T) { func TestMutator_EditIngressSpec(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.SetIngressClassName("nginx") return nil @@ -68,6 +72,7 @@ func TestMutator_EditIngressSpec(t *testing.T) { func TestMutator_EditIngressSpec_Nil(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(nil) assert.NoError(t, m.Apply()) } @@ -75,6 +80,7 @@ func TestMutator_EditIngressSpec_Nil(t *testing.T) { func TestMutator_EditIngressSpec_Error(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(func(_ *editors.IngressSpecEditor) error { return errors.New("spec error") }) @@ -86,6 +92,7 @@ func TestMutator_EditIngressSpec_Error(t *testing.T) { func TestMutator_EditIngressSpec_EnsureRule(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureRule(networkingv1.IngressRule{Host: "example.com"}) return nil @@ -98,6 +105,7 @@ func TestMutator_EditIngressSpec_EnsureRule(t *testing.T) { func TestMutator_EditIngressSpec_EnsureTLS(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureTLS(networkingv1.IngressTLS{ Hosts: []string{"example.com"}, @@ -116,6 +124,7 @@ func TestMutator_OperationOrder(t *testing.T) { // Within a feature: metadata edits run before spec edits. ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() // Register in reverse logical order to confirm Apply() enforces category ordering. m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { @@ -136,6 +145,7 @@ func TestMutator_OperationOrder(t *testing.T) { func TestMutator_MultipleFeatures(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { e.EnsureRule(networkingv1.IngressRule{Host: "feature1.com"}) return nil @@ -155,6 +165,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { func TestMutator_CombinedMutations(t *testing.T) { ing := newTestIngress() m := NewMutator(ing) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app", "web") @@ -181,6 +192,64 @@ func TestMutator_CombinedMutations(t *testing.T) { require.Len(t, ing.Spec.TLS, 1) } +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + + 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) { + ing := newTestIngress() + m := NewMutator(ing) + + 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) { + ing := newTestIngress() + m := NewMutator(ing) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "f0.com"}) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.EnsureRule(networkingv1.IngressRule{Host: "f1.com"}) + return nil + }) + + assert.Len(t, m.plans[0].ingressSpecEdits, 1, "first plan should have one spec edit") + assert.Len(t, m.plans[1].ingressSpecEdits, 1, "second plan should have one spec edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + m.BeginFeature() + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + + require.NoError(t, m.Apply()) + require.Len(t, m.plans, 1, "single feature should have exactly one plan") +} + // --- ObjectMutator interface --- func TestMutator_ImplementsObjectMutator(_ *testing.T) { From 56c03ca2cdc314a89cbdc97d5b50d3e826b42f2c 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:29 +0000 Subject: [PATCH 19/22] Remove field applicators and flavors from ingress primitive Align with the framework's switch to Server-Side Apply: remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, flavors.go, and all related tests and documentation. Update Mutate tests to use the Object()-first pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/ingress.md | 45 ------- pkg/primitives/ingress/builder.go | 41 +------ pkg/primitives/ingress/builder_test.go | 38 ------ pkg/primitives/ingress/flavors.go | 25 ---- pkg/primitives/ingress/flavors_test.go | 121 ------------------- pkg/primitives/ingress/resource.go | 21 +--- pkg/primitives/ingress/resource_test.go | 153 +++--------------------- 7 files changed, 27 insertions(+), 417 deletions(-) delete mode 100644 pkg/primitives/ingress/flavors.go delete mode 100644 pkg/primitives/ingress/flavors_test.go diff --git a/docs/primitives/ingress.md b/docs/primitives/ingress.md index b6a5e5ac..d3b8f1d2 100644 --- a/docs/primitives/ingress.md +++ b/docs/primitives/ingress.md @@ -11,7 +11,6 @@ and metadata. For an overview of all built-in primitives, see [Primitives](../pr | **Operational status** | Reports `OperationPending` until the ingress controller assigns an address, then `Operational` | | **Suspension** | No-op by default — Ingress is left in place; backend returns 502/503 | | **Mutation pipeline** | Typed editors for metadata and ingress spec (rules, TLS, class name, default backend) | -| **Flavors** | Preserves externally-managed fields (labels, annotations) | ## Building an Ingress Primitive @@ -50,20 +49,10 @@ base := &networkingv1.Ingress{ } resource, err := ingress.NewBuilder(base). - WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations). WithMutation(MyFeatureMutation(owner.Spec.Version)). Build() ``` -## Default Field Application - -`DefaultFieldApplicator` replaces the current Ingress with a deep copy of the desired object, then restores -server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the -Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data -written by the ingress controller (e.g. `Status.LoadBalancer.Ingress` addresses). - -Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. - ## Mutations Mutations are the primary mechanism for modifying an `Ingress` beyond its baseline. Each mutation is a named function @@ -269,46 +258,12 @@ resource, err := ingress.NewBuilder(base). Build() ``` -## 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 := ingress.NewBuilder(base). - WithFieldApplicationFlavor(ingress.PreserveCurrentLabels). - Build() -``` - -### PreserveCurrentAnnotations - -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on -overlap. - -This is particularly useful for Ingress resources, where ingress controllers and cert-manager often manage annotations: - -```go -resource, err := ingress.NewBuilder(base). - WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations). - Build() -``` - -Multiple flavors can be registered and run in registration order. - ## 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 `PreserveCurrentAnnotations` when sharing an Ingress.** Ingress controllers, cert-manager, and external-dns -frequently manage annotations. This flavor prevents your operator from silently deleting those annotations each -reconcile cycle. - **Register mutations in dependency order.** If mutation B relies on a rule added by mutation A, register A first. **Prefer no-op suspension.** The default no-op suspension is almost always correct for Ingress resources. Only override diff --git a/pkg/primitives/ingress/builder.go b/pkg/primitives/ingress/builder.go index ec7449cf..0f926121 100644 --- a/pkg/primitives/ingress/builder.go +++ b/pkg/primitives/ingress/builder.go @@ -11,9 +11,9 @@ import ( // Builder is a configuration helper for creating and customizing an Ingress Resource. // -// It provides a fluent API for registering mutations, field application flavors, -// status handlers, 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, status handlers, and data +// extractors. Build() validates the configuration and returns an initialized +// Resource ready for use in a reconciliation loop. type Builder struct { base *generic.IntegrationBuilder[*networkingv1.Ingress, *Mutator] } @@ -22,7 +22,7 @@ type Builder struct { // // The Ingress 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 Ingress must have both Name and Namespace set, which is validated // during the Build() call. @@ -34,7 +34,6 @@ func NewBuilder(ing *networkingv1.Ingress) *Builder { base := generic.NewIntegrationBuilder[*networkingv1.Ingress, *Mutator]( ing, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -51,8 +50,7 @@ func NewBuilder(ing *networkingv1.Ingress) *Builder { // WithMutation registers a mutation for the Ingress. // -// 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 { @@ -60,35 +58,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing Ingress 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 *networkingv1.Ingress) 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[*networkingv1.Ingress](flavor)) - return b -} - // WithCustomOperationalStatus overrides the default logic for determining if the // Ingress has reached its operational state. // diff --git a/pkg/primitives/ingress/builder_test.go b/pkg/primitives/ingress/builder_test.go index d7927a73..04afaaef 100644 --- a/pkg/primitives/ingress/builder_test.go +++ b/pkg/primitives/ingress/builder_test.go @@ -92,44 +92,6 @@ func TestBuilder(t *testing.T) { assert.Equal(t, "test-mutation", res.base.Mutations[0].Name) }) - t.Run("WithCustomFieldApplicator", func(t *testing.T) { - t.Parallel() - ing := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ing", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_, _ *networkingv1.Ingress) error { - applied = true - return nil - } - res, err := NewBuilder(ing). - WithCustomFieldApplicator(applicator). - Build() - require.NoError(t, err) - require.NotNil(t, res.base.CustomFieldApplicator) - _ = res.base.CustomFieldApplicator(nil, nil) - assert.True(t, applied) - }) - - t.Run("WithFieldApplicationFlavor", func(t *testing.T) { - t.Parallel() - ing := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ing", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(ing). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) - }) - t.Run("WithCustomOperationalStatus", func(t *testing.T) { t.Parallel() ing := &networkingv1.Ingress{ diff --git a/pkg/primitives/ingress/flavors.go b/pkg/primitives/ingress/flavors.go deleted file mode 100644 index 276ce370..00000000 --- a/pkg/primitives/ingress/flavors.go +++ /dev/null @@ -1,25 +0,0 @@ -package ingress - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - networkingv1 "k8s.io/api/networking/v1" -) - -// FieldApplicationFlavor defines a function signature for applying flavors to an -// Ingress 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[*networkingv1.Ingress] - -// PreserveCurrentLabels ensures that any labels present on the current live -// Ingress but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *networkingv1.Ingress) error { - return flavors.PreserveCurrentLabels[*networkingv1.Ingress]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live Ingress but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *networkingv1.Ingress) error { - return flavors.PreserveCurrentAnnotations[*networkingv1.Ingress]()(applied, current, desired) -} diff --git a/pkg/primitives/ingress/flavors_test.go b/pkg/primitives/ingress/flavors_test.go deleted file mode 100644 index e753c5cc..00000000 --- a/pkg/primitives/ingress/flavors_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package ingress - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - networkingv1 "k8s.io/api/networking/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutate_OrderingAndFlavors(t *testing.T) { - desired := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ing", - Namespace: "test-ns", - Labels: map[string]string{"app": "desired"}, - }, - } - - t.Run("flavors run after baseline applicator", func(t *testing.T) { - current := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ing", - Namespace: "test-ns", - Labels: map[string]string{"extra": "preserved"}, - }, - } - - res, buildErr := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveCurrentLabels). - Build() - require.NoError(t, buildErr) - - err := res.Mutate(current) - require.NoError(t, err) - - assert.Equal(t, "desired", current.Labels["app"]) - assert.Equal(t, "preserved", current.Labels["extra"]) - }) - - t.Run("flavors run in registration order", func(t *testing.T) { - current := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-ing", - Namespace: "test-ns", - }, - } - - var order []string - flavor1 := func(_, _, _ *networkingv1.Ingress) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *networkingv1.Ingress) error { - order = append(order, "flavor2") - return nil - } - - res, buildErr := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, buildErr) - - err := res.Mutate(current) - require.NoError(t, err) - assert.Equal(t, []string{"flavor1", "flavor2"}, order) - }) - - t.Run("flavor error is returned with context", func(t *testing.T) { - current := &networkingv1.Ingress{} - flavorErr := errors.New("boom") - flavor := func(_, _, _ *networkingv1.Ingress) error { - return flavorErr - } - - res, buildErr := NewBuilder(desired). - WithFieldApplicationFlavor(flavor). - Build() - require.NoError(t, buildErr) - - err := res.Mutate(current) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to apply field application flavor") - assert.True(t, errors.Is(err, flavorErr)) - }) -} - -func TestDefaultFlavors(t *testing.T) { - t.Run("PreserveCurrentLabels", func(t *testing.T) { - applied := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} - current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current", "overlap": "current"}}} - - err := PreserveCurrentLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Labels["keep"]) - assert.Equal(t, "applied", applied.Labels["overlap"]) - assert.Equal(t, "current", applied.Labels["extra"]) - }) - - t.Run("PreserveCurrentAnnotations", func(t *testing.T) { - applied := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"extra": "current"}}} - - err := PreserveCurrentAnnotations(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "applied", applied.Annotations["keep"]) - assert.Equal(t, "current", applied.Annotations["extra"]) - }) - - t.Run("handles nil maps safely", func(t *testing.T) { - applied := &networkingv1.Ingress{} - current := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"extra": "current"}}} - - err := PreserveCurrentLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "current", applied.Labels["extra"]) - }) -} diff --git a/pkg/primitives/ingress/resource.go b/pkg/primitives/ingress/resource.go index 903c7233..715d8a0e 100644 --- a/pkg/primitives/ingress/resource.go +++ b/pkg/primitives/ingress/resource.go @@ -8,18 +8,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.), -// shared-controller fields (OwnerReferences, Finalizers), and the Status -// subresource from the original current object. -func DefaultFieldApplicator(current, desired *networkingv1.Ingress) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - generic.PreserveStatus(current, original) - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes Ingress within // a controller's reconciliation loop. // @@ -54,13 +42,10 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes Ingress 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. -// 4. Suspension mutation: if the component is suspended, the suspension mutation is applied. +// 1. Feature mutations: all registered feature-gated mutations are applied in order. +// 2. Suspension mutation: if the component is suspended, the suspension mutation is applied. // -// This method is invoked by the framework during the Update phase of reconciliation. +// This method is invoked by the framework during the reconciliation loop. func (r *Resource) Mutate(current client.Object) error { return r.base.Mutate(current) } diff --git a/pkg/primitives/ingress/resource_test.go b/pkg/primitives/ingress/resource_test.go index a71dcd27..3233780a 100644 --- a/pkg/primitives/ingress/resource_test.go +++ b/pkg/primitives/ingress/resource_test.go @@ -76,12 +76,14 @@ func TestResource_Mutate(t *testing.T) { res, err := NewBuilder(desired).Build() require.NoError(t, err) - current := &networkingv1.Ingress{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) - require.Len(t, current.Spec.Rules, 1) - assert.Equal(t, "example.com", current.Spec.Rules[0].Host) + got := obj.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("nginx"), got.Spec.IngressClassName) + require.Len(t, got.Spec.Rules, 1) + assert.Equal(t, "example.com", got.Spec.Rules[0].Host) } func TestResource_Mutate_WithMutation(t *testing.T) { @@ -104,12 +106,14 @@ func TestResource_Mutate_WithMutation(t *testing.T) { Build() require.NoError(t, err) - current := &networkingv1.Ingress{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) - require.Len(t, current.Spec.TLS, 1) - assert.Equal(t, "tls-cert", current.Spec.TLS[0].SecretName) + got := obj.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("nginx"), got.Spec.IngressClassName) + require.Len(t, got.Spec.TLS, 1) + assert.Equal(t, "tls-cert", got.Spec.TLS[0].SecretName) } func TestResource_Mutate_FeatureOrdering(t *testing.T) { @@ -140,92 +144,13 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Build() require.NoError(t, err) - current := &networkingv1.Ingress{} - require.NoError(t, res.Mutate(current)) - - // Last mutation wins. - assert.Equal(t, ptr.To("haproxy"), current.Spec.IngressClassName) -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &networkingv1.Ingress{ - 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 := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - Labels: map[string]string{"app": "test"}, - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("nginx"), - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired spec and labels are applied - assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) - 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 TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { - current := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - ResourceVersion: "12345", - }, - Status: networkingv1.IngressStatus{ - LoadBalancer: networkingv1.IngressLoadBalancerStatus{ - Ingress: []networkingv1.IngressLoadBalancerIngress{ - {IP: "10.0.0.1"}, - {Hostname: "lb.example.com"}, - }, - }, - }, - } - desired := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: ptr.To("nginx"), - }, - } - - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // Desired spec is applied - assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) - - // Status is preserved from the live object - require.Len(t, current.Status.LoadBalancer.Ingress, 2) - assert.Equal(t, "10.0.0.1", current.Status.LoadBalancer.Ingress[0].IP) - assert.Equal(t, "lb.example.com", current.Status.LoadBalancer.Ingress[1].Hostname) + // Last mutation wins. + got := obj.(*networkingv1.Ingress) + assert.Equal(t, ptr.To("haproxy"), got.Spec.IngressClassName) } func TestDefaultOperationalStatusHandler_Operational(t *testing.T) { @@ -258,46 +183,6 @@ func TestDefaultOperationalStatusHandler_OperationalWithHostname(t *testing.T) { assert.Equal(t, concepts.OperationalStatusOperational, status.Status) } -func TestResource_Mutate_CustomFieldApplicator(t *testing.T) { - desired := newValidIngress() - - applicatorCalled := false - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(current, d *networkingv1.Ingress) error { - applicatorCalled = true - current.Spec.IngressClassName = d.Spec.IngressClassName - return nil - }). - Build() - require.NoError(t, err) - - current := &networkingv1.Ingress{ - Spec: networkingv1.IngressSpec{ - Rules: []networkingv1.IngressRule{{Host: "preserved.com"}}, - }, - } - require.NoError(t, res.Mutate(current)) - - assert.True(t, applicatorCalled) - assert.Equal(t, ptr.To("nginx"), current.Spec.IngressClassName) - // Custom applicator only copied className, so rules should be preserved. - require.Len(t, current.Spec.Rules, 1) - assert.Equal(t, "preserved.com", current.Spec.Rules[0].Host) -} - -func TestResource_Mutate_CustomFieldApplicator_Error(t *testing.T) { - res, err := NewBuilder(newValidIngress()). - WithCustomFieldApplicator(func(_, _ *networkingv1.Ingress) error { - return errors.New("applicator error") - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&networkingv1.Ingress{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") -} - func TestResource_ConvergingStatus_Pending(t *testing.T) { res, err := NewBuilder(newValidIngress()).Build() require.NoError(t, err) From d0fc2921287f66f8bd3c019d02fb7e4459323b80 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:46 +0000 Subject: [PATCH 20/22] Remove references to deleted field applicators in ingress example The WithFieldApplicationFlavor, PreserveCurrentLabels, and PreserveCurrentAnnotations were removed from the ingress primitive but the example still referenced them, breaking the lint CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/ingress-primitive/resources/ingress.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/ingress-primitive/resources/ingress.go b/examples/ingress-primitive/resources/ingress.go index 0d3d44f9..d0bc8e53 100644 --- a/examples/ingress-primitive/resources/ingress.go +++ b/examples/ingress-primitive/resources/ingress.go @@ -58,11 +58,7 @@ func NewIngressResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.VersionAnnotation(owner.Spec.Version)) builder.WithMutation(features.TLSFeature(owner.Spec.EnableTracing, owner.Name)) - // 4. Configure flavors. - builder.WithFieldApplicationFlavor(ingress.PreserveCurrentLabels) - builder.WithFieldApplicationFlavor(ingress.PreserveCurrentAnnotations) - - // 5. Data extraction. + // 4. Data extraction. builder.WithDataExtractor(func(ing networkingv1.Ingress) error { fmt.Printf("Reconciling ingress: %s\n", ing.Name) @@ -75,6 +71,6 @@ func NewIngressResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } From 74df3e970a5e1f6d69122e660c64618c150707bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:03:38 +0000 Subject: [PATCH 21/22] Remove stale Field Flavors reference from ingress example README Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/ingress-primitive/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/ingress-primitive/README.md b/examples/ingress-primitive/README.md index e5d87535..2a09b6f5 100644 --- a/examples/ingress-primitive/README.md +++ b/examples/ingress-primitive/README.md @@ -5,8 +5,6 @@ manage a Kubernetes Ingress as a component of a larger application, utilizing fe - **Base Construction**: Initializing an Ingress with rules, TLS, and ingress class. - **Feature Mutations**: Applying conditional changes (TLS configuration, version annotations) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., cert-manager, - ingress controllers). - **Data Extraction**: Harvesting information from the reconciled resource. ## Directory Structure From ff1c2a9dee3e32277157d64a03f8d63afaa8da94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:16:25 +0000 Subject: [PATCH 22/22] Implicitly call BeginFeature in EditObjectMetadata and EditIngressSpec Both methods now call BeginFeature() when no active feature plan exists, preventing nil-pointer panics when callers omit BeginFeature(). This preserves the ObjectMutator interface contract (no error return) while making the Mutator safe for public use without requiring strict call ordering. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/ingress/mutator.go | 12 +++++++++++- pkg/primitives/ingress/mutator_test.go | 27 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/ingress/mutator.go b/pkg/primitives/ingress/mutator.go index 0b08f8aa..62ff52ed 100644 --- a/pkg/primitives/ingress/mutator.go +++ b/pkg/primitives/ingress/mutator.go @@ -36,7 +36,9 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given Ingress. -// BeginFeature must be called before registering any mutations. +// BeginFeature should be called before registering mutations to establish +// feature boundaries. If omitted, EditObjectMetadata and EditIngressSpec +// will call it implicitly. func NewMutator(ing *networkingv1.Ingress) *Mutator { return &Mutator{ ing: ing, @@ -54,10 +56,14 @@ func (m *Mutator) BeginFeature() { // // Metadata edits are applied before ingress spec edits within the same feature. // A nil edit function is ignored. +// If BeginFeature has not been called, it is called implicitly. func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { if edit == nil { return } + if m.active == nil { + m.BeginFeature() + } m.active.metadataEdits = append(m.active.metadataEdits, edit) } @@ -69,10 +75,14 @@ func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) // registration order. // // A nil edit function is ignored. +// If BeginFeature has not been called, it is called implicitly. func (m *Mutator) EditIngressSpec(edit func(*editors.IngressSpecEditor) error) { if edit == nil { return } + if m.active == nil { + m.BeginFeature() + } m.active.ingressSpecEdits = append(m.active.ingressSpecEdits, edit) } diff --git a/pkg/primitives/ingress/mutator_test.go b/pkg/primitives/ingress/mutator_test.go index 97accce4..c3faeb97 100644 --- a/pkg/primitives/ingress/mutator_test.go +++ b/pkg/primitives/ingress/mutator_test.go @@ -250,6 +250,33 @@ func TestMutator_SingleFeature_PlanCount(t *testing.T) { require.Len(t, m.plans, 1, "single feature should have exactly one plan") } +// --- Implicit BeginFeature --- + +func TestMutator_EditObjectMetadata_ImplicitBeginFeature(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + // No explicit BeginFeature call — should not panic. + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("implicit", "true") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "true", ing.Labels["implicit"]) +} + +func TestMutator_EditIngressSpec_ImplicitBeginFeature(t *testing.T) { + ing := newTestIngress() + m := NewMutator(ing) + // No explicit BeginFeature call — should not panic. + m.EditIngressSpec(func(e *editors.IngressSpecEditor) error { + e.SetIngressClassName("nginx") + return nil + }) + require.NoError(t, m.Apply()) + require.NotNil(t, ing.Spec.IngressClassName) + assert.Equal(t, "nginx", *ing.Spec.IngressClassName) +} + // --- ObjectMutator interface --- func TestMutator_ImplementsObjectMutator(_ *testing.T) {