From e4651850a10ad602e0f4093ccad8845123bb2941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:39:29 +0000 Subject: [PATCH 01/28] Add HPASpecEditor for HorizontalPodAutoscaler spec mutations Provides typed methods for setting scale target ref, min/max replicas, metrics (with upsert-by-type/name semantics), and scaling behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/hpaspec.go | 148 ++++++++++++++ pkg/mutation/editors/hpaspec_test.go | 291 +++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 pkg/mutation/editors/hpaspec.go create mode 100644 pkg/mutation/editors/hpaspec_test.go diff --git a/pkg/mutation/editors/hpaspec.go b/pkg/mutation/editors/hpaspec.go new file mode 100644 index 00000000..f7630c2e --- /dev/null +++ b/pkg/mutation/editors/hpaspec.go @@ -0,0 +1,148 @@ +package editors + +import ( + autoscalingv2 "k8s.io/api/autoscaling/v2" +) + +// HPASpecEditor provides a typed API for mutating a Kubernetes HorizontalPodAutoscalerSpec. +type HPASpecEditor struct { + spec *autoscalingv2.HorizontalPodAutoscalerSpec +} + +// NewHPASpecEditor creates a new HPASpecEditor for the given HorizontalPodAutoscalerSpec. +func NewHPASpecEditor(spec *autoscalingv2.HorizontalPodAutoscalerSpec) *HPASpecEditor { + return &HPASpecEditor{spec: spec} +} + +// Raw returns the underlying *autoscalingv2.HorizontalPodAutoscalerSpec. +// +// This is an escape hatch for cases where the typed API is insufficient. +func (e *HPASpecEditor) Raw() *autoscalingv2.HorizontalPodAutoscalerSpec { + return e.spec +} + +// SetScaleTargetRef sets the reference to the resource being scaled. +func (e *HPASpecEditor) SetScaleTargetRef(ref autoscalingv2.CrossVersionObjectReference) { + e.spec.ScaleTargetRef = ref +} + +// SetMinReplicas sets the lower bound for the number of replicas. +// Passing nil removes the lower bound (Kubernetes defaults to 1). +func (e *HPASpecEditor) SetMinReplicas(n *int32) { + e.spec.MinReplicas = n +} + +// SetMaxReplicas sets the upper bound for the number of replicas. +func (e *HPASpecEditor) SetMaxReplicas(n int32) { + e.spec.MaxReplicas = n +} + +// EnsureMetric upserts a metric in the spec's Metrics slice. +// +// Matching is performed by MetricSpec.Type. Within that type, matching is refined +// by the metric name where applicable: +// - Resource: matched by Resource.Name +// - Pods: matched by Pods.Metric.Name +// - Object: matched by Object.Metric.Name +// - ContainerResource: matched by ContainerResource.Name + ContainerResource.Container +// - External: matched by External.Metric.Name +// +// If a matching entry exists it is replaced; otherwise the metric is appended. +func (e *HPASpecEditor) EnsureMetric(metric autoscalingv2.MetricSpec) { + for i, existing := range e.spec.Metrics { + if metricsMatch(existing, metric) { + e.spec.Metrics[i] = metric + return + } + } + e.spec.Metrics = append(e.spec.Metrics, metric) +} + +// RemoveMetric removes a metric matching the given type and name. +// +// For Resource metrics, name corresponds to the resource name (e.g. "cpu"). +// For Pods, Object, and External metrics, name corresponds to the metric name. +// For ContainerResource metrics, name corresponds to the resource name; all +// container variants of that resource are removed. +// +// If no matching metric is found, this is a no-op. +func (e *HPASpecEditor) RemoveMetric(metricType autoscalingv2.MetricSourceType, name string) { + filtered := e.spec.Metrics[:0] + for _, m := range e.spec.Metrics { + if m.Type == metricType && metricName(m) == name { + continue + } + filtered = append(filtered, m) + } + e.spec.Metrics = filtered +} + +// SetBehavior sets the autoscaling behavior configuration. +// Passing nil removes custom behavior (Kubernetes uses defaults). +func (e *HPASpecEditor) SetBehavior(behavior *autoscalingv2.HorizontalPodAutoscalerBehavior) { + e.spec.Behavior = behavior +} + +// metricsMatch reports whether two MetricSpec values target the same metric. +func metricsMatch(a, b autoscalingv2.MetricSpec) bool { + if a.Type != b.Type { + return false + } + switch a.Type { + case autoscalingv2.ResourceMetricSourceType: + if a.Resource == nil || b.Resource == nil { + return false + } + return a.Resource.Name == b.Resource.Name + case autoscalingv2.PodsMetricSourceType: + if a.Pods == nil || b.Pods == nil { + return false + } + return a.Pods.Metric.Name == b.Pods.Metric.Name + case autoscalingv2.ObjectMetricSourceType: + if a.Object == nil || b.Object == nil { + return false + } + return a.Object.Metric.Name == b.Object.Metric.Name + case autoscalingv2.ContainerResourceMetricSourceType: + if a.ContainerResource == nil || b.ContainerResource == nil { + return false + } + return a.ContainerResource.Name == b.ContainerResource.Name && + a.ContainerResource.Container == b.ContainerResource.Container + case autoscalingv2.ExternalMetricSourceType: + if a.External == nil || b.External == nil { + return false + } + return a.External.Metric.Name == b.External.Metric.Name + default: + return false + } +} + +// metricName extracts the identifying name from a MetricSpec. +func metricName(m autoscalingv2.MetricSpec) string { + switch m.Type { + case autoscalingv2.ResourceMetricSourceType: + if m.Resource != nil { + return string(m.Resource.Name) + } + case autoscalingv2.PodsMetricSourceType: + if m.Pods != nil { + return m.Pods.Metric.Name + } + case autoscalingv2.ObjectMetricSourceType: + if m.Object != nil { + return m.Object.Metric.Name + } + case autoscalingv2.ContainerResourceMetricSourceType: + if m.ContainerResource != nil { + return string(m.ContainerResource.Name) + } + case autoscalingv2.ExternalMetricSourceType: + if m.External != nil { + return m.External.Metric.Name + } + } + return "" +} diff --git a/pkg/mutation/editors/hpaspec_test.go b/pkg/mutation/editors/hpaspec_test.go new file mode 100644 index 00000000..53c25223 --- /dev/null +++ b/pkg/mutation/editors/hpaspec_test.go @@ -0,0 +1,291 @@ +package editors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func int32Ptr(v int32) *int32 { return &v } + +func resourcePtr(s string) *resource.Quantity { + q := resource.MustParse(s) + return &q +} + +func TestHPASpecEditor_SetScaleTargetRef(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + ref := autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "web", + } + e.SetScaleTargetRef(ref) + + assert.Equal(t, ref, spec.ScaleTargetRef) +} + +func TestHPASpecEditor_SetMinReplicas(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + e.SetMinReplicas(int32Ptr(2)) + require.NotNil(t, spec.MinReplicas) + assert.Equal(t, int32(2), *spec.MinReplicas) + + e.SetMinReplicas(nil) + assert.Nil(t, spec.MinReplicas) +} + +func TestHPASpecEditor_SetMaxReplicas(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + e.SetMaxReplicas(10) + assert.Equal(t, int32(10), spec.MaxReplicas) +} + +func TestHPASpecEditor_EnsureMetric_Resource(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + cpuMetric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(80), + }, + }, + } + + // Add CPU metric + e.EnsureMetric(cpuMetric) + require.Len(t, spec.Metrics, 1) + assert.Equal(t, int32(80), *spec.Metrics[0].Resource.Target.AverageUtilization) + + // Update CPU metric (upsert by resource name) + updatedCPU := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(50), + }, + }, + } + e.EnsureMetric(updatedCPU) + require.Len(t, spec.Metrics, 1) + assert.Equal(t, int32(50), *spec.Metrics[0].Resource.Target.AverageUtilization) + + // Add memory metric (different resource name) + memMetric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.AverageValueMetricType, + AverageValue: resourcePtr("500Mi"), + }, + }, + } + e.EnsureMetric(memMetric) + assert.Len(t, spec.Metrics, 2) +} + +func TestHPASpecEditor_EnsureMetric_Pods(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + metric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.PodsMetricSourceType, + Pods: &autoscalingv2.PodsMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"}, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.AverageValueMetricType, + AverageValue: resourcePtr("100"), + }, + }, + } + e.EnsureMetric(metric) + require.Len(t, spec.Metrics, 1) + + // Upsert same metric name + updated := autoscalingv2.MetricSpec{ + Type: autoscalingv2.PodsMetricSourceType, + Pods: &autoscalingv2.PodsMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"}, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.AverageValueMetricType, + AverageValue: resourcePtr("200"), + }, + }, + } + e.EnsureMetric(updated) + assert.Len(t, spec.Metrics, 1) +} + +func TestHPASpecEditor_EnsureMetric_Object(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + metric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ObjectMetricSourceType, + Object: &autoscalingv2.ObjectMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "queue_length"}, + DescribedObject: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "v1", + Kind: "Service", + Name: "worker", + }, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.ValueMetricType, + Value: resourcePtr("30"), + }, + }, + } + e.EnsureMetric(metric) + require.Len(t, spec.Metrics, 1) + assert.Equal(t, "queue_length", spec.Metrics[0].Object.Metric.Name) +} + +func TestHPASpecEditor_EnsureMetric_External(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + metric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ExternalMetricSourceType, + External: &autoscalingv2.ExternalMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "pubsub_undelivered"}, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.ValueMetricType, + Value: resourcePtr("100"), + }, + }, + } + e.EnsureMetric(metric) + require.Len(t, spec.Metrics, 1) + assert.Equal(t, "pubsub_undelivered", spec.Metrics[0].External.Metric.Name) +} + +func TestHPASpecEditor_EnsureMetric_ContainerResource(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + metric := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ContainerResourceMetricSourceType, + ContainerResource: &autoscalingv2.ContainerResourceMetricSource{ + Name: corev1.ResourceCPU, + Container: "app", + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(70), + }, + }, + } + e.EnsureMetric(metric) + require.Len(t, spec.Metrics, 1) + + // Different container same resource -> separate entry + metric2 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ContainerResourceMetricSourceType, + ContainerResource: &autoscalingv2.ContainerResourceMetricSource{ + Name: corev1.ResourceCPU, + Container: "sidecar", + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(50), + }, + }, + } + e.EnsureMetric(metric2) + assert.Len(t, spec.Metrics, 2) +} + +func TestHPASpecEditor_RemoveMetric(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{ + Metrics: []autoscalingv2.MetricSpec{ + { + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(80), + }, + }, + }, + { + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(70), + }, + }, + }, + }, + } + e := NewHPASpecEditor(spec) + + e.RemoveMetric(autoscalingv2.ResourceMetricSourceType, "cpu") + require.Len(t, spec.Metrics, 1) + assert.Equal(t, corev1.ResourceMemory, spec.Metrics[0].Resource.Name) +} + +func TestHPASpecEditor_RemoveMetric_NoMatch(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{ + Metrics: []autoscalingv2.MetricSpec{ + { + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + }, + }, + }, + } + e := NewHPASpecEditor(spec) + + e.RemoveMetric(autoscalingv2.ResourceMetricSourceType, "memory") + assert.Len(t, spec.Metrics, 1) +} + +func TestHPASpecEditor_SetBehavior(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + stabilization := int32(300) + behavior := &autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: &stabilization, + }, + } + e.SetBehavior(behavior) + require.NotNil(t, spec.Behavior) + assert.Equal(t, int32(300), *spec.Behavior.ScaleDown.StabilizationWindowSeconds) + + e.SetBehavior(nil) + assert.Nil(t, spec.Behavior) +} + +func TestHPASpecEditor_Raw(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{ + MaxReplicas: 5, + } + e := NewHPASpecEditor(spec) + + raw := e.Raw() + assert.Equal(t, int32(5), raw.MaxReplicas) + + raw.MaxReplicas = 10 + assert.Equal(t, int32(10), spec.MaxReplicas) +} From b149934e7480e55e89be3d6fbd29b084ea6676b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:43:30 +0000 Subject: [PATCH 02/28] Add HPA primitive package with builder, resource, mutator, flavors, and handlers Integration lifecycle primitive for autoscaling/v2 HorizontalPodAutoscaler. DefaultDeleteOnSuspend = true (HPA has no native suspend field). Operational status derived from ScalingActive and AbleToScale conditions. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/builder.go | 164 ++++++++++++++++++ pkg/primitives/hpa/builder_test.go | 251 ++++++++++++++++++++++++++++ pkg/primitives/hpa/flavors.go | 25 +++ pkg/primitives/hpa/flavors_test.go | 78 +++++++++ pkg/primitives/hpa/handlers.go | 115 +++++++++++++ pkg/primitives/hpa/handlers_test.go | 153 +++++++++++++++++ pkg/primitives/hpa/mutator.go | 104 ++++++++++++ pkg/primitives/hpa/mutator_test.go | 157 +++++++++++++++++ pkg/primitives/hpa/resource.go | 100 +++++++++++ 9 files changed, 1147 insertions(+) create mode 100644 pkg/primitives/hpa/builder.go create mode 100644 pkg/primitives/hpa/builder_test.go create mode 100644 pkg/primitives/hpa/flavors.go create mode 100644 pkg/primitives/hpa/flavors_test.go create mode 100644 pkg/primitives/hpa/handlers.go create mode 100644 pkg/primitives/hpa/handlers_test.go create mode 100644 pkg/primitives/hpa/mutator.go create mode 100644 pkg/primitives/hpa/mutator_test.go create mode 100644 pkg/primitives/hpa/resource.go diff --git a/pkg/primitives/hpa/builder.go b/pkg/primitives/hpa/builder.go new file mode 100644 index 00000000..a565e530 --- /dev/null +++ b/pkg/primitives/hpa/builder.go @@ -0,0 +1,164 @@ +package hpa + +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" + autoscalingv2 "k8s.io/api/autoscaling/v2" +) + +// Builder is a configuration helper for creating and customizing an HPA 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.IntegrationBuilder[*autoscalingv2.HorizontalPodAutoscaler, *Mutator] +} + +// NewBuilder initializes a new Builder with the provided HorizontalPodAutoscaler object. +// +// The HPA 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 HPA must have at least a Name and Namespace set, which +// is validated during the Build() call. +func NewBuilder(hpa *autoscalingv2.HorizontalPodAutoscaler) *Builder { + identityFunc := func(h *autoscalingv2.HorizontalPodAutoscaler) string { + return fmt.Sprintf("autoscaling/v2/HorizontalPodAutoscaler/%s/%s", h.Namespace, h.Name) + } + + base := generic.NewIntegrationBuilder[*autoscalingv2.HorizontalPodAutoscaler, *Mutator]( + hpa, + identityFunc, + DefaultFieldApplicator, + NewMutator, + ) + + base. + WithCustomOperationalStatus(DefaultOperationalStatusHandler). + WithCustomSuspendStatus(DefaultSuspensionStatusHandler). + WithCustomSuspendMutation(DefaultSuspendMutationHandler). + WithCustomSuspendDeletionDecision(DefaultDeleteOnSuspendHandler) + + return &Builder{ + base: base, + } +} + +// WithMutation registers a feature-based mutation for the HPA. +// +// Mutations are applied sequentially during the Mutate() phase of reconciliation. +// A mutation with a nil Feature is applied unconditionally; one with a non-nil +// Feature is applied only when that feature is enabled. +func (b *Builder) WithMutation(m Mutation) *Builder { + b.base.WithMutation(feature.Mutation[*Mutator](m)) + return b +} + +// WithCustomFieldApplicator sets a custom strategy for applying the desired +// state to the existing HPA in the cluster. +// +// The default applicator (DefaultFieldApplicator) replaces the current object +// with a deep copy of the desired object. Use a custom applicator when other +// controllers manage fields you need to preserve. +func (b *Builder) WithCustomFieldApplicator( + applicator func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error, +) *Builder { + b.base.WithCustomFieldApplicator(applicator) + return b +} + +// WithFieldApplicationFlavor registers a post-baseline field application flavor. +// +// Flavors run after the baseline applicator (default or custom) in registration +// order. They are typically used to preserve fields from the live cluster object +// that should not be overwritten by the desired state. +// +// A nil flavor is ignored. +func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { + b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*autoscalingv2.HorizontalPodAutoscaler](flavor)) + return b +} + +// WithCustomOperationalStatus overrides the default logic for determining the +// HPA's operational status. +// +// The default behavior uses DefaultOperationalStatusHandler, which inspects +// HPA conditions (ScalingActive, AbleToScale) to determine status. +func (b *Builder) WithCustomOperationalStatus( + handler func(concepts.ConvergingOperation, *autoscalingv2.HorizontalPodAutoscaler) (concepts.OperationalStatusWithReason, error), +) *Builder { + b.base.WithCustomOperationalStatus(handler) + return b +} + +// WithCustomSuspendStatus overrides how the progress of suspension is reported. +// +// The default behavior uses DefaultSuspensionStatusHandler, which reports +// Suspended with a reason indicating the HPA was deleted on suspend. +func (b *Builder) WithCustomSuspendStatus( + handler func(*autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error), +) *Builder { + b.base.WithCustomSuspendStatus(handler) + return b +} + +// WithCustomSuspendMutation defines how the HPA should be modified when +// the component is suspended. +// +// The default behavior uses DefaultSuspendMutationHandler, which is a no-op +// since the HPA is deleted on suspend. +func (b *Builder) WithCustomSuspendMutation( + handler func(*Mutator) error, +) *Builder { + b.base.WithCustomSuspendMutation(handler) + return b +} + +// WithCustomSuspendDeletionDecision overrides the decision of whether to delete +// the HPA when the component is suspended. +// +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true. +// HPA has no native suspend field; deleting prevents it from interfering with +// manually-scaled replicas while suspended. +func (b *Builder) WithCustomSuspendDeletionDecision( + handler func(*autoscalingv2.HorizontalPodAutoscaler) bool, +) *Builder { + b.base.WithCustomSuspendDeletionDecision(handler) + return b +} + +// WithDataExtractor registers a function to read values from the HPA after +// it has been successfully reconciled. +// +// The extractor receives a value copy of the reconciled HPA. This is useful +// for surfacing generated or updated fields to other components or resources. +// +// A nil extractor is ignored. +func (b *Builder) WithDataExtractor( + extractor func(autoscalingv2.HorizontalPodAutoscaler) error, +) *Builder { + if extractor != nil { + b.base.WithDataExtractor(func(h *autoscalingv2.HorizontalPodAutoscaler) error { + return extractor(*h) + }) + } + return b +} + +// Build validates the configuration and returns the initialized Resource. +// +// It returns an error if: +// - No HPA object was provided. +// - The HPA is missing a Name or Namespace. +func (b *Builder) Build() (*Resource, error) { + genericRes, err := b.base.Build() + if err != nil { + return nil, err + } + return &Resource{base: genericRes}, nil +} diff --git a/pkg/primitives/hpa/builder_test.go b/pkg/primitives/hpa/builder_test.go new file mode 100644 index 00000000..c2961715 --- /dev/null +++ b/pkg/primitives/hpa/builder_test.go @@ -0,0 +1,251 @@ +package hpa + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + 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 + hpa *autoscalingv2.HorizontalPodAutoscaler + expectedErr string + }{ + { + name: "nil HPA", + hpa: nil, + expectedErr: "object cannot be nil", + }, + { + name: "empty name", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + }, + }, + expectedErr: "object name cannot be empty", + }, + { + name: "empty namespace", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + }, + }, + expectedErr: "object namespace cannot be empty", + }, + { + name: "valid HPA", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + }, + expectedErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := NewBuilder(tt.hpa).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, "autoscaling/v2/HorizontalPodAutoscaler/test-ns/test-hpa", res.Identity()) + } + }) + } + }) + + t.Run("WithMutation", func(t *testing.T) { + t.Parallel() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + m := Mutation{ + Name: "test-mutation", + } + res, err := NewBuilder(hpa). + 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() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + applied := false + applicator := func(_, _ *autoscalingv2.HorizontalPodAutoscaler) error { + applied = true + return nil + } + res, err := NewBuilder(hpa). + 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() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(hpa). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 1) + }) + + t.Run("WithCustomOperationalStatus", func(t *testing.T) { + t.Parallel() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + handler := func(_ concepts.ConvergingOperation, _ *autoscalingv2.HorizontalPodAutoscaler) (concepts.OperationalStatusWithReason, error) { + return concepts.OperationalStatusWithReason{Status: concepts.OperationalStatusOperational}, nil + } + res, err := NewBuilder(hpa). + WithCustomOperationalStatus(handler). + Build() + require.NoError(t, err) + require.NotNil(t, res.base.OperationalStatusHandler) + status, err := res.base.OperationalStatusHandler(concepts.ConvergingOperationNone, nil) + require.NoError(t, err) + assert.Equal(t, concepts.OperationalStatusOperational, status.Status) + }) + + t.Run("WithCustomSuspendStatus", func(t *testing.T) { + t.Parallel() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + handler := func(_ *autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{Status: concepts.SuspensionStatusSuspended}, nil + } + res, err := NewBuilder(hpa). + 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() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + handler := func(_ *Mutator) error { + return errors.New("suspend error") + } + res, err := NewBuilder(hpa). + 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() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + handler := func(_ *autoscalingv2.HorizontalPodAutoscaler) bool { + return false + } + res, err := NewBuilder(hpa). + 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() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + called := false + extractor := func(_ autoscalingv2.HorizontalPodAutoscaler) error { + called = true + return nil + } + res, err := NewBuilder(hpa). + WithDataExtractor(extractor). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 1) + err = res.base.DataExtractors[0](&autoscalingv2.HorizontalPodAutoscaler{}) + require.NoError(t, err) + assert.True(t, called) + }) + + t.Run("WithDataExtractor nil", func(t *testing.T) { + t.Parallel() + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "test-ns", + }, + } + res, err := NewBuilder(hpa). + WithDataExtractor(nil). + Build() + require.NoError(t, err) + assert.Len(t, res.base.DataExtractors, 0) + }) +} diff --git a/pkg/primitives/hpa/flavors.go b/pkg/primitives/hpa/flavors.go new file mode 100644 index 00000000..54d21647 --- /dev/null +++ b/pkg/primitives/hpa/flavors.go @@ -0,0 +1,25 @@ +package hpa + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/flavors" + autoscalingv2 "k8s.io/api/autoscaling/v2" +) + +// FieldApplicationFlavor defines a function signature for applying "flavors" to an HPA resource. +// A flavor typically preserves certain fields from the current (live) object after the +// baseline field application has occurred. +type FieldApplicationFlavor flavors.FieldApplicationFlavor[*autoscalingv2.HorizontalPodAutoscaler] + +// PreserveCurrentLabels ensures that any labels present on the current live +// HPA but missing from the applied (desired) object are preserved. +// If a label exists in both, the applied value wins. +func PreserveCurrentLabels(applied, current, desired *autoscalingv2.HorizontalPodAutoscaler) error { + return flavors.PreserveCurrentLabels[*autoscalingv2.HorizontalPodAutoscaler]()(applied, current, desired) +} + +// PreserveCurrentAnnotations ensures that any annotations present on the current +// live HPA but missing from the applied (desired) object are preserved. +// If an annotation exists in both, the applied value wins. +func PreserveCurrentAnnotations(applied, current, desired *autoscalingv2.HorizontalPodAutoscaler) error { + return flavors.PreserveCurrentAnnotations[*autoscalingv2.HorizontalPodAutoscaler]()(applied, current, desired) +} diff --git a/pkg/primitives/hpa/flavors_test.go b/pkg/primitives/hpa/flavors_test.go new file mode 100644 index 00000000..aa4a6782 --- /dev/null +++ b/pkg/primitives/hpa/flavors_test.go @@ -0,0 +1,78 @@ +package hpa + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestPreserveCurrentLabels(t *testing.T) { + t.Parallel() + + applied := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "desired-value", + }, + }, + } + current := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": "current-value", + "external": "preserved", + }, + }, + } + + err := PreserveCurrentLabels(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "desired-value", applied.Labels["app"]) + assert.Equal(t, "preserved", applied.Labels["external"]) +} + +func TestPreserveCurrentAnnotations(t *testing.T) { + t.Parallel() + + applied := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "config": "desired", + }, + }, + } + current := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "config": "current", + "external": "preserved", + }, + }, + } + + err := PreserveCurrentAnnotations(applied, current, nil) + require.NoError(t, err) + assert.Equal(t, "desired", applied.Annotations["config"]) + assert.Equal(t, "preserved", applied.Annotations["external"]) +} + +func TestFlavors_RunInRegistrationOrder(t *testing.T) { + t.Parallel() + + base := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "default", + }, + } + + res, err := NewBuilder(base). + WithFieldApplicationFlavor(PreserveCurrentLabels). + WithFieldApplicationFlavor(PreserveCurrentAnnotations). + Build() + require.NoError(t, err) + assert.Len(t, res.base.FieldFlavors, 2) +} diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go new file mode 100644 index 00000000..5cbe9e94 --- /dev/null +++ b/pkg/primitives/hpa/handlers.go @@ -0,0 +1,115 @@ +package hpa + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" +) + +// DefaultOperationalStatusHandler is the default logic for determining the operational status +// of a HorizontalPodAutoscaler. +// +// It inspects Status.Conditions to classify the HPA's state: +// - OperationalStatusOperational: condition ScalingActive is True. +// - OperationalStatusPending: conditions are absent, or ScalingActive is Unknown. +// - OperationalStatusFailing: condition ScalingActive is False, or condition AbleToScale is False. +// +// This function is used as the default handler by the Resource if no custom handler is registered +// via Builder.WithCustomOperationalStatus. It can be reused within custom handlers to augment +// the default behavior. +func DefaultOperationalStatusHandler( + _ concepts.ConvergingOperation, hpa *autoscalingv2.HorizontalPodAutoscaler, +) (concepts.OperationalStatusWithReason, error) { + scalingActive := findCondition(hpa.Status.Conditions, autoscalingv2.ScalingActive) + ableToScale := findCondition(hpa.Status.Conditions, autoscalingv2.AbleToScale) + + // Check for failing conditions first + if ableToScale != nil && ableToScale.Status == corev1.ConditionFalse { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusFailing, + Reason: conditionReason(ableToScale, "AbleToScale is False"), + }, nil + } + + if scalingActive != nil { + switch scalingActive.Status { + case corev1.ConditionTrue: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusOperational, + Reason: conditionReason(scalingActive, "ScalingActive is True"), + }, nil + case corev1.ConditionFalse: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusFailing, + Reason: conditionReason(scalingActive, "ScalingActive is False"), + }, nil + } + } + + // Conditions absent or ScalingActive is Unknown + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Waiting for HPA conditions to be populated", + }, nil +} + +// DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the HPA +// when the parent component is suspended. +// +// It always returns true. HPA has no native suspend field; deleting prevents it from +// interfering with manually-scaled replicas while 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(_ *autoscalingv2.HorizontalPodAutoscaler) bool { + return true +} + +// DefaultSuspendMutationHandler provides the default mutation applied to an HPA when +// the component is suspended. +// +// It is a no-op because the HPA is deleted on suspend (DefaultDeleteOnSuspendHandler returns true). +// +// 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 reports the suspension status of the HPA. +// +// It always returns Suspended with a reason indicating the HPA is deleted on suspend. +// Since the HPA is deleted during suspension, its status is inherently Suspended once +// the framework reaches this handler. +// +// 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( + _ *autoscalingv2.HorizontalPodAutoscaler, +) (concepts.SuspensionStatusWithReason, error) { + return concepts.SuspensionStatusWithReason{ + Status: concepts.SuspensionStatusSuspended, + Reason: "HorizontalPodAutoscaler deleted on suspend", + }, nil +} + +// findCondition returns the first condition matching the given type, or nil. +func findCondition( + conditions []autoscalingv2.HorizontalPodAutoscalerCondition, + condType autoscalingv2.HorizontalPodAutoscalerConditionType, +) *autoscalingv2.HorizontalPodAutoscalerCondition { + for i := range conditions { + if conditions[i].Type == condType { + return &conditions[i] + } + } + return nil +} + +// conditionReason returns the condition's message if present, otherwise a fallback. +func conditionReason(cond *autoscalingv2.HorizontalPodAutoscalerCondition, fallback string) string { + if cond.Message != "" { + return cond.Message + } + return fallback +} diff --git a/pkg/primitives/hpa/handlers_test.go b/pkg/primitives/hpa/handlers_test.go new file mode 100644 index 00000000..a9fbc221 --- /dev/null +++ b/pkg/primitives/hpa/handlers_test.go @@ -0,0 +1,153 @@ +package hpa + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" +) + +func TestDefaultOperationalStatusHandler(t *testing.T) { + tests := []struct { + name string + hpa *autoscalingv2.HorizontalPodAutoscaler + wantStatus concepts.OperationalStatus + }{ + { + name: "operational when ScalingActive is True", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionTrue, + Message: "the HPA was able to successfully calculate a replica count", + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusOperational, + }, + { + name: "pending when no conditions", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{}, + }, + wantStatus: concepts.OperationalStatusPending, + }, + { + name: "pending when ScalingActive is Unknown", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionUnknown, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + }, + { + name: "failing when ScalingActive is False", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionFalse, + Message: "the HPA target is missing", + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusFailing, + }, + { + name: "failing when AbleToScale is False", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionTrue, + }, + { + Type: autoscalingv2.AbleToScale, + Status: corev1.ConditionFalse, + Message: "the HPA controller was unable to update the target scale", + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusFailing, + }, + { + name: "failing when AbleToScale is False takes precedence over ScalingActive True", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.AbleToScale, + Status: corev1.ConditionFalse, + }, + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusFailing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, tt.hpa) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, got.Status) + }) + } +} + +func TestDefaultOperationalStatusHandler_UsesConditionMessage(t *testing.T) { + hpa := &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionTrue, + Message: "scaling is active and healthy", + }, + }, + }, + } + got, err := DefaultOperationalStatusHandler(concepts.ConvergingOperationNone, hpa) + require.NoError(t, err) + assert.Equal(t, "scaling is active and healthy", got.Reason) +} + +func TestDefaultDeleteOnSuspendHandler(t *testing.T) { + hpa := &autoscalingv2.HorizontalPodAutoscaler{} + assert.True(t, DefaultDeleteOnSuspendHandler(hpa)) +} + +func TestDefaultSuspendMutationHandler(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + err := DefaultSuspendMutationHandler(m) + require.NoError(t, err) +} + +func TestDefaultSuspensionStatusHandler(t *testing.T) { + hpa := &autoscalingv2.HorizontalPodAutoscaler{} + got, err := DefaultSuspensionStatusHandler(hpa) + require.NoError(t, err) + assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) + assert.Equal(t, "HorizontalPodAutoscaler deleted on suspend", got.Reason) +} diff --git a/pkg/primitives/hpa/mutator.go b/pkg/primitives/hpa/mutator.go new file mode 100644 index 00000000..26f2c5f0 --- /dev/null +++ b/pkg/primitives/hpa/mutator.go @@ -0,0 +1,104 @@ +// Package hpa provides a builder and resource for managing Kubernetes HorizontalPodAutoscalers. +package hpa + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + autoscalingv2 "k8s.io/api/autoscaling/v2" +) + +// Mutation defines a mutation that is applied to an HPA Mutator +// only if its associated feature gate is enabled. +type Mutation feature.Mutation[*Mutator] + +type featurePlan struct { + metadataEdits []func(*editors.ObjectMetaEditor) error + hpaSpecEdits []func(*editors.HPASpecEditor) error +} + +// Mutator is a high-level helper for modifying a Kubernetes HorizontalPodAutoscaler. +// +// It uses a "plan-and-apply" pattern: mutations are recorded first, then +// applied to the HPA in a single controlled pass when Apply() is called. +// +// The Mutator maintains feature boundaries: each feature's mutations are planned +// together and applied in the order the features were registered. +// +// Mutator implements editors.ObjectMutator. +type Mutator struct { + hpa *autoscalingv2.HorizontalPodAutoscaler + + plans []featurePlan + active *featurePlan +} + +// NewMutator creates a new Mutator for the given HorizontalPodAutoscaler. +func NewMutator(hpa *autoscalingv2.HorizontalPodAutoscaler) *Mutator { + m := &Mutator{hpa: hpa} + m.beginFeature() + return m +} + +// beginFeature starts a new feature planning scope. All subsequent mutation +// registrations will be grouped into this feature's plan. +func (m *Mutator) beginFeature() { + m.plans = append(m.plans, featurePlan{}) + m.active = &m.plans[len(m.plans)-1] +} + +// EditObjectMetadata records a mutation for the HPA's own metadata. +// +// Metadata edits are applied before HPA spec edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditObjectMetadata(edit func(*editors.ObjectMetaEditor) error) { + if edit == nil { + return + } + m.active.metadataEdits = append(m.active.metadataEdits, edit) +} + +// EditHPASpec records a mutation for the HPA's spec. +// +// HPA spec edits are applied after metadata edits within the same feature. +// A nil edit function is ignored. +func (m *Mutator) EditHPASpec(edit func(*editors.HPASpecEditor) error) { + if edit == nil { + return + } + m.active.hpaSpecEdits = append(m.active.hpaSpecEdits, edit) +} + +// Apply executes all recorded mutation intents on the underlying HPA. +// +// Execution order across all registered features: +// +// 1. Metadata edits (in registration order within each feature) +// 2. HPA spec edits (in registration order within each feature) +// +// Features are applied in the order they were registered. Later features observe +// the HPA as modified by all previous features. +func (m *Mutator) Apply() error { + for _, plan := range m.plans { + // 1. Metadata edits + if len(plan.metadataEdits) > 0 { + editor := editors.NewObjectMetaEditor(&m.hpa.ObjectMeta) + for _, edit := range plan.metadataEdits { + if err := edit(editor); err != nil { + return err + } + } + } + + // 2. HPA spec edits + if len(plan.hpaSpecEdits) > 0 { + editor := editors.NewHPASpecEditor(&m.hpa.Spec) + for _, edit := range plan.hpaSpecEdits { + if err := edit(editor); err != nil { + return err + } + } + } + } + + return nil +} diff --git a/pkg/primitives/hpa/mutator_test.go b/pkg/primitives/hpa/mutator_test.go new file mode 100644 index 00000000..d105b42d --- /dev/null +++ b/pkg/primitives/hpa/mutator_test.go @@ -0,0 +1,157 @@ +package hpa + +import ( + "errors" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestHPA() *autoscalingv2.HorizontalPodAutoscaler { + return &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-hpa", + Namespace: "default", + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + MaxReplicas: 10, + }, + } +} + +// --- EditObjectMetadata --- + +func TestMutator_EditObjectMetadata(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app", "myapp") + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, "myapp", hpa.Labels["app"]) +} + +func TestMutator_EditObjectMetadata_Nil(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditObjectMetadata(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditObjectMetadata_Error(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { + return errors.New("metadata error") + }) + assert.EqualError(t, m.Apply(), "metadata error") +} + +// --- EditHPASpec --- + +func TestMutator_EditHPASpec(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(20) + return nil + }) + require.NoError(t, m.Apply()) + assert.Equal(t, int32(20), hpa.Spec.MaxReplicas) +} + +func TestMutator_EditHPASpec_Nil(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditHPASpec(nil) + assert.NoError(t, m.Apply()) +} + +func TestMutator_EditHPASpec_Error(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditHPASpec(func(_ *editors.HPASpecEditor) error { + return errors.New("spec error") + }) + assert.EqualError(t, m.Apply(), "spec error") +} + +func TestMutator_EditHPASpec_EnsureMetric(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: int32Ptr(80), + }, + }, + }) + return nil + }) + require.NoError(t, m.Apply()) + require.Len(t, hpa.Spec.Metrics, 1) + assert.Equal(t, corev1.ResourceCPU, hpa.Spec.Metrics[0].Resource.Name) +} + +// --- Execution order --- + +func TestMutator_ExecutionOrder(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + + var order []string + + // Register spec edit first, metadata second — metadata must still run first + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + order = append(order, "spec") + e.SetMaxReplicas(5) + return nil + }) + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + order = append(order, "metadata") + e.EnsureLabel("test", "value") + return nil + }) + + require.NoError(t, m.Apply()) + require.Equal(t, []string{"metadata", "spec"}, order) +} + +// --- Multiple features --- + +func TestMutator_MultipleFeatures(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("feature", "one") + return nil + }) + + m.beginFeature() + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + // Second feature overwrites the label + e.EnsureLabel("feature", "two") + return nil + }) + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(42) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Equal(t, "two", hpa.Labels["feature"]) + assert.Equal(t, int32(42), hpa.Spec.MaxReplicas) +} + +func int32Ptr(v int32) *int32 { return &v } diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go new file mode 100644 index 00000000..bbe04d7c --- /dev/null +++ b/pkg/primitives/hpa/resource.go @@ -0,0 +1,100 @@ +package hpa + +import ( + "github.com/sourcehawk/operator-component-framework/internal/generic" + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + autoscalingv2 "k8s.io/api/autoscaling/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DefaultFieldApplicator replaces current with a deep copy of desired. +// +// This is the default baseline field application strategy for HPA resources. +// Use a custom field applicator via Builder.WithCustomFieldApplicator if you need +// to preserve fields that other controllers manage. +func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { + *current = *desired.DeepCopy() + return nil +} + +// Resource is a high-level abstraction for managing a Kubernetes HorizontalPodAutoscaler +// within a controller's reconciliation loop. +// +// It implements the following component interfaces: +// - component.Resource: for basic identity and mutation behaviour. +// - component.Operational: for reporting operational status based on HPA conditions. +// - component.Suspendable: for delete-on-suspend behaviour (HPA has no native suspend). +// - component.DataExtractable: for exporting values after successful reconciliation. +type Resource struct { + base *generic.IntegrationResource[*autoscalingv2.HorizontalPodAutoscaler, *Mutator] +} + +// Identity returns a unique identifier for the HPA in the format +// "autoscaling/v2/HorizontalPodAutoscaler//". +func (r *Resource) Identity() string { + return r.base.Identity() +} + +// Object returns a deep copy of the underlying Kubernetes HorizontalPodAutoscaler object. +// +// The returned object implements client.Object, making it compatible with +// controller-runtime's Client for Create, Update, and Patch operations. +func (r *Resource) Object() (client.Object, error) { + return r.base.Object() +} + +// Mutate transforms the current state of a Kubernetes HorizontalPodAutoscaler into the desired state. +// +// The mutation process follows this order: +// 1. Field application: the current object is updated to reflect the desired base state, +// using either DefaultFieldApplicator or a custom applicator if one is configured. +// 2. Field application flavors: any registered flavors are applied in registration order. +// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// +// This method is invoked by the framework during the Update phase of reconciliation. +func (r *Resource) Mutate(current client.Object) error { + return r.base.Mutate(current) +} + +// ConvergingStatus reports the HPA's operational status using the configured handler. +// +// By default, it uses DefaultOperationalStatusHandler, which inspects HPA conditions +// to determine if the autoscaler is active, pending, or failing. +func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.OperationalStatusWithReason, error) { + return r.base.ConvergingStatus(op) +} + +// DeleteOnSuspend determines whether the HPA should be deleted from the cluster +// when the parent component is suspended. +// +// By default, it uses DefaultDeleteOnSuspendHandler, which returns true. HPA has +// no native suspend field; deleting prevents it from interfering with manually-scaled +// replicas while suspended. +func (r *Resource) DeleteOnSuspend() bool { + return r.base.DeleteOnSuspend() +} + +// Suspend registers the configured suspension mutation for the next mutate cycle. +// +// For HPA, the default suspension mutation is a no-op since the resource is deleted +// on suspend. +func (r *Resource) Suspend() error { + return r.base.Suspend() +} + +// SuspensionStatus reports the suspension status of the HPA. +// +// By default, it uses DefaultSuspensionStatusHandler, which reports Suspended with +// a reason indicating the HPA was deleted on suspend. +func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { + return r.base.SuspensionStatus() +} + +// ExtractData executes all registered data extractor functions against a deep copy +// of the reconciled HPA. +// +// This is called by the framework after successful reconciliation, allowing the +// component to read generated or updated values from the HPA. +func (r *Resource) ExtractData() error { + return r.base.ExtractData() +} From b9230320a05ce22a45e65ab3851db79df20c6241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:44:44 +0000 Subject: [PATCH 03/28] Add HPA primitive documentation Covers capabilities, building, mutations (always/boolean/version-gated), internal ordering, editors, operational status, suspension, flavors, and a full autoscaling example. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 351 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/primitives/hpa.md diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md new file mode 100644 index 00000000..d7a962a3 --- /dev/null +++ b/docs/primitives/hpa.md @@ -0,0 +1,351 @@ +# HorizontalPodAutoscaler (HPA) Primitive + +The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes `HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational, Suspendable resource and provides a structured mutation API for configuring autoscaling behavior. + +## Capabilities + +| Capability | Detail | +|----------------------------|-----------------------------------------------------------------------------------------------------------| +| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | +| **Suspension (delete)** | Deletes the HPA on suspend — prevents it from interfering with manually-scaled replicas | +| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | +| **Flavors** | Preserves externally-managed labels and annotations | +| **Data extraction** | Reads current and desired replica counts from the reconciled HPA after each sync cycle | + +## Building an HPA Primitive + +```go +import "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" + +base := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "web-hpa", + Namespace: owner.Namespace, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "web", + }, + MinReplicas: ptr.To(int32(2)), + MaxReplicas: 10, + }, +} + +resource, err := hpa.NewBuilder(base). + WithFieldApplicationFlavor(hpa.PreserveCurrentLabels). + WithMutation(CPUMetricMutation(owner.Spec.Version)). + Build() +``` + +## Default Field Application + +`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object. This ensures every reconciliation cycle produces a clean, predictable state. + +Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: + +```go +resource, err := hpa.NewBuilder(base). + WithCustomFieldApplicator(func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { + // Preserve status-managed fields while updating spec + desired.DeepCopyInto(current) + return nil + }). + Build() +``` + +## Mutations + +Mutations are the primary mechanism for modifying an HPA 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 CPUMetricMutation(version string) hpa.Mutation { + return hpa.Mutation{ + Name: "cpu-metric", + Feature: feature.NewResourceFeature(version, nil), // always enabled + Mutate: func(m *hpa.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 CustomMetricsMutation(version string, enabled bool) hpa.Mutation { + return hpa.Mutation{ + Name: "custom-metrics", + Feature: feature.NewResourceFeature(version, nil).When(enabled), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.PodsMetricSourceType, + Pods: &autoscalingv2.PodsMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "requests_per_second"}, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.AverageValueMetricType, + AverageValue: ptr.To(resource.MustParse("100")), + }, + }, + }) + return nil + }) + return nil + }, + } +} +``` + +### Version-gated mutations + +Pass a `[]feature.VersionConstraint` to gate on a semver range: + +```go +var legacyConstraint = mustSemverConstraint("< 2.0.0") + +func LegacyScalingMutation(version string) hpa.Mutation { + return hpa.Mutation{ + Name: "legacy-scaling", + Feature: feature.NewResourceFeature( + version, + []feature.VersionConstraint{legacyConstraint}, + ), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(5) // legacy apps limited to 5 replicas + return nil + }) + return nil + }, + } +} +``` + +All version constraints and `When()` conditions must be satisfied for a mutation to apply. + +## Internal Mutation Ordering + +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded: + +| Step | Category | What it affects | +|---|---|---| +| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object | +| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior | + +## Editors + +### HPASpecEditor + +Controls HPA-level settings via `m.EditHPASpec`. + +Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`, `SetBehavior`, `Raw`. + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMinReplicas(ptr.To(int32(2))) + e.SetMaxReplicas(10) + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(80)), + }, + }, + }) + return nil +}) +``` + +#### EnsureMetric + +`EnsureMetric` upserts a metric by type and name. Matching rules: + +| Metric type | Match key | +|---|---| +| Resource | `Resource.Name` (e.g. `cpu`, `memory`) | +| Pods | `Pods.Metric.Name` | +| Object | `Object.Metric.Name` | +| ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` | +| External | `External.Metric.Name` | + +If a matching entry exists it is replaced; otherwise the metric is appended. + +#### RemoveMetric + +`RemoveMetric(type, name)` removes all metrics matching the given type and name. For ContainerResource metrics, all container variants of the named resource are removed. + +#### SetBehavior + +`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom behavior and use Kubernetes defaults. + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(300)), + }, + }) + return nil +}) +``` + +For fields not covered by the typed API, use `Raw()`: + +```go +m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.Raw().MinReplicas = ptr.To(int32(1)) + 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/managed-by", "my-operator") + e.EnsureAnnotation("autoscaling.example.io/policy", "aggressive") + 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. + +## Operational Status + +The default operational status handler inspects `Status.Conditions`: + +| Status | Condition | +|---|---| +| `Operational` | `ScalingActive` is `True` | +| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` | +| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` | + +`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not operationally healthy regardless of what the scaling-active condition reports. + +Override with `WithCustomOperationalStatus`: + +```go +hpa.NewBuilder(base). + WithCustomOperationalStatus(func(op concepts.ConvergingOperation, h *autoscalingv2.HorizontalPodAutoscaler) (concepts.OperationalStatusWithReason, error) { + status, err := hpa.DefaultOperationalStatusHandler(op, h) + if err != nil { + return status, err + } + // Add custom logic + return status, nil + }) +``` + +## Suspension + +HPA has no native suspend field. The default behavior is to **delete the HPA** when the component is suspended (`DefaultDeleteOnSuspendHandler` returns `true`). This prevents the autoscaler from interfering with manually-scaled replicas during suspension. + +The default suspension status handler reports `Suspended` with the reason `"HorizontalPodAutoscaler deleted on suspend"`. + +Override with `WithCustomSuspendDeletionDecision` if you want to keep the HPA during suspension: + +```go +hpa.NewBuilder(base). + WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool { + return false // keep HPA during suspension + }) +``` + +## Flavors + +| Flavor | Effect | +|---|---| +| `PreserveCurrentLabels` | Keeps labels from the live object that the desired state does not declare | +| `PreserveCurrentAnnotations` | Keeps annotations from the live object that the desired state does not declare | + +```go +hpa.NewBuilder(base). + WithFieldApplicationFlavor(hpa.PreserveCurrentLabels). + WithFieldApplicationFlavor(hpa.PreserveCurrentAnnotations) +``` + +## Full Example: CPU and Memory Autoscaling + +```go +func AutoscalingMutation(version string) hpa.Mutation { + return hpa.Mutation{ + Name: "autoscaling-config", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMinReplicas(ptr.To(int32(2))) + e.SetMaxReplicas(10) + + // CPU-based scaling + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(70)), + }, + }, + }) + + // Memory-based scaling + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(int32(80)), + }, + }, + }) + + // Conservative scale-down + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(300)), + }, + }) + + return nil + }) + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} +``` + +Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per the internal ordering. Order your source calls for readability — the framework handles execution 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 metric added by mutation A, register A first. + +**Use `EnsureMetric` for idempotent metric management.** The editor matches by type and name, so repeated calls with the same metric identity update rather than duplicate. + +**HPA deletion on suspend is intentional.** Without a native suspend field, leaving the HPA active during suspension would cause it to scale the target workload back up, fighting against the suspension logic. Override `WithCustomSuspendDeletionDecision` only if you have a specific reason to keep the HPA alive. From 06cb44bc439aa6fc1020c0e0e3f091bc8b7b76fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 02:46:35 +0000 Subject: [PATCH 04/28] Add HPA primitive example Demonstrates base HPA construction, CPU/memory metric mutations, boolean-gated features, conservative scale-down behavior, flavors, operational status reporting, delete-on-suspend, and data extraction. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/hpa-primitive/README.md | 33 ++++++ examples/hpa-primitive/app/controller.go | 54 +++++++++ examples/hpa-primitive/app/owner.go | 20 ++++ examples/hpa-primitive/features/mutations.go | 93 +++++++++++++++ examples/hpa-primitive/main.go | 118 +++++++++++++++++++ examples/hpa-primitive/resources/hpa.go | 78 ++++++++++++ 6 files changed, 396 insertions(+) create mode 100644 examples/hpa-primitive/README.md create mode 100644 examples/hpa-primitive/app/controller.go create mode 100644 examples/hpa-primitive/app/owner.go create mode 100644 examples/hpa-primitive/features/mutations.go create mode 100644 examples/hpa-primitive/main.go create mode 100644 examples/hpa-primitive/resources/hpa.go diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md new file mode 100644 index 00000000..6dd4010c --- /dev/null +++ b/examples/hpa-primitive/README.md @@ -0,0 +1,33 @@ +# HPA Primitive Example + +This example demonstrates the usage of the `hpa` primitive within the operator component framework. +It shows how to manage a Kubernetes HorizontalPodAutoscaler as a component of a larger application, utilizing features like: + +- **Base Construction**: Initializing an HPA with a scale target ref, min/max replicas, and labels. +- **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) using the `Mutator`. +- **Field Flavors**: Preserving labels and annotations that might be managed by external tools. +- **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. +- **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed when the component is suspended. +- **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`: CPU metric, memory metric, scale behavior, and flavor functions. +- `resources/`: Contains the central `NewHPAResource` factory that assembles all features using the `hpa.Builder`. +- `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. + +## Running the Example + +You can run this example directly using `go run`: + +```bash +go run examples/hpa-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 and HPA state. diff --git a/examples/hpa-primitive/app/controller.go b/examples/hpa-primitive/app/controller.go new file mode 100644 index 00000000..4d552058 --- /dev/null +++ b/examples/hpa-primitive/app/controller.go @@ -0,0 +1,54 @@ +// Package app provides a sample controller using the HPA 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 + + // NewHPAResource is a factory function to create the HPA resource. + // This allows us to inject the resource construction logic. + NewHPAResource 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 HPA resource for this owner. + hpaResource, err := r.NewHPAResource(owner) + if err != nil { + return err + } + + // 2. Build the component that manages the HPA. + comp, err := component.NewComponentBuilder(). + WithName("example-app"). + WithConditionType("AppReady"). + WithResource(hpaResource, 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/hpa-primitive/app/owner.go b/examples/hpa-primitive/app/owner.go new file mode 100644 index 00000000..6b611a02 --- /dev/null +++ b/examples/hpa-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/hpa-primitive/features/mutations.go b/examples/hpa-primitive/features/mutations.go new file mode 100644 index 00000000..2514a22a --- /dev/null +++ b/examples/hpa-primitive/features/mutations.go @@ -0,0 +1,93 @@ +// Package features provides sample features for the HPA primitive example. +package features + +import ( + "github.com/sourcehawk/operator-component-framework/pkg/feature" + "github.com/sourcehawk/operator-component-framework/pkg/mutation/editors" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" + autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +// CPUMetricFeature configures CPU-based autoscaling with the given utilization target. +func CPUMetricFeature(version string, targetUtilization int32) hpa.Mutation { + return hpa.Mutation{ + Name: "CPUMetric", + Feature: feature.NewResourceFeature(version, nil), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceCPU, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(targetUtilization), + }, + }, + }) + return nil + }) + + m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { + e.EnsureLabel("app.kubernetes.io/version", version) + return nil + }) + + return nil + }, + } +} + +// MemoryMetricFeature adds memory-based autoscaling when enabled. +func MemoryMetricFeature(enabled bool, targetUtilization int32) hpa.Mutation { + return hpa.Mutation{ + Name: "MemoryMetric", + Feature: feature.NewResourceFeature("any", nil).When(enabled), + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.EnsureMetric(autoscalingv2.MetricSpec{ + Type: autoscalingv2.ResourceMetricSourceType, + Resource: &autoscalingv2.ResourceMetricSource{ + Name: corev1.ResourceMemory, + Target: autoscalingv2.MetricTarget{ + Type: autoscalingv2.UtilizationMetricType, + AverageUtilization: ptr.To(targetUtilization), + }, + }, + }) + return nil + }) + return nil + }, + } +} + +// ScaleBehaviorFeature configures conservative scale-down behavior. +func ScaleBehaviorFeature() hpa.Mutation { + return hpa.Mutation{ + Name: "ScaleBehavior", + Mutate: func(m *hpa.Mutator) error { + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetBehavior(&autoscalingv2.HorizontalPodAutoscalerBehavior{ + ScaleDown: &autoscalingv2.HPAScalingRules{ + StabilizationWindowSeconds: ptr.To(int32(300)), + }, + }) + return nil + }) + return nil + }, + } +} + +// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. +func PreserveLabelsFlavor() hpa.FieldApplicationFlavor { + return hpa.PreserveCurrentLabels +} + +// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. +func PreserveAnnotationsFlavor() hpa.FieldApplicationFlavor { + return hpa.PreserveCurrentAnnotations +} diff --git a/examples/hpa-primitive/main.go b/examples/hpa-primitive/main.go new file mode 100644 index 00000000..88be3f5a --- /dev/null +++ b/examples/hpa-primitive/main.go @@ -0,0 +1,118 @@ +// Package main is the entry point for the HPA 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/hpa-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/resources" + autoscalingv2 "k8s.io/api/autoscaling/v2" + "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 := autoscalingv2.AddToScheme(scheme); err != nil { + fmt.Fprintf(os.Stderr, "failed to add autoscaling/v2 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: false, + EnableMetrics: true, + Suspended: false, + }, + } + owner.Name = "my-example-app" + owner.Namespace = "default" + + if err := fakeClient.Create(context.Background(), owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to create owner: %v\n", err) + os.Exit(1) + } + + // 3. Initialize our controller. + gauge := ocm.NewOperatorConditionsGauge("example") + controller := &app.ExampleController{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(100), + Metrics: &ocm.ConditionMetricRecorder{ + Controller: "example-controller", + OperatorConditionsGauge: gauge, + }, + + // Pass the HPA resource factory. + NewHPAResource: resources.NewHPAResource, + } + + // 4. Run reconciliation with multiple spec versions. + specs := []app.ExampleAppSpec{ + { + Version: "1.2.3", + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", // Version upgrade + EnableMetrics: true, + Suspended: false, + }, + { + Version: "1.2.4", + EnableMetrics: false, // Disable memory metric + Suspended: false, + }, + { + Version: "1.2.4", + EnableMetrics: false, + Suspended: true, // Suspend the app (HPA will be deleted) + }, + } + + ctx := context.Background() + + for i, spec := range specs { + fmt.Printf("\n--- Step %d: Applying Spec: Version=%s, Metrics=%v, Suspended=%v ---\n", + i+1, spec.Version, spec.EnableMetrics, spec.Suspended) + + // Update owner spec + owner.Spec = spec + if err := fakeClient.Update(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "failed to update owner: %v\n", err) + os.Exit(1) + } + + fmt.Println("Running reconciliation...") + if err := controller.Reconcile(ctx, owner); err != nil { + fmt.Fprintf(os.Stderr, "reconciliation failed: %v\n", err) + os.Exit(1) + } + + // Inspect the owner conditions. + for _, cond := range owner.Status.Conditions { + fmt.Printf("Condition: %s, Status: %s, Reason: %s\n", + cond.Type, cond.Status, cond.Reason) + } + } + + fmt.Println("\nReconciliation sequence completed successfully!") +} diff --git a/examples/hpa-primitive/resources/hpa.go b/examples/hpa-primitive/resources/hpa.go new file mode 100644 index 00000000..652f14cd --- /dev/null +++ b/examples/hpa-primitive/resources/hpa.go @@ -0,0 +1,78 @@ +// Package resources provides resource implementations for the HPA primitive example. +package resources + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/app" + "github.com/sourcehawk/operator-component-framework/examples/hpa-primitive/features" + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/hpa" + autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" +) + +// NewHPAResource constructs an HPA primitive resource with all the features. +func NewHPAResource(owner *app.ExampleApp) (component.Resource, error) { + // 1. Create the base HPA object. + base := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: owner.Name + "-hpa", + Namespace: owner.Namespace, + Labels: map[string]string{ + "app": owner.Name, + }, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: owner.Name + "-deployment", + }, + MinReplicas: ptr.To(int32(2)), + MaxReplicas: 10, + }, + } + + // 2. Initialize the HPA builder. + builder := hpa.NewBuilder(base) + + // 3. Apply mutations (features) based on the owner spec. + builder.WithMutation(features.CPUMetricFeature(owner.Spec.Version, 70)) + builder.WithMutation(features.MemoryMetricFeature(owner.Spec.EnableMetrics, 80)) + builder.WithMutation(features.ScaleBehaviorFeature()) + + // 4. Configure flavors. + builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) + builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) + + // 5. Data extraction. + builder.WithDataExtractor(func(h autoscalingv2.HorizontalPodAutoscaler) error { + fmt.Printf("HPA %s: min=%d, max=%d, metrics=%d\n", + h.Name, + derefInt32(h.Spec.MinReplicas, 1), + h.Spec.MaxReplicas, + len(h.Spec.Metrics), + ) + + y, err := yaml.Marshal(h) + if err != nil { + return fmt.Errorf("failed to marshal HPA to yaml: %w", err) + } + fmt.Printf("Complete HPA Resource:\n---\n%s\n---\n", string(y)) + + return nil + }) + + // 6. Build the final resource. + return builder.Build() +} + +func derefInt32(p *int32, defaultVal int32) int32 { + if p != nil { + return *p + } + return defaultVal +} From 1a072c3d9e92f7bcaa2a2ddc1cfc45b513f589fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:31:00 +0000 Subject: [PATCH 05/28] Fix HPA documentation per Copilot review feedback - Fix custom field applicator example: preserve Status before DeepCopyInto overwrites it, matching the comment's intent of preserving live fields - Fix data extraction capability description: clarify it is opt-in via WithDataExtractor, not automatic Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index d7a962a3..b63d63f5 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -10,7 +10,7 @@ The `hpa` primitive is the framework's built-in integration abstraction for mana | **Suspension (delete)** | Deletes the HPA on suspend — prevents it from interfering with manually-scaled replicas | | **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | | **Flavors** | Preserves externally-managed labels and annotations | -| **Data extraction** | Reads current and desired replica counts from the reconciled HPA after each sync cycle | +| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive @@ -48,8 +48,10 @@ Use `WithCustomFieldApplicator` when other controllers manage fields that should ```go resource, err := hpa.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - // Preserve status-managed fields while updating spec + // Preserve the current status while updating spec and metadata from desired + savedStatus := current.Status desired.DeepCopyInto(current) + current.Status = savedStatus return nil }). Build() From 8f98c111c265e76b3a94f1f1b2ddd01aa514b784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 04:34:50 +0000 Subject: [PATCH 06/28] Add HPA primitive to built-in primitives index Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/primitives.md b/docs/primitives.md index 13fde6a6..e9a1557b 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -130,6 +130,7 @@ Selectors are evaluated against the container list *after* any presence operatio |--------------------------------------|------------|-----------------------------------------| | `pkg/primitives/deployment` | Workload | [deployment.md](primitives/deployment.md) | | `pkg/primitives/configmap` | Static | [configmap.md](primitives/configmap.md) | +| `pkg/primitives/hpa` | Integration| [hpa.md](primitives/hpa.md) | ## Usage Examples From 577021dffa9d362be3dcbca2b06aae688784c988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 13:28:15 +0000 Subject: [PATCH 07/28] Fix HPA handlers per Copilot review: explicit ConditionUnknown case, suspend docs - Add explicit corev1.ConditionUnknown case in DefaultOperationalStatusHandler so the condition's Message is surfaced instead of a generic fallback - Add doc note on DefaultSuspensionStatusHandler that it assumes delete-on-suspend - Add guidance in hpa.md that keeping HPA on suspend requires custom handlers Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 2 ++ pkg/primitives/hpa/handlers.go | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index b63d63f5..30edfbe3 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -270,6 +270,8 @@ hpa.NewBuilder(base). }) ``` +If you choose to keep the HPA during suspension, ensure that your suspend mutation and status handling are updated to match this behavior so that the component's suspended state and reported reasons remain accurate. + ## Flavors | Flavor | Effect | diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 5cbe9e94..46f53d68 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -43,10 +43,15 @@ func DefaultOperationalStatusHandler( Status: concepts.OperationalStatusFailing, Reason: conditionReason(scalingActive, "ScalingActive is False"), }, nil + case corev1.ConditionUnknown: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: conditionReason(scalingActive, "ScalingActive is Unknown"), + }, nil } } - // Conditions absent or ScalingActive is Unknown + // Conditions absent or ScalingActive has an unrecognized status return concepts.OperationalStatusWithReason{ Status: concepts.OperationalStatusPending, Reason: "Waiting for HPA conditions to be populated", @@ -82,6 +87,10 @@ func DefaultSuspendMutationHandler(_ *Mutator) error { // Since the HPA is deleted during suspension, its status is inherently Suspended once // the framework reaches this handler. // +// This handler assumes DefaultDeleteOnSuspendHandler (delete-on-suspend) is in effect. +// If you override the deletion decision to keep the HPA, provide a custom suspension +// status handler via Builder.WithCustomSuspendStatus as well. +// // 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( From 53f9a7ba83c1c7f54bb297c0721d2bd5b6214fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Sun, 22 Mar 2026 19:48:49 +0000 Subject: [PATCH 08/28] preserve server-managed metadata in default field applicator Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/resource.go | 11 +++--- pkg/primitives/hpa/resource_test.go | 59 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 pkg/primitives/hpa/resource_test.go diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index bbe04d7c..783a04ce 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -7,13 +7,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired. -// -// This is the default baseline field application strategy for HPA resources. -// Use a custom field applicator via Builder.WithCustomFieldApplicator if you need -// to preserve fields that other controllers manage. +// DefaultFieldApplicator replaces current with a deep copy of desired while +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) +// and shared-controller fields (OwnerReferences, Finalizers) from the original +// current object. func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { + original := current.DeepCopy() *current = *desired.DeepCopy() + generic.PreserveServerManagedFields(current, original) return nil } diff --git a/pkg/primitives/hpa/resource_test.go b/pkg/primitives/hpa/resource_test.go new file mode 100644 index 00000000..49084761 --- /dev/null +++ b/pkg/primitives/hpa/resource_test.go @@ -0,0 +1,59 @@ +package hpa + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { + current := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + ResourceVersion: "12345", + UID: "abc-def", + Generation: 3, + OwnerReferences: []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, + }, + Finalizers: []string{"finalizer.example.com"}, + }, + } + desired := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + Labels: map[string]string{"app": "test"}, + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + MinReplicas: ptrInt32(2), + MaxReplicas: 10, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec and labels are applied + assert.Equal(t, int32(2), *current.Spec.MinReplicas) + assert.Equal(t, int32(10), current.Spec.MaxReplicas) + assert.Equal(t, "test", current.Labels["app"]) + + // Server-managed fields are preserved + assert.Equal(t, "12345", current.ResourceVersion) + assert.Equal(t, "abc-def", string(current.UID)) + assert.Equal(t, int64(3), current.Generation) + + // Shared-controller fields are preserved + assert.Len(t, current.OwnerReferences, 1) + assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) + assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) +} + +func ptrInt32(v int32) *int32 { + return &v +} From ffd30518541c8a610e4751fbce73cfd9774a1c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 00:17:11 +0000 Subject: [PATCH 09/28] Fix misleading HPA pending reason and strengthen metric identity matching Distinguish between empty conditions and missing ScalingActive condition in operational status handler. Enhance EnsureMetric matching for Object, Pods, and External metrics to include DescribedObject and Selector fields, preventing unrelated metrics from being overwritten when they share a name. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/hpaspec.go | 23 +++++++--- pkg/mutation/editors/hpaspec_test.go | 67 ++++++++++++++++++++++++++++ pkg/primitives/hpa/handlers.go | 13 ++++-- pkg/primitives/hpa/handlers_test.go | 14 ++++++ 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/pkg/mutation/editors/hpaspec.go b/pkg/mutation/editors/hpaspec.go index f7630c2e..02853cbe 100644 --- a/pkg/mutation/editors/hpaspec.go +++ b/pkg/mutation/editors/hpaspec.go @@ -1,6 +1,8 @@ package editors import ( + "reflect" + autoscalingv2 "k8s.io/api/autoscaling/v2" ) @@ -40,12 +42,12 @@ func (e *HPASpecEditor) SetMaxReplicas(n int32) { // EnsureMetric upserts a metric in the spec's Metrics slice. // // Matching is performed by MetricSpec.Type. Within that type, matching is refined -// by the metric name where applicable: +// by the full identity of the metric source: // - Resource: matched by Resource.Name -// - Pods: matched by Pods.Metric.Name -// - Object: matched by Object.Metric.Name +// - Pods: matched by Pods.Metric (Name + Selector) +// - Object: matched by Object.DescribedObject + Object.Metric (Name + Selector) // - ContainerResource: matched by ContainerResource.Name + ContainerResource.Container -// - External: matched by External.Metric.Name +// - External: matched by External.Metric (Name + Selector) // // If a matching entry exists it is replaced; otherwise the metric is appended. func (e *HPASpecEditor) EnsureMetric(metric autoscalingv2.MetricSpec) { @@ -98,12 +100,13 @@ func metricsMatch(a, b autoscalingv2.MetricSpec) bool { if a.Pods == nil || b.Pods == nil { return false } - return a.Pods.Metric.Name == b.Pods.Metric.Name + return metricIdentifiersMatch(a.Pods.Metric, b.Pods.Metric) case autoscalingv2.ObjectMetricSourceType: if a.Object == nil || b.Object == nil { return false } - return a.Object.Metric.Name == b.Object.Metric.Name + return a.Object.DescribedObject == b.Object.DescribedObject && + metricIdentifiersMatch(a.Object.Metric, b.Object.Metric) case autoscalingv2.ContainerResourceMetricSourceType: if a.ContainerResource == nil || b.ContainerResource == nil { return false @@ -114,12 +117,18 @@ func metricsMatch(a, b autoscalingv2.MetricSpec) bool { if a.External == nil || b.External == nil { return false } - return a.External.Metric.Name == b.External.Metric.Name + return metricIdentifiersMatch(a.External.Metric, b.External.Metric) default: return false } } +// metricIdentifiersMatch reports whether two MetricIdentifier values match. +// It compares both Name and Selector. +func metricIdentifiersMatch(a, b autoscalingv2.MetricIdentifier) bool { + return a.Name == b.Name && reflect.DeepEqual(a.Selector, b.Selector) +} + // metricName extracts the identifying name from a MetricSpec. func metricName(m autoscalingv2.MetricSpec) string { switch m.Type { diff --git a/pkg/mutation/editors/hpaspec_test.go b/pkg/mutation/editors/hpaspec_test.go index 53c25223..e4fcb8fb 100644 --- a/pkg/mutation/editors/hpaspec_test.go +++ b/pkg/mutation/editors/hpaspec_test.go @@ -8,6 +8,7 @@ import ( autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func int32Ptr(v int32) *int32 { return &v } @@ -157,6 +158,72 @@ func TestHPASpecEditor_EnsureMetric_Object(t *testing.T) { assert.Equal(t, "queue_length", spec.Metrics[0].Object.Metric.Name) } +func TestHPASpecEditor_EnsureMetric_Object_DifferentDescribedObject(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + // Add metric for Service "worker" + m1 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ObjectMetricSourceType, + Object: &autoscalingv2.ObjectMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "queue_length"}, + DescribedObject: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "v1", Kind: "Service", Name: "worker", + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("30")}, + }, + } + e.EnsureMetric(m1) + require.Len(t, spec.Metrics, 1) + + // Same metric name but different described object -> separate entry + m2 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ObjectMetricSourceType, + Object: &autoscalingv2.ObjectMetricSource{ + Metric: autoscalingv2.MetricIdentifier{Name: "queue_length"}, + DescribedObject: autoscalingv2.CrossVersionObjectReference{ + APIVersion: "v1", Kind: "Service", Name: "processor", + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("50")}, + }, + } + e.EnsureMetric(m2) + assert.Len(t, spec.Metrics, 2, "different DescribedObject should create a separate metric entry") +} + +func TestHPASpecEditor_EnsureMetric_External_DifferentSelector(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + // Add external metric with selector + m1 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ExternalMetricSourceType, + External: &autoscalingv2.ExternalMetricSource{ + Metric: autoscalingv2.MetricIdentifier{ + Name: "pubsub_undelivered", + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"topic": "orders"}}, + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("100")}, + }, + } + e.EnsureMetric(m1) + require.Len(t, spec.Metrics, 1) + + // Same metric name but different selector -> separate entry + m2 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ExternalMetricSourceType, + External: &autoscalingv2.ExternalMetricSource{ + Metric: autoscalingv2.MetricIdentifier{ + Name: "pubsub_undelivered", + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"topic": "events"}}, + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("200")}, + }, + } + e.EnsureMetric(m2) + assert.Len(t, spec.Metrics, 2, "different selector should create a separate metric entry") +} + func TestHPASpecEditor_EnsureMetric_External(t *testing.T) { spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} e := NewHPASpecEditor(spec) diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 46f53d68..90204d9e 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -11,7 +11,7 @@ import ( // // It inspects Status.Conditions to classify the HPA's state: // - OperationalStatusOperational: condition ScalingActive is True. -// - OperationalStatusPending: conditions are absent, or ScalingActive is Unknown. +// - OperationalStatusPending: conditions are absent, ScalingActive is missing, or ScalingActive is Unknown. // - OperationalStatusFailing: condition ScalingActive is False, or condition AbleToScale is False. // // This function is used as the default handler by the Resource if no custom handler is registered @@ -51,10 +51,17 @@ func DefaultOperationalStatusHandler( } } - // Conditions absent or ScalingActive has an unrecognized status + // Distinguish between no conditions at all and ScalingActive missing/unrecognized + if len(hpa.Status.Conditions) == 0 { + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: "Waiting for HPA conditions to be populated", + }, nil + } + return concepts.OperationalStatusWithReason{ Status: concepts.OperationalStatusPending, - Reason: "Waiting for HPA conditions to be populated", + Reason: "Waiting for ScalingActive condition on HPA", }, nil } diff --git a/pkg/primitives/hpa/handlers_test.go b/pkg/primitives/hpa/handlers_test.go index a9fbc221..139ebddc 100644 --- a/pkg/primitives/hpa/handlers_test.go +++ b/pkg/primitives/hpa/handlers_test.go @@ -38,6 +38,20 @@ func TestDefaultOperationalStatusHandler(t *testing.T) { }, wantStatus: concepts.OperationalStatusPending, }, + { + name: "pending when conditions present but ScalingActive missing", + hpa: &autoscalingv2.HorizontalPodAutoscaler{ + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.AbleToScale, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + wantStatus: concepts.OperationalStatusPending, + }, { name: "pending when ScalingActive is Unknown", hpa: &autoscalingv2.HorizontalPodAutoscaler{ From df84e7062475990445460017bb0abe9df445ebfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 00:38:21 +0000 Subject: [PATCH 10/28] default suspension behavior is noop --- docs/primitives/hpa.md | 12 ++++++------ examples/hpa-primitive/README.md | 2 +- pkg/primitives/hpa/builder.go | 10 +++++----- pkg/primitives/hpa/handlers.go | 27 +++++++++++++++------------ pkg/primitives/hpa/handlers_test.go | 4 ++-- pkg/primitives/hpa/resource.go | 16 ++++++++-------- 6 files changed, 37 insertions(+), 34 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 30edfbe3..6d8c8974 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -7,7 +7,7 @@ The `hpa` primitive is the framework's built-in integration abstraction for mana | Capability | Detail | |----------------------------|-----------------------------------------------------------------------------------------------------------| | **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | -| **Suspension (delete)** | Deletes the HPA on suspend — prevents it from interfering with manually-scaled replicas | +| **Suspension (no-op)** | Leaves the HPA in place on suspend — an idle HPA has no cluster impact when its scale target is absent | | **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | | **Flavors** | Preserves externally-managed labels and annotations | | **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | @@ -257,20 +257,20 @@ hpa.NewBuilder(base). ## Suspension -HPA has no native suspend field. The default behavior is to **delete the HPA** when the component is suspended (`DefaultDeleteOnSuspendHandler` returns `true`). This prevents the autoscaler from interfering with manually-scaled replicas during suspension. +HPA has no native suspend field. The default behavior is a **no-op**: the HPA is left in place when the component is suspended (`DefaultDeleteOnSuspendHandler` returns `false`). An idle HPA has no effect on the cluster when its scale target is absent or suspended, so there is no reason to delete it. Keeping it avoids unnecessary churn and simplifies resumption. -The default suspension status handler reports `Suspended` with the reason `"HorizontalPodAutoscaler deleted on suspend"`. +The default suspension status handler reports `Suspended` immediately with the reason `"HorizontalPodAutoscaler left in place; no-op suspend"`. -Override with `WithCustomSuspendDeletionDecision` if you want to keep the HPA during suspension: +Override with `WithCustomSuspendDeletionDecision` if you want to delete the HPA during suspension: ```go hpa.NewBuilder(base). WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool { - return false // keep HPA during suspension + return true // delete HPA during suspension }) ``` -If you choose to keep the HPA during suspension, ensure that your suspend mutation and status handling are updated to match this behavior so that the component's suspended state and reported reasons remain accurate. +If you choose to delete the HPA during suspension, consider providing a custom suspend status handler via `WithCustomSuspendStatus` to report an accurate reason. ## Flavors diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md index 6dd4010c..e8460021 100644 --- a/examples/hpa-primitive/README.md +++ b/examples/hpa-primitive/README.md @@ -7,7 +7,7 @@ It shows how to manage a Kubernetes HorizontalPodAutoscaler as a component of a - **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) using the `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. -- **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed when the component is suspended. +- **Suspension (No-op)**: Demonstrating no-op suspend behavior — the HPA is left in place since an idle HPA has no cluster impact. - **Data Extraction**: Harvesting information from the reconciled resource. ## Directory Structure diff --git a/pkg/primitives/hpa/builder.go b/pkg/primitives/hpa/builder.go index a565e530..0d4b4f69 100644 --- a/pkg/primitives/hpa/builder.go +++ b/pkg/primitives/hpa/builder.go @@ -99,7 +99,7 @@ func (b *Builder) WithCustomOperationalStatus( // WithCustomSuspendStatus overrides how the progress of suspension is reported. // // The default behavior uses DefaultSuspensionStatusHandler, which reports -// Suspended with a reason indicating the HPA was deleted on suspend. +// Suspended immediately because the default suspend is a no-op. func (b *Builder) WithCustomSuspendStatus( handler func(*autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error), ) *Builder { @@ -111,7 +111,7 @@ func (b *Builder) WithCustomSuspendStatus( // the component is suspended. // // The default behavior uses DefaultSuspendMutationHandler, which is a no-op -// since the HPA is deleted on suspend. +// since an idle HPA has no impact on the cluster. func (b *Builder) WithCustomSuspendMutation( handler func(*Mutator) error, ) *Builder { @@ -122,9 +122,9 @@ func (b *Builder) WithCustomSuspendMutation( // WithCustomSuspendDeletionDecision overrides the decision of whether to delete // the HPA when the component is suspended. // -// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true. -// HPA has no native suspend field; deleting prevents it from interfering with -// manually-scaled replicas while suspended. +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns false. +// An idle HPA has no effect on the cluster when its scale target is absent or +// suspended, so there is no reason to delete it. func (b *Builder) WithCustomSuspendDeletionDecision( handler func(*autoscalingv2.HorizontalPodAutoscaler) bool, ) *Builder { diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 90204d9e..34268380 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -68,19 +68,25 @@ func DefaultOperationalStatusHandler( // DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the HPA // when the parent component is suspended. // -// It always returns true. HPA has no native suspend field; deleting prevents it from -// interfering with manually-scaled replicas while suspended. +// It always returns false. An idle HPA has no effect on a cluster when its scale target is +// absent or suspended, so there is no reason to delete it. Keeping it in place avoids +// unnecessary churn and simplifies resumption. +// +// Override this via Builder.WithCustomSuspendDeletionDecision if your use case requires +// the HPA to be removed during suspension. // // 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(_ *autoscalingv2.HorizontalPodAutoscaler) bool { - return true + return false } // DefaultSuspendMutationHandler provides the default mutation applied to an HPA when // the component is suspended. // -// It is a no-op because the HPA is deleted on suspend (DefaultDeleteOnSuspendHandler returns true). +// It is a no-op. The default suspension behavior leaves the HPA in place +// (DefaultDeleteOnSuspendHandler returns false) because an idle HPA has no impact on +// the cluster when its scale target is absent or suspended. No mutations are needed. // // 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. @@ -90,13 +96,10 @@ func DefaultSuspendMutationHandler(_ *Mutator) error { // DefaultSuspensionStatusHandler reports the suspension status of the HPA. // -// It always returns Suspended with a reason indicating the HPA is deleted on suspend. -// Since the HPA is deleted during suspension, its status is inherently Suspended once -// the framework reaches this handler. -// -// This handler assumes DefaultDeleteOnSuspendHandler (delete-on-suspend) is in effect. -// If you override the deletion decision to keep the HPA, provide a custom suspension -// status handler via Builder.WithCustomSuspendStatus as well. +// It always returns Suspended immediately. The default suspension behaviour is a no-op: +// the HPA is left in place because an idle HPA has no effect on the cluster when its +// scale target is absent or suspended. Because no work is required to suspend, the +// status is always Suspended. // // 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. @@ -105,7 +108,7 @@ func DefaultSuspensionStatusHandler( ) (concepts.SuspensionStatusWithReason, error) { return concepts.SuspensionStatusWithReason{ Status: concepts.SuspensionStatusSuspended, - Reason: "HorizontalPodAutoscaler deleted on suspend", + Reason: "HorizontalPodAutoscaler left in place; no-op suspend", }, nil } diff --git a/pkg/primitives/hpa/handlers_test.go b/pkg/primitives/hpa/handlers_test.go index 139ebddc..89fe3634 100644 --- a/pkg/primitives/hpa/handlers_test.go +++ b/pkg/primitives/hpa/handlers_test.go @@ -148,7 +148,7 @@ func TestDefaultOperationalStatusHandler_UsesConditionMessage(t *testing.T) { func TestDefaultDeleteOnSuspendHandler(t *testing.T) { hpa := &autoscalingv2.HorizontalPodAutoscaler{} - assert.True(t, DefaultDeleteOnSuspendHandler(hpa)) + assert.False(t, DefaultDeleteOnSuspendHandler(hpa)) } func TestDefaultSuspendMutationHandler(t *testing.T) { @@ -163,5 +163,5 @@ func TestDefaultSuspensionStatusHandler(t *testing.T) { got, err := DefaultSuspensionStatusHandler(hpa) require.NoError(t, err) assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) - assert.Equal(t, "HorizontalPodAutoscaler deleted on suspend", got.Reason) + assert.Equal(t, "HorizontalPodAutoscaler left in place; no-op suspend", got.Reason) } diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index 783a04ce..30bcf62e 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -24,7 +24,7 @@ func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutosca // It implements the following component interfaces: // - component.Resource: for basic identity and mutation behaviour. // - component.Operational: for reporting operational status based on HPA conditions. -// - component.Suspendable: for delete-on-suspend behaviour (HPA has no native suspend). +// - component.Suspendable: for no-op suspend behaviour (idle HPA has no cluster impact). // - component.DataExtractable: for exporting values after successful reconciliation. type Resource struct { base *generic.IntegrationResource[*autoscalingv2.HorizontalPodAutoscaler, *Mutator] @@ -68,25 +68,25 @@ func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.O // DeleteOnSuspend determines whether the HPA should be deleted from the cluster // when the parent component is suspended. // -// By default, it uses DefaultDeleteOnSuspendHandler, which returns true. HPA has -// no native suspend field; deleting prevents it from interfering with manually-scaled -// replicas while suspended. +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. An idle +// HPA has no effect on the cluster when its scale target is absent or suspended, +// so there is no reason to delete it. func (r *Resource) DeleteOnSuspend() bool { return r.base.DeleteOnSuspend() } // Suspend registers the configured suspension mutation for the next mutate cycle. // -// For HPA, the default suspension mutation is a no-op since the resource is deleted -// on suspend. +// For HPA, the default suspension mutation is a no-op since an idle HPA has no +// impact on the cluster. func (r *Resource) Suspend() error { return r.base.Suspend() } // SuspensionStatus reports the suspension status of the HPA. // -// By default, it uses DefaultSuspensionStatusHandler, which reports Suspended with -// a reason indicating the HPA was deleted on suspend. +// By default, it uses DefaultSuspensionStatusHandler, which reports Suspended +// immediately because the default suspend is a no-op. func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { return r.base.SuspensionStatus() } From 14482d41b448510276ac3d88e9f7652626746451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 02:56:39 +0000 Subject: [PATCH 11/28] Fix docs and comments to match default no-op HPA suspension behavior Update EnsureMetric documentation to reflect full metric identity matching (including selectors and described objects), fix example comment that incorrectly claimed HPA is deleted on suspend, and correct guidance section that contradicted the default retention behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 14 +++++++------- examples/hpa-primitive/main.go | 2 +- pkg/primitives/hpa/resource.go | 7 ++++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 6d8c8974..37211cac 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -171,17 +171,17 @@ m.EditHPASpec(func(e *editors.HPASpecEditor) error { #### EnsureMetric -`EnsureMetric` upserts a metric by type and name. Matching rules: +`EnsureMetric` upserts a metric based on its full metric identity, not just type and name. Matching rules: | Metric type | Match key | |---|---| | Resource | `Resource.Name` (e.g. `cpu`, `memory`) | -| Pods | `Pods.Metric.Name` | -| Object | `Object.Metric.Name` | +| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label selector; `nil` is a distinct identity) | +| Object | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` | | ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` | -| External | `External.Metric.Name` | +| External | `External.Metric.Name` + `External.Metric.Selector` (label selector; `nil` is a distinct identity) | -If a matching entry exists it is replaced; otherwise the metric is appended. +If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or described objects result in different metric identities, even if the metric names are the same. #### RemoveMetric @@ -350,6 +350,6 @@ Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, **Register mutations in dependency order.** If mutation B relies on a metric added by mutation A, register A first. -**Use `EnsureMetric` for idempotent metric management.** The editor matches by type and name, so repeated calls with the same metric identity update rather than duplicate. +**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name, selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate. -**HPA deletion on suspend is intentional.** Without a native suspend field, leaving the HPA active during suspension would cause it to scale the target workload back up, fighting against the suspension logic. Override `WithCustomSuspendDeletionDecision` only if you have a specific reason to keep the HPA alive. +**HPA retention on suspend is the default.** The primitive's default `DeleteOnSuspend` decision leaves the HPA in place during component suspension (matching the "Suspension (no-op)" capability). This avoids unnecessary churn and simplifies resumption. Note that a retained HPA may attempt to scale its target if the target still exists and is scaled to zero. If you need the HPA to be removed during suspension — for example, to guarantee that no scaling can interfere — override `WithCustomSuspendDeletionDecision` to return `true`. diff --git a/examples/hpa-primitive/main.go b/examples/hpa-primitive/main.go index 88be3f5a..94e58498 100644 --- a/examples/hpa-primitive/main.go +++ b/examples/hpa-primitive/main.go @@ -84,7 +84,7 @@ func main() { { Version: "1.2.4", EnableMetrics: false, - Suspended: true, // Suspend the app (HPA will be deleted) + Suspended: true, // Suspend the app (HPA is not deleted by default) }, } diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index 30bcf62e..9dd70a22 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -68,9 +68,10 @@ func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.O // DeleteOnSuspend determines whether the HPA should be deleted from the cluster // when the parent component is suspended. // -// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. An idle -// HPA has no effect on the cluster when its scale target is absent or suspended, -// so there is no reason to delete it. +// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. The HPA +// is left in place to avoid unnecessary churn and simplify resumption. Note that +// a retained HPA may attempt to scale its target if the target still exists and is +// scaled to zero; override with WithCustomSuspendDeletionDecision if this is a concern. func (r *Resource) DeleteOnSuspend() bool { return r.base.DeleteOnSuspend() } From 117dd6784f468eae41eaf805dcb82b53ab9224f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:42:55 +0000 Subject: [PATCH 12/28] fix: remove beginFeature call from HPA mutator constructor Aligns with the fix applied to the deployment mutator in #42. The constructor now initializes the first feature plan inline so the generic mutator_helper's beginFeature() call does not create a duplicate empty feature. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/mutator.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/hpa/mutator.go b/pkg/primitives/hpa/mutator.go index 26f2c5f0..184da199 100644 --- a/pkg/primitives/hpa/mutator.go +++ b/pkg/primitives/hpa/mutator.go @@ -34,8 +34,11 @@ type Mutator struct { // NewMutator creates a new Mutator for the given HorizontalPodAutoscaler. func NewMutator(hpa *autoscalingv2.HorizontalPodAutoscaler) *Mutator { - m := &Mutator{hpa: hpa} - m.beginFeature() + m := &Mutator{ + hpa: hpa, + plans: []featurePlan{{}}, + } + m.active = &m.plans[0] return m } From 1854325ca83851829c84069c4e507aaf1f5577b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 16:58:12 +0000 Subject: [PATCH 13/28] fix: preserve HPA status in DefaultFieldApplicator and add test coverage Add generic.PreserveStatus call to prevent deep copy from wiping live HPA Status.Conditions, which would cause operational status to report Pending even when the HPA is healthy. Add dedicated test asserting Status preservation including Conditions. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 4 +-- pkg/primitives/hpa/resource.go | 7 +++-- pkg/primitives/hpa/resource_test.go | 44 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 37211cac..7fbbcc8b 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -41,9 +41,9 @@ resource, err := hpa.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object. This ensures every reconciliation cycle produces a clean, predictable state. +`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. -Use `WithCustomFieldApplicator` when other controllers manage fields that should not be overwritten: +Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten: ```go resource, err := hpa.NewBuilder(base). diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index 9dd70a22..5cecdce3 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -8,13 +8,14 @@ import ( ) // DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.) -// and shared-controller fields (OwnerReferences, Finalizers) from the original -// current object. +// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), +// shared-controller fields (OwnerReferences, Finalizers), and the Status +// subresource from the original current object. func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { original := current.DeepCopy() *current = *desired.DeepCopy() generic.PreserveServerManagedFields(current, original) + generic.PreserveStatus(current, original) return nil } diff --git a/pkg/primitives/hpa/resource_test.go b/pkg/primitives/hpa/resource_test.go index 49084761..bf026a36 100644 --- a/pkg/primitives/hpa/resource_test.go +++ b/pkg/primitives/hpa/resource_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" autoscalingv2 "k8s.io/api/autoscaling/v2" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -54,6 +55,49 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) } +func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { + current := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Status: autoscalingv2.HorizontalPodAutoscalerStatus{ + CurrentReplicas: 3, + DesiredReplicas: 5, + Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ + { + Type: autoscalingv2.ScalingActive, + Status: corev1.ConditionTrue, + }, + }, + }, + } + desired := &autoscalingv2.HorizontalPodAutoscaler{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ + MinReplicas: ptrInt32(2), + MaxReplicas: 10, + }, + } + + err := DefaultFieldApplicator(current, desired) + require.NoError(t, err) + + // Desired spec is applied + assert.Equal(t, int32(2), *current.Spec.MinReplicas) + assert.Equal(t, int32(10), current.Spec.MaxReplicas) + + // Status from the live object is preserved + assert.Equal(t, int32(3), current.Status.CurrentReplicas) + assert.Equal(t, int32(5), current.Status.DesiredReplicas) + require.Len(t, current.Status.Conditions, 1) + assert.Equal(t, autoscalingv2.ScalingActive, current.Status.Conditions[0].Type) + assert.Equal(t, corev1.ConditionTrue, current.Status.Conditions[0].Status) +} + func ptrInt32(v int32) *int32 { return &v } From b47709d1c8957dd939733b37f7280ad826a0e29a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Mon, 23 Mar 2026 21:35:13 +0000 Subject: [PATCH 14/28] fix: export BeginFeature to match updated FeatureMutator interface Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/mutator.go | 4 ++-- pkg/primitives/hpa/mutator_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/primitives/hpa/mutator.go b/pkg/primitives/hpa/mutator.go index 184da199..42abe712 100644 --- a/pkg/primitives/hpa/mutator.go +++ b/pkg/primitives/hpa/mutator.go @@ -42,9 +42,9 @@ func NewMutator(hpa *autoscalingv2.HorizontalPodAutoscaler) *Mutator { return m } -// beginFeature starts a new feature planning scope. All subsequent mutation +// BeginFeature starts a new feature planning scope. All subsequent mutation // registrations will be grouped into this feature's plan. -func (m *Mutator) beginFeature() { +func (m *Mutator) BeginFeature() { m.plans = append(m.plans, featurePlan{}) m.active = &m.plans[len(m.plans)-1] } diff --git a/pkg/primitives/hpa/mutator_test.go b/pkg/primitives/hpa/mutator_test.go index d105b42d..35e553bb 100644 --- a/pkg/primitives/hpa/mutator_test.go +++ b/pkg/primitives/hpa/mutator_test.go @@ -138,7 +138,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { return nil }) - m.beginFeature() + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { // Second feature overwrites the label e.EnsureLabel("feature", "two") From f5682d7edd7d61b0eab1065dc12b27f1277bc03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:29:26 +0000 Subject: [PATCH 15/28] style: apply markdown formatting from fmt-md Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 118 +++++++++++++++++++------------ examples/hpa-primitive/README.md | 13 ++-- 2 files changed, 81 insertions(+), 50 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 7fbbcc8b..9b36619d 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -1,16 +1,18 @@ # HorizontalPodAutoscaler (HPA) Primitive -The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes `HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational, Suspendable resource and provides a structured mutation API for configuring autoscaling behavior. +The `hpa` primitive is the framework's built-in integration abstraction for managing Kubernetes +`HorizontalPodAutoscaler` resources (`autoscaling/v2`). It integrates with the component lifecycle as an Operational, +Suspendable resource and provides a structured mutation API for configuring autoscaling behavior. ## Capabilities -| Capability | Detail | -|----------------------------|-----------------------------------------------------------------------------------------------------------| -| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | -| **Suspension (no-op)** | Leaves the HPA in place on suspend — an idle HPA has no cluster impact when its scale target is absent | -| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | -| **Flavors** | Preserves externally-managed labels and annotations | -| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | +| Capability | Detail | +| ---------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | +| **Suspension (no-op)** | Leaves the HPA in place on suspend — an idle HPA has no cluster impact when its scale target is absent | +| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | +| **Flavors** | Preserves externally-managed labels and annotations | +| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive @@ -41,7 +43,10 @@ resource, err := hpa.NewBuilder(base). ## Default Field Application -`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object, then restores server-managed metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. +`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object, then restores server-managed +metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status +subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by +the API server or other controllers. Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten: @@ -59,9 +64,11 @@ resource, err := hpa.NewBuilder(base). ## Mutations -Mutations are the primary mechanism for modifying an HPA beyond its baseline. Each mutation is a named function that receives a `*Mutator` and records edit intent through typed editors. +Mutations are the primary mechanism for modifying an HPA 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 CPUMetricMutation(version string) hpa.Mutation { @@ -76,7 +83,8 @@ func CPUMetricMutation(version string) hpa.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 @@ -136,12 +144,13 @@ All version constraints and `When()` conditions must be satisfied for a mutation ## Internal Mutation Ordering -Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the order they are recorded: +Within a single mutation, edit operations are grouped into categories and applied in a fixed sequence regardless of the +order they are recorded: -| Step | Category | What it affects | -|---|---|---| -| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object | -| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior | +| Step | Category | What it affects | +| ---- | -------------- | -------------------------------------------------------------- | +| 1 | Metadata edits | Labels and annotations on the `HorizontalPodAutoscaler` object | +| 2 | HPA spec edits | Scale target ref, min/max replicas, metrics, behavior | ## Editors @@ -149,7 +158,8 @@ Within a single mutation, edit operations are grouped into categories and applie Controls HPA-level settings via `m.EditHPASpec`. -Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`, `SetBehavior`, `Raw`. +Available methods: `SetScaleTargetRef`, `SetMinReplicas`, `SetMaxReplicas`, `EnsureMetric`, `RemoveMetric`, +`SetBehavior`, `Raw`. ```go m.EditHPASpec(func(e *editors.HPASpecEditor) error { @@ -173,23 +183,26 @@ m.EditHPASpec(func(e *editors.HPASpecEditor) error { `EnsureMetric` upserts a metric based on its full metric identity, not just type and name. Matching rules: -| Metric type | Match key | -|---|---| -| Resource | `Resource.Name` (e.g. `cpu`, `memory`) | -| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label selector; `nil` is a distinct identity) | -| Object | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` | -| ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` | -| External | `External.Metric.Name` + `External.Metric.Selector` (label selector; `nil` is a distinct identity) | +| Metric type | Match key | +| ----------------- | --------------------------------------------------------------------------------------------------------- | +| Resource | `Resource.Name` (e.g. `cpu`, `memory`) | +| Pods | `Pods.Metric.Name` + `Pods.Metric.Selector` (label selector; `nil` is a distinct identity) | +| Object | `Object.DescribedObject` (`APIVersion`, `Kind`, `Name`) + `Object.Metric.Name` + `Object.Metric.Selector` | +| ContainerResource | `ContainerResource.Name` + `ContainerResource.Container` | +| External | `External.Metric.Name` + `External.Metric.Selector` (label selector; `nil` is a distinct identity) | -If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or described objects result in different metric identities, even if the metric names are the same. +If a matching entry exists it is replaced; otherwise the metric is appended. Be aware that different selectors or +described objects result in different metric identities, even if the metric names are the same. #### RemoveMetric -`RemoveMetric(type, name)` removes all metrics matching the given type and name. For ContainerResource metrics, all container variants of the named resource are removed. +`RemoveMetric(type, name)` removes all metrics matching the given type and name. For ContainerResource metrics, all +container variants of the named resource are removed. #### SetBehavior -`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom behavior and use Kubernetes defaults. +`SetBehavior` sets the autoscaling behavior (stabilization windows, scaling policies). Pass `nil` to remove custom +behavior and use Kubernetes defaults. ```go m.EditHPASpec(func(e *editors.HPASpecEditor) error { @@ -227,19 +240,21 @@ 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. +All editors provide a `.Raw()` method for direct access to the underlying Kubernetes struct when the typed API is +insufficient. ## Operational Status The default operational status handler inspects `Status.Conditions`: -| Status | Condition | -|---|---| -| `Operational` | `ScalingActive` is `True` | -| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` | -| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` | +| Status | Condition | +| ------------- | ------------------------------------------------------- | +| `Operational` | `ScalingActive` is `True` | +| `Pending` | Conditions absent, or `ScalingActive` is `Unknown` | +| `Failing` | `ScalingActive` is `False`, or `AbleToScale` is `False` | -`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not operationally healthy regardless of what the scaling-active condition reports. +`AbleToScale = False` takes precedence over `ScalingActive = True` because an HPA that cannot actually scale is not +operationally healthy regardless of what the scaling-active condition reports. Override with `WithCustomOperationalStatus`: @@ -257,9 +272,13 @@ hpa.NewBuilder(base). ## Suspension -HPA has no native suspend field. The default behavior is a **no-op**: the HPA is left in place when the component is suspended (`DefaultDeleteOnSuspendHandler` returns `false`). An idle HPA has no effect on the cluster when its scale target is absent or suspended, so there is no reason to delete it. Keeping it avoids unnecessary churn and simplifies resumption. +HPA has no native suspend field. The default behavior is a **no-op**: the HPA is left in place when the component is +suspended (`DefaultDeleteOnSuspendHandler` returns `false`). An idle HPA has no effect on the cluster when its scale +target is absent or suspended, so there is no reason to delete it. Keeping it avoids unnecessary churn and simplifies +resumption. -The default suspension status handler reports `Suspended` immediately with the reason `"HorizontalPodAutoscaler left in place; no-op suspend"`. +The default suspension status handler reports `Suspended` immediately with the reason +`"HorizontalPodAutoscaler left in place; no-op suspend"`. Override with `WithCustomSuspendDeletionDecision` if you want to delete the HPA during suspension: @@ -270,13 +289,14 @@ hpa.NewBuilder(base). }) ``` -If you choose to delete the HPA during suspension, consider providing a custom suspend status handler via `WithCustomSuspendStatus` to report an accurate reason. +If you choose to delete the HPA during suspension, consider providing a custom suspend status handler via +`WithCustomSuspendStatus` to report an accurate reason. ## Flavors -| Flavor | Effect | -|---|---| -| `PreserveCurrentLabels` | Keeps labels from the live object that the desired state does not declare | +| Flavor | Effect | +| ---------------------------- | ------------------------------------------------------------------------------ | +| `PreserveCurrentLabels` | Keeps labels from the live object that the desired state does not declare | | `PreserveCurrentAnnotations` | Keeps annotations from the live object that the desired state does not declare | ```go @@ -342,14 +362,22 @@ func AutoscalingMutation(version string) hpa.Mutation { } ``` -Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per the internal ordering. Order your source calls for readability — the framework handles execution order. +Note: although `EditObjectMetadata` is called after `EditHPASpec` in the source, metadata edits are applied first per +the internal ordering. Order your source calls for readability — the framework handles execution 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 metric added by mutation A, register A first. -**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name, selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate. +**Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name, +selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate. -**HPA retention on suspend is the default.** The primitive's default `DeleteOnSuspend` decision leaves the HPA in place during component suspension (matching the "Suspension (no-op)" capability). This avoids unnecessary churn and simplifies resumption. Note that a retained HPA may attempt to scale its target if the target still exists and is scaled to zero. If you need the HPA to be removed during suspension — for example, to guarantee that no scaling can interfere — override `WithCustomSuspendDeletionDecision` to return `true`. +**HPA retention on suspend is the default.** The primitive's default `DeleteOnSuspend` decision leaves the HPA in place +during component suspension (matching the "Suspension (no-op)" capability). This avoids unnecessary churn and simplifies +resumption. Note that a retained HPA may attempt to scale its target if the target still exists and is scaled to zero. +If you need the HPA to be removed during suspension — for example, to guarantee that no scaling can interfere — override +`WithCustomSuspendDeletionDecision` to return `true`. diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md index e8460021..178f1419 100644 --- a/examples/hpa-primitive/README.md +++ b/examples/hpa-primitive/README.md @@ -1,20 +1,22 @@ # HPA Primitive Example -This example demonstrates the usage of the `hpa` primitive within the operator component framework. -It shows how to manage a Kubernetes HorizontalPodAutoscaler as a component of a larger application, utilizing features like: +This example demonstrates the usage of the `hpa` primitive within the operator component framework. It shows how to +manage a Kubernetes HorizontalPodAutoscaler as a component of a larger application, utilizing features like: - **Base Construction**: Initializing an HPA with a scale target ref, min/max replicas, and labels. -- **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) using the `Mutator`. +- **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) + using the `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. -- **Suspension (No-op)**: Demonstrating no-op suspend behavior — the HPA is left in place since an idle HPA has no cluster impact. +- **Suspension (No-op)**: Demonstrating no-op suspend behavior — the HPA is left in place since an idle HPA has no + cluster impact. - **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`: CPU metric, memory metric, scale behavior, and flavor functions. + - `mutations.go`: CPU metric, memory metric, scale behavior, and flavor functions. - `resources/`: Contains the central `NewHPAResource` factory that assembles all features using the `hpa.Builder`. - `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. @@ -27,6 +29,7 @@ go run examples/hpa-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 96ae39b690eea19c87063ff7bf529292d3907521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 00:34:22 +0000 Subject: [PATCH 16/28] fix: preserve server-managed metadata in WithCustomFieldApplicator example The doc example for WithCustomFieldApplicator was overwriting server-managed fields (ResourceVersion, UID, Generation) and shared-controller fields (OwnerReferences, Finalizers) by deep-copying desired into current without restoring them. This could produce invalid updates in practice. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 9b36619d..7e00df16 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -53,9 +53,21 @@ Use `WithCustomFieldApplicator` when other controllers manage spec-level fields ```go resource, err := hpa.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - // Preserve the current status while updating spec and metadata from desired + // Preserve the current status and server-managed/shared-controller metadata savedStatus := current.Status + savedMeta := current.ObjectMeta + + // Update spec and other desired fields desired.DeepCopyInto(current) + + // Restore server-managed and shared-controller metadata from the live object + current.ObjectMeta.ResourceVersion = savedMeta.ResourceVersion + current.ObjectMeta.UID = savedMeta.UID + current.ObjectMeta.Generation = savedMeta.Generation + current.ObjectMeta.OwnerReferences = savedMeta.OwnerReferences + current.ObjectMeta.Finalizers = savedMeta.Finalizers + + // Restore status from the live object current.Status = savedStatus return nil }). From 25d6c3cac41747e00ab646cf8f284e05d9dd3748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 01:12:30 +0000 Subject: [PATCH 17/28] fix: address Copilot review feedback for HPA primitive Add compile-time ObjectMutator interface assertion, clarify suspend docs to note retained HPA may still scale if target is present. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/handlers.go | 4 +++- pkg/primitives/hpa/mutator_test.go | 3 +++ pkg/primitives/hpa/resource.go | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 34268380..acf94fa7 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -70,7 +70,9 @@ func DefaultOperationalStatusHandler( // // It always returns false. An idle HPA has no effect on a cluster when its scale target is // absent or suspended, so there is no reason to delete it. Keeping it in place avoids -// unnecessary churn and simplifies resumption. +// unnecessary churn and simplifies resumption. Note that a retained HPA may still attempt +// to scale its target if the target remains present; override with +// Builder.WithCustomSuspendDeletionDecision if this is a concern. // // Override this via Builder.WithCustomSuspendDeletionDecision if your use case requires // the HPA to be removed during suspension. diff --git a/pkg/primitives/hpa/mutator_test.go b/pkg/primitives/hpa/mutator_test.go index 35e553bb..04b46595 100644 --- a/pkg/primitives/hpa/mutator_test.go +++ b/pkg/primitives/hpa/mutator_test.go @@ -12,6 +12,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// compile-time check that Mutator implements editors.ObjectMutator. +var _ editors.ObjectMutator = (*Mutator)(nil) + func newTestHPA() *autoscalingv2.HorizontalPodAutoscaler { return &autoscalingv2.HorizontalPodAutoscaler{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index 5cecdce3..384ab8b3 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -25,7 +25,8 @@ func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutosca // It implements the following component interfaces: // - component.Resource: for basic identity and mutation behaviour. // - component.Operational: for reporting operational status based on HPA conditions. -// - component.Suspendable: for no-op suspend behaviour (idle HPA has no cluster impact). +// - component.Suspendable: for default no-op suspend behaviour that leaves the HPA in place +// (the HPA may still scale if its target remains present). // - component.DataExtractable: for exporting values after successful reconciliation. type Resource struct { base *generic.IntegrationResource[*autoscalingv2.HorizontalPodAutoscaler, *Mutator] From e0ae52120565eb74321e08bbb0277d9b12dd3f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 13:09:38 +0000 Subject: [PATCH 18/28] fix: delete HPA on suspend to prevent scaling interference A retained HPA conflicts with its scale target's suspension because the Kubernetes HPA controller continuously enforces minReplicas and would scale the target (e.g. a Deployment scaled to zero) back up. Change the default from no-op (leave in place) to delete-on-suspend so the HPA cannot interfere. On resume the framework recreates the HPA. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 43 ++++++++++++++--------------- examples/hpa-primitive/README.md | 4 +-- examples/hpa-primitive/main.go | 2 +- pkg/primitives/hpa/builder.go | 10 +++---- pkg/primitives/hpa/handlers.go | 30 ++++++++++---------- pkg/primitives/hpa/handlers_test.go | 4 +-- pkg/primitives/hpa/resource.go | 17 ++++++------ 7 files changed, 54 insertions(+), 56 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 7e00df16..0f72360d 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -6,13 +6,13 @@ Suspendable resource and provides a structured mutation API for configuring auto ## Capabilities -| Capability | Detail | -| ---------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | -| **Suspension (no-op)** | Leaves the HPA in place on suspend — an idle HPA has no cluster impact when its scale target is absent | -| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | -| **Flavors** | Preserves externally-managed labels and annotations | -| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | +| Capability | Detail | +| ----------------------- | ----------------------------------------------------------------------------------------------------------- | +| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | +| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | +| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | +| **Flavors** | Preserves externally-managed labels and annotations | +| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive @@ -284,26 +284,25 @@ hpa.NewBuilder(base). ## Suspension -HPA has no native suspend field. The default behavior is a **no-op**: the HPA is left in place when the component is -suspended (`DefaultDeleteOnSuspendHandler` returns `false`). An idle HPA has no effect on the cluster when its scale -target is absent or suspended, so there is no reason to delete it. Keeping it avoids unnecessary churn and simplifies -resumption. +HPA has no native suspend field. The default behavior is **delete on suspend**: the HPA is removed when the component is +suspended (`DefaultDeleteOnSuspendHandler` returns `true`). A retained HPA would conflict with the suspension of its +scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA controller continuously enforces +`minReplicas` and would scale the target back up. Deleting the HPA prevents this interference. On resume the framework +recreates the HPA with the desired spec. The default suspension status handler reports `Suspended` immediately with the reason -`"HorizontalPodAutoscaler left in place; no-op suspend"`. +`"HorizontalPodAutoscaler deleted to prevent scaling interference during suspension"`. -Override with `WithCustomSuspendDeletionDecision` if you want to delete the HPA during suspension: +Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale +target is managed externally and will not be present during suspension): ```go hpa.NewBuilder(base). WithCustomSuspendDeletionDecision(func(_ *autoscalingv2.HorizontalPodAutoscaler) bool { - return true // delete HPA during suspension + return false // keep HPA during suspension }) ``` -If you choose to delete the HPA during suspension, consider providing a custom suspend status handler via -`WithCustomSuspendStatus` to report an accurate reason. - ## Flavors | Flavor | Effect | @@ -388,8 +387,8 @@ boolean conditions. **Use `EnsureMetric` for idempotent metric management.** The editor matches by full metric identity (type, name, selector, and described object where applicable), so repeated calls with the same identity update rather than duplicate. -**HPA retention on suspend is the default.** The primitive's default `DeleteOnSuspend` decision leaves the HPA in place -during component suspension (matching the "Suspension (no-op)" capability). This avoids unnecessary churn and simplifies -resumption. Note that a retained HPA may attempt to scale its target if the target still exists and is scaled to zero. -If you need the HPA to be removed during suspension — for example, to guarantee that no scaling can interfere — override -`WithCustomSuspendDeletionDecision` to return `true`. +**HPA deletion on suspend is the default.** The primitive's default `DeleteOnSuspend` decision removes the HPA during +component suspension (matching the "Suspension (delete)" capability). This prevents the Kubernetes HPA controller from +scaling the target back up while it is suspended. On resume the framework recreates the HPA with the desired spec. If +you need the HPA to be retained during suspension — for example, when the scale target is managed externally and will +not be present — override `WithCustomSuspendDeletionDecision` to return `false`. diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md index 178f1419..a9ee60e8 100644 --- a/examples/hpa-primitive/README.md +++ b/examples/hpa-primitive/README.md @@ -8,8 +8,8 @@ manage a Kubernetes HorizontalPodAutoscaler as a component of a larger applicati using the `Mutator`. - **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. -- **Suspension (No-op)**: Demonstrating no-op suspend behavior — the HPA is left in place since an idle HPA has no - cluster impact. +- **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed during suspension to prevent it + from scaling the target back up. - **Data Extraction**: Harvesting information from the reconciled resource. ## Directory Structure diff --git a/examples/hpa-primitive/main.go b/examples/hpa-primitive/main.go index 94e58498..fe087c9b 100644 --- a/examples/hpa-primitive/main.go +++ b/examples/hpa-primitive/main.go @@ -84,7 +84,7 @@ func main() { { Version: "1.2.4", EnableMetrics: false, - Suspended: true, // Suspend the app (HPA is not deleted by default) + Suspended: true, // Suspend the app (HPA is deleted to prevent scaling interference) }, } diff --git a/pkg/primitives/hpa/builder.go b/pkg/primitives/hpa/builder.go index 0d4b4f69..f3a8334e 100644 --- a/pkg/primitives/hpa/builder.go +++ b/pkg/primitives/hpa/builder.go @@ -99,7 +99,7 @@ func (b *Builder) WithCustomOperationalStatus( // WithCustomSuspendStatus overrides how the progress of suspension is reported. // // The default behavior uses DefaultSuspensionStatusHandler, which reports -// Suspended immediately because the default suspend is a no-op. +// Suspended immediately because deletion is handled by the framework. func (b *Builder) WithCustomSuspendStatus( handler func(*autoscalingv2.HorizontalPodAutoscaler) (concepts.SuspensionStatusWithReason, error), ) *Builder { @@ -111,7 +111,7 @@ func (b *Builder) WithCustomSuspendStatus( // the component is suspended. // // The default behavior uses DefaultSuspendMutationHandler, which is a no-op -// since an idle HPA has no impact on the cluster. +// since the HPA is deleted on suspend (no spec mutations are needed). func (b *Builder) WithCustomSuspendMutation( handler func(*Mutator) error, ) *Builder { @@ -122,9 +122,9 @@ func (b *Builder) WithCustomSuspendMutation( // WithCustomSuspendDeletionDecision overrides the decision of whether to delete // the HPA when the component is suspended. // -// The default behavior uses DefaultDeleteOnSuspendHandler, which returns false. -// An idle HPA has no effect on the cluster when its scale target is absent or -// suspended, so there is no reason to delete it. +// The default behavior uses DefaultDeleteOnSuspendHandler, which returns true. +// The HPA is deleted to prevent the Kubernetes HPA controller from scaling the +// target back up during suspension. On resume the framework recreates the HPA. func (b *Builder) WithCustomSuspendDeletionDecision( handler func(*autoscalingv2.HorizontalPodAutoscaler) bool, ) *Builder { diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index acf94fa7..88cb7d6f 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -68,27 +68,27 @@ func DefaultOperationalStatusHandler( // DefaultDeleteOnSuspendHandler provides the default decision of whether to delete the HPA // when the parent component is suspended. // -// It always returns false. An idle HPA has no effect on a cluster when its scale target is -// absent or suspended, so there is no reason to delete it. Keeping it in place avoids -// unnecessary churn and simplifies resumption. Note that a retained HPA may still attempt -// to scale its target if the target remains present; override with -// Builder.WithCustomSuspendDeletionDecision if this is a concern. +// It always returns true. A retained HPA would conflict with the suspension of its scale +// target (e.g. a Deployment scaled to zero) because the Kubernetes HPA controller +// continuously enforces minReplicas and would scale the target back up. Deleting the HPA +// prevents this interference and guarantees clean suspension semantics. On resume the +// framework recreates the HPA with the desired spec. // // Override this via Builder.WithCustomSuspendDeletionDecision if your use case requires -// the HPA to be removed during suspension. +// the HPA to be retained during suspension. // // 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(_ *autoscalingv2.HorizontalPodAutoscaler) bool { - return false + return true } // DefaultSuspendMutationHandler provides the default mutation applied to an HPA when // the component is suspended. // -// It is a no-op. The default suspension behavior leaves the HPA in place -// (DefaultDeleteOnSuspendHandler returns false) because an idle HPA has no impact on -// the cluster when its scale target is absent or suspended. No mutations are needed. +// It is a no-op. The default suspension behavior deletes the HPA +// (DefaultDeleteOnSuspendHandler returns true), so no spec mutations are needed before +// deletion. // // 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. @@ -98,10 +98,10 @@ func DefaultSuspendMutationHandler(_ *Mutator) error { // DefaultSuspensionStatusHandler reports the suspension status of the HPA. // -// It always returns Suspended immediately. The default suspension behaviour is a no-op: -// the HPA is left in place because an idle HPA has no effect on the cluster when its -// scale target is absent or suspended. Because no work is required to suspend, the -// status is always Suspended. +// It always returns Suspended immediately. The default suspension behaviour deletes the +// HPA to prevent it from interfering with the scale target's suspension. Because +// deletion is handled by the framework after this status is reported, no additional +// work is required and the status is always Suspended. // // 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. @@ -110,7 +110,7 @@ func DefaultSuspensionStatusHandler( ) (concepts.SuspensionStatusWithReason, error) { return concepts.SuspensionStatusWithReason{ Status: concepts.SuspensionStatusSuspended, - Reason: "HorizontalPodAutoscaler left in place; no-op suspend", + Reason: "HorizontalPodAutoscaler deleted to prevent scaling interference during suspension", }, nil } diff --git a/pkg/primitives/hpa/handlers_test.go b/pkg/primitives/hpa/handlers_test.go index 89fe3634..2e84bc8e 100644 --- a/pkg/primitives/hpa/handlers_test.go +++ b/pkg/primitives/hpa/handlers_test.go @@ -148,7 +148,7 @@ func TestDefaultOperationalStatusHandler_UsesConditionMessage(t *testing.T) { func TestDefaultDeleteOnSuspendHandler(t *testing.T) { hpa := &autoscalingv2.HorizontalPodAutoscaler{} - assert.False(t, DefaultDeleteOnSuspendHandler(hpa)) + assert.True(t, DefaultDeleteOnSuspendHandler(hpa)) } func TestDefaultSuspendMutationHandler(t *testing.T) { @@ -163,5 +163,5 @@ func TestDefaultSuspensionStatusHandler(t *testing.T) { got, err := DefaultSuspensionStatusHandler(hpa) require.NoError(t, err) assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) - assert.Equal(t, "HorizontalPodAutoscaler left in place; no-op suspend", got.Reason) + assert.Equal(t, "HorizontalPodAutoscaler deleted to prevent scaling interference during suspension", got.Reason) } diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index 384ab8b3..afd787f5 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -25,8 +25,8 @@ func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutosca // It implements the following component interfaces: // - component.Resource: for basic identity and mutation behaviour. // - component.Operational: for reporting operational status based on HPA conditions. -// - component.Suspendable: for default no-op suspend behaviour that leaves the HPA in place -// (the HPA may still scale if its target remains present). +// - component.Suspendable: for default delete-on-suspend behaviour that removes the HPA to +// prevent it from scaling the target back up during suspension. // - component.DataExtractable: for exporting values after successful reconciliation. type Resource struct { base *generic.IntegrationResource[*autoscalingv2.HorizontalPodAutoscaler, *Mutator] @@ -70,18 +70,17 @@ func (r *Resource) ConvergingStatus(op concepts.ConvergingOperation) (concepts.O // DeleteOnSuspend determines whether the HPA should be deleted from the cluster // when the parent component is suspended. // -// By default, it uses DefaultDeleteOnSuspendHandler, which returns false. The HPA -// is left in place to avoid unnecessary churn and simplify resumption. Note that -// a retained HPA may attempt to scale its target if the target still exists and is -// scaled to zero; override with WithCustomSuspendDeletionDecision if this is a concern. +// By default, it uses DefaultDeleteOnSuspendHandler, which returns true. The HPA is +// deleted to prevent the Kubernetes HPA controller from scaling the target back up +// while it is suspended. On resume the framework recreates the HPA with the desired spec. func (r *Resource) DeleteOnSuspend() bool { return r.base.DeleteOnSuspend() } // Suspend registers the configured suspension mutation for the next mutate cycle. // -// For HPA, the default suspension mutation is a no-op since an idle HPA has no -// impact on the cluster. +// For HPA, the default suspension mutation is a no-op since the HPA is deleted on +// suspend (no spec mutations are needed before deletion). func (r *Resource) Suspend() error { return r.base.Suspend() } @@ -89,7 +88,7 @@ func (r *Resource) Suspend() error { // SuspensionStatus reports the suspension status of the HPA. // // By default, it uses DefaultSuspensionStatusHandler, which reports Suspended -// immediately because the default suspend is a no-op. +// immediately because deletion is handled by the framework after this status is reported. func (r *Resource) SuspensionStatus() (concepts.SuspensionStatusWithReason, error) { return r.base.SuspensionStatus() } From 4c32cd7139452c1599b7c9c87d01c48063c1b66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 17:56:11 +0000 Subject: [PATCH 19/28] fix: do not construct HPA mutator with initial feature plan Align HPA mutator with deployment/configmap pattern: NewMutator no longer creates an initial plan. BeginFeature must be called before registering mutations. Updates all tests to call BeginFeature and adds constructor/BeginFeature invariant tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/primitives/hpa/mutator.go | 8 ++-- pkg/primitives/hpa/mutator_test.go | 70 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/pkg/primitives/hpa/mutator.go b/pkg/primitives/hpa/mutator.go index 42abe712..4cffd7f6 100644 --- a/pkg/primitives/hpa/mutator.go +++ b/pkg/primitives/hpa/mutator.go @@ -33,13 +33,11 @@ type Mutator struct { } // NewMutator creates a new Mutator for the given HorizontalPodAutoscaler. +// BeginFeature must be called before registering any mutations. func NewMutator(hpa *autoscalingv2.HorizontalPodAutoscaler) *Mutator { - m := &Mutator{ - hpa: hpa, - plans: []featurePlan{{}}, + return &Mutator{ + hpa: hpa, } - m.active = &m.plans[0] - return m } // BeginFeature starts a new feature planning scope. All subsequent mutation diff --git a/pkg/primitives/hpa/mutator_test.go b/pkg/primitives/hpa/mutator_test.go index 04b46595..d11c9260 100644 --- a/pkg/primitives/hpa/mutator_test.go +++ b/pkg/primitives/hpa/mutator_test.go @@ -32,6 +32,7 @@ func newTestHPA() *autoscalingv2.HorizontalPodAutoscaler { func TestMutator_EditObjectMetadata(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("app", "myapp") return nil @@ -43,6 +44,7 @@ func TestMutator_EditObjectMetadata(t *testing.T) { func TestMutator_EditObjectMetadata_Nil(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditObjectMetadata(nil) assert.NoError(t, m.Apply()) } @@ -50,6 +52,7 @@ func TestMutator_EditObjectMetadata_Nil(t *testing.T) { func TestMutator_EditObjectMetadata_Error(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditObjectMetadata(func(_ *editors.ObjectMetaEditor) error { return errors.New("metadata error") }) @@ -61,6 +64,7 @@ func TestMutator_EditObjectMetadata_Error(t *testing.T) { func TestMutator_EditHPASpec(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditHPASpec(func(e *editors.HPASpecEditor) error { e.SetMaxReplicas(20) return nil @@ -72,6 +76,7 @@ func TestMutator_EditHPASpec(t *testing.T) { func TestMutator_EditHPASpec_Nil(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditHPASpec(nil) assert.NoError(t, m.Apply()) } @@ -79,6 +84,7 @@ func TestMutator_EditHPASpec_Nil(t *testing.T) { func TestMutator_EditHPASpec_Error(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditHPASpec(func(_ *editors.HPASpecEditor) error { return errors.New("spec error") }) @@ -88,6 +94,7 @@ func TestMutator_EditHPASpec_Error(t *testing.T) { func TestMutator_EditHPASpec_EnsureMetric(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditHPASpec(func(e *editors.HPASpecEditor) error { e.EnsureMetric(autoscalingv2.MetricSpec{ Type: autoscalingv2.ResourceMetricSourceType, @@ -111,6 +118,7 @@ func TestMutator_EditHPASpec_EnsureMetric(t *testing.T) { func TestMutator_ExecutionOrder(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() var order []string @@ -136,6 +144,7 @@ func TestMutator_MultipleFeatures(t *testing.T) { hpa := newTestHPA() m := NewMutator(hpa) + m.BeginFeature() m.EditObjectMetadata(func(e *editors.ObjectMetaEditor) error { e.EnsureLabel("feature", "one") return nil @@ -157,4 +166,65 @@ func TestMutator_MultipleFeatures(t *testing.T) { assert.Equal(t, int32(42), hpa.Spec.MaxReplicas) } +// --- Constructor and feature plan invariants --- + +func TestNewMutator_InitializesNoPlan(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + + assert.Empty(t, m.plans, "NewMutator must not create any plans") + assert.Nil(t, m.active, "active plan must not be set") +} + +func TestBeginFeature_AddsExactlyOnePlan(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + + m.BeginFeature() + require.Len(t, m.plans, 1, "BeginFeature must add exactly one plan") + assert.Equal(t, &m.plans[0], m.active, "active must point to the new plan") + + m.BeginFeature() + require.Len(t, m.plans, 2) + assert.Equal(t, &m.plans[1], m.active) +} + +func TestBeginFeature_IsolatesFeaturePlans(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + + // Record a mutation in the first feature plan + m.BeginFeature() + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(5) + return nil + }) + + // Start a new feature and record a different mutation + m.BeginFeature() + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(10) + return nil + }) + + // The initial plan should have exactly one spec edit + assert.Len(t, m.plans[0].hpaSpecEdits, 1, "initial plan should have one edit") + // The second plan should also have exactly one spec edit + assert.Len(t, m.plans[1].hpaSpecEdits, 1, "second plan should have one edit") +} + +func TestMutator_SingleFeature_PlanCount(t *testing.T) { + hpa := newTestHPA() + m := NewMutator(hpa) + m.BeginFeature() + m.EditHPASpec(func(e *editors.HPASpecEditor) error { + e.SetMaxReplicas(5) + return nil + }) + + require.NoError(t, m.Apply()) + assert.Len(t, m.plans, 1, "no extra plans should be created during Apply") + assert.Equal(t, int32(5), hpa.Spec.MaxReplicas) +} + func int32Ptr(v int32) *int32 { return &v } From 9af9be32592a2fac17d78aa484289d4d1be0b474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:11:06 +0000 Subject: [PATCH 20/28] fix: address Copilot review feedback for docs and Makefile Narrow the custom field applicator example claim in hpa.md to accurately reflect the specific fields being restored, and add hpa-primitive to the run-examples Makefile target. Co-Authored-By: Claude Opus 4.6 (1M context) --- Makefile | 1 + docs/primitives/hpa.md | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 29424227..17e34a35 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,7 @@ run-examples: ## Run all examples to verify they execute without error. go run ./examples/deployment-primitive/. go run ./examples/configmap-primitive/. go run ./examples/custom-resource-implementation/. + go run ./examples/hpa-primitive/. # go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 0f72360d..42ca315d 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -48,19 +48,22 @@ metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by the API server or other controllers. -Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten: +Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. The +example below restores only the fields needed for API server updates and shared-controller coordination; for full +server-managed field preservation, use `DefaultFieldApplicator` as a starting point: ```go resource, err := hpa.NewBuilder(base). WithCustomFieldApplicator(func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - // Preserve the current status and server-managed/shared-controller metadata + // Preserve specific fields from the live object savedStatus := current.Status savedMeta := current.ObjectMeta // Update spec and other desired fields desired.DeepCopyInto(current) - // Restore server-managed and shared-controller metadata from the live object + // Restore ResourceVersion, UID, Generation (required for updates), + // plus OwnerReferences and Finalizers (shared-controller fields) current.ObjectMeta.ResourceVersion = savedMeta.ResourceVersion current.ObjectMeta.UID = savedMeta.UID current.ObjectMeta.Generation = savedMeta.Generation From f94e37edb36cc6d0579bc581d1587b02e5474701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:31:30 +0000 Subject: [PATCH 21/28] fix: make default suspension reason deletion-agnostic The DefaultSuspensionStatusHandler reason string previously asserted that the HPA was deleted, which is misleading when consumers override deletion behaviour via WithCustomSuspendDeletionDecision. Update the reason to be deletion-agnostic and document that WithCustomSuspendStatus should be used when a custom reason is needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 3 ++- pkg/primitives/hpa/handlers.go | 7 ++++++- pkg/primitives/hpa/handlers_test.go | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 42ca315d..f1eb2df9 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -294,7 +294,8 @@ scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA contr recreates the HPA with the desired spec. The default suspension status handler reports `Suspended` immediately with the reason -`"HorizontalPodAutoscaler deleted to prevent scaling interference during suspension"`. +`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. +Override this handler with `WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour. Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale target is managed externally and will not be present during suspension): diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 88cb7d6f..7d8e8719 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -103,6 +103,11 @@ func DefaultSuspendMutationHandler(_ *Mutator) error { // deletion is handled by the framework after this status is reported, no additional // work is required and the status is always Suspended. // +// The reason string is intentionally deletion-agnostic so that this handler remains +// accurate even when the deletion decision is overridden via +// Builder.WithCustomSuspendDeletionDecision. If you need a reason that reflects your +// custom deletion behaviour, override this handler via Builder.WithCustomSuspendStatus. +// // 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( @@ -110,7 +115,7 @@ func DefaultSuspensionStatusHandler( ) (concepts.SuspensionStatusWithReason, error) { return concepts.SuspensionStatusWithReason{ Status: concepts.SuspensionStatusSuspended, - Reason: "HorizontalPodAutoscaler deleted to prevent scaling interference during suspension", + Reason: "HorizontalPodAutoscaler suspended to prevent scaling interference", }, nil } diff --git a/pkg/primitives/hpa/handlers_test.go b/pkg/primitives/hpa/handlers_test.go index 2e84bc8e..7c4e976e 100644 --- a/pkg/primitives/hpa/handlers_test.go +++ b/pkg/primitives/hpa/handlers_test.go @@ -163,5 +163,5 @@ func TestDefaultSuspensionStatusHandler(t *testing.T) { got, err := DefaultSuspensionStatusHandler(hpa) require.NoError(t, err) assert.Equal(t, concepts.SuspensionStatusSuspended, got.Status) - assert.Equal(t, "HorizontalPodAutoscaler deleted to prevent scaling interference during suspension", got.Reason) + assert.Equal(t, "HorizontalPodAutoscaler suspended to prevent scaling interference", got.Reason) } From 3bdb9e5c5e17ad57ffdd5706e21333c1e9851147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:35:35 +0000 Subject: [PATCH 22/28] fix: use semantic label selector comparison in metric identity matching Replace reflect.DeepEqual with order-insensitive comparison for LabelSelectors in metricIdentifiersMatch. This prevents EnsureMetric from appending duplicates when MatchExpressions or Values appear in a different order but are semantically equivalent. Also clarify RemoveMetric docs: it removes all metrics matching type+name regardless of selector, and directs users to Raw() for fine-grained removal. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/mutation/editors/hpaspec.go | 49 +++++++++++++++++++++++++--- pkg/mutation/editors/hpaspec_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/pkg/mutation/editors/hpaspec.go b/pkg/mutation/editors/hpaspec.go index 02853cbe..8ad039d5 100644 --- a/pkg/mutation/editors/hpaspec.go +++ b/pkg/mutation/editors/hpaspec.go @@ -1,9 +1,12 @@ package editors import ( - "reflect" + "maps" + "slices" + "strings" autoscalingv2 "k8s.io/api/autoscaling/v2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // HPASpecEditor provides a typed API for mutating a Kubernetes HorizontalPodAutoscalerSpec. @@ -60,13 +63,17 @@ func (e *HPASpecEditor) EnsureMetric(metric autoscalingv2.MetricSpec) { e.spec.Metrics = append(e.spec.Metrics, metric) } -// RemoveMetric removes a metric matching the given type and name. +// RemoveMetric removes all metrics matching the given type and name. // // For Resource metrics, name corresponds to the resource name (e.g. "cpu"). // For Pods, Object, and External metrics, name corresponds to the metric name. // For ContainerResource metrics, name corresponds to the resource name; all // container variants of that resource are removed. // +// This method removes every metric entry that matches the type and name, +// regardless of selector or described object. For fine-grained removal of a +// single metric identity, use [HPASpecEditor.Raw] and modify the Metrics slice directly. +// // If no matching metric is found, this is a no-op. func (e *HPASpecEditor) RemoveMetric(metricType autoscalingv2.MetricSourceType, name string) { filtered := e.spec.Metrics[:0] @@ -124,9 +131,43 @@ func metricsMatch(a, b autoscalingv2.MetricSpec) bool { } // metricIdentifiersMatch reports whether two MetricIdentifier values match. -// It compares both Name and Selector. +// It compares both Name and Selector. Selector comparison is semantic: label +// selectors that differ only in the ordering of MatchExpressions or expression +// Values are treated as equal. func metricIdentifiersMatch(a, b autoscalingv2.MetricIdentifier) bool { - return a.Name == b.Name && reflect.DeepEqual(a.Selector, b.Selector) + return a.Name == b.Name && labelSelectorsEqual(a.Selector, b.Selector) +} + +// labelSelectorsEqual performs a semantic comparison of two LabelSelectors. +// Unlike reflect.DeepEqual it is insensitive to the ordering of +// MatchExpressions and the Values within each expression. +func labelSelectorsEqual(a, b *metav1.LabelSelector) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + if !maps.Equal(a.MatchLabels, b.MatchLabels) { + return false + } + if len(a.MatchExpressions) != len(b.MatchExpressions) { + return false + } + + // Build a canonical key for each expression and compare as sorted sets. + canon := func(exprs []metav1.LabelSelectorRequirement) []string { + keys := make([]string, len(exprs)) + for i, e := range exprs { + vals := slices.Clone(e.Values) + slices.Sort(vals) + keys[i] = e.Key + "\x00" + string(e.Operator) + "\x00" + strings.Join(vals, "\x00") + } + slices.Sort(keys) + return keys + } + + return slices.Equal(canon(a.MatchExpressions), canon(b.MatchExpressions)) } // metricName extracts the identifying name from a MetricSpec. diff --git a/pkg/mutation/editors/hpaspec_test.go b/pkg/mutation/editors/hpaspec_test.go index e4fcb8fb..26704f85 100644 --- a/pkg/mutation/editors/hpaspec_test.go +++ b/pkg/mutation/editors/hpaspec_test.go @@ -277,6 +277,52 @@ func TestHPASpecEditor_EnsureMetric_ContainerResource(t *testing.T) { assert.Len(t, spec.Metrics, 2) } +func TestHPASpecEditor_EnsureMetric_SelectorOrderInsensitive(t *testing.T) { + spec := &autoscalingv2.HorizontalPodAutoscalerSpec{} + e := NewHPASpecEditor(spec) + + // Add external metric with two match expressions in order A, B. + m1 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ExternalMetricSourceType, + External: &autoscalingv2.ExternalMetricSource{ + Metric: autoscalingv2.MetricIdentifier{ + Name: "queue_depth", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "worker"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "env", Operator: metav1.LabelSelectorOpIn, Values: []string{"staging", "prod"}}, + {Key: "region", Operator: metav1.LabelSelectorOpIn, Values: []string{"us-east-1"}}, + }, + }, + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("50")}, + }, + } + e.EnsureMetric(m1) + require.Len(t, spec.Metrics, 1) + + // Upsert with same selector but expressions in order B, A and values reversed. + m2 := autoscalingv2.MetricSpec{ + Type: autoscalingv2.ExternalMetricSourceType, + External: &autoscalingv2.ExternalMetricSource{ + Metric: autoscalingv2.MetricIdentifier{ + Name: "queue_depth", + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "worker"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "region", Operator: metav1.LabelSelectorOpIn, Values: []string{"us-east-1"}}, + {Key: "env", Operator: metav1.LabelSelectorOpIn, Values: []string{"prod", "staging"}}, + }, + }, + }, + Target: autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: resourcePtr("100")}, + }, + } + e.EnsureMetric(m2) + assert.Len(t, spec.Metrics, 1, "semantically equal selectors in different order should upsert, not append") + assert.Equal(t, resourcePtr("100").String(), spec.Metrics[0].External.Target.Value.String()) +} + func TestHPASpecEditor_RemoveMetric(t *testing.T) { spec := &autoscalingv2.HorizontalPodAutoscalerSpec{ Metrics: []autoscalingv2.MetricSpec{ From 433c8cb310cc0edeca7588cfe42a4501fec22b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= Date: Tue, 24 Mar 2026 18:36:09 +0000 Subject: [PATCH 23/28] docs: reflow line wrapping in HPA suspension section Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index f1eb2df9..4b7924d8 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -294,8 +294,8 @@ scale target (e.g. a Deployment scaled to zero) because the Kubernetes HPA contr recreates the HPA with the desired spec. The default suspension status handler reports `Suspended` immediately with the reason -`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. -Override this handler with `WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour. +`"HorizontalPodAutoscaler suspended to prevent scaling interference"`. Override this handler with +`WithCustomSuspendStatus` if you need a reason that reflects custom deletion behaviour. Override with `WithCustomSuspendDeletionDecision` if you want to retain the HPA during suspension (e.g. when the scale target is managed externally and will not be present during suspension): From abf2c7471b33e9c1505f82970749a6bb2c8906da 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:07:11 +0000 Subject: [PATCH 24/28] refactor: remove field applicators and flavors from HPA primitive Align with the framework's switch to Server-Side Apply. Remove DefaultFieldApplicator, WithCustomFieldApplicator, WithFieldApplicationFlavor, the FieldApplicationFlavor type alias, flavors.go/flavors_test.go, and all associated tests and documentation. Update the NewBuilder constructor call to match the new generic builder signature (no defaultApplicator parameter). Update resource_test.go to use Object() output for Mutate instead of an empty struct. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 51 ------------------ pkg/primitives/hpa/builder.go | 27 +--------- pkg/primitives/hpa/builder_test.go | 38 ------------- pkg/primitives/hpa/flavors.go | 25 --------- pkg/primitives/hpa/flavors_test.go | 78 --------------------------- pkg/primitives/hpa/resource.go | 18 +------ pkg/primitives/hpa/resource_test.go | 83 ++++------------------------- 7 files changed, 11 insertions(+), 309 deletions(-) delete mode 100644 pkg/primitives/hpa/flavors.go delete mode 100644 pkg/primitives/hpa/flavors_test.go diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 4b7924d8..b4c7b3d2 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -11,7 +11,6 @@ Suspendable resource and provides a structured mutation API for configuring auto | **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | | **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | | **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | -| **Flavors** | Preserves externally-managed labels and annotations | | **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive @@ -36,47 +35,10 @@ base := &autoscalingv2.HorizontalPodAutoscaler{ } resource, err := hpa.NewBuilder(base). - WithFieldApplicationFlavor(hpa.PreserveCurrentLabels). WithMutation(CPUMetricMutation(owner.Spec.Version)). Build() ``` -## Default Field Application - -`DefaultFieldApplicator` replaces the current HPA with a deep copy of the desired object, then restores server-managed -metadata (ResourceVersion, UID, etc.), shared-controller fields (OwnerReferences, Finalizers), and the Status -subresource from the original live object. This prevents spec-level reconciliation from clearing status data written by -the API server or other controllers. - -Use `WithCustomFieldApplicator` when other controllers manage spec-level fields that should not be overwritten. The -example below restores only the fields needed for API server updates and shared-controller coordination; for full -server-managed field preservation, use `DefaultFieldApplicator` as a starting point: - -```go -resource, err := hpa.NewBuilder(base). - WithCustomFieldApplicator(func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - // Preserve specific fields from the live object - savedStatus := current.Status - savedMeta := current.ObjectMeta - - // Update spec and other desired fields - desired.DeepCopyInto(current) - - // Restore ResourceVersion, UID, Generation (required for updates), - // plus OwnerReferences and Finalizers (shared-controller fields) - current.ObjectMeta.ResourceVersion = savedMeta.ResourceVersion - current.ObjectMeta.UID = savedMeta.UID - current.ObjectMeta.Generation = savedMeta.Generation - current.ObjectMeta.OwnerReferences = savedMeta.OwnerReferences - current.ObjectMeta.Finalizers = savedMeta.Finalizers - - // Restore status from the live object - current.Status = savedStatus - return nil - }). - Build() -``` - ## Mutations Mutations are the primary mechanism for modifying an HPA beyond its baseline. Each mutation is a named function that @@ -307,19 +269,6 @@ hpa.NewBuilder(base). }) ``` -## Flavors - -| Flavor | Effect | -| ---------------------------- | ------------------------------------------------------------------------------ | -| `PreserveCurrentLabels` | Keeps labels from the live object that the desired state does not declare | -| `PreserveCurrentAnnotations` | Keeps annotations from the live object that the desired state does not declare | - -```go -hpa.NewBuilder(base). - WithFieldApplicationFlavor(hpa.PreserveCurrentLabels). - WithFieldApplicationFlavor(hpa.PreserveCurrentAnnotations) -``` - ## Full Example: CPU and Memory Autoscaling ```go diff --git a/pkg/primitives/hpa/builder.go b/pkg/primitives/hpa/builder.go index f3a8334e..9004d1fa 100644 --- a/pkg/primitives/hpa/builder.go +++ b/pkg/primitives/hpa/builder.go @@ -9,6 +9,7 @@ import ( autoscalingv2 "k8s.io/api/autoscaling/v2" ) + // Builder is a configuration helper for creating and customizing an HPA Resource. // // It provides a fluent API for registering mutations, status handlers, and @@ -34,7 +35,6 @@ func NewBuilder(hpa *autoscalingv2.HorizontalPodAutoscaler) *Builder { base := generic.NewIntegrationBuilder[*autoscalingv2.HorizontalPodAutoscaler, *Mutator]( hpa, identityFunc, - DefaultFieldApplicator, NewMutator, ) @@ -59,31 +59,6 @@ func (b *Builder) WithMutation(m Mutation) *Builder { return b } -// WithCustomFieldApplicator sets a custom strategy for applying the desired -// state to the existing HPA in the cluster. -// -// The default applicator (DefaultFieldApplicator) replaces the current object -// with a deep copy of the desired object. Use a custom applicator when other -// controllers manage fields you need to preserve. -func (b *Builder) WithCustomFieldApplicator( - applicator func(current, desired *autoscalingv2.HorizontalPodAutoscaler) error, -) *Builder { - b.base.WithCustomFieldApplicator(applicator) - return b -} - -// WithFieldApplicationFlavor registers a post-baseline field application flavor. -// -// Flavors run after the baseline applicator (default or custom) in registration -// order. They are typically used to preserve fields from the live cluster object -// that should not be overwritten by the desired state. -// -// A nil flavor is ignored. -func (b *Builder) WithFieldApplicationFlavor(flavor FieldApplicationFlavor) *Builder { - b.base.WithFieldApplicationFlavor(generic.FieldApplicationFlavor[*autoscalingv2.HorizontalPodAutoscaler](flavor)) - return b -} - // WithCustomOperationalStatus overrides the default logic for determining the // HPA's operational status. // diff --git a/pkg/primitives/hpa/builder_test.go b/pkg/primitives/hpa/builder_test.go index c2961715..3f20e4f4 100644 --- a/pkg/primitives/hpa/builder_test.go +++ b/pkg/primitives/hpa/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() - hpa := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-hpa", - Namespace: "test-ns", - }, - } - applied := false - applicator := func(_, _ *autoscalingv2.HorizontalPodAutoscaler) error { - applied = true - return nil - } - res, err := NewBuilder(hpa). - 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() - hpa := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-hpa", - Namespace: "test-ns", - }, - } - res, err := NewBuilder(hpa). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(nil). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 1) - }) - t.Run("WithCustomOperationalStatus", func(t *testing.T) { t.Parallel() hpa := &autoscalingv2.HorizontalPodAutoscaler{ diff --git a/pkg/primitives/hpa/flavors.go b/pkg/primitives/hpa/flavors.go deleted file mode 100644 index 54d21647..00000000 --- a/pkg/primitives/hpa/flavors.go +++ /dev/null @@ -1,25 +0,0 @@ -package hpa - -import ( - "github.com/sourcehawk/operator-component-framework/pkg/flavors" - autoscalingv2 "k8s.io/api/autoscaling/v2" -) - -// FieldApplicationFlavor defines a function signature for applying "flavors" to an HPA resource. -// A flavor typically preserves certain fields from the current (live) object after the -// baseline field application has occurred. -type FieldApplicationFlavor flavors.FieldApplicationFlavor[*autoscalingv2.HorizontalPodAutoscaler] - -// PreserveCurrentLabels ensures that any labels present on the current live -// HPA but missing from the applied (desired) object are preserved. -// If a label exists in both, the applied value wins. -func PreserveCurrentLabels(applied, current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - return flavors.PreserveCurrentLabels[*autoscalingv2.HorizontalPodAutoscaler]()(applied, current, desired) -} - -// PreserveCurrentAnnotations ensures that any annotations present on the current -// live HPA but missing from the applied (desired) object are preserved. -// If an annotation exists in both, the applied value wins. -func PreserveCurrentAnnotations(applied, current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - return flavors.PreserveCurrentAnnotations[*autoscalingv2.HorizontalPodAutoscaler]()(applied, current, desired) -} diff --git a/pkg/primitives/hpa/flavors_test.go b/pkg/primitives/hpa/flavors_test.go deleted file mode 100644 index aa4a6782..00000000 --- a/pkg/primitives/hpa/flavors_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package hpa - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - autoscalingv2 "k8s.io/api/autoscaling/v2" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestPreserveCurrentLabels(t *testing.T) { - t.Parallel() - - applied := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": "desired-value", - }, - }, - } - current := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": "current-value", - "external": "preserved", - }, - }, - } - - err := PreserveCurrentLabels(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "desired-value", applied.Labels["app"]) - assert.Equal(t, "preserved", applied.Labels["external"]) -} - -func TestPreserveCurrentAnnotations(t *testing.T) { - t.Parallel() - - applied := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - "config": "desired", - }, - }, - } - current := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - "config": "current", - "external": "preserved", - }, - }, - } - - err := PreserveCurrentAnnotations(applied, current, nil) - require.NoError(t, err) - assert.Equal(t, "desired", applied.Annotations["config"]) - assert.Equal(t, "preserved", applied.Annotations["external"]) -} - -func TestFlavors_RunInRegistrationOrder(t *testing.T) { - t.Parallel() - - base := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-hpa", - Namespace: "default", - }, - } - - res, err := NewBuilder(base). - WithFieldApplicationFlavor(PreserveCurrentLabels). - WithFieldApplicationFlavor(PreserveCurrentAnnotations). - Build() - require.NoError(t, err) - assert.Len(t, res.base.FieldFlavors, 2) -} diff --git a/pkg/primitives/hpa/resource.go b/pkg/primitives/hpa/resource.go index afd787f5..cfb9fc01 100644 --- a/pkg/primitives/hpa/resource.go +++ b/pkg/primitives/hpa/resource.go @@ -7,18 +7,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// DefaultFieldApplicator replaces current with a deep copy of desired while -// preserving server-managed metadata (ResourceVersion, UID, Generation, etc.), -// shared-controller fields (OwnerReferences, Finalizers), and the Status -// subresource from the original current object. -func DefaultFieldApplicator(current, desired *autoscalingv2.HorizontalPodAutoscaler) error { - original := current.DeepCopy() - *current = *desired.DeepCopy() - generic.PreserveServerManagedFields(current, original) - generic.PreserveStatus(current, original) - return nil -} - // Resource is a high-level abstraction for managing a Kubernetes HorizontalPodAutoscaler // within a controller's reconciliation loop. // @@ -48,11 +36,7 @@ func (r *Resource) Object() (client.Object, error) { // Mutate transforms the current state of a Kubernetes HorizontalPodAutoscaler into the desired state. // -// The mutation process follows this order: -// 1. Field application: the current object is updated to reflect the desired base state, -// using either DefaultFieldApplicator or a custom applicator if one is configured. -// 2. Field application flavors: any registered flavors are applied in registration order. -// 3. Feature mutations: all registered feature-gated mutations are applied in order. +// All registered feature-gated mutations are applied in order. // // This method is invoked by the framework during the Update phase of reconciliation. func (r *Resource) Mutate(current client.Object) error { diff --git a/pkg/primitives/hpa/resource_test.go b/pkg/primitives/hpa/resource_test.go index bf026a36..9b95d845 100644 --- a/pkg/primitives/hpa/resource_test.go +++ b/pkg/primitives/hpa/resource_test.go @@ -6,29 +6,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" autoscalingv2 "k8s.io/api/autoscaling/v2" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { - current := &autoscalingv2.HorizontalPodAutoscaler{ +func TestResource_ObjectAndMutate(t *testing.T) { + base := &autoscalingv2.HorizontalPodAutoscaler{ ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - ResourceVersion: "12345", - UID: "abc-def", - Generation: 3, - OwnerReferences: []metav1.OwnerReference{ - {APIVersion: "v1", Kind: "Pod", Name: "other-owner", UID: "other-uid"}, - }, - Finalizers: []string{"finalizer.example.com"}, - }, - } - desired := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", + Name: "test-hpa", Namespace: "default", - Labels: map[string]string{"app": "test"}, }, Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ MinReplicas: ptrInt32(2), @@ -36,66 +21,16 @@ func TestDefaultFieldApplicator_PreservesServerManagedFields(t *testing.T) { }, } - err := DefaultFieldApplicator(current, desired) + res, err := NewBuilder(base).Build() require.NoError(t, err) - // Desired spec and labels are applied - assert.Equal(t, int32(2), *current.Spec.MinReplicas) - assert.Equal(t, int32(10), current.Spec.MaxReplicas) - assert.Equal(t, "test", current.Labels["app"]) - - // Server-managed fields are preserved - assert.Equal(t, "12345", current.ResourceVersion) - assert.Equal(t, "abc-def", string(current.UID)) - assert.Equal(t, int64(3), current.Generation) - - // Shared-controller fields are preserved - assert.Len(t, current.OwnerReferences, 1) - assert.Equal(t, "other-owner", current.OwnerReferences[0].Name) - assert.Equal(t, []string{"finalizer.example.com"}, current.Finalizers) -} - -func TestDefaultFieldApplicator_PreservesStatus(t *testing.T) { - current := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Status: autoscalingv2.HorizontalPodAutoscalerStatus{ - CurrentReplicas: 3, - DesiredReplicas: 5, - Conditions: []autoscalingv2.HorizontalPodAutoscalerCondition{ - { - Type: autoscalingv2.ScalingActive, - Status: corev1.ConditionTrue, - }, - }, - }, - } - desired := &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "default", - }, - Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ - MinReplicas: ptrInt32(2), - MaxReplicas: 10, - }, - } - - err := DefaultFieldApplicator(current, desired) + obj, err := res.Object() require.NoError(t, err) + require.NoError(t, res.Mutate(obj)) - // Desired spec is applied - assert.Equal(t, int32(2), *current.Spec.MinReplicas) - assert.Equal(t, int32(10), current.Spec.MaxReplicas) - - // Status from the live object is preserved - assert.Equal(t, int32(3), current.Status.CurrentReplicas) - assert.Equal(t, int32(5), current.Status.DesiredReplicas) - require.Len(t, current.Status.Conditions, 1) - assert.Equal(t, autoscalingv2.ScalingActive, current.Status.Conditions[0].Type) - assert.Equal(t, corev1.ConditionTrue, current.Status.Conditions[0].Status) + got := obj.(*autoscalingv2.HorizontalPodAutoscaler) + assert.Equal(t, int32(2), *got.Spec.MinReplicas) + assert.Equal(t, int32(10), got.Spec.MaxReplicas) } func ptrInt32(v int32) *int32 { From 17fcdda3ef8d84e1030eef436823ba8808353ec4 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:53:07 +0000 Subject: [PATCH 25/28] fix: remove references to deleted field applicators and flavors in examples The refactor in abf2c74 removed FieldApplicationFlavor and related types from the HPA primitive but the example code still referenced them, causing the lint CI to fail with typecheck errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/hpa-primitive/features/mutations.go | 10 ---------- examples/hpa-primitive/resources/hpa.go | 8 ++------ pkg/primitives/hpa/builder.go | 1 - 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/examples/hpa-primitive/features/mutations.go b/examples/hpa-primitive/features/mutations.go index 2514a22a..35c612fd 100644 --- a/examples/hpa-primitive/features/mutations.go +++ b/examples/hpa-primitive/features/mutations.go @@ -81,13 +81,3 @@ func ScaleBehaviorFeature() hpa.Mutation { }, } } - -// PreserveLabelsFlavor demonstrates using a flavor to keep external labels. -func PreserveLabelsFlavor() hpa.FieldApplicationFlavor { - return hpa.PreserveCurrentLabels -} - -// PreserveAnnotationsFlavor demonstrates using a flavor to keep external annotations. -func PreserveAnnotationsFlavor() hpa.FieldApplicationFlavor { - return hpa.PreserveCurrentAnnotations -} diff --git a/examples/hpa-primitive/resources/hpa.go b/examples/hpa-primitive/resources/hpa.go index 652f14cd..101dd948 100644 --- a/examples/hpa-primitive/resources/hpa.go +++ b/examples/hpa-primitive/resources/hpa.go @@ -44,11 +44,7 @@ func NewHPAResource(owner *app.ExampleApp) (component.Resource, error) { builder.WithMutation(features.MemoryMetricFeature(owner.Spec.EnableMetrics, 80)) builder.WithMutation(features.ScaleBehaviorFeature()) - // 4. Configure flavors. - builder.WithFieldApplicationFlavor(features.PreserveLabelsFlavor()) - builder.WithFieldApplicationFlavor(features.PreserveAnnotationsFlavor()) - - // 5. Data extraction. + // 4. Configure data extraction. builder.WithDataExtractor(func(h autoscalingv2.HorizontalPodAutoscaler) error { fmt.Printf("HPA %s: min=%d, max=%d, metrics=%d\n", h.Name, @@ -66,7 +62,7 @@ func NewHPAResource(owner *app.ExampleApp) (component.Resource, error) { return nil }) - // 6. Build the final resource. + // 5. Build the final resource. return builder.Build() } diff --git a/pkg/primitives/hpa/builder.go b/pkg/primitives/hpa/builder.go index 9004d1fa..53e6de06 100644 --- a/pkg/primitives/hpa/builder.go +++ b/pkg/primitives/hpa/builder.go @@ -9,7 +9,6 @@ import ( autoscalingv2 "k8s.io/api/autoscaling/v2" ) - // Builder is a configuration helper for creating and customizing an HPA Resource. // // It provides a fluent API for registering mutations, status handlers, and From 7fa5c3b063293ef64813f16e5d3a9e06f6720b1e 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:02:48 +0000 Subject: [PATCH 26/28] docs: remove remaining flavor references from HPA example README The previous refactor removed field applicators and flavors from the HPA primitive but left stale references in the example README. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/hpa-primitive/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/hpa-primitive/README.md b/examples/hpa-primitive/README.md index a9ee60e8..2f22716f 100644 --- a/examples/hpa-primitive/README.md +++ b/examples/hpa-primitive/README.md @@ -6,7 +6,6 @@ manage a Kubernetes HorizontalPodAutoscaler as a component of a larger applicati - **Base Construction**: Initializing an HPA with a scale target ref, min/max replicas, and labels. - **Feature Mutations**: Applying version-gated or conditional changes (CPU metrics, memory metrics, scaling behavior) using the `Mutator`. -- **Field Flavors**: Preserving labels and annotations that might be managed by external tools. - **Operational Status**: Reporting HPA health based on `ScalingActive` and `AbleToScale` conditions. - **Suspension (Delete)**: Demonstrating delete-on-suspend behavior — the HPA is removed during suspension to prevent it from scaling the target back up. @@ -16,7 +15,7 @@ manage a Kubernetes HorizontalPodAutoscaler as a component of a larger applicati - `app/`: Defines the mock `ExampleApp` CRD and the controller that uses the component framework. - `features/`: Contains modular feature definitions: - - `mutations.go`: CPU metric, memory metric, scale behavior, and flavor functions. + - `mutations.go`: CPU metric, memory metric, and scale behavior feature mutations. - `resources/`: Contains the central `NewHPAResource` factory that assembles all features using the `hpa.Builder`. - `main.go`: A standalone entry point that demonstrates a reconciliation loop using a fake client. From e5e79e038f4e61bd6316013d1c438d9b71657054 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:12:55 +0000 Subject: [PATCH 27/28] fix: address PR review comments for HPA primitive Add explicit default branch to operational status switch for clarity when ScalingActive has an unrecognized status value. Update data extraction capability description to accurately reflect the generic WithDataExtractor hook API. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/primitives/hpa.md | 2 +- pkg/primitives/hpa/handlers.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index b4c7b3d2..2bc64a13 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -11,7 +11,7 @@ Suspendable resource and provides a structured mutation API for configuring auto | **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | | **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | | **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | -| **Data extraction** | Optionally exposes current and desired replica counts via a registered data extractor (`WithDataExtractor`) | +| **Data extraction** | Allows custom extraction from the reconciled HPA object via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive diff --git a/pkg/primitives/hpa/handlers.go b/pkg/primitives/hpa/handlers.go index 7d8e8719..dc3ddac0 100644 --- a/pkg/primitives/hpa/handlers.go +++ b/pkg/primitives/hpa/handlers.go @@ -48,6 +48,11 @@ func DefaultOperationalStatusHandler( Status: concepts.OperationalStatusPending, Reason: conditionReason(scalingActive, "ScalingActive is Unknown"), }, nil + default: + return concepts.OperationalStatusWithReason{ + Status: concepts.OperationalStatusPending, + Reason: conditionReason(scalingActive, "ScalingActive has unrecognized status"), + }, nil } } From 9e42ab48b57b4d4c589377ac064767d8f69961d6 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:54:03 +0000 Subject: [PATCH 28/28] fix docs --- docs/primitives/hpa.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/primitives/hpa.md b/docs/primitives/hpa.md index 2bc64a13..f624aacb 100644 --- a/docs/primitives/hpa.md +++ b/docs/primitives/hpa.md @@ -6,11 +6,11 @@ Suspendable resource and provides a structured mutation API for configuring auto ## Capabilities -| Capability | Detail | -| ----------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | -| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | -| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | +| Capability | Detail | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Operational status** | Inspects `ScalingActive` and `AbleToScale` conditions to report `Operational`, `Pending`, or `Failing` | +| **Suspension (delete)** | Deletes the HPA on suspend to prevent it from scaling the target back up; recreated on resume | +| **Mutation pipeline** | Typed editors for HPA spec (metrics, scale target, behavior) and object metadata | | **Data extraction** | Allows custom extraction from the reconciled HPA object via a registered data extractor (`WithDataExtractor`) | ## Building an HPA Primitive