From 0b00b782cfbaf4b925d6afaf62f7c7e8f6235431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:42:41 +0000 Subject: [PATCH 01/24] Add Pod primitive package with builder, resource, mutator, handlers, and flavors Implements the Pod primitive for managing Kubernetes Pod objects within the operator-component-framework. The Pod follows the Workload lifecycle pattern with pod-specific behavior: immutable spec preservation via DefaultFieldApplicator, deletion-based suspension, and direct PodSpec/Container mutation (no pod template indirection). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/builder.go | 209 ++++++++++++ pkg/primitives/pod/builder_test.go | 272 +++++++++++++++ pkg/primitives/pod/flavors.go | 25 ++ pkg/primitives/pod/flavors_test.go | 118 +++++++ pkg/primitives/pod/handlers.go | 130 +++++++ pkg/primitives/pod/handlers_test.go | 203 +++++++++++ pkg/primitives/pod/mutator.go | 395 +++++++++++++++++++++ pkg/primitives/pod/mutator_test.go | 508 ++++++++++++++++++++++++++++ pkg/primitives/pod/resource.go | 139 ++++++++ 9 files changed, 1999 insertions(+) create mode 100644 pkg/primitives/pod/builder.go create mode 100644 pkg/primitives/pod/builder_test.go create mode 100644 pkg/primitives/pod/flavors.go create mode 100644 pkg/primitives/pod/flavors_test.go create mode 100644 pkg/primitives/pod/handlers.go create mode 100644 pkg/primitives/pod/handlers_test.go create mode 100644 pkg/primitives/pod/mutator.go create mode 100644 pkg/primitives/pod/mutator_test.go create mode 100644 pkg/primitives/pod/resource.go diff --git a/pkg/primitives/pod/builder.go b/pkg/primitives/pod/builder.go new file mode 100644 index 00000000..58c39435 --- /dev/null +++ b/pkg/primitives/pod/builder.go @@ -0,0 +1,209 @@ +// Package pod provides a builder and resource for managing Kubernetes Pods. +package pod + +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" + corev1 "k8s.io/api/core/v1" +) + +// Builder is a configuration helper for creating and customizing a Pod Resource. +// +// It provides a fluent API for registering mutations, status handlers, and +// data extractors. This builder ensures that the resulting Resource is +// properly initialized and validated before use in a reconciliation loop. +type Builder struct { + base *generic.WorkloadBuilder[*corev1.Pod, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided Pod object. +// +// The Pod object passed here serves as the "desired base state". During +// reconciliation, the Resource will attempt to make the cluster's state match +// this base state, modified by any registered mutations. +// +// The provided pod must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(pod *corev1.Pod) *Builder { + identityFunc := func(p *corev1.Pod) string { + return fmt.Sprintf("v1/Pod/%s/%s", p.Namespace, p.Name) + } + + base := generic.NewWorkloadBuilder[*corev1.Pod, *Mutator]( + pod, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomConvergeStatus(DefaultConvergingStatusHandler). + WithCustomGraceStatus(DefaultGraceStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the Pod. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// They are typically used by Features to inject environment variables, +// arguments, or other configuration into the Pod's containers. +// +// Since mutations are often version-gated, the provided feature.Mutation +// should contain the logic to determine if and how the mutation is applied +// based on the component's current version or configuration. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomFieldApplicator sets a custom strategy for applying the desired +// state to the existing Pod in the cluster. +// +// The default field applicator (DefaultFieldApplicator) preserves the spec +// on existing pods (since pod spec is largely immutable) and only updates +// metadata. Using a custom applicator is necessary when: +// - Additional metadata fields need to be selectively propagated. +// - Specific labels or annotations should be excluded from updates. +// +// The applicator function receives both the 'current' object from the API +// server and the 'desired' object from the Resource. It is responsible for +// merging the desired changes into the current object. +// +// If a custom applicator is set, it overrides the default baseline application +// logic. Post-application flavors and mutations are still applied afterward. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current *corev1.Pod, desired *corev1.Pod) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a reusable post-application "flavor" for +// the Pod. +// +// Flavors are applied in the order they are registered, after the baseline field +// applicator (default or custom) has already run. They are typically used to +// preserve selected live fields from the current object that should not be +// overwritten by the desired state. +// +// If the provided flavor is nil, it is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*corev1.Pod](flavor)) + return b +} + +// WithCustomConvergeStatus overrides the default logic for determining if the +// Pod has reached its desired state. +// +// The default behavior uses DefaultConvergingStatusHandler, which checks the Pod's +// phase and container statuses. Use this method if your Pod requires more complex +// health checks. +// +// If you want to augment the default behavior, you can call DefaultConvergingStatusHandler +// within your custom handler. +func (b *Builder) WithCustomConvergeStatus( + handler func(concepts.ConvergingOperation, *corev1.Pod) (concepts.AliveStatusWithReason, error), +) *Builder { + b.base.WithCustomConvergeStatus(handler) + return b +} + +// WithCustomGraceStatus overrides how the Pod reports its health while +// it has not yet reached full readiness. +// +// The default behavior uses DefaultGraceStatusHandler. +// +// If you want to augment the default behavior, you can call DefaultGraceStatusHandler +// within your custom handler. +func (b *Builder) WithCustomGraceStatus( + handler func(*corev1.Pod) (concepts.GraceStatusWithReason, error), +) *Builder { + b.base.WithCustomGraceStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which always reports +// Suspended because pods are deleted on suspend. +// +// If you want to augment the default behavior, you can call DefaultSuspensionStatusHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendStatus( + handler func(*corev1.Pod) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the Pod should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// because pods are deleted on suspend rather than mutated. +// +// If you want to augment the default behavior, you can call DefaultSuspendMutationHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the Pod when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true +// because pods cannot be paused. Return false from this handler if you want +// the Pod to remain in the cluster when suspended. +// +// If you want to augment the default behavior, you can call DefaultDeleteOnSuspendHandler +// within your custom handler. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*corev1.Pod) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to harvest information from the +// Pod after it has been successfully reconciled. +// +// This is useful for capturing auto-generated fields (like pod IP or node +// assignment) and making them available to other components or resources via +// the framework's data extraction mechanism. +func (b *Builder) WithDataExtractor( + extractor func(corev1.Pod) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(p *corev1.Pod) error { + return extractor(*p) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It ensures that: +// - A base Pod object was provided. +// - The Pod has both a name and a namespace set. +// +// If validation fails, an error is returned and the Resource should not be used. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/pod/builder_test.go b/pkg/primitives/pod/builder_test.go new file mode 100644 index 00000000..1baac4c7 --- /dev/null +++ b/pkg/primitives/pod/builder_test.go @@ -0,0 +1,272 @@ +package pod + +import ( + "errors" + "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" + 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 + pod *corev1.Pod + expectedErr string + }{ + { + name: "nil pod", + pod: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid pod", + pod: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.pod).Build() + if tt.expectedErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + assert.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + assert.Equal(t, "v1/Pod/test-ns/test-pod", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_ *corev1.Pod, _ *corev1.Pod) error { + applied = true + return nil + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(pod). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomConvergeStatus", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *corev1.Pod) (concepts.AliveStatusWithReason, error) { + return concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusUpdating}, nil + } + res, err := NewBuilder(pod). + WithCustomConvergeStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.ConvergingStatusHandler) + status, err := res.base.ConvergingStatusHandler(concepts.ConvergingOperationUpdated, nil) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusUpdating, status.Status) + }) + + t.Run("WithCustomGraceStatus", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) (concepts.GraceStatusWithReason, error) { + return concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy}, nil + } + res, err := NewBuilder(pod). + WithCustomGraceStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.GraceStatusHandler) + status, err := res.base.GraceStatusHandler(nil) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(pod). + 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() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + handler := func(_ *corev1.Pod) bool { + return false + } + res, err := NewBuilder(pod). + WithCustomSuspendDeletionDecision(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.DeleteOnSuspendHandler) + assert.False(t, res.base.DeleteOnSuspendHandler(nil)) + }) + + t.Run("WithDataExtractor", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ corev1.Pod) error { + called = true + return nil + } + res, err := NewBuilder(pod). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&corev1.Pod{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(pod). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/pod/flavors.go b/pkg/primitives/pod/flavors.go new file mode 100644 index 00000000..acd53f5a --- /dev/null +++ b/pkg/primitives/pod/flavors.go @@ -0,0 +1,25 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + corev1 "k8s.io/api/core/v1" +) + +// FieldApplicationFlavor defines a function signature for applying "flavors" to a resource. +// A flavor typically preserves certain fields from the current (live) object after the +// baseline field application has occurred. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*corev1.Pod] + +// PreserveCurrentLabels ensures that any labels present on the current live +// Pod but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *corev1.Pod) error { + return flavors.PreserveCurrentLabels[*corev1.Pod]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live Pod but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *corev1.Pod) error { + return flavors.PreserveCurrentAnnotations[*corev1.Pod]()(applied, current, desired) +} diff --git a/pkg/primitives/pod/flavors_test.go b/pkg/primitives/pod/flavors_test.go new file mode 100644 index 00000000..e232c573 --- /dev/null +++ b/pkg/primitives/pod/flavors_test.go @@ -0,0 +1,118 @@ +package pod + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMutate_OrderingAndFlavors(t *testing.T) { + desired := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{"app": "desired"}, + }, + } + + t.Run("flavors run after baseline applicator", func(t *testing.T) { + current := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + 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 := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + } + + var order []string + flavor1 := func(_, _, _ *corev1.Pod) error { + order = append(order, "flavor1") + return nil + } + flavor2 := func(_, _, _ *corev1.Pod) 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 := &corev1.Pod{} + flavorErr := errors.New("boom") + flavor := func(_, _, _ *corev1.Pod) 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 := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} + current := &corev1.Pod{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 := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} + current := &corev1.Pod{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 := &corev1.Pod{} + current := &corev1.Pod{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/pod/handlers.go b/pkg/primitives/pod/handlers.go new file mode 100644 index 00000000..b8051cb5 --- /dev/null +++ b/pkg/primitives/pod/handlers.go @@ -0,0 +1,130 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" +) + +// DefaultConvergingStatusHandler is the default logic for determining if a Pod has reached its desired state. +// +// It considers a Pod: +// - Healthy: when Status.Phase is Running AND all container statuses report Ready. +// - Failing: when any container is in CrashLoopBackOff or has terminated with an error. +// - Updating: when the converging operation indicates the pod is being recreated. +// - Creating: when Status.Phase is Pending and no restart failures are detected. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomConvergeStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultConvergingStatusHandler( + op concepts.ConvergingOperation, pod *corev1.Pod, +) (concepts.AliveStatusWithReason, error) { + // Check for failing containers first + for _, cs := range pod.Status.ContainerStatuses { + if cs.State.Waiting != nil && cs.State.Waiting.Reason == "CrashLoopBackOff" { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Container " + cs.Name + " is in CrashLoopBackOff", + }, nil + } + if cs.State.Terminated != nil && cs.State.Terminated.ExitCode != 0 { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Container " + cs.Name + " terminated with error", + }, nil + } + } + + // Check if pod is running and all containers are ready + if pod.Status.Phase == corev1.PodRunning { + allReady := true + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + allReady = false + break + } + } + if allReady { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "Pod is running and all containers are ready", + }, nil + } + } + + // Determine status based on converging operation + switch op { + case concepts.ConvergingOperationUpdated: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusUpdating, + Reason: "Pod is being recreated", + }, nil + case concepts.ConvergingOperationCreated: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod is starting", + }, nil + default: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod is pending", + }, nil + } +} + +// DefaultGraceStatusHandler provides a default health assessment of the Pod when it has not yet +// reached full readiness. +// +// It categorizes the current state into: +// - GraceStatusDegraded: Pod is Running but not all containers are Ready. +// - GraceStatusDown: Pod phase is not Running. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomGraceStatus. It can be reused within custom handlers to augment the default behavior. +func DefaultGraceStatusHandler(pod *corev1.Pod) (concepts.GraceStatusWithReason, error) { + if pod.Status.Phase == corev1.PodRunning { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "Pod running but not all containers ready", + }, nil + } + + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "Pod is not running", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the Pod +// when the parent component is suspended. +// +// It always returns true because pods cannot be paused — they must be deleted when suspended. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendDeletionDecision. It can be reused within custom handlers. +func DefaultDeleteOnSuspendHandler(_ *corev1.Pod) bool { + return true +} + +// DefaultSuspendMutationHandler provides the default mutation applied to a Pod when the component is suspended. +// +// It is a no-op because pods are deleted on suspend rather than mutated. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendMutation. It can be reused within custom handlers. +func DefaultSuspendMutationHandler(_ *Mutator) error { + return nil +} + +// DefaultSuspensionStatusHandler monitors the progress of the suspension process. +// +// It always reports Suspended because pod suspension is handled by deletion — once +// the framework decides to delete the pod, suspension is considered complete. +// +// This function is used as the default handler by the Resource if no custom handler is registered via +// Builder.WithCustomSuspendStatus. It can be reused within custom handlers. +func DefaultSuspensionStatusHandler(_ *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "Pod deleted on suspend", + }, nil +} diff --git a/pkg/primitives/pod/handlers_test.go b/pkg/primitives/pod/handlers_test.go new file mode 100644 index 00000000..3a62cc7f --- /dev/null +++ b/pkg/primitives/pod/handlers_test.go @@ -0,0 +1,203 @@ +package pod + +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" +) + +func TestDefaultConvergingStatusHandler(t *testing.T) { + tests := []struct { + name string + op concepts.ConvergingOperation + pod *corev1.Pod + wantStatus concepts.AliveConvergingStatus + wantReason string + }{ + { + name: "healthy - running and all containers ready", + op: concepts.ConvergingOperationUpdated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "Pod is running and all containers are ready", + }, + { + name: "healthy - running with no container statuses", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "Pod is running and all containers are ready", + }, + { + name: "failing - crash loop backoff", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "app", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "CrashLoopBackOff", + }, + }, + }, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Container app is in CrashLoopBackOff", + }, + { + name: "failing - terminated with error", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "worker", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + }, + }, + }, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Container worker terminated with error", + }, + { + name: "creating - on created operation", + op: concepts.ConvergingOperationCreated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod is starting", + }, + { + name: "updating - on updated operation", + op: concepts.ConvergingOperationUpdated, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusUpdating, + wantReason: "Pod is being recreated", + }, + { + name: "creating - on none operation when pending", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod is pending", + }, + { + name: "running but not all containers ready", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + {Name: "sidecar", Ready: false}, + }, + }, + }, + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod is pending", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultConvergingStatusHandler(tt.op, tt.pod) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + assert.Equal(t, tt.wantReason, got.Reason) + }) + } +} + +func TestDefaultGraceStatusHandler(t *testing.T) { + t.Run("degraded (running but not ready)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "Pod running but not all containers ready", got.Reason) + }) + + t.Run("down (not running)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + assert.Equal(t, "Pod is not running", got.Reason) + }) + + t.Run("down (failed phase)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDown, got.Status) + }) +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + pod := &corev1.Pod{} + assert.True(t, DefaultDeleteOnSuspendHandler(pod)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + pod := &corev1.Pod{} + mutator := NewMutator(pod) + err := DefaultSuspendMutationHandler(mutator) + require.NoError(t, err) + // No-op; just verify no error +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + pod := &corev1.Pod{} + got, err := DefaultSuspensionStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "Pod deleted on suspend", got.Reason) +} diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go new file mode 100644 index 00000000..efb65ead --- /dev/null +++ b/pkg/primitives/pod/mutator.go @@ -0,0 +1,395 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + corev1 "k8s.io/api/core/v1" +) + +// Mutation defines a mutation that is applied to a pod Mutator +// only if its associated feature.ResourceFeature is enabled. +type Mutation feature.Mutation[*Mutator] + +type containerEdit struct { + selector selectors.ContainerSelector + edit func(*editors.ContainerEditor) error +} + +type containerPresenceOp struct { + name string + container *corev1.Container // nil for remove +} + +type featurePlan struct { + podMetadataEdits []func(*editors.ObjectMetaEditor) error + podSpecEdits []func(*editors.PodSpecEditor) error + containerPresence []containerPresenceOp + containerEdits []containerEdit + initContainerPresence []containerPresenceOp + initContainerEdits []containerEdit +} + +// Mutator is a high-level helper for modifying a Kubernetes Pod. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, and then +// applied to the Pod in a single controlled pass when Apply() is called. +// +// This approach ensures that mutations are applied consistently and minimises +// repeated scans of the underlying Kubernetes structures. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +type Mutator struct { + current *corev1.Pod + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given Pod. +// +// It is typically used within a Feature's Mutation logic to express desired +// changes to the Pod. +func NewMutator(current *corev1.Pod) *Mutator { + m := &Mutator{ + current: current, + } + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until beginFeature +// is called again. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the Pod's own metadata. +// +// Planning: +// All object metadata edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, object metadata edits are executed BEFORE all other categories within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.podMetadataEdits = append(m.active.podMetadataEdits, edit) +} + +// EditPodSpec records a mutation for the Pod's spec. +// +// Planning: +// All pod spec edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, pod spec edits are executed AFTER metadata edits but BEFORE container edits within the same feature. +// +// If the edit function is nil, the registration is ignored. +func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { + if edit == nil { + return + } + m.active.podSpecEdits = append(m.active.podSpecEdits, edit) +} + +// EnsureContainer records that a regular container must be present in the Pod. +// If a container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureContainer(container corev1.Container) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveContainer records that a regular container should be removed by name. +func (m *Mutator) RemoveContainer(name string) { + m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveContainers records that multiple regular containers should be removed by name. +func (m *Mutator) RemoveContainers(names []string) { + for _, name := range names { + m.RemoveContainer(name) + } +} + +// EditContainers records a mutation for containers matching the given selector. +// +// Planning: +// All container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, container edits are executed AFTER container presence operations within the same feature. +// +// Selection: +// - The selector determines which containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selector matching is evaluated against a snapshot taken after the current feature's container +// presence operations are applied. +// - Mutations should not rely on earlier edits in the SAME feature phase changing which selectors match. +func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.containerEdits = append(m.active.containerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureInitContainer records that an init container must be present in the Pod. +// If an init container with the same name exists, it is replaced; otherwise, it is appended. +func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: container.Name, + container: &container, + }) +} + +// RemoveInitContainer records that an init container should be removed by name. +func (m *Mutator) RemoveInitContainer(name string) { + m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ + name: name, + container: nil, + }) +} + +// RemoveInitContainers records that multiple init containers should be removed by name. +func (m *Mutator) RemoveInitContainers(names []string) { + for _, name := range names { + m.RemoveInitContainer(name) + } +} + +// EditInitContainers records a mutation for init containers matching the given selector. +// +// Planning: +// All init container edits are stored and executed during Apply(). +// +// Execution Order: +// - Within a feature, edits are applied in registration order. +// - Overall, init container edits apply only to spec.initContainers. +// - They run in their own category during Apply(), after init container presence operations within the same feature. +// +// Selection: +// - The selector determines which init containers the edit function will be called for. +// - If either selector or edit function is nil, the registration is ignored. +// - Selector matching is evaluated against a snapshot taken after the current feature's init container +// presence operations are applied. +func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit func(*editors.ContainerEditor) error) { + if selector == nil || edit == nil { + return + } + m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ + selector: selector, + edit: edit, + }) +} + +// EnsureContainerEnvVar records that an environment variable must be present +// in all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) EnsureContainerEnvVar(ev corev1.EnvVar) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(ev) + return nil + }) +} + +// RemoveContainerEnvVar records that an environment variable should be +// removed from all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVar(name string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVar(name) + return nil + }) +} + +// RemoveContainerEnvVars records that multiple environment variables should be +// removed from all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerEnvVars(names []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveEnvVars(names) + return nil + }) +} + +// EnsureContainerArg records that a command-line argument must be present +// in all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) EnsureContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureArg(arg) + return nil + }) +} + +// RemoveContainerArg records that a command-line argument should be +// removed from all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerArg(arg string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArg(arg) + return nil + }) +} + +// RemoveContainerArgs records that multiple command-line arguments should be +// removed from all containers of the Pod. +// +// This is a convenience wrapper over EditContainers. +func (m *Mutator) RemoveContainerArgs(args []string) { + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.RemoveArgs(args) + return nil + }) +} + +// Apply executes all recorded mutation intents on the underlying Pod. +// +// Execution Order: +// Features are applied in the order they were registered. +// Within each feature, mutations are applied in this fixed category order: +// 1. Object metadata edits +// 2. Pod spec edits +// 3. Regular container presence operations +// 4. Regular container edits +// 5. Init container presence operations +// 6. Init container edits +// +// Within each category of a single feature, edits are applied in their registration order. +// +// Selection & Identity: +// - Container selectors target containers in the state they are in at the start of that feature's +// container phase (after presence operations of the SAME feature have been applied). +// - Selector matching within a phase is evaluated against a snapshot of containers at the start +// of that phase, not the progressively mutated live containers. +// - Later features observe the Pod as modified by all previous features. +// +// Timing: +// No changes are made to the Pod until Apply() is called. +// Selectors and edit functions are executed during this pass. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Object metadata + if len(plan.podMetadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.current.ObjectMeta) + for _, edit := range plan.podMetadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. Pod spec + if len(plan.podSpecEdits) > 0 { + editor := editors.NewPodSpecEditor(&m.current.Spec) + for _, edit := range plan.podSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 3. Regular container presence + for _, op := range plan.containerPresence { + applyPresenceOp(&m.current.Spec.Containers, op) + } + + // 4. Regular container edits + if len(plan.containerEdits) > 0 { + // Take snapshot of containers AFTER presence ops but BEFORE applying any edits for stable selector matching + snapshots := make([]corev1.Container, len(m.current.Spec.Containers)) + for i := range m.current.Spec.Containers { + m.current.Spec.Containers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.Containers { + container := &m.current.Spec.Containers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.containerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + + // 5. Init container presence + for _, op := range plan.initContainerPresence { + applyPresenceOp(&m.current.Spec.InitContainers, op) + } + + // 6. Init container edits + if len(plan.initContainerEdits) > 0 { + // Take snapshot of init containers AFTER presence ops but BEFORE applying any edits + snapshots := make([]corev1.Container, len(m.current.Spec.InitContainers)) + for i := range m.current.Spec.InitContainers { + m.current.Spec.InitContainers[i].DeepCopyInto(&snapshots[i]) + } + + for i := range m.current.Spec.InitContainers { + container := &m.current.Spec.InitContainers[i] + snapshot := &snapshots[i] + editor := editors.NewContainerEditor(container) + for _, ce := range plan.initContainerEdits { + if ce.selector(i, snapshot) { + if err := ce.edit(editor); err != nil { + return err + } + } + } + } + } + } + + return nil +} + +func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { + found := -1 + for i, c := range *containers { + if c.Name == op.name { + found = i + break + } + } + + if op.container == nil { + // Remove + if found != -1 { + *containers = append((*containers)[:found], (*containers)[found+1:]...) + } + return + } + + // Ensure + if found != -1 { + (*containers)[found] = *op.container + } else { + *containers = append(*containers, *op.container) + } +} diff --git a/pkg/primitives/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go new file mode 100644 index 00000000..4ca445f3 --- /dev/null +++ b/pkg/primitives/pod/mutator_test.go @@ -0,0 +1,508 @@ +package pod + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewMutator(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + assert.NotNil(t, m) + assert.Equal(t, pod, m.current) +} + +func TestMutator_EnvVars(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Env: []corev1.EnvVar{ + {Name: "KEEP", Value: "stay"}, + {Name: "CHANGE", Value: "old"}, + {Name: "REMOVE", Value: "gone"}, + }, + }, + }, + }, + } + + m := NewMutator(pod) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) + m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) + + err := m.Apply() + require.NoError(t, err) + + env := pod.Spec.Containers[0].Env + assert.Len(t, env, 3) + + findEnv := func(name string) *corev1.EnvVar { + for _, e := range env { + if e.Name == name { + return &e + } + } + return nil + } + + assert.NotNil(t, findEnv("KEEP")) + assert.Equal(t, "stay", findEnv("KEEP").Value) + assert.Equal(t, "new", findEnv("CHANGE").Value) + assert.Equal(t, "added", findEnv("ADD").Value) + assert.Nil(t, findEnv("REMOVE")) +} + +func TestMutator_Args(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Args: []string{"--keep", "--change=old", "--remove"}, + }, + }, + }, + } + + m := NewMutator(pod) + m.EnsureContainerArg("--change=new") + m.EnsureContainerArg("--add") + m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) + + err := m.Apply() + require.NoError(t, err) + + args := pod.Spec.Containers[0].Args + assert.Contains(t, args, "--keep") + assert.Contains(t, args, "--change=old") + assert.Contains(t, args, "--change=new") + assert.Contains(t, args, "--add") + assert.NotContains(t, args, "--remove") +} + +func TestMutator_EditContainers(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "c1"}, + {Name: "c2"}, + }, + }, + } + + m := NewMutator(pod) + m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "c1-image" + return nil + }) + m.EditContainers(selectors.AllContainers(), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "GLOBAL", Value: "true"}) + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + assert.Equal(t, "c1-image", pod.Spec.Containers[0].Image) + assert.Equal(t, "", pod.Spec.Containers[1].Image) + assert.Equal(t, "GLOBAL", pod.Spec.Containers[0].Env[0].Name) + assert.Equal(t, "GLOBAL", pod.Spec.Containers[1].Env[0].Name) +} + +func TestMutator_EditPodSpec(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.Raw().ServiceAccountName = "my-sa" + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "my-sa", pod.Spec.ServiceAccountName) +} + +func TestMutator_EditMetadata(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.Raw().Labels = map[string]string{"pod": "label"} + return nil + }) + + err := m.Apply() + require.NoError(t, err) + assert.Equal(t, "label", pod.Labels["pod"]) +} + +func TestMutator_Errors(t *testing.T) { + pod := &corev1.Pod{} + m := NewMutator(pod) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + return errors.New("boom") + }) + + err := m.Apply() + assert.Error(t, err) + assert.Equal(t, "boom", err.Error()) +} + +func TestMutator_Order(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + } + + var order []string + + m := NewMutator(pod) + // Register in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + order = append(order, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + order = append(order, "podspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + order = append(order, "podmeta") + return nil + }) + + err := m.Apply() + require.NoError(t, err) + + expected := []string{"podmeta", "podspec", "container"} + assert.Equal(t, expected, order) +} + +func TestMutator_InitContainers(t *testing.T) { + const newImage = "new-image" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "old-image"}, + }, + }, + } + + m := NewMutator(pod) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = newImage + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if pod.Spec.InitContainers[0].Image != newImage { + t.Errorf("expected image %s, got %s", newImage, pod.Spec.InitContainers[0].Image) + } +} + +func TestMutator_ContainerPresence(t *testing.T) { + const newImage = "new-image" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + {Name: "sidecar", Image: "sidecar-image"}, + }, + }, + } + + m := NewMutator(pod) + // Replace + m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) + // Remove + m.RemoveContainer("sidecar") + // Append + m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if len(pod.Spec.Containers) != 2 { + t.Fatalf("expected 2 containers, got %d", len(pod.Spec.Containers)) + } + + if pod.Spec.Containers[0].Name != "app" || pod.Spec.Containers[0].Image != "app-new-image" { + t.Errorf("unexpected container at index 0: %+v", pod.Spec.Containers[0]) + } + + if pod.Spec.Containers[1].Name != "new-container" || pod.Spec.Containers[1].Image != newImage { + t.Errorf("unexpected container at index 1: %+v", pod.Spec.Containers[1]) + } +} + +func TestMutator_InitContainerPresence(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "init-1", Image: "init-1-image"}, + }, + }, + } + + m := NewMutator(pod) + m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) + m.RemoveInitContainers([]string{"init-1"}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if len(pod.Spec.InitContainers) != 1 { + t.Fatalf("expected 1 init container, got %d", len(pod.Spec.InitContainers)) + } + + if pod.Spec.InitContainers[0].Name != "init-2" { + t.Errorf("expected init-2, got %s", pod.Spec.InitContainers[0].Name) + } +} + +func TestMutator_SelectorSnapshotSemantics(t *testing.T) { + const appV2 = "app-v2" + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "app-image"}, + }, + }, + } + + m := NewMutator(pod) + + // First edit renames the container + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = appV2 + return nil + }) + + // Second edit should still match using "app" selector because of snapshot + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "app-image-updated" + return nil + }) + + // Third edit targeting "app-v2" should NOT match in this apply pass + m.EditContainers(selectors.ContainerNamed(appV2), func(e *editors.ContainerEditor) error { + e.Raw().Image = "should-not-be-set" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if pod.Spec.Containers[0].Name != appV2 { + t.Errorf("expected name %s, got %s", appV2, pod.Spec.Containers[0].Name) + } + + if pod.Spec.Containers[0].Image != "app-image-updated" { + t.Errorf("expected image app-image-updated, got %s", pod.Spec.Containers[0].Image) + } +} + +func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{}, + }, + } + + m := NewMutator(pod) + + // Register edit first + m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "edited-image" + return nil + }) + + // Register presence later + m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + // It should work because presence happens before edits in Apply() + if len(pod.Spec.Containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(pod.Spec.Containers)) + } + + if pod.Spec.Containers[0].Image != "edited-image" { + t.Errorf("expected edited-image, got %s", pod.Spec.Containers[0].Image) + } +} + +func TestMutator_NilSafety(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "main"}}, + }, + } + m := NewMutator(pod) + + // These should all be no-ops and not panic + m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) + m.EditContainers(selectors.AllContainers(), nil) + m.EditPodSpec(nil) + m.EditObjectMetadata(nil) + + err := m.Apply() + assert.NoError(t, err) +} + +func TestMutator_CrossFeatureOrdering(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "v1"}}, + }, + } + + m := NewMutator(pod) + + // Feature A: sets image to v2 + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + + // Feature B: sets image to v3 + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v3" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + // Feature B should win + assert.Equal(t, "v3", pod.Spec.Containers[0].Image) +} + +func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "original-name"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + } + + m := NewMutator(pod) + + var executionOrder []string + + // Register in reverse order of expected execution + m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { + executionOrder = append(executionOrder, "container") + return nil + }) + m.EditPodSpec(func(_ *editors.PodSpecEditor) error { + executionOrder = append(executionOrder, "podspec") + return nil + }) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + executionOrder = append(executionOrder, "podmeta") + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + expectedOrder := []string{ + "podmeta", + "podspec", + "container", + } + assert.Equal(t, expectedOrder, executionOrder) +} + +func TestMutator_CrossFeatureVisibility(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app"}}, + }, + } + + m := NewMutator(pod) + + // Feature A renames container + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "app-v2" + return nil + }) + + // Feature B selects by the new name - this should work! + m.beginFeature() + m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2-image" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + assert.Equal(t, "app-v2", pod.Spec.Containers[0].Name) + assert.Equal(t, "v2-image", pod.Spec.Containers[0].Image) +} + +func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{}, + }, + } + + m := NewMutator(pod) + + // 1. Add init-1 + m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) + + // 2. Edit init-1 (it's present in the same feature's phase) + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-edited" + return nil + }) + + // 3. Rename it inside the edit phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Name = "init-1-renamed" + return nil + }) + + // 4. Selector targeting "init-1" should still match because of snapshot in same phase + m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v1-final" + return nil + }) + + if err := m.Apply(); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + require.Len(t, pod.Spec.InitContainers, 1) + assert.Equal(t, "init-1-renamed", pod.Spec.InitContainers[0].Name) + assert.Equal(t, "v1-final", pod.Spec.InitContainers[0].Image) +} diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go new file mode 100644 index 00000000..fd896722 --- /dev/null +++ b/pkg/primitives/pod/resource.go @@ -0,0 +1,139 @@ +package pod + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator handles the immutable nature of pod specs. +// +// For new pods (empty ResourceVersion), the entire desired state is applied. +// For existing pods, only metadata (labels and annotations) is propagated +// because pod spec fields are largely immutable after creation. +func DefaultFieldApplicator(current, desired *corev1.Pod) error { + if current.ResourceVersion == "" { + *current = *desired.DeepCopy() + return nil + } + // Pod spec is largely immutable; only propagate metadata changes + current.Labels = desired.Labels + current.Annotations = desired.Annotations + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes Pod within a controller's +// reconciliation loop. +// +// It implements several component interfaces to integrate with the operator-component-framework: +// - component.Resource: for basic identity and mutation behavior. +// - component.Alive: for health and readiness tracking. +// - component.Suspendable: for deletion-based deactivation. +// - component.DataExtractable: for exporting information after successful reconciliation. +// +// This resource handles the lifecycle of a Pod, including initial creation, +// updates via feature mutations, and status monitoring. +type Resource struct { + base *generic.WorkloadResource[*corev1.Pod, *Mutator] +} + +// Identity returns a unique identifier for the Pod in the format +// "v1/Pod//". +// +// This identifier is used by the framework's internal tracking and recording +// mechanisms to distinguish this specific Pod from other resources +// managed by the same component. +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a copy of the underlying Kubernetes Pod object. +// +// The returned object implements the client.Object interface, making it +// fully compatible with controller-runtime's Client for operations like +// Get, Create, Update, and Patch. +// +// This method is called by the framework to obtain the current state +// of the resource before applying mutations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes Pod into the desired state. +// +// The mutation process follows a specific order: +// 1. Core State: The current object is reset to the desired base state, or +// modified via a custom customFieldApplicator if one is configured. +// 2. Feature Mutations: All registered feature-based mutations are applied, +// allowing for granular, version-gated changes to the Pod. +// 3. Suspension: If the resource is in a suspending state, the suspension +// logic is applied. +// +// This method is invoked by the framework during the "Update" phase of +// reconciliation. It ensures that the in-memory object reflects all +// configuration and feature requirements before it is sent to the API server. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus evaluates if the Pod has successfully reached its desired state. +// +// By default, it uses DefaultConvergingStatusHandler, which checks the Pod's phase +// and container statuses to determine health. +// +// The return value includes a descriptive status (Healthy, Creating, Updating, or Failing) +// and a human-readable reason, which are used to update the component's conditions. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.AliveStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// GraceStatus provides a health assessment of the Pod when it has not yet +// reached full readiness. +// +// By default, it uses DefaultGraceStatusHandler, which categorizes the current state into: +// - GraceStatusDegraded: Pod is Running but not all containers are Ready. +// - GraceStatusDown: Pod phase is not Running. +func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { + return r.base.GraceStatus() +} + +// DeleteOnSuspend determines whether the Pod should be deleted from the +// cluster when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns true because +// pods cannot be paused — they must be deleted when suspended. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend triggers the deactivation of the Pod. +// +// It registers a mutation that will be executed during the next Mutate call. +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// because pods are deleted on suspend rather than mutated. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus monitors the progress of the suspension process. +// +// By default, it uses DefaultSuspensionStatusHandler, which always reports +// Suspended because pod suspension is handled by deletion. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes registered data extraction functions to harvest information +// from the reconciled Pod. +// +// This is called by the framework after a successful reconciliation of the +// resource. It allows the component to export details (like pod IP, node name, +// or status fields) that might be needed by other resources or higher-level +// controllers. +// +// Data extractors are provided with a deep copy of the current Pod to +// prevent accidental mutations during the extraction process. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From 16081f6adda8feffb1ba82fbca50ab6d7e8fdf16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:44 +0000 Subject: [PATCH 02/24] Add Pod primitive documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 244 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/primitives/pod.md diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md new file mode 100644 index 00000000..8ea546fd --- /dev/null +++ b/docs/primitives/pod.md @@ -0,0 +1,244 @@ +# Pod Primitive + +The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly. It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and metadata. + +Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage pod objects (e.g. debugging utilities, node-local agents). + +## Capabilities + +| Capability | Detail | +|-----------------------|-------------------------------------------------------------------------------------------------| +| **Health tracking** | Monitors pod phase and container statuses; reports `Healthy`, `Creating`, `Updating`, or `Failing` | +| **Graceful rollouts** | Detects degraded or down states via grace status handler | +| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations) | + +## Building a Pod Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + +base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "debug-pod", + Namespace: owner.Namespace, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "debug", + Image: "busybox:latest", + }, + }, + }, +} + +resource, err := pod.NewBuilder(base). + WithFieldApplicationFlavor(pod.PreserveCurrentLabels). + WithMutation(MyFeatureMutation(owner.Spec.Version)). + Build() +``` + +## Default Field Application + +`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec fields are largely immutable after creation. + +Use `WithCustomFieldApplicator` when additional metadata fields need to be selectively propagated: + +```go +resource, err := pod.NewBuilder(base). + WithCustomFieldApplicator(func(current, desired *corev1.Pod) error { + current.Labels = desired.Labels + // Selectively preserve some annotations + current.Annotations = desired.Annotations + return nil + }). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. + +The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: + +```go +func MyFeatureMutation(version string) pod.Mutation { + return pod.Mutation{ + Name: "my-feature", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *pod.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 DebugMutation(version string, enabled bool) pod.Mutation { + return pod.Mutation{ + Name: "debug-mode", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *pod.Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "DEBUG", Value: "true"}) + return nil + }, + } +} +``` + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded. This ensures structural consistency across mutations. + +| Step | Category | What it affects | +|---|---|---| +| 1 | Object metadata edits | Labels and annotations on the `Pod` object | +| 2 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 3 | Regular container presence | Adding or removing containers from `spec.containers` | +| 4 | Regular container edits | Env vars, args, resources (snapshot taken after step 3) | +| 5 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) | + +Container edits (steps 4 and 6) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. + +## Editors + +### PodSpecEditor + +Manages pod-level configuration via `m.EditPodSpec`. + +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. + +```go +m.EditPodSpec(func(e *editors.PodSpecEditor) error { + e.SetServiceAccountName("my-service-account") + e.EnsureVolume(corev1.Volume{ + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "app-config"}, + }, + }, + }) + return nil +}) +``` + +### ContainerEditor + +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). + +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. + +```go +m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.EnsureEnvVar(corev1.EnvVar{Name: "LOG_LEVEL", Value: "info"}) + e.EnsureArg("--metrics-port=9090") + e.SetResourceLimit(corev1.ResourceCPU, resource.MustParse("500m")) + return nil +}) +``` + +For fields not covered by the typed API (such as volume mounts), use `Raw()`: + +```go +m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().VolumeMounts = append(e.Raw().VolumeMounts, corev1.VolumeMount{ + Name: "config", + MountPath: "/etc/config", + }) + return nil +}) +``` + +### ObjectMetaEditor + +Modifies labels and annotations via `m.EditObjectMetadata`. + +Available methods: `EnsureLabel`, `RemoveLabel`, `EnsureAnnotation`, `RemoveAnnotation`, `Raw`. + +```go +m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil +}) +``` + +### Raw Escape Hatch + +All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the spec. + +```go +m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().SecurityContext = &corev1.SecurityContext{ + ReadOnlyRootFilesystem: ptr.To(true), + } + return nil +}) +``` + +## Convenience Methods + +The `Mutator` also exposes convenience wrappers that target all containers at once: + +| Method | Equivalent to | +|-------------------------------|---------------------------------------------------------------| +| `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | +| `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | +| `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | +| `RemoveContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `RemoveArg(arg)` | + +## Suspension + +Pods cannot be paused. The default behavior deletes the pod when the component is suspended. + +- `DefaultDeleteOnSuspendHandler`: returns `true` — pod is deleted on suspend. +- `DefaultSuspendMutationHandler`: no-op (deletion is handled by the framework). +- `DefaultSuspensionStatusHandler`: always returns `{Suspended, "Pod deleted on suspend"}`. + +## 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 := pod.NewBuilder(base). + WithFieldApplicationFlavor(pod.PreserveCurrentLabels). + Build() +``` + +### PreserveCurrentAnnotations + +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. + +```go +resource, err := pod.NewBuilder(base). + WithFieldApplicationFlavor(pod.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. + +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. The internal ordering within each mutation handles intra-mutation dependencies automatically. + +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly and reconciliation remains idempotent. + +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. + +**Pod spec immutability.** Most pod spec fields cannot be changed after creation. The `DefaultFieldApplicator` accounts for this by only propagating metadata changes to existing pods. New pods receive the full desired state. From d20af431d96214a05a72aeb1181f884d2ba40531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:45:05 +0000 Subject: [PATCH 03/24] Add Pod primitive example with controller, resource factory, and mutations Demonstrates pod lifecycle management including version-gated mutations, tracing sidecar injection, field preservation flavors, and deletion-based suspension. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/pod-primitive/README.md | 32 +++++ examples/pod-primitive/app/controller.go | 54 +++++++++ examples/pod-primitive/app/owner.go | 20 ++++ examples/pod-primitive/features/mutations.go | 53 +++++++++ examples/pod-primitive/main.go | 118 +++++++++++++++++++ examples/pod-primitive/resources/pod.go | 64 ++++++++++ 6 files changed, 341 insertions(+) create mode 100644 examples/pod-primitive/README.md create mode 100644 examples/pod-primitive/app/controller.go create mode 100644 examples/pod-primitive/app/owner.go create mode 100644 examples/pod-primitive/features/mutations.go create mode 100644 examples/pod-primitive/main.go create mode 100644 examples/pod-primitive/resources/pod.go diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md new file mode 100644 index 00000000..403c6833 --- /dev/null +++ b/examples/pod-primitive/README.md @@ -0,0 +1,32 @@ +# Pod Primitive Example + +This example demonstrates the usage of the `pod` primitive within the operator component framework. +It shows how to manage a Kubernetes Pod as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing a Pod with basic metadata and spec. +- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars) using the `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). +- **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). +- **Data Extraction**: Harvesting information from the reconciled resource. + +## Directory Structure + +- `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. +- `features/`: Contains modular feature definitions: + - `mutations.go`: sidecar injection, env vars, and version-based image updates. +- `resources/`: Contains the central `NewPodResource` factory that assembles all features using the `pod.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/pod-primitive/main.go +``` + +This will: +1. Initialize a fake Kubernetes client. +2. Create an `ExampleApp` owner object. +3. Reconcile the `ExampleApp` components through multiple spec changes. +4. Print the resulting status conditions. diff --git a/examples/pod-primitive/app/controller.go b/examples/pod-primitive/app/controller.go new file mode 100644 index 00000000..3a69a2f5 --- /dev/null +++ b/examples/pod-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the pod 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 + + // NewPodResource is a factory function to create the pod resource. + // This allows us to inject the resource construction logic. + NewPodResource 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 pod resource for this owner. + podResource, err := r.NewPodResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the pod. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(podResource, 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/pod-primitive/app/owner.go b/examples/pod-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/pod-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/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go new file mode 100644 index 00000000..8c7302e8 --- /dev/null +++ b/examples/pod-primitive/features/mutations.go @@ -0,0 +1,53 @@ +package features + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + corev1 "k8s.io/api/core/v1" +) + +// TracingFeature adds a Jaeger sidecar to the pod. +func TracingFeature(enabled bool) pod.Mutation { + return pod.Mutation{ + Name: "Tracing", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *pod.Mutator) error { + m.EnsureContainer(corev1.Container{ + Name: "jaeger-agent", + Image: "jaegertracing/jaeger-agent:1.28", + }) + + m.EnsureContainerEnvVar(corev1.EnvVar{ + Name: "JAEGER_AGENT_HOST", + Value: "localhost", + }) + + return nil + }, + } +} + +// VersionFeature sets the image version and a label. +func VersionFeature(version string) pod.Mutation { + return pod.Mutation{ + Name: "Version", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *pod.Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { + ce.Raw().Image = fmt.Sprintf("my-app:%s", version) + return nil + }) + + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} diff --git a/examples/pod-primitive/main.go b/examples/pod-primitive/main.go new file mode 100644 index 00000000..350124a7 --- /dev/null +++ b/examples/pod-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the pod 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/pod-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/resources" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func main() { + // 1. Setup scheme and fake client 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 := corev1.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add core/v1 to scheme: %v\n", err) + os.Exit(1) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&app.ExampleApp{}). + Build() + + // 2. Create an example Owner object. + owner := &app.ExampleApp{ + Spec: app.ExampleAppSpec{ + Version: "1.2.3", + EnableTracing: true, + 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 pod resource factory. + NewPodResource: resources.NewPodResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableTracing: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableTracing: false, // Disable tracing + 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, Tracing=%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/pod-primitive/resources/pod.go b/examples/pod-primitive/resources/pod.go new file mode 100644 index 00000000..859d29a8 --- /dev/null +++ b/examples/pod-primitive/resources/pod.go @@ -0,0 +1,64 @@ +// Package resources provides resource implementations for the pod primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/pod-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +// NewPodResource constructs a pod primitive resource with all the features. +func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base pod object. + base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-pod", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: "my-app:latest", // Will be overwritten by VersionFeature + }, + }, + }, + } + + // 2. Initialize the pod builder. + builder := pod.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.VersionFeature(owner.Spec.Version)) + builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) + + // 4. Configure flavors (e.g., preserve labels/annotations if they were modified externally). + builder.WithFieldApplicationFlavor(pod.PreserveCurrentLabels) + builder.WithFieldApplicationFlavor(pod.PreserveCurrentAnnotations) + + // 5. Data extraction (optional). + builder.WithDataExtractor(func(p corev1.Pod) error { + fmt.Printf("Reconciling pod: %s, phase: %s\n", p.Name, p.Status.Phase) + + // Print the complete pod resource object as yaml + y, err := yaml.Marshal(p) + if err != nil { + return fmt.Errorf("failed to marshal pod to yaml: %w", err) + } + fmt.Printf("Complete Pod Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} From c1ba2ca46348855e5444d5b679cb7c55aacd3286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 03:30:38 +0000 Subject: [PATCH 04/24] add missing package comment --- examples/pod-primitive/features/mutations.go | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go index 8c7302e8..3a0a9127 100644 --- a/examples/pod-primitive/features/mutations.go +++ b/examples/pod-primitive/features/mutations.go @@ -1,3 +1,4 @@ +// Package features provides sample mutations for the pod primitive example. package features import ( From 1249e5b2d17b1f5d548327aed778a1694c53f459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:32:30 +0000 Subject: [PATCH 05/24] Address Copilot review feedback for pod primitive - Handle PodFailed and PodSucceeded phases explicitly in DefaultConvergingStatusHandler instead of falling through to "pending" - Check container readiness in DefaultGraceStatusHandler so healthy running pods are not incorrectly reported as degraded - Clone label/annotation maps in DefaultFieldApplicator to prevent shared mutable references between desired and current pods - Fix "custom customFieldApplicator" doc comment typo in Resource.Mutate - Clarify Mutation type comment to document nil-feature semantics - Fix findEnv test helper to return pointer to slice element, not copy - Add Kubernetes immutability note for container presence operations in pod.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 2 + pkg/primitives/pod/handlers.go | 50 ++++++++++++++++++++++--- pkg/primitives/pod/handlers_test.go | 57 ++++++++++++++++++++++++++++- pkg/primitives/pod/mutator.go | 6 ++- pkg/primitives/pod/mutator_test.go | 6 +-- pkg/primitives/pod/resource.go | 23 +++++++++--- 6 files changed, 126 insertions(+), 18 deletions(-) diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index 8ea546fd..3b7bff54 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -110,6 +110,8 @@ Within a single mutation, edit operations are grouped into categories and applie Container edits (steps 4 and 6) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. +**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall structure of `spec.containers` and `spec.initContainers`. Presence operations such as `EnsureContainer` / `RemoveContainer` (and the corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the Kubernetes API server will reject the update. Only per-container mutable fields (such as environment variables, args, and resources) should be edited on existing Pods. + ## Editors ### PodSpecEditor diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go index b8051cb5..ec63d345 100644 --- a/pkg/primitives/pod/handlers.go +++ b/pkg/primitives/pod/handlers.go @@ -51,7 +51,22 @@ func DefaultConvergingStatusHandler( } } - // Determine status based on converging operation + // Handle terminal phases explicitly. + switch pod.Status.Phase { + case corev1.PodFailed: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusFailing, + Reason: "Pod has failed", + }, nil + case corev1.PodSucceeded: + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusHealthy, + Reason: "Pod has completed successfully", + }, nil + } + + // Pod is Running with not-all-ready containers, or Pending. + // Determine status based on converging operation. switch op { case concepts.ConvergingOperationUpdated: return concepts.AliveStatusWithReason{ @@ -64,6 +79,12 @@ func DefaultConvergingStatusHandler( Reason: "Pod is starting", }, nil default: + if pod.Status.Phase == corev1.PodRunning { + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod running but not all containers ready", + }, nil + } return concepts.AliveStatusWithReason{ Status: concepts.AliveConvergingStatusCreating, Reason: "Pod is pending", @@ -75,22 +96,39 @@ func DefaultConvergingStatusHandler( // reached full readiness. // // It categorizes the current state into: -// - GraceStatusDegraded: Pod is Running but not all containers are Ready. +// - GraceStatusHealthy: Pod is Running and all containers are Ready. +// - GraceStatusDegraded: Pod is Running but not all containers are Ready, or container readiness is unknown. // - GraceStatusDown: Pod phase is not Running. // // This function is used as the default handler by the Resource if no custom handler is registered via // Builder.WithCustomGraceStatus. It can be reused within custom handlers to augment the default behavior. func DefaultGraceStatusHandler(pod *corev1.Pod) (concepts.GraceStatusWithReason, error) { - if pod.Status.Phase == corev1.PodRunning { + if pod.Status.Phase != corev1.PodRunning { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDown, + Reason: "Pod is not running", + }, nil + } + + if len(pod.Status.ContainerStatuses) == 0 { return concepts.GraceStatusWithReason{ Status: concepts.GraceStatusDegraded, - Reason: "Pod running but not all containers ready", + Reason: "Pod running but container readiness unknown", }, nil } + for _, cs := range pod.Status.ContainerStatuses { + if !cs.Ready { + return concepts.GraceStatusWithReason{ + Status: concepts.GraceStatusDegraded, + Reason: "Pod running but not all containers ready", + }, nil + } + } + return concepts.GraceStatusWithReason{ - Status: concepts.GraceStatusDown, - Reason: "Pod is not running", + Status: concepts.GraceStatusHealthy, + Reason: "Pod running and all containers ready", }, nil } diff --git a/pkg/primitives/pod/handlers_test.go b/pkg/primitives/pod/handlers_test.go index 3a62cc7f..ab077378 100644 --- a/pkg/primitives/pod/handlers_test.go +++ b/pkg/primitives/pod/handlers_test.go @@ -130,7 +130,29 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { }, }, wantStatus: concepts.AliveConvergingStatusCreating, - wantReason: "Pod is pending", + wantReason: "Pod running but not all containers ready", + }, + { + name: "failed pod phase", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodFailed, + }, + }, + wantStatus: concepts.AliveConvergingStatusFailing, + wantReason: "Pod has failed", + }, + { + name: "succeeded pod phase", + op: concepts.ConvergingOperationNone, + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodSucceeded, + }, + }, + wantStatus: concepts.AliveConvergingStatusHealthy, + wantReason: "Pod has completed successfully", }, } @@ -145,10 +167,26 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { } func TestDefaultGraceStatusHandler(t *testing.T) { - t.Run("degraded (running but not ready)", func(t *testing.T) { + t.Run("degraded (running, no container statuses)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusDegraded, got.Status) + assert.Equal(t, "Pod running but container readiness unknown", got.Reason) + }) + + t.Run("degraded (running, not all containers ready)", func(t *testing.T) { pod := &corev1.Pod{ Status: corev1.PodStatus{ Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + {Name: "sidecar", Ready: false}, + }, }, } got, err := DefaultGraceStatusHandler(pod) @@ -157,6 +195,21 @@ func TestDefaultGraceStatusHandler(t *testing.T) { assert.Equal(t, "Pod running but not all containers ready", got.Reason) }) + t.Run("healthy (running, all containers ready)", func(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + }, + }, + } + got, err := DefaultGraceStatusHandler(pod) + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, got.Status) + assert.Equal(t, "Pod running and all containers ready", got.Reason) + }) + t.Run("down (not running)", func(t *testing.T) { pod := &corev1.Pod{ Status: corev1.PodStatus{ diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index efb65ead..9bf26a3a 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -7,8 +7,10 @@ import ( corev1 "k8s.io/api/core/v1" ) -// Mutation defines a mutation that is applied to a pod Mutator -// only if its associated feature.ResourceFeature is enabled. +// Mutation defines a feature-aware mutation applied by a Pod Mutator. +// If constructed with a non-nil feature.ResourceFeature, it is applied only +// when that feature is enabled; if the feature is nil, the mutation is +// always applied. type Mutation feature.Mutation[*Mutator] type containerEdit struct { diff --git a/pkg/primitives/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go index 4ca445f3..4d53dda1 100644 --- a/pkg/primitives/pod/mutator_test.go +++ b/pkg/primitives/pod/mutator_test.go @@ -47,9 +47,9 @@ func TestMutator_EnvVars(t *testing.T) { assert.Len(t, env, 3) findEnv := func(name string) *corev1.EnvVar { - for _, e := range env { - if e.Name == name { - return &e + for i := range env { + if env[i].Name == name { + return &env[i] } } return nil diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go index fd896722..3a9c582e 100644 --- a/pkg/primitives/pod/resource.go +++ b/pkg/primitives/pod/resource.go @@ -17,12 +17,24 @@ func DefaultFieldApplicator(current, desired *corev1.Pod) error { *current = *desired.DeepCopy() return nil } - // Pod spec is largely immutable; only propagate metadata changes - current.Labels = desired.Labels - current.Annotations = desired.Annotations + // Pod spec is largely immutable; only propagate metadata changes. + // Clone maps to avoid sharing mutable references between desired and current. + current.Labels = cloneStringMap(desired.Labels) + current.Annotations = cloneStringMap(desired.Annotations) return nil } +func cloneStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + // Resource is a high-level abstraction for managing a Kubernetes Pod within a controller's // reconciliation loop. // @@ -64,7 +76,7 @@ func (r *Resource) Object() (client.Object, error) { // // The mutation process follows a specific order: // 1. Core State: The current object is reset to the desired base state, or -// modified via a custom customFieldApplicator if one is configured. +// modified via a customFieldApplicator if one is configured. // 2. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the Pod. // 3. Suspension: If the resource is in a suspending state, the suspension @@ -92,7 +104,8 @@ func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.A // reached full readiness. // // By default, it uses DefaultGraceStatusHandler, which categorizes the current state into: -// - GraceStatusDegraded: Pod is Running but not all containers are Ready. +// - GraceStatusHealthy: Pod is Running and all containers are Ready. +// - GraceStatusDegraded: Pod is Running but not all containers are Ready, or readiness is unknown. // - GraceStatusDown: Pod phase is not Running. func (r *Resource) GraceStatus() (concepts.GraceStatusWithReason, error) { return r.base.GraceStatus() From b4baf8bbdc9c6a4eaf32b2b339c31d0ffccd542a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:40:13 +0000 Subject: [PATCH 06/24] Add resource_test.go for pod primitive Exercises the Resource wrapper integration including: identity format, Object deep-copy semantics, Mutate pipeline with mutations and feature ordering, disabled feature skipping, default and custom handler wiring for converging status, grace status, suspension status, delete-on-suspend, suspend mutation, data extraction, custom field applicator, and default field applicator metadata-only propagation for existing pods. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/resource_test.go | 441 ++++++++++++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 pkg/primitives/pod/resource_test.go diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go new file mode 100644 index 00000000..48be1851 --- /dev/null +++ b/pkg/primitives/pod/resource_test.go @@ -0,0 +1,441 @@ +package pod + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newValidPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + } +} + +func TestResource_Identity(t *testing.T) { + res, err := NewBuilder(newValidPod()).Build() + require.NoError(t, err) + assert.Equal(t, "v1/Pod/test-ns/test-pod", res.Identity()) +} + +func TestResource_Object(t *testing.T) { + pod := newValidPod() + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + + obj, err := res.Object() + require.NoError(t, err) + + got, ok := obj.(*corev1.Pod) + require.True(t, ok) + assert.Equal(t, pod.Name, got.Name) + assert.Equal(t, pod.Namespace, got.Namespace) + + // Must be a deep copy. + got.Name = "changed" + assert.Equal(t, "test-pod", pod.Name) +} + +func TestResource_Mutate(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "add-env", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "FOO", Value: "BAR"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &corev1.Pod{} + err = res.Mutate(current) + require.NoError(t, err) + + assert.Equal(t, "test", current.Labels["app"]) + assert.Equal(t, "BAR", current.Spec.Containers[0].Env[0].Value) +} + +func TestResource_Mutate_DeepCopySemantics(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + current := &corev1.Pod{} + require.NoError(t, res.Mutate(current)) + + // Modifying current's labels must not affect the desired object. + current.Labels["app"] = "modified" + assert.Equal(t, "test", desired.Labels["app"]) +} + +func TestResource_Mutate_FeatureOrdering(t *testing.T) { + desired := newValidPod() + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "feature-a", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + e.Raw().Image = "v2" + return nil + }) + return nil + }, + }). + WithMutation(Mutation{ + Name: "feature-b", + Feature: feature.NewResourceFeature("v1", nil).When(true), + Mutate: func(m *Mutator) error { + m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { + if e.Raw().Image == "v2" { + e.Raw().Image = "v3" + } + return nil + }) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &corev1.Pod{} + require.NoError(t, res.Mutate(current)) + + assert.Equal(t, "v3", current.Spec.Containers[0].Image) +} + +func TestResource_Mutate_DisabledFeatureSkipped(t *testing.T) { + desired := newValidPod() + + res, err := NewBuilder(desired). + WithMutation(Mutation{ + Name: "disabled", + Feature: feature.NewResourceFeature("v1", nil).When(false), + Mutate: func(m *Mutator) error { + m.EnsureContainerEnvVar(corev1.EnvVar{Name: "SHOULD_NOT_EXIST", Value: "true"}) + return nil + }, + }). + Build() + require.NoError(t, err) + + current := &corev1.Pod{} + require.NoError(t, res.Mutate(current)) + + assert.Empty(t, current.Spec.Containers[0].Env) +} + +type podMockHandlers struct { + mock.Mock +} + +func (m *podMockHandlers) ConvergingStatus(op concepts.ConvergingOperation, p *corev1.Pod) (concepts.AliveStatusWithReason, error) { + args := m.Called(op, p) + return args.Get(0).(concepts.AliveStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) GraceStatus(p *corev1.Pod) (concepts.GraceStatusWithReason, error) { + args := m.Called(p) + return args.Get(0).(concepts.GraceStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) SuspensionStatus(p *corev1.Pod) (concepts.SuspensionStatusWithReason, error) { + args := m.Called(p) + return args.Get(0).(concepts.SuspensionStatusWithReason), args.Error(1) +} + +func (m *podMockHandlers) Suspend(mut *Mutator) error { + args := m.Called(mut) + return args.Error(0) +} + +func (m *podMockHandlers) DeleteOnSuspend(p *corev1.Pod) bool { + args := m.Called(p) + return args.Bool(0) +} + +func TestResource_ConvergingStatus(t *testing.T) { + pod := newValidPod() + pod.Status.Phase = corev1.PodRunning + pod.Status.ContainerStatuses = []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + } + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusReady := concepts.AliveStatusWithReason{Status: concepts.AliveConvergingStatusHealthy} + m.On("ConvergingStatus", concepts.ConvergingOperationUpdated, pod).Return(statusReady, nil) + + res, err := NewBuilder(pod). + WithCustomConvergeStatus(m.ConvergingStatus). + Build() + require.NoError(t, err) + + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.AliveConvergingStatusHealthy, status.Status) + }) + + t.Run("uses default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + status, err := res.ConvergingStatus(concepts.ConvergingOperationUpdated) + require.NoError(t, err) + assert.Equal(t, concepts.AliveConvergingStatusHealthy, status.Status) + }) +} + +func TestResource_GraceStatus(t *testing.T) { + pod := newValidPod() + pod.Status.Phase = corev1.PodRunning + pod.Status.ContainerStatuses = []corev1.ContainerStatus{ + {Name: "app", Ready: true}, + } + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusReady := concepts.GraceStatusWithReason{Status: concepts.GraceStatusHealthy} + m.On("GraceStatus", pod).Return(statusReady, nil) + + res, err := NewBuilder(pod). + WithCustomGraceStatus(m.GraceStatus). + Build() + require.NoError(t, err) + + status, err := res.GraceStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) + + t.Run("uses default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + status, err := res.GraceStatus() + require.NoError(t, err) + assert.Equal(t, concepts.GraceStatusHealthy, status.Status) + }) +} + +func TestResource_DeleteOnSuspend(t *testing.T) { + pod := newValidPod() + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + m.On("DeleteOnSuspend", pod).Return(false) + + res, err := NewBuilder(pod). + WithCustomSuspendDeletionDecision(m.DeleteOnSuspend). + Build() + require.NoError(t, err) + assert.False(t, res.DeleteOnSuspend()) + m.AssertExpectations(t) + }) + + t.Run("uses default (true for pods)", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + assert.True(t, res.DeleteOnSuspend()) + }) +} + +func TestResource_Suspend(t *testing.T) { + pod := newValidPod() + + t.Run("registers mutation and Mutate applies it using default handler", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := pod.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + // Default suspend mutation is a no-op for pods (they are deleted instead). + // Just verify Mutate succeeds. + }) + + t.Run("uses custom suspend mutation handler", func(t *testing.T) { + m := &podMockHandlers{} + m.On("Suspend", mock.Anything).Return(nil).Run(func(args mock.Arguments) { + mut := args.Get(0).(*Mutator) + mut.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("suspended", "true") + return nil + }) + }) + + res, err := NewBuilder(pod). + WithCustomSuspendMutation(m.Suspend). + Build() + require.NoError(t, err) + err = res.Suspend() + require.NoError(t, err) + + current := pod.DeepCopy() + err = res.Mutate(current) + require.NoError(t, err) + + m.AssertExpectations(t) + assert.Equal(t, "true", current.Labels["suspended"]) + }) +} + +func TestResource_SuspensionStatus(t *testing.T) { + pod := newValidPod() + + t.Run("calls custom handler", func(t *testing.T) { + m := &podMockHandlers{} + statusSuspended := concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended} + m.On("SuspensionStatus", pod).Return(statusSuspended, nil) + + res, err := NewBuilder(pod). + WithCustomSuspendStatus(m.SuspensionStatus). + Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + m.AssertExpectations(t) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + }) + + t.Run("uses default", func(t *testing.T) { + res, err := NewBuilder(pod).Build() + require.NoError(t, err) + status, err := res.SuspensionStatus() + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, status.Status) + assert.Equal(t, "Pod deleted on suspend", status.Reason) + }) +} + +func TestResource_ExtractData(t *testing.T) { + pod := newValidPod() + + extractedImage := "" + res, err := NewBuilder(pod). + WithDataExtractor(func(p corev1.Pod) error { + extractedImage = p.Spec.Containers[0].Image + return nil + }). + Build() + require.NoError(t, err) + + err = res.ExtractData() + require.NoError(t, err) + assert.Equal(t, "nginx:latest", extractedImage) +} + +func TestResource_ExtractData_Error(t *testing.T) { + res, err := NewBuilder(newValidPod()). + WithDataExtractor(func(_ corev1.Pod) 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") +} + +func TestResource_CustomFieldApplicator(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + + applicatorCalled := false + res, err := NewBuilder(desired). + WithCustomFieldApplicator(func(current, d *corev1.Pod) error { + applicatorCalled = true + current.Name = d.Name + current.Namespace = d.Namespace + current.Spec = *d.Spec.DeepCopy() + // Intentionally do not copy labels. + return nil + }). + Build() + require.NoError(t, err) + + current := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"external": "label"}, + }, + } + err = res.Mutate(current) + require.NoError(t, err) + + assert.True(t, applicatorCalled) + assert.Equal(t, "nginx:latest", current.Spec.Containers[0].Image) + assert.Equal(t, "label", current.Labels["external"]) + assert.NotContains(t, current.Labels, "app") +} + +func TestResource_CustomFieldApplicator_Error(t *testing.T) { + res, err := NewBuilder(newValidPod()). + WithCustomFieldApplicator(func(_, _ *corev1.Pod) error { + return errors.New("applicator error") + }). + Build() + require.NoError(t, err) + + err = res.Mutate(&corev1.Pod{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "applicator error") +} + +func TestResource_DefaultFieldApplicator_ExistingPod(t *testing.T) { + desired := newValidPod() + desired.Labels = map[string]string{"app": "test"} + desired.Annotations = map[string]string{"note": "hello"} + + res, err := NewBuilder(desired).Build() + require.NoError(t, err) + + // Existing pod has a ResourceVersion, so only metadata should be propagated. + current := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + ResourceVersion: "12345", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "old-image"}, + }, + }, + } + err = res.Mutate(current) + require.NoError(t, err) + + // Metadata should be updated. + assert.Equal(t, "test", current.Labels["app"]) + assert.Equal(t, "hello", current.Annotations["note"]) + // Spec should be preserved (immutable). + assert.Equal(t, "old-image", current.Spec.Containers[0].Image) +} From 63411c59ec8c79f446f1c0968d04bfcc0c67810a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:29:48 +0000 Subject: [PATCH 07/24] Address Copilot review feedback round 2 - Fix DefaultConvergingStatusHandler reporting Healthy when ContainerStatuses is empty; now returns Creating to match DefaultGraceStatusHandler behavior - Fix misleading "Pod is being recreated" reason to "Pod is being updated" - Rewrite example mutations to only mutate metadata, avoiding immutable Pod spec fields (containers, env, args) - Fix Pod immutability docs: env/args/resources are immutable, not mutable - Fix WithCustomFieldApplicator doc example to deep-copy maps - Assert Build() errors in flavors_test.go instead of ignoring them Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 16 +++++++-- examples/pod-primitive/features/mutations.go | 35 ++++++++------------ pkg/primitives/pod/flavors_test.go | 15 +++++---- pkg/primitives/pod/handlers.go | 9 ++++- pkg/primitives/pod/handlers_test.go | 8 ++--- 5 files changed, 48 insertions(+), 35 deletions(-) diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index 3b7bff54..879ca483 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -49,9 +49,19 @@ Use `WithCustomFieldApplicator` when additional metadata fields need to be selec ```go resource, err := pod.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *corev1.Pod) error { - current.Labels = desired.Labels + if desired.Labels != nil { + current.Labels = make(map[string]string, len(desired.Labels)) + for k, v := range desired.Labels { + current.Labels[k] = v + } + } // Selectively preserve some annotations - current.Annotations = desired.Annotations + if desired.Annotations != nil { + current.Annotations = make(map[string]string, len(desired.Annotations)) + for k, v := range desired.Annotations { + current.Annotations[k] = v + } + } return nil }). Build() @@ -110,7 +120,7 @@ Within a single mutation, edit operations are grouped into categories and applie Container edits (steps 4 and 6) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. -**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall structure of `spec.containers` and `spec.initContainers`. Presence operations such as `EnsureContainer` / `RemoveContainer` (and the corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the Kubernetes API server will reject the update. Only per-container mutable fields (such as environment variables, args, and resources) should be edited on existing Pods. +**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`, `args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize); treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes. ## Editors diff --git a/examples/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go index 3a0a9127..4f1f5871 100644 --- a/examples/pod-primitive/features/mutations.go +++ b/examples/pod-primitive/features/mutations.go @@ -1,53 +1,46 @@ // Package features provides sample mutations for the pod primitive example. +// +// These examples only mutate object metadata because most Pod spec fields +// (including containers, env, args, and resources) are immutable after creation. +// To change spec fields on an existing Pod the controller must delete and +// recreate it. package features import ( - "fmt" - "github.com/sourcehawk/operator-component-framework/pkg/feature" "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" - "github.com/sourcehawk/operator-component-framework/pkg/mutation/selectors" "github.com/sourcehawk/operator-component-framework/pkg/primitives/pod" - corev1 "k8s.io/api/core/v1" ) -// TracingFeature adds a Jaeger sidecar to the pod. +// TracingFeature marks the pod as tracing-enabled via metadata. +// Controllers or sidecar injectors can watch the label and handle +// any required Pod replacement or injection. func TracingFeature(enabled bool) pod.Mutation { return pod.Mutation{ Name: "Tracing", Feature: feature.NewResourceFeature("any", nil).When(enabled), Mutate: func(m *pod.Mutator) error { - m.EnsureContainer(corev1.Container{ - Name: "jaeger-agent", - Image: "jaegertracing/jaeger-agent:1.28", - }) - - m.EnsureContainerEnvVar(corev1.EnvVar{ - Name: "JAEGER_AGENT_HOST", - Value: "localhost", + m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { + meta.EnsureLabel("sidecar.jaegertracing.io/inject", "true") + return nil }) - return nil }, } } -// VersionFeature sets the image version and a label. +// VersionFeature records the desired version on the pod as a label. +// It avoids mutating container images directly, which would be invalid +// on an already-created Pod. func VersionFeature(version string) pod.Mutation { return pod.Mutation{ Name: "Version", Feature: feature.NewResourceFeature(version, nil), Mutate: func(m *pod.Mutator) error { - m.EditContainers(selectors.ContainerNamed("app"), func(ce *editors.ContainerEditor) error { - ce.Raw().Image = fmt.Sprintf("my-app:%s", version) - return nil - }) - m.EditObjectMetadata(func(meta *editors.ObjectMetaEditor) error { meta.EnsureLabel("app.kubernetes.io/version", version) return nil }) - return nil }, } diff --git a/pkg/primitives/pod/flavors_test.go b/pkg/primitives/pod/flavors_test.go index e232c573..9f19448b 100644 --- a/pkg/primitives/pod/flavors_test.go +++ b/pkg/primitives/pod/flavors_test.go @@ -28,11 +28,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { }, } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(PreserveCurrentLabels). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, "desired", current.Labels["app"]) @@ -57,12 +58,13 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return nil } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor1). WithFieldApplicationFlavor(flavor2). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.NoError(t, err) assert.Equal(t, []string{"flavor1", "flavor2"}, order) }) @@ -74,11 +76,12 @@ func TestMutate_OrderingAndFlavors(t *testing.T) { return flavorErr } - res, _ := NewBuilder(desired). + res, err := NewBuilder(desired). WithFieldApplicationFlavor(flavor). Build() + require.NoError(t, err) - err := res.Mutate(current) + err = res.Mutate(current) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply field application flavor") assert.True(t, errors.Is(err, flavorErr)) diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go index ec63d345..47372fae 100644 --- a/pkg/primitives/pod/handlers.go +++ b/pkg/primitives/pod/handlers.go @@ -36,6 +36,13 @@ func DefaultConvergingStatusHandler( // Check if pod is running and all containers are ready if pod.Status.Phase == corev1.PodRunning { + if len(pod.Status.ContainerStatuses) == 0 { + // Container statuses not yet populated; readiness is unknown. + return concepts.AliveStatusWithReason{ + Status: concepts.AliveConvergingStatusCreating, + Reason: "Pod running but container readiness unknown", + }, nil + } allReady := true for _, cs := range pod.Status.ContainerStatuses { if !cs.Ready { @@ -71,7 +78,7 @@ func DefaultConvergingStatusHandler( case concepts.ConvergingOperationUpdated: return concepts.AliveStatusWithReason{ Status: concepts.AliveConvergingStatusUpdating, - Reason: "Pod is being recreated", + Reason: "Pod is being updated", }, nil case concepts.ConvergingOperationCreated: return concepts.AliveStatusWithReason{ diff --git a/pkg/primitives/pod/handlers_test.go b/pkg/primitives/pod/handlers_test.go index ab077378..bce32fc6 100644 --- a/pkg/primitives/pod/handlers_test.go +++ b/pkg/primitives/pod/handlers_test.go @@ -32,15 +32,15 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { wantReason: "Pod is running and all containers are ready", }, { - name: "healthy - running with no container statuses", + name: "creating - running with no container statuses", op: concepts.ConvergingOperationNone, pod: &corev1.Pod{ Status: corev1.PodStatus{ Phase: corev1.PodRunning, }, }, - wantStatus: concepts.AliveConvergingStatusHealthy, - wantReason: "Pod is running and all containers are ready", + wantStatus: concepts.AliveConvergingStatusCreating, + wantReason: "Pod running but container readiness unknown", }, { name: "failing - crash loop backoff", @@ -104,7 +104,7 @@ func TestDefaultConvergingStatusHandler(t *testing.T) { }, }, wantStatus: concepts.AliveConvergingStatusUpdating, - wantReason: "Pod is being recreated", + wantReason: "Pod is being updated", }, { name: "creating - on none operation when pending", From 5322c04da371661ea22a35484023b6d698b2852e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:48:50 +0000 Subject: [PATCH 08/24] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/resource.go | 7 +++- pkg/primitives/pod/resource_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go index 3a9c582e..f0e19dc3 100644 --- a/pkg/primitives/pod/resource.go +++ b/pkg/primitives/pod/resource.go @@ -7,7 +7,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator handles the immutable nature of pod specs. +// DefaultFieldApplicator handles the immutable nature of pod specs while +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) +// and shared-controller fields (OwnerReferences, Finalizers) from the original +// current object. // // For new pods (empty ResourceVersion), the entire desired state is applied. // For existing pods, only metadata (labels and annotations) is propagated @@ -17,10 +20,12 @@ func DefaultFieldApplicator(current, desired *corev1.Pod) error { *current = *desired.DeepCopy() return nil } + original := current.DeepCopy() // Pod spec is largely immutable; only propagate metadata changes. // Clone maps to avoid sharing mutable references between desired and current. current.Labels = cloneStringMap(desired.Labels) current.Annotations = cloneStringMap(desired.Annotations) + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go index 48be1851..f9b2a79f 100644 --- a/pkg/primitives/pod/resource_test.go +++ b/pkg/primitives/pod/resource_test.go @@ -409,6 +409,57 @@ func TestResource_CustomFieldApplicator_Error(t *testing.T) { assert.Contains(t, err.Error(), "applicator error") } +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + } + desired := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{"app": "test"}, + Annotations: map[string]string{"note": "hello"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired labels and annotations are applied + assert.Equal(t, "test", current.Labels["app"]) + assert.Equal(t, "hello", current.Annotations["note"]) + + // 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_DefaultFieldApplicator_ExistingPod(t *testing.T) { desired := newValidPod() desired.Labels = map[string]string{"app": "test"} From 1668b17e403d2f70ca57d9cdb014eeb2e0c3ea70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 22:16:01 +0000 Subject: [PATCH 09/24] address Copilot review feedback on pod primitive Fix inaccurate comments and documentation: - README: describe actual label mutations instead of sidecars/env vars - mutations.go: clarify Kubernetes allows some in-place spec updates (e.g. images) - handlers.go: fix GoDoc to say "updated" instead of "recreated" - resources/pod.go: remove misleading "overwritten by VersionFeature" comment Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/pod-primitive/README.md | 4 ++-- examples/pod-primitive/features/mutations.go | 10 +++++----- examples/pod-primitive/resources/pod.go | 2 +- pkg/primitives/pod/handlers.go | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md index 403c6833..e7447b14 100644 --- a/examples/pod-primitive/README.md +++ b/examples/pod-primitive/README.md @@ -4,7 +4,7 @@ This example demonstrates the usage of the `pod` primitive within the operator c It shows how to manage a Kubernetes Pod as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a Pod with basic metadata and spec. -- **Feature Mutations**: Applying version-gated or conditional changes (sidecars, env vars) using the `Mutator`. +- **Feature Mutations**: Applying version-gated or conditional metadata changes (labels) using the `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). - **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). - **Data Extraction**: Harvesting information from the reconciled resource. @@ -13,7 +13,7 @@ It shows how to manage a Kubernetes Pod as a component of a larger application, - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: sidecar injection, env vars, and version-based image updates. + - `mutations.go`: label mutations applied to the Pod using the `Mutator`. - `resources/`: Contains the central `NewPodResource` factory that assembles all features using the `pod.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. diff --git a/examples/pod-primitive/features/mutations.go b/examples/pod-primitive/features/mutations.go index 4f1f5871..aaebad6a 100644 --- a/examples/pod-primitive/features/mutations.go +++ b/examples/pod-primitive/features/mutations.go @@ -1,9 +1,9 @@ // Package features provides sample mutations for the pod primitive example. // // These examples only mutate object metadata because most Pod spec fields -// (including containers, env, args, and resources) are immutable after creation. -// To change spec fields on an existing Pod the controller must delete and -// recreate it. +// are immutable after creation. Kubernetes does allow a small set of in-place +// updates (notably container images), but other fields such as env, args, and +// resources require the Pod to be deleted and recreated. package features import ( @@ -30,8 +30,8 @@ func TracingFeature(enabled bool) pod.Mutation { } // VersionFeature records the desired version on the pod as a label. -// It avoids mutating container images directly, which would be invalid -// on an already-created Pod. +// It avoids mutating container images directly; while Kubernetes allows +// in-place image updates, this example keeps mutations in metadata only. func VersionFeature(version string) pod.Mutation { return pod.Mutation{ Name: "Version", diff --git a/examples/pod-primitive/resources/pod.go b/examples/pod-primitive/resources/pod.go index 859d29a8..2b3f16bb 100644 --- a/examples/pod-primitive/resources/pod.go +++ b/examples/pod-primitive/resources/pod.go @@ -28,7 +28,7 @@ func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { Containers: []corev1.Container{ { Name: "app", - Image: "my-app:latest", // Will be overwritten by VersionFeature + Image: "my-app:latest", // Base image for the app container }, }, }, diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go index 47372fae..ad94aaa2 100644 --- a/pkg/primitives/pod/handlers.go +++ b/pkg/primitives/pod/handlers.go @@ -10,7 +10,7 @@ import ( // It considers a Pod: // - Healthy: when Status.Phase is Running AND all container statuses report Ready. // - Failing: when any container is in CrashLoopBackOff or has terminated with an error. -// - Updating: when the converging operation indicates the pod is being recreated. +// - Updating: when the converging operation is concepts.ConvergingOperationUpdated. // - Creating: when Status.Phase is Pending and no restart failures are detected. // // This function is used as the default handler by the Resource if no custom handler is registered via From fda4c2b4a2d831f11ca8061a4b9d7b80573e128b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 03:23:41 +0000 Subject: [PATCH 10/24] replace t.Fatalf/t.Errorf with testify require/assert in pod mutator tests Address Copilot review feedback: convert all raw testing.T assertion calls to testify helpers for consistent test style and better failure output across the pod primitive test suite. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/mutator_test.go | 88 ++++++++++-------------------- 1 file changed, 30 insertions(+), 58 deletions(-) diff --git a/pkg/primitives/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go index 4d53dda1..73821816 100644 --- a/pkg/primitives/pod/mutator_test.go +++ b/pkg/primitives/pod/mutator_test.go @@ -204,13 +204,10 @@ func TestMutator_InitContainers(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) - if pod.Spec.InitContainers[0].Image != newImage { - t.Errorf("expected image %s, got %s", newImage, pod.Spec.InitContainers[0].Image) - } + assert.Equal(t, newImage, pod.Spec.InitContainers[0].Image) } func TestMutator_ContainerPresence(t *testing.T) { @@ -232,21 +229,16 @@ func TestMutator_ContainerPresence(t *testing.T) { // Append m.EnsureContainer(corev1.Container{Name: "new-container", Image: newImage}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) - if len(pod.Spec.Containers) != 2 { - t.Fatalf("expected 2 containers, got %d", len(pod.Spec.Containers)) - } + require.Len(t, pod.Spec.Containers, 2) - if pod.Spec.Containers[0].Name != "app" || pod.Spec.Containers[0].Image != "app-new-image" { - t.Errorf("unexpected container at index 0: %+v", pod.Spec.Containers[0]) - } + assert.Equal(t, "app", pod.Spec.Containers[0].Name) + assert.Equal(t, "app-new-image", pod.Spec.Containers[0].Image) - if pod.Spec.Containers[1].Name != "new-container" || pod.Spec.Containers[1].Image != newImage { - t.Errorf("unexpected container at index 1: %+v", pod.Spec.Containers[1]) - } + assert.Equal(t, "new-container", pod.Spec.Containers[1].Name) + assert.Equal(t, newImage, pod.Spec.Containers[1].Image) } func TestMutator_InitContainerPresence(t *testing.T) { @@ -262,17 +254,12 @@ func TestMutator_InitContainerPresence(t *testing.T) { m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) - if len(pod.Spec.InitContainers) != 1 { - t.Fatalf("expected 1 init container, got %d", len(pod.Spec.InitContainers)) - } + require.Len(t, pod.Spec.InitContainers, 1) - if pod.Spec.InitContainers[0].Name != "init-2" { - t.Errorf("expected init-2, got %s", pod.Spec.InitContainers[0].Name) - } + assert.Equal(t, "init-2", pod.Spec.InitContainers[0].Name) } func TestMutator_SelectorSnapshotSemantics(t *testing.T) { @@ -305,17 +292,11 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } - - if pod.Spec.Containers[0].Name != appV2 { - t.Errorf("expected name %s, got %s", appV2, pod.Spec.Containers[0].Name) - } + err := m.Apply() + require.NoError(t, err) - if pod.Spec.Containers[0].Image != "app-image-updated" { - t.Errorf("expected image app-image-updated, got %s", pod.Spec.Containers[0].Image) - } + assert.Equal(t, appV2, pod.Spec.Containers[0].Name) + assert.Equal(t, "app-image-updated", pod.Spec.Containers[0].Image) } func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { @@ -336,18 +317,13 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { // Register presence later m.EnsureContainer(corev1.Container{Name: "new-app", Image: "original-image"}) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) // It should work because presence happens before edits in Apply() - if len(pod.Spec.Containers) != 1 { - t.Fatalf("expected 1 container, got %d", len(pod.Spec.Containers)) - } + require.Len(t, pod.Spec.Containers, 1) - if pod.Spec.Containers[0].Image != "edited-image" { - t.Errorf("expected edited-image, got %s", pod.Spec.Containers[0].Image) - } + assert.Equal(t, "edited-image", pod.Spec.Containers[0].Image) } func TestMutator_NilSafety(t *testing.T) { @@ -391,9 +367,8 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) // Feature B should win assert.Equal(t, "v3", pod.Spec.Containers[0].Image) @@ -425,9 +400,8 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) expectedOrder := []string{ "podmeta", @@ -460,9 +434,8 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) assert.Equal(t, "app-v2", pod.Spec.Containers[0].Name) assert.Equal(t, "v2-image", pod.Spec.Containers[0].Image) @@ -498,9 +471,8 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { return nil }) - if err := m.Apply(); err != nil { - t.Fatalf("Apply failed: %v", err) - } + err := m.Apply() + require.NoError(t, err) require.Len(t, pod.Spec.InitContainers, 1) assert.Equal(t, "init-1-renamed", pod.Spec.InitContainers[0].Name) From 034f6fb55a8c97bf3d883c68d3d720a3e24c836c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:08:32 +0000 Subject: [PATCH 11/24] preserve pod status subresource in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 2 +- pkg/primitives/pod/resource.go | 7 +++-- pkg/primitives/pod/resource_test.go | 48 +++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index 879ca483..6590b146 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -42,7 +42,7 @@ resource, err := pod.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec fields are largely immutable after creation. +`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec fields are largely immutable after creation. In both cases, server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource are preserved from the original live object. This prevents spec-level reconciliation from clearing status data written by the kubelet or other controllers. Use `WithCustomFieldApplicator` when additional metadata fields need to be selectively propagated: diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go index f0e19dc3..25a216ef 100644 --- a/pkg/primitives/pod/resource.go +++ b/pkg/primitives/pod/resource.go @@ -8,9 +8,9 @@ import ( ) // DefaultFieldApplicator handles the immutable nature of pod specs while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), +// shared-controller fields (OwnerReferences, Finalizers), and the Status +// subresource from the original current object. // // For new pods (empty ResourceVersion), the entire desired state is applied. // For existing pods, only metadata (labels and annotations) is propagated @@ -26,6 +26,7 @@ func DefaultFieldApplicator(current, desired *corev1.Pod) error { current.Labels = cloneStringMap(desired.Labels) current.Annotations = cloneStringMap(desired.Annotations) generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) return nil } diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go index f9b2a79f..57c5edf1 100644 --- a/pkg/primitives/pod/resource_test.go +++ b/pkg/primitives/pod/resource_test.go @@ -460,6 +460,54 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + current := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + ResourceVersion: "12345", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true, RestartCount: 2}, + }, + }, + } + desired := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{"app": "updated"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired labels are applied + assert.Equal(t, "updated", current.Labels["app"]) + + // Status from the live object is preserved + assert.Equal(t, corev1.PodRunning, current.Status.Phase) + assert.Equal(t, "10.0.0.1", current.Status.PodIP) + require.Len(t, current.Status.ContainerStatuses, 1) + assert.Equal(t, "app", current.Status.ContainerStatuses[0].Name) + assert.True(t, current.Status.ContainerStatuses[0].Ready) + assert.Equal(t, int32(2), current.Status.ContainerStatuses[0].RestartCount) +} + func TestResource_DefaultFieldApplicator_ExistingPod(t *testing.T) { desired := newValidPod() desired.Labels = map[string]string{"app": "test"} From f4d713e39a6a180183271c8a04c44de799118c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:43:15 +0000 Subject: [PATCH 12/24] fix pod mutator constructor to not call beginFeature Initialize plans slice directly and set active pointer instead of calling beginFeature(), preventing an empty feature plan when the generic mutator helper also calls beginFeature(). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/mutator.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index 9bf26a3a..17567e54 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -56,8 +56,9 @@ type Mutator struct { func NewMutator(current *corev1.Pod) *Mutator { m := &Mutator{ current: current, + plans: []featurePlan{{}}, } - m.beginFeature() + m.active = &m.plans[0] return m } From d74a8afcf188cea395e972e4bdd5f7d79383f15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:35:16 +0000 Subject: [PATCH 13/24] export BeginFeature() to align with FeatureMutator interface on main Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/mutator.go | 6 +++--- pkg/primitives/pod/mutator_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index 17567e54..28e0d878 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -62,10 +62,10 @@ func NewMutator(current *corev1.Pod) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation -// registrations will be grouped into this feature's plan until beginFeature +// BeginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan until BeginFeature // is called again. -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/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go index 73821816..67d6cb91 100644 --- a/pkg/primitives/pod/mutator_test.go +++ b/pkg/primitives/pod/mutator_test.go @@ -354,14 +354,14 @@ func TestMutator_CrossFeatureOrdering(t *testing.T) { m := NewMutator(pod) // Feature A: sets image to v2 - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2" return nil }) // Feature B: sets image to v3 - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v3" return nil @@ -421,14 +421,14 @@ func TestMutator_CrossFeatureVisibility(t *testing.T) { m := NewMutator(pod) // Feature A renames container - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { e.Raw().Name = "app-v2" return nil }) // Feature B selects by the new name - this should work! - m.beginFeature() + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("app-v2"), func(e *editors.ContainerEditor) error { e.Raw().Image = "v2-image" return nil From 564561c550a8fd37d2a2aaebd585010614defd93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:10 +0000 Subject: [PATCH 14/24] format markdown files with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 109 ++++++++++++++++++++----------- examples/pod-primitive/README.md | 10 +-- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index 6590b146..ffc0f5b4 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -1,18 +1,21 @@ # Pod Primitive -The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly. It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and metadata. +The `pod` primitive is the framework's built-in workload abstraction for managing Kubernetes `Pod` resources directly. +It integrates fully with the component lifecycle and provides a mutation API for managing containers, pod specs, and +metadata. -Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage pod objects (e.g. debugging utilities, node-local agents). +Pods are rarely managed directly by operators; this primitive is provided for completeness and for operators that manage +pod objects (e.g. debugging utilities, node-local agents). ## Capabilities -| Capability | Detail | -|-----------------------|-------------------------------------------------------------------------------------------------| +| Capability | Detail | +| --------------------- | -------------------------------------------------------------------------------------------------- | | **Health tracking** | Monitors pod phase and container statuses; reports `Healthy`, `Creating`, `Updating`, or `Failing` | -| **Graceful rollouts** | Detects degraded or down states via grace status handler | -| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | -| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations) | +| **Graceful rollouts** | Detects degraded or down states via grace status handler | +| **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | +| **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | +| **Flavors** | Preserves externally-managed fields (labels, annotations) | ## Building a Pod Primitive @@ -42,7 +45,11 @@ resource, err := pod.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec fields are largely immutable after creation. In both cases, server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource are preserved from the original live object. This prevents spec-level reconciliation from clearing status data written by the kubelet or other controllers. +`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire +desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec +fields are largely immutable after creation. In both cases, server-managed metadata (ResourceVersion, UID, etc.), +shared-controller fields (OwnerReferences, Finalizers), and the Status subresource are preserved from the original live +object. This prevents spec-level reconciliation from clearing status data written by the kubelet or other controllers. Use `WithCustomFieldApplicator` when additional metadata fields need to be selectively propagated: @@ -69,9 +76,11 @@ resource, err := pod.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that +receives a `*Mutator` and records edit intent through typed editors. -The `Feature` field controls when a mutation applies. Leaving it nil applies the mutation unconditionally. A feature with no version constraints and no `When()` conditions is also always enabled: +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) pod.Mutation { @@ -86,7 +95,8 @@ func MyFeatureMutation(version string) pod.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,20 +117,29 @@ func DebugMutation(version string, enabled bool) pod.Mutation { ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded. This ensures structural consistency across mutations. - -| Step | Category | What it affects | -|---|---|---| -| 1 | Object metadata edits | Labels and annotations on the `Pod` object | -| 2 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | -| 3 | Regular container presence | Adding or removing containers from `spec.containers` | -| 4 | Regular container edits | Env vars, args, resources (snapshot taken after step 3) | -| 5 | Init container presence | Adding or removing containers from `spec.initContainers` | -| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) | - -Container edits (steps 4 and 6) are evaluated against a snapshot taken *after* presence operations in the same mutation. This means a single mutation can add a container and then configure it without selector resolution issues. - -**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`, `args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize); treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes. +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded. This ensures structural consistency across mutations. + +| Step | Category | What it affects | +| ---- | -------------------------- | ----------------------------------------------------------------------- | +| 1 | Object metadata edits | Labels and annotations on the `Pod` object | +| 2 | Pod spec edits | Volumes, tolerations, node selectors, service account, security context | +| 3 | Regular container presence | Adding or removing containers from `spec.containers` | +| 4 | Regular container edits | Env vars, args, resources (snapshot taken after step 3) | +| 5 | Init container presence | Adding or removing containers from `spec.initContainers` | +| 6 | Init container edits | Env vars, args, resources (snapshot taken after step 5) | + +Container edits (steps 4 and 6) are evaluated against a snapshot taken _after_ presence operations in the same mutation. +This means a single mutation can add a container and then configure it without selector resolution issues. + +**Kubernetes immutability note:** most fields in `Pod.spec` are immutable after creation, including the overall +structure of `spec.containers` and `spec.initContainers` and the majority of per-container fields (such as `env`, +`args`, resources, ports, and probes). Presence operations such as `EnsureContainer` / `RemoveContainer` (and the +corresponding init container operations) are intended for use when constructing a new Pod or when recreating the Pod, +not for in-place updates to an existing Pod. If a mutation attempts to add or remove containers on an existing Pod, the +Kubernetes API server will reject the update. In practice, the set of fields that can be updated in-place on an existing +Pod is very small (primarily container images, plus a few feature-gated fields such as resources with in-place resize); +treat Pods as effectively immutable and use delete-and-recreate when you need to change other container attributes. ## Editors @@ -128,7 +147,9 @@ Container edits (steps 4 and 6) are evaluated against a snapshot taken *after* p Manages pod-level configuration via `m.EditPodSpec`. -Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, `SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. +Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, +`EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { @@ -147,9 +168,11 @@ m.EditPodSpec(func(e *editors.PodSpecEditor) error { ### ContainerEditor -Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a [selector](../primitives.md#container-selectors). +Modifies individual containers via `m.EditContainers` or `m.EditInitContainers`. Always used in combination with a +[selector](../primitives.md#container-selectors). -Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, `RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. +Available methods: `EnsureEnvVar`, `EnsureEnvVars`, `RemoveEnvVar`, `RemoveEnvVars`, `EnsureArg`, `EnsureArgs`, +`RemoveArg`, `RemoveArgs`, `SetResourceLimit`, `SetResourceRequest`, `SetResources`, `Raw`. ```go m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -187,7 +210,9 @@ m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { ### Raw Escape Hatch -All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the spec. +All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is +insufficient. The mutation remains scoped to the editor's target — you cannot accidentally modify unrelated parts of the +spec. ```go m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -203,7 +228,7 @@ m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEdito The `Mutator` also exposes convenience wrappers that target all containers at once: | Method | Equivalent to | -|-------------------------------|---------------------------------------------------------------| +| ----------------------------- | ------------------------------------------------------------- | | `EnsureContainerEnvVar(ev)` | `EditContainers(AllContainers(), ...)` → `EnsureEnvVar(ev)` | | `RemoveContainerEnvVar(name)` | `EditContainers(AllContainers(), ...)` → `RemoveEnvVar(name)` | | `EnsureContainerArg(arg)` | `EditContainers(AllContainers(), ...)` → `EnsureArg(arg)` | @@ -219,7 +244,8 @@ Pods cannot be paused. The default behavior deletes the pod when the component i ## 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 @@ -233,7 +259,8 @@ resource, err := pod.NewBuilder(base). ### PreserveCurrentAnnotations -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on overlap. +Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on +overlap. ```go resource, err := pod.NewBuilder(base). @@ -245,12 +272,18 @@ 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. -**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. The internal ordering within each mutation handles intra-mutation dependencies automatically. +**Register mutations in dependency order.** If mutation B relies on a container added by mutation A, register A first. +The internal ordering within each mutation handles intra-mutation dependencies automatically. -**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in the same mutation resolve correctly and reconciliation remains idempotent. +**Prefer `EnsureContainer` over direct slice manipulation.** The mutator tracks presence operations so that selectors in +the same mutation resolve correctly and reconciliation remains idempotent. -**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. +**Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can +cause unexpected behavior if sidecar containers are present. -**Pod spec immutability.** Most pod spec fields cannot be changed after creation. The `DefaultFieldApplicator` accounts for this by only propagating metadata changes to existing pods. New pods receive the full desired state. +**Pod spec immutability.** Most pod spec fields cannot be changed after creation. The `DefaultFieldApplicator` accounts +for this by only propagating metadata changes to existing pods. New pods receive the full desired state. diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md index e7447b14..279dc4d0 100644 --- a/examples/pod-primitive/README.md +++ b/examples/pod-primitive/README.md @@ -1,11 +1,12 @@ # Pod Primitive Example -This example demonstrates the usage of the `pod` primitive within the operator component framework. -It shows how to manage a Kubernetes Pod as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `pod` primitive within the operator component framework. It shows how to +manage a Kubernetes Pod as a component of a larger application, utilizing features like: - **Base Construction**: Initializing a Pod with basic metadata and spec. - **Feature Mutations**: Applying version-gated or conditional metadata changes (labels) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual edits). +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual + edits). - **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). - **Data Extraction**: Harvesting information from the reconciled resource. @@ -13,7 +14,7 @@ It shows how to manage a Kubernetes Pod as a component of a larger application, - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: label mutations applied to the Pod using the `Mutator`. + - `mutations.go`: label mutations applied to the Pod using the `Mutator`. - `resources/`: Contains the central `NewPodResource` factory that assembles all features using the `pod.Builder`. - `main.go`: A standalone entry point that demonstrates a single reconciliation loop using a fake client. @@ -26,6 +27,7 @@ go run examples/pod-primitive/main.go ``` This will: + 1. Initialize a fake Kubernetes client. 2. Create an `ExampleApp` owner object. 3. Reconcile the `ExampleApp` components through multiple spec changes. From 3448a413e654afd72ce22d39238afebb79e166fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:24:38 +0000 Subject: [PATCH 15/24] address copilot review: improve pod phase reason, add pod to primitives index Use phase-specific reason in DefaultConvergingStatusHandler instead of always falling back to "Pod is pending" for non-running phases. Add pod primitive entry to the Built-in Primitives table in docs/primitives.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 1 + pkg/primitives/pod/handlers.go | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/primitives.md b/docs/primitives.md index dc2d4c2a..b3451564 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -155,6 +155,7 @@ have been applied. This means a single mutation can safely add a container and t | --------------------------- | -------- | ----------------------------------------- | | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/pod` | Workload | [pod.md](primitives/pod.md) | ## Usage Examples diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go index ad94aaa2..b463af93 100644 --- a/pkg/primitives/pod/handlers.go +++ b/pkg/primitives/pod/handlers.go @@ -92,9 +92,13 @@ func DefaultConvergingStatusHandler( Reason: "Pod running but not all containers ready", }, nil } + reason := "Pod phase is " + string(pod.Status.Phase) + if pod.Status.Phase == corev1.PodPending { + reason = "Pod is pending" + } return concepts.AliveStatusWithReason{ Status: concepts.AliveConvergingStatusCreating, - Reason: "Pod is pending", + Reason: reason, }, nil } } From 345d91c05937296bf0bafefc9fe9b139f57edb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 02:13:28 +0000 Subject: [PATCH 16/24] docs: clarify RemoveTolerations predicate signature in pod docs Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index ffc0f5b4..3ad1515a 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -149,7 +149,8 @@ Manages pod-level configuration via `m.EditPodSpec`. Available methods: `SetServiceAccountName`, `EnsureVolume`, `RemoveVolume`, `EnsureToleration`, `RemoveTolerations`, `EnsureNodeSelector`, `RemoveNodeSelector`, `EnsureImagePullSecret`, `RemoveImagePullSecret`, `SetPriorityClassName`, -`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. +`SetHostNetwork`, `SetHostPID`, `SetHostIPC`, `SetSecurityContext`, `Raw`. `RemoveTolerations` accepts a predicate +function (`match func(corev1.Toleration) bool`) and removes all tolerations for which `match` returns `true`. ```go m.EditPodSpec(func(e *editors.PodSpecEditor) error { From 271dd808e1c702dd7b51e84d216e5941abdd313e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 12:30:35 +0000 Subject: [PATCH 17/24] fix: clear status on create path in pod DefaultFieldApplicator The create-path branch (ResourceVersion == "") was copying the entire desired object including Status, which is server-managed and should never leak from the desired object. This aligns the pod primitive with the deployment primitive, which effectively clears status via PreserveStatus on all paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/resource.go | 2 ++ pkg/primitives/pod/resource_test.go | 34 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/pkg/primitives/pod/resource.go b/pkg/primitives/pod/resource.go index 25a216ef..f0206aee 100644 --- a/pkg/primitives/pod/resource.go +++ b/pkg/primitives/pod/resource.go @@ -18,6 +18,8 @@ import ( func DefaultFieldApplicator(current, desired *corev1.Pod) error { if current.ResourceVersion == "" { *current = *desired.DeepCopy() + // Status is server-managed and must not leak from the desired object. + current.Status = corev1.PodStatus{} return nil } original := current.DeepCopy() diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go index 57c5edf1..453bfad9 100644 --- a/pkg/primitives/pod/resource_test.go +++ b/pkg/primitives/pod/resource_test.go @@ -409,6 +409,40 @@ func TestResource_CustomFieldApplicator_Error(t *testing.T) { assert.Contains(t, err.Error(), "applicator error") } +func TestDefaultFieldApplicator_CreatePath_DoesNotLeakStatus(t *testing.T) { + desired := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + Labels: map[string]string{"app": "test"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "app", Image: "nginx:latest"}, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "app", Ready: true, RestartCount: 5}, + }, + }, + } + + // Empty ResourceVersion simulates the create path. + current := &corev1.Pod{} + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Spec and metadata from desired are applied. + assert.Equal(t, "test", current.Labels["app"]) + assert.Equal(t, "nginx:latest", current.Spec.Containers[0].Image) + + // Status must not leak from the desired object. + assert.Equal(t, corev1.PodStatus{}, current.Status) +} + func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { current := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ From de86aa50feeea1f48a61c1f8f68683588aa315c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:56:50 +0000 Subject: [PATCH 18/24] fix: do not initialize an empty plan on pod mutator construction Align pod NewMutator with configmap and deployment: construction 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/pod/mutator.go | 8 +++----- pkg/primitives/pod/mutator_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index 28e0d878..18940d57 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -52,14 +52,12 @@ type Mutator struct { // NewMutator creates a new Mutator for the given Pod. // // It is typically used within a Feature's Mutation logic to express desired -// changes to the Pod. +// changes to the Pod. BeginFeature must be called before registering +// any mutations. func NewMutator(current *corev1.Pod) *Mutator { - m := &Mutator{ + return &Mutator{ current: current, - plans: []featurePlan{{}}, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/pod/mutator_test.go b/pkg/primitives/pod/mutator_test.go index 67d6cb91..1ff72584 100644 --- a/pkg/primitives/pod/mutator_test.go +++ b/pkg/primitives/pod/mutator_test.go @@ -17,6 +17,8 @@ func TestNewMutator(t *testing.T) { m := NewMutator(pod) assert.NotNil(t, m) assert.Equal(t, pod, m.current) + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") } func TestMutator_EnvVars(t *testing.T) { @@ -36,6 +38,7 @@ func TestMutator_EnvVars(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() m.EnsureContainerEnvVar(corev1.EnvVar{Name: "CHANGE", Value: "new"}) m.EnsureContainerEnvVar(corev1.EnvVar{Name: "ADD", Value: "added"}) m.RemoveContainerEnvVars([]string{"REMOVE", "NONEXISTENT"}) @@ -75,6 +78,7 @@ func TestMutator_Args(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() m.EnsureContainerArg("--change=new") m.EnsureContainerArg("--add") m.RemoveContainerArgs([]string{"--remove", "--nonexistent"}) @@ -101,6 +105,7 @@ func TestMutator_EditContainers(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() m.EditContainers(selectors.ContainerNamed("c1"), func(e *editors.ContainerEditor) error { e.Raw().Image = "c1-image" return nil @@ -122,6 +127,7 @@ func TestMutator_EditContainers(t *testing.T) { func TestMutator_EditPodSpec(t *testing.T) { pod := &corev1.Pod{} m := NewMutator(pod) + m.BeginFeature() m.EditPodSpec(func(e *editors.PodSpecEditor) error { e.Raw().ServiceAccountName = "my-sa" return nil @@ -135,6 +141,7 @@ func TestMutator_EditPodSpec(t *testing.T) { func TestMutator_EditMetadata(t *testing.T) { pod := &corev1.Pod{} m := NewMutator(pod) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.Raw().Labels = map[string]string{"pod": "label"} return nil @@ -148,6 +155,7 @@ func TestMutator_EditMetadata(t *testing.T) { func TestMutator_Errors(t *testing.T) { pod := &corev1.Pod{} m := NewMutator(pod) + m.BeginFeature() m.EditPodSpec(func(_ *editors.PodSpecEditor) error { return errors.New("boom") }) @@ -167,6 +175,7 @@ func TestMutator_Order(t *testing.T) { var order []string m := NewMutator(pod) + m.BeginFeature() // Register in reverse order of expected execution m.EditContainers(selectors.AllContainers(), func(_ *editors.ContainerEditor) error { order = append(order, "container") @@ -199,6 +208,7 @@ func TestMutator_InitContainers(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() m.EditInitContainers(selectors.ContainerNamed("init-1"), func(e *editors.ContainerEditor) error { e.Raw().Image = newImage return nil @@ -222,6 +232,7 @@ func TestMutator_ContainerPresence(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() // Replace m.EnsureContainer(corev1.Container{Name: "app", Image: "app-new-image"}) // Remove @@ -251,6 +262,7 @@ func TestMutator_InitContainerPresence(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() m.EnsureInitContainer(corev1.Container{Name: "init-2", Image: "init-2-image"}) m.RemoveInitContainers([]string{"init-1"}) @@ -273,6 +285,7 @@ func TestMutator_SelectorSnapshotSemantics(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() // First edit renames the container m.EditContainers(selectors.ContainerNamed("app"), func(e *editors.ContainerEditor) error { @@ -307,6 +320,7 @@ func TestMutator_Ordering_PresenceBeforeEdit(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() // Register edit first m.EditContainers(selectors.ContainerNamed("new-app"), func(e *editors.ContainerEditor) error { @@ -333,6 +347,7 @@ func TestMutator_NilSafety(t *testing.T) { }, } m := NewMutator(pod) + m.BeginFeature() // These should all be no-ops and not panic m.EditContainers(nil, func(_ *editors.ContainerEditor) error { return nil }) @@ -383,6 +398,7 @@ func TestMutator_WithinFeatureCategoryOrdering(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() var executionOrder []string @@ -449,6 +465,7 @@ func TestMutator_InitContainer_OrderingAndSnapshots(t *testing.T) { } m := NewMutator(pod) + m.BeginFeature() // 1. Add init-1 m.EnsureInitContainer(corev1.Container{Name: "init-1", Image: "v1"}) From 4b9e53f1a4bc54ae4dc26ca2843446261bc1876f 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:09:50 +0000 Subject: [PATCH 19/24] refactor: remove field applicators and flavors from pod primitive Align the pod primitive with the framework's SSA migration: remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, flavors.go, and all associated tests and documentation. Update Mutate tests to use the Object()-first pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/pod.md | 64 ------- pkg/primitives/pod/builder.go | 37 ---- pkg/primitives/pod/builder_test.go | 38 ----- pkg/primitives/pod/flavors.go | 25 --- pkg/primitives/pod/flavors_test.go | 121 ------------- pkg/primitives/pod/resource.go | 39 +---- pkg/primitives/pod/resource_test.go | 255 ++++------------------------ 7 files changed, 30 insertions(+), 549 deletions(-) delete mode 100644 pkg/primitives/pod/flavors.go delete mode 100644 pkg/primitives/pod/flavors_test.go diff --git a/docs/primitives/pod.md b/docs/primitives/pod.md index 3ad1515a..45985592 100644 --- a/docs/primitives/pod.md +++ b/docs/primitives/pod.md @@ -15,7 +15,6 @@ pod objects (e.g. debugging utilities, node-local agents). | **Graceful rollouts** | Detects degraded or down states via grace status handler | | **Suspension** | Deletes the pod (pods cannot be paused); reports `Suspended` | | **Mutation pipeline** | Typed editors for metadata, pod spec, and containers | -| **Flavors** | Preserves externally-managed fields (labels, annotations) | ## Building a Pod Primitive @@ -38,42 +37,10 @@ base := &corev1.Pod{ } resource, err := pod.NewBuilder(base). - WithFieldApplicationFlavor(pod.PreserveCurrentLabels). WithMutation(MyFeatureMutation(owner.Spec.Version)). Build() ``` -## Default Field Application - -`DefaultFieldApplicator` handles the immutable nature of pod specs. For new pods (empty `ResourceVersion`), the entire -desired state is applied. For existing pods, only metadata (labels and annotations) is propagated because pod spec -fields are largely immutable after creation. In both cases, server-managed metadata (ResourceVersion, UID, etc.), -shared-controller fields (OwnerReferences, Finalizers), and the Status subresource are preserved from the original live -object. This prevents spec-level reconciliation from clearing status data written by the kubelet or other controllers. - -Use `WithCustomFieldApplicator` when additional metadata fields need to be selectively propagated: - -```go -resource, err := pod.NewBuilder(base). - WithCustomFieldApplicator(func(current, desired *corev1.Pod) error { - if desired.Labels != nil { - current.Labels = make(map[string]string, len(desired.Labels)) - for k, v := range desired.Labels { - current.Labels[k] = v - } - } - // Selectively preserve some annotations - if desired.Annotations != nil { - current.Annotations = make(map[string]string, len(desired.Annotations)) - for k, v := range desired.Annotations { - current.Annotations[k] = v - } - } - return nil - }). - Build() -``` - ## Mutations Mutations are the primary mechanism for modifying a `Pod` beyond its baseline. Each mutation is a named function that @@ -243,34 +210,6 @@ Pods cannot be paused. The default behavior deletes the pod when the component i - `DefaultSuspendMutationHandler`: no-op (deletion is handled by the framework). - `DefaultSuspensionStatusHandler`: always returns `{Suspended, "Pod deleted on suspend"}`. -## 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 := pod.NewBuilder(base). - WithFieldApplicationFlavor(pod.PreserveCurrentLabels). - Build() -``` - -### PreserveCurrentAnnotations - -Preserves annotations present on the live object but absent from the applied desired state. Applied annotations win on -overlap. - -```go -resource, err := pod.NewBuilder(base). - WithFieldApplicationFlavor(pod.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 @@ -285,6 +224,3 @@ the same mutation resolve correctly and reconciliation remains idempotent. **Use selectors for precision.** Targeting `AllContainers()` when you only mean to modify the primary container can cause unexpected behavior if sidecar containers are present. - -**Pod spec immutability.** Most pod spec fields cannot be changed after creation. The `DefaultFieldApplicator` accounts -for this by only propagating metadata changes to existing pods. New pods receive the full desired state. diff --git a/pkg/primitives/pod/builder.go b/pkg/primitives/pod/builder.go index 58c39435..9b32239d 100644 --- a/pkg/primitives/pod/builder.go +++ b/pkg/primitives/pod/builder.go @@ -35,7 +35,6 @@ func NewBuilder(pod *corev1.Pod) *Builder { base := generic.NewWorkloadBuilder[*corev1.Pod, *Mutator]( pod, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -65,42 +64,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing Pod in the cluster. -// -// The default field applicator (DefaultFieldApplicator) preserves the spec -// on existing pods (since pod spec is largely immutable) and only updates -// metadata. Using a custom applicator is necessary when: -// - Additional metadata fields need to be selectively propagated. -// - Specific labels or annotations should be excluded from updates. -// -// The applicator function receives both the 'current' object from the API -// server and the 'desired' object from the Resource. It is responsible for -// merging the desired changes into the current object. -// -// If a custom applicator is set, it overrides the default baseline application -// logic. Post-application flavors and mutations are still applied afterward. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current *corev1.Pod, desired *corev1.Pod) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a reusable post-application "flavor" for -// the Pod. -// -// Flavors are applied in the order they are registered, after the baseline field -// applicator (default or custom) has already run. They are typically used to -// preserve selected live fields from the current object that should not be -// overwritten by the desired state. -// -// If the provided flavor is nil, it is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*corev1.Pod](flavor)) - return b -} - // WithCustomConvergeStatus overrides the default logic for determining if the // Pod has reached its desired state. // diff --git a/pkg/primitives/pod/builder_test.go b/pkg/primitives/pod/builder_test.go index 1baac4c7..eea10ba3 100644 --- a/pkg/primitives/pod/builder_test.go +++ b/pkg/primitives/pod/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() - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_ *corev1.Pod, _ *corev1.Pod) error { - applied = true - return nil - } - res, err := NewBuilder(pod). - 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() - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(pod). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) - }) - t.Run("WithCustomConvergeStatus", func(t *testing.T) { t.Parallel() pod := &corev1.Pod{ diff --git a/pkg/primitives/pod/flavors.go b/pkg/primitives/pod/flavors.go deleted file mode 100644 index acd53f5a..00000000 --- a/pkg/primitives/pod/flavors.go +++ /dev/null @@ -1,25 +0,0 @@ -package pod - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - corev1 "k8s.io/api/core/v1" -) - -// FieldApplicationFlavor defines a function signature for applying "flavors" to a resource. -// A flavor typically preserves certain fields from the current (live) object after the -// baseline field application has occurred. -type FieldApplicationFlavor flavors.FieldApplicationFlavor[*corev1.Pod] - -// PreserveCurrentLabels ensures that any labels present on the current live -// Pod but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *corev1.Pod) error { - return flavors.PreserveCurrentLabels[*corev1.Pod]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live Pod but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *corev1.Pod) error { - return flavors.PreserveCurrentAnnotations[*corev1.Pod]()(applied, current, desired) -} diff --git a/pkg/primitives/pod/flavors_test.go b/pkg/primitives/pod/flavors_test.go deleted file mode 100644 index 9f19448b..00000000 --- a/pkg/primitives/pod/flavors_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package pod - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutate_OrderingAndFlavors(t *testing.T) { - desired := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - Labels: map[string]string{"app": "desired"}, - }, - } - - t.Run("flavors run after baseline applicator", func(t *testing.T) { - current := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - Labels: map[string]string{"extra": "preserved"}, - }, - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(PreserveCurrentLabels). - Build() - require.NoError(t, err) - - 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - }, - } - - var order []string - flavor1 := func(_, _, _ *corev1.Pod) error { - order = append(order, "flavor1") - return nil - } - flavor2 := func(_, _, _ *corev1.Pod) error { - order = append(order, "flavor2") - return nil - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor1). - WithFieldApplicationFlavor(flavor2). - Build() - require.NoError(t, err) - - 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 := &corev1.Pod{} - flavorErr := errors.New("boom") - flavor := func(_, _, _ *corev1.Pod) error { - return flavorErr - } - - res, err := NewBuilder(desired). - WithFieldApplicationFlavor(flavor). - Build() - require.NoError(t, err) - - 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 := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"keep": "applied", "overlap": "applied"}}} - current := &corev1.Pod{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 := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{"keep": "applied"}}} - current := &corev1.Pod{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 := &corev1.Pod{} - current := &corev1.Pod{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/pod/resource.go b/pkg/primitives/pod/resource.go index f0206aee..7c7eb297 100644 --- a/pkg/primitives/pod/resource.go +++ b/pkg/primitives/pod/resource.go @@ -7,42 +7,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator handles the immutable nature of pod specs while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), -// shared-controller fields (OwnerReferences, Finalizers), and the Status -// subresource from the original current object. -// -// For new pods (empty ResourceVersion), the entire desired state is applied. -// For existing pods, only metadata (labels and annotations) is propagated -// because pod spec fields are largely immutable after creation. -func DefaultFieldApplicator(current, desired *corev1.Pod) error { - if current.ResourceVersion == "" { - *current = *desired.DeepCopy() - // Status is server-managed and must not leak from the desired object. - current.Status = corev1.PodStatus{} - return nil - } - original := current.DeepCopy() - // Pod spec is largely immutable; only propagate metadata changes. - // Clone maps to avoid sharing mutable references between desired and current. - current.Labels = cloneStringMap(desired.Labels) - current.Annotations = cloneStringMap(desired.Annotations) - generic.PreserveServerManagedFields(current, original) - generic.PreserveStatus(current, original) - return nil -} - -func cloneStringMap(src map[string]string) map[string]string { - if src == nil { - return nil - } - dst := make(map[string]string, len(src)) - for k, v := range src { - dst[k] = v - } - return dst -} - // Resource is a high-level abstraction for managing a Kubernetes Pod within a controller's // reconciliation loop. // @@ -83,8 +47,7 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes Pod into the desired state. // // The mutation process follows a specific order: -// 1. Core State: The current object is reset to the desired base state, or -// modified via a customFieldApplicator if one is configured. +// 1. Core State: The desired base state is applied to the current object. // 2. Feature Mutations: All registered feature-based mutations are applied, // allowing for granular, version-gated changes to the Pod. // 3. Suspension: If the resource is in a suspending state, the suspension diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go index 453bfad9..19300f0b 100644 --- a/pkg/primitives/pod/resource_test.go +++ b/pkg/primitives/pod/resource_test.go @@ -69,12 +69,13 @@ func TestResource_Mutate(t *testing.T) { Build() require.NoError(t, err) - current := &corev1.Pod{} - err = res.Mutate(current) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "test", current.Labels["app"]) - assert.Equal(t, "BAR", current.Spec.Containers[0].Env[0].Value) + got := obj.(*corev1.Pod) + assert.Equal(t, "test", got.Labels["app"]) + assert.Equal(t, "BAR", got.Spec.Containers[0].Env[0].Value) } func TestResource_Mutate_DeepCopySemantics(t *testing.T) { @@ -84,11 +85,13 @@ func TestResource_Mutate_DeepCopySemantics(t *testing.T) { res, err := NewBuilder(desired).Build() require.NoError(t, err) - current := &corev1.Pod{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // Modifying current's labels must not affect the desired object. - current.Labels["app"] = "modified" + // Modifying the mutated object's labels must not affect the desired object. + got := obj.(*corev1.Pod) + got.Labels["app"] = "modified" assert.Equal(t, "test", desired.Labels["app"]) } @@ -123,10 +126,12 @@ func TestResource_Mutate_FeatureOrdering(t *testing.T) { Build() require.NoError(t, err) - current := &corev1.Pod{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Equal(t, "v3", current.Spec.Containers[0].Image) + got := obj.(*corev1.Pod) + assert.Equal(t, "v3", got.Spec.Containers[0].Image) } func TestResource_Mutate_DisabledFeatureSkipped(t *testing.T) { @@ -144,10 +149,12 @@ func TestResource_Mutate_DisabledFeatureSkipped(t *testing.T) { Build() require.NoError(t, err) - current := &corev1.Pod{} - require.NoError(t, res.Mutate(current)) + obj, err := res.Object() + require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - assert.Empty(t, current.Spec.Containers[0].Env) + got := obj.(*corev1.Pod) + assert.Empty(t, got.Spec.Containers[0].Env) } type podMockHandlers struct { @@ -274,8 +281,9 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := pod.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() + require.NoError(t, err) + err = res.Mutate(obj) require.NoError(t, err) // Default suspend mutation is a no-op for pods (they are deleted instead). // Just verify Mutate succeeds. @@ -298,12 +306,14 @@ func TestResource_Suspend(t *testing.T) { err = res.Suspend() require.NoError(t, err) - current := pod.DeepCopy() - err = res.Mutate(current) + obj, err := res.Object() + require.NoError(t, err) + err = res.Mutate(obj) require.NoError(t, err) m.AssertExpectations(t) - assert.Equal(t, "true", current.Labels["suspended"]) + got := obj.(*corev1.Pod) + assert.Equal(t, "true", got.Labels["suspended"]) }) } @@ -365,210 +375,3 @@ func TestResource_ExtractData_Error(t *testing.T) { assert.Contains(t, err.Error(), "extract error") } -func TestResource_CustomFieldApplicator(t *testing.T) { - desired := newValidPod() - desired.Labels = map[string]string{"app": "test"} - - applicatorCalled := false - res, err := NewBuilder(desired). - WithCustomFieldApplicator(func(current, d *corev1.Pod) error { - applicatorCalled = true - current.Name = d.Name - current.Namespace = d.Namespace - current.Spec = *d.Spec.DeepCopy() - // Intentionally do not copy labels. - return nil - }). - Build() - require.NoError(t, err) - - current := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"external": "label"}, - }, - } - err = res.Mutate(current) - require.NoError(t, err) - - assert.True(t, applicatorCalled) - assert.Equal(t, "nginx:latest", current.Spec.Containers[0].Image) - assert.Equal(t, "label", current.Labels["external"]) - assert.NotContains(t, current.Labels, "app") -} - -func TestResource_CustomFieldApplicator_Error(t *testing.T) { - res, err := NewBuilder(newValidPod()). - WithCustomFieldApplicator(func(_, _ *corev1.Pod) error { - return errors.New("applicator error") - }). - Build() - require.NoError(t, err) - - err = res.Mutate(&corev1.Pod{}) - require.Error(t, err) - assert.Contains(t, err.Error(), "applicator error") -} - -func TestDefaultFieldApplicator_CreatePath_DoesNotLeakStatus(t *testing.T) { - desired := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - Labels: map[string]string{"app": "test"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - }, - Status: corev1.PodStatus{ - Phase: corev1.PodRunning, - PodIP: "10.0.0.1", - ContainerStatuses: []corev1.ContainerStatus{ - {Name: "app", Ready: true, RestartCount: 5}, - }, - }, - } - - // Empty ResourceVersion simulates the create path. - current := &corev1.Pod{} - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Spec and metadata from desired are applied. - assert.Equal(t, "test", current.Labels["app"]) - assert.Equal(t, "nginx:latest", current.Spec.Containers[0].Image) - - // Status must not leak from the desired object. - assert.Equal(t, corev1.PodStatus{}, current.Status) -} - -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - }, - } - desired := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - Labels: map[string]string{"app": "test"}, - Annotations: map[string]string{"note": "hello"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired labels and annotations are applied - assert.Equal(t, "test", current.Labels["app"]) - assert.Equal(t, "hello", current.Annotations["note"]) - - // 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - ResourceVersion: "12345", - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - }, - Status: corev1.PodStatus{ - Phase: corev1.PodRunning, - PodIP: "10.0.0.1", - ContainerStatuses: []corev1.ContainerStatus{ - {Name: "app", Ready: true, RestartCount: 2}, - }, - }, - } - desired := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - Labels: map[string]string{"app": "updated"}, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "nginx:latest"}, - }, - }, - } - - err := DefaultFieldApplicator(current, desired) - require.NoError(t, err) - - // Desired labels are applied - assert.Equal(t, "updated", current.Labels["app"]) - - // Status from the live object is preserved - assert.Equal(t, corev1.PodRunning, current.Status.Phase) - assert.Equal(t, "10.0.0.1", current.Status.PodIP) - require.Len(t, current.Status.ContainerStatuses, 1) - assert.Equal(t, "app", current.Status.ContainerStatuses[0].Name) - assert.True(t, current.Status.ContainerStatuses[0].Ready) - assert.Equal(t, int32(2), current.Status.ContainerStatuses[0].RestartCount) -} - -func TestResource_DefaultFieldApplicator_ExistingPod(t *testing.T) { - desired := newValidPod() - desired.Labels = map[string]string{"app": "test"} - desired.Annotations = map[string]string{"note": "hello"} - - res, err := NewBuilder(desired).Build() - require.NoError(t, err) - - // Existing pod has a ResourceVersion, so only metadata should be propagated. - current := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-pod", - Namespace: "test-ns", - ResourceVersion: "12345", - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "app", Image: "old-image"}, - }, - }, - } - err = res.Mutate(current) - require.NoError(t, err) - - // Metadata should be updated. - assert.Equal(t, "test", current.Labels["app"]) - assert.Equal(t, "hello", current.Annotations["note"]) - // Spec should be preserved (immutable). - assert.Equal(t, "old-image", current.Spec.Containers[0].Image) -} From e59f104357b18b067d170aac8a4e55bc83b10b39 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/24] fix: remove references to deleted field applicators in pod example The pod primitive refactor removed WithFieldApplicationFlavor and related flavors, but the example still referenced them, breaking the lint CI. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/pod-primitive/resources/pod.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/pod-primitive/resources/pod.go b/examples/pod-primitive/resources/pod.go index 2b3f16bb..45d1cb6d 100644 --- a/examples/pod-primitive/resources/pod.go +++ b/examples/pod-primitive/resources/pod.go @@ -41,11 +41,7 @@ func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.VersionFeature(owner.Spec.Version)) builder.WithMutation(features.TracingFeature(owner.Spec.EnableTracing)) - // 4. Configure flavors (e.g., preserve labels/annotations if they were modified externally). - builder.WithFieldApplicationFlavor(pod.PreserveCurrentLabels) - builder.WithFieldApplicationFlavor(pod.PreserveCurrentAnnotations) - - // 5. Data extraction (optional). + // 4. Data extraction (optional). builder.WithDataExtractor(func(p corev1.Pod) error { fmt.Printf("Reconciling pod: %s, phase: %s\n", p.Name, p.Status.Phase) @@ -59,6 +55,6 @@ func NewPodResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } From 4d987705e4fa9b9dbd35fe1c8ce130574b757b41 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:58:10 +0000 Subject: [PATCH 21/24] fix --- pkg/primitives/pod/resource_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/primitives/pod/resource_test.go b/pkg/primitives/pod/resource_test.go index 19300f0b..ec08ed09 100644 --- a/pkg/primitives/pod/resource_test.go +++ b/pkg/primitives/pod/resource_test.go @@ -374,4 +374,3 @@ func TestResource_ExtractData_Error(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "extract error") } - From 050ad637763fc94aa33f465989e387ba3c62b617 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:03 +0000 Subject: [PATCH 22/24] fix: remove stale Field Flavors reference from pod example README Address Copilot review feedback: the README still mentioned "Field Flavors" which were removed from the pod primitive in a prior commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/pod-primitive/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/pod-primitive/README.md b/examples/pod-primitive/README.md index 279dc4d0..fee67a4a 100644 --- a/examples/pod-primitive/README.md +++ b/examples/pod-primitive/README.md @@ -5,8 +5,6 @@ manage a Kubernetes Pod as a component of a larger application, utilizing featur - **Base Construction**: Initializing a Pod with basic metadata and spec. - **Feature Mutations**: Applying version-gated or conditional metadata changes (labels) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools (e.g., ArgoCD, manual - edits). - **Suspension**: Deleting the pod when the component is suspended (pods cannot be paused). - **Data Extraction**: Harvesting information from the reconciled resource. From d7e26e53660735089848f90475c73147118a96a7 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:18:41 +0000 Subject: [PATCH 23/24] fix: address PR review feedback for pod primitive - Add requireActive() guard to mutator registration methods to fail fast with a clear message instead of nil pointer dereference - Change containerPresenceOp to store container by value with explicit remove bool instead of using nil pointer as sentinel - Include pod phase in grace status reason for better debugging context Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/handlers.go | 2 +- pkg/primitives/pod/handlers_test.go | 2 +- pkg/primitives/pod/mutator.go | 37 ++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/pkg/primitives/pod/handlers.go b/pkg/primitives/pod/handlers.go index b463af93..48b4349d 100644 --- a/pkg/primitives/pod/handlers.go +++ b/pkg/primitives/pod/handlers.go @@ -117,7 +117,7 @@ func DefaultGraceStatusHandler(pod *corev1.Pod) (concepts.GraceStatusWithReason, if pod.Status.Phase != corev1.PodRunning { return concepts.GraceStatusWithReason{ Status: concepts.GraceStatusDown, - Reason: "Pod is not running", + Reason: "Pod phase is " + string(pod.Status.Phase), }, nil } diff --git a/pkg/primitives/pod/handlers_test.go b/pkg/primitives/pod/handlers_test.go index bce32fc6..b3ecfdbb 100644 --- a/pkg/primitives/pod/handlers_test.go +++ b/pkg/primitives/pod/handlers_test.go @@ -219,7 +219,7 @@ func TestDefaultGraceStatusHandler(t *testing.T) { got, err := DefaultGraceStatusHandler(pod) require.NoError(t, err) assert.Equal(t, concepts.GraceStatusDown, got.Status) - assert.Equal(t, "Pod is not running", got.Reason) + assert.Equal(t, "Pod phase is Pending", got.Reason) }) t.Run("down (failed phase)", func(t *testing.T) { diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index 18940d57..29d1e3e6 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -20,7 +20,8 @@ type containerEdit struct { type containerPresenceOp struct { name string - container *corev1.Container // nil for remove + container corev1.Container + remove bool } type featurePlan struct { @@ -60,6 +61,13 @@ func NewMutator(current *corev1.Pod) *Mutator { } } +// requireActive panics with a clear message if BeginFeature has not been called. +func (m *Mutator) requireActive() { + if m.active == nil { + panic("pod.Mutator: BeginFeature() must be called before registering mutations") + } +} + // BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan until BeginFeature // is called again. @@ -82,6 +90,7 @@ func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) if edit == nil { return } + m.requireActive() m.active.podMetadataEdits = append(m.active.podMetadataEdits, edit) } @@ -99,23 +108,26 @@ func (m *Mutator) EditPodSpec(edit func(*editors.PodSpecEditor) error) { if edit == nil { return } + m.requireActive() m.active.podSpecEdits = append(m.active.podSpecEdits, edit) } // EnsureContainer records that a regular container must be present in the Pod. // If a container with the same name exists, it is replaced; otherwise, it is appended. func (m *Mutator) EnsureContainer(container corev1.Container) { + m.requireActive() m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ name: container.Name, - container: &container, + container: container, }) } // RemoveContainer records that a regular container should be removed by name. func (m *Mutator) RemoveContainer(name string) { + m.requireActive() m.active.containerPresence = append(m.active.containerPresence, containerPresenceOp{ - name: name, - container: nil, + name: name, + remove: true, }) } @@ -145,6 +157,7 @@ func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func if selector == nil || edit == nil { return } + m.requireActive() m.active.containerEdits = append(m.active.containerEdits, containerEdit{ selector: selector, edit: edit, @@ -154,17 +167,19 @@ func (m *Mutator) EditContainers(selector selectors.ContainerSelector, edit func // EnsureInitContainer records that an init container must be present in the Pod. // If an init container with the same name exists, it is replaced; otherwise, it is appended. func (m *Mutator) EnsureInitContainer(container corev1.Container) { + m.requireActive() m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ name: container.Name, - container: &container, + container: container, }) } // RemoveInitContainer records that an init container should be removed by name. func (m *Mutator) RemoveInitContainer(name string) { + m.requireActive() m.active.initContainerPresence = append(m.active.initContainerPresence, containerPresenceOp{ - name: name, - container: nil, + name: name, + remove: true, }) } @@ -194,6 +209,7 @@ func (m *Mutator) EditInitContainers(selector selectors.ContainerSelector, edit if selector == nil || edit == nil { return } + m.requireActive() m.active.initContainerEdits = append(m.active.initContainerEdits, containerEdit{ selector: selector, edit: edit, @@ -379,8 +395,7 @@ func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { } } - if op.container == nil { - // Remove + if op.remove { if found != -1 { *containers = append((*containers)[:found], (*containers)[found+1:]...) } @@ -389,8 +404,8 @@ func applyPresenceOp(containers *[]corev1.Container, op containerPresenceOp) { // Ensure if found != -1 { - (*containers)[found] = *op.container + (*containers)[found] = op.container } else { - *containers = append(*containers, *op.container) + *containers = append(*containers, op.container) } } From 73a7d0e780771e7ff94b2d540cddb9ca80124846 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:26:17 +0000 Subject: [PATCH 24/24] fix: use American English spelling for consistency in pod mutator comments Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/pod/mutator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/primitives/pod/mutator.go b/pkg/primitives/pod/mutator.go index 29d1e3e6..67ba543a 100644 --- a/pkg/primitives/pod/mutator.go +++ b/pkg/primitives/pod/mutator.go @@ -38,7 +38,7 @@ type featurePlan struct { // It uses a "plan-and-apply" pattern: mutations are recorded first, and then // applied to the Pod in a single controlled pass when Apply() is called. // -// This approach ensures that mutations are applied consistently and minimises +// This approach ensures that mutations are applied consistently and minimizes // repeated scans of the underlying Kubernetes structures. // // The Mutator maintains feature boundaries: each feature's mutations are planned